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::{db, validate};
9use anyhow::Result;
10use clap::{Args, Subcommand};
11use std::path::Path;
12
13#[derive(Args)]
14pub struct ArchiveArgs {
15    #[command(subcommand)]
16    pub action: ArchiveAction,
17}
18
19#[derive(Subcommand)]
20pub enum ArchiveAction {
21    /// List archived memories
22    List {
23        #[arg(long, short)]
24        namespace: Option<String>,
25        #[arg(long, default_value_t = 50)]
26        limit: usize,
27        #[arg(long, default_value_t = 0)]
28        offset: usize,
29    },
30    /// Restore an archived memory back to active
31    Restore { id: String },
32    /// Permanently delete old archive entries
33    Purge {
34        /// Delete archive entries older than N days (all if omitted)
35        #[arg(long)]
36        older_than_days: Option<i64>,
37    },
38    /// Show archive statistics
39    Stats,
40}
41
42/// `archive` handler.
43pub fn run(
44    db_path: &Path,
45    args: ArchiveArgs,
46    json_out: bool,
47    out: &mut CliOutput<'_>,
48) -> Result<()> {
49    let conn = db::open(db_path)?;
50    match args.action {
51        ArchiveAction::List {
52            namespace,
53            limit,
54            offset,
55        } => {
56            let items = db::list_archived(&conn, namespace.as_deref(), limit, offset)?;
57            if json_out {
58                writeln!(
59                    out.stdout,
60                    "{}",
61                    serde_json::json!({"archived": items, "count": items.len()})
62                )?;
63            } else if items.is_empty() {
64                writeln!(out.stdout, "no archived memories")?;
65            } else {
66                for item in &items {
67                    writeln!(
68                        out.stdout,
69                        "[{}] {} (archived: {})",
70                        id_short(item["id"].as_str().unwrap_or("")),
71                        item["title"].as_str().unwrap_or(""),
72                        item["archived_at"].as_str().unwrap_or("")
73                    )?;
74                }
75                writeln!(out.stdout, "{} archived memories", items.len())?;
76            }
77        }
78        ArchiveAction::Restore { id } => {
79            validate::validate_id(&id)?;
80            let restored = db::restore_archived(&conn, &id)?;
81            if json_out {
82                writeln!(
83                    out.stdout,
84                    "{}",
85                    serde_json::json!({"restored": restored, "id": id})
86                )?;
87            } else if restored {
88                writeln!(out.stdout, "restored: {}", id_short(&id))?;
89            } else {
90                writeln!(out.stderr, "not found in archive: {id}")?;
91                std::process::exit(1);
92            }
93        }
94        ArchiveAction::Purge { older_than_days } => {
95            let purged = db::purge_archive(&conn, older_than_days)?;
96            if json_out {
97                writeln!(out.stdout, "{}", serde_json::json!({"purged": purged}))?;
98            } else {
99                writeln!(out.stdout, "purged {purged} archived memories")?;
100            }
101        }
102        ArchiveAction::Stats => {
103            let stats = db::archive_stats(&conn)?;
104            if json_out {
105                writeln!(out.stdout, "{stats}")?;
106            } else {
107                writeln!(out.stdout, "archived: {} total", stats["archived_total"])?;
108                if let Some(by_ns) = stats["by_namespace"].as_array() {
109                    for ns in by_ns {
110                        writeln!(
111                            out.stdout,
112                            "  {}: {}",
113                            ns["namespace"].as_str().unwrap_or(""),
114                            ns["count"]
115                        )?;
116                    }
117                }
118            }
119        }
120    }
121    Ok(())
122}
123
124#[cfg(test)]
125mod tests {
126    use super::*;
127    use crate::cli::test_utils::{TestEnv, seed_memory};
128
129    #[test]
130    fn test_archive_list_empty() {
131        let mut env = TestEnv::fresh();
132        let db = env.db_path.clone();
133        let args = ArchiveArgs {
134            action: ArchiveAction::List {
135                namespace: None,
136                limit: 50,
137                offset: 0,
138            },
139        };
140        {
141            let mut out = env.output();
142            run(&db, args, false, &mut out).unwrap();
143        }
144        assert!(env.stdout_str().contains("no archived memories"));
145    }
146
147    #[test]
148    fn test_archive_list_empty_json() {
149        let mut env = TestEnv::fresh();
150        let db = env.db_path.clone();
151        let args = ArchiveArgs {
152            action: ArchiveAction::List {
153                namespace: None,
154                limit: 50,
155                offset: 0,
156            },
157        };
158        {
159            let mut out = env.output();
160            run(&db, args, true, &mut out).unwrap();
161        }
162        let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
163        assert_eq!(v["count"].as_u64().unwrap(), 0);
164        assert!(v["archived"].is_array());
165    }
166
167    #[test]
168    fn test_archive_list_with_namespace_filter() {
169        let mut env = TestEnv::fresh();
170        let db = env.db_path.clone();
171        let args = ArchiveArgs {
172            action: ArchiveAction::List {
173                namespace: Some("nope".to_string()),
174                limit: 50,
175                offset: 0,
176            },
177        };
178        {
179            let mut out = env.output();
180            run(&db, args, false, &mut out).unwrap();
181        }
182        // No archived memories in any namespace yet.
183        assert!(env.stdout_str().contains("no archived memories"));
184    }
185
186    #[test]
187    fn test_archive_restore_nonexistent_exits_via_stderr() {
188        // process::exit would terminate the test; we instead use a valid-looking
189        // ID and expect the stderr write, but since exit(1) happens we test the
190        // success branch via direct DB seeding.
191        let mut env = TestEnv::fresh();
192        let db = env.db_path.clone();
193        // Seed a memory and archive it via direct DB call.
194        let id = seed_memory(&db, "ns", "t", "c");
195        let conn = db::open(&db).unwrap();
196        let _ = db::archive_memory(&conn, &id, None);
197        drop(conn);
198        let args = ArchiveArgs {
199            action: ArchiveAction::Restore { id: id.clone() },
200        };
201        {
202            let mut out = env.output();
203            run(&db, args, false, &mut out).unwrap();
204        }
205        assert!(env.stdout_str().contains("restored:"));
206    }
207
208    #[test]
209    fn test_archive_restore_json() {
210        let mut env = TestEnv::fresh();
211        let db = env.db_path.clone();
212        let id = seed_memory(&db, "ns", "t", "c");
213        let conn = db::open(&db).unwrap();
214        let _ = db::archive_memory(&conn, &id, None);
215        drop(conn);
216        let args = ArchiveArgs {
217            action: ArchiveAction::Restore { id: id.clone() },
218        };
219        {
220            let mut out = env.output();
221            run(&db, args, true, &mut out).unwrap();
222        }
223        let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
224        assert_eq!(v["restored"].as_bool().unwrap(), true);
225    }
226
227    #[test]
228    fn test_archive_purge_no_filter() {
229        let mut env = TestEnv::fresh();
230        let db = env.db_path.clone();
231        let args = ArchiveArgs {
232            action: ArchiveAction::Purge {
233                older_than_days: None,
234            },
235        };
236        {
237            let mut out = env.output();
238            run(&db, args, false, &mut out).unwrap();
239        }
240        assert!(env.stdout_str().contains("purged 0"));
241    }
242
243    #[test]
244    fn test_archive_purge_older_than_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: Some(30),
250            },
251        };
252        {
253            let mut out = env.output();
254            run(&db, args, true, &mut out).unwrap();
255        }
256        let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
257        assert_eq!(v["purged"].as_u64().unwrap(), 0);
258    }
259
260    #[test]
261    fn test_archive_stats() {
262        let mut env = TestEnv::fresh();
263        let db = env.db_path.clone();
264        let args = ArchiveArgs {
265            action: ArchiveAction::Stats,
266        };
267        {
268            let mut out = env.output();
269            run(&db, args, false, &mut out).unwrap();
270        }
271        assert!(env.stdout_str().contains("archived:"));
272    }
273
274    #[test]
275    fn test_archive_stats_json() {
276        let mut env = TestEnv::fresh();
277        let db = env.db_path.clone();
278        let args = ArchiveArgs {
279            action: ArchiveAction::Stats,
280        };
281        {
282            let mut out = env.output();
283            run(&db, args, true, &mut out).unwrap();
284        }
285        // Stats prints raw json blob, parseable.
286        let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
287        assert!(v["archived_total"].is_number());
288    }
289}