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 files: Vec<FileEntry>,
74 #[serde(skip_serializing_if = "Option::is_none")]
76 sentinels: Option<Vec<SentinelEntry>>,
77 next: Vec<String>,
79}
80
81pub fn run(args: &InitArgs) -> Result<(), RkError> {
91 let out = Output::new(args.json);
92 if !args.target.is_dir() {
93 return Err(RkError::refusal(
94 Diagnostic::new(
95 Reason::TargetNotFound,
96 format!(
97 "target {} is not a directory; nothing was written",
98 args.target
99 ),
100 )
101 .expected("an existing directory to land into")
102 .target_state("unchanged"),
103 ));
104 }
105 let resolved = landing::resolve(&args.target, args.forge.as_deref(), args.repo.as_deref())?;
106 let forge = resolved.forge;
107 let workflow = Workflow::parse(&args.workflow)?;
108 let style = Style::parse(&args.style)?;
109 if args.apply {
110 let repo = resolved.repo.ok_or_else(landing::repo_unresolved)?;
111 let scopes = landing::parse_scopes(args.scopes.as_deref().ok_or_else(|| {
112 RkError::Usage(
113 "an apply renders the scope-bearing files; pass --scopes <list>, the Conventional Commit scopes this project accepts".into(),
114 )
115 })?)?;
116 let mut entries = landing::projection(
117 &args.tech,
118 &forge,
119 &repo,
120 &scopes,
121 workflow,
122 Some(style),
123 args.nix,
124 )?;
125 let withheld = landing::withhold_nix(&args.target, args.nix, None, &mut entries)?;
126 apply(
127 out, args, &forge, &repo, &scopes, workflow, style, &entries, withheld,
128 )
129 } else {
130 if resolved.repo.is_none() {
135 out.frame(
136 "note: no repository detected; an apply derives the owner from --repo <path>",
137 );
138 }
139 let repo = resolved.repo;
140 let scopes = args
141 .scopes
142 .as_deref()
143 .map(landing::parse_scopes)
144 .transpose()?
145 .unwrap_or_default();
146 let mut entries = landing::projection(
147 &args.tech,
148 &forge,
149 repo.as_deref().unwrap_or("OWNER"),
150 &scopes,
151 workflow,
152 Some(style),
153 args.nix,
154 )?;
155 let withheld = landing::withhold_nix(&args.target, args.nix, None, &mut entries)?;
158 preview(out, args, &forge, repo, workflow, style, &entries, withheld)
159 }
160}
161
162#[allow(clippy::too_many_arguments)]
164fn preview(
165 out: Output,
166 args: &InitArgs,
167 forge: &str,
168 repo: Option<String>,
169 workflow: Workflow,
170 style: Style,
171 entries: &[Entry],
172 withheld: Vec<landing::Withheld>,
173) -> Result<(), RkError> {
174 let repo_argument = repo.as_deref().unwrap_or("<owner/name>");
175 let scopes_argument = args.scopes.as_deref().unwrap_or("<scope,scope>");
176 let nix_flag = if args.nix { " --nix" } else { "" };
177 let next = vec![format!(
178 "rk init --tech {} --forge {forge} --repo {repo_argument} --scopes {scopes_argument} --workflow {} --style {}{nix_flag} --target {} --apply",
179 args.tech,
180 workflow.as_str(),
181 style.as_str(),
182 args.target
183 )];
184 out.result_line(format!(
185 "DRY RUN: rk init writes these files into {}; re-run with --apply",
186 args.target
187 ));
188 for entry in entries {
189 out.result_line(&entry.destination);
190 }
191 for entry in &withheld {
192 out.result_line(format!("withheld {}: {}", entry.path, entry.reason));
193 }
194 out.next(&next);
195 out.emit(&Report {
196 schema: "rk.init/4",
197 mode: "preview",
198 tech: args.tech.clone(),
199 forge: forge.to_owned(),
200 target: args.target.to_string(),
201 repo,
202 workflow: workflow.as_str(),
203 style: style.as_str(),
204 nix: args.nix,
205 withheld: (!withheld.is_empty()).then_some(withheld),
206 files: entries
207 .iter()
208 .map(|entry| FileEntry {
209 path: entry.destination.clone(),
210 kind: entry.kind.as_str(),
211 action: "land",
212 })
213 .collect(),
214 sentinels: None,
215 next,
216 })
217}
218
219#[allow(clippy::too_many_arguments, clippy::too_many_lines)]
223fn apply(
224 out: Output,
225 args: &InitArgs,
226 forge: &str,
227 repo: &str,
228 scopes: &[String],
229 workflow: Workflow,
230 style: Style,
231 entries: &[Entry],
232 withheld: Vec<landing::Withheld>,
233) -> Result<(), RkError> {
234 refuse_a_recorded_target(args)?;
235 landing::hooks_splice_refusal(&args.target)?;
236 let planned = plan(&args.target, entries)?;
237 let mut file_entries = Vec::new();
238 let mut records = Vec::new();
239 let mut sentinels = Vec::new();
240 for Planned {
241 entry,
242 action,
243 found,
244 } in planned
245 {
246 if action == "write" {
247 landing::write_destination(&args.target, entry)?;
248 }
249 out.result_line(format!(
250 "{} {}",
251 match action {
252 "write" => "wrote",
253 "kept" => "kept (target-owned)",
254 _ => "unchanged",
255 },
256 entry.destination
257 ));
258 let landed = match (action, found) {
261 ("kept", Some(bytes)) => bytes,
262 _ => entry.rendered.clone(),
263 };
264 collect_sentinels(&args.target, &entry.destination, &landed, &mut sentinels);
265 records.push(FileRecord {
266 destination: entry.destination.clone(),
267 kind: entry.kind,
268 sha256: Digest::of(&landed),
269 baseline_sha256: match entry.kind {
270 Kind::State => None,
271 Kind::Rendered | Kind::Seeded => Some(Digest::of(&entry.baseline)),
272 },
273 });
274 file_entries.push(FileEntry {
275 path: entry.destination.clone(),
276 kind: entry.kind.as_str(),
277 action,
278 });
279 }
280 for entry in &withheld {
281 out.result_line(format!("withheld {}: {}", entry.path, entry.reason));
282 }
283
284 manifest::write(
286 &args.target,
287 &Manifest {
288 schema_version: manifest::SCHEMA_VERSION,
289 rk_version: env!("CARGO_PKG_VERSION").to_owned(),
290 payload_sha256: crate::commands::payload::report().payload_sha256,
291 origin: "init".to_owned(),
292 tech: args.tech.clone(),
293 forge: forge.to_owned(),
294 landed_at: manifest::now(),
295 parameters: Parameters {
296 repo: repo.to_owned(),
297 scopes: scopes.to_vec(),
298 workflow,
299 style: Some(style),
300 nix: args.nix,
301 },
302 files: records,
303 pins: registry::pins_for(&args.tech)
304 .into_iter()
305 .map(|pin| (pin.name, pin.version))
306 .collect(),
307 },
308 )?;
309 out.result_line(format!("wrote {}", manifest::MANIFEST_PATH));
310
311 if sentinels.is_empty() {
312 out.result_line("no sentinels to fill");
313 } else {
314 out.result_line("fill these sentinels before the workflow runs:");
315 for sentinel in &sentinels {
316 out.result_line(format!(
317 "{}:{}: {}",
318 sentinel.path, sentinel.line, sentinel.text
319 ));
320 }
321 }
322 let next = vec![
323 if sentinels.is_empty() {
324 "commit the landed files, the record included".to_owned()
325 } else {
326 "fill each sentinel above, then commit the landed files, the record included".to_owned()
327 },
328 format!("rk status --target {} reports this landing", args.target),
329 "rk method setup orders what follows".to_owned(),
330 ];
331 out.next(&next);
332 out.emit(&Report {
333 schema: "rk.init/4",
334 mode: "apply",
335 tech: args.tech.clone(),
336 forge: forge.to_owned(),
337 target: args.target.to_string(),
338 repo: Some(repo.to_owned()),
339 workflow: workflow.as_str(),
340 style: style.as_str(),
341 nix: args.nix,
342 withheld: (!withheld.is_empty()).then_some(withheld),
343 files: file_entries,
344 sentinels: Some(sentinels),
345 next,
346 })
347}
348
349fn refuse_a_recorded_target(args: &InitArgs) -> Result<(), RkError> {
352 if landing::manifest::load(&args.target)?.is_none() {
353 return Ok(());
354 }
355 Err(RkError::refusal(
356 Diagnostic::new(
357 Reason::StateDrift,
358 format!(
359 "{} already carries {}, and nothing was written",
360 args.target,
361 manifest::MANIFEST_PATH
362 ),
363 )
364 .expected("a target without a landing record")
365 .action(format!(
366 "rk upgrade --target {} takes it to this binary's payload",
367 args.target
368 ))
369 .target_state("unchanged"),
370 ))
371}
372
373struct Planned<'a> {
376 entry: &'a Entry,
378 action: &'static str,
380 found: Option<Vec<u8>>,
382}
383
384fn plan<'a>(target: &Utf8Path, entries: &'a [Entry]) -> Result<Vec<Planned<'a>>, RkError> {
390 let mut conflicts: Vec<&str> = Vec::new();
391 let mut planned = Vec::new();
392 for entry in entries {
393 let found = landing::read_destination(target, entry)?;
394 let action = match (&found, entry.kind) {
395 (None, _) => "write",
396 (Some(bytes), _) if *bytes == entry.rendered => "unchanged",
397 (Some(_), Kind::Rendered) => {
398 conflicts.push(entry.destination.as_str());
399 "conflict"
400 }
401 (Some(_), Kind::Seeded | Kind::State) => "kept",
402 };
403 planned.push(Planned {
404 entry,
405 action,
406 found,
407 });
408 }
409 if conflicts.is_empty() {
410 return Ok(planned);
411 }
412 Err(RkError::refusal(
413 Diagnostic::new(
414 Reason::StateDrift,
415 format!(
416 "these files exist with different content, and nothing was written: {}",
417 conflicts.join(", ")
418 ),
419 )
420 .expected("every rendered destination absent, or holding this landing's bytes")
421 .target_state("unchanged"),
422 ))
423}
424
425fn collect_sentinels(
428 target: &Utf8Path,
429 destination: &str,
430 bytes: &[u8],
431 found: &mut Vec<SentinelEntry>,
432) {
433 let text = String::from_utf8_lossy(bytes);
434 for (idx, line) in text.lines().enumerate() {
435 if line.contains(embedded::SENTINEL) {
436 found.push(SentinelEntry {
437 path: target.join(destination).to_string(),
438 line: idx + 1,
439 text: line.trim().to_owned(),
440 });
441 }
442 }
443}
444
445#[cfg(test)]
446mod tests {
447 #![allow(clippy::expect_used)]
448
449 use super::{FileEntry, Report, SentinelEntry};
450
451 #[test]
455 fn the_init_report_schema_snapshot_holds() {
456 let apply = Report {
457 schema: "rk.init/4",
458 mode: "apply",
459 tech: "rust".into(),
460 forge: "github".into(),
461 target: "/tmp/t".into(),
462 repo: Some("acme/widget".into()),
463 workflow: "worktree",
464 style: "trunk",
465 nix: true,
466 withheld: Some(vec![crate::landing::Withheld {
467 path: "flake.nix".into(),
468 reason: "the target already carries flake.nix".into(),
469 }]),
470 files: vec![FileEntry {
471 path: "release-plz.toml".into(),
472 kind: "seeded",
473 action: "write",
474 }],
475 sentinels: Some(vec![SentinelEntry {
476 path: "/tmp/t/release-plz.toml".into(),
477 line: 3,
478 text: "# TODO(release-kit): keep false for a binary-only crate".into(),
479 }]),
480 next: vec!["commit the landed files, the record included".into()],
481 };
482 assert_eq!(
483 serde_json::to_string(&apply).expect("a report serializes"),
484 r##"{"schema":"rk.init/4","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"}],"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"]}"##
485 );
486 let preview = Report {
487 sentinels: None,
488 repo: None,
489 mode: "preview",
490 nix: false,
491 withheld: None,
492 ..apply
493 };
494 assert_eq!(
495 serde_json::to_string(&preview).expect("a report serializes"),
496 r#"{"schema":"rk.init/4","mode":"preview","tech":"rust","forge":"github","target":"/tmp/t","workflow":"worktree","style":"trunk","nix":false,"files":[{"path":"release-plz.toml","kind":"seeded","action":"write"}],"next":["commit the landed files, the record included"]}"#,
497 "a preview omits the sentinels, the unresolved repo, and an empty withheld list rather than serializing null"
498 );
499 }
500}