1use crate::cli::CliOutput;
16use crate::cli::governance::{GovernanceOutcome, enforce as enforce_governance};
17use crate::cli::helpers::{human_age, id_short};
18use crate::{config, db, identity, models, validate};
19use anyhow::Result;
20use clap::Args;
21use models::Tier;
22use std::path::Path;
23
24#[derive(Args)]
25pub struct GetArgs {
26 pub id: String,
27}
28
29#[derive(Args)]
30pub struct ListArgs {
31 #[arg(long, short)]
32 pub namespace: Option<String>,
33 #[arg(long, short)]
34 pub tier: Option<String>,
35 #[arg(long, default_value_t = 20)]
36 pub limit: usize,
37 #[arg(long)]
38 pub since: Option<String>,
39 #[arg(long)]
40 pub until: Option<String>,
41 #[arg(long)]
42 pub tags: Option<String>,
43 #[arg(long, default_value_t = 0)]
44 pub offset: usize,
45 #[arg(long)]
47 pub agent_id: Option<String>,
48}
49
50#[derive(Args)]
51pub struct DeleteArgs {
52 pub id: String,
53}
54
55pub fn cmd_get(
57 db_path: &Path,
58 args: &GetArgs,
59 json_out: bool,
60 out: &mut CliOutput<'_>,
61) -> Result<()> {
62 validate::validate_id(&args.id)?;
63 let conn = db::open(db_path)?;
64 if let Some(mem) = db::resolve_id(&conn, &args.id)? {
65 let links = db::get_links(&conn, &mem.id).unwrap_or_default();
66 if json_out {
67 writeln!(
68 out.stdout,
69 "{}",
70 serde_json::to_string(&serde_json::json!({"memory": mem, "links": links}))?
71 )?;
72 } else {
73 writeln!(out.stdout, "{}", serde_json::to_string_pretty(&mem)?)?;
74 if !links.is_empty() {
75 writeln!(out.stdout, "\nlinks:")?;
76 for l in &links {
77 writeln!(
78 out.stdout,
79 " {} --[{}]--> {}",
80 l.source_id, l.relation, l.target_id
81 )?;
82 }
83 }
84 }
85 } else {
86 writeln!(out.stderr, "not found: {}", args.id)?;
87 std::process::exit(1);
88 }
89 Ok(())
90}
91
92#[allow(clippy::too_many_lines)]
94pub fn cmd_list(
95 db_path: &Path,
96 args: &ListArgs,
97 json_out: bool,
98 app_config: &config::AppConfig,
99 out: &mut CliOutput<'_>,
100) -> Result<()> {
101 if let Some(ref aid) = args.agent_id {
102 validate::validate_agent_id(aid)?;
103 }
104 let conn = db::open(db_path)?;
105 let _ = db::gc_if_needed(&conn, app_config.effective_archive_on_gc());
106 let tier = args.tier.as_deref().and_then(Tier::from_str);
107 let results = db::list(
108 &conn,
109 args.namespace.as_deref(),
110 tier.as_ref(),
111 args.limit,
112 args.offset,
113 None,
114 args.since.as_deref(),
115 args.until.as_deref(),
116 args.tags.as_deref(),
117 args.agent_id.as_deref(),
118 )?;
119 if json_out {
120 writeln!(
121 out.stdout,
122 "{}",
123 serde_json::to_string(
124 &serde_json::json!({"memories": results, "count": results.len()})
125 )?
126 )?;
127 return Ok(());
128 }
129 if results.is_empty() {
130 writeln!(out.stderr, "no memories stored")?;
131 return Ok(());
132 }
133 for mem in &results {
134 let age = human_age(&mem.updated_at);
135 writeln!(
136 out.stdout,
137 "[{}/{}] {} (p={}, ns={}, {})",
138 mem.tier,
139 id_short(&mem.id),
140 mem.title,
141 mem.priority,
142 mem.namespace,
143 age
144 )?;
145 }
146 writeln!(out.stdout, "\n{} memory(ies)", results.len())?;
147 Ok(())
148}
149
150pub fn cmd_delete(
152 db_path: &Path,
153 args: &DeleteArgs,
154 json_out: bool,
155 cli_agent_id: Option<&str>,
156 out: &mut CliOutput<'_>,
157) -> Result<()> {
158 validate::validate_id(&args.id)?;
159 let conn = db::open(db_path)?;
160 let target = db::resolve_id(&conn, &args.id)?;
161 let Some(target) = target else {
162 writeln!(out.stderr, "not found: {}", args.id)?;
163 std::process::exit(1);
164 };
165
166 {
167 use models::GovernedAction;
168 let caller_agent_id = identity::resolve_agent_id(cli_agent_id, None)?;
169 let mem_owner = target
170 .metadata
171 .get("agent_id")
172 .and_then(|v| v.as_str())
173 .map(str::to_string);
174 let payload = serde_json::json!({"id": target.id, "title": target.title});
175 match enforce_governance(
176 &conn,
177 GovernedAction::Delete,
178 &target.namespace,
179 &caller_agent_id,
180 Some(&target.id),
181 mem_owner.as_deref(),
182 &payload,
183 json_out,
184 out,
185 )? {
186 GovernanceOutcome::Allow => {}
187 GovernanceOutcome::Deny => {
188 std::process::exit(1);
189 }
190 GovernanceOutcome::Pending => {
191 return Ok(());
192 }
193 }
194 }
195
196 if db::delete(&conn, &target.id)? {
197 crate::audit::emit(crate::audit::EventBuilder::new(
199 crate::audit::AuditAction::Delete,
200 crate::audit::actor(
201 identity::resolve_agent_id(cli_agent_id, None).unwrap_or_default(),
202 cli_agent_id.map_or("default_fallback", |_| "explicit"),
203 None,
204 ),
205 crate::audit::target_memory(
206 target.id.clone(),
207 target.namespace.clone(),
208 Some(target.title.clone()),
209 Some(target.tier.to_string()),
210 None,
211 ),
212 ));
213 if json_out {
214 writeln!(
215 out.stdout,
216 "{}",
217 serde_json::json!({"deleted": true, "id": target.id})
218 )?;
219 } else {
220 writeln!(out.stdout, "deleted: {}", target.id)?;
221 }
222 } else {
223 writeln!(out.stderr, "not found: {}", args.id)?;
224 std::process::exit(1);
225 }
226 Ok(())
227}
228
229#[cfg(test)]
230mod tests {
231 use super::*;
232 use crate::cli::test_utils::{TestEnv, seed_memory};
233
234 fn list_args() -> ListArgs {
235 ListArgs {
236 namespace: None,
237 tier: None,
238 limit: 20,
239 since: None,
240 until: None,
241 tags: None,
242 offset: 0,
243 agent_id: None,
244 }
245 }
246
247 #[test]
250 fn test_get_by_full_id() {
251 let mut env = TestEnv::fresh();
252 let db = env.db_path.clone();
253 let id = seed_memory(&db, "ns", "title", "content");
254 {
255 let mut out = env.output();
256 cmd_get(&db, &GetArgs { id: id.clone() }, true, &mut out).unwrap();
257 }
258 let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
259 assert_eq!(v["memory"]["id"].as_str().unwrap(), id);
260 assert_eq!(v["memory"]["title"].as_str().unwrap(), "title");
261 }
262
263 #[test]
264 fn test_get_by_prefix() {
265 let mut env = TestEnv::fresh();
266 let db = env.db_path.clone();
267 let id = seed_memory(&db, "ns", "title", "content");
268 let prefix = id[..8].to_string();
269 {
270 let mut out = env.output();
271 cmd_get(&db, &GetArgs { id: prefix }, true, &mut out).unwrap();
272 }
273 let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
274 assert_eq!(v["memory"]["id"].as_str().unwrap(), id);
275 }
276
277 #[test]
281 fn test_get_invalid_id_validation_error() {
282 let mut env = TestEnv::fresh();
283 let db = env.db_path.clone();
284 let bad = "bad\0id".to_string();
287 let mut out = env.output();
288 let res = cmd_get(&db, &GetArgs { id: bad }, false, &mut out);
289 assert!(res.is_err());
290 }
291
292 #[test]
298 fn test_get_includes_links() {
299 let mut env = TestEnv::fresh();
300 let db = env.db_path.clone();
301 let id1 = seed_memory(&db, "ns", "a", "ca");
302 let id2 = seed_memory(&db, "ns", "b", "cb");
303 {
304 let conn = db::open(&db).unwrap();
305 db::create_link(&conn, &id1, &id2, "supersedes").unwrap();
306 }
307 {
308 let mut out = env.output();
309 cmd_get(&db, &GetArgs { id: id1.clone() }, false, &mut out).unwrap();
310 }
311 let stdout = env.stdout_str();
312 assert!(stdout.contains("links:"), "got: {stdout}");
314 assert!(stdout.contains("supersedes"), "got: {stdout}");
315 }
316
317 #[test]
318 fn test_get_json_output() {
319 let mut env = TestEnv::fresh();
320 let db = env.db_path.clone();
321 let id = seed_memory(&db, "ns-j", "tt", "cc");
322 {
323 let mut out = env.output();
324 cmd_get(&db, &GetArgs { id }, true, &mut out).unwrap();
325 }
326 let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
327 assert!(v["memory"].is_object());
328 assert!(v["links"].is_array());
329 }
330
331 #[test]
332 fn test_get_text_output_when_no_links() {
333 let mut env = TestEnv::fresh();
334 let db = env.db_path.clone();
335 let id = seed_memory(&db, "ns-t", "tt", "cc");
336 {
337 let mut out = env.output();
338 cmd_get(&db, &GetArgs { id }, false, &mut out).unwrap();
339 }
340 let stdout = env.stdout_str();
341 assert!(stdout.contains("\"title\": \"tt\""), "got: {stdout}");
343 assert!(!stdout.contains("links:"));
345 }
346
347 #[test]
350 fn test_list_empty_db() {
351 let mut env = TestEnv::fresh();
352 let db = env.db_path.clone();
353 let _ = seed_memory(&db, "ns", "t", "c");
355 {
356 let conn = db::open(&db).unwrap();
357 db::forget(&conn, Some("ns"), None, None, false).unwrap();
358 }
359 let cfg = config::AppConfig::default();
360 let args = list_args();
361 {
362 let mut out = env.output();
363 cmd_list(&db, &args, false, &cfg, &mut out).unwrap();
364 }
365 assert!(
367 env.stderr_str().contains("no memories stored"),
368 "got: {}",
369 env.stderr_str()
370 );
371 }
372
373 #[test]
374 fn test_list_with_namespace_filter() {
375 let mut env = TestEnv::fresh();
376 let db = env.db_path.clone();
377 let _ = seed_memory(&db, "alpha", "a", "ca");
378 let _ = seed_memory(&db, "beta", "b", "cb");
379 let cfg = config::AppConfig::default();
380 let mut args = list_args();
381 args.namespace = Some("alpha".to_string());
382 {
383 let mut out = env.output();
384 cmd_list(&db, &args, true, &cfg, &mut out).unwrap();
385 }
386 let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
387 let mems = v["memories"].as_array().unwrap();
388 assert_eq!(mems.len(), 1);
389 assert_eq!(mems[0]["namespace"].as_str().unwrap(), "alpha");
390 }
391
392 #[test]
393 fn test_list_with_tier_filter() {
394 let mut env = TestEnv::fresh();
395 let db = env.db_path.clone();
396 let _ = seed_memory(&db, "ns", "a", "ca");
397 let id_long = seed_memory(&db, "ns", "b-long", "cb");
399 {
400 let conn = db::open(&db).unwrap();
401 db::update(
402 &conn,
403 &id_long,
404 None,
405 None,
406 Some(&Tier::Long),
407 None,
408 None,
409 None,
410 None,
411 None,
412 None,
413 )
414 .unwrap();
415 }
416 let cfg = config::AppConfig::default();
417 let mut args = list_args();
418 args.tier = Some("long".to_string());
419 {
420 let mut out = env.output();
421 cmd_list(&db, &args, true, &cfg, &mut out).unwrap();
422 }
423 let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
424 let mems = v["memories"].as_array().unwrap();
425 assert_eq!(mems.len(), 1);
426 assert_eq!(mems[0]["tier"].as_str().unwrap(), "long");
427 }
428
429 #[test]
430 fn test_list_with_pagination_offset_limit() {
431 let mut env = TestEnv::fresh();
432 let db = env.db_path.clone();
433 for i in 0..5 {
434 let _ = seed_memory(&db, "ns", &format!("t-{i}"), "c");
435 }
436 let cfg = config::AppConfig::default();
437 let mut args = list_args();
438 args.limit = 2;
439 args.offset = 1;
440 {
441 let mut out = env.output();
442 cmd_list(&db, &args, true, &cfg, &mut out).unwrap();
443 }
444 let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
445 let mems = v["memories"].as_array().unwrap();
446 assert_eq!(mems.len(), 2);
447 }
448
449 #[test]
450 fn test_list_invalid_agent_id_validation_error() {
451 let mut env = TestEnv::fresh();
452 let db = env.db_path.clone();
453 let cfg = config::AppConfig::default();
454 let mut args = list_args();
455 args.agent_id = Some("has spaces".to_string());
456 let mut out = env.output();
457 let res = cmd_list(&db, &args, false, &cfg, &mut out);
458 assert!(res.is_err());
459 }
460
461 #[test]
462 fn test_list_text_output_includes_short_id_and_age() {
463 let mut env = TestEnv::fresh();
464 let db = env.db_path.clone();
465 let _ = seed_memory(&db, "ns-t", "the-title", "c");
466 let cfg = config::AppConfig::default();
467 let args = list_args();
468 {
469 let mut out = env.output();
470 cmd_list(&db, &args, false, &cfg, &mut out).unwrap();
471 }
472 let stdout = env.stdout_str();
473 assert!(stdout.contains("the-title"), "got: {stdout}");
474 assert!(stdout.contains("ns=ns-t"), "got: {stdout}");
475 assert!(stdout.contains("memory(ies)"), "got: {stdout}");
476 }
477
478 #[test]
481 fn test_delete_happy_path() {
482 let mut env = TestEnv::fresh();
483 let db = env.db_path.clone();
484 let id = seed_memory(&db, "ns", "tt", "cc");
485 {
486 let mut out = env.output();
487 cmd_delete(
488 &db,
489 &DeleteArgs { id: id.clone() },
490 false,
491 Some("test-agent"),
492 &mut out,
493 )
494 .unwrap();
495 }
496 assert!(
497 env.stdout_str().contains("deleted"),
498 "got: {}",
499 env.stdout_str()
500 );
501 let conn = db::open(&db).unwrap();
502 assert!(db::get(&conn, &id).unwrap().is_none());
503 }
504
505 #[test]
506 fn test_delete_by_prefix() {
507 let mut env = TestEnv::fresh();
508 let db = env.db_path.clone();
509 let id = seed_memory(&db, "ns", "tt", "cc");
510 let prefix = id[..8].to_string();
511 {
512 let mut out = env.output();
513 cmd_delete(
514 &db,
515 &DeleteArgs { id: prefix },
516 true,
517 Some("test-agent"),
518 &mut out,
519 )
520 .unwrap();
521 }
522 let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
523 assert_eq!(v["deleted"].as_bool().unwrap(), true);
524 assert_eq!(v["id"].as_str().unwrap(), id);
525 }
526
527 #[test]
528 fn test_delete_governance_pending_returns_pending_status() {
529 use crate::models::{ApproverType, GovernanceLevel, GovernancePolicy};
530 let mut env = TestEnv::fresh();
531 let db = env.db_path.clone();
532 let id = seed_memory(&db, "gov-ns", "tt", "cc");
534 let policy = GovernancePolicy {
536 write: GovernanceLevel::Any,
537 promote: GovernanceLevel::Any,
538 delete: GovernanceLevel::Approve,
539 approver: ApproverType::Human,
540 inherit: true,
541 };
542 let conn = db::open(&db).unwrap();
543 let now = chrono::Utc::now().to_rfc3339();
544 let mut metadata = models::default_metadata();
545 if let Some(obj) = metadata.as_object_mut() {
546 obj.insert(
547 "agent_id".to_string(),
548 serde_json::Value::String("alice".to_string()),
549 );
550 obj.insert(
551 "governance".to_string(),
552 serde_json::to_value(&policy).unwrap(),
553 );
554 }
555 let standard = models::Memory {
556 id: uuid::Uuid::new_v4().to_string(),
557 tier: Tier::Long,
558 namespace: "_standards-gov-ns".to_string(),
559 title: "standard for gov-ns".to_string(),
560 content: "policy".to_string(),
561 tags: vec![],
562 priority: 9,
563 confidence: 1.0,
564 source: "test".to_string(),
565 access_count: 0,
566 created_at: now.clone(),
567 updated_at: now,
568 last_accessed_at: None,
569 expires_at: None,
570 metadata,
571 };
572 let standard_id = db::insert(&conn, &standard).unwrap();
573 db::set_namespace_standard(&conn, "gov-ns", &standard_id, None).unwrap();
574 drop(conn);
575
576 {
577 let mut out = env.output();
578 cmd_delete(
579 &db,
580 &DeleteArgs { id: id.clone() },
581 true,
582 Some("bob"),
583 &mut out,
584 )
585 .unwrap();
586 }
587 let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
588 assert_eq!(v["status"].as_str().unwrap(), "pending");
589 assert_eq!(v["action"].as_str().unwrap(), "delete");
590 let conn = db::open(&db).unwrap();
592 assert!(db::get(&conn, &id).unwrap().is_some());
593 }
594}