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 inherit: true,
170 };
171 let conn = db::open(db_path).unwrap();
172 let now = chrono::Utc::now().to_rfc3339();
173 let mut metadata = models::default_metadata();
174 if let Some(obj) = metadata.as_object_mut() {
175 obj.insert(
176 "agent_id".to_string(),
177 serde_json::Value::String(owner_agent_id.to_string()),
178 );
179 obj.insert(
180 "governance".to_string(),
181 serde_json::to_value(&policy).unwrap(),
182 );
183 }
184 let standard = models::Memory {
185 id: uuid::Uuid::new_v4().to_string(),
186 tier: Tier::Long,
187 namespace: format!("_standards-{namespace}"),
188 title: format!("standard for {namespace}"),
189 content: "policy".to_string(),
190 tags: vec![],
191 priority: 9,
192 confidence: 1.0,
193 source: "test".to_string(),
194 access_count: 0,
195 created_at: now.clone(),
196 updated_at: now,
197 last_accessed_at: None,
198 expires_at: None,
199 metadata,
200 };
201 let standard_id = db::insert(&conn, &standard).unwrap();
202 db::set_namespace_standard(&conn, namespace, &standard_id, None).unwrap();
203 }
204
205 #[test]
206 fn test_promote_horizontal_to_long() {
207 let mut env = TestEnv::fresh();
208 let db = env.db_path.clone();
209 let id = seed_memory(&db, "ns", "tt", "cc");
210 let args = promote_args(&id);
211 {
212 let mut out = env.output();
213 cmd_promote(&db, &args, true, Some("test-agent"), &mut out).unwrap();
214 }
215 let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
216 assert_eq!(v["promoted"].as_bool().unwrap(), true);
217 assert_eq!(v["tier"].as_str().unwrap(), "long");
218 let conn = db::open(&db).unwrap();
219 let mem = db::get(&conn, &id).unwrap().unwrap();
220 assert_eq!(mem.tier, Tier::Long);
221 }
222
223 #[test]
224 fn test_promote_by_prefix() {
225 let mut env = TestEnv::fresh();
226 let db = env.db_path.clone();
227 let id = seed_memory(&db, "ns", "tt", "cc");
228 let prefix = id[..8].to_string();
229 let args = promote_args(&prefix);
230 {
231 let mut out = env.output();
232 cmd_promote(&db, &args, true, Some("test-agent"), &mut out).unwrap();
233 }
234 let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
235 assert_eq!(v["id"].as_str().unwrap(), id);
236 }
237
238 #[test]
239 fn test_promote_vertical_with_to_namespace() {
240 let mut env = TestEnv::fresh();
241 let db = env.db_path.clone();
242 let id = seed_memory(&db, "parent/child", "tt", "cc");
245 let mut args = promote_args(&id);
246 args.to_namespace = Some("parent".to_string());
247 {
248 let mut out = env.output();
249 cmd_promote(&db, &args, true, Some("test-agent"), &mut out).unwrap();
250 }
251 let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
252 assert_eq!(v["mode"].as_str().unwrap(), "vertical");
253 assert!(v["clone_id"].is_string());
254 assert_eq!(v["to_namespace"].as_str().unwrap(), "parent");
255 }
256
257 #[test]
258 fn test_promote_vertical_invalid_namespace_validation_error() {
259 let mut env = TestEnv::fresh();
260 let db = env.db_path.clone();
261 let id = seed_memory(&db, "ns", "tt", "cc");
262 let mut args = promote_args(&id);
263 args.to_namespace = Some("has spaces".to_string());
264 let mut out = env.output();
265 let res = cmd_promote(&db, &args, false, Some("test-agent"), &mut out);
266 assert!(res.is_err());
267 }
268
269 #[test]
270 fn test_promote_governance_pending() {
271 let mut env = TestEnv::fresh();
272 let db = env.db_path.clone();
273 let id = seed_memory(&db, "gov-promote-ns", "tt", "cc");
274 seed_governance_policy(
275 &db,
276 "gov-promote-ns",
277 models::GovernanceLevel::Approve,
278 "alice",
279 );
280 let args = promote_args(&id);
281 {
282 let mut out = env.output();
283 cmd_promote(&db, &args, true, Some("bob"), &mut out).unwrap();
284 }
285 let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
286 assert_eq!(v["status"].as_str().unwrap(), "pending");
287 assert_eq!(v["action"].as_str().unwrap(), "promote");
288 let conn = db::open(&db).unwrap();
290 let mem = db::get(&conn, &id).unwrap().unwrap();
291 assert_eq!(mem.tier, Tier::Mid);
292 }
293
294 #[test]
295 fn test_promote_governance_deny() {
296 use crate::cli::governance::{GovernanceOutcome, enforce as enforce_governance};
303 let mut env = TestEnv::fresh();
304 let db = env.db_path.clone();
305 let conn = db::open(&db).unwrap();
306 let now = chrono::Utc::now().to_rfc3339();
307 let mut metadata = models::default_metadata();
308 if let Some(obj) = metadata.as_object_mut() {
309 obj.insert(
310 "agent_id".to_string(),
311 serde_json::Value::String("alice".to_string()),
312 );
313 }
314 let mem = models::Memory {
315 id: uuid::Uuid::new_v4().to_string(),
316 tier: Tier::Mid,
317 namespace: "deny-ns".to_string(),
318 title: "tt".to_string(),
319 content: "cc".to_string(),
320 tags: vec![],
321 priority: 5,
322 confidence: 1.0,
323 source: "test".to_string(),
324 access_count: 0,
325 created_at: now.clone(),
326 updated_at: now,
327 last_accessed_at: None,
328 expires_at: None,
329 metadata,
330 };
331 let id = db::insert(&conn, &mem).unwrap();
332 drop(conn);
333 seed_governance_policy(&db, "deny-ns", models::GovernanceLevel::Owner, "alice");
334
335 let conn = db::open(&db).unwrap();
336 let payload = serde_json::json!({"id": id, "to_namespace": serde_json::Value::Null});
337 let outcome = {
338 let mut out = env.output();
339 enforce_governance(
340 &conn,
341 models::GovernedAction::Promote,
342 "deny-ns",
343 "bob",
344 Some(&id),
345 Some("alice"),
346 &payload,
347 false,
348 &mut out,
349 )
350 .unwrap()
351 };
352 assert_eq!(outcome, GovernanceOutcome::Deny);
353 assert!(env.stderr_str().contains("promote denied by governance"));
354 }
355
356 #[test]
360 fn test_promote_nonexistent_exits_nonzero() {
361 let mut env = TestEnv::fresh();
362 let db = env.db_path.clone();
363 let bad = "bad\0id".to_string();
366 let args = promote_args(&bad);
367 let mut out = env.output();
368 let res = cmd_promote(&db, &args, false, Some("x"), &mut out);
369 assert!(res.is_err());
370 }
371}