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