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, "{}", crate::errors::msg::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, "{}", crate::errors::msg::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(crate::audit::synthesis_sources::DEFAULT_FALLBACK, |_| {
203 crate::audit::synthesis_sources::EXPLICIT
204 }),
205 None,
206 ),
207 crate::audit::target_memory(
208 target.id.clone(),
209 target.namespace.clone(),
210 Some(target.title.clone()),
211 Some(target.tier.to_string()),
212 None,
213 ),
214 ));
215 if json_out {
216 writeln!(
217 out.stdout,
218 "{}",
219 serde_json::json!({"deleted": true, "id": target.id})
220 )?;
221 } else {
222 writeln!(out.stdout, "deleted: {}", target.id)?;
223 }
224 } else {
225 writeln!(out.stderr, "{}", crate::errors::msg::not_found(&args.id))?;
226 std::process::exit(1);
227 }
228 Ok(())
229}
230
231#[cfg(test)]
232mod tests {
233 use super::*;
234 use crate::cli::test_utils::{TestEnv, seed_memory};
235
236 fn list_args() -> ListArgs {
237 ListArgs {
238 namespace: None,
239 tier: None,
240 limit: 20,
241 since: None,
242 until: None,
243 tags: None,
244 offset: 0,
245 agent_id: None,
246 }
247 }
248
249 #[test]
252 fn test_get_by_full_id() {
253 let mut env = TestEnv::fresh();
254 let db = env.db_path.clone();
255 let id = seed_memory(&db, "ns", "title", "content");
256 {
257 let mut out = env.output();
258 cmd_get(&db, &GetArgs { id: id.clone() }, true, &mut out).unwrap();
259 }
260 let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
261 assert_eq!(v["memory"]["id"].as_str().unwrap(), id);
262 assert_eq!(v["memory"]["title"].as_str().unwrap(), "title");
263 }
264
265 #[test]
266 fn test_get_by_prefix() {
267 let mut env = TestEnv::fresh();
268 let db = env.db_path.clone();
269 let id = seed_memory(&db, "ns", "title", "content");
270 let prefix = id[..8].to_string();
271 {
272 let mut out = env.output();
273 cmd_get(&db, &GetArgs { id: prefix }, true, &mut out).unwrap();
274 }
275 let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
276 assert_eq!(v["memory"]["id"].as_str().unwrap(), id);
277 }
278
279 #[test]
283 fn test_get_invalid_id_validation_error() {
284 let mut env = TestEnv::fresh();
285 let db = env.db_path.clone();
286 let bad = "bad\0id".to_string();
289 let mut out = env.output();
290 let res = cmd_get(&db, &GetArgs { id: bad }, false, &mut out);
291 assert!(res.is_err());
292 }
293
294 #[test]
300 fn test_get_includes_links() {
301 let mut env = TestEnv::fresh();
302 let db = env.db_path.clone();
303 let id1 = seed_memory(&db, "ns", "a", "ca");
304 let id2 = seed_memory(&db, "ns", "b", "cb");
305 {
306 let conn = db::open(&db).unwrap();
307 db::create_link(&conn, &id1, &id2, "supersedes").unwrap();
308 }
309 {
310 let mut out = env.output();
311 cmd_get(&db, &GetArgs { id: id1.clone() }, false, &mut out).unwrap();
312 }
313 let stdout = env.stdout_str();
314 assert!(stdout.contains("links:"), "got: {stdout}");
316 assert!(stdout.contains("supersedes"), "got: {stdout}");
317 }
318
319 #[test]
320 fn test_get_json_output() {
321 let mut env = TestEnv::fresh();
322 let db = env.db_path.clone();
323 let id = seed_memory(&db, "ns-j", "tt", "cc");
324 {
325 let mut out = env.output();
326 cmd_get(&db, &GetArgs { id }, true, &mut out).unwrap();
327 }
328 let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
329 assert!(v["memory"].is_object());
330 assert!(v["links"].is_array());
331 }
332
333 #[test]
334 fn test_get_text_output_when_no_links() {
335 let mut env = TestEnv::fresh();
336 let db = env.db_path.clone();
337 let id = seed_memory(&db, "ns-t", "tt", "cc");
338 {
339 let mut out = env.output();
340 cmd_get(&db, &GetArgs { id }, false, &mut out).unwrap();
341 }
342 let stdout = env.stdout_str();
343 assert!(stdout.contains("\"title\": \"tt\""), "got: {stdout}");
345 assert!(!stdout.contains("links:"));
347 }
348
349 #[test]
352 fn test_list_empty_db() {
353 let mut env = TestEnv::fresh();
354 let db = env.db_path.clone();
355 let _ = seed_memory(&db, "ns", "t", "c");
357 {
358 let conn = db::open(&db).unwrap();
359 db::forget(&conn, Some("ns"), None, None, false).unwrap();
360 }
361 let cfg = config::AppConfig::default();
362 let args = list_args();
363 {
364 let mut out = env.output();
365 cmd_list(&db, &args, false, &cfg, &mut out).unwrap();
366 }
367 assert!(
369 env.stderr_str().contains("no memories stored"),
370 "got: {}",
371 env.stderr_str()
372 );
373 }
374
375 #[test]
376 fn test_list_with_namespace_filter() {
377 let mut env = TestEnv::fresh();
378 let db = env.db_path.clone();
379 let _ = seed_memory(&db, "alpha", "a", "ca");
380 let _ = seed_memory(&db, "beta", "b", "cb");
381 let cfg = config::AppConfig::default();
382 let mut args = list_args();
383 args.namespace = Some("alpha".to_string());
384 {
385 let mut out = env.output();
386 cmd_list(&db, &args, true, &cfg, &mut out).unwrap();
387 }
388 let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
389 let mems = v["memories"].as_array().unwrap();
390 assert_eq!(mems.len(), 1);
391 assert_eq!(mems[0]["namespace"].as_str().unwrap(), "alpha");
392 }
393
394 #[test]
395 fn test_list_with_tier_filter() {
396 let mut env = TestEnv::fresh();
397 let db = env.db_path.clone();
398 let _ = seed_memory(&db, "ns", "a", "ca");
399 let id_long = seed_memory(&db, "ns", "b-long", "cb");
401 {
402 let conn = db::open(&db).unwrap();
403 db::update(
404 &conn,
405 &id_long,
406 None,
407 None,
408 Some(&Tier::Long),
409 None,
410 None,
411 None,
412 None,
413 None,
414 None,
415 )
416 .unwrap();
417 }
418 let cfg = config::AppConfig::default();
419 let mut args = list_args();
420 args.tier = Some(Tier::Long.as_str().to_string());
421 {
422 let mut out = env.output();
423 cmd_list(&db, &args, true, &cfg, &mut out).unwrap();
424 }
425 let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
426 let mems = v["memories"].as_array().unwrap();
427 assert_eq!(mems.len(), 1);
428 assert_eq!(mems[0]["tier"].as_str().unwrap(), Tier::Long.as_str());
429 }
430
431 #[test]
432 fn test_list_with_pagination_offset_limit() {
433 let mut env = TestEnv::fresh();
434 let db = env.db_path.clone();
435 for i in 0..5 {
436 let _ = seed_memory(&db, "ns", &format!("t-{i}"), "c");
437 }
438 let cfg = config::AppConfig::default();
439 let mut args = list_args();
440 args.limit = 2;
441 args.offset = 1;
442 {
443 let mut out = env.output();
444 cmd_list(&db, &args, true, &cfg, &mut out).unwrap();
445 }
446 let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
447 let mems = v["memories"].as_array().unwrap();
448 assert_eq!(mems.len(), 2);
449 }
450
451 #[test]
452 fn test_list_invalid_agent_id_validation_error() {
453 let mut env = TestEnv::fresh();
454 let db = env.db_path.clone();
455 let cfg = config::AppConfig::default();
456 let mut args = list_args();
457 args.agent_id = Some("has spaces".to_string());
458 let mut out = env.output();
459 let res = cmd_list(&db, &args, false, &cfg, &mut out);
460 assert!(res.is_err());
461 }
462
463 #[test]
464 fn test_list_text_output_includes_short_id_and_age() {
465 let mut env = TestEnv::fresh();
466 let db = env.db_path.clone();
467 let _ = seed_memory(&db, "ns-t", "the-title", "c");
468 let cfg = config::AppConfig::default();
469 let args = list_args();
470 {
471 let mut out = env.output();
472 cmd_list(&db, &args, false, &cfg, &mut out).unwrap();
473 }
474 let stdout = env.stdout_str();
475 assert!(stdout.contains("the-title"), "got: {stdout}");
476 assert!(stdout.contains("ns=ns-t"), "got: {stdout}");
477 assert!(stdout.contains("memory(ies)"), "got: {stdout}");
478 }
479
480 #[test]
483 fn test_delete_happy_path() {
484 let mut env = TestEnv::fresh();
485 let db = env.db_path.clone();
486 let id = seed_memory(&db, "ns", "tt", "cc");
487 {
488 let mut out = env.output();
489 cmd_delete(
490 &db,
491 &DeleteArgs { id: id.clone() },
492 false,
493 Some("test-agent"),
494 &mut out,
495 )
496 .unwrap();
497 }
498 assert!(
499 env.stdout_str().contains("deleted"),
500 "got: {}",
501 env.stdout_str()
502 );
503 let conn = db::open(&db).unwrap();
504 assert!(db::get(&conn, &id).unwrap().is_none());
505 }
506
507 #[test]
508 fn test_delete_by_prefix() {
509 let mut env = TestEnv::fresh();
510 let db = env.db_path.clone();
511 let id = seed_memory(&db, "ns", "tt", "cc");
512 let prefix = id[..8].to_string();
513 {
514 let mut out = env.output();
515 cmd_delete(
516 &db,
517 &DeleteArgs { id: prefix },
518 true,
519 Some("test-agent"),
520 &mut out,
521 )
522 .unwrap();
523 }
524 let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
525 assert_eq!(v["deleted"].as_bool().unwrap(), true);
526 assert_eq!(v["id"].as_str().unwrap(), id);
527 }
528
529 #[test]
530 fn test_delete_governance_pending_returns_pending_status() {
531 let _gate = crate::config::lock_permissions_mode_for_test();
536 crate::config::override_active_permissions_mode_for_test(
537 crate::config::PermissionsMode::Enforce,
538 );
539
540 use crate::models::{ApproverType, CorePolicy, GovernanceLevel, GovernancePolicy};
541 let mut env = TestEnv::fresh();
542 let db = env.db_path.clone();
543 let id = seed_memory(&db, "gov-ns", "tt", "cc");
545 let policy = GovernancePolicy {
547 core: CorePolicy {
548 write: GovernanceLevel::Any,
549 promote: GovernanceLevel::Any,
550 delete: GovernanceLevel::Approve,
551 approver: ApproverType::Human,
552 inherit: true,
553 max_reflection_depth: None,
554 },
555 ..Default::default()
556 };
557 let conn = db::open(&db).unwrap();
558 let now = chrono::Utc::now().to_rfc3339();
559 let mut metadata = models::default_metadata();
560 if let Some(obj) = metadata.as_object_mut() {
561 obj.insert(
562 "agent_id".to_string(),
563 serde_json::Value::String("alice".to_string()),
564 );
565 obj.insert(
566 "governance".to_string(),
567 serde_json::to_value(&policy).unwrap(),
568 );
569 }
570 let standard = models::Memory {
571 id: uuid::Uuid::new_v4().to_string(),
572 tier: Tier::Long,
573 namespace: "_standards-gov-ns".to_string(),
574 title: "standard for gov-ns".to_string(),
575 content: "policy".to_string(),
576 tags: vec![],
577 priority: 9,
578 confidence: 1.0,
579 source: "test".to_string(),
580 access_count: 0,
581 created_at: now.clone(),
582 updated_at: now,
583 last_accessed_at: None,
584 expires_at: None,
585 metadata,
586 reflection_depth: 0,
587 memory_kind: crate::models::MemoryKind::Observation,
588 entity_id: None,
589 persona_version: None,
590 citations: Vec::new(),
591 source_uri: None,
592 source_span: None,
593 confidence_source: crate::models::ConfidenceSource::CallerProvided,
594 confidence_signals: None,
595 confidence_decayed_at: None,
596 version: 1,
597 };
598 let standard_id = db::insert(&conn, &standard).unwrap();
599 db::set_namespace_standard(&conn, "gov-ns", &standard_id, None).unwrap();
600 drop(conn);
601
602 {
603 let mut out = env.output();
604 cmd_delete(
605 &db,
606 &DeleteArgs { id: id.clone() },
607 true,
608 Some("bob"),
609 &mut out,
610 )
611 .unwrap();
612 }
613 let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
614 assert_eq!(v["status"].as_str().unwrap(), "pending");
615 assert_eq!(v["action"].as_str().unwrap(), "delete");
616 let conn = db::open(&db).unwrap();
618 assert!(db::get(&conn, &id).unwrap().is_some());
619 }
620}