1use 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#[derive(Debug, Serialize)]
29struct Report<'a> {
30 #[serde(flatten)]
32 receipt: &'a Receipt,
33 output_source: &'static str,
35 next: Vec<String>,
37}
38
39#[derive(Debug, Serialize)]
41struct CleanReport {
42 schema: &'static str,
44 stage_root: String,
46 target: String,
48 removed: bool,
50 recovery: String,
52 next: Vec<String>,
54}
55
56pub 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
75fn 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 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 ¶ms,
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
209fn 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 ¶meters.profile,
219 ¶meters.git,
220 ¶meters.capabilities,
221 ¶meters.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) = ¬e.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
308fn 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 #[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 #[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}