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        // 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("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    // ---------------- get ---------------------------------------------
248
249    #[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    // process::exit kills the test runner. Use a child-style sentinel
278    // by validating the id-format error path, which `cmd_get` raises
279    // before the not-found exit branch.
280    #[test]
281    fn test_get_invalid_id_validation_error() {
282        let mut env = TestEnv::fresh();
283        let db = env.db_path.clone();
284        // Malformed id with embedded null byte fails validate_id before
285        // the lookup, so we never hit process::exit.
286        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    // Non-existent id triggers process::exit; covered by integration
293    // suite that spawns the binary. In-process we can only assert the
294    // helper returned with the not-found stderr message before exiting,
295    // which is unreachable here.
296
297    #[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        // Pretty text branch prints "links:" + each pair.
313        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        // Pretty-printed body has 2-space indents.
342        assert!(stdout.contains("\"title\": \"tt\""), "got: {stdout}");
343        // No links section when there are no links.
344        assert!(!stdout.contains("links:"));
345    }
346
347    // ---------------- list --------------------------------------------
348
349    #[test]
350    fn test_list_empty_db() {
351        let mut env = TestEnv::fresh();
352        let db = env.db_path.clone();
353        // Materialize schema with a row, then forget it so the db has 0 rows.
354        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        // text branch writes the empty-state message to stderr.
366        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        // Promote one to long via direct update so we have a tier mix.
398        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    // ---------------- delete ------------------------------------------
479
480    #[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        // Seed a memory in 'gov-ns' first so resolve_id finds something.
533        let id = seed_memory(&db, "gov-ns", "tt", "cc");
534        // Now seed a governance policy that gates delete behind Approve.
535        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        // Memory must NOT be deleted on Pending.
591        let conn = db::open(&db).unwrap();
592        assert!(db::get(&conn, &id).unwrap().is_some());
593    }
594}