1use camino::Utf8Path;
18use serde::Serialize;
19
20use crate::cli::init::InitArgs;
21use crate::diagnostic::{Diagnostic, Reason};
22use crate::embedded;
23use crate::error::RkError;
24use crate::held;
25use crate::landing::apply::{self, Action, Collision, Prepared};
26use crate::landing::manifest::{self, Provider};
27use crate::landing::{self, lock};
28use crate::output::Output;
29use crate::profile::{CapabilityRequests, GitWorkflow, ProfileSnapshot};
30use crate::stage::CapabilityNote;
31
32#[derive(Debug, Serialize)]
34struct FileEntry {
35 path: String,
37 kind: &'static str,
39 action: &'static str,
42}
43
44#[derive(Debug, Serialize)]
46struct SentinelEntry {
47 path: String,
49 line: usize,
51 text: String,
53}
54
55#[derive(Debug, Serialize)]
57struct Report {
58 schema: &'static str,
60 mode: &'static str,
62 target: String,
64 profile: ProfileSnapshot,
66 git: GitWorkflow,
68 capabilities: CapabilityRequests,
70 #[serde(skip_serializing_if = "Option::is_none")]
72 repo: Option<String>,
73 selection: Vec<CapabilityNote>,
75 #[serde(skip_serializing_if = "Option::is_none")]
79 release_unavailable: Option<String>,
80 #[serde(skip_serializing_if = "Option::is_none")]
84 licence_refusal: Option<String>,
85 #[serde(skip_serializing_if = "Option::is_none")]
88 withheld: Option<Vec<landing::Withheld>>,
89 #[serde(skip_serializing_if = "Option::is_none")]
92 collisions: Option<Vec<Collision>>,
93 config: crate::config::Plan,
94 files: Vec<FileEntry>,
96 #[serde(skip_serializing_if = "Option::is_none")]
98 sentinels: Option<Vec<SentinelEntry>>,
99 next: Vec<String>,
101}
102
103fn withheld_of(prepared: &Prepared) -> Option<Vec<landing::Withheld>> {
105 let withheld: Vec<landing::Withheld> = prepared
106 .projection
107 .omissions
108 .iter()
109 .map(|omission| landing::Withheld {
110 path: omission.destination.clone(),
111 reason: omission.reason.clone(),
112 })
113 .collect();
114 (!withheld.is_empty()).then_some(withheld)
115}
116
117pub fn run(args: &InitArgs) -> Result<(), RkError> {
126 let out = Output::new(args.json);
127 if !args.target.is_dir() {
128 return Err(RkError::refusal(
129 Diagnostic::new(
130 Reason::TargetNotFound,
131 format!(
132 "target {} is not a directory; nothing was written",
133 args.target
134 ),
135 )
136 .expected("an existing directory to land into")
137 .target_state("unchanged"),
138 ));
139 }
140 let lock = args
144 .apply
145 .then(|| lock::acquire(&args.target))
146 .transpose()?;
147 held::pause(apply::PAUSE_VAR, "locked", "proceed-locked");
152 let held = lock.as_ref().map_or_else(
153 || apply::Held::open(&args.target),
154 |lock| apply::Held::open_locked(&args.target, lock),
155 )?;
156 held::pause(apply::PAUSE_VAR, "held", "proceed-held");
158 let config = crate::config::load(held.base().as_std_path())?;
159 let params = landing::Params::resolve(
160 held.base(),
161 &landing::Inputs {
162 nix: args.nix_packaging.then_some(true),
163 reporting_policy: args.reporting_policy.then_some(true),
164 scorecard: args.scorecard.then_some(true),
165 code_scanning: args
166 .code_scanning
167 .as_deref()
168 .map(Provider::parse)
169 .transpose()?,
170 ..args.profile.inputs()?
171 },
172 config.as_ref(),
173 None,
174 if args.apply {
175 landing::Purpose::Init
176 } else {
177 landing::Purpose::Preview
178 },
179 )?;
180 if let Some(lock) = lock {
181 refuse_a_recorded_target(&held)?;
182 let prepared = apply::prepare(&held, None, ¶ms, config.as_ref())?;
183 let landed = apply::land(&held, None, &prepared, apply::Origin::Init, &lock)?;
184 drop(lock);
185 report_apply(out, args, &held, ¶ms, &prepared, &landed)
186 } else {
187 let prepared = apply::prepare(&held, None, ¶ms, config.as_ref())?;
188 let repo = (params.repo() != landing::REPO_PLACEHOLDER && !params.repo().is_empty())
189 .then(|| params.repo().to_owned());
190 if params.repo() == landing::REPO_PLACEHOLDER {
191 out.frame(
192 "note: no repository detected; an apply derives the owner from --repo <path>",
193 );
194 }
195 preview(out, args, ¶ms, repo, &prepared)
196 }
197}
198
199fn selection_of(prepared: &Prepared) -> Vec<CapabilityNote> {
201 prepared
202 .projection
203 .capabilities
204 .iter()
205 .map(|selection| CapabilityNote::of(selection, &prepared.projection))
206 .collect()
207}
208
209pub(crate) fn describe_selection(out: Output, prepared: &Prepared) {
211 for note in selection_of(prepared) {
212 let mut line = format!("capability {}: {}", note.id, note.status);
213 if let Some(reason) = ¬e.reason {
214 line.push_str(" (");
215 line.push_str(reason);
216 line.push(')');
217 }
218 out.result_line(line);
219 if let Some(action) = ¬e.action {
220 out.result_line(format!(" action: {action}"));
221 }
222 }
223}
224
225#[allow(
228 clippy::too_many_lines,
229 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"
230)]
231fn preview(
232 out: Output,
233 args: &InitArgs,
234 params: &landing::Params,
235 repo: Option<String>,
236 prepared: &Prepared,
237) -> Result<(), RkError> {
238 let flags = params.canonical_flags();
239 let flags = if params.forge().is_some() && repo.is_none() {
240 format!("{flags} --repo <owner/name>")
241 } else {
242 flags
243 };
244 let capabilities = params.capability_flags();
245 let mut next = vec![format!(
246 "rk init{flags}{capabilities} --target {} --apply",
247 args.target
248 )];
249 if let Some(reason) = prepared.projection.release_unavailable() {
250 next.insert(
251 0,
252 format!("the apply refuses until the release automation resolves: {reason}"),
253 );
254 }
255 if !prepared.collisions.is_empty() {
256 next.insert(
257 0,
258 "resolve each collision above through the rk-setup skill; the apply refuses until then"
259 .to_owned(),
260 );
261 }
262 if let Some(reason) = prepared.projection.licence_refusal.as_deref() {
263 next.insert(
264 0,
265 format!("the apply refuses until the licence condition is answered: {reason}"),
266 );
267 }
268 next.push(format!(
269 "rk stage --target {} stages the complete candidate for a byte comparison",
270 args.target
271 ));
272 out.result_line(format!(
273 "DRY RUN: rk init writes these files into {}; re-run with --apply",
274 args.target
275 ));
276 out.result_line(format!(
277 "profile: {}",
278 crate::commands::profile::describe(
279 params.profile(),
280 params.git(),
281 params.capabilities(),
282 params.repo()
283 )
284 ));
285 describe_selection(out, prepared);
286 for decision in &prepared.decisions {
287 out.result_line(format!(
288 "{} {}",
289 decision.action.as_str(),
290 decision.destination
291 ));
292 }
293 for collision in &prepared.collisions {
294 out.result_line(format!(
295 "collision {}: {}",
296 collision.path, collision.reason
297 ));
298 }
299 out.result_line(format!(
300 "{} {}\n{}",
301 prepared.config.action,
302 crate::config::CONFIG_PATH,
303 prepared.config.content
304 ));
305 for entry in &prepared.projection.omissions {
306 out.result_line(format!("withheld {}: {}", entry.destination, entry.reason));
307 if let Some(action) = &entry.action {
308 out.result_line(format!(" action: {action}"));
309 }
310 }
311 out.next(&next);
312 out.emit(&Report {
313 schema: "rk.init/10",
314 config: prepared.config.clone(),
315 mode: "preview",
316 target: args.target.to_string(),
317 profile: params.profile().clone(),
318 git: params.git().clone(),
319 capabilities: params.capabilities().clone(),
320 repo,
321 selection: selection_of(prepared),
322 release_unavailable: prepared.projection.release_unavailable().map(str::to_owned),
323 licence_refusal: prepared.projection.licence_refusal.clone(),
324 withheld: withheld_of(prepared),
325 collisions: (!prepared.collisions.is_empty()).then(|| prepared.collisions.clone()),
326 files: prepared
327 .decisions
328 .iter()
329 .map(|decision| FileEntry {
330 path: decision.destination.clone(),
331 kind: decision.kind.as_str(),
332 action: decision.action.as_str(),
333 })
334 .chain(prepared.collisions.iter().map(|collision| FileEntry {
335 path: collision.path.clone(),
336 kind: landing::kind_of(&collision.path).map_or("unknown", landing::Kind::as_str),
337 action: "collision",
338 }))
339 .collect(),
340 sentinels: None,
341 next,
342 })
343}
344
345#[allow(
348 clippy::too_many_arguments,
349 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"
350)]
351#[allow(
352 clippy::too_many_lines,
353 reason = "one pass reports the profile, the selection, every decision, and the sentinels the operator still owes"
354)]
355fn report_apply(
356 out: Output,
357 args: &InitArgs,
358 held: &apply::Held,
359 params: &landing::Params,
360 prepared: &Prepared,
361 landed: &apply::Landed,
362) -> Result<(), RkError> {
363 let mut file_entries = Vec::new();
364 let mut sentinels = Vec::new();
365 out.result_line(format!(
366 "profile: {}",
367 crate::commands::profile::describe(
368 params.profile(),
369 params.git(),
370 params.capabilities(),
371 params.repo()
372 )
373 ));
374 describe_selection(out, prepared);
375 for decision in &prepared.decisions {
376 out.result_line(describe(decision));
377 if decision.action != Action::Released {
378 let bytes =
379 landing::read_recorded(held.base(), &decision.destination)?.unwrap_or_default();
380 collect_sentinels(
381 held.display(),
382 &decision.destination,
383 &bytes,
384 &mut sentinels,
385 );
386 }
387 file_entries.push(FileEntry {
388 path: decision.destination.clone(),
389 kind: decision.kind.as_str(),
390 action: decision.action.as_str(),
391 });
392 }
393 for entry in &prepared.projection.omissions {
394 out.result_line(format!("withheld {}: {}", entry.destination, entry.reason));
395 if let Some(action) = &entry.action {
396 out.result_line(format!(" action: {action}"));
397 }
398 }
399 out.result_line(format!(
400 "{} {}",
401 prepared.config.action,
402 crate::config::CONFIG_PATH
403 ));
404 for (key, empty_line) in [
405 ("setup.required_check", "required_check = \"\""),
406 ("setup.bot.app_id", "app_id = \"\""),
407 ] {
408 if let Some((index, _)) = prepared
409 .config
410 .content
411 .lines()
412 .enumerate()
413 .find(|(_, line)| line.starts_with(empty_line))
414 {
415 sentinels.push(SentinelEntry {
416 path: crate::config::CONFIG_PATH.into(),
417 line: index + 1,
418 text: format!("set {key} before forge setup"),
419 });
420 }
421 }
422 out.result_line(format!("wrote {}", manifest::MANIFEST_PATH));
423 debug_assert_eq!(
424 landed.completed.last().map(String::as_str),
425 Some(manifest::MANIFEST_PATH)
426 );
427
428 if sentinels.is_empty() {
429 out.result_line("no sentinels to fill");
430 } else {
431 out.result_line("fill these sentinels before the workflow runs:");
432 for sentinel in &sentinels {
433 out.result_line(format!(
434 "{}:{}: {}",
435 sentinel.path, sentinel.line, sentinel.text
436 ));
437 }
438 }
439 let mut next = vec![
440 if sentinels.is_empty() {
441 "commit the landed files, the receipt included".to_owned()
442 } else {
443 "fill each sentinel above, then commit the landed files, the receipt included"
444 .to_owned()
445 },
446 format!("rk status --target {} reports this landing", args.target),
447 ];
448 if params.release_mode() == crate::profile::ReleaseMode::Automatic {
451 next.push("rk method setup orders what follows".to_owned());
452 } else if params.forge().is_some() {
453 next.push("rk setup --target . previews the applicable forge steps".to_owned());
454 }
455 out.next(&next);
456 out.emit(&Report {
457 schema: "rk.init/10",
458 config: prepared.config.clone(),
459 mode: "apply",
460 target: args.target.to_string(),
461 profile: params.profile().clone(),
462 git: params.git().clone(),
463 capabilities: params.capabilities().clone(),
464 repo: (!params.repo().is_empty()).then(|| params.repo().to_owned()),
465 selection: selection_of(prepared),
466 release_unavailable: None,
467 licence_refusal: prepared.projection.licence_refusal.clone(),
468 withheld: withheld_of(prepared),
469 collisions: None,
470 files: file_entries,
471 sentinels: Some(sentinels),
472 next,
473 })
474}
475
476pub(crate) fn describe(decision: &apply::Decision) -> String {
478 match decision.action {
479 Action::Preserved | Action::Drift => format!(
480 "{} {} ({}, target-owned)",
481 decision.action.as_str(),
482 decision.destination,
483 decision.kind.as_str()
484 ),
485 Action::Released => format!(
486 "released {} (no longer produced; target-owned from this landing)",
487 decision.destination
488 ),
489 Action::Matched => format!(
490 "matched {} (already holds the candidate's bytes)",
491 decision.destination
492 ),
493 Action::Created | Action::Replaced => {
494 format!("{} {}", decision.action.as_str(), decision.destination)
495 }
496 }
497}
498
499fn refuse_a_recorded_target(held: &apply::Held) -> Result<(), RkError> {
502 if landing::manifest::load(held.base())?.is_none() {
503 return Ok(());
504 }
505 let target = held.display();
506 Err(RkError::refusal(
507 Diagnostic::new(
508 Reason::StateDrift,
509 format!(
510 "{target} already carries {}, and nothing was written",
511 manifest::MANIFEST_PATH
512 ),
513 )
514 .expected("a target without a landing receipt")
515 .action(format!(
516 "rk upgrade --target {target} takes it to this binary's projection"
517 ))
518 .target_state("unchanged"),
519 ))
520}
521
522fn collect_sentinels(
525 target: &Utf8Path,
526 destination: &str,
527 bytes: &[u8],
528 found: &mut Vec<SentinelEntry>,
529) {
530 let text = String::from_utf8_lossy(bytes);
531 for (idx, line) in text.lines().enumerate() {
532 if line.contains(embedded::SENTINEL) {
533 found.push(SentinelEntry {
534 path: target.join(destination).to_string(),
535 line: idx + 1,
536 text: line.trim().to_owned(),
537 });
538 }
539 }
540}
541
542#[cfg(test)]
543mod tests {
544 use super::{FileEntry, Report, SentinelEntry};
545 use crate::landing::CheckoutMode;
546 use crate::profile::{
547 CapabilityRequests, GitWorkflow, ProfileSnapshot, ReleaseIntent, ReleaseMode,
548 };
549 use crate::stage::CapabilityNote;
550
551 #[test]
555 fn the_init_report_schema_snapshot_holds() {
556 let apply = Report {
557 schema: "rk.init/10",
558 config: crate::config::Plan {
559 action: "added",
560 changes: vec![],
561 content: "schema_version = 2\n".into(),
562 },
563 mode: "apply",
564 target: "/tmp/t".into(),
565 profile: ProfileSnapshot {
566 technologies: vec!["rust".into()],
567 forge: Some("github".into()),
568 release: ReleaseIntent {
569 mode: ReleaseMode::Automatic,
570 driver: Some("rust".into()),
571 style: Some(crate::landing::Style::Trunk),
572 line_prefix: Some("release/".into()),
573 },
574 },
575 git: GitWorkflow {
576 trunk: "master".into(),
577 checkout_mode: CheckoutMode::LinkedWorktree,
578 },
579 capabilities: CapabilityRequests {
580 nix_packaging: true,
581 reporting_policy: true,
582 scorecard: true,
583 code_scanning: Some(crate::landing::Provider::CodeQl),
584 },
585 repo: Some("acme/widget".into()),
586 selection: vec![CapabilityNote {
587 id: "git.guards".into(),
588 status: "selected".into(),
589 reason: None,
590 action: None,
591 destinations: vec!["AGENTS.md".into()],
592 }],
593 release_unavailable: None,
594 licence_refusal: None,
595 withheld: Some(vec![crate::landing::Withheld {
596 path: "flake.nix".into(),
597 reason: "the target already carries flake.nix".into(),
598 }]),
599 collisions: None,
600 files: vec![FileEntry {
601 path: "release-plz.toml".into(),
602 kind: "seeded",
603 action: "created",
604 }],
605 sentinels: Some(vec![SentinelEntry {
606 path: "/tmp/t/release-plz.toml".into(),
607 line: 3,
608 text: "# TODO(release-kit): keep false for a binary-only crate".into(),
609 }]),
610 next: vec!["commit the landed files, the receipt included".into()],
611 };
612 assert_eq!(
613 serde_json::to_string(&apply).expect("a report serializes"),
614 r##"{"schema":"rk.init/10","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"},"capabilities":{"nix_packaging":true,"reporting_policy":true,"scorecard":true,"code_scanning":"codeql"},"repo":"acme/widget","selection":[{"id":"git.guards","status":"selected","destinations":["AGENTS.md"]}],"withheld":[{"path":"flake.nix","reason":"the target already carries flake.nix"}],"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"]}"##
615 );
616 let preview = Report {
617 sentinels: None,
618 repo: None,
619 mode: "preview",
620 capabilities: CapabilityRequests {
621 nix_packaging: false,
622 reporting_policy: false,
623 scorecard: false,
624 code_scanning: None,
625 },
626 selection: vec![],
627 release_unavailable: Some(
628 "the release automation at (python, gitlab) has no landable files".to_owned(),
629 ),
630 licence_refusal: Some("the target's Cargo.toml declares no license field".to_owned()),
631 withheld: None,
632 collisions: Some(vec![super::Collision {
633 path: "SECURITY.md".into(),
634 reason: "exists, and no receipt attributes it to release-kit".into(),
635 }]),
636 ..apply
637 };
638 assert_eq!(
639 serde_json::to_string(&preview).expect("a report serializes"),
640 r#"{"schema":"rk.init/10","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"},"capabilities":{"nix_packaging":false,"reporting_policy":false,"scorecard":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"}],"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"]}"#,
641 "a preview omits the sentinels, the unresolved repo, and an empty withheld list rather than serializing null"
642 );
643 }
644}