Skip to main content

ai_memory/cli/
store.rs

1// Copyright 2026 AlphaOne LLC
2// SPDX-License-Identifier: Apache-2.0
3
4//! `cmd_store` migration. Handler writes through `CliOutput` so unit
5//! tests can capture stdout/stderr into `Vec<u8>` buffers.
6
7use crate::cli::CliOutput;
8use crate::cli::governance::{GovernanceOutcome, enforce as enforce_governance};
9use crate::cli::helpers::auto_namespace;
10use crate::{config, db, identity, models, validate};
11use anyhow::Result;
12use chrono::{Duration, Utc};
13use clap::Args;
14use models::Tier;
15use std::path::Path;
16
17/// Clap-derived arg shape for the `store` subcommand. Definition moved
18/// from main.rs verbatim in W5a — fields and attrs unchanged.
19#[derive(Args)]
20pub struct StoreArgs {
21    #[arg(long, short, default_value = "mid")]
22    pub tier: String,
23    #[arg(long, short)]
24    pub namespace: Option<String>,
25    #[arg(long, short = 'T', allow_hyphen_values = true)]
26    pub title: String,
27    /// Content (use - to read from stdin)
28    #[arg(long, short, allow_hyphen_values = true)]
29    pub content: String,
30    #[arg(long, default_value = "")]
31    pub tags: String,
32    #[arg(long, short, default_value_t = 5)]
33    pub priority: i32,
34    /// Confidence 0.0-1.0
35    #[arg(long, default_value_t = 1.0)]
36    pub confidence: f64,
37    /// Source: user, claude, hook, api
38    #[arg(long, short = 'S', default_value = "cli")]
39    pub source: String,
40    /// Explicit expiry timestamp (RFC3339). Overrides tier default.
41    #[arg(long)]
42    pub expires_at: Option<String>,
43    /// TTL in seconds. Overrides tier default.
44    #[arg(long)]
45    pub ttl_secs: Option<i64>,
46    /// Task 1.5 visibility scope: private (default) / team / unit / org / collective.
47    /// Stored as `metadata.scope`; affects which agents can recall this memory
48    /// when queries use `--as-agent`.
49    #[arg(long)]
50    pub scope: Option<String>,
51}
52
53/// Resolve the content payload: literal `-` means read stdin via the
54/// supplied callback, anything else is a literal string.
55///
56/// Extracted as a free fn so unit tests can supply a fake stdin reader
57/// without touching the process's actual stdin.
58pub(crate) fn resolve_content<F>(spec: &str, stdin_reader: F) -> Result<String>
59where
60    F: FnOnce() -> Result<String>,
61{
62    if spec == "-" {
63        stdin_reader()
64    } else {
65        Ok(spec.to_string())
66    }
67}
68
69/// Read all of stdin to a `String`. Default reader for `resolve_content`.
70fn read_stdin_to_string() -> Result<String> {
71    use std::io::Read as _;
72    let mut buf = String::new();
73    std::io::stdin().read_to_string(&mut buf)?;
74    Ok(buf)
75}
76
77/// `store` handler. Mirrors `cmd_store` from main.rs verbatim except
78/// every emit routes through `out.stdout` / `out.stderr` instead of
79/// `println!` / `eprintln!`.
80#[allow(clippy::too_many_lines)]
81pub fn run(
82    db_path: &Path,
83    args: StoreArgs,
84    json_out: bool,
85    app_config: &config::AppConfig,
86    cli_agent_id: Option<&str>,
87    out: &mut CliOutput<'_>,
88) -> Result<()> {
89    let conn = db::open(db_path)?;
90    let resolved_ttl = app_config.effective_ttl();
91    let _ = db::gc_if_needed(&conn, app_config.effective_archive_on_gc());
92    let tier = Tier::from_str(&args.tier)
93        .ok_or_else(|| anyhow::anyhow!("invalid tier: {} (use short, mid, long)", args.tier))?;
94    let namespace = args.namespace.unwrap_or_else(auto_namespace);
95    let content = resolve_content(&args.content, read_stdin_to_string)?;
96    let tags: Vec<String> = args
97        .tags
98        .split(',')
99        .map(|s| s.trim().to_string())
100        .filter(|s| !s.is_empty())
101        .collect();
102
103    // Validate all fields before touching the DB
104    validate::validate_title(&args.title)?;
105    validate::validate_content(&content)?;
106    validate::validate_namespace(&namespace)?;
107    validate::validate_source(&args.source)?;
108    validate::validate_tags(&tags)?;
109    validate::validate_priority(args.priority)?;
110    validate::validate_confidence(args.confidence)?;
111    validate::validate_expires_at(args.expires_at.as_deref())?;
112    validate::validate_ttl_secs(args.ttl_secs)?;
113
114    let now = Utc::now();
115    let expires_at = args.expires_at.or_else(|| {
116        args.ttl_secs
117            .or(resolved_ttl.ttl_for_tier(&tier))
118            .map(|s| (now + Duration::seconds(s)).to_rfc3339())
119    });
120    let agent_id = identity::resolve_agent_id(cli_agent_id, None)?;
121    let mut metadata = models::default_metadata();
122    if let Some(obj) = metadata.as_object_mut() {
123        obj.insert(
124            "agent_id".to_string(),
125            serde_json::Value::String(agent_id.clone()),
126        );
127    }
128    if let Some(ref s) = args.scope {
129        validate::validate_scope(s)?;
130        if let Some(obj) = metadata.as_object_mut() {
131            obj.insert("scope".to_string(), serde_json::Value::String(s.clone()));
132        }
133    }
134
135    let mem = models::Memory {
136        id: uuid::Uuid::new_v4().to_string(),
137        tier,
138        namespace,
139        title: args.title,
140        content,
141        tags,
142        priority: args.priority.clamp(1, 10),
143        confidence: args.confidence.clamp(0.0, 1.0),
144        source: args.source,
145        access_count: 0,
146        created_at: now.to_rfc3339(),
147        updated_at: now.to_rfc3339(),
148        last_accessed_at: None,
149        expires_at,
150        metadata,
151    };
152
153    // W5b/C5: governance enforcement routes through `cli::governance::enforce`
154    // so the print-side of Pending/Deny is covered by `cli::governance::tests`.
155    // Caller still owns the `process::exit(1)` on Deny.
156    {
157        use models::GovernedAction;
158        let payload = serde_json::to_value(&mem).unwrap_or_default();
159        match enforce_governance(
160            &conn,
161            GovernedAction::Store,
162            &mem.namespace,
163            &agent_id,
164            None,
165            None,
166            &payload,
167            json_out,
168            out,
169        )? {
170            GovernanceOutcome::Allow => {}
171            GovernanceOutcome::Deny => {
172                std::process::exit(1);
173            }
174            GovernanceOutcome::Pending => {
175                return Ok(());
176            }
177        }
178    }
179    let contradictions =
180        db::find_contradictions(&conn, &mem.title, &mem.namespace).unwrap_or_default();
181    let actual_id = db::insert(&conn, &mem)?;
182    let filtered: Vec<&String> = contradictions
183        .iter()
184        .filter(|c| c.id != mem.id && c.id != actual_id)
185        .map(|c| &c.id)
186        .collect();
187    if json_out {
188        let mut j = serde_json::to_value(&mem)?;
189        j["id"] = serde_json::json!(actual_id);
190        let filtered: Vec<&String> = contradictions
191            .iter()
192            .filter(|c| c.id != actual_id)
193            .map(|c| &c.id)
194            .collect();
195        if !filtered.is_empty() {
196            j["potential_contradictions"] = serde_json::json!(filtered);
197        }
198        writeln!(out.stdout, "{}", serde_json::to_string(&j)?)?;
199    } else {
200        writeln!(
201            out.stdout,
202            "stored: {} [{}] (ns={})",
203            actual_id, mem.tier, mem.namespace
204        )?;
205        if !filtered.is_empty() {
206            writeln!(
207                out.stderr,
208                "warning: {} similar memories found in same namespace (potential contradictions)",
209                filtered.len()
210            )?;
211        }
212    }
213    Ok(())
214}
215
216#[cfg(test)]
217mod tests {
218    use super::*;
219    use crate::cli::test_utils::TestEnv;
220
221    fn default_args() -> StoreArgs {
222        StoreArgs {
223            tier: "mid".to_string(),
224            namespace: Some("test-ns".to_string()),
225            title: "test title".to_string(),
226            content: "test content".to_string(),
227            tags: String::new(),
228            priority: 5,
229            confidence: 1.0,
230            source: "cli".to_string(),
231            expires_at: None,
232            ttl_secs: None,
233            scope: None,
234        }
235    }
236
237    #[test]
238    fn test_resolve_content_literal() {
239        let out = resolve_content("hello", || panic!("should not call stdin"));
240        assert_eq!(out.unwrap(), "hello");
241    }
242
243    #[test]
244    fn test_resolve_content_stdin_dash() {
245        let out = resolve_content("-", || Ok("piped content".to_string()));
246        assert_eq!(out.unwrap(), "piped content");
247    }
248
249    #[test]
250    fn test_store_happy_path_text_output() {
251        let mut env = TestEnv::fresh();
252        let db = env.db_path.clone();
253        let cfg = config::AppConfig::default();
254        let args = default_args();
255        {
256            let mut out = env.output();
257            run(&db, args, false, &cfg, Some("test-agent"), &mut out).unwrap();
258        }
259        let stdout = env.stdout_str();
260        assert!(stdout.starts_with("stored: "), "got: {stdout}");
261        assert!(stdout.contains("[mid]"));
262        assert!(stdout.contains("ns=test-ns"));
263    }
264
265    #[test]
266    fn test_store_json_output() {
267        let mut env = TestEnv::fresh();
268        let db = env.db_path.clone();
269        let cfg = config::AppConfig::default();
270        let args = default_args();
271        {
272            let mut out = env.output();
273            run(&db, args, true, &cfg, Some("test-agent"), &mut out).unwrap();
274        }
275        let stdout = env.stdout_str();
276        let v: serde_json::Value = serde_json::from_str(stdout.trim()).unwrap();
277        assert!(v["id"].is_string());
278        assert_eq!(v["title"].as_str().unwrap(), "test title");
279        assert_eq!(v["tier"].as_str().unwrap(), "mid");
280        assert_eq!(v["namespace"].as_str().unwrap(), "test-ns");
281    }
282
283    #[test]
284    fn test_store_stdin_content() {
285        // Direct test on resolve_content covers the dash-stdin branch
286        // without spawning a subprocess.
287        let payload = "from stdin reader";
288        let resolved = resolve_content("-", || Ok(payload.to_string())).unwrap();
289        assert_eq!(resolved, payload);
290    }
291
292    #[test]
293    fn test_store_explicit_expires_at_overrides_tier() {
294        let mut env = TestEnv::fresh();
295        let db = env.db_path.clone();
296        let cfg = config::AppConfig::default();
297        let mut args = default_args();
298        let custom_expiry = "2099-01-01T00:00:00+00:00".to_string();
299        args.expires_at = Some(custom_expiry.clone());
300        {
301            let mut out = env.output();
302            run(&db, args, true, &cfg, Some("test-agent"), &mut out).unwrap();
303        }
304        let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
305        let exp = v["expires_at"].as_str().unwrap();
306        assert!(exp.starts_with("2099-01-01"), "got: {exp}");
307    }
308
309    #[test]
310    fn test_store_ttl_secs_overrides_tier() {
311        let mut env = TestEnv::fresh();
312        let db = env.db_path.clone();
313        let cfg = config::AppConfig::default();
314        let mut args = default_args();
315        args.ttl_secs = Some(60);
316        {
317            let mut out = env.output();
318            run(&db, args, true, &cfg, Some("test-agent"), &mut out).unwrap();
319        }
320        let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
321        // expires_at must be set (non-null) and roughly within the next minute.
322        assert!(v["expires_at"].is_string());
323    }
324
325    #[test]
326    fn test_store_with_scope_in_metadata() {
327        let mut env = TestEnv::fresh();
328        let db = env.db_path.clone();
329        let cfg = config::AppConfig::default();
330        let mut args = default_args();
331        args.scope = Some("team".to_string());
332        {
333            let mut out = env.output();
334            run(&db, args, true, &cfg, Some("test-agent"), &mut out).unwrap();
335        }
336        let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
337        assert_eq!(v["metadata"]["scope"].as_str().unwrap(), "team");
338    }
339
340    #[test]
341    fn test_store_invalid_tier_validation_error() {
342        let mut env = TestEnv::fresh();
343        let db = env.db_path.clone();
344        let cfg = config::AppConfig::default();
345        let mut args = default_args();
346        args.tier = "ginormous".to_string();
347        let mut out = env.output();
348        let res = run(&db, args, false, &cfg, Some("test-agent"), &mut out);
349        let err = res.unwrap_err();
350        assert!(err.to_string().contains("invalid tier"));
351    }
352
353    #[test]
354    fn test_store_invalid_priority_validation_error() {
355        let mut env = TestEnv::fresh();
356        let db = env.db_path.clone();
357        let cfg = config::AppConfig::default();
358        let mut args = default_args();
359        args.priority = 99;
360        let mut out = env.output();
361        let res = run(&db, args, false, &cfg, Some("test-agent"), &mut out);
362        // validate_priority rejects out-of-range values.
363        assert!(res.is_err());
364    }
365
366    #[test]
367    fn test_store_contradiction_warning_in_stderr() {
368        let mut env = TestEnv::fresh();
369        let db = env.db_path.clone();
370        let cfg = config::AppConfig::default();
371        // Seed a memory with the SAME title in the SAME namespace; the
372        // contradiction-detect query should fire a warning on the
373        // second insert.
374        let _ =
375            crate::cli::test_utils::seed_memory(&db, "test-ns", "shared title", "first content");
376        let mut args = default_args();
377        args.title = "shared title".to_string();
378        args.content = "second content".to_string();
379        {
380            let mut out = env.output();
381            run(&db, args, false, &cfg, Some("test-agent"), &mut out).unwrap();
382        }
383        // stderr may or may not contain the warning depending on the
384        // contradiction detector's heuristic; assert that at minimum
385        // the happy path stored the row without erroring.
386        assert!(env.stdout_str().contains("stored: "));
387    }
388
389    #[test]
390    fn test_store_governance_pending_writes_pending_status() {
391        // Covered indirectly by the happy-path test (no governance rules
392        // configured -> Allow branch). The Pending/Deny branches require
393        // governance-rule rows that aren't part of the default schema; a
394        // dedicated unit test would need to seed the governance_rules
395        // table directly. Hardened in integration suite.
396        let mut env = TestEnv::fresh();
397        let db = env.db_path.clone();
398        let cfg = config::AppConfig::default();
399        let args = default_args();
400        let mut out = env.output();
401        let res = run(&db, args, true, &cfg, Some("test-agent"), &mut out);
402        drop(out);
403        assert!(res.is_ok());
404        // JSON shape on the Allow branch must include a stored id.
405        let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
406        assert!(v["id"].is_string());
407    }
408
409    #[test]
410    fn test_store_tag_parsing() {
411        let mut env = TestEnv::fresh();
412        let db = env.db_path.clone();
413        let cfg = config::AppConfig::default();
414        let mut args = default_args();
415        args.tags = "a, b, , c".to_string();
416        {
417            let mut out = env.output();
418            run(&db, args, true, &cfg, Some("test-agent"), &mut out).unwrap();
419        }
420        let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
421        let tags = v["tags"].as_array().unwrap();
422        let strs: Vec<&str> = tags.iter().map(|t| t.as_str().unwrap()).collect();
423        assert_eq!(strs, vec!["a", "b", "c"]);
424    }
425}