use crate::cli::CliOutput;
use crate::cli::governance::{GovernanceOutcome, enforce as enforce_governance};
use crate::cli::helpers::auto_namespace;
use crate::{config, db, identity, models, validate};
use anyhow::Result;
use chrono::{Duration, Utc};
use clap::Args;
use models::Tier;
use std::path::Path;
#[derive(Args)]
pub struct StoreArgs {
#[arg(long, short, default_value = "mid")]
pub tier: String,
#[arg(long, short)]
pub namespace: Option<String>,
#[arg(long, short = 'T', allow_hyphen_values = true)]
pub title: String,
#[arg(long, short, allow_hyphen_values = true)]
pub content: String,
#[arg(long, default_value = "")]
pub tags: String,
#[arg(long, short, default_value_t = 5)]
pub priority: i32,
#[arg(long, default_value_t = 1.0)]
pub confidence: f64,
#[arg(long, short = 'S', default_value = "cli")]
pub source: String,
#[arg(long)]
pub expires_at: Option<String>,
#[arg(long)]
pub ttl_secs: Option<i64>,
#[arg(long)]
pub scope: Option<String>,
}
pub(crate) fn resolve_content<F>(spec: &str, stdin_reader: F) -> Result<String>
where
F: FnOnce() -> Result<String>,
{
if spec == "-" {
stdin_reader()
} else {
Ok(spec.to_string())
}
}
fn read_stdin_to_string() -> Result<String> {
use std::io::Read as _;
let mut buf = String::new();
std::io::stdin().read_to_string(&mut buf)?;
Ok(buf)
}
#[allow(clippy::too_many_lines)]
pub fn run(
db_path: &Path,
args: StoreArgs,
json_out: bool,
app_config: &config::AppConfig,
cli_agent_id: Option<&str>,
out: &mut CliOutput<'_>,
) -> Result<()> {
let conn = db::open(db_path)?;
let resolved_ttl = app_config.effective_ttl();
let _ = db::gc_if_needed(&conn, app_config.effective_archive_on_gc());
let tier = Tier::from_str(&args.tier)
.ok_or_else(|| anyhow::anyhow!("invalid tier: {} (use short, mid, long)", args.tier))?;
let namespace = args.namespace.unwrap_or_else(auto_namespace);
let content = resolve_content(&args.content, read_stdin_to_string)?;
let tags: Vec<String> = args
.tags
.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect();
validate::validate_title(&args.title)?;
validate::validate_content(&content)?;
validate::validate_namespace(&namespace)?;
validate::validate_source(&args.source)?;
validate::validate_tags(&tags)?;
validate::validate_priority(args.priority)?;
validate::validate_confidence(args.confidence)?;
validate::validate_expires_at(args.expires_at.as_deref())?;
validate::validate_ttl_secs(args.ttl_secs)?;
let now = Utc::now();
let expires_at = args.expires_at.or_else(|| {
args.ttl_secs
.or(resolved_ttl.ttl_for_tier(&tier))
.map(|s| (now + Duration::seconds(s)).to_rfc3339())
});
let agent_id = identity::resolve_agent_id(cli_agent_id, None)?;
let mut metadata = models::default_metadata();
if let Some(obj) = metadata.as_object_mut() {
obj.insert(
"agent_id".to_string(),
serde_json::Value::String(agent_id.clone()),
);
}
if let Some(ref s) = args.scope {
validate::validate_scope(s)?;
if let Some(obj) = metadata.as_object_mut() {
obj.insert("scope".to_string(), serde_json::Value::String(s.clone()));
}
}
let mem = models::Memory {
id: uuid::Uuid::new_v4().to_string(),
tier,
namespace,
title: args.title,
content,
tags,
priority: args.priority.clamp(1, 10),
confidence: args.confidence.clamp(0.0, 1.0),
source: args.source,
access_count: 0,
created_at: now.to_rfc3339(),
updated_at: now.to_rfc3339(),
last_accessed_at: None,
expires_at,
metadata,
};
{
use models::GovernedAction;
let payload = serde_json::to_value(&mem).unwrap_or_default();
match enforce_governance(
&conn,
GovernedAction::Store,
&mem.namespace,
&agent_id,
None,
None,
&payload,
json_out,
out,
)? {
GovernanceOutcome::Allow => {}
GovernanceOutcome::Deny => {
std::process::exit(1);
}
GovernanceOutcome::Pending => {
return Ok(());
}
}
}
let contradictions =
db::find_contradictions(&conn, &mem.title, &mem.namespace).unwrap_or_default();
let actual_id = db::insert(&conn, &mem)?;
let filtered: Vec<&String> = contradictions
.iter()
.filter(|c| c.id != mem.id && c.id != actual_id)
.map(|c| &c.id)
.collect();
if json_out {
let mut j = serde_json::to_value(&mem)?;
j["id"] = serde_json::json!(actual_id);
let filtered: Vec<&String> = contradictions
.iter()
.filter(|c| c.id != actual_id)
.map(|c| &c.id)
.collect();
if !filtered.is_empty() {
j["potential_contradictions"] = serde_json::json!(filtered);
}
writeln!(out.stdout, "{}", serde_json::to_string(&j)?)?;
} else {
writeln!(
out.stdout,
"stored: {} [{}] (ns={})",
actual_id, mem.tier, mem.namespace
)?;
if !filtered.is_empty() {
writeln!(
out.stderr,
"warning: {} similar memories found in same namespace (potential contradictions)",
filtered.len()
)?;
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cli::test_utils::TestEnv;
fn default_args() -> StoreArgs {
StoreArgs {
tier: "mid".to_string(),
namespace: Some("test-ns".to_string()),
title: "test title".to_string(),
content: "test content".to_string(),
tags: String::new(),
priority: 5,
confidence: 1.0,
source: "cli".to_string(),
expires_at: None,
ttl_secs: None,
scope: None,
}
}
#[test]
fn test_resolve_content_literal() {
let out = resolve_content("hello", || panic!("should not call stdin"));
assert_eq!(out.unwrap(), "hello");
}
#[test]
fn test_resolve_content_stdin_dash() {
let out = resolve_content("-", || Ok("piped content".to_string()));
assert_eq!(out.unwrap(), "piped content");
}
#[test]
fn test_store_happy_path_text_output() {
let mut env = TestEnv::fresh();
let db = env.db_path.clone();
let cfg = config::AppConfig::default();
let args = default_args();
{
let mut out = env.output();
run(&db, args, false, &cfg, Some("test-agent"), &mut out).unwrap();
}
let stdout = env.stdout_str();
assert!(stdout.starts_with("stored: "), "got: {stdout}");
assert!(stdout.contains("[mid]"));
assert!(stdout.contains("ns=test-ns"));
}
#[test]
fn test_store_json_output() {
let mut env = TestEnv::fresh();
let db = env.db_path.clone();
let cfg = config::AppConfig::default();
let args = default_args();
{
let mut out = env.output();
run(&db, args, true, &cfg, Some("test-agent"), &mut out).unwrap();
}
let stdout = env.stdout_str();
let v: serde_json::Value = serde_json::from_str(stdout.trim()).unwrap();
assert!(v["id"].is_string());
assert_eq!(v["title"].as_str().unwrap(), "test title");
assert_eq!(v["tier"].as_str().unwrap(), "mid");
assert_eq!(v["namespace"].as_str().unwrap(), "test-ns");
}
#[test]
fn test_store_stdin_content() {
let payload = "from stdin reader";
let resolved = resolve_content("-", || Ok(payload.to_string())).unwrap();
assert_eq!(resolved, payload);
}
#[test]
fn test_store_explicit_expires_at_overrides_tier() {
let mut env = TestEnv::fresh();
let db = env.db_path.clone();
let cfg = config::AppConfig::default();
let mut args = default_args();
let custom_expiry = "2099-01-01T00:00:00+00:00".to_string();
args.expires_at = Some(custom_expiry.clone());
{
let mut out = env.output();
run(&db, args, true, &cfg, Some("test-agent"), &mut out).unwrap();
}
let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
let exp = v["expires_at"].as_str().unwrap();
assert!(exp.starts_with("2099-01-01"), "got: {exp}");
}
#[test]
fn test_store_ttl_secs_overrides_tier() {
let mut env = TestEnv::fresh();
let db = env.db_path.clone();
let cfg = config::AppConfig::default();
let mut args = default_args();
args.ttl_secs = Some(60);
{
let mut out = env.output();
run(&db, args, true, &cfg, Some("test-agent"), &mut out).unwrap();
}
let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
assert!(v["expires_at"].is_string());
}
#[test]
fn test_store_with_scope_in_metadata() {
let mut env = TestEnv::fresh();
let db = env.db_path.clone();
let cfg = config::AppConfig::default();
let mut args = default_args();
args.scope = Some("team".to_string());
{
let mut out = env.output();
run(&db, args, true, &cfg, Some("test-agent"), &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");
}
#[test]
fn test_store_invalid_tier_validation_error() {
let mut env = TestEnv::fresh();
let db = env.db_path.clone();
let cfg = config::AppConfig::default();
let mut args = default_args();
args.tier = "ginormous".to_string();
let mut out = env.output();
let res = run(&db, args, false, &cfg, Some("test-agent"), &mut out);
let err = res.unwrap_err();
assert!(err.to_string().contains("invalid tier"));
}
#[test]
fn test_store_invalid_priority_validation_error() {
let mut env = TestEnv::fresh();
let db = env.db_path.clone();
let cfg = config::AppConfig::default();
let mut args = default_args();
args.priority = 99;
let mut out = env.output();
let res = run(&db, args, false, &cfg, Some("test-agent"), &mut out);
assert!(res.is_err());
}
#[test]
fn test_store_contradiction_warning_in_stderr() {
let mut env = TestEnv::fresh();
let db = env.db_path.clone();
let cfg = config::AppConfig::default();
let _ =
crate::cli::test_utils::seed_memory(&db, "test-ns", "shared title", "first content");
let mut args = default_args();
args.title = "shared title".to_string();
args.content = "second content".to_string();
{
let mut out = env.output();
run(&db, args, false, &cfg, Some("test-agent"), &mut out).unwrap();
}
assert!(env.stdout_str().contains("stored: "));
}
#[test]
fn test_store_governance_pending_writes_pending_status() {
let mut env = TestEnv::fresh();
let db = env.db_path.clone();
let cfg = config::AppConfig::default();
let args = default_args();
let mut out = env.output();
let res = run(&db, args, true, &cfg, Some("test-agent"), &mut out);
drop(out);
assert!(res.is_ok());
let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
assert!(v["id"].is_string());
}
#[test]
fn test_store_tag_parsing() {
let mut env = TestEnv::fresh();
let db = env.db_path.clone();
let cfg = config::AppConfig::default();
let mut args = default_args();
args.tags = "a, b, , c".to_string();
{
let mut out = env.output();
run(&db, args, true, &cfg, Some("test-agent"), &mut out).unwrap();
}
let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
let tags = v["tags"].as_array().unwrap();
let strs: Vec<&str> = tags.iter().map(|t| t.as_str().unwrap()).collect();
assert_eq!(strs, vec!["a", "b", "c"]);
}
}