Skip to main content

release_kit/commands/
init.rs

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