Skip to main content

release_kit/commands/
init.rs

1//! `rk init`: land a technology's deterministic files into a target.
2//!
3//! Dry-run by default: without `--apply` the destinations are listed and
4//! nothing is touched. The payload is rendered before anything is
5//! compared — the repository owner substitutes into `rendered` files from
6//! the detection-resolved `--repo` parameter — so the comparison is
7//! against what would be written, not against the raw payload. Apply is
8//! all-or-nothing against conflicts on `rendered` files; a differing
9//! `seeded` or `state` file is the target's own and is reported and kept.
10//! Every write goes through the temp-plus-rename writer, and the landing
11//! record is written last: a refused landing writes nothing, the record
12//! included.
13
14use 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/// One destination and what happened to it.
26#[derive(Debug, Serialize)]
27struct FileEntry {
28    /// The destination, relative to the target.
29    path: String,
30    /// The declared ownership kind.
31    kind: &'static str,
32    /// `land` in a preview; `write`, `unchanged`, or `kept` in an apply.
33    action: &'static str,
34}
35
36/// One sentinel line left for the operator.
37#[derive(Debug, Serialize)]
38struct SentinelEntry {
39    /// The landed file holding the sentinel.
40    path: String,
41    /// The 1-indexed line.
42    line: usize,
43    /// The line's text, trimmed.
44    text: String,
45}
46
47/// The machine form of a landing report.
48#[derive(Debug, Serialize)]
49struct Report {
50    /// The shape version of this document.
51    schema: &'static str,
52    /// `preview` or `apply`.
53    mode: &'static str,
54    /// The technology whose files land.
55    tech: String,
56    /// The forge whose subtree lands.
57    forge: String,
58    /// The target directory.
59    target: String,
60    /// The resolved project path, where detection or `--repo` named one.
61    #[serde(skip_serializing_if = "Option::is_none")]
62    repo: Option<String>,
63    /// Every destination, with its kind and action.
64    files: Vec<FileEntry>,
65    /// The sentinels an apply left to fill; absent in a preview.
66    #[serde(skip_serializing_if = "Option::is_none")]
67    sentinels: Option<Vec<SentinelEntry>>,
68    /// What plausibly follows.
69    next: Vec<String>,
70}
71
72/// Land the files for `--tech` into `--target`.
73///
74/// # Errors
75///
76/// Returns [`RkError::Usage`] for an unknown technology or pair,
77/// [`RkError::Refusal`] when the target is missing, already carries a
78/// record, or a `rendered` destination conflicts, [`RkError::Missing`]
79/// when an apply resolves no repository, and [`RkError::Io`] on
80/// filesystem failure.
81pub 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        // A preview lists destinations and compares nothing, so an
109        // unresolved repository only means the owner substitution is
110        // shown unrendered; the placeholder substitutes to itself, and an
111        // absent scope list leaves the scope tokens standing.
112        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
134/// List every destination and write nothing.
135fn 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
176/// Land the files — all-or-nothing against `rendered` conflicts — write
177/// the record last, and report the judgment sentinels the operator still
178/// owes.
179fn 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        // What the destination now holds: the rendered bytes, or the
212        // target's own where a seeded or state file was kept.
213        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    // The record, last, after every file has landed.
235    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
292/// A re-landing over an existing record is `rk upgrade`'s job, not a
293/// second `rk init`.
294fn 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
316/// One planned destination: what was found there, and what an apply does
317/// about it.
318struct Planned<'a> {
319    /// The projected artifact.
320    entry: &'a Entry,
321    /// `write`, `unchanged`, or `kept`.
322    action: &'static str,
323    /// The bytes the destination already held, where it held any.
324    found: Option<Vec<u8>>,
325}
326
327/// The read pass before anything writes: every destination is read and
328/// classified, so an unreadable path — a directory where a file should
329/// land, a permission failure — surfaces here and the target is never
330/// left half-written, and every `rendered` conflict is collected before
331/// the one refusal.
332fn 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
368/// Collect every judgment-sentinel line one landed file carries, so
369/// nothing stays half-configured silently.
370fn 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    /// The complete `rk.init/1` shape, held by snapshot in both modes: a
395    /// field rename or removal fails here and becomes a schema-version
396    /// bump instead of a silent parser break at some agent.
397    #[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}