1use camino::Utf8Path;
15use serde::Serialize;
16
17use crate::cli::init::InitArgs;
18use crate::diagnostic::{Diagnostic, Reason};
19use crate::error::RkError;
20use crate::landing::manifest::{self, FileRecord, Manifest, Parameters, Style, Workflow};
21use crate::landing::{self, Entry, Kind};
22use crate::output::Output;
23use crate::{digest::Digest, embedded, registry};
24
25#[derive(Debug, Serialize)]
27struct FileEntry {
28 path: String,
30 kind: &'static str,
32 action: &'static str,
34}
35
36#[derive(Debug, Serialize)]
38struct SentinelEntry {
39 path: String,
41 line: usize,
43 text: String,
45}
46
47#[derive(Debug, Serialize)]
49struct Report {
50 schema: &'static str,
52 mode: &'static str,
54 tech: String,
56 forge: String,
58 target: String,
60 #[serde(skip_serializing_if = "Option::is_none")]
62 repo: Option<String>,
63 workflow: &'static str,
65 style: &'static str,
66 nix: bool,
68 #[serde(skip_serializing_if = "Option::is_none")]
71 withheld: Option<Vec<landing::Withheld>>,
72 config: crate::config::Plan,
73 files: Vec<FileEntry>,
75 #[serde(skip_serializing_if = "Option::is_none")]
77 sentinels: Option<Vec<SentinelEntry>>,
78 next: Vec<String>,
80}
81
82pub fn run(args: &InitArgs) -> Result<(), RkError> {
92 let out = Output::new(args.json);
93 if !args.target.is_dir() {
94 return Err(RkError::refusal(
95 Diagnostic::new(
96 Reason::TargetNotFound,
97 format!(
98 "target {} is not a directory; nothing was written",
99 args.target
100 ),
101 )
102 .expected("an existing directory to land into")
103 .target_state("unchanged"),
104 ));
105 }
106 let config = crate::config::load(args.target.as_std_path())?;
107 let params = landing::Params::resolve(
108 &args.target,
109 &landing::Inputs {
110 tech: args.tech.as_deref(),
111 forge: args.forge.as_deref(),
112 repo: args.repo.as_deref(),
113 workflow: args.workflow.as_deref().map(Workflow::parse).transpose()?,
114 style: args.style.as_deref().map(Style::parse).transpose()?,
115 nix: args.nix.then_some(true),
116 },
117 config.as_ref(),
118 None,
119 if args.apply {
120 landing::Purpose::Init
121 } else {
122 landing::Purpose::Preview
123 },
124 )?;
125 let config_plan =
126 crate::config::Plan::new(args.target.as_std_path(), ¶ms, config.as_ref(), None)?;
127 let mut effective = args.clone();
128 effective.tech = Some(params.tech().into());
129 effective.nix = params.nix();
130 let mut entries = landing::projection(¶ms)?;
131 let withheld = landing::withhold_nix(&args.target, params.nix(), None, &mut entries)?;
132 let style = params
133 .style()
134 .ok_or_else(|| RkError::Usage("landing style is unresolved".into()))?;
135 if args.apply {
136 apply(
137 out,
138 &effective,
139 params.forge(),
140 params.repo(),
141 params.workflow(),
142 style,
143 &entries,
144 withheld,
145 config_plan,
146 ¶ms,
147 )
148 } else {
149 let repo = (params.repo() != "OWNER").then(|| params.repo().to_owned());
150 if repo.is_none() {
151 out.frame(
152 "note: no repository detected; an apply derives the owner from --repo <path>",
153 );
154 }
155 preview(
156 out,
157 &effective,
158 params.forge(),
159 repo,
160 params.workflow(),
161 style,
162 &entries,
163 withheld,
164 config_plan,
165 )
166 }
167}
168
169#[allow(
171 clippy::too_many_arguments,
172 reason = "the landing parameters are one flat set the caller resolves once, and a struct around them would add a type nothing else reads"
173)]
174fn preview(
175 out: Output,
176 args: &InitArgs,
177 forge: &str,
178 repo: Option<String>,
179 workflow: Workflow,
180 style: Style,
181 entries: &[Entry],
182 withheld: Vec<landing::Withheld>,
183 config: crate::config::Plan,
184) -> Result<(), RkError> {
185 let repo_argument = repo.as_deref().unwrap_or("<owner/name>");
186 let nix_flag = if args.nix { " --nix" } else { "" };
187 let next = vec![format!(
188 "rk init --tech {} --forge {forge} --repo {repo_argument} --workflow {} --style {}{nix_flag} --target {} --apply",
189 args.tech.as_deref().unwrap_or_default(),
190 workflow.as_str(),
191 style.as_str(),
192 args.target
193 )];
194 out.result_line(format!(
195 "DRY RUN: rk init writes these files into {}; re-run with --apply",
196 args.target
197 ));
198 for entry in entries {
199 out.result_line(&entry.destination);
200 }
201 out.result_line(format!(
202 "{} {}\n{}",
203 config.action,
204 crate::config::CONFIG_PATH,
205 config.content
206 ));
207 for entry in &withheld {
208 out.result_line(format!("withheld {}: {}", entry.path, entry.reason));
209 }
210 out.next(&next);
211 out.emit(&Report {
212 schema: "rk.init/5",
213 config,
214 mode: "preview",
215 tech: args.tech.clone().unwrap_or_default(),
216 forge: forge.to_owned(),
217 target: args.target.to_string(),
218 repo,
219 workflow: workflow.as_str(),
220 style: style.as_str(),
221 nix: args.nix,
222 withheld: (!withheld.is_empty()).then_some(withheld),
223 files: entries
224 .iter()
225 .map(|entry| FileEntry {
226 path: entry.destination.clone(),
227 kind: entry.kind.as_str(),
228 action: "land",
229 })
230 .collect(),
231 sentinels: None,
232 next,
233 })
234}
235
236#[allow(
240 clippy::too_many_arguments,
241 clippy::too_many_lines,
242 reason = "the landing parameters are one flat set the caller resolves once, and the landing is all-or-nothing, so its ordered steps stay in one place"
243)]
244fn apply(
245 out: Output,
246 args: &InitArgs,
247 forge: &str,
248 repo: &str,
249 workflow: Workflow,
250 style: Style,
251 entries: &[Entry],
252 withheld: Vec<landing::Withheld>,
253 config: crate::config::Plan,
254 params: &landing::Params,
255) -> Result<(), RkError> {
256 refuse_a_recorded_target(args)?;
257 landing::hooks_splice_refusal(&args.target)?;
258 let planned = plan(&args.target, entries)?;
259 let mut file_entries = Vec::new();
260 let mut records = Vec::new();
261 let mut sentinels = Vec::new();
262 for Planned {
263 entry,
264 action,
265 found,
266 } in planned
267 {
268 if action == "write" {
269 landing::write_destination(&args.target, entry)?;
270 }
271 out.result_line(format!(
272 "{} {}",
273 match action {
274 "write" => "wrote",
275 "kept" => "kept (target-owned)",
276 _ => "unchanged",
277 },
278 entry.destination
279 ));
280 let landed = match (action, found) {
283 ("kept", Some(bytes)) => bytes,
284 _ => entry.rendered.clone(),
285 };
286 collect_sentinels(&args.target, &entry.destination, &landed, &mut sentinels);
287 records.push(FileRecord {
288 destination: entry.destination.clone(),
289 kind: entry.kind,
290 sha256: Digest::of(&landed),
291 baseline_sha256: match entry.kind {
292 Kind::State => None,
293 Kind::Rendered | Kind::Seeded => Some(Digest::of(&entry.baseline)),
294 },
295 });
296 file_entries.push(FileEntry {
297 path: entry.destination.clone(),
298 kind: entry.kind.as_str(),
299 action,
300 });
301 }
302 for entry in &withheld {
303 out.result_line(format!("withheld {}: {}", entry.path, entry.reason));
304 }
305
306 config.apply(args.target.as_std_path())?;
307 out.result_line(format!("{} {}", config.action, crate::config::CONFIG_PATH));
308 for (key, empty_line) in [
309 ("setup.required_check", "required_check = \"\""),
310 ("setup.bot.app_id", "app_id = \"\""),
311 ] {
312 if let Some((index, _)) = config
313 .content
314 .lines()
315 .enumerate()
316 .find(|(_, line)| line.starts_with(empty_line))
317 {
318 sentinels.push(SentinelEntry {
319 path: crate::config::CONFIG_PATH.into(),
320 line: index + 1,
321 text: format!("set {key} before forge setup"),
322 });
323 }
324 }
325 manifest::write(
327 &args.target,
328 &Manifest {
329 schema_version: manifest::SCHEMA_VERSION,
330 rk_version: env!("CARGO_PKG_VERSION").to_owned(),
331 payload_sha256: crate::commands::payload::report().payload_sha256,
332 origin: "init".to_owned(),
333 tech: args.tech.clone().unwrap_or_default(),
334 forge: forge.to_owned(),
335 landed_at: manifest::now(),
336 parameters: Parameters {
337 repo: repo.to_owned(),
338 workflow,
339 style: Some(style),
340 nix: args.nix,
341 trunk: params.trunk().to_owned(),
342 line_prefix: params.line_prefix().to_owned(),
343 },
344 files: records,
345 pins: registry::pins_for(args.tech.as_deref().unwrap_or_default())
346 .into_iter()
347 .map(|pin| (pin.name, pin.version))
348 .collect(),
349 },
350 )?;
351 out.result_line(format!("wrote {}", manifest::MANIFEST_PATH));
352
353 if sentinels.is_empty() {
354 out.result_line("no sentinels to fill");
355 } else {
356 out.result_line("fill these sentinels before the workflow runs:");
357 for sentinel in &sentinels {
358 out.result_line(format!(
359 "{}:{}: {}",
360 sentinel.path, sentinel.line, sentinel.text
361 ));
362 }
363 }
364 let next = vec![
365 if sentinels.is_empty() {
366 "commit the landed files, the record included".to_owned()
367 } else {
368 "fill each sentinel above, then commit the landed files, the record included".to_owned()
369 },
370 format!("rk status --target {} reports this landing", args.target),
371 "rk method setup orders what follows".to_owned(),
372 ];
373 out.next(&next);
374 out.emit(&Report {
375 schema: "rk.init/5",
376 config,
377 mode: "apply",
378 tech: args.tech.clone().unwrap_or_default(),
379 forge: forge.to_owned(),
380 target: args.target.to_string(),
381 repo: Some(repo.to_owned()),
382 workflow: workflow.as_str(),
383 style: style.as_str(),
384 nix: args.nix,
385 withheld: (!withheld.is_empty()).then_some(withheld),
386 files: file_entries,
387 sentinels: Some(sentinels),
388 next,
389 })
390}
391
392fn refuse_a_recorded_target(args: &InitArgs) -> Result<(), RkError> {
395 if landing::manifest::load(&args.target)?.is_none() {
396 return Ok(());
397 }
398 Err(RkError::refusal(
399 Diagnostic::new(
400 Reason::StateDrift,
401 format!(
402 "{} already carries {}, and nothing was written",
403 args.target,
404 manifest::MANIFEST_PATH
405 ),
406 )
407 .expected("a target without a landing record")
408 .action(format!(
409 "rk upgrade --target {} takes it to this binary's payload",
410 args.target
411 ))
412 .target_state("unchanged"),
413 ))
414}
415
416struct Planned<'a> {
419 entry: &'a Entry,
421 action: &'static str,
423 found: Option<Vec<u8>>,
425}
426
427fn plan<'a>(target: &Utf8Path, entries: &'a [Entry]) -> Result<Vec<Planned<'a>>, RkError> {
433 let mut conflicts: Vec<&str> = Vec::new();
434 let mut planned = Vec::new();
435 for entry in entries {
436 let found = landing::read_destination(target, entry)?;
437 let action = match (&found, entry.kind) {
438 (None, _) => "write",
439 (Some(bytes), _) if *bytes == entry.rendered => "unchanged",
440 (Some(_), Kind::Rendered) => {
441 conflicts.push(entry.destination.as_str());
442 "conflict"
443 }
444 (Some(_), Kind::Seeded | Kind::State) => "kept",
445 };
446 planned.push(Planned {
447 entry,
448 action,
449 found,
450 });
451 }
452 if conflicts.is_empty() {
453 return Ok(planned);
454 }
455 Err(RkError::refusal(
456 Diagnostic::new(
457 Reason::StateDrift,
458 format!(
459 "these files exist with different content, and nothing was written: {}",
460 conflicts.join(", ")
461 ),
462 )
463 .expected("every rendered destination absent, or holding this landing's bytes")
464 .target_state("unchanged"),
465 ))
466}
467
468fn collect_sentinels(
471 target: &Utf8Path,
472 destination: &str,
473 bytes: &[u8],
474 found: &mut Vec<SentinelEntry>,
475) {
476 let text = String::from_utf8_lossy(bytes);
477 for (idx, line) in text.lines().enumerate() {
478 if line.contains(embedded::SENTINEL) {
479 found.push(SentinelEntry {
480 path: target.join(destination).to_string(),
481 line: idx + 1,
482 text: line.trim().to_owned(),
483 });
484 }
485 }
486}
487
488#[cfg(test)]
489mod tests {
490 use super::{FileEntry, Report, SentinelEntry};
491
492 #[test]
496 fn the_init_report_schema_snapshot_holds() {
497 let apply = Report {
498 schema: "rk.init/5",
499 config: crate::config::Plan {
500 action: "added",
501 changes: vec![],
502 content: "schema_version = 1\n".into(),
503 },
504 mode: "apply",
505 tech: "rust".into(),
506 forge: "github".into(),
507 target: "/tmp/t".into(),
508 repo: Some("acme/widget".into()),
509 workflow: "worktree",
510 style: "trunk",
511 nix: true,
512 withheld: Some(vec![crate::landing::Withheld {
513 path: "flake.nix".into(),
514 reason: "the target already carries flake.nix".into(),
515 }]),
516 files: vec![FileEntry {
517 path: "release-plz.toml".into(),
518 kind: "seeded",
519 action: "write",
520 }],
521 sentinels: Some(vec![SentinelEntry {
522 path: "/tmp/t/release-plz.toml".into(),
523 line: 3,
524 text: "# TODO(release-kit): keep false for a binary-only crate".into(),
525 }]),
526 next: vec!["commit the landed files, the record included".into()],
527 };
528 assert_eq!(
529 serde_json::to_string(&apply).expect("a report serializes"),
530 r##"{"schema":"rk.init/5","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":"write"}],"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 record included"]}"##
531 );
532 let preview = Report {
533 sentinels: None,
534 repo: None,
535 mode: "preview",
536 nix: false,
537 withheld: None,
538 ..apply
539 };
540 assert_eq!(
541 serde_json::to_string(&preview).expect("a report serializes"),
542 r#"{"schema":"rk.init/5","mode":"preview","tech":"rust","forge":"github","target":"/tmp/t","workflow":"worktree","style":"trunk","nix":false,"config":{"action":"added","changes":[],"content":"schema_version = 1\n"},"files":[{"path":"release-plz.toml","kind":"seeded","action":"write"}],"next":["commit the landed files, the record included"]}"#,
543 "a preview omits the sentinels, the unresolved repo, and an empty withheld list rather than serializing null"
544 );
545 }
546}