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