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