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