1use camino::Utf8Path;
19use serde::Serialize;
20
21use crate::cli::init::InitArgs;
22use crate::diagnostic::{Diagnostic, Reason};
23use crate::embedded;
24use crate::error::RkError;
25use crate::held;
26use crate::landing::apply::{self, Action, Collision, Prepared, Replaced};
27use crate::landing::manifest::{self, Provider};
28use crate::landing::{self, lock};
29use crate::output::Output;
30use crate::profile::{CapabilityRequests, GitWorkflow, ProfileSnapshot};
31use crate::stage::CapabilityNote;
32
33#[derive(Debug, Serialize)]
35struct FileEntry {
36 path: String,
38 kind: &'static str,
40 action: &'static str,
43}
44
45#[derive(Debug, Serialize)]
47struct SentinelEntry {
48 path: String,
50 line: usize,
52 text: String,
54}
55
56#[derive(Debug, Serialize)]
58struct Report {
59 schema: &'static str,
61 mode: &'static str,
63 target: String,
65 profile: ProfileSnapshot,
67 git: GitWorkflow,
69 capabilities: CapabilityRequests,
71 #[serde(skip_serializing_if = "Option::is_none")]
73 repo: Option<String>,
74 selection: Vec<CapabilityNote>,
76 #[serde(skip_serializing_if = "Option::is_none")]
80 release_unavailable: Option<String>,
81 #[serde(skip_serializing_if = "Option::is_none")]
85 licence_refusal: Option<String>,
86 #[serde(skip_serializing_if = "Option::is_none")]
89 withheld: Option<Vec<landing::Withheld>>,
90 #[serde(skip_serializing_if = "Option::is_none")]
93 collisions: Option<Vec<Collision>>,
94 replaced: Vec<Replaced>,
97 config: crate::config::Plan,
98 files: Vec<FileEntry>,
100 #[serde(skip_serializing_if = "Option::is_none")]
102 sentinels: Option<Vec<SentinelEntry>>,
103 next: Vec<String>,
105}
106
107fn withheld_of(prepared: &Prepared) -> Option<Vec<landing::Withheld>> {
109 let withheld: Vec<landing::Withheld> = prepared
110 .projection
111 .omissions
112 .iter()
113 .map(|omission| landing::Withheld {
114 path: omission.destination.clone(),
115 reason: omission.reason.clone(),
116 })
117 .collect();
118 (!withheld.is_empty()).then_some(withheld)
119}
120
121pub fn run(args: &InitArgs) -> Result<(), RkError> {
130 let out = Output::new(args.json);
131 if !args.target.is_dir() {
132 return Err(RkError::refusal(
133 Diagnostic::new(
134 Reason::TargetNotFound,
135 format!(
136 "target {} is not a directory; nothing was written",
137 args.target
138 ),
139 )
140 .expected("an existing directory to land into")
141 .target_state("unchanged"),
142 ));
143 }
144 let lock = args
148 .apply
149 .then(|| lock::acquire(&args.target))
150 .transpose()?;
151 held::pause(apply::PAUSE_VAR, "locked", "proceed-locked");
156 let held = lock.as_ref().map_or_else(
157 || apply::Held::open(&args.target),
158 |lock| apply::Held::open_locked(&args.target, lock),
159 )?;
160 held::pause(apply::PAUSE_VAR, "held", "proceed-held");
162 let config = crate::config::load(held.base().as_std_path())?;
163 let purpose = if args.apply {
164 landing::Purpose::Init
165 } else {
166 landing::Purpose::Preview
167 };
168 let params = if let Some(root) = args.stage.as_deref() {
169 if answered(args) {
170 return Err(crate::stage::read::answered_twice());
171 }
172 let canonical = std::fs::canonicalize(args.target.as_std_path())?;
173 let receipt = crate::stage::read::load(root, &canonical)?;
174 let params = landing::Params::resolve(
175 held.base(),
176 &crate::stage::read::inputs(&receipt.parameters),
177 config.as_ref(),
178 None,
179 purpose,
180 )?;
181 crate::stage::read::confirm(¶ms, &receipt, root)?;
182 params
183 } else {
184 let inputs = landing::Inputs {
185 nix: args.nix_packaging.then_some(true),
186 reporting_policy: args.reporting_policy.then_some(true),
187 scorecard: args.scorecard.then_some(true),
188 workflow_guards: args.workflow_guards.then_some(true),
189 code_scanning: args
190 .code_scanning
191 .as_deref()
192 .map(Provider::parse)
193 .transpose()?,
194 ..args.profile.inputs()?
195 };
196 let inputs = if args.workflow_only {
197 crate::cli::profile_flags::workflow_only(inputs)?
198 } else {
199 inputs
200 };
201 landing::Params::resolve(held.base(), &inputs, config.as_ref(), None, purpose)?
202 };
203 if let Some(lock) = lock {
204 refuse_a_recorded_target(&held)?;
205 let mut prepared = apply::prepare(&held, None, ¶ms, config.as_ref())?;
206 apply::name_replacements(&mut prepared, &args.replace)?;
207 report_moved_secrets(out, &held, &prepared)?;
208 let landed = apply::land(&held, None, &prepared, apply::Origin::Init, &lock)?;
209 drop(lock);
210 report_apply(out, args, &held, ¶ms, &prepared, &landed)
211 } else {
212 let mut prepared = apply::prepare(&held, None, ¶ms, config.as_ref())?;
213 apply::name_replacements(&mut prepared, &args.replace)?;
214 report_moved_secrets(out, &held, &prepared)?;
215 let repo = (params.repo() != landing::REPO_PLACEHOLDER && !params.repo().is_empty())
216 .then(|| params.repo().to_owned());
217 if params.repo() == landing::REPO_PLACEHOLDER {
218 out.frame(
219 "note: no repository detected; an apply derives the owner from --repo <path>",
220 );
221 }
222 preview(out, args, ¶ms, repo, &prepared)
223 }
224}
225
226pub fn report_moved_secrets(
235 out: Output,
236 held: &apply::Held,
237 prepared: &Prepared,
238) -> Result<(), RkError> {
239 for moved in apply::replaced_secrets(held, prepared)? {
240 out.result_line(moved.line());
241 }
242 Ok(())
243}
244
245const SCHEMA: &str = "rk.init/11";
247
248const fn answered(args: &InitArgs) -> bool {
250 !args.profile.is_empty()
251 || args.nix_packaging
252 || args.reporting_policy
253 || args.scorecard
254 || args.workflow_guards
255 || args.workflow_only
256 || args.code_scanning.is_some()
257}
258
259fn selection_of(prepared: &Prepared) -> Vec<CapabilityNote> {
261 prepared
262 .projection
263 .capabilities
264 .iter()
265 .map(|selection| CapabilityNote::of(selection, &prepared.projection))
266 .collect()
267}
268
269pub(crate) fn describe_selection(out: Output, prepared: &Prepared) {
271 for note in selection_of(prepared) {
272 let mut line = format!("capability {}: {}", note.id, note.status);
273 if let Some(reason) = ¬e.reason {
274 line.push_str(" (");
275 line.push_str(reason);
276 line.push(')');
277 }
278 out.result_line(line);
279 if let Some(action) = ¬e.action {
280 out.result_line(format!(" action: {action}"));
281 }
282 }
283}
284
285#[allow(
288 clippy::too_many_lines,
289 reason = "one pass prints the profile, the selection, every decision, and the follow-up, and splitting it would separate a line from the value behind it"
290)]
291fn preview(
292 out: Output,
293 args: &InitArgs,
294 params: &landing::Params,
295 repo: Option<String>,
296 prepared: &Prepared,
297) -> Result<(), RkError> {
298 let flags = params.canonical_flags();
299 let flags = if params.forge().is_some() && repo.is_none() {
300 format!("{flags} --repo <owner/name>")
301 } else {
302 flags
303 };
304 let capabilities = params.capability_flags();
305 let mut next = vec![format!(
306 "rk init{flags}{capabilities} --target {} --apply",
307 args.target
308 )];
309 if let Some(reason) = prepared.projection.release_unavailable() {
310 next.insert(
311 0,
312 format!("the apply refuses until the release automation resolves: {reason}"),
313 );
314 }
315 if !prepared.collisions.is_empty() {
316 next.insert(
317 0,
318 "answer each collision above: --replace <path> saves a copy of a conflict and replaces it; the apply refuses every one not named"
319 .to_owned(),
320 );
321 }
322 if let Some(reason) = prepared.projection.licence_refusal.as_deref() {
323 next.insert(
324 0,
325 format!("the apply refuses until the licence condition is answered: {reason}"),
326 );
327 }
328 if let Some(reason) = prepared.projection.driver_refusal.as_deref() {
329 next.insert(
330 0,
331 format!("the apply refuses until the project takes the release driver: {reason}"),
332 );
333 }
334 next.push(format!(
335 "rk stage --target {} stages the complete candidate for a byte comparison",
336 args.target
337 ));
338 out.result_line(format!(
339 "DRY RUN: rk init writes these files into {}; re-run with --apply",
340 args.target
341 ));
342 out.result_line(format!(
343 "profile: {}",
344 crate::commands::profile::describe(
345 params.profile(),
346 params.git(),
347 params.capabilities(),
348 params.repo()
349 )
350 ));
351 describe_selection(out, prepared);
352 for decision in &prepared.decisions {
353 out.result_line(format!(
354 "{} {}",
355 decision.action.as_str(),
356 decision.destination
357 ));
358 }
359 for collision in &prepared.collisions {
360 out.result_line(format!(
361 "collision {}: {}",
362 collision.path, collision.reason
363 ));
364 }
365 out.result_line(format!(
366 "{} {}\n{}",
367 prepared.config.action,
368 crate::config::CONFIG_PATH,
369 prepared.config.content
370 ));
371 for entry in &prepared.projection.omissions {
372 out.result_line(format!("withheld {}: {}", entry.destination, entry.reason));
373 if let Some(action) = &entry.action {
374 out.result_line(format!(" action: {action}"));
375 }
376 }
377 out.next(&next);
378 out.emit(&Report {
379 schema: SCHEMA,
380 config: prepared.config.clone(),
381 mode: "preview",
382 target: args.target.to_string(),
383 profile: params.profile().clone(),
384 git: params.git().clone(),
385 capabilities: params.capabilities().clone(),
386 repo,
387 selection: selection_of(prepared),
388 release_unavailable: prepared.projection.release_unavailable().map(str::to_owned),
389 licence_refusal: prepared.projection.licence_refusal.clone(),
390 withheld: withheld_of(prepared),
391 collisions: (!prepared.collisions.is_empty()).then(|| prepared.collisions.clone()),
392 replaced: apply::previewed_replacements(prepared),
393 files: prepared
394 .decisions
395 .iter()
396 .map(|decision| FileEntry {
397 path: decision.destination.clone(),
398 kind: decision.kind.as_str(),
399 action: decision.action.as_str(),
400 })
401 .chain(prepared.collisions.iter().map(|collision| FileEntry {
402 path: collision.path.clone(),
403 kind: landing::kind_of(&collision.path).map_or("unknown", landing::Kind::as_str),
404 action: "collision",
405 }))
406 .collect(),
407 sentinels: None,
408 next,
409 })
410}
411
412#[allow(
415 clippy::too_many_arguments,
416 reason = "the report reads the landed files through the held target and names them by the path the operator gave, which are two arguments for one target"
417)]
418#[allow(
419 clippy::too_many_lines,
420 reason = "one pass reports the profile, the selection, every decision, and the sentinels the operator still owes"
421)]
422fn report_apply(
423 out: Output,
424 args: &InitArgs,
425 held: &apply::Held,
426 params: &landing::Params,
427 prepared: &Prepared,
428 landed: &apply::Landed,
429) -> Result<(), RkError> {
430 let mut file_entries = Vec::new();
431 let mut sentinels = Vec::new();
432 out.result_line(format!(
433 "profile: {}",
434 crate::commands::profile::describe(
435 params.profile(),
436 params.git(),
437 params.capabilities(),
438 params.repo()
439 )
440 ));
441 describe_selection(out, prepared);
442 for decision in &prepared.decisions {
443 out.result_line(describe(decision));
444 if !matches!(decision.action, Action::Retired | Action::Dropped) {
445 let bytes =
446 landing::read_recorded(held.base(), &decision.destination)?.unwrap_or_default();
447 collect_sentinels(
448 held.display(),
449 &decision.destination,
450 &bytes,
451 &mut sentinels,
452 );
453 }
454 file_entries.push(FileEntry {
455 path: decision.destination.clone(),
456 kind: decision.kind.as_str(),
457 action: decision.action.as_str(),
458 });
459 }
460 for entry in &prepared.projection.omissions {
461 out.result_line(format!("withheld {}: {}", entry.destination, entry.reason));
462 if let Some(action) = &entry.action {
463 out.result_line(format!(" action: {action}"));
464 }
465 }
466 out.result_line(format!(
467 "{} {}",
468 prepared.config.action,
469 crate::config::CONFIG_PATH
470 ));
471 let gated = prepared.projection.capabilities.iter().any(|selection| {
474 matches!(
475 selection.id,
476 crate::profile::catalog::TITLE_CHECK | crate::profile::catalog::COMMIT_CHECK
477 ) && selection.lands()
478 });
479 for (key, empty_line) in [
480 ("setup.required_check", "required_check = \"\""),
481 ("setup.bot.app_id", "app_id = \"\""),
482 ] {
483 if key == "setup.required_check" && !gated {
484 continue;
485 }
486 if let Some((index, _)) = prepared
487 .config
488 .content
489 .lines()
490 .enumerate()
491 .find(|(_, line)| line.starts_with(empty_line))
492 {
493 sentinels.push(SentinelEntry {
494 path: crate::config::CONFIG_PATH.into(),
495 line: index + 1,
496 text: format!("set {key} before forge setup"),
497 });
498 }
499 }
500 for entry in &landed.replaced {
501 if let Some(saved) = &entry.saved_to {
502 out.result_line(format!("saved {} to {saved}", entry.path));
503 }
504 }
505 out.result_line(format!("wrote {}", manifest::MANIFEST_PATH));
506 debug_assert_eq!(
507 landed.completed.last().map(String::as_str),
508 Some(manifest::MANIFEST_PATH)
509 );
510
511 if sentinels.is_empty() {
512 out.result_line("no sentinels to fill");
513 } else {
514 out.result_line("fill these sentinels before the workflow runs:");
515 for sentinel in &sentinels {
516 out.result_line(format!(
517 "{}:{}: {}",
518 sentinel.path, sentinel.line, sentinel.text
519 ));
520 }
521 }
522 let mut next = vec![
523 if sentinels.is_empty() {
524 "commit the landed files, the receipt included".to_owned()
525 } else {
526 "fill each sentinel above, then commit the landed files, the receipt included"
527 .to_owned()
528 },
529 format!("rk status --target {} reports this landing", args.target),
530 ];
531 if params.release_mode() == crate::profile::ReleaseMode::Automatic {
534 next.push("rk method setup orders what follows".to_owned());
535 } else if params.forge().is_some() {
536 next.push("rk setup --target . previews the applicable forge steps".to_owned());
537 }
538 out.next(&next);
539 out.emit(&Report {
540 schema: SCHEMA,
541 config: prepared.config.clone(),
542 mode: "apply",
543 target: args.target.to_string(),
544 profile: params.profile().clone(),
545 git: params.git().clone(),
546 capabilities: params.capabilities().clone(),
547 repo: (!params.repo().is_empty()).then(|| params.repo().to_owned()),
548 selection: selection_of(prepared),
549 release_unavailable: None,
550 licence_refusal: prepared.projection.licence_refusal.clone(),
551 withheld: withheld_of(prepared),
552 collisions: None,
553 replaced: landed.replaced.clone(),
554 files: file_entries,
555 sentinels: Some(sentinels),
556 next,
557 })
558}
559
560pub(crate) fn describe(decision: &apply::Decision) -> String {
562 if let (Action::Preserved, Some(accepted)) = (decision.action, &decision.accepted) {
563 return format!(
564 "preserved {} (rendered, accepted drift: {}; the candidate {} since the last landing)",
565 decision.destination,
566 accepted.reason,
567 if accepted.candidate_moved {
568 "moved"
569 } else {
570 "did not move"
571 }
572 );
573 }
574 match decision.action {
575 Action::Preserved | Action::Drift => format!(
576 "{} {} ({}, target-owned)",
577 decision.action.as_str(),
578 decision.destination,
579 decision.kind.as_str()
580 ),
581 Action::Retired => format!(
582 "retired {} (no longer produced and unedited since it landed; the landing removes it)",
583 decision.destination
584 ),
585 Action::Dropped => format!(
586 "dropped {} (no longer produced; the target's own from this landing)",
587 decision.destination
588 ),
589 Action::Matched => format!(
590 "matched {} (already holds the candidate's bytes)",
591 decision.destination
592 ),
593 Action::Created | Action::Replaced => {
594 format!("{} {}", decision.action.as_str(), decision.destination)
595 }
596 }
597}
598
599fn refuse_a_recorded_target(held: &apply::Held) -> Result<(), RkError> {
602 if landing::manifest::load(held.base())?.is_none() {
603 return Ok(());
604 }
605 let target = held.display();
606 Err(RkError::refusal(
607 Diagnostic::new(
608 Reason::StateDrift,
609 format!(
610 "{target} already carries {}, and nothing was written",
611 manifest::MANIFEST_PATH
612 ),
613 )
614 .expected("a target without a landing receipt")
615 .action(format!(
616 "rk upgrade --target {target} takes it to this binary's projection"
617 ))
618 .target_state("unchanged"),
619 ))
620}
621
622fn collect_sentinels(
625 target: &Utf8Path,
626 destination: &str,
627 bytes: &[u8],
628 found: &mut Vec<SentinelEntry>,
629) {
630 let text = String::from_utf8_lossy(bytes);
631 for (idx, line) in text.lines().enumerate() {
632 if line.contains(embedded::SENTINEL) {
633 found.push(SentinelEntry {
634 path: target.join(destination).to_string(),
635 line: idx + 1,
636 text: line.trim().to_owned(),
637 });
638 }
639 }
640}
641
642#[cfg(test)]
643mod tests {
644 use super::{FileEntry, Report, SentinelEntry};
645 use crate::landing::CheckoutMode;
646 use crate::landing::Integration;
647 use crate::profile::{
648 CapabilityRequests, GitWorkflow, ProfileSnapshot, ReleaseIntent, ReleaseMode,
649 };
650 use crate::stage::CapabilityNote;
651
652 #[test]
656 fn the_init_report_schema_snapshot_holds() {
657 let apply = Report {
658 schema: super::SCHEMA,
659 config: crate::config::Plan {
660 action: "added",
661 changes: vec![],
662 content: "schema_version = 2\n".into(),
663 },
664 mode: "apply",
665 target: "/tmp/t".into(),
666 profile: ProfileSnapshot {
667 technologies: vec!["rust".into()],
668 forge: Some("github".into()),
669 release: ReleaseIntent {
670 mode: ReleaseMode::Automatic,
671 driver: Some("rust".into()),
672 style: Some(crate::landing::Style::Trunk),
673 line_prefix: Some("release/".into()),
674 registry: None,
675 build: None,
676 },
677 },
678 git: GitWorkflow {
679 trunk: "master".into(),
680 checkout_mode: CheckoutMode::LinkedWorktree,
681 integration: Integration::Local,
682 merge_form: crate::landing::MergeForm::Squash,
683 },
684 capabilities: CapabilityRequests {
685 nix_packaging: true,
686 reporting_policy: true,
687 scorecard: true,
688 code_scanning: Some(crate::landing::Provider::CodeQl),
689 workflow_guards: Some(false),
690 },
691 repo: Some("acme/widget".into()),
692 selection: vec![CapabilityNote {
693 id: "git.guards".into(),
694 status: "selected".into(),
695 reason: None,
696 action: None,
697 destinations: vec!["AGENTS.md".into()],
698 }],
699 release_unavailable: None,
700 licence_refusal: None,
701 withheld: Some(vec![crate::landing::Withheld {
702 path: "flake.nix".into(),
703 reason: "the target already carries flake.nix".into(),
704 }]),
705 collisions: None,
706 replaced: vec![],
707 files: vec![FileEntry {
708 path: "release-plz.toml".into(),
709 kind: "seeded",
710 action: "created",
711 }],
712 sentinels: Some(vec![SentinelEntry {
713 path: "/tmp/t/release-plz.toml".into(),
714 line: 3,
715 text: "# TODO(release-kit): keep false for a binary-only crate".into(),
716 }]),
717 next: vec!["commit the landed files, the receipt included".into()],
718 };
719 assert_eq!(
720 serde_json::to_string(&apply).expect("a report serializes"),
721 r##"{"schema":"rk.init/11","mode":"apply","target":"/tmp/t","profile":{"technologies":["rust"],"forge":"github","release":{"mode":"automatic","driver":"rust","style":"trunk","line_prefix":"release/"}},"git":{"trunk":"master","checkout_mode":"linked-worktree","integration":"local","merge_form":"squash"},"capabilities":{"nix_packaging":true,"reporting_policy":true,"scorecard":true,"code_scanning":"codeql","workflow_guards":false},"repo":"acme/widget","selection":[{"id":"git.guards","status":"selected","destinations":["AGENTS.md"]}],"withheld":[{"path":"flake.nix","reason":"the target already carries flake.nix"}],"replaced":[],"config":{"action":"added","changes":[],"content":"schema_version = 2\n"},"files":[{"path":"release-plz.toml","kind":"seeded","action":"created"}],"sentinels":[{"path":"/tmp/t/release-plz.toml","line":3,"text":"# TODO(release-kit): keep false for a binary-only crate"}],"next":["commit the landed files, the receipt included"]}"##
722 );
723 let preview = Report {
724 sentinels: None,
725 repo: None,
726 mode: "preview",
727 capabilities: CapabilityRequests {
728 nix_packaging: false,
729 reporting_policy: false,
730 scorecard: false,
731 code_scanning: None,
732 workflow_guards: Some(false),
733 },
734 selection: vec![],
735 release_unavailable: Some(
736 "the release automation at (python, gitlab) has no landable files".to_owned(),
737 ),
738 licence_refusal: Some("the target's Cargo.toml declares no license field".to_owned()),
739 withheld: None,
740 collisions: Some(vec![super::Collision {
741 path: "SECURITY.md".into(),
742 reason: "exists, and no receipt attributes it to release-kit".into(),
743 replaceable: true,
744 }]),
745 replaced: vec![super::Replaced {
746 path: "SECURITY.md".into(),
747 saved_to: None,
748 }],
749 ..apply
750 };
751 assert_eq!(
752 serde_json::to_string(&preview).expect("a report serializes"),
753 r#"{"schema":"rk.init/11","mode":"preview","target":"/tmp/t","profile":{"technologies":["rust"],"forge":"github","release":{"mode":"automatic","driver":"rust","style":"trunk","line_prefix":"release/"}},"git":{"trunk":"master","checkout_mode":"linked-worktree","integration":"local","merge_form":"squash"},"capabilities":{"nix_packaging":false,"reporting_policy":false,"scorecard":false,"workflow_guards":false},"selection":[],"release_unavailable":"the release automation at (python, gitlab) has no landable files","licence_refusal":"the target's Cargo.toml declares no license field","collisions":[{"path":"SECURITY.md","reason":"exists, and no receipt attributes it to release-kit","replaceable":true}],"replaced":[{"path":"SECURITY.md","saved_to":null}],"config":{"action":"added","changes":[],"content":"schema_version = 2\n"},"files":[{"path":"release-plz.toml","kind":"seeded","action":"created"}],"next":["commit the landed files, the receipt included"]}"#,
754 "a preview omits the sentinels, the unresolved repo, and an empty withheld list rather than serializing null"
755 );
756 }
757}