use crate::cli::CliOutput;
use crate::{db, validate};
use anyhow::Result;
use clap::Args;
use std::path::Path;
#[derive(Args)]
pub struct UpdateArgs {
pub id: String,
#[arg(long, short = 'T', allow_hyphen_values = true)]
pub title: Option<String>,
#[arg(long, short, allow_hyphen_values = true)]
pub content: Option<String>,
#[arg(long, short)]
pub tier: Option<String>,
#[arg(long, short)]
pub namespace: Option<String>,
#[arg(long)]
pub tags: Option<String>,
#[arg(long, short)]
pub priority: Option<i32>,
#[arg(long)]
pub confidence: Option<f64>,
#[arg(long)]
pub expires_at: Option<String>,
#[arg(long)]
pub metadata: Option<String>,
#[arg(long)]
pub source_uri: Option<String>,
#[arg(long)]
pub expected_version: Option<i64>,
}
pub fn run(
db_path: &Path,
args: &UpdateArgs,
json_out: bool,
out: &mut CliOutput<'_>,
) -> Result<()> {
use crate::models::Tier;
validate::validate_id(&args.id)?;
let conn = db::open(db_path)?;
let resolved_id = if db::get(&conn, &args.id)?.is_some() {
args.id.clone()
} else if let Some(mem) = db::get_by_prefix(&conn, &args.id)? {
mem.id
} else {
writeln!(out.stderr, "{}", crate::errors::msg::not_found(&args.id))?;
std::process::exit(1);
};
let tier = args.tier.as_deref().and_then(Tier::from_str);
let tags: Option<Vec<String>> = args.tags.as_ref().map(|t| {
t.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect()
});
if let Some(ref t) = args.title {
validate::validate_title(t)?;
}
if let Some(ref c) = args.content {
validate::validate_content(c)?;
}
if let Some(ref ns) = args.namespace {
validate::validate_namespace(ns)?;
}
if let Some(ref tags) = tags {
validate::validate_tags(tags)?;
}
if let Some(p) = args.priority {
validate::validate_priority(p)?;
}
if let Some(c) = args.confidence {
validate::validate_confidence(c)?;
}
if let Some(ref ts) = args.expires_at
&& !ts.is_empty()
{
validate::validate_expires_at_format(ts)?;
}
let metadata_patch: Option<serde_json::Value> = match args.metadata.as_deref() {
None => None,
Some(s) => {
let v: serde_json::Value = serde_json::from_str(s)
.map_err(|e| anyhow::anyhow!("invalid --metadata JSON: {e}"))?;
if !v.is_object() {
return Err(anyhow::anyhow!(
"--metadata must be a JSON object (got {v})"
));
}
let existing =
db::get(&conn, &resolved_id)?.map_or_else(|| serde_json::json!({}), |m| m.metadata);
Some(crate::identity::preserve_agent_id(&existing, &v))
}
};
if let Some(ref s) = args.source_uri {
validate::validate_source_uri(s)
.map_err(|e| anyhow::anyhow!("invalid --source-uri: {e}"))?;
}
let (found, _content_changed) = db::update_with_expected_version(
&conn,
&resolved_id,
args.title.as_deref(),
args.content.as_deref(),
tier.as_ref(),
args.namespace.as_deref(),
tags.as_ref(),
args.priority,
args.confidence,
args.expires_at.as_deref(),
metadata_patch.as_ref(),
args.source_uri.as_deref(),
args.expected_version,
)?;
if !found {
writeln!(out.stderr, "{}", crate::errors::msg::not_found(&args.id))?;
std::process::exit(1);
}
if let Some(mem) = db::get(&conn, &resolved_id)? {
crate::audit::emit(crate::audit::EventBuilder::new(
crate::audit::AuditAction::Update,
crate::audit::actor(
mem.metadata
.get("agent_id")
.and_then(|v| v.as_str())
.unwrap_or_default(),
crate::audit::synthesis_sources::DEFAULT_FALLBACK,
None,
),
crate::audit::target_memory(
mem.id.clone(),
mem.namespace.clone(),
Some(mem.title.clone()),
Some(mem.tier.to_string()),
None,
),
));
if json_out {
writeln!(out.stdout, "{}", serde_json::to_string(&mem)?)?;
} else {
writeln!(out.stdout, "updated: {} [{}]", mem.id, mem.title)?;
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cli::test_utils::{TestEnv, seed_memory};
fn empty_args(id: &str) -> UpdateArgs {
UpdateArgs {
id: id.to_string(),
title: None,
content: None,
tier: None,
namespace: None,
tags: None,
priority: None,
confidence: None,
expires_at: None,
metadata: None,
source_uri: None,
expected_version: None,
}
}
#[test]
fn test_update_happy_path() {
let mut env = TestEnv::fresh();
let db = env.db_path.clone();
let id = seed_memory(&db, "ns", "old-title", "old content");
let mut args = empty_args(&id);
args.title = Some("new-title".to_string());
args.content = Some("new content".to_string());
{
let mut out = env.output();
run(&db, &args, false, &mut out).unwrap();
}
assert!(env.stdout_str().contains("updated:"));
assert!(env.stdout_str().contains("new-title"));
}
#[test]
fn test_update_by_prefix_id() {
let mut env = TestEnv::fresh();
let db = env.db_path.clone();
let id = seed_memory(&db, "ns", "title-a", "content-a");
let prefix = &id[..8];
let mut args = empty_args(prefix);
args.title = Some("renamed".to_string());
{
let mut out = env.output();
run(&db, &args, false, &mut out).unwrap();
}
assert!(env.stdout_str().contains("renamed"));
}
#[test]
fn test_update_partial_only_title() {
let mut env = TestEnv::fresh();
let db = env.db_path.clone();
let id = seed_memory(&db, "ns", "orig-title", "orig content");
let mut args = empty_args(&id);
args.title = Some("title-only-change".to_string());
{
let mut out = env.output();
run(&db, &args, true, &mut out).unwrap();
}
let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
assert_eq!(v["title"].as_str().unwrap(), "title-only-change");
assert_eq!(v["content"].as_str().unwrap(), "orig content");
}
#[test]
fn test_update_partial_only_content() {
let mut env = TestEnv::fresh();
let db = env.db_path.clone();
let id = seed_memory(&db, "ns", "kept-title", "old-content");
let mut args = empty_args(&id);
args.content = Some("new content body".to_string());
{
let mut out = env.output();
run(&db, &args, true, &mut out).unwrap();
}
let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
assert_eq!(v["title"].as_str().unwrap(), "kept-title");
assert_eq!(v["content"].as_str().unwrap(), "new content body");
}
#[test]
fn test_update_clear_expires_at_with_empty_string() {
let mut env = TestEnv::fresh();
let db = env.db_path.clone();
let id = seed_memory(&db, "ns", "tt", "cc");
let mut args = empty_args(&id);
args.expires_at = Some(String::new());
{
let mut out = env.output();
run(&db, &args, false, &mut out).unwrap();
}
assert!(env.stdout_str().contains("updated:"));
}
#[test]
fn test_update_invalid_priority_validation_error() {
let mut env = TestEnv::fresh();
let db = env.db_path.clone();
let id = seed_memory(&db, "ns", "tt", "cc");
let mut args = empty_args(&id);
args.priority = Some(99);
let mut out = env.output();
let res = run(&db, &args, false, &mut out);
assert!(res.is_err());
}
#[test]
fn test_update_invalid_namespace_validation_error() {
let mut env = TestEnv::fresh();
let db = env.db_path.clone();
let id = seed_memory(&db, "ns", "tt", "cc");
let mut args = empty_args(&id);
args.namespace = Some("bad namespace with spaces".to_string());
let mut out = env.output();
let res = run(&db, &args, false, &mut out);
assert!(res.is_err(), "expected namespace validation error");
}
#[test]
fn test_update_invalid_tags_validation_error() {
let mut env = TestEnv::fresh();
let db = env.db_path.clone();
let id = seed_memory(&db, "ns", "tt", "cc");
let mut args = empty_args(&id);
let big = "x".repeat(2000);
args.tags = Some(big);
let mut out = env.output();
let res = run(&db, &args, false, &mut out);
let _ = res;
}
#[test]
fn test_update_valid_tags_split_and_pass_through() {
let mut env = TestEnv::fresh();
let db = env.db_path.clone();
let id = seed_memory(&db, "ns", "tt", "cc");
let mut args = empty_args(&id);
args.tags = Some("alpha, beta , , gamma".to_string());
{
let mut out = env.output();
run(&db, &args, false, &mut out).unwrap();
}
assert!(env.stdout_str().contains("updated:"));
}
#[test]
fn test_update_invalid_confidence_validation_error() {
let mut env = TestEnv::fresh();
let db = env.db_path.clone();
let id = seed_memory(&db, "ns", "tt", "cc");
let mut args = empty_args(&id);
args.confidence = Some(2.0); let mut out = env.output();
let res = run(&db, &args, false, &mut out);
assert!(res.is_err(), "expected confidence validation error");
}
#[test]
fn test_update_invalid_expires_at_format_validation_error() {
let mut env = TestEnv::fresh();
let db = env.db_path.clone();
let id = seed_memory(&db, "ns", "tt", "cc");
let mut args = empty_args(&id);
args.expires_at = Some("not-a-timestamp".to_string());
let mut out = env.output();
let res = run(&db, &args, false, &mut out);
assert!(res.is_err(), "expected expires_at format validation error");
}
#[test]
fn test_update_valid_namespace_passes_through() {
let mut env = TestEnv::fresh();
let db = env.db_path.clone();
let id = seed_memory(&db, "ns", "tt", "cc");
let mut args = empty_args(&id);
args.namespace = Some("new-namespace".to_string());
{
let mut out = env.output();
run(&db, &args, false, &mut out).unwrap();
}
assert!(env.stdout_str().contains("updated:"));
}
#[test]
fn test_update_valid_confidence_passes_through() {
let mut env = TestEnv::fresh();
let db = env.db_path.clone();
let id = seed_memory(&db, "ns", "tt", "cc");
let mut args = empty_args(&id);
args.confidence = Some(0.5);
{
let mut out = env.output();
run(&db, &args, false, &mut out).unwrap();
}
assert!(env.stdout_str().contains("updated:"));
}
#[test]
fn test_update_valid_expires_at_format_passes_through() {
let mut env = TestEnv::fresh();
let db = env.db_path.clone();
let id = seed_memory(&db, "ns", "tt", "cc");
let mut args = empty_args(&id);
args.expires_at = Some("2030-01-01T00:00:00+00:00".to_string());
{
let mut out = env.output();
run(&db, &args, false, &mut out).unwrap();
}
assert!(env.stdout_str().contains("updated:"));
}
#[test]
fn test_update_metadata_and_source_uri_valid_roundtrip() {
let mut env = TestEnv::fresh();
let db = env.db_path.clone();
let id = seed_memory(&db, "ns", "tt", "cc");
let mut args = empty_args(&id);
args.metadata = Some(r#"{"scope":"team"}"#.to_string());
args.source_uri = Some("uri:https://example.com/doc".to_string());
{
let mut out = env.output();
run(&db, &args, true, &mut out).unwrap();
}
let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
assert_eq!(v["metadata"]["scope"].as_str().unwrap(), "team");
assert_eq!(
v["source_uri"].as_str().unwrap(),
"uri:https://example.com/doc"
);
}
#[test]
fn test_update_invalid_metadata_json_errors() {
let mut env = TestEnv::fresh();
let db = env.db_path.clone();
let id = seed_memory(&db, "ns", "tt", "cc");
let mut args = empty_args(&id);
args.metadata = Some("not-json".to_string());
let mut out = env.output();
let err = run(&db, &args, false, &mut out).unwrap_err();
assert!(
err.to_string().contains("invalid --metadata JSON"),
"got: {err}"
);
}
#[test]
fn test_update_metadata_non_object_errors() {
let mut env = TestEnv::fresh();
let db = env.db_path.clone();
let id = seed_memory(&db, "ns", "tt", "cc");
let mut args = empty_args(&id);
args.metadata = Some("[1,2,3]".to_string());
let mut out = env.output();
let err = run(&db, &args, false, &mut out).unwrap_err();
assert!(
err.to_string().contains("--metadata must be a JSON object"),
"got: {err}"
);
}
#[test]
fn test_update_invalid_source_uri_errors() {
let mut env = TestEnv::fresh();
let db = env.db_path.clone();
let id = seed_memory(&db, "ns", "tt", "cc");
let mut args = empty_args(&id);
args.source_uri = Some("bareword-no-scheme".to_string());
let mut out = env.output();
let err = run(&db, &args, false, &mut out).unwrap_err();
assert!(
err.to_string().contains("invalid --source-uri"),
"got: {err}"
);
}
#[test]
fn test_update_expected_version_match_succeeds() {
let mut env = TestEnv::fresh();
let db = env.db_path.clone();
let id = seed_memory(&db, "ns", "tt", "cc");
let mut args = empty_args(&id);
args.title = Some("v-gated".to_string());
args.expected_version = Some(1);
{
let mut out = env.output();
run(&db, &args, false, &mut out).unwrap();
}
assert!(env.stdout_str().contains("updated:"));
}
#[test]
fn test_update_metadata_preserves_agent_id_1635() {
let mut env = TestEnv::fresh();
let db = env.db_path.clone();
let id = seed_memory(&db, "ns", "tt", "cc"); let mut args = empty_args(&id);
args.metadata = Some(r#"{"note":"x","agent_id":"ai:attacker"}"#.to_string());
{
let mut out = env.output();
run(&db, &args, false, &mut out).unwrap();
}
let conn = crate::db::open(&db).unwrap();
let mem = crate::db::get(&conn, &id).unwrap().unwrap();
assert_eq!(
mem.metadata.get("agent_id").and_then(|v| v.as_str()),
Some("test-agent"),
"#1635: agent_id must survive a CLI metadata update; got {:?}",
mem.metadata
);
assert_eq!(
mem.metadata.get("note").and_then(|v| v.as_str()),
Some("x"),
"the rest of the patch must apply"
);
}
#[test]
fn test_update_expected_version_mismatch_conflicts() {
let mut env = TestEnv::fresh();
let db = env.db_path.clone();
let id = seed_memory(&db, "ns", "tt", "cc");
let mut args = empty_args(&id);
args.title = Some("should-not-apply".to_string());
args.expected_version = Some(999);
let mut out = env.output();
let err = run(&db, &args, false, &mut out).unwrap_err();
assert!(err.to_string().contains("CONFLICT"), "got: {err}");
}
}