Skip to main content

ai_memory/cli/
crud.rs

1// Copyright 2026 AlphaOne LLC
2// SPDX-License-Identifier: Apache-2.0
3
4//! `cmd_get`, `cmd_list`, `cmd_delete` migrations. See `cli::store` for
5//! the design pattern.
6//!
7//! ## Public surface
8//!
9//! ```ignore
10//! pub fn cmd_get(db_path: &Path, args: &GetArgs, json_out: bool, out: &mut CliOutput<'_>) -> Result<()>;
11//! pub fn cmd_list(db_path: &Path, args: &ListArgs, json_out: bool, app_config: &config::AppConfig, out: &mut CliOutput<'_>) -> Result<()>;
12//! pub fn cmd_delete(db_path: &Path, args: &DeleteArgs, json_out: bool, cli_agent_id: Option<&str>, out: &mut CliOutput<'_>) -> Result<()>;
13//! ```
14
15use 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    /// Filter by `metadata.agent_id` (exact match)
46    #[arg(long)]
47    pub agent_id: Option<String>,
48}
49
50#[derive(Args)]
51pub struct DeleteArgs {
52    pub id: String,
53}
54
55/// `get` handler. Looks up by full id then prefix; prints memory + links.
56pub 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/// `list` handler.
93#[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
150/// `delete` handler.
151pub 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        // PR-5 (issue #487): security audit trail.
198        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    // ---------------- get ---------------------------------------------
250
251    #[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    // process::exit kills the test runner. Use a child-style sentinel
280    // by validating the id-format error path, which `cmd_get` raises
281    // before the not-found exit branch.
282    #[test]
283    fn test_get_invalid_id_validation_error() {
284        let mut env = TestEnv::fresh();
285        let db = env.db_path.clone();
286        // Malformed id with embedded null byte fails validate_id before
287        // the lookup, so we never hit process::exit.
288        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    // Non-existent id triggers process::exit; covered by integration
295    // suite that spawns the binary. In-process we can only assert the
296    // helper returned with the not-found stderr message before exiting,
297    // which is unreachable here.
298
299    #[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        // Pretty text branch prints "links:" + each pair.
315        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        // Pretty-printed body has 2-space indents.
344        assert!(stdout.contains("\"title\": \"tt\""), "got: {stdout}");
345        // No links section when there are no links.
346        assert!(!stdout.contains("links:"));
347    }
348
349    // ---------------- list --------------------------------------------
350
351    #[test]
352    fn test_list_empty_db() {
353        let mut env = TestEnv::fresh();
354        let db = env.db_path.clone();
355        // Materialize schema with a row, then forget it so the db has 0 rows.
356        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        // text branch writes the empty-state message to stderr.
368        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        // Promote one to long via direct update so we have a tier mix.
400        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    // ---------------- delete ------------------------------------------
481
482    #[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        // v0.7.0 K3 — pin Enforce so delete-Pending still drives the
532        // strict path (Advisory is the v0.7.0 default and would Allow
533        // the delete unconditionally). Holds the central gate-mode
534        // Mutex from `config::lock_permissions_mode_for_test`.
535        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        // Seed a memory in 'gov-ns' first so resolve_id finds something.
544        let id = seed_memory(&db, "gov-ns", "tt", "cc");
545        // Now seed a governance policy that gates delete behind Approve.
546        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        // Memory must NOT be deleted on Pending.
617        let conn = db::open(&db).unwrap();
618        assert!(db::get(&conn, &id).unwrap().is_some());
619    }
620}