1use camino::Utf8Path;
14use serde::Serialize;
15
16use crate::cli::init::InitArgs;
17use crate::commands::reconcile::{self, FrontApplied, Trace};
18use crate::diagnostic::{Diagnostic, Reason};
19use crate::embedded;
20use crate::error::RkError;
21use crate::landing::manifest::{self, Style, Workflow};
22use crate::landing::{self, Kind};
23use crate::output::Output;
24use crate::plan::gather::Flags;
25use crate::plan::{Disposition, Intent, PlanRequest, Planned};
26use crate::release::EmbeddedReleaseSource;
27
28#[derive(Debug, Serialize)]
30struct FileEntry {
31 path: String,
33 kind: &'static str,
35 action: &'static str,
37}
38
39#[derive(Debug, Serialize)]
41struct SentinelEntry {
42 path: String,
44 line: usize,
46 text: String,
48}
49
50#[derive(Debug, Serialize)]
52struct Report {
53 schema: &'static str,
55 mode: &'static str,
57 tech: String,
59 forge: String,
61 target: String,
63 #[serde(skip_serializing_if = "Option::is_none")]
65 repo: Option<String>,
66 workflow: &'static str,
68 style: &'static str,
69 nix: bool,
71 #[serde(skip_serializing_if = "Option::is_none")]
74 withheld: Option<Vec<landing::Withheld>>,
75 config: crate::config::Plan,
76 files: Vec<FileEntry>,
78 #[serde(skip_serializing_if = "Option::is_none")]
80 sentinels: Option<Vec<SentinelEntry>>,
81 #[serde(skip_serializing_if = "Option::is_none")]
83 plan: Option<Trace>,
84 next: Vec<String>,
86}
87
88pub fn run(args: &InitArgs) -> Result<(), RkError> {
98 let out = Output::new(args.json);
99 if !args.target.is_dir() {
100 return Err(RkError::refusal(
101 Diagnostic::new(
102 Reason::TargetNotFound,
103 format!(
104 "target {} is not a directory; nothing was written",
105 args.target
106 ),
107 )
108 .expected("an existing directory to land into")
109 .target_state("unchanged"),
110 ));
111 }
112 let config = crate::config::load(args.target.as_std_path())?;
113 let source = EmbeddedReleaseSource;
114 let params = landing::Params::resolve(
115 &source,
116 &args.target,
117 &landing::Inputs {
118 tech: args.tech.as_deref(),
119 forge: args.forge.as_deref(),
120 repo: args.repo.as_deref(),
121 workflow: args.workflow.as_deref().map(Workflow::parse).transpose()?,
122 style: args.style.as_deref().map(Style::parse).transpose()?,
123 nix: args.nix.then_some(true),
124 },
125 config.as_ref(),
126 None,
127 if args.apply {
128 landing::Purpose::Init
129 } else {
130 landing::Purpose::Preview
131 },
132 )?;
133 let mut effective = args.clone();
134 effective.tech = Some(params.tech().into());
135 effective.nix = params.nix();
136 let style = params
137 .style()
138 .ok_or_else(|| RkError::Usage("landing style is unresolved".into()))?;
139 let request = PlanRequest {
143 target: args.target.clone(),
144 intent: Intent::Setup,
145 selector: "embedded".into(),
146 fetch: false,
147 observe_forge: false,
148 flags: Flags {
149 tech: Some(params.tech().to_owned()),
150 forge: Some(params.forge().to_owned()),
151 repo: args
152 .repo
153 .clone()
154 .or_else(|| (params.repo() != "OWNER").then(|| params.repo().to_owned())),
155 workflow: Some(params.workflow().as_str().to_owned()),
156 style: Some(style.as_str().to_owned()),
157 nix: Some(params.nix()),
158 },
159 decisions: std::collections::BTreeMap::new(),
160 };
161 let planned = reconcile::compute(&request, &manifest::now())?;
162 let config_plan = planned
163 .config
164 .clone()
165 .ok_or_else(|| RkError::Usage("landing parameters are unresolved".into()))?;
166 if args.apply {
167 apply(
168 out,
169 &effective,
170 params.forge(),
171 params.repo(),
172 params.workflow(),
173 style,
174 &planned,
175 &request,
176 config_plan,
177 )
178 } else {
179 let repo = (params.repo() != "OWNER").then(|| params.repo().to_owned());
180 if repo.is_none() {
181 out.frame(
182 "note: no repository detected; an apply derives the owner from --repo <path>",
183 );
184 }
185 preview(
186 out,
187 &effective,
188 params.forge(),
189 repo,
190 params.workflow(),
191 style,
192 &planned,
193 config_plan,
194 )
195 }
196}
197
198#[allow(
200 clippy::too_many_arguments,
201 reason = "the landing parameters are one flat set the caller resolves once, and a struct around them would add a type nothing else reads"
202)]
203fn preview(
204 out: Output,
205 args: &InitArgs,
206 forge: &str,
207 repo: Option<String>,
208 workflow: Workflow,
209 style: Style,
210 planned: &Planned,
211 config: crate::config::Plan,
212) -> Result<(), RkError> {
213 let repo_argument = repo.as_deref().unwrap_or("<owner/name>");
214 let nix_flag = if args.nix { " --nix" } else { "" };
215 let next = vec![format!(
216 "rk init --tech {} --forge {forge} --repo {repo_argument} --workflow {} --style {}{nix_flag} --target {} --apply",
217 args.tech.as_deref().unwrap_or_default(),
218 workflow.as_str(),
219 style.as_str(),
220 args.target
221 )];
222 out.result_line(format!(
223 "DRY RUN: rk init writes these files into {}; re-run with --apply",
224 args.target
225 ));
226 for outcome in &planned.outcomes {
227 out.result_line(&outcome.path);
228 }
229 out.result_line(format!(
230 "{} {}\n{}",
231 config.action,
232 crate::config::CONFIG_PATH,
233 config.content
234 ));
235 for entry in &planned.withheld {
236 out.result_line(format!("withheld {}: {}", entry.path, entry.reason));
237 }
238 out.next(&next);
239 out.emit(&Report {
240 schema: "rk.init/6",
241 config,
242 mode: "preview",
243 tech: args.tech.clone().unwrap_or_default(),
244 forge: forge.to_owned(),
245 target: args.target.to_string(),
246 repo,
247 workflow: workflow.as_str(),
248 style: style.as_str(),
249 nix: args.nix,
250 withheld: (!planned.withheld.is_empty()).then(|| planned.withheld.clone()),
251 files: planned
252 .outcomes
253 .iter()
254 .map(|outcome| FileEntry {
255 path: outcome.path.clone(),
256 kind: outcome.kind.as_str(),
257 action: "land",
258 })
259 .collect(),
260 sentinels: None,
261 plan: None,
262 next,
263 })
264}
265
266#[allow(
270 clippy::too_many_arguments,
271 clippy::too_many_lines,
272 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"
273)]
274fn apply(
275 out: Output,
276 args: &InitArgs,
277 forge: &str,
278 repo: &str,
279 workflow: Workflow,
280 style: Style,
281 planned: &Planned,
282 request: &PlanRequest,
283 config: crate::config::Plan,
284) -> Result<(), RkError> {
285 refuse_a_recorded_target(args)?;
286 landing::hooks_splice_refusal(&EmbeddedReleaseSource, &args.target)?;
287 refuse_conflicts(planned)?;
288 let applied = reconcile::apply_in_process(planned, request, "init")?;
289 let mut file_entries = Vec::new();
290 let mut sentinels = Vec::new();
291 for outcome in &planned.outcomes {
292 let action = match outcome.disposition {
293 Disposition::Write => "write",
294 Disposition::Kept | Disposition::Drift | Disposition::State => "kept",
295 Disposition::Unchanged | Disposition::Conflict | Disposition::Missing => "unchanged",
296 };
297 out.result_line(format!(
298 "{} {}",
299 match action {
300 "write" => "wrote",
301 "kept" => "kept (target-owned)",
302 _ => "unchanged",
303 },
304 outcome.path
305 ));
306 let landed: Vec<u8> = match FrontApplied::written(planned, &outcome.path) {
309 Some(bytes) => bytes.to_vec(),
310 None => landing::read_recorded(&args.target, &outcome.path)?.unwrap_or_default(),
311 };
312 collect_sentinels(&args.target, &outcome.path, &landed, &mut sentinels);
313 file_entries.push(FileEntry {
314 path: outcome.path.clone(),
315 kind: outcome.kind.as_str(),
316 action,
317 });
318 }
319 for entry in &planned.withheld {
320 out.result_line(format!("withheld {}: {}", entry.path, entry.reason));
321 }
322
323 out.result_line(format!("{} {}", config.action, crate::config::CONFIG_PATH));
324 for (key, empty_line) in [
325 ("setup.required_check", "required_check = \"\""),
326 ("setup.bot.app_id", "app_id = \"\""),
327 ] {
328 if let Some((index, _)) = config
329 .content
330 .lines()
331 .enumerate()
332 .find(|(_, line)| line.starts_with(empty_line))
333 {
334 sentinels.push(SentinelEntry {
335 path: crate::config::CONFIG_PATH.into(),
336 line: index + 1,
337 text: format!("set {key} before forge setup"),
338 });
339 }
340 }
341 out.result_line(format!("wrote {}", manifest::MANIFEST_PATH));
342 out.result_line(applied.line());
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 record included".to_owned()
358 } else {
359 "fill each sentinel above, then commit the landed files, the record included".to_owned()
360 },
361 format!("rk status --target {} reports this landing", args.target),
362 "rk method setup orders what follows".to_owned(),
363 ];
364 out.next(&next);
365 out.emit(&Report {
366 schema: "rk.init/6",
367 config,
368 mode: "apply",
369 tech: args.tech.clone().unwrap_or_default(),
370 forge: forge.to_owned(),
371 target: args.target.to_string(),
372 repo: Some(repo.to_owned()),
373 workflow: workflow.as_str(),
374 style: style.as_str(),
375 nix: args.nix,
376 withheld: (!planned.withheld.is_empty()).then(|| planned.withheld.clone()),
377 files: file_entries,
378 sentinels: Some(sentinels),
379 plan: Some(applied.trace()),
380 next,
381 })?;
382 applied.applied.failure().map_or(Ok(()), Err)
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
409fn refuse_conflicts(planned: &Planned) -> Result<(), RkError> {
412 let conflicts: Vec<&str> = planned
413 .outcomes
414 .iter()
415 .filter(|outcome| {
416 outcome.kind == Kind::Rendered
417 && matches!(
418 outcome.disposition,
419 Disposition::Conflict | Disposition::Missing
420 )
421 })
422 .map(|outcome| outcome.path.as_str())
423 .collect();
424 if conflicts.is_empty() {
425 return Ok(());
426 }
427 Err(RkError::refusal(
428 Diagnostic::new(
429 Reason::StateDrift,
430 format!(
431 "these files exist with different content, and nothing was written: {}",
432 conflicts.join(", ")
433 ),
434 )
435 .expected("every rendered destination absent, or holding this landing's bytes")
436 .target_state("unchanged"),
437 ))
438}
439
440fn collect_sentinels(
443 target: &Utf8Path,
444 destination: &str,
445 bytes: &[u8],
446 found: &mut Vec<SentinelEntry>,
447) {
448 let text = String::from_utf8_lossy(bytes);
449 for (idx, line) in text.lines().enumerate() {
450 if line.contains(embedded::SENTINEL) {
451 found.push(SentinelEntry {
452 path: target.join(destination).to_string(),
453 line: idx + 1,
454 text: line.trim().to_owned(),
455 });
456 }
457 }
458}
459
460#[cfg(test)]
461mod tests {
462 use super::{FileEntry, Report, SentinelEntry};
463 use crate::digest::Digest;
464
465 #[test]
469 fn the_init_report_schema_snapshot_holds() {
470 let apply = Report {
471 schema: "rk.init/6",
472 config: crate::config::Plan {
473 action: "added",
474 changes: vec![],
475 content: "schema_version = 1\n".into(),
476 },
477 mode: "apply",
478 tech: "rust".into(),
479 forge: "github".into(),
480 target: "/tmp/t".into(),
481 repo: Some("acme/widget".into()),
482 workflow: "worktree",
483 style: "trunk",
484 nix: true,
485 withheld: Some(vec![crate::landing::Withheld {
486 path: "flake.nix".into(),
487 reason: "the target already carries flake.nix".into(),
488 }]),
489 files: vec![FileEntry {
490 path: "release-plz.toml".into(),
491 kind: "seeded",
492 action: "write",
493 }],
494 sentinels: Some(vec![SentinelEntry {
495 path: "/tmp/t/release-plz.toml".into(),
496 line: 3,
497 text: "# TODO(release-kit): keep false for a binary-only crate".into(),
498 }]),
499 plan: Some(crate::commands::reconcile::Trace {
500 plan_id: "0123456789abcdef".into(),
501 input_fingerprint: Digest::of(b"a"),
502 stored: true,
503 run_id: Some("run".into()),
504 }),
505 next: vec!["commit the landed files, the record included".into()],
506 };
507 assert_eq!(
508 serde_json::to_string(&apply).expect("a report serializes"),
509 format!(
510 r##"{{"schema":"rk.init/6","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"}}],"plan":{{"plan_id":"0123456789abcdef","input_fingerprint":"{}","stored":true,"run_id":"run"}},"next":["commit the landed files, the record included"]}}"##,
511 Digest::of(b"a")
512 )
513 );
514 let preview = Report {
515 sentinels: None,
516 repo: None,
517 mode: "preview",
518 nix: false,
519 withheld: None,
520 plan: None,
521 ..apply
522 };
523 assert_eq!(
524 serde_json::to_string(&preview).expect("a report serializes"),
525 r#"{"schema":"rk.init/6","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"]}"#,
526 "a preview omits the sentinels, the unresolved repo, the plan, and an empty withheld list rather than serializing null"
527 );
528 }
529}