1use 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 #[arg(long)]
31 pub expires_at: Option<String>,
32 #[arg(long)]
36 pub metadata: Option<String>,
37 #[arg(long)]
41 pub source_uri: Option<String>,
42 #[arg(long)]
47 pub expected_version: Option<i64>,
48}
49
50pub 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 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 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 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 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 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 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 #[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 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 #[test]
302 fn test_update_invalid_namespace_validation_error() {
303 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 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 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 let _ = res;
331 }
332
333 #[test]
334 fn test_update_valid_tags_split_and_pass_through() {
335 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 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); 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 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 #[test]
421 fn test_update_metadata_and_source_uri_valid_roundtrip() {
422 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 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 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 let mut env = TestEnv::fresh();
510 let db = env.db_path.clone();
511 let id = seed_memory(&db, "ns", "tt", "cc"); 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}