Skip to main content

subcog/cli/
delete.rs

1//! Delete CLI command for removing memories.
2//!
3//! Provides both soft delete (tombstone) and hard delete capabilities.
4//!
5//! # Usage
6//!
7//! ```bash
8//! # Soft delete (default) - can be restored via gc command
9//! subcog delete abc123
10//! subcog delete id1 id2 id3
11//!
12//! # Hard delete - permanent, irreversible
13//! subcog delete --hard abc123
14//!
15//! # Skip confirmation
16//! subcog delete --force abc123
17//!
18//! # Preview what would be deleted
19//! subcog delete --dry-run abc123
20//! ```
21
22// Allow print_stdout/stderr in CLI module (consistent with main.rs)
23#![allow(clippy::print_stdout)]
24#![allow(clippy::print_stderr)]
25// Allow pass-by-value for command functions (consistent with main.rs)
26#![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/// Result of a delete operation.
39#[derive(Debug, Default)]
40pub struct DeleteResult {
41    /// Number of memories successfully deleted.
42    pub deleted: usize,
43    /// Number of memories not found.
44    pub not_found: usize,
45    /// IDs that were deleted.
46    pub deleted_ids: Vec<String>,
47    /// IDs that were not found.
48    pub not_found_ids: Vec<String>,
49}
50
51/// Executes the delete command.
52///
53/// # Arguments
54///
55/// * `ids` - Memory IDs to delete
56/// * `hard` - If true, permanently delete; otherwise tombstone (soft delete)
57/// * `force` - If true, skip confirmation prompt
58/// * `dry_run` - If true, show what would be deleted without making changes
59///
60/// # Errors
61///
62/// Returns an error if storage access fails.
63pub 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    // Get service container with proper SQLite backend access
70    let container = ServiceContainer::from_current_dir_or_user()?;
71    let index = container.index()?;
72
73    // Validate IDs and collect existing memories
74    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    // Report not found IDs
90    if !not_found_ids.is_empty() {
91        println!("Not found ({}):", not_found_ids.len());
92        for id in &not_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    // Dry-run mode
104    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    // Confirmation prompt (unless --force)
117    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    // Execute deletion
158    let result = if hard {
159        hard_delete(&index, &valid_ids)
160    } else {
161        soft_delete(&index, &valid_ids)
162    };
163
164    // Report results
165    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
182/// Performs soft delete (tombstone) on the given memories.
183fn 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        // Update memory status to tombstoned
194        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        // Re-index with updated status
200        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
229/// Performs hard delete (permanent) on the given memories.
230fn 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        // Store a memory
303        let memory = create_test_memory("test-soft-1");
304        backend.index(&memory).unwrap();
305
306        // Soft delete
307        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        // Verify it's tombstoned, not deleted
314        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        // Store a memory
324        let memory = create_test_memory("test-hard-1");
325        backend.index(&memory).unwrap();
326
327        // Hard delete
328        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        // Verify it's gone
335        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        // Create a memory object but don't index it
345        let memory = create_test_memory("nonexistent");
346
347        // Try to delete non-existent memory
348        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        // Store multiple memories
365        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        // Hard delete all
373        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}