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, "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, "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    // ---------------- get ---------------------------------------------
232
233    #[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    // process::exit kills the test runner. Use a child-style sentinel
262    // by validating the id-format error path, which `cmd_get` raises
263    // before the not-found exit branch.
264    #[test]
265    fn test_get_invalid_id_validation_error() {
266        let mut env = TestEnv::fresh();
267        let db = env.db_path.clone();
268        // Malformed id with embedded null byte fails validate_id before
269        // the lookup, so we never hit process::exit.
270        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    // Non-existent id triggers process::exit; covered by integration
277    // suite that spawns the binary. In-process we can only assert the
278    // helper returned with the not-found stderr message before exiting,
279    // which is unreachable here.
280
281    #[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        // Pretty text branch prints "links:" + each pair.
297        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        // Pretty-printed body has 2-space indents.
326        assert!(stdout.contains("\"title\": \"tt\""), "got: {stdout}");
327        // No links section when there are no links.
328        assert!(!stdout.contains("links:"));
329    }
330
331    // ---------------- list --------------------------------------------
332
333    #[test]
334    fn test_list_empty_db() {
335        let mut env = TestEnv::fresh();
336        let db = env.db_path.clone();
337        // Materialize schema with a row, then forget it so the db has 0 rows.
338        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        // text branch writes the empty-state message to stderr.
350        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        // Promote one to long via direct update so we have a tier mix.
382        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    // ---------------- delete ------------------------------------------
463
464    #[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        // Seed a memory in 'gov-ns' first so resolve_id finds something.
517        let id = seed_memory(&db, "gov-ns", "tt", "cc");
518        // Now seed a governance policy that gates delete behind Approve.
519        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        // Memory must NOT be deleted on Pending.
574        let conn = db::open(&db).unwrap();
575        assert!(db::get(&conn, &id).unwrap().is_some());
576    }
577}