Skip to main content

ai_memory/cli/
archive.rs

1// Copyright 2026 AlphaOne LLC
2// SPDX-License-Identifier: Apache-2.0
3
4//! `cmd_archive` migration. See `cli::store` for the design pattern.
5
6use crate::cli::CliOutput;
7use crate::cli::helpers::id_short;
8use crate::models::field_names;
9use crate::{db, validate};
10use anyhow::Result;
11use clap::{Args, Subcommand};
12use std::path::Path;
13
14#[derive(Args)]
15pub struct ArchiveArgs {
16    #[command(subcommand)]
17    pub action: ArchiveAction,
18}
19
20#[derive(Subcommand)]
21pub enum ArchiveAction {
22    /// List archived memories
23    List {
24        #[arg(long, short)]
25        namespace: Option<String>,
26        #[arg(long, default_value_t = 50)]
27        limit: usize,
28        #[arg(long, default_value_t = 0)]
29        offset: usize,
30    },
31    /// Restore an archived memory back to active
32    Restore { id: String },
33    /// Permanently delete old archive entries
34    Purge {
35        /// Delete archive entries older than N days (all if omitted)
36        #[arg(long)]
37        older_than_days: Option<i64>,
38    },
39    /// Show archive statistics
40    Stats,
41}
42
43/// `archive` handler.
44pub fn run(
45    db_path: &Path,
46    args: ArchiveArgs,
47    json_out: bool,
48    out: &mut CliOutput<'_>,
49) -> Result<()> {
50    let conn = db::open(db_path)?;
51    match args.action {
52        ArchiveAction::List {
53            namespace,
54            limit,
55            offset,
56        } => {
57            let items = db::list_archived(&conn, namespace.as_deref(), limit, offset)?;
58            if json_out {
59                writeln!(
60                    out.stdout,
61                    "{}",
62                    serde_json::json!({"archived": items, "count": items.len()})
63                )?;
64            } else if items.is_empty() {
65                writeln!(out.stdout, "no archived memories")?;
66            } else {
67                for item in &items {
68                    writeln!(
69                        out.stdout,
70                        "[{}] {} (archived: {})",
71                        id_short(item["id"].as_str().unwrap_or("")),
72                        item["title"].as_str().unwrap_or(""),
73                        item[field_names::ARCHIVED_AT].as_str().unwrap_or("")
74                    )?;
75                }
76                writeln!(out.stdout, "{} archived memories", items.len())?;
77            }
78        }
79        ArchiveAction::Restore { id } => {
80            validate::validate_id(&id)?;
81            let restored = db::restore_archived(&conn, &id)?;
82            if json_out {
83                writeln!(
84                    out.stdout,
85                    "{}",
86                    serde_json::json!({"restored": restored, "id": id})
87                )?;
88            } else if restored {
89                writeln!(out.stdout, "restored: {}", id_short(&id))?;
90            } else {
91                writeln!(out.stderr, "not found in archive: {id}")?;
92                std::process::exit(1);
93            }
94        }
95        ArchiveAction::Purge { older_than_days } => {
96            // #913 (security-medium / SOC2, 2026-05-19) — admin/destructive
97            // state-change audit. CLI archive purge mirrors the HTTP +
98            // MCP fixes; emit the forensic-chain row BEFORE the storage
99            // write so the audit trail captures the operator regardless
100            // of downstream outcome.
101            let caller = crate::identity::resolve_agent_id(None, None)
102                .unwrap_or_else(|_| format!("anonymous:pid-{}", std::process::id()));
103            crate::governance::audit::record_decision(
104                &caller,
105                "allow",
106                crate::governance::action_labels::ARCHIVE_PURGE,
107                "",
108                serde_json::json!({ (field_names::OLDER_THAN_DAYS): older_than_days }),
109            );
110
111            let purged = db::purge_archive(&conn, older_than_days)?;
112            if json_out {
113                writeln!(out.stdout, "{}", serde_json::json!({"purged": purged}))?;
114            } else {
115                writeln!(out.stdout, "purged {purged} archived memories")?;
116            }
117        }
118        ArchiveAction::Stats => {
119            let stats = db::archive_stats(&conn)?;
120            if json_out {
121                writeln!(out.stdout, "{stats}")?;
122            } else {
123                writeln!(out.stdout, "archived: {} total", stats["archived_total"])?;
124                if let Some(by_ns) = stats[field_names::BY_NAMESPACE].as_array() {
125                    for ns in by_ns {
126                        writeln!(
127                            out.stdout,
128                            "  {}: {}",
129                            ns["namespace"].as_str().unwrap_or(""),
130                            ns["count"]
131                        )?;
132                    }
133                }
134            }
135        }
136    }
137    Ok(())
138}
139
140#[cfg(test)]
141mod tests {
142    use super::*;
143    use crate::cli::test_utils::{TestEnv, seed_memory};
144
145    #[test]
146    fn test_archive_list_empty() {
147        let mut env = TestEnv::fresh();
148        let db = env.db_path.clone();
149        let args = ArchiveArgs {
150            action: ArchiveAction::List {
151                namespace: None,
152                limit: 50,
153                offset: 0,
154            },
155        };
156        {
157            let mut out = env.output();
158            run(&db, args, false, &mut out).unwrap();
159        }
160        assert!(env.stdout_str().contains("no archived memories"));
161    }
162
163    #[test]
164    fn test_archive_list_empty_json() {
165        let mut env = TestEnv::fresh();
166        let db = env.db_path.clone();
167        let args = ArchiveArgs {
168            action: ArchiveAction::List {
169                namespace: None,
170                limit: 50,
171                offset: 0,
172            },
173        };
174        {
175            let mut out = env.output();
176            run(&db, args, true, &mut out).unwrap();
177        }
178        let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
179        assert_eq!(v["count"].as_u64().unwrap(), 0);
180        assert!(v["archived"].is_array());
181    }
182
183    #[test]
184    fn test_archive_list_with_namespace_filter() {
185        let mut env = TestEnv::fresh();
186        let db = env.db_path.clone();
187        let args = ArchiveArgs {
188            action: ArchiveAction::List {
189                namespace: Some("nope".to_string()),
190                limit: 50,
191                offset: 0,
192            },
193        };
194        {
195            let mut out = env.output();
196            run(&db, args, false, &mut out).unwrap();
197        }
198        // No archived memories in any namespace yet.
199        assert!(env.stdout_str().contains("no archived memories"));
200    }
201
202    #[test]
203    fn test_archive_restore_nonexistent_exits_via_stderr() {
204        // process::exit would terminate the test; we instead use a valid-looking
205        // ID and expect the stderr write, but since exit(1) happens we test the
206        // success branch via direct DB seeding.
207        let mut env = TestEnv::fresh();
208        let db = env.db_path.clone();
209        // Seed a memory and archive it via direct DB call.
210        let id = seed_memory(&db, "ns", "t", "c");
211        let conn = db::open(&db).unwrap();
212        let _ = db::archive_memory(&conn, &id, None);
213        drop(conn);
214        let args = ArchiveArgs {
215            action: ArchiveAction::Restore { id: id.clone() },
216        };
217        {
218            let mut out = env.output();
219            run(&db, args, false, &mut out).unwrap();
220        }
221        assert!(env.stdout_str().contains("restored:"));
222    }
223
224    #[test]
225    fn test_archive_restore_json() {
226        let mut env = TestEnv::fresh();
227        let db = env.db_path.clone();
228        let id = seed_memory(&db, "ns", "t", "c");
229        let conn = db::open(&db).unwrap();
230        let _ = db::archive_memory(&conn, &id, None);
231        drop(conn);
232        let args = ArchiveArgs {
233            action: ArchiveAction::Restore { id: id.clone() },
234        };
235        {
236            let mut out = env.output();
237            run(&db, args, true, &mut out).unwrap();
238        }
239        let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
240        assert_eq!(v["restored"].as_bool().unwrap(), true);
241    }
242
243    #[test]
244    fn test_archive_purge_no_filter() {
245        let mut env = TestEnv::fresh();
246        let db = env.db_path.clone();
247        let args = ArchiveArgs {
248            action: ArchiveAction::Purge {
249                older_than_days: None,
250            },
251        };
252        {
253            let mut out = env.output();
254            run(&db, args, false, &mut out).unwrap();
255        }
256        assert!(env.stdout_str().contains("purged 0"));
257    }
258
259    #[test]
260    fn test_archive_purge_older_than_filter() {
261        let mut env = TestEnv::fresh();
262        let db = env.db_path.clone();
263        let args = ArchiveArgs {
264            action: ArchiveAction::Purge {
265                older_than_days: Some(30),
266            },
267        };
268        {
269            let mut out = env.output();
270            run(&db, args, true, &mut out).unwrap();
271        }
272        let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
273        assert_eq!(v["purged"].as_u64().unwrap(), 0);
274    }
275
276    #[test]
277    fn test_archive_stats() {
278        let mut env = TestEnv::fresh();
279        let db = env.db_path.clone();
280        let args = ArchiveArgs {
281            action: ArchiveAction::Stats,
282        };
283        {
284            let mut out = env.output();
285            run(&db, args, false, &mut out).unwrap();
286        }
287        assert!(env.stdout_str().contains("archived:"));
288    }
289
290    #[test]
291    fn test_archive_stats_json() {
292        let mut env = TestEnv::fresh();
293        let db = env.db_path.clone();
294        let args = ArchiveArgs {
295            action: ArchiveAction::Stats,
296        };
297        {
298            let mut out = env.output();
299            run(&db, args, true, &mut out).unwrap();
300        }
301        // Stats prints raw json blob, parseable.
302        let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
303        assert!(v["archived_total"].is_number());
304    }
305
306    // ---------- E1 coverage uplift: list-with-items + stats with by_namespace
307    // Both branches require seeding then archiving at least one memory so the
308    // archived_at row materializes.
309
310    /// Seed N memories in `ns`, archive them all. Returns the archived ids.
311    fn seed_and_archive(db: &std::path::Path, ns: &str, n: usize) -> Vec<String> {
312        let mut ids = Vec::with_capacity(n);
313        let conn = db::open(db).unwrap();
314        for i in 0..n {
315            let id = seed_memory(db, ns, &format!("title-{i}"), &format!("body-{i}"));
316            db::archive_memory(&conn, &id, None).unwrap();
317            ids.push(id);
318        }
319        ids
320    }
321
322    #[test]
323    fn test_archive_list_text_with_items() {
324        // Drives the for-loop body (lines 66-75) — `[id_short] title (archived: ts)`.
325        let mut env = TestEnv::fresh();
326        let db = env.db_path.clone();
327        seed_and_archive(&db, "ns-arch", 2);
328        let args = ArchiveArgs {
329            action: ArchiveAction::List {
330                namespace: Some("ns-arch".to_string()),
331                limit: 50,
332                offset: 0,
333            },
334        };
335        {
336            let mut out = env.output();
337            run(&db, args, false, &mut out).unwrap();
338        }
339        let s = env.stdout_str();
340        // Should mention both rows + the footer.
341        assert!(s.contains("archived:"));
342        assert!(s.contains("title-0") || s.contains("title-1"));
343        assert!(s.contains("2 archived memories"));
344    }
345
346    #[test]
347    fn test_archive_list_json_with_items() {
348        // JSON variant — covers the `if json_out` arm with non-empty items.
349        let mut env = TestEnv::fresh();
350        let db = env.db_path.clone();
351        seed_and_archive(&db, "ns-arch-j", 3);
352        let args = ArchiveArgs {
353            action: ArchiveAction::List {
354                namespace: Some("ns-arch-j".to_string()),
355                limit: 50,
356                offset: 0,
357            },
358        };
359        {
360            let mut out = env.output();
361            run(&db, args, true, &mut out).unwrap();
362        }
363        let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
364        assert_eq!(v["count"].as_u64().unwrap(), 3);
365    }
366
367    #[test]
368    fn test_archive_stats_text_with_namespace_breakdown() {
369        // Drives the `if let Some(by_ns)` arm (lines 108-117) — one row per ns.
370        let mut env = TestEnv::fresh();
371        let db = env.db_path.clone();
372        seed_and_archive(&db, "ns-stats-a", 1);
373        seed_and_archive(&db, "ns-stats-b", 2);
374        let args = ArchiveArgs {
375            action: ArchiveAction::Stats,
376        };
377        {
378            let mut out = env.output();
379            run(&db, args, false, &mut out).unwrap();
380        }
381        let s = env.stdout_str();
382        assert!(s.contains("archived:"));
383        // Either of the two namespace lines should appear.
384        assert!(
385            s.contains("ns-stats-a") || s.contains("ns-stats-b"),
386            "stats text missing namespace breakdown, got: {s}"
387        );
388    }
389
390    #[test]
391    fn test_archive_purge_clears_with_filter() {
392        // Seed + archive, then purge with older_than_days=0 — sweeps everything.
393        let mut env = TestEnv::fresh();
394        let db = env.db_path.clone();
395        seed_and_archive(&db, "ns-purge", 2);
396        let args = ArchiveArgs {
397            action: ArchiveAction::Purge {
398                older_than_days: Some(0),
399            },
400        };
401        {
402            let mut out = env.output();
403            run(&db, args, false, &mut out).unwrap();
404        }
405        let s = env.stdout_str();
406        // Anything from 0 to 2 — depends on archive_age semantics on this
407        // SQLite build. The line itself must surface.
408        assert!(s.contains("purged"));
409    }
410}