1use crate::cli::CliOutput;
14use crate::cli::governance::{GovernanceOutcome, enforce as enforce_governance};
15use crate::cli::helpers::id_short;
16use crate::{db, identity, models, validate};
17use anyhow::Result;
18use clap::Args;
19use models::Tier;
20use std::path::Path;
21
22#[derive(Args)]
23pub struct PromoteArgs {
24 pub id: String,
25 #[arg(long)]
30 pub to_namespace: Option<String>,
31}
32
33#[allow(clippy::too_many_lines)]
35pub fn cmd_promote(
36 db_path: &Path,
37 args: &PromoteArgs,
38 json_out: bool,
39 cli_agent_id: Option<&str>,
40 out: &mut CliOutput<'_>,
41) -> Result<()> {
42 validate::validate_id(&args.id)?;
43 if let Some(ref to_ns) = args.to_namespace {
44 validate::validate_namespace(to_ns)?;
45 }
46 let conn = db::open(db_path)?;
47 let target = if let Some(m) = db::get(&conn, &args.id)? {
48 m
49 } else if let Some(m) = db::get_by_prefix(&conn, &args.id)? {
50 m
51 } else {
52 writeln!(out.stderr, "not found: {}", args.id)?;
53 std::process::exit(1);
54 };
55 let resolved_id = target.id.clone();
56
57 {
58 use models::GovernedAction;
59 let caller_agent_id = identity::resolve_agent_id(cli_agent_id, None)?;
60 let mem_owner = target
61 .metadata
62 .get("agent_id")
63 .and_then(|v| v.as_str())
64 .map(str::to_string);
65 let payload = serde_json::json!({
66 "id": resolved_id,
67 "to_namespace": args.to_namespace,
68 });
69 match enforce_governance(
70 &conn,
71 GovernedAction::Promote,
72 &target.namespace,
73 &caller_agent_id,
74 Some(&resolved_id),
75 mem_owner.as_deref(),
76 &payload,
77 json_out,
78 out,
79 )? {
80 GovernanceOutcome::Allow => {}
81 GovernanceOutcome::Deny => {
82 std::process::exit(1);
83 }
84 GovernanceOutcome::Pending => {
85 return Ok(());
86 }
87 }
88 }
89
90 if let Some(ref to_ns) = args.to_namespace {
91 let clone_id = db::promote_to_namespace(&conn, &resolved_id, to_ns)?;
92 if json_out {
93 writeln!(
94 out.stdout,
95 "{}",
96 serde_json::to_string(&serde_json::json!({
97 "promoted": true,
98 "mode": "vertical",
99 "source_id": resolved_id,
100 "clone_id": clone_id,
101 "to_namespace": to_ns,
102 }))?
103 )?;
104 } else {
105 writeln!(
106 out.stdout,
107 "promoted (vertical): {} → {} (clone: {})",
108 id_short(&resolved_id),
109 to_ns,
110 id_short(&clone_id),
111 )?;
112 }
113 return Ok(());
114 }
115
116 let (found, _) = db::update(
117 &conn,
118 &resolved_id,
119 None,
120 None,
121 Some(&Tier::Long),
122 None,
123 None,
124 None,
125 None,
126 Some(""),
127 None,
128 )?;
129 if !found {
130 writeln!(out.stderr, "not found: {}", args.id)?;
131 std::process::exit(1);
132 }
133 if json_out {
134 writeln!(
135 out.stdout,
136 "{}",
137 serde_json::json!({"promoted": true, "id": resolved_id, "tier": "long"})
138 )?;
139 } else {
140 writeln!(out.stdout, "promoted to long-term: {resolved_id}")?;
141 }
142 Ok(())
143}
144
145#[cfg(test)]
146mod tests {
147 use super::*;
148 use crate::cli::test_utils::{TestEnv, seed_memory};
149
150 fn promote_args(id: &str) -> PromoteArgs {
151 PromoteArgs {
152 id: id.to_string(),
153 to_namespace: None,
154 }
155 }
156
157 fn seed_governance_policy(
158 db_path: &Path,
159 namespace: &str,
160 promote_level: models::GovernanceLevel,
161 owner_agent_id: &str,
162 ) {
163 use models::{ApproverType, GovernanceLevel, GovernancePolicy};
164 let policy = GovernancePolicy {
165 write: GovernanceLevel::Any,
166 promote: promote_level,
167 delete: GovernanceLevel::Owner,
168 approver: ApproverType::Human,
169 };
170 let conn = db::open(db_path).unwrap();
171 let now = chrono::Utc::now().to_rfc3339();
172 let mut metadata = models::default_metadata();
173 if let Some(obj) = metadata.as_object_mut() {
174 obj.insert(
175 "agent_id".to_string(),
176 serde_json::Value::String(owner_agent_id.to_string()),
177 );
178 obj.insert(
179 "governance".to_string(),
180 serde_json::to_value(&policy).unwrap(),
181 );
182 }
183 let standard = models::Memory {
184 id: uuid::Uuid::new_v4().to_string(),
185 tier: Tier::Long,
186 namespace: format!("_standards-{namespace}"),
187 title: format!("standard for {namespace}"),
188 content: "policy".to_string(),
189 tags: vec![],
190 priority: 9,
191 confidence: 1.0,
192 source: "test".to_string(),
193 access_count: 0,
194 created_at: now.clone(),
195 updated_at: now,
196 last_accessed_at: None,
197 expires_at: None,
198 metadata,
199 };
200 let standard_id = db::insert(&conn, &standard).unwrap();
201 db::set_namespace_standard(&conn, namespace, &standard_id, None).unwrap();
202 }
203
204 #[test]
205 fn test_promote_horizontal_to_long() {
206 let mut env = TestEnv::fresh();
207 let db = env.db_path.clone();
208 let id = seed_memory(&db, "ns", "tt", "cc");
209 let args = promote_args(&id);
210 {
211 let mut out = env.output();
212 cmd_promote(&db, &args, true, Some("test-agent"), &mut out).unwrap();
213 }
214 let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
215 assert_eq!(v["promoted"].as_bool().unwrap(), true);
216 assert_eq!(v["tier"].as_str().unwrap(), "long");
217 let conn = db::open(&db).unwrap();
218 let mem = db::get(&conn, &id).unwrap().unwrap();
219 assert_eq!(mem.tier, Tier::Long);
220 }
221
222 #[test]
223 fn test_promote_by_prefix() {
224 let mut env = TestEnv::fresh();
225 let db = env.db_path.clone();
226 let id = seed_memory(&db, "ns", "tt", "cc");
227 let prefix = id[..8].to_string();
228 let args = promote_args(&prefix);
229 {
230 let mut out = env.output();
231 cmd_promote(&db, &args, true, Some("test-agent"), &mut out).unwrap();
232 }
233 let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
234 assert_eq!(v["id"].as_str().unwrap(), id);
235 }
236
237 #[test]
238 fn test_promote_vertical_with_to_namespace() {
239 let mut env = TestEnv::fresh();
240 let db = env.db_path.clone();
241 let id = seed_memory(&db, "parent/child", "tt", "cc");
244 let mut args = promote_args(&id);
245 args.to_namespace = Some("parent".to_string());
246 {
247 let mut out = env.output();
248 cmd_promote(&db, &args, true, Some("test-agent"), &mut out).unwrap();
249 }
250 let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
251 assert_eq!(v["mode"].as_str().unwrap(), "vertical");
252 assert!(v["clone_id"].is_string());
253 assert_eq!(v["to_namespace"].as_str().unwrap(), "parent");
254 }
255
256 #[test]
257 fn test_promote_vertical_invalid_namespace_validation_error() {
258 let mut env = TestEnv::fresh();
259 let db = env.db_path.clone();
260 let id = seed_memory(&db, "ns", "tt", "cc");
261 let mut args = promote_args(&id);
262 args.to_namespace = Some("has spaces".to_string());
263 let mut out = env.output();
264 let res = cmd_promote(&db, &args, false, Some("test-agent"), &mut out);
265 assert!(res.is_err());
266 }
267
268 #[test]
269 fn test_promote_governance_pending() {
270 let mut env = TestEnv::fresh();
271 let db = env.db_path.clone();
272 let id = seed_memory(&db, "gov-promote-ns", "tt", "cc");
273 seed_governance_policy(
274 &db,
275 "gov-promote-ns",
276 models::GovernanceLevel::Approve,
277 "alice",
278 );
279 let args = promote_args(&id);
280 {
281 let mut out = env.output();
282 cmd_promote(&db, &args, true, Some("bob"), &mut out).unwrap();
283 }
284 let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
285 assert_eq!(v["status"].as_str().unwrap(), "pending");
286 assert_eq!(v["action"].as_str().unwrap(), "promote");
287 let conn = db::open(&db).unwrap();
289 let mem = db::get(&conn, &id).unwrap().unwrap();
290 assert_eq!(mem.tier, Tier::Mid);
291 }
292
293 #[test]
294 fn test_promote_governance_deny() {
295 use crate::cli::governance::{GovernanceOutcome, enforce as enforce_governance};
302 let mut env = TestEnv::fresh();
303 let db = env.db_path.clone();
304 let conn = db::open(&db).unwrap();
305 let now = chrono::Utc::now().to_rfc3339();
306 let mut metadata = models::default_metadata();
307 if let Some(obj) = metadata.as_object_mut() {
308 obj.insert(
309 "agent_id".to_string(),
310 serde_json::Value::String("alice".to_string()),
311 );
312 }
313 let mem = models::Memory {
314 id: uuid::Uuid::new_v4().to_string(),
315 tier: Tier::Mid,
316 namespace: "deny-ns".to_string(),
317 title: "tt".to_string(),
318 content: "cc".to_string(),
319 tags: vec![],
320 priority: 5,
321 confidence: 1.0,
322 source: "test".to_string(),
323 access_count: 0,
324 created_at: now.clone(),
325 updated_at: now,
326 last_accessed_at: None,
327 expires_at: None,
328 metadata,
329 };
330 let id = db::insert(&conn, &mem).unwrap();
331 drop(conn);
332 seed_governance_policy(&db, "deny-ns", models::GovernanceLevel::Owner, "alice");
333
334 let conn = db::open(&db).unwrap();
335 let payload = serde_json::json!({"id": id, "to_namespace": serde_json::Value::Null});
336 let outcome = {
337 let mut out = env.output();
338 enforce_governance(
339 &conn,
340 models::GovernedAction::Promote,
341 "deny-ns",
342 "bob",
343 Some(&id),
344 Some("alice"),
345 &payload,
346 false,
347 &mut out,
348 )
349 .unwrap()
350 };
351 assert_eq!(outcome, GovernanceOutcome::Deny);
352 assert!(env.stderr_str().contains("promote denied by governance"));
353 }
354
355 #[test]
359 fn test_promote_nonexistent_exits_nonzero() {
360 let mut env = TestEnv::fresh();
361 let db = env.db_path.clone();
362 let bad = "bad\0id".to_string();
365 let args = promote_args(&bad);
366 let mut out = env.output();
367 let res = cmd_promote(&db, &args, false, Some("x"), &mut out);
368 assert!(res.is_err());
369 }
370}