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