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    /// Whether the landing carries the Nix capability.
67    nix: bool,
68    /// The Nix destinations this target could not take, each with why;
69    /// absent where nothing was withheld.
70    #[serde(skip_serializing_if = "Option::is_none")]
71    withheld: Option<Vec<landing::Withheld>>,
72    /// Every destination, with its kind and action.
73    files: Vec<FileEntry>,
74    /// The sentinels an apply left to fill; absent in a preview.
75    #[serde(skip_serializing_if = "Option::is_none")]
76    sentinels: Option<Vec<SentinelEntry>>,
77    /// What plausibly follows.
78    next: Vec<String>,
79}
80
81/// Land the files for `--tech` into `--target`.
82///
83/// # Errors
84///
85/// Returns [`RkError::Usage`] for an unknown technology or pair,
86/// [`RkError::Refusal`] when the target is missing, already carries a
87/// record, or a `rendered` destination conflicts, [`RkError::Missing`]
88/// when an apply resolves no repository, and [`RkError::Io`] on
89/// filesystem failure.
90pub fn run(args: &InitArgs) -> Result<(), RkError> {
91    let out = Output::new(args.json);
92    if !args.target.is_dir() {
93        return Err(RkError::refusal(
94            Diagnostic::new(
95                Reason::TargetNotFound,
96                format!(
97                    "target {} is not a directory; nothing was written",
98                    args.target
99                ),
100            )
101            .expected("an existing directory to land into")
102            .target_state("unchanged"),
103        ));
104    }
105    let resolved = landing::resolve(&args.target, args.forge.as_deref(), args.repo.as_deref())?;
106    let forge = resolved.forge;
107    let workflow = Workflow::parse(&args.workflow)?;
108    let style = Style::parse(&args.style)?;
109    if args.apply {
110        let repo = resolved.repo.ok_or_else(landing::repo_unresolved)?;
111        let scopes = landing::parse_scopes(args.scopes.as_deref().ok_or_else(|| {
112            RkError::Usage(
113                "an apply renders the scope-bearing files; pass --scopes <list>, the Conventional Commit scopes this project accepts".into(),
114            )
115        })?)?;
116        let mut entries = landing::projection(
117            &args.tech,
118            &forge,
119            &repo,
120            &scopes,
121            workflow,
122            Some(style),
123            args.nix,
124        )?;
125        let withheld = landing::withhold_nix(&args.target, args.nix, None, &mut entries)?;
126        apply(
127            out, args, &forge, &repo, &scopes, workflow, style, &entries, withheld,
128        )
129    } else {
130        // A preview lists destinations and compares nothing, so an
131        // unresolved repository only means the owner substitution is
132        // shown unrendered; the placeholder substitutes to itself, and an
133        // absent scope list leaves the scope tokens standing.
134        if resolved.repo.is_none() {
135            out.frame(
136                "note: no repository detected; an apply derives the owner from --repo <path>",
137            );
138        }
139        let repo = resolved.repo;
140        let scopes = args
141            .scopes
142            .as_deref()
143            .map(landing::parse_scopes)
144            .transpose()?
145            .unwrap_or_default();
146        let mut entries = landing::projection(
147            &args.tech,
148            &forge,
149            repo.as_deref().unwrap_or("OWNER"),
150            &scopes,
151            workflow,
152            Some(style),
153            args.nix,
154        )?;
155        // The preview withholds exactly as the apply would, so what is
156        // listed is what lands.
157        let withheld = landing::withhold_nix(&args.target, args.nix, None, &mut entries)?;
158        preview(out, args, &forge, repo, workflow, style, &entries, withheld)
159    }
160}
161
162/// List every destination and write nothing.
163#[allow(clippy::too_many_arguments)]
164fn preview(
165    out: Output,
166    args: &InitArgs,
167    forge: &str,
168    repo: Option<String>,
169    workflow: Workflow,
170    style: Style,
171    entries: &[Entry],
172    withheld: Vec<landing::Withheld>,
173) -> Result<(), RkError> {
174    let repo_argument = repo.as_deref().unwrap_or("<owner/name>");
175    let scopes_argument = args.scopes.as_deref().unwrap_or("<scope,scope>");
176    let nix_flag = if args.nix { " --nix" } else { "" };
177    let next = vec![format!(
178        "rk init --tech {} --forge {forge} --repo {repo_argument} --scopes {scopes_argument} --workflow {} --style {}{nix_flag} --target {} --apply",
179        args.tech,
180        workflow.as_str(),
181        style.as_str(),
182        args.target
183    )];
184    out.result_line(format!(
185        "DRY RUN: rk init writes these files into {}; re-run with --apply",
186        args.target
187    ));
188    for entry in entries {
189        out.result_line(&entry.destination);
190    }
191    for entry in &withheld {
192        out.result_line(format!("withheld {}: {}", entry.path, entry.reason));
193    }
194    out.next(&next);
195    out.emit(&Report {
196        schema: "rk.init/4",
197        mode: "preview",
198        tech: args.tech.clone(),
199        forge: forge.to_owned(),
200        target: args.target.to_string(),
201        repo,
202        workflow: workflow.as_str(),
203        style: style.as_str(),
204        nix: args.nix,
205        withheld: (!withheld.is_empty()).then_some(withheld),
206        files: entries
207            .iter()
208            .map(|entry| FileEntry {
209                path: entry.destination.clone(),
210                kind: entry.kind.as_str(),
211                action: "land",
212            })
213            .collect(),
214        sentinels: None,
215        next,
216    })
217}
218
219/// Land the files — all-or-nothing against `rendered` conflicts — write
220/// the record last, and report the judgment sentinels the operator still
221/// owes.
222#[allow(clippy::too_many_arguments, clippy::too_many_lines)]
223fn apply(
224    out: Output,
225    args: &InitArgs,
226    forge: &str,
227    repo: &str,
228    scopes: &[String],
229    workflow: Workflow,
230    style: Style,
231    entries: &[Entry],
232    withheld: Vec<landing::Withheld>,
233) -> Result<(), RkError> {
234    refuse_a_recorded_target(args)?;
235    landing::hooks_splice_refusal(&args.target)?;
236    let planned = plan(&args.target, entries)?;
237    let mut file_entries = Vec::new();
238    let mut records = Vec::new();
239    let mut sentinels = Vec::new();
240    for Planned {
241        entry,
242        action,
243        found,
244    } in planned
245    {
246        if action == "write" {
247            landing::write_destination(&args.target, entry)?;
248        }
249        out.result_line(format!(
250            "{} {}",
251            match action {
252                "write" => "wrote",
253                "kept" => "kept (target-owned)",
254                _ => "unchanged",
255            },
256            entry.destination
257        ));
258        // What the destination now holds: the rendered bytes, or the
259        // target's own where a seeded or state file was kept.
260        let landed = match (action, found) {
261            ("kept", Some(bytes)) => bytes,
262            _ => entry.rendered.clone(),
263        };
264        collect_sentinels(&args.target, &entry.destination, &landed, &mut sentinels);
265        records.push(FileRecord {
266            destination: entry.destination.clone(),
267            kind: entry.kind,
268            sha256: Digest::of(&landed),
269            baseline_sha256: match entry.kind {
270                Kind::State => None,
271                Kind::Rendered | Kind::Seeded => Some(Digest::of(&entry.baseline)),
272            },
273        });
274        file_entries.push(FileEntry {
275            path: entry.destination.clone(),
276            kind: entry.kind.as_str(),
277            action,
278        });
279    }
280    for entry in &withheld {
281        out.result_line(format!("withheld {}: {}", entry.path, entry.reason));
282    }
283
284    // The record, last, after every file has landed.
285    manifest::write(
286        &args.target,
287        &Manifest {
288            schema_version: manifest::SCHEMA_VERSION,
289            rk_version: env!("CARGO_PKG_VERSION").to_owned(),
290            payload_sha256: crate::commands::payload::report().payload_sha256,
291            origin: "init".to_owned(),
292            tech: args.tech.clone(),
293            forge: forge.to_owned(),
294            landed_at: manifest::now(),
295            parameters: Parameters {
296                repo: repo.to_owned(),
297                scopes: scopes.to_vec(),
298                workflow,
299                style: Some(style),
300                nix: args.nix,
301            },
302            files: records,
303            pins: registry::pins_for(&args.tech)
304                .into_iter()
305                .map(|pin| (pin.name, pin.version))
306                .collect(),
307        },
308    )?;
309    out.result_line(format!("wrote {}", manifest::MANIFEST_PATH));
310
311    if sentinels.is_empty() {
312        out.result_line("no sentinels to fill");
313    } else {
314        out.result_line("fill these sentinels before the workflow runs:");
315        for sentinel in &sentinels {
316            out.result_line(format!(
317                "{}:{}: {}",
318                sentinel.path, sentinel.line, sentinel.text
319            ));
320        }
321    }
322    let next = vec![
323        if sentinels.is_empty() {
324            "commit the landed files, the record included".to_owned()
325        } else {
326            "fill each sentinel above, then commit the landed files, the record included".to_owned()
327        },
328        format!("rk status --target {} reports this landing", args.target),
329        "rk method setup orders what follows".to_owned(),
330    ];
331    out.next(&next);
332    out.emit(&Report {
333        schema: "rk.init/4",
334        mode: "apply",
335        tech: args.tech.clone(),
336        forge: forge.to_owned(),
337        target: args.target.to_string(),
338        repo: Some(repo.to_owned()),
339        workflow: workflow.as_str(),
340        style: style.as_str(),
341        nix: args.nix,
342        withheld: (!withheld.is_empty()).then_some(withheld),
343        files: file_entries,
344        sentinels: Some(sentinels),
345        next,
346    })
347}
348
349/// A re-landing over an existing record is `rk upgrade`'s job, not a
350/// second `rk init`.
351fn refuse_a_recorded_target(args: &InitArgs) -> Result<(), RkError> {
352    if landing::manifest::load(&args.target)?.is_none() {
353        return Ok(());
354    }
355    Err(RkError::refusal(
356        Diagnostic::new(
357            Reason::StateDrift,
358            format!(
359                "{} already carries {}, and nothing was written",
360                args.target,
361                manifest::MANIFEST_PATH
362            ),
363        )
364        .expected("a target without a landing record")
365        .action(format!(
366            "rk upgrade --target {} takes it to this binary's payload",
367            args.target
368        ))
369        .target_state("unchanged"),
370    ))
371}
372
373/// One planned destination: what was found there, and what an apply does
374/// about it.
375struct Planned<'a> {
376    /// The projected artifact.
377    entry: &'a Entry,
378    /// `write`, `unchanged`, or `kept`.
379    action: &'static str,
380    /// The bytes the destination already held, where it held any.
381    found: Option<Vec<u8>>,
382}
383
384/// The read pass before anything writes: every destination is read and
385/// classified, so an unreadable path — a directory where a file should
386/// land, a permission failure — surfaces here and the target is never
387/// left half-written, and every `rendered` conflict is collected before
388/// the one refusal.
389fn plan<'a>(target: &Utf8Path, entries: &'a [Entry]) -> Result<Vec<Planned<'a>>, RkError> {
390    let mut conflicts: Vec<&str> = Vec::new();
391    let mut planned = Vec::new();
392    for entry in entries {
393        let found = landing::read_destination(target, entry)?;
394        let action = match (&found, entry.kind) {
395            (None, _) => "write",
396            (Some(bytes), _) if *bytes == entry.rendered => "unchanged",
397            (Some(_), Kind::Rendered) => {
398                conflicts.push(entry.destination.as_str());
399                "conflict"
400            }
401            (Some(_), Kind::Seeded | Kind::State) => "kept",
402        };
403        planned.push(Planned {
404            entry,
405            action,
406            found,
407        });
408    }
409    if conflicts.is_empty() {
410        return Ok(planned);
411    }
412    Err(RkError::refusal(
413        Diagnostic::new(
414            Reason::StateDrift,
415            format!(
416                "these files exist with different content, and nothing was written: {}",
417                conflicts.join(", ")
418            ),
419        )
420        .expected("every rendered destination absent, or holding this landing's bytes")
421        .target_state("unchanged"),
422    ))
423}
424
425/// Collect every judgment-sentinel line one landed file carries, so
426/// nothing stays half-configured silently.
427fn collect_sentinels(
428    target: &Utf8Path,
429    destination: &str,
430    bytes: &[u8],
431    found: &mut Vec<SentinelEntry>,
432) {
433    let text = String::from_utf8_lossy(bytes);
434    for (idx, line) in text.lines().enumerate() {
435        if line.contains(embedded::SENTINEL) {
436            found.push(SentinelEntry {
437                path: target.join(destination).to_string(),
438                line: idx + 1,
439                text: line.trim().to_owned(),
440            });
441        }
442    }
443}
444
445#[cfg(test)]
446mod tests {
447    #![allow(clippy::expect_used)]
448
449    use super::{FileEntry, Report, SentinelEntry};
450
451    /// The complete `rk.init/3` shape, held by snapshot in both modes: a
452    /// field rename or removal fails here and becomes a schema-version
453    /// bump instead of a silent parser break at some agent.
454    #[test]
455    fn the_init_report_schema_snapshot_holds() {
456        let apply = Report {
457            schema: "rk.init/4",
458            mode: "apply",
459            tech: "rust".into(),
460            forge: "github".into(),
461            target: "/tmp/t".into(),
462            repo: Some("acme/widget".into()),
463            workflow: "worktree",
464            style: "trunk",
465            nix: true,
466            withheld: Some(vec![crate::landing::Withheld {
467                path: "flake.nix".into(),
468                reason: "the target already carries flake.nix".into(),
469            }]),
470            files: vec![FileEntry {
471                path: "release-plz.toml".into(),
472                kind: "seeded",
473                action: "write",
474            }],
475            sentinels: Some(vec![SentinelEntry {
476                path: "/tmp/t/release-plz.toml".into(),
477                line: 3,
478                text: "# TODO(release-kit): keep false for a binary-only crate".into(),
479            }]),
480            next: vec!["commit the landed files, the record included".into()],
481        };
482        assert_eq!(
483            serde_json::to_string(&apply).expect("a report serializes"),
484            r##"{"schema":"rk.init/4","mode":"apply","tech":"rust","forge":"github","target":"/tmp/t","repo":"acme/widget","workflow":"worktree","style":"trunk","nix":true,"withheld":[{"path":"flake.nix","reason":"the target already carries flake.nix"}],"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"]}"##
485        );
486        let preview = Report {
487            sentinels: None,
488            repo: None,
489            mode: "preview",
490            nix: false,
491            withheld: None,
492            ..apply
493        };
494        assert_eq!(
495            serde_json::to_string(&preview).expect("a report serializes"),
496            r#"{"schema":"rk.init/4","mode":"preview","tech":"rust","forge":"github","target":"/tmp/t","workflow":"worktree","style":"trunk","nix":false,"files":[{"path":"release-plz.toml","kind":"seeded","action":"write"}],"next":["commit the landed files, the record included"]}"#,
497            "a preview omits the sentinels, the unresolved repo, and an empty withheld list rather than serializing null"
498        );
499    }
500}