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    landing::accepted_drift_faults(&projection, record.as_ref(), config.as_ref())?;
171    let (output, source) = stage::resolve_output(args.output.as_deref(), target.as_std_path())?;
172    let prepared = stage::prepare(&output, source, target.as_std_path())?;
173    let composed = stage::compose(
174        &projection,
175        &params,
176        target.as_std_path(),
177        prepared.resolved(),
178        record.as_ref(),
179        receipt_schema_version,
180    );
181    stage::write(&prepared, &composed)?;
182    let receipt = &composed.receipt;
183    let next = vec![
184        format!(
185            "read {}/{} for the candidate inventory and its reasons",
186            receipt.stage_root,
187            stage::RECEIPT_NAME
188        ),
189        format!(
190            "compare {}/{} with {} before anything lands",
191            receipt.stage_root,
192            stage::ARTIFACTS_DIR,
193            receipt.target
194        ),
195        format!(
196            "rk stage clean {} removes the stage once the landing is verified",
197            receipt.stage_root
198        ),
199    ];
200    render(out, receipt, source);
201    out.next(&next);
202    out.emit(&Report {
203        receipt,
204        output_source: source.as_str(),
205        next,
206    })
207}
208
209/// The human report, one fact per line.
210fn render(out: Output, receipt: &Receipt, source: OutputSource) {
211    out.result_line(format!("stage: {}", receipt.stage_root));
212    out.result_line(format!("output: from {}", source.as_str()));
213    out.result_line(format!("target: {}", receipt.target));
214    let parameters = &receipt.parameters;
215    out.result_line(format!(
216        "parameters: {}",
217        crate::commands::profile::describe(
218            &parameters.profile,
219            &parameters.git,
220            &parameters.capabilities,
221            &parameters.repo
222        )
223    ));
224    for note in &receipt.capabilities {
225        out.result_line(format!(
226            "capability {}: {}{}",
227            note.id,
228            note.status,
229            note.reason
230                .as_deref()
231                .map_or_else(String::new, |reason| format!(" ({reason})"))
232        ));
233    }
234    out.result_line(format!(
235        "landing record: {}",
236        receipt.receipt_schema_version.map_or_else(
237            || "none".to_owned(),
238            |version| format!("schema_version {version}")
239        )
240    ));
241    out.result_line(format!("candidates: {}", receipt.candidates.len()));
242    for candidate in &receipt.candidates {
243        out.result_line(format!(
244            "  {}/{} ({}, {})",
245            stage::ARTIFACTS_DIR,
246            candidate.destination,
247            candidate.kind.as_str(),
248            candidate.placement
249        ));
250    }
251    for note in &receipt.omissions {
252        out.result_line(format!("omitted {}: {}", note.destination, note.reason));
253        if let Some(action) = &note.action {
254            out.result_line(format!("  action: {action}"));
255        }
256    }
257    for note in &receipt.collisions {
258        out.result_line(format!("collision {}: {}", note.destination, note.reason));
259    }
260    for destination in &receipt.retired {
261        out.result_line(format!(
262            "retired {destination}: recorded, no longer produced; target-owned from the next landing"
263        ));
264    }
265    for moved in &receipt.secrets_moved {
266        out.result_line(moved.line());
267    }
268    for destination in &receipt.seeded_present {
269        out.result_line(format!("seeded present {destination}: a landing keeps it"));
270    }
271    for destination in &receipt.state_present {
272        out.result_line(format!("state present {destination}: a landing keeps it"));
273    }
274    out.result_line(format!(
275        "reference: {}",
276        receipt
277            .reference
278            .iter()
279            .map(|root| format!("{}/{root}", stage::REFERENCE_DIR))
280            .collect::<Vec<_>>()
281            .join(", ")
282    ));
283}
284
285fn remove(out: Output, path: &Utf8Path) -> Result<(), RkError> {
286    let validated = clean::validate(path.as_std_path())?;
287    clean::remove(&validated)?;
288    let stage_root = validated.resolved().display().to_string();
289    let target = validated.target().to_owned();
290    let recovery = format!("rk stage --target {target} --output {stage_root}");
291    out.result_line(format!("removed {stage_root}"));
292    out.result_line(format!("recovery: {recovery} stages the candidate again"));
293    let next = vec![
294        format!("rk status --target {target} reports what the target holds"),
295        format!("rk stage --target {target} stages this binary's candidate again"),
296    ];
297    out.next(&next);
298    out.emit(&CleanReport {
299        schema: "rk.stage-clean/1",
300        stage_root,
301        target,
302        removed: true,
303        recovery,
304        next,
305    })
306}
307
308/// The canonical absolute target, as text.
309fn canonical(target: &Utf8Path) -> Result<Utf8PathBuf, RkError> {
310    let path = std::fs::canonicalize(target)?;
311    Utf8PathBuf::from_path_buf(path)
312        .map_err(|path| RkError::Usage(format!("the target path is not UTF-8: {}", path.display())))
313}
314
315#[cfg(test)]
316mod tests {
317    use super::{CleanReport, Report};
318    use crate::landing::Integration;
319    use crate::stage::{Parameters, Receipt, STAGE_SCHEMA};
320
321    /// The `rk stage --json` report is the receipt with the output source
322    /// and the next lines beside it, held by snapshot.
323    #[test]
324    fn the_stage_report_schema_snapshot_holds() {
325        let receipt = Receipt {
326            schema: STAGE_SCHEMA.to_owned(),
327            rk_version: "0.0.0".into(),
328            target: "/tmp/t".into(),
329            stage_root: "/tmp/s".into(),
330            parameters: Parameters {
331                profile: crate::profile::ProfileSnapshot {
332                    technologies: vec!["rust".into()],
333                    forge: Some("github".into()),
334                    release: crate::profile::ReleaseIntent {
335                        mode: crate::profile::ReleaseMode::Automatic,
336                        driver: Some("rust".into()),
337                        style: None,
338                        line_prefix: Some("release/".into()),
339                        registry: None,
340                        build: None,
341                    },
342                },
343                git: crate::profile::GitWorkflow {
344                    trunk: "main".into(),
345                    checkout_mode: crate::landing::CheckoutMode::MainWorktree,
346                    integration: Integration::Local,
347                    merge_form: crate::landing::MergeForm::Squash,
348                },
349                capabilities: crate::profile::CapabilityRequests {
350                    nix_packaging: true,
351                    reporting_policy: true,
352                    scorecard: false,
353                    code_scanning: None,
354                    workflow_guards: Some(false),
355                },
356                repo: "acme/widget".into(),
357                security_contact: String::new(),
358                required_check: String::new(),
359                required_workflow: String::new(),
360                dev_environment: None,
361                security_response: "best-effort".into(),
362                security_scope: String::new(),
363            },
364            capabilities: vec![],
365            receipt_schema_version: None,
366            candidates: vec![],
367            omissions: vec![],
368            collisions: vec![],
369            retired: vec![],
370            seeded_present: vec![],
371            state_present: vec![],
372            secrets_moved: Vec::new(),
373            reference: vec!["CHANGELOG.md".into()],
374        };
375        let report = Report {
376            receipt: &receipt,
377            output_source: "--output",
378            next: vec![
379                "rk stage clean /tmp/s removes the stage once the landing is verified".into(),
380            ],
381        };
382        assert_eq!(
383            serde_json::to_string(&report).expect("a report serializes"),
384            r#"{"schema":"rk.stage/7","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","merge_form":"squash"},"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":[],"secrets_moved":[],"reference":["CHANGELOG.md"],"output_source":"--output","next":["rk stage clean /tmp/s removes the stage once the landing is verified"]}"#
385        );
386    }
387
388    /// The complete `rk.stage-clean/1` shape, held by snapshot.
389    #[test]
390    fn the_stage_clean_report_schema_snapshot_holds() {
391        let report = CleanReport {
392            schema: "rk.stage-clean/1",
393            stage_root: "/tmp/s".into(),
394            target: "/tmp/t".into(),
395            removed: true,
396            recovery: "rk stage --target /tmp/t --output /tmp/s".into(),
397            next: vec!["rk status --target /tmp/t reports what the target holds".into()],
398        };
399        assert_eq!(
400            serde_json::to_string(&report).expect("a report serializes"),
401            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"]}"#
402        );
403    }
404}