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