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}
33
34/// `update` handler.
35pub fn run(
36    db_path: &Path,
37    args: &UpdateArgs,
38    json_out: bool,
39    out: &mut CliOutput<'_>,
40) -> Result<()> {
41    use crate::models::Tier;
42    validate::validate_id(&args.id)?;
43    let conn = db::open(db_path)?;
44    let resolved_id = if db::get(&conn, &args.id)?.is_some() {
45        args.id.clone()
46    } else if let Some(mem) = db::get_by_prefix(&conn, &args.id)? {
47        mem.id
48    } else {
49        writeln!(out.stderr, "not found: {}", args.id)?;
50        std::process::exit(1);
51    };
52    let tier = args.tier.as_deref().and_then(Tier::from_str);
53    let tags: Option<Vec<String>> = args.tags.as_ref().map(|t| {
54        t.split(',')
55            .map(|s| s.trim().to_string())
56            .filter(|s| !s.is_empty())
57            .collect()
58    });
59    if let Some(ref t) = args.title {
60        validate::validate_title(t)?;
61    }
62    if let Some(ref c) = args.content {
63        validate::validate_content(c)?;
64    }
65    if let Some(ref ns) = args.namespace {
66        validate::validate_namespace(ns)?;
67    }
68    if let Some(ref tags) = tags {
69        validate::validate_tags(tags)?;
70    }
71    if let Some(p) = args.priority {
72        validate::validate_priority(p)?;
73    }
74    if let Some(c) = args.confidence {
75        validate::validate_confidence(c)?;
76    }
77    if let Some(ref ts) = args.expires_at
78        && !ts.is_empty()
79    {
80        validate::validate_expires_at_format(ts)?;
81    }
82    let (found, _content_changed) = db::update(
83        &conn,
84        &resolved_id,
85        args.title.as_deref(),
86        args.content.as_deref(),
87        tier.as_ref(),
88        args.namespace.as_deref(),
89        tags.as_ref(),
90        args.priority,
91        args.confidence,
92        args.expires_at.as_deref(),
93        None,
94    )?;
95    if !found {
96        writeln!(out.stderr, "not found: {}", args.id)?;
97        std::process::exit(1);
98    }
99    if let Some(mem) = db::get(&conn, &resolved_id)? {
100        // PR-5 (issue #487): security audit trail. No-op when disabled.
101        crate::audit::emit(crate::audit::EventBuilder::new(
102            crate::audit::AuditAction::Update,
103            crate::audit::actor(
104                mem.metadata
105                    .get("agent_id")
106                    .and_then(|v| v.as_str())
107                    .unwrap_or_default(),
108                "default_fallback",
109                None,
110            ),
111            crate::audit::target_memory(
112                mem.id.clone(),
113                mem.namespace.clone(),
114                Some(mem.title.clone()),
115                Some(mem.tier.to_string()),
116                None,
117            ),
118        ));
119        if json_out {
120            writeln!(out.stdout, "{}", serde_json::to_string(&mem)?)?;
121        } else {
122            writeln!(out.stdout, "updated: {} [{}]", mem.id, mem.title)?;
123        }
124    }
125    Ok(())
126}
127
128#[cfg(test)]
129mod tests {
130    use super::*;
131    use crate::cli::test_utils::{TestEnv, seed_memory};
132
133    fn empty_args(id: &str) -> UpdateArgs {
134        UpdateArgs {
135            id: id.to_string(),
136            title: None,
137            content: None,
138            tier: None,
139            namespace: None,
140            tags: None,
141            priority: None,
142            confidence: None,
143            expires_at: None,
144        }
145    }
146
147    #[test]
148    fn test_update_happy_path() {
149        let mut env = TestEnv::fresh();
150        let db = env.db_path.clone();
151        let id = seed_memory(&db, "ns", "old-title", "old content");
152        let mut args = empty_args(&id);
153        args.title = Some("new-title".to_string());
154        args.content = Some("new content".to_string());
155        {
156            let mut out = env.output();
157            run(&db, &args, false, &mut out).unwrap();
158        }
159        assert!(env.stdout_str().contains("updated:"));
160        assert!(env.stdout_str().contains("new-title"));
161    }
162
163    #[test]
164    fn test_update_by_prefix_id() {
165        let mut env = TestEnv::fresh();
166        let db = env.db_path.clone();
167        let id = seed_memory(&db, "ns", "title-a", "content-a");
168        // Use an 8-char prefix (UUIDs are 36 chars).
169        let prefix = &id[..8];
170        let mut args = empty_args(prefix);
171        args.title = Some("renamed".to_string());
172        {
173            let mut out = env.output();
174            run(&db, &args, false, &mut out).unwrap();
175        }
176        assert!(env.stdout_str().contains("renamed"));
177    }
178
179    // Skip nonexistent-id-exits-nonzero test directly: process::exit
180    // tears down the test runner. Exit-path coverage handled in the
181    // integration suite that spawns the binary.
182
183    #[test]
184    fn test_update_partial_only_title() {
185        let mut env = TestEnv::fresh();
186        let db = env.db_path.clone();
187        let id = seed_memory(&db, "ns", "orig-title", "orig content");
188        let mut args = empty_args(&id);
189        args.title = Some("title-only-change".to_string());
190        {
191            let mut out = env.output();
192            run(&db, &args, true, &mut out).unwrap();
193        }
194        let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
195        assert_eq!(v["title"].as_str().unwrap(), "title-only-change");
196        assert_eq!(v["content"].as_str().unwrap(), "orig content");
197    }
198
199    #[test]
200    fn test_update_partial_only_content() {
201        let mut env = TestEnv::fresh();
202        let db = env.db_path.clone();
203        let id = seed_memory(&db, "ns", "kept-title", "old-content");
204        let mut args = empty_args(&id);
205        args.content = Some("new content body".to_string());
206        {
207            let mut out = env.output();
208            run(&db, &args, true, &mut out).unwrap();
209        }
210        let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
211        assert_eq!(v["title"].as_str().unwrap(), "kept-title");
212        assert_eq!(v["content"].as_str().unwrap(), "new content body");
213    }
214
215    #[test]
216    fn test_update_clear_expires_at_with_empty_string() {
217        let mut env = TestEnv::fresh();
218        let db = env.db_path.clone();
219        let id = seed_memory(&db, "ns", "tt", "cc");
220        let mut args = empty_args(&id);
221        args.expires_at = Some(String::new());
222        {
223            let mut out = env.output();
224            // Empty-string skips the format-validate branch and is
225            // forwarded as a clear-expiry directive to db::update.
226            run(&db, &args, false, &mut out).unwrap();
227        }
228        assert!(env.stdout_str().contains("updated:"));
229    }
230
231    #[test]
232    fn test_update_invalid_priority_validation_error() {
233        let mut env = TestEnv::fresh();
234        let db = env.db_path.clone();
235        let id = seed_memory(&db, "ns", "tt", "cc");
236        let mut args = empty_args(&id);
237        args.priority = Some(99);
238        let mut out = env.output();
239        let res = run(&db, &args, false, &mut out);
240        assert!(res.is_err());
241    }
242}