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 .canonicalized()?;
162 let planned = reconcile::compute(&request, &manifest::now())?;
163 let config_plan = planned
164 .config
165 .clone()
166 .ok_or_else(|| RkError::Usage("landing parameters are unresolved".into()))?;
167 if args.apply {
168 apply(
169 out,
170 &effective,
171 params.forge(),
172 params.repo(),
173 params.workflow(),
174 style,
175 &planned,
176 &request,
177 config_plan,
178 )
179 } else {
180 let repo = (params.repo() != "OWNER").then(|| params.repo().to_owned());
181 if repo.is_none() {
182 out.frame(
183 "note: no repository detected; an apply derives the owner from --repo <path>",
184 );
185 }
186 preview(
187 out,
188 &effective,
189 params.forge(),
190 repo,
191 params.workflow(),
192 style,
193 &planned,
194 config_plan,
195 )
196 }
197}
198
199#[allow(
201 clippy::too_many_arguments,
202 reason = "the landing parameters are one flat set the caller resolves once, and a struct around them would add a type nothing else reads"
203)]
204fn preview(
205 out: Output,
206 args: &InitArgs,
207 forge: &str,
208 repo: Option<String>,
209 workflow: Workflow,
210 style: Style,
211 planned: &Planned,
212 config: crate::config::Plan,
213) -> Result<(), RkError> {
214 let repo_argument = repo.as_deref().unwrap_or("<owner/name>");
215 let nix_flag = if args.nix { " --nix" } else { "" };
216 let next = vec![format!(
217 "rk init --tech {} --forge {forge} --repo {repo_argument} --workflow {} --style {}{nix_flag} --target {} --apply",
218 args.tech.as_deref().unwrap_or_default(),
219 workflow.as_str(),
220 style.as_str(),
221 args.target
222 )];
223 out.result_line(format!(
224 "DRY RUN: rk init writes these files into {}; re-run with --apply",
225 args.target
226 ));
227 for outcome in &planned.outcomes {
228 out.result_line(&outcome.path);
229 }
230 out.result_line(format!(
231 "{} {}\n{}",
232 config.action,
233 crate::config::CONFIG_PATH,
234 config.content
235 ));
236 for entry in &planned.withheld {
237 out.result_line(format!("withheld {}: {}", entry.path, entry.reason));
238 }
239 out.next(&next);
240 out.emit(&Report {
241 schema: "rk.init/6",
242 config,
243 mode: "preview",
244 tech: args.tech.clone().unwrap_or_default(),
245 forge: forge.to_owned(),
246 target: args.target.to_string(),
247 repo,
248 workflow: workflow.as_str(),
249 style: style.as_str(),
250 nix: args.nix,
251 withheld: (!planned.withheld.is_empty()).then(|| planned.withheld.clone()),
252 files: planned
253 .outcomes
254 .iter()
255 .map(|outcome| FileEntry {
256 path: outcome.path.clone(),
257 kind: outcome.kind.as_str(),
258 action: "land",
259 })
260 .collect(),
261 sentinels: None,
262 plan: None,
263 next,
264 })
265}
266
267#[allow(
271 clippy::too_many_arguments,
272 clippy::too_many_lines,
273 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"
274)]
275fn apply(
276 out: Output,
277 args: &InitArgs,
278 forge: &str,
279 repo: &str,
280 workflow: Workflow,
281 style: Style,
282 planned: &Planned,
283 request: &PlanRequest,
284 config: crate::config::Plan,
285) -> Result<(), RkError> {
286 refuse_a_recorded_target(args)?;
287 landing::hooks_splice_refusal(&EmbeddedReleaseSource, &args.target)?;
288 refuse_conflicts(planned)?;
289 let applied = reconcile::apply_in_process(planned, request, "init")?;
290 let mut file_entries = Vec::new();
291 let mut sentinels = Vec::new();
292 for outcome in &planned.outcomes {
293 let action = match outcome.disposition {
294 Disposition::Write => "write",
295 Disposition::Kept | Disposition::Drift | Disposition::State => "kept",
296 Disposition::Unchanged | Disposition::Conflict | Disposition::Missing => "unchanged",
297 };
298 out.result_line(format!(
299 "{} {}",
300 match action {
301 "write" => "wrote",
302 "kept" => "kept (target-owned)",
303 _ => "unchanged",
304 },
305 outcome.path
306 ));
307 let landed: Vec<u8> = match FrontApplied::written(planned, &outcome.path) {
310 Some(bytes) => bytes.to_vec(),
311 None => landing::read_recorded(&args.target, &outcome.path)?.unwrap_or_default(),
312 };
313 collect_sentinels(&args.target, &outcome.path, &landed, &mut sentinels);
314 file_entries.push(FileEntry {
315 path: outcome.path.clone(),
316 kind: outcome.kind.as_str(),
317 action,
318 });
319 }
320 for entry in &planned.withheld {
321 out.result_line(format!("withheld {}: {}", entry.path, entry.reason));
322 }
323
324 out.result_line(format!("{} {}", config.action, crate::config::CONFIG_PATH));
325 for (key, empty_line) in [
326 ("setup.required_check", "required_check = \"\""),
327 ("setup.bot.app_id", "app_id = \"\""),
328 ] {
329 if let Some((index, _)) = config
330 .content
331 .lines()
332 .enumerate()
333 .find(|(_, line)| line.starts_with(empty_line))
334 {
335 sentinels.push(SentinelEntry {
336 path: crate::config::CONFIG_PATH.into(),
337 line: index + 1,
338 text: format!("set {key} before forge setup"),
339 });
340 }
341 }
342 out.result_line(format!("wrote {}", manifest::MANIFEST_PATH));
343 out.result_line(applied.line());
344
345 if sentinels.is_empty() {
346 out.result_line("no sentinels to fill");
347 } else {
348 out.result_line("fill these sentinels before the workflow runs:");
349 for sentinel in &sentinels {
350 out.result_line(format!(
351 "{}:{}: {}",
352 sentinel.path, sentinel.line, sentinel.text
353 ));
354 }
355 }
356 let next = vec![
357 if sentinels.is_empty() {
358 "commit the landed files, the record included".to_owned()
359 } else {
360 "fill each sentinel above, then commit the landed files, the record included".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/6",
368 config,
369 mode: "apply",
370 tech: args.tech.clone().unwrap_or_default(),
371 forge: forge.to_owned(),
372 target: args.target.to_string(),
373 repo: Some(repo.to_owned()),
374 workflow: workflow.as_str(),
375 style: style.as_str(),
376 nix: args.nix,
377 withheld: (!planned.withheld.is_empty()).then(|| planned.withheld.clone()),
378 files: file_entries,
379 sentinels: Some(sentinels),
380 plan: Some(applied.trace()),
381 next,
382 })?;
383 applied.applied.failure().map_or(Ok(()), Err)
384}
385
386fn refuse_a_recorded_target(args: &InitArgs) -> Result<(), RkError> {
389 if landing::manifest::load(&args.target)?.is_none() {
390 return Ok(());
391 }
392 Err(RkError::refusal(
393 Diagnostic::new(
394 Reason::StateDrift,
395 format!(
396 "{} already carries {}, and nothing was written",
397 args.target,
398 manifest::MANIFEST_PATH
399 ),
400 )
401 .expected("a target without a landing record")
402 .action(format!(
403 "rk upgrade --target {} takes it to this binary's payload",
404 args.target
405 ))
406 .target_state("unchanged"),
407 ))
408}
409
410fn refuse_conflicts(planned: &Planned) -> Result<(), RkError> {
413 let conflicts: Vec<&str> = planned
414 .outcomes
415 .iter()
416 .filter(|outcome| {
417 outcome.kind == Kind::Rendered
418 && matches!(
419 outcome.disposition,
420 Disposition::Conflict | Disposition::Missing
421 )
422 })
423 .map(|outcome| outcome.path.as_str())
424 .collect();
425 if conflicts.is_empty() {
426 return Ok(());
427 }
428 Err(RkError::refusal(
429 Diagnostic::new(
430 Reason::StateDrift,
431 format!(
432 "these files exist with different content, and nothing was written: {}",
433 conflicts.join(", ")
434 ),
435 )
436 .expected("every rendered destination absent, or holding this landing's bytes")
437 .target_state("unchanged"),
438 ))
439}
440
441fn collect_sentinels(
444 target: &Utf8Path,
445 destination: &str,
446 bytes: &[u8],
447 found: &mut Vec<SentinelEntry>,
448) {
449 let text = String::from_utf8_lossy(bytes);
450 for (idx, line) in text.lines().enumerate() {
451 if line.contains(embedded::SENTINEL) {
452 found.push(SentinelEntry {
453 path: target.join(destination).to_string(),
454 line: idx + 1,
455 text: line.trim().to_owned(),
456 });
457 }
458 }
459}
460
461#[cfg(test)]
462mod tests {
463 use super::{FileEntry, Report, SentinelEntry};
464 use crate::digest::Digest;
465
466 #[test]
470 fn the_init_report_schema_snapshot_holds() {
471 let apply = Report {
472 schema: "rk.init/6",
473 config: crate::config::Plan {
474 action: "added",
475 changes: vec![],
476 content: "schema_version = 1\n".into(),
477 },
478 mode: "apply",
479 tech: "rust".into(),
480 forge: "github".into(),
481 target: "/tmp/t".into(),
482 repo: Some("acme/widget".into()),
483 workflow: "worktree",
484 style: "trunk",
485 nix: true,
486 withheld: Some(vec![crate::landing::Withheld {
487 path: "flake.nix".into(),
488 reason: "the target already carries flake.nix".into(),
489 }]),
490 files: vec![FileEntry {
491 path: "release-plz.toml".into(),
492 kind: "seeded",
493 action: "write",
494 }],
495 sentinels: Some(vec![SentinelEntry {
496 path: "/tmp/t/release-plz.toml".into(),
497 line: 3,
498 text: "# TODO(release-kit): keep false for a binary-only crate".into(),
499 }]),
500 plan: Some(crate::commands::reconcile::Trace {
501 plan_id: "0123456789abcdef".into(),
502 input_fingerprint: Digest::of(b"a"),
503 stored: true,
504 run_id: Some("run".into()),
505 }),
506 next: vec!["commit the landed files, the record included".into()],
507 };
508 assert_eq!(
509 serde_json::to_string(&apply).expect("a report serializes"),
510 format!(
511 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"]}}"##,
512 Digest::of(b"a")
513 )
514 );
515 let preview = Report {
516 sentinels: None,
517 repo: None,
518 mode: "preview",
519 nix: false,
520 withheld: None,
521 plan: None,
522 ..apply
523 };
524 assert_eq!(
525 serde_json::to_string(&preview).expect("a report serializes"),
526 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"]}"#,
527 "a preview omits the sentinels, the unresolved repo, the plan, and an empty withheld list rather than serializing null"
528 );
529 }
530}