1use crate::cli::CliOutput;
8use crate::cli::governance::{GovernanceOutcome, enforce as enforce_governance};
9use crate::cli::helpers::auto_namespace;
10use crate::{config, db, identity, models, validate};
11use anyhow::Result;
12use chrono::{Duration, Utc};
13use clap::Args;
14use models::Tier;
15use std::path::Path;
16
17#[derive(Args)]
20pub struct StoreArgs {
21 #[arg(long, short, default_value = "mid")]
22 pub tier: String,
23 #[arg(long, short)]
24 pub namespace: Option<String>,
25 #[arg(long, short = 'T', allow_hyphen_values = true)]
26 pub title: String,
27 #[arg(long, short, allow_hyphen_values = true)]
29 pub content: String,
30 #[arg(long, default_value = "")]
31 pub tags: String,
32 #[arg(long, short, default_value_t = 5)]
33 pub priority: i32,
34 #[arg(long, default_value_t = 1.0)]
36 pub confidence: f64,
37 #[arg(long, short = 'S', default_value = "cli")]
39 pub source: String,
40 #[arg(long)]
42 pub expires_at: Option<String>,
43 #[arg(long)]
45 pub ttl_secs: Option<i64>,
46 #[arg(long)]
50 pub scope: Option<String>,
51}
52
53pub(crate) fn resolve_content<F>(spec: &str, stdin_reader: F) -> Result<String>
59where
60 F: FnOnce() -> Result<String>,
61{
62 if spec == "-" {
63 stdin_reader()
64 } else {
65 Ok(spec.to_string())
66 }
67}
68
69fn read_stdin_to_string() -> Result<String> {
71 use std::io::Read as _;
72 let mut buf = String::new();
73 std::io::stdin().read_to_string(&mut buf)?;
74 Ok(buf)
75}
76
77#[allow(clippy::too_many_lines)]
81pub fn run(
82 db_path: &Path,
83 args: StoreArgs,
84 json_out: bool,
85 app_config: &config::AppConfig,
86 cli_agent_id: Option<&str>,
87 out: &mut CliOutput<'_>,
88) -> Result<()> {
89 let conn = db::open(db_path)?;
90 let resolved_ttl = app_config.effective_ttl();
91 let _ = db::gc_if_needed(&conn, app_config.effective_archive_on_gc());
92 let tier = Tier::from_str(&args.tier)
93 .ok_or_else(|| anyhow::anyhow!("invalid tier: {} (use short, mid, long)", args.tier))?;
94 let namespace = args.namespace.unwrap_or_else(auto_namespace);
95 let content = resolve_content(&args.content, read_stdin_to_string)?;
96 let tags: Vec<String> = args
97 .tags
98 .split(',')
99 .map(|s| s.trim().to_string())
100 .filter(|s| !s.is_empty())
101 .collect();
102
103 validate::validate_title(&args.title)?;
105 validate::validate_content(&content)?;
106 validate::validate_namespace(&namespace)?;
107 validate::validate_source(&args.source)?;
108 validate::validate_tags(&tags)?;
109 validate::validate_priority(args.priority)?;
110 validate::validate_confidence(args.confidence)?;
111 validate::validate_expires_at(args.expires_at.as_deref())?;
112 validate::validate_ttl_secs(args.ttl_secs)?;
113
114 let now = Utc::now();
115 let expires_at = args.expires_at.or_else(|| {
116 args.ttl_secs
117 .or(resolved_ttl.ttl_for_tier(&tier))
118 .map(|s| (now + Duration::seconds(s)).to_rfc3339())
119 });
120 let agent_id = identity::resolve_agent_id(cli_agent_id, None)?;
121 let mut metadata = models::default_metadata();
122 if let Some(obj) = metadata.as_object_mut() {
123 obj.insert(
124 "agent_id".to_string(),
125 serde_json::Value::String(agent_id.clone()),
126 );
127 }
128 if let Some(ref s) = args.scope {
129 validate::validate_scope(s)?;
130 if let Some(obj) = metadata.as_object_mut() {
131 obj.insert("scope".to_string(), serde_json::Value::String(s.clone()));
132 }
133 }
134
135 let mem = models::Memory {
136 id: uuid::Uuid::new_v4().to_string(),
137 tier,
138 namespace,
139 title: args.title,
140 content,
141 tags,
142 priority: args.priority.clamp(1, 10),
143 confidence: args.confidence.clamp(0.0, 1.0),
144 source: args.source,
145 access_count: 0,
146 created_at: now.to_rfc3339(),
147 updated_at: now.to_rfc3339(),
148 last_accessed_at: None,
149 expires_at,
150 metadata,
151 };
152
153 {
157 use models::GovernedAction;
158 let payload = serde_json::to_value(&mem).unwrap_or_default();
159 match enforce_governance(
160 &conn,
161 GovernedAction::Store,
162 &mem.namespace,
163 &agent_id,
164 None,
165 None,
166 &payload,
167 json_out,
168 out,
169 )? {
170 GovernanceOutcome::Allow => {}
171 GovernanceOutcome::Deny => {
172 std::process::exit(1);
173 }
174 GovernanceOutcome::Pending => {
175 return Ok(());
176 }
177 }
178 }
179 let contradictions =
180 db::find_contradictions(&conn, &mem.title, &mem.namespace).unwrap_or_default();
181 let actual_id = db::insert(&conn, &mem)?;
182 let filtered: Vec<&String> = contradictions
183 .iter()
184 .filter(|c| c.id != mem.id && c.id != actual_id)
185 .map(|c| &c.id)
186 .collect();
187 if json_out {
188 let mut j = serde_json::to_value(&mem)?;
189 j["id"] = serde_json::json!(actual_id);
190 let filtered: Vec<&String> = contradictions
191 .iter()
192 .filter(|c| c.id != actual_id)
193 .map(|c| &c.id)
194 .collect();
195 if !filtered.is_empty() {
196 j["potential_contradictions"] = serde_json::json!(filtered);
197 }
198 writeln!(out.stdout, "{}", serde_json::to_string(&j)?)?;
199 } else {
200 writeln!(
201 out.stdout,
202 "stored: {} [{}] (ns={})",
203 actual_id, mem.tier, mem.namespace
204 )?;
205 if !filtered.is_empty() {
206 writeln!(
207 out.stderr,
208 "warning: {} similar memories found in same namespace (potential contradictions)",
209 filtered.len()
210 )?;
211 }
212 }
213 Ok(())
214}
215
216#[cfg(test)]
217mod tests {
218 use super::*;
219 use crate::cli::test_utils::TestEnv;
220
221 fn default_args() -> StoreArgs {
222 StoreArgs {
223 tier: "mid".to_string(),
224 namespace: Some("test-ns".to_string()),
225 title: "test title".to_string(),
226 content: "test content".to_string(),
227 tags: String::new(),
228 priority: 5,
229 confidence: 1.0,
230 source: "cli".to_string(),
231 expires_at: None,
232 ttl_secs: None,
233 scope: None,
234 }
235 }
236
237 #[test]
238 fn test_resolve_content_literal() {
239 let out = resolve_content("hello", || panic!("should not call stdin"));
240 assert_eq!(out.unwrap(), "hello");
241 }
242
243 #[test]
244 fn test_resolve_content_stdin_dash() {
245 let out = resolve_content("-", || Ok("piped content".to_string()));
246 assert_eq!(out.unwrap(), "piped content");
247 }
248
249 #[test]
250 fn test_store_happy_path_text_output() {
251 let mut env = TestEnv::fresh();
252 let db = env.db_path.clone();
253 let cfg = config::AppConfig::default();
254 let args = default_args();
255 {
256 let mut out = env.output();
257 run(&db, args, false, &cfg, Some("test-agent"), &mut out).unwrap();
258 }
259 let stdout = env.stdout_str();
260 assert!(stdout.starts_with("stored: "), "got: {stdout}");
261 assert!(stdout.contains("[mid]"));
262 assert!(stdout.contains("ns=test-ns"));
263 }
264
265 #[test]
266 fn test_store_json_output() {
267 let mut env = TestEnv::fresh();
268 let db = env.db_path.clone();
269 let cfg = config::AppConfig::default();
270 let args = default_args();
271 {
272 let mut out = env.output();
273 run(&db, args, true, &cfg, Some("test-agent"), &mut out).unwrap();
274 }
275 let stdout = env.stdout_str();
276 let v: serde_json::Value = serde_json::from_str(stdout.trim()).unwrap();
277 assert!(v["id"].is_string());
278 assert_eq!(v["title"].as_str().unwrap(), "test title");
279 assert_eq!(v["tier"].as_str().unwrap(), "mid");
280 assert_eq!(v["namespace"].as_str().unwrap(), "test-ns");
281 }
282
283 #[test]
284 fn test_store_stdin_content() {
285 let payload = "from stdin reader";
288 let resolved = resolve_content("-", || Ok(payload.to_string())).unwrap();
289 assert_eq!(resolved, payload);
290 }
291
292 #[test]
293 fn test_store_explicit_expires_at_overrides_tier() {
294 let mut env = TestEnv::fresh();
295 let db = env.db_path.clone();
296 let cfg = config::AppConfig::default();
297 let mut args = default_args();
298 let custom_expiry = "2099-01-01T00:00:00+00:00".to_string();
299 args.expires_at = Some(custom_expiry.clone());
300 {
301 let mut out = env.output();
302 run(&db, args, true, &cfg, Some("test-agent"), &mut out).unwrap();
303 }
304 let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
305 let exp = v["expires_at"].as_str().unwrap();
306 assert!(exp.starts_with("2099-01-01"), "got: {exp}");
307 }
308
309 #[test]
310 fn test_store_ttl_secs_overrides_tier() {
311 let mut env = TestEnv::fresh();
312 let db = env.db_path.clone();
313 let cfg = config::AppConfig::default();
314 let mut args = default_args();
315 args.ttl_secs = Some(60);
316 {
317 let mut out = env.output();
318 run(&db, args, true, &cfg, Some("test-agent"), &mut out).unwrap();
319 }
320 let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
321 assert!(v["expires_at"].is_string());
323 }
324
325 #[test]
326 fn test_store_with_scope_in_metadata() {
327 let mut env = TestEnv::fresh();
328 let db = env.db_path.clone();
329 let cfg = config::AppConfig::default();
330 let mut args = default_args();
331 args.scope = Some("team".to_string());
332 {
333 let mut out = env.output();
334 run(&db, args, true, &cfg, Some("test-agent"), &mut out).unwrap();
335 }
336 let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
337 assert_eq!(v["metadata"]["scope"].as_str().unwrap(), "team");
338 }
339
340 #[test]
341 fn test_store_invalid_tier_validation_error() {
342 let mut env = TestEnv::fresh();
343 let db = env.db_path.clone();
344 let cfg = config::AppConfig::default();
345 let mut args = default_args();
346 args.tier = "ginormous".to_string();
347 let mut out = env.output();
348 let res = run(&db, args, false, &cfg, Some("test-agent"), &mut out);
349 let err = res.unwrap_err();
350 assert!(err.to_string().contains("invalid tier"));
351 }
352
353 #[test]
354 fn test_store_invalid_priority_validation_error() {
355 let mut env = TestEnv::fresh();
356 let db = env.db_path.clone();
357 let cfg = config::AppConfig::default();
358 let mut args = default_args();
359 args.priority = 99;
360 let mut out = env.output();
361 let res = run(&db, args, false, &cfg, Some("test-agent"), &mut out);
362 assert!(res.is_err());
364 }
365
366 #[test]
367 fn test_store_contradiction_warning_in_stderr() {
368 let mut env = TestEnv::fresh();
369 let db = env.db_path.clone();
370 let cfg = config::AppConfig::default();
371 let _ =
375 crate::cli::test_utils::seed_memory(&db, "test-ns", "shared title", "first content");
376 let mut args = default_args();
377 args.title = "shared title".to_string();
378 args.content = "second content".to_string();
379 {
380 let mut out = env.output();
381 run(&db, args, false, &cfg, Some("test-agent"), &mut out).unwrap();
382 }
383 assert!(env.stdout_str().contains("stored: "));
387 }
388
389 #[test]
390 fn test_store_governance_pending_writes_pending_status() {
391 let mut env = TestEnv::fresh();
397 let db = env.db_path.clone();
398 let cfg = config::AppConfig::default();
399 let args = default_args();
400 let mut out = env.output();
401 let res = run(&db, args, true, &cfg, Some("test-agent"), &mut out);
402 drop(out);
403 assert!(res.is_ok());
404 let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
406 assert!(v["id"].is_string());
407 }
408
409 #[test]
410 fn test_store_tag_parsing() {
411 let mut env = TestEnv::fresh();
412 let db = env.db_path.clone();
413 let cfg = config::AppConfig::default();
414 let mut args = default_args();
415 args.tags = "a, b, , c".to_string();
416 {
417 let mut out = env.output();
418 run(&db, args, true, &cfg, Some("test-agent"), &mut out).unwrap();
419 }
420 let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
421 let tags = v["tags"].as_array().unwrap();
422 let strs: Vec<&str> = tags.iter().map(|t| t.as_str().unwrap()).collect();
423 assert_eq!(strs, vec!["a", "b", "c"]);
424 }
425}