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, Style, Workflow};
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 tech: args.tech.as_deref(),
100 forge: args.forge.as_deref(),
101 repo: args.repo.as_deref(),
102 workflow: args.workflow.as_deref().map(Workflow::parse).transpose()?,
103 style: args.style.as_deref().map(Style::parse).transpose()?,
104 nix: args.nix.then_some(true),
105 scorecard: args.scorecard.then_some(true),
106 code_scanning: args
107 .code_scanning
108 .as_deref()
109 .map(Provider::parse)
110 .transpose()?,
111 },
112 config.as_ref(),
113 record.as_ref(),
114 if record.is_some() {
115 landing::Purpose::Upgrade
116 } else {
117 landing::Purpose::Init
118 },
119 )?;
120 let evidence = TargetEvidence::gather(&target, record.as_ref())?;
121 let projection = Projection::compute(&ProjectionInput {
122 params: params.clone(),
123 evidence,
124 })?;
125 let (output, source) = stage::resolve_output(args.output.as_deref(), target.as_std_path())?;
126 let prepared = stage::prepare(&output, source, target.as_std_path())?;
127 let composed = stage::compose(
128 &projection,
129 ¶ms,
130 target.as_std_path(),
131 prepared.resolved(),
132 record.as_ref(),
133 receipt_schema_version,
134 );
135 stage::write(&prepared, &composed)?;
136 let receipt = &composed.receipt;
137 let next = vec![
138 format!(
139 "read {}/{} for the candidate inventory and its reasons",
140 receipt.stage_root,
141 stage::RECEIPT_NAME
142 ),
143 format!(
144 "compare {}/{} with {} before anything lands",
145 receipt.stage_root,
146 stage::ARTIFACTS_DIR,
147 receipt.target
148 ),
149 format!(
150 "rk stage clean {} removes the stage once the landing is verified",
151 receipt.stage_root
152 ),
153 ];
154 render(out, receipt, source);
155 out.next(&next);
156 out.emit(&Report {
157 receipt,
158 output_source: source.as_str(),
159 next,
160 })
161}
162
163fn render(out: Output, receipt: &Receipt, source: OutputSource) {
165 out.result_line(format!("stage: {}", receipt.stage_root));
166 out.result_line(format!("output: from {}", source.as_str()));
167 out.result_line(format!("target: {}", receipt.target));
168 let parameters = &receipt.parameters;
169 out.result_line(format!(
170 "parameters: tech {}, forge {}, repo {}, workflow {}, style {}, nix {}",
171 parameters.tech,
172 parameters.forge,
173 parameters.repo,
174 parameters.workflow.as_str(),
175 parameters.style.map_or("unresolved", Style::as_str),
176 if parameters.nix { "on" } else { "off" }
177 ));
178 out.result_line(format!(
179 "landing record: {}",
180 receipt.receipt_schema_version.map_or_else(
181 || "none".to_owned(),
182 |version| format!("schema_version {version}")
183 )
184 ));
185 out.result_line(format!("candidates: {}", receipt.candidates.len()));
186 for candidate in &receipt.candidates {
187 out.result_line(format!(
188 " {}/{} ({}, {})",
189 stage::ARTIFACTS_DIR,
190 candidate.destination,
191 candidate.kind.as_str(),
192 candidate.placement
193 ));
194 }
195 for note in &receipt.omissions {
196 out.result_line(format!("omitted {}: {}", note.destination, note.reason));
197 }
198 for note in &receipt.collisions {
199 out.result_line(format!("collision {}: {}", note.destination, note.reason));
200 }
201 for destination in &receipt.retired {
202 out.result_line(format!(
203 "retired {destination}: recorded, no longer produced; target-owned from the next landing"
204 ));
205 }
206 for destination in &receipt.seeded_present {
207 out.result_line(format!("seeded present {destination}: a landing keeps it"));
208 }
209 for destination in &receipt.state_present {
210 out.result_line(format!("state present {destination}: a landing keeps it"));
211 }
212 out.result_line(format!(
213 "reference: {}",
214 receipt
215 .reference
216 .iter()
217 .map(|root| format!("{}/{root}", stage::REFERENCE_DIR))
218 .collect::<Vec<_>>()
219 .join(", ")
220 ));
221}
222
223fn remove(out: Output, path: &Utf8Path) -> Result<(), RkError> {
224 let validated = clean::validate(path.as_std_path())?;
225 clean::remove(&validated)?;
226 let stage_root = validated.resolved().display().to_string();
227 let target = validated.target().to_owned();
228 let recovery = format!("rk stage --target {target} --output {stage_root}");
229 out.result_line(format!("removed {stage_root}"));
230 out.result_line(format!("recovery: {recovery} stages the candidate again"));
231 let next = vec![
232 format!("rk status --target {target} reports what the target holds"),
233 format!("rk stage --target {target} stages this binary's candidate again"),
234 ];
235 out.next(&next);
236 out.emit(&CleanReport {
237 schema: "rk.stage-clean/1",
238 stage_root,
239 target,
240 removed: true,
241 recovery,
242 next,
243 })
244}
245
246fn canonical(target: &Utf8Path) -> Result<Utf8PathBuf, RkError> {
248 let path = std::fs::canonicalize(target)?;
249 Utf8PathBuf::from_path_buf(path)
250 .map_err(|path| RkError::Usage(format!("the target path is not UTF-8: {}", path.display())))
251}
252
253#[cfg(test)]
254mod tests {
255 use super::{CleanReport, Report};
256 use crate::stage::{Parameters, Receipt, STAGE_SCHEMA};
257
258 #[test]
261 fn the_stage_report_schema_snapshot_holds() {
262 let receipt = Receipt {
263 schema: STAGE_SCHEMA.to_owned(),
264 rk_version: "0.0.0".into(),
265 target: "/tmp/t".into(),
266 stage_root: "/tmp/s".into(),
267 parameters: Parameters {
268 tech: "rust".into(),
269 forge: "github".into(),
270 repo: "acme/widget".into(),
271 workflow: crate::landing::Workflow::Branches,
272 style: None,
273 nix: true,
274 scorecard: false,
275 code_scanning: None,
276 trunk: "main".into(),
277 line_prefix: "release/".into(),
278 security_contact: String::new(),
279 security_response: "best-effort".into(),
280 },
281 receipt_schema_version: None,
282 candidates: vec![],
283 omissions: vec![],
284 collisions: vec![],
285 retired: vec![],
286 seeded_present: vec![],
287 state_present: vec![],
288 reference: vec!["CHANGELOG.md".into()],
289 };
290 let report = Report {
291 receipt: &receipt,
292 output_source: "--output",
293 next: vec![
294 "rk stage clean /tmp/s removes the stage once the landing is verified".into(),
295 ],
296 };
297 assert_eq!(
298 serde_json::to_string(&report).expect("a report serializes"),
299 r#"{"schema":"rk.stage/3","rk_version":"0.0.0","target":"/tmp/t","stage_root":"/tmp/s","parameters":{"tech":"rust","forge":"github","repo":"acme/widget","workflow":"branches","style":null,"nix":true,"scorecard":false,"code_scanning":null,"trunk":"main","line_prefix":"release/","security_contact":"","security_response":"best-effort"},"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"]}"#
300 );
301 }
302
303 #[test]
305 fn the_stage_clean_report_schema_snapshot_holds() {
306 let report = CleanReport {
307 schema: "rk.stage-clean/1",
308 stage_root: "/tmp/s".into(),
309 target: "/tmp/t".into(),
310 removed: true,
311 recovery: "rk stage --target /tmp/t --output /tmp/s".into(),
312 next: vec!["rk status --target /tmp/t reports what the target holds".into()],
313 };
314 assert_eq!(
315 serde_json::to_string(&report).expect("a report serializes"),
316 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"]}"#
317 );
318 }
319}