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