1use 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 {
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 { id: String },
33 Purge {
35 #[arg(long)]
37 older_than_days: Option<i64>,
38 },
39 Stats,
41}
42
43pub 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 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 assert!(env.stdout_str().contains("no archived memories"));
200 }
201
202 #[test]
203 fn test_archive_restore_nonexistent_exits_via_stderr() {
204 let mut env = TestEnv::fresh();
208 let db = env.db_path.clone();
209 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 let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
303 assert!(v["archived_total"].is_number());
304 }
305
306 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 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 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 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 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 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 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 assert!(s.contains("purged"));
409 }
410}