Skip to main content

release_kit/stage/
read.rs

1//! Reading a stage back as a landing's answers: the receipt's
2//! parameters, never its bytes.
3//!
4//! A production verb named `--stage <root>` reads `stage.json`, refuses a
5//! receipt another binary wrote or one taken for another target, takes
6//! every answer the stage resolved as its own, and renders again from
7//! scratch. Once the answers resolve, they must equal the stage's, so the
8//! landing cannot differ from the stage a comparison reads against.
9//!
10//! SATISFIES staging:a-landing-reads-the-stage-parameters
11
12use std::path::Path;
13
14use camino::Utf8Path;
15
16use super::{Parameters, RECEIPT_NAME, Receipt};
17use crate::diagnostic::{Diagnostic, Reason};
18use crate::error::RkError;
19use crate::landing::Params;
20use crate::profile::Inputs;
21
22/// Read the receipt at `root` and hold it to this binary and this target.
23///
24/// # Errors
25///
26/// [`RkError::Missing`] where no receipt reads at `root`, and a
27/// `state-drift` refusal for a receipt from another version or another
28/// target.
29pub fn load(root: &Utf8Path, canonical_target: &Path) -> Result<Receipt, RkError> {
30    let path = root.join(RECEIPT_NAME);
31    let receipt: Receipt = std::fs::read(&path)
32        .ok()
33        .and_then(|bytes| serde_json::from_slice(&bytes).ok())
34        .ok_or_else(|| {
35            RkError::missing(
36                Diagnostic::new(
37                    Reason::TargetNotFound,
38                    format!("{path} is no stage receipt this binary reads"),
39                )
40                .expected("the root of a stage `rk stage` wrote")
41                .action("rk stage --target <target> writes one"),
42            )
43        })?;
44    if receipt.rk_version != env!("CARGO_PKG_VERSION") {
45        return Err(RkError::refusal(
46            Diagnostic::new(
47                Reason::StateDrift,
48                format!(
49                    "the stage at {root} was written by rk {}, and this is rk {}; nothing was written",
50                    receipt.rk_version,
51                    env!("CARGO_PKG_VERSION")
52                ),
53            )
54            .expected("a stage this binary wrote")
55            .action("stage again with this binary, then land from that stage")
56            .target_state("unchanged"),
57        ));
58    }
59    let target = canonical_target.display().to_string();
60    if receipt.target != target {
61        return Err(RkError::refusal(
62            Diagnostic::new(
63                Reason::StateDrift,
64                format!(
65                    "the stage at {root} was taken for {}, not {target}; nothing was written",
66                    receipt.target
67                ),
68            )
69            .expected("a stage taken for this target")
70            .action(format!("rk stage --target {target} writes one"))
71            .target_state("unchanged"),
72        ));
73    }
74    Ok(receipt)
75}
76
77/// Every answer the stage resolved, as invocation answers.
78#[must_use]
79pub fn inputs(parameters: &Parameters) -> Inputs<'_> {
80    let release = &parameters.profile.release;
81    Inputs {
82        technologies: &parameters.profile.technologies,
83        forge: parameters.profile.forge.as_deref(),
84        repo: (!parameters.repo.is_empty()).then_some(parameters.repo.as_str()),
85        release_mode: Some(release.mode),
86        release_driver: release.driver.as_deref(),
87        style: release.style,
88        trunk: Some(parameters.git.trunk.as_str()),
89        checkout_mode: Some(parameters.git.checkout_mode),
90        integration: Some(parameters.git.integration),
91        merge_form: Some(parameters.git.merge_form),
92        required_check: (!parameters.required_check.is_empty())
93            .then_some(parameters.required_check.as_str()),
94        required_workflow: (!parameters.required_workflow.is_empty())
95            .then_some(parameters.required_workflow.as_str()),
96        nix: Some(parameters.capabilities.nix_packaging),
97        reporting_policy: Some(parameters.capabilities.reporting_policy),
98        scorecard: Some(parameters.capabilities.scorecard),
99        workflow_guards: parameters.capabilities.workflow_guards,
100        code_scanning: Some(parameters.capabilities.code_scanning),
101    }
102}
103
104/// Refuse where the answers a landing resolved differ from the stage's:
105/// an answer no flag carries, such as the security contact, moved in the
106/// configuration after the stage was written.
107///
108/// # Errors
109///
110/// A `state-drift` refusal naming the restage.
111pub fn confirm(params: &Params, receipt: &Receipt, root: &Utf8Path) -> Result<(), RkError> {
112    let resolved = serde_json::to_value(Parameters::from(params)).map_err(anyhow::Error::from)?;
113    let staged = serde_json::to_value(&receipt.parameters).map_err(anyhow::Error::from)?;
114    if resolved == staged {
115        return Ok(());
116    }
117    Err(RkError::refusal(
118        Diagnostic::new(
119            Reason::StateDrift,
120            format!(
121                "the target's answers moved since the stage at {root} was written, and nothing was written"
122            ),
123        )
124        .expected("the answers the stage resolved")
125        .action("stage again, compare, then land from the new stage")
126        .target_state("unchanged"),
127    ))
128}
129
130/// The refusal for an answer given beside `--stage`, which carries every
131/// answer itself.
132#[must_use]
133pub fn answered_twice() -> RkError {
134    RkError::Usage(
135        "--stage carries every answer the stage resolved; pass the answers to rk stage instead"
136            .to_owned(),
137    )
138}