Skip to main content

blotter/commands/
promote.rs

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    // Redacted before hashing and before append, so the hashed bytes are the
42    // stored bytes and a dry run predicts them exactly (r48).
43    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(&note), home.as_deref());
48            validate_free_text(&note, "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    // Read → fold → validate → append inside one critical section, after the
57    // version probe. Unlike `add --dry-run`, a dry run opens the log: validating
58    // every `--source` requires the fold (r48).
59    // `(changed, duplicate, record)`. The two facts are independent: a dry run
60    // that folds onto an existing ID has found a duplicate and must say so, or
61    // the plan would report `changed:false` with no reason and promise
62    // something the apply would not produce (r31).
63    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        // Duplicates follow the cut and dogear rules exactly: first-wins, the
103        // existing record returned, nothing appended.
104        if let Some(existing) = folded.record(id) {
105            return match existing {
106                LogEvent::Promotion { .. } => Ok((false, true, existing.clone())),
107                // `store::append_unique` answers a cut/dogear ID colliding with
108                // another kind exactly this way; the wording follows its
109                // `{kind} ID collides with an existing non-{kind} record`.
110                _ => 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    // Both, in this order, on a dry run over a duplicate — the shape
132    // `resolve --dry-run` already uses for an already-resolved ID.
133    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
149/// `artifact.ref` and `note` are bounded at 10,000 bytes after redaction — the
150/// bound `add` applies to cut text — and reject empty and whitespace-only
151/// values (r48).
152fn 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}