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