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, Style, Workflow};
27use crate::landing::{self, lock};
28use crate::output::Output;
29
30#[derive(Debug, Serialize)]
32struct FileEntry {
33 path: String,
35 kind: &'static str,
37 action: &'static str,
40}
41
42#[derive(Debug, Serialize)]
44struct SentinelEntry {
45 path: String,
47 line: usize,
49 text: String,
51}
52
53#[derive(Debug, Serialize)]
55struct Report {
56 schema: &'static str,
58 mode: &'static str,
60 tech: String,
62 forge: String,
64 target: String,
66 #[serde(skip_serializing_if = "Option::is_none")]
68 repo: Option<String>,
69 workflow: &'static str,
71 style: &'static str,
72 nix: bool,
74 scorecard: bool,
76 #[serde(skip_serializing_if = "Option::is_none")]
79 code_scanning: Option<&'static str>,
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 tech: args.tech.as_deref(),
163 forge: args.forge.as_deref(),
164 repo: args.repo.as_deref(),
165 workflow: args.workflow.as_deref().map(Workflow::parse).transpose()?,
166 style: args.style.as_deref().map(Style::parse).transpose()?,
167 nix: args.nix.then_some(true),
168 scorecard: args.scorecard.then_some(true),
169 code_scanning: args
170 .code_scanning
171 .as_deref()
172 .map(Provider::parse)
173 .transpose()?,
174 },
175 config.as_ref(),
176 None,
177 if args.apply {
178 landing::Purpose::Init
179 } else {
180 landing::Purpose::Preview
181 },
182 )?;
183 let style = params
184 .style()
185 .ok_or_else(|| RkError::Usage("landing style is unresolved".into()))?;
186 if let Some(lock) = lock {
187 refuse_a_recorded_target(&held)?;
188 let prepared = apply::prepare(&held, None, ¶ms, config.as_ref())?;
189 let landed = apply::land(&held, None, &prepared, apply::Origin::Init, &lock)?;
190 drop(lock);
191 report_apply(out, args, &held, ¶ms, style, &prepared, &landed)
192 } else {
193 let prepared = apply::prepare(&held, None, ¶ms, config.as_ref())?;
194 let repo = (params.repo() != landing::REPO_PLACEHOLDER).then(|| params.repo().to_owned());
195 if repo.is_none() {
196 out.frame(
197 "note: no repository detected; an apply derives the owner from --repo <path>",
198 );
199 }
200 preview(out, args, ¶ms, repo, style, &prepared)
201 }
202}
203
204fn preview(
207 out: Output,
208 args: &InitArgs,
209 params: &landing::Params,
210 repo: Option<String>,
211 style: Style,
212 prepared: &Prepared,
213) -> Result<(), RkError> {
214 let repo_argument = repo.as_deref().unwrap_or("<owner/name>");
215 let capabilities = params.capability_flags();
216 let mut next = vec![format!(
217 "rk init --tech {} --forge {} --repo {repo_argument} --workflow {} --style {}{capabilities} --target {} --apply",
218 params.tech(),
219 params.forge(),
220 params.workflow().as_str(),
221 style.as_str(),
222 args.target
223 )];
224 if !prepared.collisions.is_empty() {
225 next.insert(
226 0,
227 "resolve each collision above through the rk-setup skill; the apply refuses until then"
228 .to_owned(),
229 );
230 }
231 if let Some(reason) = prepared.projection.licence_refusal.as_deref() {
232 next.insert(
233 0,
234 format!("the apply refuses until the licence condition is answered: {reason}"),
235 );
236 }
237 next.push(format!(
238 "rk stage --target {} stages the complete candidate for a byte comparison",
239 args.target
240 ));
241 out.result_line(format!(
242 "DRY RUN: rk init writes these files into {}; re-run with --apply",
243 args.target
244 ));
245 for decision in &prepared.decisions {
246 out.result_line(format!(
247 "{} {}",
248 decision.action.as_str(),
249 decision.destination
250 ));
251 }
252 for collision in &prepared.collisions {
253 out.result_line(format!(
254 "collision {}: {}",
255 collision.path, collision.reason
256 ));
257 }
258 out.result_line(format!(
259 "{} {}\n{}",
260 prepared.config.action,
261 crate::config::CONFIG_PATH,
262 prepared.config.content
263 ));
264 for entry in &prepared.projection.omissions {
265 out.result_line(format!("withheld {}: {}", entry.destination, entry.reason));
266 }
267 out.next(&next);
268 out.emit(&Report {
269 schema: "rk.init/9",
270 config: prepared.config.clone(),
271 mode: "preview",
272 tech: params.tech().to_owned(),
273 forge: params.forge().to_owned(),
274 target: args.target.to_string(),
275 repo,
276 workflow: params.workflow().as_str(),
277 style: style.as_str(),
278 nix: params.nix(),
279 scorecard: params.scorecard(),
280 code_scanning: params.code_scanning().map(Provider::as_str),
281 licence_refusal: prepared.projection.licence_refusal.clone(),
282 withheld: withheld_of(prepared),
283 collisions: (!prepared.collisions.is_empty()).then(|| prepared.collisions.clone()),
284 files: prepared
285 .decisions
286 .iter()
287 .map(|decision| FileEntry {
288 path: decision.destination.clone(),
289 kind: decision.kind.as_str(),
290 action: decision.action.as_str(),
291 })
292 .chain(prepared.collisions.iter().map(|collision| FileEntry {
293 path: collision.path.clone(),
294 kind: landing::kind_of(&collision.path).map_or("unknown", landing::Kind::as_str),
295 action: "collision",
296 }))
297 .collect(),
298 sentinels: None,
299 next,
300 })
301}
302
303#[allow(
306 clippy::too_many_arguments,
307 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"
308)]
309fn report_apply(
310 out: Output,
311 args: &InitArgs,
312 held: &apply::Held,
313 params: &landing::Params,
314 style: Style,
315 prepared: &Prepared,
316 landed: &apply::Landed,
317) -> Result<(), RkError> {
318 let mut file_entries = Vec::new();
319 let mut sentinels = Vec::new();
320 for decision in &prepared.decisions {
321 out.result_line(describe(decision));
322 if decision.action != Action::Released {
323 let bytes =
324 landing::read_recorded(held.base(), &decision.destination)?.unwrap_or_default();
325 collect_sentinels(
326 held.display(),
327 &decision.destination,
328 &bytes,
329 &mut sentinels,
330 );
331 }
332 file_entries.push(FileEntry {
333 path: decision.destination.clone(),
334 kind: decision.kind.as_str(),
335 action: decision.action.as_str(),
336 });
337 }
338 for entry in &prepared.projection.omissions {
339 out.result_line(format!("withheld {}: {}", entry.destination, entry.reason));
340 }
341 out.result_line(format!(
342 "{} {}",
343 prepared.config.action,
344 crate::config::CONFIG_PATH
345 ));
346 for (key, empty_line) in [
347 ("setup.required_check", "required_check = \"\""),
348 ("setup.bot.app_id", "app_id = \"\""),
349 ] {
350 if let Some((index, _)) = prepared
351 .config
352 .content
353 .lines()
354 .enumerate()
355 .find(|(_, line)| line.starts_with(empty_line))
356 {
357 sentinels.push(SentinelEntry {
358 path: crate::config::CONFIG_PATH.into(),
359 line: index + 1,
360 text: format!("set {key} before forge setup"),
361 });
362 }
363 }
364 out.result_line(format!("wrote {}", manifest::MANIFEST_PATH));
365 debug_assert_eq!(
366 landed.completed.last().map(String::as_str),
367 Some(manifest::MANIFEST_PATH)
368 );
369
370 if sentinels.is_empty() {
371 out.result_line("no sentinels to fill");
372 } else {
373 out.result_line("fill these sentinels before the workflow runs:");
374 for sentinel in &sentinels {
375 out.result_line(format!(
376 "{}:{}: {}",
377 sentinel.path, sentinel.line, sentinel.text
378 ));
379 }
380 }
381 let next = vec![
382 if sentinels.is_empty() {
383 "commit the landed files, the receipt included".to_owned()
384 } else {
385 "fill each sentinel above, then commit the landed files, the receipt included"
386 .to_owned()
387 },
388 format!("rk status --target {} reports this landing", args.target),
389 "rk method setup orders what follows".to_owned(),
390 ];
391 out.next(&next);
392 out.emit(&Report {
393 schema: "rk.init/9",
394 config: prepared.config.clone(),
395 mode: "apply",
396 tech: params.tech().to_owned(),
397 forge: params.forge().to_owned(),
398 target: args.target.to_string(),
399 repo: Some(params.repo().to_owned()),
400 workflow: params.workflow().as_str(),
401 style: style.as_str(),
402 nix: params.nix(),
403 scorecard: params.scorecard(),
404 code_scanning: params.code_scanning().map(Provider::as_str),
405 licence_refusal: prepared.projection.licence_refusal.clone(),
406 withheld: withheld_of(prepared),
407 collisions: None,
408 files: file_entries,
409 sentinels: Some(sentinels),
410 next,
411 })
412}
413
414pub(crate) fn describe(decision: &apply::Decision) -> String {
416 match decision.action {
417 Action::Preserved | Action::Drift => format!(
418 "{} {} ({}, target-owned)",
419 decision.action.as_str(),
420 decision.destination,
421 decision.kind.as_str()
422 ),
423 Action::Released => format!(
424 "released {} (no longer produced; target-owned from this landing)",
425 decision.destination
426 ),
427 Action::Matched => format!(
428 "matched {} (already holds the candidate's bytes)",
429 decision.destination
430 ),
431 Action::Created | Action::Replaced => {
432 format!("{} {}", decision.action.as_str(), decision.destination)
433 }
434 }
435}
436
437fn refuse_a_recorded_target(held: &apply::Held) -> Result<(), RkError> {
440 if landing::manifest::load(held.base())?.is_none() {
441 return Ok(());
442 }
443 let target = held.display();
444 Err(RkError::refusal(
445 Diagnostic::new(
446 Reason::StateDrift,
447 format!(
448 "{target} already carries {}, and nothing was written",
449 manifest::MANIFEST_PATH
450 ),
451 )
452 .expected("a target without a landing receipt")
453 .action(format!(
454 "rk upgrade --target {target} takes it to this binary's projection"
455 ))
456 .target_state("unchanged"),
457 ))
458}
459
460fn collect_sentinels(
463 target: &Utf8Path,
464 destination: &str,
465 bytes: &[u8],
466 found: &mut Vec<SentinelEntry>,
467) {
468 let text = String::from_utf8_lossy(bytes);
469 for (idx, line) in text.lines().enumerate() {
470 if line.contains(embedded::SENTINEL) {
471 found.push(SentinelEntry {
472 path: target.join(destination).to_string(),
473 line: idx + 1,
474 text: line.trim().to_owned(),
475 });
476 }
477 }
478}
479
480#[cfg(test)]
481mod tests {
482 use super::{FileEntry, Report, SentinelEntry};
483
484 #[test]
488 fn the_init_report_schema_snapshot_holds() {
489 let apply = Report {
490 schema: "rk.init/9",
491 config: crate::config::Plan {
492 action: "added",
493 changes: vec![],
494 content: "schema_version = 1\n".into(),
495 },
496 mode: "apply",
497 tech: "rust".into(),
498 forge: "github".into(),
499 target: "/tmp/t".into(),
500 repo: Some("acme/widget".into()),
501 workflow: "worktree",
502 style: "trunk",
503 nix: true,
504 scorecard: true,
505 code_scanning: Some("codeql"),
506 licence_refusal: None,
507 withheld: Some(vec![crate::landing::Withheld {
508 path: "flake.nix".into(),
509 reason: "the target already carries flake.nix".into(),
510 }]),
511 collisions: None,
512 files: vec![FileEntry {
513 path: "release-plz.toml".into(),
514 kind: "seeded",
515 action: "created",
516 }],
517 sentinels: Some(vec![SentinelEntry {
518 path: "/tmp/t/release-plz.toml".into(),
519 line: 3,
520 text: "# TODO(release-kit): keep false for a binary-only crate".into(),
521 }]),
522 next: vec!["commit the landed files, the receipt included".into()],
523 };
524 assert_eq!(
525 serde_json::to_string(&apply).expect("a report serializes"),
526 r##"{"schema":"rk.init/9","mode":"apply","tech":"rust","forge":"github","target":"/tmp/t","repo":"acme/widget","workflow":"worktree","style":"trunk","nix":true,"scorecard":true,"code_scanning":"codeql","withheld":[{"path":"flake.nix","reason":"the target already carries flake.nix"}],"config":{"action":"added","changes":[],"content":"schema_version = 1\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"]}"##
527 );
528 let preview = Report {
529 sentinels: None,
530 repo: None,
531 mode: "preview",
532 nix: false,
533 scorecard: false,
534 code_scanning: None,
535 licence_refusal: Some("the target's Cargo.toml declares no license field".to_owned()),
536 withheld: None,
537 collisions: Some(vec![super::Collision {
538 path: "SECURITY.md".into(),
539 reason: "exists, and no receipt attributes it to release-kit".into(),
540 }]),
541 ..apply
542 };
543 assert_eq!(
544 serde_json::to_string(&preview).expect("a report serializes"),
545 r#"{"schema":"rk.init/9","mode":"preview","tech":"rust","forge":"github","target":"/tmp/t","workflow":"worktree","style":"trunk","nix":false,"scorecard":false,"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 = 1\n"},"files":[{"path":"release-plz.toml","kind":"seeded","action":"created"}],"next":["commit the landed files, the receipt included"]}"#,
546 "a preview omits the sentinels, the unresolved repo, and an empty withheld list rather than serializing null"
547 );
548 }
549}