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, 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 #[serde(skip_serializing_if = "Option::is_none")]
77 withheld: Option<Vec<landing::Withheld>>,
78 #[serde(skip_serializing_if = "Option::is_none")]
81 collisions: Option<Vec<Collision>>,
82 config: crate::config::Plan,
83 files: Vec<FileEntry>,
85 #[serde(skip_serializing_if = "Option::is_none")]
87 sentinels: Option<Vec<SentinelEntry>>,
88 next: Vec<String>,
90}
91
92fn withheld_of(prepared: &Prepared) -> Option<Vec<landing::Withheld>> {
94 let withheld: Vec<landing::Withheld> = prepared
95 .projection
96 .omissions
97 .iter()
98 .map(|omission| landing::Withheld {
99 path: omission.destination.clone(),
100 reason: omission.reason.clone(),
101 })
102 .collect();
103 (!withheld.is_empty()).then_some(withheld)
104}
105
106pub fn run(args: &InitArgs) -> Result<(), RkError> {
115 let out = Output::new(args.json);
116 if !args.target.is_dir() {
117 return Err(RkError::refusal(
118 Diagnostic::new(
119 Reason::TargetNotFound,
120 format!(
121 "target {} is not a directory; nothing was written",
122 args.target
123 ),
124 )
125 .expected("an existing directory to land into")
126 .target_state("unchanged"),
127 ));
128 }
129 let lock = args
133 .apply
134 .then(|| lock::acquire(&args.target))
135 .transpose()?;
136 held::pause(apply::PAUSE_VAR, "locked", "proceed-locked");
141 let held = lock.as_ref().map_or_else(
142 || apply::Held::open(&args.target),
143 |lock| apply::Held::open_locked(&args.target, lock),
144 )?;
145 held::pause(apply::PAUSE_VAR, "held", "proceed-held");
147 let config = crate::config::load(held.base().as_std_path())?;
148 let params = landing::Params::resolve(
149 held.base(),
150 &landing::Inputs {
151 tech: args.tech.as_deref(),
152 forge: args.forge.as_deref(),
153 repo: args.repo.as_deref(),
154 workflow: args.workflow.as_deref().map(Workflow::parse).transpose()?,
155 style: args.style.as_deref().map(Style::parse).transpose()?,
156 nix: args.nix.then_some(true),
157 },
158 config.as_ref(),
159 None,
160 if args.apply {
161 landing::Purpose::Init
162 } else {
163 landing::Purpose::Preview
164 },
165 )?;
166 let style = params
167 .style()
168 .ok_or_else(|| RkError::Usage("landing style is unresolved".into()))?;
169 if let Some(lock) = lock {
170 refuse_a_recorded_target(&held)?;
171 let prepared = apply::prepare(&held, None, ¶ms, config.as_ref())?;
172 let landed = apply::land(&held, None, &prepared, apply::Origin::Init, &lock)?;
173 drop(lock);
174 report_apply(out, args, &held, ¶ms, style, &prepared, &landed)
175 } else {
176 let prepared = apply::prepare(&held, None, ¶ms, config.as_ref())?;
177 let repo = (params.repo() != landing::REPO_PLACEHOLDER).then(|| params.repo().to_owned());
178 if repo.is_none() {
179 out.frame(
180 "note: no repository detected; an apply derives the owner from --repo <path>",
181 );
182 }
183 preview(out, args, ¶ms, repo, style, &prepared)
184 }
185}
186
187fn preview(
190 out: Output,
191 args: &InitArgs,
192 params: &landing::Params,
193 repo: Option<String>,
194 style: Style,
195 prepared: &Prepared,
196) -> Result<(), RkError> {
197 let repo_argument = repo.as_deref().unwrap_or("<owner/name>");
198 let nix_flag = if params.nix() { " --nix" } else { "" };
199 let mut next = vec![format!(
200 "rk init --tech {} --forge {} --repo {repo_argument} --workflow {} --style {}{nix_flag} --target {} --apply",
201 params.tech(),
202 params.forge(),
203 params.workflow().as_str(),
204 style.as_str(),
205 args.target
206 )];
207 if !prepared.collisions.is_empty() {
208 next.insert(
209 0,
210 "resolve each collision above through the rk-setup skill; the apply refuses until then"
211 .to_owned(),
212 );
213 }
214 next.push(format!(
215 "rk stage --target {} stages the complete candidate for a byte comparison",
216 args.target
217 ));
218 out.result_line(format!(
219 "DRY RUN: rk init writes these files into {}; re-run with --apply",
220 args.target
221 ));
222 for decision in &prepared.decisions {
223 out.result_line(format!(
224 "{} {}",
225 decision.action.as_str(),
226 decision.destination
227 ));
228 }
229 for collision in &prepared.collisions {
230 out.result_line(format!(
231 "collision {}: {}",
232 collision.path, collision.reason
233 ));
234 }
235 out.result_line(format!(
236 "{} {}\n{}",
237 prepared.config.action,
238 crate::config::CONFIG_PATH,
239 prepared.config.content
240 ));
241 for entry in &prepared.projection.omissions {
242 out.result_line(format!("withheld {}: {}", entry.destination, entry.reason));
243 }
244 out.next(&next);
245 out.emit(&Report {
246 schema: "rk.init/7",
247 config: prepared.config.clone(),
248 mode: "preview",
249 tech: params.tech().to_owned(),
250 forge: params.forge().to_owned(),
251 target: args.target.to_string(),
252 repo,
253 workflow: params.workflow().as_str(),
254 style: style.as_str(),
255 nix: params.nix(),
256 withheld: withheld_of(prepared),
257 collisions: (!prepared.collisions.is_empty()).then(|| prepared.collisions.clone()),
258 files: prepared
259 .decisions
260 .iter()
261 .map(|decision| FileEntry {
262 path: decision.destination.clone(),
263 kind: decision.kind.as_str(),
264 action: decision.action.as_str(),
265 })
266 .chain(prepared.collisions.iter().map(|collision| FileEntry {
267 path: collision.path.clone(),
268 kind: landing::kind_of(&collision.path).map_or("unknown", landing::Kind::as_str),
269 action: "collision",
270 }))
271 .collect(),
272 sentinels: None,
273 next,
274 })
275}
276
277#[allow(
280 clippy::too_many_arguments,
281 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"
282)]
283fn report_apply(
284 out: Output,
285 args: &InitArgs,
286 held: &apply::Held,
287 params: &landing::Params,
288 style: Style,
289 prepared: &Prepared,
290 landed: &apply::Landed,
291) -> Result<(), RkError> {
292 let mut file_entries = Vec::new();
293 let mut sentinels = Vec::new();
294 for decision in &prepared.decisions {
295 out.result_line(describe(decision));
296 if decision.action != Action::Released {
297 let bytes =
298 landing::read_recorded(held.base(), &decision.destination)?.unwrap_or_default();
299 collect_sentinels(
300 held.display(),
301 &decision.destination,
302 &bytes,
303 &mut sentinels,
304 );
305 }
306 file_entries.push(FileEntry {
307 path: decision.destination.clone(),
308 kind: decision.kind.as_str(),
309 action: decision.action.as_str(),
310 });
311 }
312 for entry in &prepared.projection.omissions {
313 out.result_line(format!("withheld {}: {}", entry.destination, entry.reason));
314 }
315 out.result_line(format!(
316 "{} {}",
317 prepared.config.action,
318 crate::config::CONFIG_PATH
319 ));
320 for (key, empty_line) in [
321 ("setup.required_check", "required_check = \"\""),
322 ("setup.bot.app_id", "app_id = \"\""),
323 ] {
324 if let Some((index, _)) = prepared
325 .config
326 .content
327 .lines()
328 .enumerate()
329 .find(|(_, line)| line.starts_with(empty_line))
330 {
331 sentinels.push(SentinelEntry {
332 path: crate::config::CONFIG_PATH.into(),
333 line: index + 1,
334 text: format!("set {key} before forge setup"),
335 });
336 }
337 }
338 out.result_line(format!("wrote {}", manifest::MANIFEST_PATH));
339 debug_assert_eq!(
340 landed.completed.last().map(String::as_str),
341 Some(manifest::MANIFEST_PATH)
342 );
343
344 if sentinels.is_empty() {
345 out.result_line("no sentinels to fill");
346 } else {
347 out.result_line("fill these sentinels before the workflow runs:");
348 for sentinel in &sentinels {
349 out.result_line(format!(
350 "{}:{}: {}",
351 sentinel.path, sentinel.line, sentinel.text
352 ));
353 }
354 }
355 let next = vec![
356 if sentinels.is_empty() {
357 "commit the landed files, the receipt included".to_owned()
358 } else {
359 "fill each sentinel above, then commit the landed files, the receipt included"
360 .to_owned()
361 },
362 format!("rk status --target {} reports this landing", args.target),
363 "rk method setup orders what follows".to_owned(),
364 ];
365 out.next(&next);
366 out.emit(&Report {
367 schema: "rk.init/7",
368 config: prepared.config.clone(),
369 mode: "apply",
370 tech: params.tech().to_owned(),
371 forge: params.forge().to_owned(),
372 target: args.target.to_string(),
373 repo: Some(params.repo().to_owned()),
374 workflow: params.workflow().as_str(),
375 style: style.as_str(),
376 nix: params.nix(),
377 withheld: withheld_of(prepared),
378 collisions: None,
379 files: file_entries,
380 sentinels: Some(sentinels),
381 next,
382 })
383}
384
385pub(crate) fn describe(decision: &apply::Decision) -> String {
387 match decision.action {
388 Action::Preserved | Action::Drift => format!(
389 "{} {} ({}, target-owned)",
390 decision.action.as_str(),
391 decision.destination,
392 decision.kind.as_str()
393 ),
394 Action::Released => format!(
395 "released {} (no longer produced; target-owned from this landing)",
396 decision.destination
397 ),
398 Action::Matched => format!(
399 "matched {} (already holds the candidate's bytes)",
400 decision.destination
401 ),
402 Action::Created | Action::Replaced => {
403 format!("{} {}", decision.action.as_str(), decision.destination)
404 }
405 }
406}
407
408fn refuse_a_recorded_target(held: &apply::Held) -> Result<(), RkError> {
411 if landing::manifest::load(held.base())?.is_none() {
412 return Ok(());
413 }
414 let target = held.display();
415 Err(RkError::refusal(
416 Diagnostic::new(
417 Reason::StateDrift,
418 format!(
419 "{target} already carries {}, and nothing was written",
420 manifest::MANIFEST_PATH
421 ),
422 )
423 .expected("a target without a landing receipt")
424 .action(format!(
425 "rk upgrade --target {target} takes it to this binary's projection"
426 ))
427 .target_state("unchanged"),
428 ))
429}
430
431fn collect_sentinels(
434 target: &Utf8Path,
435 destination: &str,
436 bytes: &[u8],
437 found: &mut Vec<SentinelEntry>,
438) {
439 let text = String::from_utf8_lossy(bytes);
440 for (idx, line) in text.lines().enumerate() {
441 if line.contains(embedded::SENTINEL) {
442 found.push(SentinelEntry {
443 path: target.join(destination).to_string(),
444 line: idx + 1,
445 text: line.trim().to_owned(),
446 });
447 }
448 }
449}
450
451#[cfg(test)]
452mod tests {
453 use super::{FileEntry, Report, SentinelEntry};
454
455 #[test]
459 fn the_init_report_schema_snapshot_holds() {
460 let apply = Report {
461 schema: "rk.init/7",
462 config: crate::config::Plan {
463 action: "added",
464 changes: vec![],
465 content: "schema_version = 1\n".into(),
466 },
467 mode: "apply",
468 tech: "rust".into(),
469 forge: "github".into(),
470 target: "/tmp/t".into(),
471 repo: Some("acme/widget".into()),
472 workflow: "worktree",
473 style: "trunk",
474 nix: true,
475 withheld: Some(vec![crate::landing::Withheld {
476 path: "flake.nix".into(),
477 reason: "the target already carries flake.nix".into(),
478 }]),
479 collisions: None,
480 files: vec![FileEntry {
481 path: "release-plz.toml".into(),
482 kind: "seeded",
483 action: "created",
484 }],
485 sentinels: Some(vec![SentinelEntry {
486 path: "/tmp/t/release-plz.toml".into(),
487 line: 3,
488 text: "# TODO(release-kit): keep false for a binary-only crate".into(),
489 }]),
490 next: vec!["commit the landed files, the receipt included".into()],
491 };
492 assert_eq!(
493 serde_json::to_string(&apply).expect("a report serializes"),
494 r##"{"schema":"rk.init/7","mode":"apply","tech":"rust","forge":"github","target":"/tmp/t","repo":"acme/widget","workflow":"worktree","style":"trunk","nix":true,"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"]}"##
495 );
496 let preview = Report {
497 sentinels: None,
498 repo: None,
499 mode: "preview",
500 nix: false,
501 withheld: None,
502 collisions: Some(vec![super::Collision {
503 path: "SECURITY.md".into(),
504 reason: "exists, and no receipt attributes it to release-kit".into(),
505 }]),
506 ..apply
507 };
508 assert_eq!(
509 serde_json::to_string(&preview).expect("a report serializes"),
510 r#"{"schema":"rk.init/7","mode":"preview","tech":"rust","forge":"github","target":"/tmp/t","workflow":"worktree","style":"trunk","nix":false,"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"]}"#,
511 "a preview omits the sentinels, the unresolved repo, and an empty withheld list rather than serializing null"
512 );
513 }
514}