1#![allow(clippy::print_stdout)]
24#![allow(clippy::print_stderr)]
25#![allow(clippy::needless_pass_by_value)]
27
28use crate::Result;
29use crate::models::{EventMeta, Memory, MemoryEvent, MemoryId, MemoryStatus};
30use crate::observability::current_request_id;
31use crate::security::record_event;
32use crate::services::ServiceContainer;
33use crate::storage::index::SqliteBackend;
34use crate::storage::traits::IndexBackend;
35use chrono::TimeZone;
36use std::io::{self, Write};
37
38#[derive(Debug, Default)]
40pub struct DeleteResult {
41 pub deleted: usize,
43 pub not_found: usize,
45 pub deleted_ids: Vec<String>,
47 pub not_found_ids: Vec<String>,
49}
50
51pub fn execute(ids: Vec<String>, hard: bool, force: bool, dry_run: bool) -> Result<()> {
64 if ids.is_empty() {
65 println!("No memory IDs provided. Usage: subcog delete <ID>...");
66 return Ok(());
67 }
68
69 let container = ServiceContainer::from_current_dir_or_user()?;
71 let index = container.index()?;
72
73 let mut valid_ids: Vec<(MemoryId, String, Memory)> = Vec::new();
75 let mut not_found_ids = Vec::new();
76
77 for id_str in &ids {
78 let id = MemoryId::new(id_str);
79 match index.get_memory(&id)? {
80 Some(memory) => {
81 valid_ids.push((id, memory.namespace.as_str().to_string(), memory));
82 },
83 None => {
84 not_found_ids.push(id_str.clone());
85 },
86 }
87 }
88
89 if !not_found_ids.is_empty() {
91 println!("Not found ({}):", not_found_ids.len());
92 for id in ¬_found_ids {
93 println!(" - {id}");
94 }
95 println!();
96 }
97
98 if valid_ids.is_empty() {
99 println!("No valid memories to delete.");
100 return Ok(());
101 }
102
103 if dry_run {
105 let action = if hard { "hard delete" } else { "tombstone" };
106 println!(
107 "Dry-run mode: would {action} {} memories:\n",
108 valid_ids.len()
109 );
110 for (id, namespace, _) in &valid_ids {
111 println!(" - {} ({})", id.as_str(), namespace);
112 }
113 return Ok(());
114 }
115
116 if !force {
118 let action = if hard {
119 "PERMANENTLY DELETE"
120 } else {
121 "tombstone (soft delete)"
122 };
123 println!("About to {action} {} memories:\n", valid_ids.len());
124 for (id, namespace, _) in &valid_ids {
125 println!(" - {} ({})", id.as_str(), namespace);
126 }
127 println!();
128
129 if hard {
130 println!("WARNING: Hard delete is IRREVERSIBLE!");
131 } else {
132 println!("Note: Tombstoned memories can be restored or purged later with `subcog gc`.");
133 }
134
135 print!("\nProceed? [y/N] ");
136 io::stdout()
137 .flush()
138 .map_err(|e| crate::Error::OperationFailed {
139 operation: "flush_stdout".to_string(),
140 cause: e.to_string(),
141 })?;
142
143 let mut input = String::new();
144 io::stdin()
145 .read_line(&mut input)
146 .map_err(|e| crate::Error::OperationFailed {
147 operation: "read_stdin".to_string(),
148 cause: e.to_string(),
149 })?;
150
151 if !input.trim().eq_ignore_ascii_case("y") {
152 println!("Cancelled.");
153 return Ok(());
154 }
155 }
156
157 let result = if hard {
159 hard_delete(&index, &valid_ids)
160 } else {
161 soft_delete(&index, &valid_ids)
162 };
163
164 let action = if hard { "Deleted" } else { "Tombstoned" };
166 println!("\n{action} {} memories.", result.deleted);
167
168 if !result.deleted_ids.is_empty() && result.deleted <= 10 {
169 for id in &result.deleted_ids {
170 println!(" ✓ {id}");
171 }
172 }
173
174 if !hard {
175 println!("\nTo permanently delete, run: subcog gc --purge");
176 println!("To restore, tombstoned memories remain searchable with --include-tombstoned");
177 }
178
179 Ok(())
180}
181
182fn soft_delete(index: &SqliteBackend, ids: &[(MemoryId, String, Memory)]) -> DeleteResult {
184 let mut result = DeleteResult::default();
185 let now = crate::current_timestamp();
186 let now_i64 = i64::try_from(now).unwrap_or(i64::MAX);
187 let now_dt = chrono::Utc
188 .timestamp_opt(now_i64, 0)
189 .single()
190 .unwrap_or_else(chrono::Utc::now);
191
192 for (id, _namespace, memory) in ids {
193 let mut updated_memory = memory.clone();
195 updated_memory.status = MemoryStatus::Tombstoned;
196 updated_memory.tombstoned_at = Some(now_dt);
197 updated_memory.updated_at = now;
198
199 match index.index(&updated_memory) {
201 Ok(()) => {
202 result.deleted += 1;
203 result.deleted_ids.push(id.as_str().to_string());
204
205 record_event(MemoryEvent::Updated {
206 meta: EventMeta::with_timestamp("cli.delete", current_request_id(), now),
207 memory_id: id.clone(),
208 modified_fields: vec!["status".to_string(), "tombstoned_at".to_string()],
209 });
210
211 tracing::info!(
212 memory_id = %id.as_str(),
213 tombstoned_at = now,
214 "Tombstoned memory via CLI"
215 );
216 },
217 Err(e) => {
218 eprintln!("Failed to tombstone {}: {e}", id.as_str());
219 result.not_found += 1;
220 result.not_found_ids.push(id.as_str().to_string());
221 },
222 }
223 }
224
225 metrics::counter!("cli_delete_tombstoned_total").increment(result.deleted as u64);
226 result
227}
228
229fn hard_delete(index: &SqliteBackend, ids: &[(MemoryId, String, Memory)]) -> DeleteResult {
231 let mut result = DeleteResult::default();
232
233 for (id, _namespace, _memory) in ids {
234 match index.remove(id) {
235 Ok(true) => {
236 result.deleted += 1;
237 result.deleted_ids.push(id.as_str().to_string());
238
239 record_event(MemoryEvent::Deleted {
240 meta: EventMeta::new("cli.delete", current_request_id()),
241 memory_id: id.clone(),
242 reason: "cli.delete --hard".to_string(),
243 });
244
245 tracing::info!(
246 memory_id = %id.as_str(),
247 "Hard deleted memory via CLI"
248 );
249 },
250 Ok(false) => {
251 result.not_found += 1;
252 result.not_found_ids.push(id.as_str().to_string());
253 },
254 Err(e) => {
255 eprintln!("Failed to delete {}: {e}", id.as_str());
256 result.not_found += 1;
257 result.not_found_ids.push(id.as_str().to_string());
258 },
259 }
260 }
261
262 metrics::counter!("cli_delete_hard_total").increment(result.deleted as u64);
263 result
264}
265
266#[cfg(test)]
267mod tests {
268 use super::*;
269 use crate::models::{Domain, Namespace};
270 use tempfile::TempDir;
271
272 fn create_test_memory(id: &str) -> Memory {
273 Memory {
274 id: MemoryId::new(id),
275 content: format!("Test content for {id}"),
276 namespace: Namespace::Decisions,
277 domain: Domain::new(),
278 project_id: None,
279 branch: None,
280 file_path: None,
281 status: MemoryStatus::Active,
282 created_at: 1_000_000,
283 updated_at: 1_000_000,
284 tombstoned_at: None,
285 expires_at: None,
286 embedding: None,
287 tags: vec![],
288 #[cfg(feature = "group-scope")]
289 group_id: None,
290 source: None,
291 is_summary: false,
292 source_memory_ids: None,
293 consolidation_timestamp: None,
294 }
295 }
296
297 #[test]
298 fn test_soft_delete_single() {
299 let dir = TempDir::new().unwrap();
300 let backend = SqliteBackend::new(dir.path().join("test.db")).unwrap();
301
302 let memory = create_test_memory("test-soft-1");
304 backend.index(&memory).unwrap();
305
306 let ids = vec![(memory.id.clone(), "decisions".to_string(), memory.clone())];
308 let result = soft_delete(&backend, &ids);
309
310 assert_eq!(result.deleted, 1);
311 assert_eq!(result.not_found, 0);
312
313 let retrieved = backend.get_memory(&memory.id).unwrap().unwrap();
315 assert_eq!(retrieved.status, MemoryStatus::Tombstoned);
316 }
317
318 #[test]
319 fn test_hard_delete_single() {
320 let dir = TempDir::new().unwrap();
321 let backend = SqliteBackend::new(dir.path().join("test.db")).unwrap();
322
323 let memory = create_test_memory("test-hard-1");
325 backend.index(&memory).unwrap();
326
327 let ids = vec![(memory.id.clone(), "decisions".to_string(), memory.clone())];
329 let result = hard_delete(&backend, &ids);
330
331 assert_eq!(result.deleted, 1);
332 assert_eq!(result.not_found, 0);
333
334 let retrieved = backend.get_memory(&memory.id).unwrap();
336 assert!(retrieved.is_none());
337 }
338
339 #[test]
340 fn test_delete_not_found() {
341 let dir = TempDir::new().unwrap();
342 let backend = SqliteBackend::new(dir.path().join("test.db")).unwrap();
343
344 let memory = create_test_memory("nonexistent");
346
347 let ids = vec![(
349 MemoryId::new("nonexistent"),
350 "decisions".to_string(),
351 memory,
352 )];
353 let result = hard_delete(&backend, &ids);
354
355 assert_eq!(result.deleted, 0);
356 assert_eq!(result.not_found, 1);
357 }
358
359 #[test]
360 fn test_delete_multiple() {
361 let dir = TempDir::new().unwrap();
362 let backend = SqliteBackend::new(dir.path().join("test.db")).unwrap();
363
364 let m1 = create_test_memory("test-multi-1");
366 let m2 = create_test_memory("test-multi-2");
367 let m3 = create_test_memory("test-multi-3");
368 backend.index(&m1).unwrap();
369 backend.index(&m2).unwrap();
370 backend.index(&m3).unwrap();
371
372 let ids = vec![
374 (m1.id.clone(), "decisions".to_string(), m1),
375 (m2.id.clone(), "decisions".to_string(), m2),
376 (m3.id.clone(), "decisions".to_string(), m3),
377 ];
378 let result = hard_delete(&backend, &ids);
379
380 assert_eq!(result.deleted, 3);
381 assert_eq!(result.not_found, 0);
382 }
383}