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(clippy::too_many_arguments)]
171fn preview(
172 out: Output,
173 args: &InitArgs,
174 forge: &str,
175 repo: Option<String>,
176 workflow: Workflow,
177 style: Style,
178 entries: &[Entry],
179 withheld: Vec<landing::Withheld>,
180 config: crate::config::Plan,
181) -> Result<(), RkError> {
182 let repo_argument = repo.as_deref().unwrap_or("<owner/name>");
183 let nix_flag = if args.nix { " --nix" } else { "" };
184 let next = vec![format!(
185 "rk init --tech {} --forge {forge} --repo {repo_argument} --workflow {} --style {}{nix_flag} --target {} --apply",
186 args.tech.as_deref().unwrap_or_default(),
187 workflow.as_str(),
188 style.as_str(),
189 args.target
190 )];
191 out.result_line(format!(
192 "DRY RUN: rk init writes these files into {}; re-run with --apply",
193 args.target
194 ));
195 for entry in entries {
196 out.result_line(&entry.destination);
197 }
198 out.result_line(format!(
199 "{} {}\n{}",
200 config.action,
201 crate::config::CONFIG_PATH,
202 config.content
203 ));
204 for entry in &withheld {
205 out.result_line(format!("withheld {}: {}", entry.path, entry.reason));
206 }
207 out.next(&next);
208 out.emit(&Report {
209 schema: "rk.init/5",
210 config,
211 mode: "preview",
212 tech: args.tech.clone().unwrap_or_default(),
213 forge: forge.to_owned(),
214 target: args.target.to_string(),
215 repo,
216 workflow: workflow.as_str(),
217 style: style.as_str(),
218 nix: args.nix,
219 withheld: (!withheld.is_empty()).then_some(withheld),
220 files: entries
221 .iter()
222 .map(|entry| FileEntry {
223 path: entry.destination.clone(),
224 kind: entry.kind.as_str(),
225 action: "land",
226 })
227 .collect(),
228 sentinels: None,
229 next,
230 })
231}
232
233#[allow(clippy::too_many_arguments, clippy::too_many_lines)]
237fn apply(
238 out: Output,
239 args: &InitArgs,
240 forge: &str,
241 repo: &str,
242 workflow: Workflow,
243 style: Style,
244 entries: &[Entry],
245 withheld: Vec<landing::Withheld>,
246 config: crate::config::Plan,
247 params: &landing::Params,
248) -> Result<(), RkError> {
249 refuse_a_recorded_target(args)?;
250 landing::hooks_splice_refusal(&args.target)?;
251 let planned = plan(&args.target, entries)?;
252 let mut file_entries = Vec::new();
253 let mut records = Vec::new();
254 let mut sentinels = Vec::new();
255 for Planned {
256 entry,
257 action,
258 found,
259 } in planned
260 {
261 if action == "write" {
262 landing::write_destination(&args.target, entry)?;
263 }
264 out.result_line(format!(
265 "{} {}",
266 match action {
267 "write" => "wrote",
268 "kept" => "kept (target-owned)",
269 _ => "unchanged",
270 },
271 entry.destination
272 ));
273 let landed = match (action, found) {
276 ("kept", Some(bytes)) => bytes,
277 _ => entry.rendered.clone(),
278 };
279 collect_sentinels(&args.target, &entry.destination, &landed, &mut sentinels);
280 records.push(FileRecord {
281 destination: entry.destination.clone(),
282 kind: entry.kind,
283 sha256: Digest::of(&landed),
284 baseline_sha256: match entry.kind {
285 Kind::State => None,
286 Kind::Rendered | Kind::Seeded => Some(Digest::of(&entry.baseline)),
287 },
288 });
289 file_entries.push(FileEntry {
290 path: entry.destination.clone(),
291 kind: entry.kind.as_str(),
292 action,
293 });
294 }
295 for entry in &withheld {
296 out.result_line(format!("withheld {}: {}", entry.path, entry.reason));
297 }
298
299 config.apply(args.target.as_std_path())?;
300 out.result_line(format!("{} {}", config.action, crate::config::CONFIG_PATH));
301 for (key, empty_line) in [
302 ("setup.required_check", "required_check = \"\""),
303 ("setup.bot.app_id", "app_id = \"\""),
304 ] {
305 if let Some((index, _)) = config
306 .content
307 .lines()
308 .enumerate()
309 .find(|(_, line)| line.starts_with(empty_line))
310 {
311 sentinels.push(SentinelEntry {
312 path: crate::config::CONFIG_PATH.into(),
313 line: index + 1,
314 text: format!("set {key} before forge setup"),
315 });
316 }
317 }
318 manifest::write(
320 &args.target,
321 &Manifest {
322 schema_version: manifest::SCHEMA_VERSION,
323 rk_version: env!("CARGO_PKG_VERSION").to_owned(),
324 payload_sha256: crate::commands::payload::report().payload_sha256,
325 origin: "init".to_owned(),
326 tech: args.tech.clone().unwrap_or_default(),
327 forge: forge.to_owned(),
328 landed_at: manifest::now(),
329 parameters: Parameters {
330 repo: repo.to_owned(),
331 workflow,
332 style: Some(style),
333 nix: args.nix,
334 trunk: params.trunk().to_owned(),
335 line_prefix: params.line_prefix().to_owned(),
336 },
337 files: records,
338 pins: registry::pins_for(args.tech.as_deref().unwrap_or_default())
339 .into_iter()
340 .map(|pin| (pin.name, pin.version))
341 .collect(),
342 },
343 )?;
344 out.result_line(format!("wrote {}", manifest::MANIFEST_PATH));
345
346 if sentinels.is_empty() {
347 out.result_line("no sentinels to fill");
348 } else {
349 out.result_line("fill these sentinels before the workflow runs:");
350 for sentinel in &sentinels {
351 out.result_line(format!(
352 "{}:{}: {}",
353 sentinel.path, sentinel.line, sentinel.text
354 ));
355 }
356 }
357 let next = vec![
358 if sentinels.is_empty() {
359 "commit the landed files, the record included".to_owned()
360 } else {
361 "fill each sentinel above, then commit the landed files, the record included".to_owned()
362 },
363 format!("rk status --target {} reports this landing", args.target),
364 "rk method setup orders what follows".to_owned(),
365 ];
366 out.next(&next);
367 out.emit(&Report {
368 schema: "rk.init/5",
369 config,
370 mode: "apply",
371 tech: args.tech.clone().unwrap_or_default(),
372 forge: forge.to_owned(),
373 target: args.target.to_string(),
374 repo: Some(repo.to_owned()),
375 workflow: workflow.as_str(),
376 style: style.as_str(),
377 nix: args.nix,
378 withheld: (!withheld.is_empty()).then_some(withheld),
379 files: file_entries,
380 sentinels: Some(sentinels),
381 next,
382 })
383}
384
385fn refuse_a_recorded_target(args: &InitArgs) -> Result<(), RkError> {
388 if landing::manifest::load(&args.target)?.is_none() {
389 return Ok(());
390 }
391 Err(RkError::refusal(
392 Diagnostic::new(
393 Reason::StateDrift,
394 format!(
395 "{} already carries {}, and nothing was written",
396 args.target,
397 manifest::MANIFEST_PATH
398 ),
399 )
400 .expected("a target without a landing record")
401 .action(format!(
402 "rk upgrade --target {} takes it to this binary's payload",
403 args.target
404 ))
405 .target_state("unchanged"),
406 ))
407}
408
409struct Planned<'a> {
412 entry: &'a Entry,
414 action: &'static str,
416 found: Option<Vec<u8>>,
418}
419
420fn plan<'a>(target: &Utf8Path, entries: &'a [Entry]) -> Result<Vec<Planned<'a>>, RkError> {
426 let mut conflicts: Vec<&str> = Vec::new();
427 let mut planned = Vec::new();
428 for entry in entries {
429 let found = landing::read_destination(target, entry)?;
430 let action = match (&found, entry.kind) {
431 (None, _) => "write",
432 (Some(bytes), _) if *bytes == entry.rendered => "unchanged",
433 (Some(_), Kind::Rendered) => {
434 conflicts.push(entry.destination.as_str());
435 "conflict"
436 }
437 (Some(_), Kind::Seeded | Kind::State) => "kept",
438 };
439 planned.push(Planned {
440 entry,
441 action,
442 found,
443 });
444 }
445 if conflicts.is_empty() {
446 return Ok(planned);
447 }
448 Err(RkError::refusal(
449 Diagnostic::new(
450 Reason::StateDrift,
451 format!(
452 "these files exist with different content, and nothing was written: {}",
453 conflicts.join(", ")
454 ),
455 )
456 .expected("every rendered destination absent, or holding this landing's bytes")
457 .target_state("unchanged"),
458 ))
459}
460
461fn collect_sentinels(
464 target: &Utf8Path,
465 destination: &str,
466 bytes: &[u8],
467 found: &mut Vec<SentinelEntry>,
468) {
469 let text = String::from_utf8_lossy(bytes);
470 for (idx, line) in text.lines().enumerate() {
471 if line.contains(embedded::SENTINEL) {
472 found.push(SentinelEntry {
473 path: target.join(destination).to_string(),
474 line: idx + 1,
475 text: line.trim().to_owned(),
476 });
477 }
478 }
479}
480
481#[cfg(test)]
482mod tests {
483 #![allow(clippy::expect_used)]
484
485 use super::{FileEntry, Report, SentinelEntry};
486
487 #[test]
491 fn the_init_report_schema_snapshot_holds() {
492 let apply = Report {
493 schema: "rk.init/5",
494 config: crate::config::Plan {
495 action: "added",
496 changes: vec![],
497 content: "schema_version = 1\n".into(),
498 },
499 mode: "apply",
500 tech: "rust".into(),
501 forge: "github".into(),
502 target: "/tmp/t".into(),
503 repo: Some("acme/widget".into()),
504 workflow: "worktree",
505 style: "trunk",
506 nix: true,
507 withheld: Some(vec![crate::landing::Withheld {
508 path: "flake.nix".into(),
509 reason: "the target already carries flake.nix".into(),
510 }]),
511 files: vec![FileEntry {
512 path: "release-plz.toml".into(),
513 kind: "seeded",
514 action: "write",
515 }],
516 sentinels: Some(vec![SentinelEntry {
517 path: "/tmp/t/release-plz.toml".into(),
518 line: 3,
519 text: "# TODO(release-kit): keep false for a binary-only crate".into(),
520 }]),
521 next: vec!["commit the landed files, the record included".into()],
522 };
523 assert_eq!(
524 serde_json::to_string(&apply).expect("a report serializes"),
525 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"]}"##
526 );
527 let preview = Report {
528 sentinels: None,
529 repo: None,
530 mode: "preview",
531 nix: false,
532 withheld: None,
533 ..apply
534 };
535 assert_eq!(
536 serde_json::to_string(&preview).expect("a report serializes"),
537 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"]}"#,
538 "a preview omits the sentinels, the unresolved repo, and an empty withheld list rather than serializing null"
539 );
540 }
541}