1use 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#[derive(Debug, Serialize)]
27struct Report<'a> {
28 #[serde(flatten)]
30 receipt: &'a Receipt,
31 output_source: &'static str,
33 next: Vec<String>,
35}
36
37#[derive(Debug, Serialize)]
39struct CleanReport {
40 schema: &'static str,
42 stage_root: String,
44 target: String,
46 removed: bool,
48 recovery: String,
50 next: Vec<String>,
52}
53
54pub 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 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 ¶ms,
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
160fn 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 ¶meters.profile,
170 ¶meters.git,
171 ¶meters.capabilities,
172 ¶meters.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) = ¬e.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
256fn 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 #[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 #[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}