Skip to main content

release_kit/commands/
stage.rs

1//! `rk stage`: write the candidate this binary would land into a
2//! disposable stage, and `rk stage clean`: remove exactly one such stage.
3//!
4//! The create verb reads the target the way the landing verbs do, resolves
5//! the same parameters, computes the one pure projection, and materializes
6//! it under the resolved output. It writes nothing inside the target, and
7//! nothing about it changes what a production landing does: `rk init`,
8//! `rk upgrade`, `rk adopt`, and `rk status` never read a stage. The
9//! clean verb removes one directory whose own receipt names it, through
10//! held descriptors, and refuses everything else.
11
12use std::fmt::Write as _;
13
14use camino::{Utf8Path, Utf8PathBuf};
15use serde::Serialize;
16
17use crate::cli::stage::{StageAction, StageArgs};
18use crate::diagnostic::{Diagnostic, Reason};
19use crate::error::RkError;
20use crate::landing::manifest::{self, Provider};
21use crate::landing::{self, Params};
22use crate::output::Output;
23use crate::projection::{Projection, ProjectionInput, TargetEvidence};
24use crate::stage::{self, OutputSource, Receipt, clean};
25
26/// The machine form of a staging: the receipt as written, plus where the
27/// output path came from and what follows.
28#[derive(Debug, Serialize)]
29struct Report<'a> {
30    /// The receipt, its `schema` first.
31    #[serde(flatten)]
32    receipt: &'a Receipt,
33    /// `--output`, `RK_STAGE_ROOT`, or `state root`.
34    output_source: &'static str,
35    /// What plausibly follows.
36    next: Vec<String>,
37}
38
39/// The machine form of a cleanup.
40#[derive(Debug, Serialize)]
41struct CleanReport {
42    /// The shape version of this document.
43    schema: &'static str,
44    /// The stage that was removed.
45    stage_root: String,
46    /// The target its receipt named.
47    target: String,
48    /// Whether the directory is gone.
49    removed: bool,
50    /// The command that recreates it.
51    recovery: String,
52    /// What plausibly follows.
53    next: Vec<String>,
54}
55
56/// Stage the candidate, or remove a stage.
57///
58/// # Errors
59///
60/// Returns [`RkError::Missing`] for a target that is not a directory, the
61/// parameter resolution's own failures, a `destructive-refusal` for a
62/// stage root inside the target, a `state-drift` refusal for an existing
63/// nonempty output, and [`RkError::Io`] for a write that fails;
64/// `clean` returns the refusals `crate::stage::clean::validate` names.
65pub fn run(args: &StageArgs) -> Result<(), RkError> {
66    match &args.action {
67        Some(StageAction::Clean { path, json }) => remove(Output::new(*json), path),
68        Some(StageAction::Compare { root, target, json }) => {
69            compare(Output::new(*json), root, target)
70        }
71        None => create(args),
72    }
73}
74
75/// Compare a stage with its target, row by row.
76fn compare(out: Output, root: &Utf8Path, target: &Utf8Path) -> Result<(), RkError> {
77    if !target.is_dir() {
78        return Err(RkError::missing(
79            Diagnostic::new(
80                Reason::TargetNotFound,
81                format!("target {target} is not a directory"),
82            )
83            .expected("the repository the stage was taken for"),
84        ));
85    }
86    let report = stage::compare::compare(root, target)?;
87    out.result_line(format!("stage: {}", report.stage_root));
88    out.result_line(format!("target: {}", report.target));
89    if let Some(reason) = &report.history.reason {
90        out.result_line(format!("history: {reason}"));
91    }
92    for row in &report.rows {
93        let mut line = format!("{} {} ({}, {})", row.state, row.path, row.kind, row.word);
94        if let Some(provenance) = row.provenance {
95            let _ = write!(line, ", provenance {provenance}");
96        }
97        if let Some(edited) = row.edited_after_added {
98            line.push_str(if edited {
99                ", edited after the commit that brought it"
100            } else {
101                ", untouched since the commit that brought it"
102            });
103        }
104        out.result_line(line);
105    }
106    let next = if stage::compare::rendered_equal(&report) {
107        vec!["every rendered destination is equal: the landing is proven".to_owned()]
108    } else {
109        vec![
110            "a conflict reads collision: answer it, then name it with --replace <path> on the landing verb".to_owned(),
111            format!(
112                "rk init, rk upgrade, or rk adopt --stage {} --target {} previews the landing",
113                report.stage_root, report.target
114            ),
115        ]
116    };
117    out.next(&next);
118    out.emit(&report)
119}
120
121fn create(args: &StageArgs) -> Result<(), RkError> {
122    let out = Output::new(args.json);
123    if !args.target.is_dir() {
124        return Err(RkError::missing(
125            Diagnostic::new(
126                Reason::TargetNotFound,
127                format!("target {} is not a directory", args.target),
128            )
129            .expected("an existing repository to stage the candidate for"),
130        ));
131    }
132    let target = canonical(&args.target)?;
133    let config = crate::config::load(target.as_std_path())?;
134    // The record explains the target to the projection; a record this
135    // binary cannot read is reported and stands aside, because a stage is
136    // evidence and refusing to explain would leave the agent with less.
137    let record = match manifest::load(&target) {
138        Ok(record) => record,
139        Err(error) => {
140            out.warn(format!(
141                "the landing record was not read, so the candidate is computed as a first landing: {error}"
142            ));
143            None
144        }
145    };
146    let receipt_schema_version = stage::recorded_schema_version(&target);
147    let params = Params::resolve(
148        &target,
149        &landing::Inputs {
150            nix: args.nix_packaging.then_some(true),
151            reporting_policy: args.reporting_policy.then_some(true),
152            scorecard: args.scorecard.then_some(true),
153            workflow_guards: args.workflow_guards.then_some(true),
154            code_scanning: args
155                .code_scanning
156                .as_deref()
157                .map(Provider::parse)
158                .transpose()?,
159            ..args.profile.inputs()?
160        },
161        config.as_ref(),
162        record.as_ref(),
163        stage::purpose(&target, config.as_ref(), record.is_some())?,
164    )?;
165    let evidence = TargetEvidence::gather(&target, record.as_ref())?;
166    let projection = Projection::compute(&ProjectionInput {
167        params: params.clone(),
168        evidence,
169    })?;
170    let (output, source) = stage::resolve_output(args.output.as_deref(), target.as_std_path())?;
171    let prepared = stage::prepare(&output, source, target.as_std_path())?;
172    let composed = stage::compose(
173        &projection,
174        &params,
175        target.as_std_path(),
176        prepared.resolved(),
177        record.as_ref(),
178        receipt_schema_version,
179    );
180    stage::write(&prepared, &composed)?;
181    let receipt = &composed.receipt;
182    let next = vec![
183        format!(
184            "read {}/{} for the candidate inventory and its reasons",
185            receipt.stage_root,
186            stage::RECEIPT_NAME
187        ),
188        format!(
189            "compare {}/{} with {} before anything lands",
190            receipt.stage_root,
191            stage::ARTIFACTS_DIR,
192            receipt.target
193        ),
194        format!(
195            "rk stage clean {} removes the stage once the landing is verified",
196            receipt.stage_root
197        ),
198    ];
199    render(out, receipt, source);
200    out.next(&next);
201    out.emit(&Report {
202        receipt,
203        output_source: source.as_str(),
204        next,
205    })
206}
207
208/// The human report, one fact per line.
209fn render(out: Output, receipt: &Receipt, source: OutputSource) {
210    out.result_line(format!("stage: {}", receipt.stage_root));
211    out.result_line(format!("output: from {}", source.as_str()));
212    out.result_line(format!("target: {}", receipt.target));
213    let parameters = &receipt.parameters;
214    out.result_line(format!(
215        "parameters: {}",
216        crate::commands::profile::describe(
217            &parameters.profile,
218            &parameters.git,
219            &parameters.capabilities,
220            &parameters.repo
221        )
222    ));
223    for note in &receipt.capabilities {
224        out.result_line(format!(
225            "capability {}: {}{}",
226            note.id,
227            note.status,
228            note.reason
229                .as_deref()
230                .map_or_else(String::new, |reason| format!(" ({reason})"))
231        ));
232    }
233    out.result_line(format!(
234        "landing record: {}",
235        receipt.receipt_schema_version.map_or_else(
236            || "none".to_owned(),
237            |version| format!("schema_version {version}")
238        )
239    ));
240    out.result_line(format!("candidates: {}", receipt.candidates.len()));
241    for candidate in &receipt.candidates {
242        out.result_line(format!(
243            "  {}/{} ({}, {})",
244            stage::ARTIFACTS_DIR,
245            candidate.destination,
246            candidate.kind.as_str(),
247            candidate.placement
248        ));
249    }
250    for note in &receipt.omissions {
251        out.result_line(format!("omitted {}: {}", note.destination, note.reason));
252        if let Some(action) = &note.action {
253            out.result_line(format!("  action: {action}"));
254        }
255    }
256    for note in &receipt.collisions {
257        out.result_line(format!("collision {}: {}", note.destination, note.reason));
258    }
259    for destination in &receipt.retired {
260        out.result_line(format!(
261            "retired {destination}: recorded, no longer produced; target-owned from the next landing"
262        ));
263    }
264    for destination in &receipt.seeded_present {
265        out.result_line(format!("seeded present {destination}: a landing keeps it"));
266    }
267    for destination in &receipt.state_present {
268        out.result_line(format!("state present {destination}: a landing keeps it"));
269    }
270    out.result_line(format!(
271        "reference: {}",
272        receipt
273            .reference
274            .iter()
275            .map(|root| format!("{}/{root}", stage::REFERENCE_DIR))
276            .collect::<Vec<_>>()
277            .join(", ")
278    ));
279}
280
281fn remove(out: Output, path: &Utf8Path) -> Result<(), RkError> {
282    let validated = clean::validate(path.as_std_path())?;
283    clean::remove(&validated)?;
284    let stage_root = validated.resolved().display().to_string();
285    let target = validated.target().to_owned();
286    let recovery = format!("rk stage --target {target} --output {stage_root}");
287    out.result_line(format!("removed {stage_root}"));
288    out.result_line(format!("recovery: {recovery} stages the candidate again"));
289    let next = vec![
290        format!("rk status --target {target} reports what the target holds"),
291        format!("rk stage --target {target} stages this binary's candidate again"),
292    ];
293    out.next(&next);
294    out.emit(&CleanReport {
295        schema: "rk.stage-clean/1",
296        stage_root,
297        target,
298        removed: true,
299        recovery,
300        next,
301    })
302}
303
304/// The canonical absolute target, as text.
305fn canonical(target: &Utf8Path) -> Result<Utf8PathBuf, RkError> {
306    let path = std::fs::canonicalize(target)?;
307    Utf8PathBuf::from_path_buf(path)
308        .map_err(|path| RkError::Usage(format!("the target path is not UTF-8: {}", path.display())))
309}
310
311#[cfg(test)]
312mod tests {
313    use super::{CleanReport, Report};
314    use crate::landing::Integration;
315    use crate::stage::{Parameters, Receipt, STAGE_SCHEMA};
316
317    /// The `rk stage --json` report is the receipt with the output source
318    /// and the next lines beside it, held by snapshot.
319    #[test]
320    fn the_stage_report_schema_snapshot_holds() {
321        let receipt = Receipt {
322            schema: STAGE_SCHEMA.to_owned(),
323            rk_version: "0.0.0".into(),
324            target: "/tmp/t".into(),
325            stage_root: "/tmp/s".into(),
326            parameters: Parameters {
327                profile: crate::profile::ProfileSnapshot {
328                    technologies: vec!["rust".into()],
329                    forge: Some("github".into()),
330                    release: crate::profile::ReleaseIntent {
331                        mode: crate::profile::ReleaseMode::Automatic,
332                        driver: Some("rust".into()),
333                        style: None,
334                        line_prefix: Some("release/".into()),
335                    },
336                },
337                git: crate::profile::GitWorkflow {
338                    trunk: "main".into(),
339                    checkout_mode: crate::landing::CheckoutMode::MainWorktree,
340                    integration: Integration::Local,
341                },
342                capabilities: crate::profile::CapabilityRequests {
343                    nix_packaging: true,
344                    reporting_policy: true,
345                    scorecard: false,
346                    code_scanning: None,
347                    workflow_guards: Some(false),
348                },
349                repo: "acme/widget".into(),
350                security_contact: String::new(),
351                required_check: String::new(),
352                required_workflow: String::new(),
353                security_response: "best-effort".into(),
354            },
355            capabilities: vec![],
356            receipt_schema_version: None,
357            candidates: vec![],
358            omissions: vec![],
359            collisions: vec![],
360            retired: vec![],
361            seeded_present: vec![],
362            state_present: vec![],
363            reference: vec!["CHANGELOG.md".into()],
364        };
365        let report = Report {
366            receipt: &receipt,
367            output_source: "--output",
368            next: vec![
369                "rk stage clean /tmp/s removes the stage once the landing is verified".into(),
370            ],
371        };
372        assert_eq!(
373            serde_json::to_string(&report).expect("a report serializes"),
374            r#"{"schema":"rk.stage/6","rk_version":"0.0.0","target":"/tmp/t","stage_root":"/tmp/s","parameters":{"profile":{"technologies":["rust"],"forge":"github","release":{"mode":"automatic","driver":"rust","line_prefix":"release/"}},"git":{"trunk":"main","checkout_mode":"main-worktree","integration":"local"},"capabilities":{"nix_packaging":true,"reporting_policy":true,"scorecard":false,"workflow_guards":false},"repo":"acme/widget","security_contact":"","security_response":"best-effort","required_check":"","required_workflow":""},"capabilities":[],"receipt_schema_version":null,"candidates":[],"omissions":[],"collisions":[],"retired":[],"seeded_present":[],"state_present":[],"reference":["CHANGELOG.md"],"output_source":"--output","next":["rk stage clean /tmp/s removes the stage once the landing is verified"]}"#
375        );
376    }
377
378    /// The complete `rk.stage-clean/1` shape, held by snapshot.
379    #[test]
380    fn the_stage_clean_report_schema_snapshot_holds() {
381        let report = CleanReport {
382            schema: "rk.stage-clean/1",
383            stage_root: "/tmp/s".into(),
384            target: "/tmp/t".into(),
385            removed: true,
386            recovery: "rk stage --target /tmp/t --output /tmp/s".into(),
387            next: vec!["rk status --target /tmp/t reports what the target holds".into()],
388        };
389        assert_eq!(
390            serde_json::to_string(&report).expect("a report serializes"),
391            r#"{"schema":"rk.stage-clean/1","stage_root":"/tmp/s","target":"/tmp/t","removed":true,"recovery":"rk stage --target /tmp/t --output /tmp/s","next":["rk status --target /tmp/t reports what the target holds"]}"#
392        );
393    }
394}