1use camino::Utf8Path;
18use serde::Serialize;
19
20use crate::cli::init::InitArgs;
21use crate::diagnostic::{Diagnostic, Reason};
22use crate::embedded;
23use crate::error::RkError;
24use crate::held;
25use crate::landing::apply::{self, Action, Collision, Prepared};
26use crate::landing::manifest::{self, Style, Workflow};
27use crate::landing::{self, lock};
28use crate::output::Output;
29use crate::release::EmbeddedReleaseSource;
30
31#[derive(Debug, Serialize)]
33struct FileEntry {
34 path: String,
36 kind: &'static str,
38 action: &'static str,
41}
42
43#[derive(Debug, Serialize)]
45struct SentinelEntry {
46 path: String,
48 line: usize,
50 text: String,
52}
53
54#[derive(Debug, Serialize)]
56struct Report {
57 schema: &'static str,
59 mode: &'static str,
61 tech: String,
63 forge: String,
65 target: String,
67 #[serde(skip_serializing_if = "Option::is_none")]
69 repo: Option<String>,
70 workflow: &'static str,
72 style: &'static str,
73 nix: bool,
75 #[serde(skip_serializing_if = "Option::is_none")]
78 withheld: Option<Vec<landing::Withheld>>,
79 #[serde(skip_serializing_if = "Option::is_none")]
82 collisions: Option<Vec<Collision>>,
83 config: crate::config::Plan,
84 files: Vec<FileEntry>,
86 #[serde(skip_serializing_if = "Option::is_none")]
88 sentinels: Option<Vec<SentinelEntry>>,
89 next: Vec<String>,
91}
92
93fn withheld_of(prepared: &Prepared) -> Option<Vec<landing::Withheld>> {
95 let withheld: Vec<landing::Withheld> = prepared
96 .projection
97 .omissions
98 .iter()
99 .map(|omission| landing::Withheld {
100 path: omission.destination.clone(),
101 reason: omission.reason.clone(),
102 })
103 .collect();
104 (!withheld.is_empty()).then_some(withheld)
105}
106
107pub fn run(args: &InitArgs) -> Result<(), RkError> {
116 let out = Output::new(args.json);
117 if !args.target.is_dir() {
118 return Err(RkError::refusal(
119 Diagnostic::new(
120 Reason::TargetNotFound,
121 format!(
122 "target {} is not a directory; nothing was written",
123 args.target
124 ),
125 )
126 .expected("an existing directory to land into")
127 .target_state("unchanged"),
128 ));
129 }
130 let lock = args
134 .apply
135 .then(|| lock::acquire(&args.target))
136 .transpose()?;
137 held::pause(apply::PAUSE_VAR, "locked", "proceed-locked");
142 let held = lock.as_ref().map_or_else(
143 || apply::Held::open(&args.target),
144 |lock| apply::Held::open_locked(&args.target, lock),
145 )?;
146 held::pause(apply::PAUSE_VAR, "held", "proceed-held");
148 let config = crate::config::load(held.base().as_std_path())?;
149 let params = landing::Params::resolve(
150 &EmbeddedReleaseSource,
151 held.base(),
152 &landing::Inputs {
153 tech: args.tech.as_deref(),
154 forge: args.forge.as_deref(),
155 repo: args.repo.as_deref(),
156 workflow: args.workflow.as_deref().map(Workflow::parse).transpose()?,
157 style: args.style.as_deref().map(Style::parse).transpose()?,
158 nix: args.nix.then_some(true),
159 },
160 config.as_ref(),
161 None,
162 if args.apply {
163 landing::Purpose::Init
164 } else {
165 landing::Purpose::Preview
166 },
167 )?;
168 let style = params
169 .style()
170 .ok_or_else(|| RkError::Usage("landing style is unresolved".into()))?;
171 if let Some(lock) = lock {
172 refuse_a_recorded_target(&held)?;
173 let prepared = apply::prepare(&held, None, ¶ms, config.as_ref())?;
174 let landed = apply::land(&held, None, &prepared, apply::Origin::Init, &lock)?;
175 drop(lock);
176 report_apply(out, args, &held, ¶ms, style, &prepared, &landed)
177 } else {
178 let prepared = apply::prepare(&held, None, ¶ms, config.as_ref())?;
179 let repo = (params.repo() != landing::REPO_PLACEHOLDER).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(out, args, ¶ms, repo, style, &prepared)
186 }
187}
188
189fn preview(
192 out: Output,
193 args: &InitArgs,
194 params: &landing::Params,
195 repo: Option<String>,
196 style: Style,
197 prepared: &Prepared,
198) -> Result<(), RkError> {
199 let repo_argument = repo.as_deref().unwrap_or("<owner/name>");
200 let nix_flag = if params.nix() { " --nix" } else { "" };
201 let mut next = vec![format!(
202 "rk init --tech {} --forge {} --repo {repo_argument} --workflow {} --style {}{nix_flag} --target {} --apply",
203 params.tech(),
204 params.forge(),
205 params.workflow().as_str(),
206 style.as_str(),
207 args.target
208 )];
209 if !prepared.collisions.is_empty() {
210 next.insert(
211 0,
212 "resolve each collision above through the rk-setup skill; the apply refuses until then"
213 .to_owned(),
214 );
215 }
216 next.push(format!(
217 "rk stage --target {} stages the complete candidate for a byte comparison",
218 args.target
219 ));
220 out.result_line(format!(
221 "DRY RUN: rk init writes these files into {}; re-run with --apply",
222 args.target
223 ));
224 for decision in &prepared.decisions {
225 out.result_line(format!(
226 "{} {}",
227 decision.action.as_str(),
228 decision.destination
229 ));
230 }
231 for collision in &prepared.collisions {
232 out.result_line(format!(
233 "collision {}: {}",
234 collision.path, collision.reason
235 ));
236 }
237 out.result_line(format!(
238 "{} {}\n{}",
239 prepared.config.action,
240 crate::config::CONFIG_PATH,
241 prepared.config.content
242 ));
243 for entry in &prepared.projection.omissions {
244 out.result_line(format!("withheld {}: {}", entry.destination, entry.reason));
245 }
246 out.next(&next);
247 out.emit(&Report {
248 schema: "rk.init/7",
249 config: prepared.config.clone(),
250 mode: "preview",
251 tech: params.tech().to_owned(),
252 forge: params.forge().to_owned(),
253 target: args.target.to_string(),
254 repo,
255 workflow: params.workflow().as_str(),
256 style: style.as_str(),
257 nix: params.nix(),
258 withheld: withheld_of(prepared),
259 collisions: (!prepared.collisions.is_empty()).then(|| prepared.collisions.clone()),
260 files: prepared
261 .decisions
262 .iter()
263 .map(|decision| FileEntry {
264 path: decision.destination.clone(),
265 kind: decision.kind.as_str(),
266 action: decision.action.as_str(),
267 })
268 .chain(prepared.collisions.iter().map(|collision| FileEntry {
269 path: collision.path.clone(),
270 kind: landing::kind_of(&collision.path).map_or("unknown", landing::Kind::as_str),
271 action: "collision",
272 }))
273 .collect(),
274 sentinels: None,
275 next,
276 })
277}
278
279#[allow(
282 clippy::too_many_arguments,
283 reason = "the report reads the landed files through the held target and names them by the path the operator gave, which are two arguments for one target"
284)]
285fn report_apply(
286 out: Output,
287 args: &InitArgs,
288 held: &apply::Held,
289 params: &landing::Params,
290 style: Style,
291 prepared: &Prepared,
292 landed: &apply::Landed,
293) -> Result<(), RkError> {
294 let mut file_entries = Vec::new();
295 let mut sentinels = Vec::new();
296 for decision in &prepared.decisions {
297 out.result_line(describe(decision));
298 if decision.action != Action::Released {
299 let bytes =
300 landing::read_recorded(held.base(), &decision.destination)?.unwrap_or_default();
301 collect_sentinels(
302 held.display(),
303 &decision.destination,
304 &bytes,
305 &mut sentinels,
306 );
307 }
308 file_entries.push(FileEntry {
309 path: decision.destination.clone(),
310 kind: decision.kind.as_str(),
311 action: decision.action.as_str(),
312 });
313 }
314 for entry in &prepared.projection.omissions {
315 out.result_line(format!("withheld {}: {}", entry.destination, entry.reason));
316 }
317 out.result_line(format!(
318 "{} {}",
319 prepared.config.action,
320 crate::config::CONFIG_PATH
321 ));
322 for (key, empty_line) in [
323 ("setup.required_check", "required_check = \"\""),
324 ("setup.bot.app_id", "app_id = \"\""),
325 ] {
326 if let Some((index, _)) = prepared
327 .config
328 .content
329 .lines()
330 .enumerate()
331 .find(|(_, line)| line.starts_with(empty_line))
332 {
333 sentinels.push(SentinelEntry {
334 path: crate::config::CONFIG_PATH.into(),
335 line: index + 1,
336 text: format!("set {key} before forge setup"),
337 });
338 }
339 }
340 out.result_line(format!("wrote {}", manifest::MANIFEST_PATH));
341 debug_assert_eq!(
342 landed.completed.last().map(String::as_str),
343 Some(manifest::MANIFEST_PATH)
344 );
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 receipt included".to_owned()
360 } else {
361 "fill each sentinel above, then commit the landed files, the receipt included"
362 .to_owned()
363 },
364 format!("rk status --target {} reports this landing", args.target),
365 "rk method setup orders what follows".to_owned(),
366 ];
367 out.next(&next);
368 out.emit(&Report {
369 schema: "rk.init/7",
370 config: prepared.config.clone(),
371 mode: "apply",
372 tech: params.tech().to_owned(),
373 forge: params.forge().to_owned(),
374 target: args.target.to_string(),
375 repo: Some(params.repo().to_owned()),
376 workflow: params.workflow().as_str(),
377 style: style.as_str(),
378 nix: params.nix(),
379 withheld: withheld_of(prepared),
380 collisions: None,
381 files: file_entries,
382 sentinels: Some(sentinels),
383 next,
384 })
385}
386
387pub(crate) fn describe(decision: &apply::Decision) -> String {
389 match decision.action {
390 Action::Preserved | Action::Drift => format!(
391 "{} {} ({}, target-owned)",
392 decision.action.as_str(),
393 decision.destination,
394 decision.kind.as_str()
395 ),
396 Action::Released => format!(
397 "released {} (no longer produced; target-owned from this landing)",
398 decision.destination
399 ),
400 Action::Matched => format!(
401 "matched {} (already holds the candidate's bytes)",
402 decision.destination
403 ),
404 Action::Created | Action::Replaced => {
405 format!("{} {}", decision.action.as_str(), decision.destination)
406 }
407 }
408}
409
410fn refuse_a_recorded_target(held: &apply::Held) -> Result<(), RkError> {
413 if landing::manifest::load(held.base())?.is_none() {
414 return Ok(());
415 }
416 let target = held.display();
417 Err(RkError::refusal(
418 Diagnostic::new(
419 Reason::StateDrift,
420 format!(
421 "{target} already carries {}, and nothing was written",
422 manifest::MANIFEST_PATH
423 ),
424 )
425 .expected("a target without a landing receipt")
426 .action(format!(
427 "rk upgrade --target {target} takes it to this binary's projection"
428 ))
429 .target_state("unchanged"),
430 ))
431}
432
433fn collect_sentinels(
436 target: &Utf8Path,
437 destination: &str,
438 bytes: &[u8],
439 found: &mut Vec<SentinelEntry>,
440) {
441 let text = String::from_utf8_lossy(bytes);
442 for (idx, line) in text.lines().enumerate() {
443 if line.contains(embedded::SENTINEL) {
444 found.push(SentinelEntry {
445 path: target.join(destination).to_string(),
446 line: idx + 1,
447 text: line.trim().to_owned(),
448 });
449 }
450 }
451}
452
453#[cfg(test)]
454mod tests {
455 use super::{FileEntry, Report, SentinelEntry};
456
457 #[test]
461 fn the_init_report_schema_snapshot_holds() {
462 let apply = Report {
463 schema: "rk.init/7",
464 config: crate::config::Plan {
465 action: "added",
466 changes: vec![],
467 content: "schema_version = 1\n".into(),
468 },
469 mode: "apply",
470 tech: "rust".into(),
471 forge: "github".into(),
472 target: "/tmp/t".into(),
473 repo: Some("acme/widget".into()),
474 workflow: "worktree",
475 style: "trunk",
476 nix: true,
477 withheld: Some(vec![crate::landing::Withheld {
478 path: "flake.nix".into(),
479 reason: "the target already carries flake.nix".into(),
480 }]),
481 collisions: None,
482 files: vec![FileEntry {
483 path: "release-plz.toml".into(),
484 kind: "seeded",
485 action: "created",
486 }],
487 sentinels: Some(vec![SentinelEntry {
488 path: "/tmp/t/release-plz.toml".into(),
489 line: 3,
490 text: "# TODO(release-kit): keep false for a binary-only crate".into(),
491 }]),
492 next: vec!["commit the landed files, the receipt included".into()],
493 };
494 assert_eq!(
495 serde_json::to_string(&apply).expect("a report serializes"),
496 r##"{"schema":"rk.init/7","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":"created"}],"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 receipt included"]}"##
497 );
498 let preview = Report {
499 sentinels: None,
500 repo: None,
501 mode: "preview",
502 nix: false,
503 withheld: None,
504 collisions: Some(vec![super::Collision {
505 path: "SECURITY.md".into(),
506 reason: "exists, and no receipt attributes it to release-kit".into(),
507 }]),
508 ..apply
509 };
510 assert_eq!(
511 serde_json::to_string(&preview).expect("a report serializes"),
512 r#"{"schema":"rk.init/7","mode":"preview","tech":"rust","forge":"github","target":"/tmp/t","workflow":"worktree","style":"trunk","nix":false,"collisions":[{"path":"SECURITY.md","reason":"exists, and no receipt attributes it to release-kit"}],"config":{"action":"added","changes":[],"content":"schema_version = 1\n"},"files":[{"path":"release-plz.toml","kind":"seeded","action":"created"}],"next":["commit the landed files, the receipt included"]}"#,
513 "a preview omits the sentinels, the unresolved repo, and an empty withheld list rather than serializing null"
514 );
515 }
516}