1use serde::Serialize;
16
17use crate::cli::upgrade::UpgradeArgs;
18use crate::commands::reconcile::{self, FrontApplied, Trace};
19use crate::diagnostic::{Diagnostic, Reason};
20use crate::embedded;
21use crate::error::RkError;
22use crate::landing::manifest::{self, Alignment, Manifest, Style, Workflow};
23use crate::landing::{self, Kind};
24use crate::output::Output;
25use crate::plan::gather::Flags;
26use crate::plan::{Disposition, Intent, PlanRequest, Planned};
27use crate::release::EmbeddedReleaseSource;
28
29#[derive(Debug, Serialize)]
31struct FileEntry {
32 path: String,
34 kind: &'static str,
36 action: &'static str,
39}
40
41#[derive(Debug, Serialize)]
43struct Report {
44 schema: &'static str,
46 mode: &'static str,
48 target: String,
50 tech: String,
52 forge: String,
54 from_version: String,
56 to_version: &'static str,
58 workflow: &'static str,
61 style: &'static str,
62 nix: bool,
64 #[serde(skip_serializing_if = "Option::is_none")]
67 withheld: Option<Vec<landing::Withheld>>,
68 config: crate::config::Plan,
69 files: Vec<FileEntry>,
71 #[serde(skip_serializing_if = "Option::is_none")]
73 plan: Option<Trace>,
74 next: Vec<String>,
76}
77
78struct Decision {
80 path: String,
81 kind: Kind,
82 action: &'static str,
83}
84
85#[allow(
94 clippy::too_many_lines,
95 reason = "one upgrade run is one linear sequence from the record to the report, and cutting it would separate a refusal from the order it is reported in"
96)]
97pub fn run(args: &UpgradeArgs) -> Result<(), RkError> {
98 let out = Output::new(args.json);
99 let recorded = load_upgradable(&args.target)?;
100 let existing = crate::config::load(args.target.as_std_path())?;
101 let params = resolve_params(args, &recorded, existing.as_ref())?;
102 let style = params
103 .style()
104 .ok_or_else(|| RkError::Usage("landing style is unresolved".into()))?;
105 let request = PlanRequest {
106 target: args.target.clone(),
107 intent: Intent::Upgrade,
108 selector: "embedded".into(),
109 fetch: false,
110 observe_forge: false,
111 flags: Flags {
112 tech: Some(params.tech().to_owned()),
113 forge: Some(params.forge().to_owned()),
114 repo: Some(params.repo().to_owned()),
115 workflow: Some(params.workflow().as_str().to_owned()),
116 style: Some(style.as_str().to_owned()),
117 nix: Some(params.nix()),
118 },
119 decisions: reconcile::parse_decisions(&args.decide)?,
120 };
121 refuse_non_regular(
122 &args.target,
123 &landing::projection(&EmbeddedReleaseSource, ¶ms)?,
124 )?;
125 let planned = reconcile::compute(&request, &manifest::now())?;
126 let config = planned
127 .config
128 .clone()
129 .ok_or_else(|| RkError::Usage("landing parameters are unresolved".into()))?;
130 for key in &config.changes {
131 out.result_line(format!("configuration changes {key}"));
132 }
133 out.result_line(format!("{} {}", config.action, crate::config::CONFIG_PATH));
134
135 let hooks_defect = planned
136 .plan
137 .preconditions
138 .iter()
139 .any(|p| p.id == "hooks-file-spliceable" && !p.evaluation.holds());
140 let (decisions, conflicts) = decide_all(&planned, hooks_defect);
141 let dropped: Vec<String> = planned
144 .plan
145 .observed_state
146 .installation
147 .destinations
148 .iter()
149 .filter(|destination| destination.recorded_kind.is_some())
150 .filter(|destination| {
151 !planned
152 .outcomes
153 .iter()
154 .any(|outcome| outcome.path == destination.path)
155 })
156 .map(|destination| destination.path.clone())
157 .collect();
158
159 if args.apply && !conflicts.is_empty() {
160 return Err(refuse_conflicts(&conflicts));
161 }
162
163 let applied = if args.apply {
164 Some(reconcile::apply_in_process(&planned, &request, "upgrade")?)
165 } else {
166 None
167 };
168 let mut sentinels: Vec<String> = Vec::new();
169 for decision in &decisions {
170 if args.apply && matches!(decision.action, "updated" | "added") {
171 if let Some(bytes) = FrontApplied::written(&planned, &decision.path) {
172 collect_sentinels(&decision.path, bytes, &mut sentinels);
173 }
174 }
175 out.result_line(describe(decision));
176 }
177 for path in &dropped {
178 out.result_line(format!(
179 "dropped {path} (no longer shipped; now target-owned)"
180 ));
181 }
182 for entry in &planned.withheld {
183 out.result_line(format!("withheld {}: {}", entry.path, entry.reason));
184 }
185
186 if let Some(applied) = &applied {
187 out.result_line(format!("rewrote {}", manifest::MANIFEST_PATH));
188 out.result_line(applied.line());
189 for sentinel in &sentinels {
190 out.result_line(format!("fill this sentinel: {sentinel}"));
191 }
192 }
193
194 let next = next_lines(args, conflicts.is_empty());
195 out.next(&next);
196 out.emit(&Report {
197 schema: "rk.upgrade/6",
198 config,
199 mode: if args.apply { "apply" } else { "preview" },
200 target: args.target.to_string(),
201 tech: params.tech().into(),
202 forge: params.forge().into(),
203 from_version: recorded.rk_version,
204 to_version: env!("CARGO_PKG_VERSION"),
205 workflow: params.workflow().as_str(),
206 style: style.as_str(),
207 nix: params.nix(),
208 withheld: (!planned.withheld.is_empty()).then(|| planned.withheld.clone()),
209 files: decisions
210 .iter()
211 .map(|decision| FileEntry {
212 path: decision.path.clone(),
213 kind: decision.kind.as_str(),
214 action: decision.action,
215 })
216 .chain(dropped.iter().map(|path| FileEntry {
217 path: path.clone(),
218 kind: "dropped",
219 action: "dropped",
220 }))
221 .collect(),
222 plan: applied.as_ref().map(FrontApplied::trace),
223 next,
224 })?;
225 applied
226 .and_then(|applied| applied.applied.failure())
227 .map_or(Ok(()), Err)
228}
229
230fn describe(decision: &Decision) -> String {
231 match decision.action {
232 "drift" => format!("drift {} (seeded, target-owned)", decision.path),
233 "kept" => format!("kept {} (target-owned)", decision.path),
234 "conflict" => format!("conflict {} (edited, release-kit-owned)", decision.path),
235 action => format!("{action} {}", decision.path),
236 }
237}
238
239fn resolve_params(
240 args: &UpgradeArgs,
241 recorded: &Manifest,
242 existing: Option<&crate::config::Config>,
243) -> Result<landing::Params, RkError> {
244 let nix = match args.nix.as_deref() {
245 None => None,
246 Some("on") => Some(true),
247 Some("off") => Some(false),
248 Some(other) => {
249 return Err(RkError::Usage(format!(
250 "unknown --nix value '{other}'; the values are: on, off"
251 )));
252 }
253 };
254 landing::Params::resolve(
255 &EmbeddedReleaseSource,
256 &args.target,
257 &landing::Inputs {
258 tech: args.tech.as_deref(),
259 forge: args.forge.as_deref(),
260 repo: args.repo.as_deref(),
261 workflow: args.workflow.as_deref().map(Workflow::parse).transpose()?,
262 style: args.style.as_deref().map(Style::parse).transpose()?,
263 nix,
264 },
265 existing,
266 Some(recorded),
267 landing::Purpose::Upgrade,
268 )
269}
270
271fn refuse_conflicts(conflicts: &[String]) -> RkError {
274 RkError::refusal(
275 Diagnostic::new(
276 Reason::StateDrift,
277 format!(
278 "these files release-kit owns were edited, and nothing was written: {}",
279 conflicts.join(", ")
280 ),
281 )
282 .expected("every rendered file as the record left it")
283 .action("resolve each, or re-land it, then run 'rk upgrade' again")
284 .target_state("unchanged"),
285 )
286}
287
288fn next_lines(args: &UpgradeArgs, clean: bool) -> Vec<String> {
292 let identity_flags: String = [
293 ("tech", args.tech.as_deref()),
294 ("forge", args.forge.as_deref()),
295 ("repo", args.repo.as_deref()),
296 ]
297 .into_iter()
298 .filter_map(|(key, value)| value.map(|value| format!(" --{key} {value}")))
299 .collect();
300 let workflow_flag = args
301 .workflow
302 .as_deref()
303 .map_or_else(String::new, |mode| format!(" --workflow {mode}"));
304 let style_flag = args
305 .style
306 .as_deref()
307 .map_or_else(String::new, |style| format!(" --style {style}"));
308 let nix_flag = args
309 .nix
310 .as_deref()
311 .map_or_else(String::new, |value| format!(" --nix {value}"));
312 if args.apply {
313 vec![
314 "commit the upgraded files, the record included".to_owned(),
315 format!("rk status --target {} reports the result", args.target),
316 ]
317 } else if clean {
318 vec![format!(
319 "rk upgrade{identity_flags}{workflow_flag}{style_flag}{nix_flag} --target {} --apply writes",
320 args.target
321 )]
322 } else {
323 vec![format!(
324 "resolve each conflict above; rk upgrade{identity_flags}{workflow_flag}{style_flag}{nix_flag} --target {} --apply refuses until then",
325 args.target
326 )]
327 }
328}
329
330fn load_upgradable(target: &camino::Utf8Path) -> Result<Manifest, RkError> {
333 let Some(recorded) = manifest::load(target)? else {
334 return Err(RkError::refusal(
335 Diagnostic::new(
336 Reason::StateDrift,
337 format!(
338 "no {} at {target}: there is no baseline to upgrade against",
339 manifest::MANIFEST_PATH
340 ),
341 )
342 .expected("a recorded landing")
343 .action(
344 "rk init lands a first landing; rk adopt records one made before the record existed",
345 )
346 .target_state("unchanged"),
347 ));
348 };
349 if manifest::alignment(&recorded.rk_version, env!("CARGO_PKG_VERSION"))
350 == Alignment::TargetNewer
351 {
352 return Err(RkError::refusal(
353 Diagnostic::new(
354 Reason::StateDrift,
355 format!(
356 "this landing came from rk {}, newer than this binary's {}; downgrading a target is not an upgrade",
357 recorded.rk_version,
358 env!("CARGO_PKG_VERSION")
359 ),
360 )
361 .expected("a binary at or above the recorded rk_version")
362 .action(format!("install release-kit {} or newer", recorded.rk_version))
363 .target_state("unchanged"),
364 ));
365 }
366 Ok(recorded)
367}
368
369fn decide_all(planned: &Planned, hooks_defect: bool) -> (Vec<Decision>, Vec<String>) {
375 let mut conflicts: Vec<String> = Vec::new();
376 if hooks_defect {
377 conflicts.push(landing::HOOKS_DESTINATION.to_owned());
378 }
379 let mut decisions = Vec::new();
380 for outcome in &planned.outcomes {
381 let decided = match outcome.disposition {
382 Disposition::Write if outcome.recorded => "updated",
383 Disposition::Write => "added",
384 Disposition::Unchanged => "unchanged",
385 Disposition::Kept => "kept",
386 Disposition::Drift => "drift",
387 Disposition::State => "state",
388 Disposition::Conflict | Disposition::Missing => {
389 conflicts.push(outcome.path.clone());
390 "conflict"
391 }
392 };
393 let action = if outcome.path == landing::HOOKS_DESTINATION && hooks_defect {
394 "conflict"
395 } else {
396 decided
397 };
398 decisions.push(Decision {
399 path: outcome.path.clone(),
400 kind: outcome.kind,
401 action,
402 });
403 }
404 let mut seen = std::collections::HashSet::new();
405 conflicts.retain(|conflict| seen.insert(conflict.clone()));
406 (decisions, conflicts)
407}
408
409fn refuse_non_regular(
412 target: &camino::Utf8Path,
413 entries: &[landing::Entry],
414) -> Result<(), RkError> {
415 for entry in entries {
416 if entry.kind != Kind::Rendered {
417 continue;
418 }
419 let path = target.join(&entry.destination);
420 if let Ok(meta) = std::fs::symlink_metadata(&path) {
421 if !meta.is_file() {
422 return Err(RkError::refusal(
423 Diagnostic::new(
424 Reason::StateDrift,
425 format!("{path} exists and is not a regular file; nothing was written"),
426 )
427 .expected("every rendered destination a regular file")
428 .target_state("unchanged"),
429 ));
430 }
431 }
432 }
433 Ok(())
434}
435
436fn collect_sentinels(destination: &str, bytes: &[u8], found: &mut Vec<String>) {
438 let text = String::from_utf8_lossy(bytes);
439 for (idx, line) in text.lines().enumerate() {
440 if line.contains(embedded::SENTINEL) {
441 found.push(format!("{destination}:{}: {}", idx + 1, line.trim()));
442 }
443 }
444}
445
446#[cfg(test)]
447mod tests {
448 use super::{FileEntry, Report};
449
450 #[test]
452 fn the_upgrade_report_schema_snapshot_holds() {
453 let report = Report {
454 schema: "rk.upgrade/6",
455 config: crate::config::Plan {
456 action: "added",
457 changes: vec![],
458 content: "schema_version = 1\n".into(),
459 },
460 mode: "preview",
461 target: "/tmp/t".into(),
462 tech: "rust".into(),
463 forge: "github".into(),
464 from_version: "0.1.0".into(),
465 to_version: "0.2.0",
466 workflow: "branches",
467 style: "trunk",
468 nix: false,
469 withheld: None,
470 files: vec![FileEntry {
471 path: "release-plz.toml".into(),
472 kind: "seeded",
473 action: "drift",
474 }],
475 plan: None,
476 next: vec!["rk upgrade --target /tmp/t --apply writes".into()],
477 };
478 assert_eq!(
479 serde_json::to_string(&report).expect("a report serializes"),
480 r#"{"schema":"rk.upgrade/6","mode":"preview","target":"/tmp/t","tech":"rust","forge":"github","from_version":"0.1.0","to_version":"0.2.0","workflow":"branches","style":"trunk","nix":false,"config":{"action":"added","changes":[],"content":"schema_version = 1\n"},"files":[{"path":"release-plz.toml","kind":"seeded","action":"drift"}],"next":["rk upgrade --target /tmp/t --apply writes"]}"#
481 );
482 }
483}