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