1use crate::cli::PromoteArgs;
2use crate::commands::add::redact_evidence;
3use crate::commands::resolve::{Candidate, candidates, match_id, normalize_prefix};
4use crate::error::{AppError, AppResult};
5use crate::output::{self, Meta};
6use crate::store;
7use crate::{
8 Artifact, LogEvent, Origin, compute_promotion_id, format_timestamp, normalized,
9 resolve_agent_checked,
10};
11use jiff::Timestamp;
12use serde::{Deserialize, Serialize};
13use std::path::{Path, PathBuf};
14
15#[derive(Debug, Serialize, Deserialize)]
16pub struct PromoteData {
17 pub changed: bool,
18 pub record: LogEvent,
19}
20
21pub fn run(
22 args: PromoteArgs,
23 file: Option<PathBuf>,
24 pretty: bool,
25 now: Timestamp,
26) -> AppResult<i32> {
27 let PromoteArgs {
28 sources,
29 artifact_type,
30 artifact_ref,
31 note,
32 agent,
33 dry_run,
34 } = args;
35 let prefixes = sources
36 .iter()
37 .map(|source| normalize_prefix(source))
38 .collect::<AppResult<Vec<_>>>()?;
39 let resolved = store::discover(file)?;
40 let home = store::home_dir(&resolved.cwd);
41 let artifact_ref = redact_evidence(strip_trailing_newlines(&artifact_ref), home.as_deref());
44 validate_free_text(&artifact_ref, "artifact ref", "--artifact-ref REF")?;
45 let note = note
46 .map(|note| {
47 let note = redact_evidence(strip_trailing_newlines(¬e), home.as_deref());
48 validate_free_text(¬e, "note", "--note TEXT")?;
49 Ok(note)
50 })
51 .transpose()?;
52 let (agent, agent_source) = resolve_agent_checked(agent, true)?;
53 let ts = format_timestamp(now);
54 let cwd = store::record_cwd(&resolved.cwd, resolved.cwd_repo(), home.as_deref());
55
56 let action = |log: &mut std::fs::File| -> AppResult<(bool, bool, LogEvent)> {
64 let bytes = store::read_bytes(log, &resolved.path)?;
65 store::check_version(&bytes, &resolved.path)?;
66 let folded = store::fold_bytes(&bytes);
67 let candidates = candidates(&folded);
68 let mut source_ids = prefixes
69 .iter()
70 .map(|prefix| {
71 let Candidate { id, kind } = match_id(prefix, &candidates)?;
72 if kind != "cut" {
73 return Err(AppError::invalid_argument(
74 format!("--source {id} is a {kind}, not a cut"),
75 "Promotion sources are cuts only; pass cut IDs to --source.",
76 ));
77 }
78 Ok(id)
79 })
80 .collect::<AppResult<Vec<_>>>()?;
81 source_ids = normalized(&source_ids);
82 let record = LogEvent::Promotion {
83 id: compute_promotion_id(
84 &ts,
85 &agent,
86 &source_ids,
87 artifact_type.as_str(),
88 &artifact_ref,
89 ),
90 ts: ts.clone(),
91 agent: agent.clone(),
92 sources: source_ids,
93 artifact: Artifact {
94 kind: artifact_type,
95 r#ref: artifact_ref.clone(),
96 },
97 note: note.clone(),
98 origin: Some(Origin::agent()),
99 cwd: cwd.clone(),
100 };
101 let id = record.id().expect("new promotions have IDs");
102 if let Some(existing) = folded.record(id) {
105 return match existing {
106 LogEvent::Promotion { .. } => Ok((false, true, existing.clone())),
107 _ => Err(AppError::internal(
111 "promotion ID collides with an existing non-promotion record",
112 )),
113 };
114 }
115 if dry_run {
116 return Ok((false, false, record));
117 }
118 store::append_json(log, &resolved.path, &bytes, &record)?;
119 Ok((true, false, record))
120 };
121 let (changed, duplicate, record) = if dry_run {
122 store::with_shared(&resolved.path, action)
123 } else {
124 store::with_exclusive(&resolved.path, false, action)
125 }?;
126
127 let mut meta = Meta::new();
128 meta.file = Some(resolved.path.to_string_lossy().into_owned());
129 meta.agent_source = Some(agent_source.into());
130 meta.warnings = resolved.warnings.clone();
131 if duplicate {
134 meta.warnings
135 .push("duplicate promotion; existing record returned".into());
136 }
137 if dry_run {
138 meta.warnings.push("dry run; no record appended".into());
139 }
140 output::write_success(PromoteData { changed, record }, pretty, meta)
141 .map_err(|error| AppError::from_io(error, Path::new("stdout")))?;
142 Ok(0)
143}
144
145fn strip_trailing_newlines(value: &str) -> &str {
146 value.trim_end_matches(['\n', '\r'])
147}
148
149fn validate_free_text(value: &str, name: &str, flag: &str) -> AppResult<()> {
153 if value.trim().is_empty() {
154 return Err(AppError::invalid_input(
155 format!("promotion {name} cannot be empty or whitespace-only"),
156 format!("Pass a non-empty {flag}."),
157 ));
158 }
159 if value.len() > 10_000 {
160 return Err(AppError::invalid_input(
161 format!(
162 "promotion {name} is {} bytes; the maximum is 10000",
163 value.len()
164 ),
165 format!("Shorten {flag} to at most 10000 UTF-8 bytes."),
166 ));
167 }
168 Ok(())
169}