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 landing::Params::resolve(
185 held.base(),
186 &landing::Inputs {
187 nix: args.nix_packaging.then_some(true),
188 reporting_policy: args.reporting_policy.then_some(true),
189 scorecard: args.scorecard.then_some(true),
190 workflow_guards: args.workflow_guards.then_some(true),
191 code_scanning: args
192 .code_scanning
193 .as_deref()
194 .map(Provider::parse)
195 .transpose()?,
196 ..args.profile.inputs()?
197 },
198 config.as_ref(),
199 None,
200 purpose,
201 )?
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 let landed = apply::land(&held, None, &prepared, apply::Origin::Init, &lock)?;
208 drop(lock);
209 report_apply(out, args, &held, ¶ms, &prepared, &landed)
210 } else {
211 let mut prepared = apply::prepare(&held, None, ¶ms, config.as_ref())?;
212 apply::name_replacements(&mut prepared, &args.replace)?;
213 let repo = (params.repo() != landing::REPO_PLACEHOLDER && !params.repo().is_empty())
214 .then(|| params.repo().to_owned());
215 if params.repo() == landing::REPO_PLACEHOLDER {
216 out.frame(
217 "note: no repository detected; an apply derives the owner from --repo <path>",
218 );
219 }
220 preview(out, args, ¶ms, repo, &prepared)
221 }
222}
223
224const SCHEMA: &str = "rk.init/11";
226
227const fn answered(args: &InitArgs) -> bool {
229 !args.profile.is_empty()
230 || args.nix_packaging
231 || args.reporting_policy
232 || args.scorecard
233 || args.workflow_guards
234 || args.code_scanning.is_some()
235}
236
237fn selection_of(prepared: &Prepared) -> Vec<CapabilityNote> {
239 prepared
240 .projection
241 .capabilities
242 .iter()
243 .map(|selection| CapabilityNote::of(selection, &prepared.projection))
244 .collect()
245}
246
247pub(crate) fn describe_selection(out: Output, prepared: &Prepared) {
249 for note in selection_of(prepared) {
250 let mut line = format!("capability {}: {}", note.id, note.status);
251 if let Some(reason) = ¬e.reason {
252 line.push_str(" (");
253 line.push_str(reason);
254 line.push(')');
255 }
256 out.result_line(line);
257 if let Some(action) = ¬e.action {
258 out.result_line(format!(" action: {action}"));
259 }
260 }
261}
262
263#[allow(
266 clippy::too_many_lines,
267 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"
268)]
269fn preview(
270 out: Output,
271 args: &InitArgs,
272 params: &landing::Params,
273 repo: Option<String>,
274 prepared: &Prepared,
275) -> Result<(), RkError> {
276 let flags = params.canonical_flags();
277 let flags = if params.forge().is_some() && repo.is_none() {
278 format!("{flags} --repo <owner/name>")
279 } else {
280 flags
281 };
282 let capabilities = params.capability_flags();
283 let mut next = vec![format!(
284 "rk init{flags}{capabilities} --target {} --apply",
285 args.target
286 )];
287 if let Some(reason) = prepared.projection.release_unavailable() {
288 next.insert(
289 0,
290 format!("the apply refuses until the release automation resolves: {reason}"),
291 );
292 }
293 if !prepared.collisions.is_empty() {
294 next.insert(
295 0,
296 "answer each collision above: --replace <path> saves a copy of a conflict and replaces it; the apply refuses every one not named"
297 .to_owned(),
298 );
299 }
300 if let Some(reason) = prepared.projection.licence_refusal.as_deref() {
301 next.insert(
302 0,
303 format!("the apply refuses until the licence condition is answered: {reason}"),
304 );
305 }
306 next.push(format!(
307 "rk stage --target {} stages the complete candidate for a byte comparison",
308 args.target
309 ));
310 out.result_line(format!(
311 "DRY RUN: rk init writes these files into {}; re-run with --apply",
312 args.target
313 ));
314 out.result_line(format!(
315 "profile: {}",
316 crate::commands::profile::describe(
317 params.profile(),
318 params.git(),
319 params.capabilities(),
320 params.repo()
321 )
322 ));
323 describe_selection(out, prepared);
324 for decision in &prepared.decisions {
325 out.result_line(format!(
326 "{} {}",
327 decision.action.as_str(),
328 decision.destination
329 ));
330 }
331 for collision in &prepared.collisions {
332 out.result_line(format!(
333 "collision {}: {}",
334 collision.path, collision.reason
335 ));
336 }
337 out.result_line(format!(
338 "{} {}\n{}",
339 prepared.config.action,
340 crate::config::CONFIG_PATH,
341 prepared.config.content
342 ));
343 for entry in &prepared.projection.omissions {
344 out.result_line(format!("withheld {}: {}", entry.destination, entry.reason));
345 if let Some(action) = &entry.action {
346 out.result_line(format!(" action: {action}"));
347 }
348 }
349 out.next(&next);
350 out.emit(&Report {
351 schema: SCHEMA,
352 config: prepared.config.clone(),
353 mode: "preview",
354 target: args.target.to_string(),
355 profile: params.profile().clone(),
356 git: params.git().clone(),
357 capabilities: params.capabilities().clone(),
358 repo,
359 selection: selection_of(prepared),
360 release_unavailable: prepared.projection.release_unavailable().map(str::to_owned),
361 licence_refusal: prepared.projection.licence_refusal.clone(),
362 withheld: withheld_of(prepared),
363 collisions: (!prepared.collisions.is_empty()).then(|| prepared.collisions.clone()),
364 replaced: apply::previewed_replacements(prepared),
365 files: prepared
366 .decisions
367 .iter()
368 .map(|decision| FileEntry {
369 path: decision.destination.clone(),
370 kind: decision.kind.as_str(),
371 action: decision.action.as_str(),
372 })
373 .chain(prepared.collisions.iter().map(|collision| FileEntry {
374 path: collision.path.clone(),
375 kind: landing::kind_of(&collision.path).map_or("unknown", landing::Kind::as_str),
376 action: "collision",
377 }))
378 .collect(),
379 sentinels: None,
380 next,
381 })
382}
383
384#[allow(
387 clippy::too_many_arguments,
388 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"
389)]
390#[allow(
391 clippy::too_many_lines,
392 reason = "one pass reports the profile, the selection, every decision, and the sentinels the operator still owes"
393)]
394fn report_apply(
395 out: Output,
396 args: &InitArgs,
397 held: &apply::Held,
398 params: &landing::Params,
399 prepared: &Prepared,
400 landed: &apply::Landed,
401) -> Result<(), RkError> {
402 let mut file_entries = Vec::new();
403 let mut sentinels = Vec::new();
404 out.result_line(format!(
405 "profile: {}",
406 crate::commands::profile::describe(
407 params.profile(),
408 params.git(),
409 params.capabilities(),
410 params.repo()
411 )
412 ));
413 describe_selection(out, prepared);
414 for decision in &prepared.decisions {
415 out.result_line(describe(decision));
416 if decision.action != Action::Released {
417 let bytes =
418 landing::read_recorded(held.base(), &decision.destination)?.unwrap_or_default();
419 collect_sentinels(
420 held.display(),
421 &decision.destination,
422 &bytes,
423 &mut sentinels,
424 );
425 }
426 file_entries.push(FileEntry {
427 path: decision.destination.clone(),
428 kind: decision.kind.as_str(),
429 action: decision.action.as_str(),
430 });
431 }
432 for entry in &prepared.projection.omissions {
433 out.result_line(format!("withheld {}: {}", entry.destination, entry.reason));
434 if let Some(action) = &entry.action {
435 out.result_line(format!(" action: {action}"));
436 }
437 }
438 out.result_line(format!(
439 "{} {}",
440 prepared.config.action,
441 crate::config::CONFIG_PATH
442 ));
443 for (key, empty_line) in [
444 ("setup.required_check", "required_check = \"\""),
445 ("setup.bot.app_id", "app_id = \"\""),
446 ] {
447 if let Some((index, _)) = prepared
448 .config
449 .content
450 .lines()
451 .enumerate()
452 .find(|(_, line)| line.starts_with(empty_line))
453 {
454 sentinels.push(SentinelEntry {
455 path: crate::config::CONFIG_PATH.into(),
456 line: index + 1,
457 text: format!("set {key} before forge setup"),
458 });
459 }
460 }
461 for entry in &landed.replaced {
462 if let Some(saved) = &entry.saved_to {
463 out.result_line(format!("saved {} to {saved}", entry.path));
464 }
465 }
466 out.result_line(format!("wrote {}", manifest::MANIFEST_PATH));
467 debug_assert_eq!(
468 landed.completed.last().map(String::as_str),
469 Some(manifest::MANIFEST_PATH)
470 );
471
472 if sentinels.is_empty() {
473 out.result_line("no sentinels to fill");
474 } else {
475 out.result_line("fill these sentinels before the workflow runs:");
476 for sentinel in &sentinels {
477 out.result_line(format!(
478 "{}:{}: {}",
479 sentinel.path, sentinel.line, sentinel.text
480 ));
481 }
482 }
483 let mut next = vec![
484 if sentinels.is_empty() {
485 "commit the landed files, the receipt included".to_owned()
486 } else {
487 "fill each sentinel above, then commit the landed files, the receipt included"
488 .to_owned()
489 },
490 format!("rk status --target {} reports this landing", args.target),
491 ];
492 if params.release_mode() == crate::profile::ReleaseMode::Automatic {
495 next.push("rk method setup orders what follows".to_owned());
496 } else if params.forge().is_some() {
497 next.push("rk setup --target . previews the applicable forge steps".to_owned());
498 }
499 out.next(&next);
500 out.emit(&Report {
501 schema: SCHEMA,
502 config: prepared.config.clone(),
503 mode: "apply",
504 target: args.target.to_string(),
505 profile: params.profile().clone(),
506 git: params.git().clone(),
507 capabilities: params.capabilities().clone(),
508 repo: (!params.repo().is_empty()).then(|| params.repo().to_owned()),
509 selection: selection_of(prepared),
510 release_unavailable: None,
511 licence_refusal: prepared.projection.licence_refusal.clone(),
512 withheld: withheld_of(prepared),
513 collisions: None,
514 replaced: landed.replaced.clone(),
515 files: file_entries,
516 sentinels: Some(sentinels),
517 next,
518 })
519}
520
521pub(crate) fn describe(decision: &apply::Decision) -> String {
523 match decision.action {
524 Action::Preserved | Action::Drift => format!(
525 "{} {} ({}, target-owned)",
526 decision.action.as_str(),
527 decision.destination,
528 decision.kind.as_str()
529 ),
530 Action::Released => format!(
531 "released {} (no longer produced; target-owned from this landing)",
532 decision.destination
533 ),
534 Action::Matched => format!(
535 "matched {} (already holds the candidate's bytes)",
536 decision.destination
537 ),
538 Action::Created | Action::Replaced => {
539 format!("{} {}", decision.action.as_str(), decision.destination)
540 }
541 }
542}
543
544fn refuse_a_recorded_target(held: &apply::Held) -> Result<(), RkError> {
547 if landing::manifest::load(held.base())?.is_none() {
548 return Ok(());
549 }
550 let target = held.display();
551 Err(RkError::refusal(
552 Diagnostic::new(
553 Reason::StateDrift,
554 format!(
555 "{target} already carries {}, and nothing was written",
556 manifest::MANIFEST_PATH
557 ),
558 )
559 .expected("a target without a landing receipt")
560 .action(format!(
561 "rk upgrade --target {target} takes it to this binary's projection"
562 ))
563 .target_state("unchanged"),
564 ))
565}
566
567fn collect_sentinels(
570 target: &Utf8Path,
571 destination: &str,
572 bytes: &[u8],
573 found: &mut Vec<SentinelEntry>,
574) {
575 let text = String::from_utf8_lossy(bytes);
576 for (idx, line) in text.lines().enumerate() {
577 if line.contains(embedded::SENTINEL) {
578 found.push(SentinelEntry {
579 path: target.join(destination).to_string(),
580 line: idx + 1,
581 text: line.trim().to_owned(),
582 });
583 }
584 }
585}
586
587#[cfg(test)]
588mod tests {
589 use super::{FileEntry, Report, SentinelEntry};
590 use crate::landing::CheckoutMode;
591 use crate::landing::Integration;
592 use crate::profile::{
593 CapabilityRequests, GitWorkflow, ProfileSnapshot, ReleaseIntent, ReleaseMode,
594 };
595 use crate::stage::CapabilityNote;
596
597 #[test]
601 fn the_init_report_schema_snapshot_holds() {
602 let apply = Report {
603 schema: super::SCHEMA,
604 config: crate::config::Plan {
605 action: "added",
606 changes: vec![],
607 content: "schema_version = 2\n".into(),
608 },
609 mode: "apply",
610 target: "/tmp/t".into(),
611 profile: ProfileSnapshot {
612 technologies: vec!["rust".into()],
613 forge: Some("github".into()),
614 release: ReleaseIntent {
615 mode: ReleaseMode::Automatic,
616 driver: Some("rust".into()),
617 style: Some(crate::landing::Style::Trunk),
618 line_prefix: Some("release/".into()),
619 },
620 },
621 git: GitWorkflow {
622 trunk: "master".into(),
623 checkout_mode: CheckoutMode::LinkedWorktree,
624 integration: Integration::Local,
625 },
626 capabilities: CapabilityRequests {
627 nix_packaging: true,
628 reporting_policy: true,
629 scorecard: true,
630 code_scanning: Some(crate::landing::Provider::CodeQl),
631 workflow_guards: Some(false),
632 },
633 repo: Some("acme/widget".into()),
634 selection: vec![CapabilityNote {
635 id: "git.guards".into(),
636 status: "selected".into(),
637 reason: None,
638 action: None,
639 destinations: vec!["AGENTS.md".into()],
640 }],
641 release_unavailable: None,
642 licence_refusal: None,
643 withheld: Some(vec![crate::landing::Withheld {
644 path: "flake.nix".into(),
645 reason: "the target already carries flake.nix".into(),
646 }]),
647 collisions: None,
648 replaced: vec![],
649 files: vec![FileEntry {
650 path: "release-plz.toml".into(),
651 kind: "seeded",
652 action: "created",
653 }],
654 sentinels: Some(vec![SentinelEntry {
655 path: "/tmp/t/release-plz.toml".into(),
656 line: 3,
657 text: "# TODO(release-kit): keep false for a binary-only crate".into(),
658 }]),
659 next: vec!["commit the landed files, the receipt included".into()],
660 };
661 assert_eq!(
662 serde_json::to_string(&apply).expect("a report serializes"),
663 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"},"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"]}"##
664 );
665 let preview = Report {
666 sentinels: None,
667 repo: None,
668 mode: "preview",
669 capabilities: CapabilityRequests {
670 nix_packaging: false,
671 reporting_policy: false,
672 scorecard: false,
673 code_scanning: None,
674 workflow_guards: Some(false),
675 },
676 selection: vec![],
677 release_unavailable: Some(
678 "the release automation at (python, gitlab) has no landable files".to_owned(),
679 ),
680 licence_refusal: Some("the target's Cargo.toml declares no license field".to_owned()),
681 withheld: None,
682 collisions: Some(vec![super::Collision {
683 path: "SECURITY.md".into(),
684 reason: "exists, and no receipt attributes it to release-kit".into(),
685 replaceable: true,
686 }]),
687 replaced: vec![super::Replaced {
688 path: "SECURITY.md".into(),
689 saved_to: None,
690 }],
691 ..apply
692 };
693 assert_eq!(
694 serde_json::to_string(&preview).expect("a report serializes"),
695 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"},"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"]}"#,
696 "a preview omits the sentinels, the unresolved repo, and an empty withheld list rather than serializing null"
697 );
698 }
699}