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, Style, Workflow};
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    /// The working-copy mode the landing records and renders under.
64    workflow: &'static str,
65    style: &'static str,
66    /// Every destination, with its kind and action.
67    files: Vec<FileEntry>,
68    /// The sentinels an apply left to fill; absent in a preview.
69    #[serde(skip_serializing_if = "Option::is_none")]
70    sentinels: Option<Vec<SentinelEntry>>,
71    /// What plausibly follows.
72    next: Vec<String>,
73}
74
75/// Land the files for `--tech` into `--target`.
76///
77/// # Errors
78///
79/// Returns [`RkError::Usage`] for an unknown technology or pair,
80/// [`RkError::Refusal`] when the target is missing, already carries a
81/// record, or a `rendered` destination conflicts, [`RkError::Missing`]
82/// when an apply resolves no repository, and [`RkError::Io`] on
83/// filesystem failure.
84pub 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        // A preview lists destinations and compares nothing, so an
115        // unresolved repository only means the owner substitution is
116        // shown unrendered; the placeholder substitutes to itself, and an
117        // absent scope list leaves the scope tokens standing.
118        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/// List every destination and write nothing.
143#[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/// Land the files — all-or-nothing against `rendered` conflicts — write
193/// the record last, and report the judgment sentinels the operator still
194/// owes.
195#[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        // What the destination now holds: the rendered bytes, or the
231        // target's own where a seeded or state file was kept.
232        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    // The record, last, after every file has landed.
254    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
315/// A re-landing over an existing record is `rk upgrade`'s job, not a
316/// second `rk init`.
317fn 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
339/// One planned destination: what was found there, and what an apply does
340/// about it.
341struct Planned<'a> {
342    /// The projected artifact.
343    entry: &'a Entry,
344    /// `write`, `unchanged`, or `kept`.
345    action: &'static str,
346    /// The bytes the destination already held, where it held any.
347    found: Option<Vec<u8>>,
348}
349
350/// The read pass before anything writes: every destination is read and
351/// classified, so an unreadable path — a directory where a file should
352/// land, a permission failure — surfaces here and the target is never
353/// left half-written, and every `rendered` conflict is collected before
354/// the one refusal.
355fn 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
391/// Collect every judgment-sentinel line one landed file carries, so
392/// nothing stays half-configured silently.
393fn 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    /// The complete `rk.init/3` shape, held by snapshot in both modes: a
418    /// field rename or removal fails here and becomes a schema-version
419    /// bump instead of a silent parser break at some agent.
420    #[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}