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