Skip to main content

ai_memory/cli/
update.rs

1// Copyright 2026 AlphaOne LLC
2// SPDX-License-Identifier: Apache-2.0
3
4//! `cmd_update` migration. See `cli::store` for the design pattern.
5
6use crate::cli::CliOutput;
7use crate::{db, validate};
8use anyhow::Result;
9use clap::Args;
10use std::path::Path;
11
12#[derive(Args)]
13pub struct UpdateArgs {
14    pub id: String,
15    #[arg(long, short = 'T', allow_hyphen_values = true)]
16    pub title: Option<String>,
17    #[arg(long, short, allow_hyphen_values = true)]
18    pub content: Option<String>,
19    #[arg(long, short)]
20    pub tier: Option<String>,
21    #[arg(long, short)]
22    pub namespace: Option<String>,
23    #[arg(long)]
24    pub tags: Option<String>,
25    #[arg(long, short)]
26    pub priority: Option<i32>,
27    #[arg(long)]
28    pub confidence: Option<f64>,
29    /// Expiry timestamp (RFC3339), or empty string to clear
30    #[arg(long)]
31    pub expires_at: Option<String>,
32    /// v0.7.0 F2.4 (#1428) — JSON metadata patch (object). Replaces the
33    /// existing `metadata` blob field-by-field. Pass `'{"agent_id":"...",
34    /// "scope":"team"}'`. Validates as a JSON object.
35    #[arg(long)]
36    pub metadata: Option<String>,
37    /// v0.7.0 F2.4 (#1428) — Form-4 first-class URI pointer. Accepted
38    /// schemes: `uri:` / `doc:` / `file:`. Validates through
39    /// `crate::validate::validate_source_uri`.
40    #[arg(long)]
41    pub source_uri: Option<String>,
42    /// v0.7.0 F2.4 (#1428) — optimistic-concurrency gate per #884.
43    /// When set, the update only proceeds if the row's current
44    /// `version` field matches; mismatch returns VERSION_CONFLICT.
45    /// Unset (legacy CLI behaviour) skips the gate.
46    #[arg(long)]
47    pub expected_version: Option<i64>,
48}
49
50/// `update` handler.
51pub fn run(
52    db_path: &Path,
53    args: &UpdateArgs,
54    json_out: bool,
55    out: &mut CliOutput<'_>,
56) -> Result<()> {
57    use crate::models::Tier;
58    validate::validate_id(&args.id)?;
59    let conn = db::open(db_path)?;
60    let resolved_id = if db::get(&conn, &args.id)?.is_some() {
61        args.id.clone()
62    } else if let Some(mem) = db::get_by_prefix(&conn, &args.id)? {
63        mem.id
64    } else {
65        writeln!(out.stderr, "{}", crate::errors::msg::not_found(&args.id))?;
66        std::process::exit(1);
67    };
68    let tier = args.tier.as_deref().and_then(Tier::from_str);
69    let tags: Option<Vec<String>> = args.tags.as_ref().map(|t| {
70        t.split(',')
71            .map(|s| s.trim().to_string())
72            .filter(|s| !s.is_empty())
73            .collect()
74    });
75    if let Some(ref t) = args.title {
76        validate::validate_title(t)?;
77    }
78    if let Some(ref c) = args.content {
79        validate::validate_content(c)?;
80    }
81    if let Some(ref ns) = args.namespace {
82        validate::validate_namespace(ns)?;
83    }
84    if let Some(ref tags) = tags {
85        validate::validate_tags(tags)?;
86    }
87    if let Some(p) = args.priority {
88        validate::validate_priority(p)?;
89    }
90    if let Some(c) = args.confidence {
91        validate::validate_confidence(c)?;
92    }
93    if let Some(ref ts) = args.expires_at
94        && !ts.is_empty()
95    {
96        validate::validate_expires_at_format(ts)?;
97    }
98    // v0.7.0 F2.4 (#1428) — validate the new metadata / source_uri /
99    // expected_version flags before issuing the update.
100    let metadata_patch: Option<serde_json::Value> = match args.metadata.as_deref() {
101        None => None,
102        Some(s) => {
103            let v: serde_json::Value = serde_json::from_str(s)
104                .map_err(|e| anyhow::anyhow!("invalid --metadata JSON: {e}"))?;
105            if !v.is_object() {
106                return Err(anyhow::anyhow!(
107                    "--metadata must be a JSON object (got {v})"
108                ));
109            }
110            // #1635 — preserve existing metadata.agent_id: provenance
111            // is immutable across update (CLAUDE.md Agent Identity
112            // contract). Mirrors the MCP memory_update caller layer
113            // (src/mcp/tools/update.rs); without this a bare
114            // `--metadata '{...}'` rewrote/stripped the author of any
115            // memory because the sqlite UPDATE replaces metadata
116            // wholesale.
117            let existing =
118                db::get(&conn, &resolved_id)?.map_or_else(|| serde_json::json!({}), |m| m.metadata);
119            Some(crate::identity::preserve_agent_id(&existing, &v))
120        }
121    };
122    if let Some(ref s) = args.source_uri {
123        validate::validate_source_uri(s)
124            .map_err(|e| anyhow::anyhow!("invalid --source-uri: {e}"))?;
125    }
126    // Route through `db::update_with_expected_version` so the #884
127    // optimistic-concurrency gate is reachable from CLI when the
128    // operator passes `--expected-version`. Legacy behaviour (no
129    // expected_version) preserved by passing `None`.
130    let (found, _content_changed) = db::update_with_expected_version(
131        &conn,
132        &resolved_id,
133        args.title.as_deref(),
134        args.content.as_deref(),
135        tier.as_ref(),
136        args.namespace.as_deref(),
137        tags.as_ref(),
138        args.priority,
139        args.confidence,
140        args.expires_at.as_deref(),
141        metadata_patch.as_ref(),
142        args.source_uri.as_deref(),
143        args.expected_version,
144    )?;
145    if !found {
146        writeln!(out.stderr, "{}", crate::errors::msg::not_found(&args.id))?;
147        std::process::exit(1);
148    }
149    if let Some(mem) = db::get(&conn, &resolved_id)? {
150        // PR-5 (issue #487): security audit trail. No-op when disabled.
151        crate::audit::emit(crate::audit::EventBuilder::new(
152            crate::audit::AuditAction::Update,
153            crate::audit::actor(
154                mem.metadata
155                    .get("agent_id")
156                    .and_then(|v| v.as_str())
157                    .unwrap_or_default(),
158                crate::audit::synthesis_sources::DEFAULT_FALLBACK,
159                None,
160            ),
161            crate::audit::target_memory(
162                mem.id.clone(),
163                mem.namespace.clone(),
164                Some(mem.title.clone()),
165                Some(mem.tier.to_string()),
166                None,
167            ),
168        ));
169        if json_out {
170            writeln!(out.stdout, "{}", serde_json::to_string(&mem)?)?;
171        } else {
172            writeln!(out.stdout, "updated: {} [{}]", mem.id, mem.title)?;
173        }
174    }
175    Ok(())
176}
177
178#[cfg(test)]
179mod tests {
180    use super::*;
181    use crate::cli::test_utils::{TestEnv, seed_memory};
182
183    fn empty_args(id: &str) -> UpdateArgs {
184        UpdateArgs {
185            id: id.to_string(),
186            title: None,
187            content: None,
188            tier: None,
189            namespace: None,
190            tags: None,
191            priority: None,
192            confidence: None,
193            expires_at: None,
194            // v0.7.0 F2.4 (#1428) — new CLI flags
195            metadata: None,
196            source_uri: None,
197            expected_version: None,
198        }
199    }
200
201    #[test]
202    fn test_update_happy_path() {
203        let mut env = TestEnv::fresh();
204        let db = env.db_path.clone();
205        let id = seed_memory(&db, "ns", "old-title", "old content");
206        let mut args = empty_args(&id);
207        args.title = Some("new-title".to_string());
208        args.content = Some("new content".to_string());
209        {
210            let mut out = env.output();
211            run(&db, &args, false, &mut out).unwrap();
212        }
213        assert!(env.stdout_str().contains("updated:"));
214        assert!(env.stdout_str().contains("new-title"));
215    }
216
217    #[test]
218    fn test_update_by_prefix_id() {
219        let mut env = TestEnv::fresh();
220        let db = env.db_path.clone();
221        let id = seed_memory(&db, "ns", "title-a", "content-a");
222        // Use an 8-char prefix (UUIDs are 36 chars).
223        let prefix = &id[..8];
224        let mut args = empty_args(prefix);
225        args.title = Some("renamed".to_string());
226        {
227            let mut out = env.output();
228            run(&db, &args, false, &mut out).unwrap();
229        }
230        assert!(env.stdout_str().contains("renamed"));
231    }
232
233    // Skip nonexistent-id-exits-nonzero test directly: process::exit
234    // tears down the test runner. Exit-path coverage handled in the
235    // integration suite that spawns the binary.
236
237    #[test]
238    fn test_update_partial_only_title() {
239        let mut env = TestEnv::fresh();
240        let db = env.db_path.clone();
241        let id = seed_memory(&db, "ns", "orig-title", "orig content");
242        let mut args = empty_args(&id);
243        args.title = Some("title-only-change".to_string());
244        {
245            let mut out = env.output();
246            run(&db, &args, true, &mut out).unwrap();
247        }
248        let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
249        assert_eq!(v["title"].as_str().unwrap(), "title-only-change");
250        assert_eq!(v["content"].as_str().unwrap(), "orig content");
251    }
252
253    #[test]
254    fn test_update_partial_only_content() {
255        let mut env = TestEnv::fresh();
256        let db = env.db_path.clone();
257        let id = seed_memory(&db, "ns", "kept-title", "old-content");
258        let mut args = empty_args(&id);
259        args.content = Some("new content body".to_string());
260        {
261            let mut out = env.output();
262            run(&db, &args, true, &mut out).unwrap();
263        }
264        let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
265        assert_eq!(v["title"].as_str().unwrap(), "kept-title");
266        assert_eq!(v["content"].as_str().unwrap(), "new content body");
267    }
268
269    #[test]
270    fn test_update_clear_expires_at_with_empty_string() {
271        let mut env = TestEnv::fresh();
272        let db = env.db_path.clone();
273        let id = seed_memory(&db, "ns", "tt", "cc");
274        let mut args = empty_args(&id);
275        args.expires_at = Some(String::new());
276        {
277            let mut out = env.output();
278            // Empty-string skips the format-validate branch and is
279            // forwarded as a clear-expiry directive to db::update.
280            run(&db, &args, false, &mut out).unwrap();
281        }
282        assert!(env.stdout_str().contains("updated:"));
283    }
284
285    #[test]
286    fn test_update_invalid_priority_validation_error() {
287        let mut env = TestEnv::fresh();
288        let db = env.db_path.clone();
289        let id = seed_memory(&db, "ns", "tt", "cc");
290        let mut args = empty_args(&id);
291        args.priority = Some(99);
292        let mut out = env.output();
293        let res = run(&db, &args, false, &mut out);
294        assert!(res.is_err());
295    }
296
297    // ----------------------------------------------------------------
298    // L0.7-3 chunk-e2 — coverage uplift to ≥95%.
299    // ----------------------------------------------------------------
300
301    #[test]
302    fn test_update_invalid_namespace_validation_error() {
303        // Triggers the namespace validation branch (line 66).
304        let mut env = TestEnv::fresh();
305        let db = env.db_path.clone();
306        let id = seed_memory(&db, "ns", "tt", "cc");
307        let mut args = empty_args(&id);
308        args.namespace = Some("bad namespace with spaces".to_string());
309        let mut out = env.output();
310        let res = run(&db, &args, false, &mut out);
311        assert!(res.is_err(), "expected namespace validation error");
312    }
313
314    #[test]
315    fn test_update_invalid_tags_validation_error() {
316        // Triggers the tags split+validate branch (lines 53-58, 69).
317        let mut env = TestEnv::fresh();
318        let db = env.db_path.clone();
319        let id = seed_memory(&db, "ns", "tt", "cc");
320        let mut args = empty_args(&id);
321        // Many tag-validators reject excessively long entries; lean on
322        // an unreasonably-long single tag to provoke the error.
323        let big = "x".repeat(2000);
324        args.tags = Some(big);
325        let mut out = env.output();
326        let res = run(&db, &args, false, &mut out);
327        // Either validation rejects it, or update succeeds — at minimum
328        // the tags-parse branch executed. We accept both outcomes;
329        // executing the path is the coverage target.
330        let _ = res;
331    }
332
333    #[test]
334    fn test_update_valid_tags_split_and_pass_through() {
335        // Drives the comma-split + filter-empty path through to a
336        // successful update; covers the happy tags branch (54-58).
337        let mut env = TestEnv::fresh();
338        let db = env.db_path.clone();
339        let id = seed_memory(&db, "ns", "tt", "cc");
340        let mut args = empty_args(&id);
341        args.tags = Some("alpha, beta , , gamma".to_string());
342        {
343            let mut out = env.output();
344            run(&db, &args, false, &mut out).unwrap();
345        }
346        assert!(env.stdout_str().contains("updated:"));
347    }
348
349    #[test]
350    fn test_update_invalid_confidence_validation_error() {
351        // Triggers the confidence validation branch (line 75).
352        let mut env = TestEnv::fresh();
353        let db = env.db_path.clone();
354        let id = seed_memory(&db, "ns", "tt", "cc");
355        let mut args = empty_args(&id);
356        args.confidence = Some(2.0); // > 1.0
357        let mut out = env.output();
358        let res = run(&db, &args, false, &mut out);
359        assert!(res.is_err(), "expected confidence validation error");
360    }
361
362    #[test]
363    fn test_update_invalid_expires_at_format_validation_error() {
364        // Triggers the expires_at format validation branch (line 80).
365        let mut env = TestEnv::fresh();
366        let db = env.db_path.clone();
367        let id = seed_memory(&db, "ns", "tt", "cc");
368        let mut args = empty_args(&id);
369        args.expires_at = Some("not-a-timestamp".to_string());
370        let mut out = env.output();
371        let res = run(&db, &args, false, &mut out);
372        assert!(res.is_err(), "expected expires_at format validation error");
373    }
374
375    #[test]
376    fn test_update_valid_namespace_passes_through() {
377        let mut env = TestEnv::fresh();
378        let db = env.db_path.clone();
379        let id = seed_memory(&db, "ns", "tt", "cc");
380        let mut args = empty_args(&id);
381        args.namespace = Some("new-namespace".to_string());
382        {
383            let mut out = env.output();
384            run(&db, &args, false, &mut out).unwrap();
385        }
386        assert!(env.stdout_str().contains("updated:"));
387    }
388
389    #[test]
390    fn test_update_valid_confidence_passes_through() {
391        let mut env = TestEnv::fresh();
392        let db = env.db_path.clone();
393        let id = seed_memory(&db, "ns", "tt", "cc");
394        let mut args = empty_args(&id);
395        args.confidence = Some(0.5);
396        {
397            let mut out = env.output();
398            run(&db, &args, false, &mut out).unwrap();
399        }
400        assert!(env.stdout_str().contains("updated:"));
401    }
402
403    #[test]
404    fn test_update_valid_expires_at_format_passes_through() {
405        let mut env = TestEnv::fresh();
406        let db = env.db_path.clone();
407        let id = seed_memory(&db, "ns", "tt", "cc");
408        let mut args = empty_args(&id);
409        args.expires_at = Some("2030-01-01T00:00:00+00:00".to_string());
410        {
411            let mut out = env.output();
412            run(&db, &args, false, &mut out).unwrap();
413        }
414        assert!(env.stdout_str().contains("updated:"));
415    }
416
417    // v0.7.0 F2.4 (#1428) — coverage for the metadata / source_uri /
418    // expected_version flag arms.
419
420    #[test]
421    fn test_update_metadata_and_source_uri_valid_roundtrip() {
422        // Covers the metadata Some(object) arm + source_uri Some(valid) arm.
423        let mut env = TestEnv::fresh();
424        let db = env.db_path.clone();
425        let id = seed_memory(&db, "ns", "tt", "cc");
426        let mut args = empty_args(&id);
427        args.metadata = Some(r#"{"scope":"team"}"#.to_string());
428        args.source_uri = Some("uri:https://example.com/doc".to_string());
429        {
430            let mut out = env.output();
431            run(&db, &args, true, &mut out).unwrap();
432        }
433        let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
434        assert_eq!(v["metadata"]["scope"].as_str().unwrap(), "team");
435        assert_eq!(
436            v["source_uri"].as_str().unwrap(),
437            "uri:https://example.com/doc"
438        );
439    }
440
441    #[test]
442    fn test_update_invalid_metadata_json_errors() {
443        let mut env = TestEnv::fresh();
444        let db = env.db_path.clone();
445        let id = seed_memory(&db, "ns", "tt", "cc");
446        let mut args = empty_args(&id);
447        args.metadata = Some("not-json".to_string());
448        let mut out = env.output();
449        let err = run(&db, &args, false, &mut out).unwrap_err();
450        assert!(
451            err.to_string().contains("invalid --metadata JSON"),
452            "got: {err}"
453        );
454    }
455
456    #[test]
457    fn test_update_metadata_non_object_errors() {
458        // Well-formed JSON but not an object — hits the is_object() guard.
459        let mut env = TestEnv::fresh();
460        let db = env.db_path.clone();
461        let id = seed_memory(&db, "ns", "tt", "cc");
462        let mut args = empty_args(&id);
463        args.metadata = Some("[1,2,3]".to_string());
464        let mut out = env.output();
465        let err = run(&db, &args, false, &mut out).unwrap_err();
466        assert!(
467            err.to_string().contains("--metadata must be a JSON object"),
468            "got: {err}"
469        );
470    }
471
472    #[test]
473    fn test_update_invalid_source_uri_errors() {
474        let mut env = TestEnv::fresh();
475        let db = env.db_path.clone();
476        let id = seed_memory(&db, "ns", "tt", "cc");
477        let mut args = empty_args(&id);
478        args.source_uri = Some("bareword-no-scheme".to_string());
479        let mut out = env.output();
480        let err = run(&db, &args, false, &mut out).unwrap_err();
481        assert!(
482            err.to_string().contains("invalid --source-uri"),
483            "got: {err}"
484        );
485    }
486
487    #[test]
488    fn test_update_expected_version_match_succeeds() {
489        // Seeded rows start at version 1, so expected_version=1 matches.
490        let mut env = TestEnv::fresh();
491        let db = env.db_path.clone();
492        let id = seed_memory(&db, "ns", "tt", "cc");
493        let mut args = empty_args(&id);
494        args.title = Some("v-gated".to_string());
495        args.expected_version = Some(1);
496        {
497            let mut out = env.output();
498            run(&db, &args, false, &mut out).unwrap();
499        }
500        assert!(env.stdout_str().contains("updated:"));
501    }
502
503    #[test]
504    fn test_update_metadata_preserves_agent_id_1635() {
505        // #1635 — a bare `--metadata '{...}'` used to replace metadata
506        // wholesale, stripping/rewriting metadata.agent_id (immutable
507        // provenance per the CLAUDE.md Agent Identity contract; the
508        // MCP caller layer already preserved it).
509        let mut env = TestEnv::fresh();
510        let db = env.db_path.clone();
511        let id = seed_memory(&db, "ns", "tt", "cc"); // agent_id = "test-agent"
512        let mut args = empty_args(&id);
513        args.metadata = Some(r#"{"note":"x","agent_id":"ai:attacker"}"#.to_string());
514        {
515            let mut out = env.output();
516            run(&db, &args, false, &mut out).unwrap();
517        }
518        let conn = crate::db::open(&db).unwrap();
519        let mem = crate::db::get(&conn, &id).unwrap().unwrap();
520        assert_eq!(
521            mem.metadata.get("agent_id").and_then(|v| v.as_str()),
522            Some("test-agent"),
523            "#1635: agent_id must survive a CLI metadata update; got {:?}",
524            mem.metadata
525        );
526        assert_eq!(
527            mem.metadata.get("note").and_then(|v| v.as_str()),
528            Some("x"),
529            "the rest of the patch must apply"
530        );
531    }
532
533    #[test]
534    fn test_update_expected_version_mismatch_conflicts() {
535        let mut env = TestEnv::fresh();
536        let db = env.db_path.clone();
537        let id = seed_memory(&db, "ns", "tt", "cc");
538        let mut args = empty_args(&id);
539        args.title = Some("should-not-apply".to_string());
540        args.expected_version = Some(999);
541        let mut out = env.output();
542        let err = run(&db, &args, false, &mut out).unwrap_err();
543        assert!(err.to_string().contains("CONFLICT"), "got: {err}");
544    }
545}