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