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    .canonicalized()?;
162    let planned = reconcile::compute(&request, &manifest::now())?;
163    let config_plan = planned
164        .config
165        .clone()
166        .ok_or_else(|| RkError::Usage("landing parameters are unresolved".into()))?;
167    if args.apply {
168        apply(
169            out,
170            &effective,
171            params.forge(),
172            params.repo(),
173            params.workflow(),
174            style,
175            &planned,
176            &request,
177            config_plan,
178        )
179    } else {
180        let repo = (params.repo() != "OWNER").then(|| params.repo().to_owned());
181        if repo.is_none() {
182            out.frame(
183                "note: no repository detected; an apply derives the owner from --repo <path>",
184            );
185        }
186        preview(
187            out,
188            &effective,
189            params.forge(),
190            repo,
191            params.workflow(),
192            style,
193            &planned,
194            config_plan,
195        )
196    }
197}
198
199/// List every destination and write nothing.
200#[allow(
201    clippy::too_many_arguments,
202    reason = "the landing parameters are one flat set the caller resolves once, and a struct around them would add a type nothing else reads"
203)]
204fn preview(
205    out: Output,
206    args: &InitArgs,
207    forge: &str,
208    repo: Option<String>,
209    workflow: Workflow,
210    style: Style,
211    planned: &Planned,
212    config: crate::config::Plan,
213) -> Result<(), RkError> {
214    let repo_argument = repo.as_deref().unwrap_or("<owner/name>");
215    let nix_flag = if args.nix { " --nix" } else { "" };
216    let next = vec![format!(
217        "rk init --tech {} --forge {forge} --repo {repo_argument} --workflow {} --style {}{nix_flag} --target {} --apply",
218        args.tech.as_deref().unwrap_or_default(),
219        workflow.as_str(),
220        style.as_str(),
221        args.target
222    )];
223    out.result_line(format!(
224        "DRY RUN: rk init writes these files into {}; re-run with --apply",
225        args.target
226    ));
227    for outcome in &planned.outcomes {
228        out.result_line(&outcome.path);
229    }
230    out.result_line(format!(
231        "{} {}\n{}",
232        config.action,
233        crate::config::CONFIG_PATH,
234        config.content
235    ));
236    for entry in &planned.withheld {
237        out.result_line(format!("withheld {}: {}", entry.path, entry.reason));
238    }
239    out.next(&next);
240    out.emit(&Report {
241        schema: "rk.init/6",
242        config,
243        mode: "preview",
244        tech: args.tech.clone().unwrap_or_default(),
245        forge: forge.to_owned(),
246        target: args.target.to_string(),
247        repo,
248        workflow: workflow.as_str(),
249        style: style.as_str(),
250        nix: args.nix,
251        withheld: (!planned.withheld.is_empty()).then(|| planned.withheld.clone()),
252        files: planned
253            .outcomes
254            .iter()
255            .map(|outcome| FileEntry {
256                path: outcome.path.clone(),
257                kind: outcome.kind.as_str(),
258                action: "land",
259            })
260            .collect(),
261        sentinels: None,
262        plan: None,
263        next,
264    })
265}
266
267/// Land the files through the engine — all-or-nothing against `rendered`
268/// conflicts — with the record last, and report the judgment sentinels
269/// the operator still owes.
270#[allow(
271    clippy::too_many_arguments,
272    clippy::too_many_lines,
273    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"
274)]
275fn apply(
276    out: Output,
277    args: &InitArgs,
278    forge: &str,
279    repo: &str,
280    workflow: Workflow,
281    style: Style,
282    planned: &Planned,
283    request: &PlanRequest,
284    config: crate::config::Plan,
285) -> Result<(), RkError> {
286    refuse_a_recorded_target(args)?;
287    landing::hooks_splice_refusal(&EmbeddedReleaseSource, &args.target)?;
288    refuse_conflicts(planned)?;
289    let applied = reconcile::apply_in_process(planned, request, "init")?;
290    let mut file_entries = Vec::new();
291    let mut sentinels = Vec::new();
292    for outcome in &planned.outcomes {
293        let action = match outcome.disposition {
294            Disposition::Write => "write",
295            Disposition::Kept | Disposition::Drift | Disposition::State => "kept",
296            Disposition::Unchanged | Disposition::Conflict | Disposition::Missing => "unchanged",
297        };
298        out.result_line(format!(
299            "{} {}",
300            match action {
301                "write" => "wrote",
302                "kept" => "kept (target-owned)",
303                _ => "unchanged",
304            },
305            outcome.path
306        ));
307        // What the destination now holds: the written bytes, or the
308        // target's own where a seeded or state file was kept.
309        let landed: Vec<u8> = match FrontApplied::written(planned, &outcome.path) {
310            Some(bytes) => bytes.to_vec(),
311            None => landing::read_recorded(&args.target, &outcome.path)?.unwrap_or_default(),
312        };
313        collect_sentinels(&args.target, &outcome.path, &landed, &mut sentinels);
314        file_entries.push(FileEntry {
315            path: outcome.path.clone(),
316            kind: outcome.kind.as_str(),
317            action,
318        });
319    }
320    for entry in &planned.withheld {
321        out.result_line(format!("withheld {}: {}", entry.path, entry.reason));
322    }
323
324    out.result_line(format!("{} {}", config.action, crate::config::CONFIG_PATH));
325    for (key, empty_line) in [
326        ("setup.required_check", "required_check = \"\""),
327        ("setup.bot.app_id", "app_id = \"\""),
328    ] {
329        if let Some((index, _)) = config
330            .content
331            .lines()
332            .enumerate()
333            .find(|(_, line)| line.starts_with(empty_line))
334        {
335            sentinels.push(SentinelEntry {
336                path: crate::config::CONFIG_PATH.into(),
337                line: index + 1,
338                text: format!("set {key} before forge setup"),
339            });
340        }
341    }
342    out.result_line(format!("wrote {}", manifest::MANIFEST_PATH));
343    out.result_line(applied.line());
344
345    if sentinels.is_empty() {
346        out.result_line("no sentinels to fill");
347    } else {
348        out.result_line("fill these sentinels before the workflow runs:");
349        for sentinel in &sentinels {
350            out.result_line(format!(
351                "{}:{}: {}",
352                sentinel.path, sentinel.line, sentinel.text
353            ));
354        }
355    }
356    let next = vec![
357        if sentinels.is_empty() {
358            "commit the landed files, the record included".to_owned()
359        } else {
360            "fill each sentinel above, then commit the landed files, the record included".to_owned()
361        },
362        format!("rk status --target {} reports this landing", args.target),
363        "rk method setup orders what follows".to_owned(),
364    ];
365    out.next(&next);
366    out.emit(&Report {
367        schema: "rk.init/6",
368        config,
369        mode: "apply",
370        tech: args.tech.clone().unwrap_or_default(),
371        forge: forge.to_owned(),
372        target: args.target.to_string(),
373        repo: Some(repo.to_owned()),
374        workflow: workflow.as_str(),
375        style: style.as_str(),
376        nix: args.nix,
377        withheld: (!planned.withheld.is_empty()).then(|| planned.withheld.clone()),
378        files: file_entries,
379        sentinels: Some(sentinels),
380        plan: Some(applied.trace()),
381        next,
382    })?;
383    applied.applied.failure().map_or(Ok(()), Err)
384}
385
386/// A re-landing over an existing record is `rk upgrade`'s job, not a
387/// second `rk init`.
388fn refuse_a_recorded_target(args: &InitArgs) -> Result<(), RkError> {
389    if landing::manifest::load(&args.target)?.is_none() {
390        return Ok(());
391    }
392    Err(RkError::refusal(
393        Diagnostic::new(
394            Reason::StateDrift,
395            format!(
396                "{} already carries {}, and nothing was written",
397                args.target,
398                manifest::MANIFEST_PATH
399            ),
400        )
401        .expected("a target without a landing record")
402        .action(format!(
403            "rk upgrade --target {} takes it to this binary's payload",
404            args.target
405        ))
406        .target_state("unchanged"),
407    ))
408}
409
410/// Every `rendered` conflict the plan found, refused in one run before
411/// anything writes.
412fn refuse_conflicts(planned: &Planned) -> Result<(), RkError> {
413    let conflicts: Vec<&str> = planned
414        .outcomes
415        .iter()
416        .filter(|outcome| {
417            outcome.kind == Kind::Rendered
418                && matches!(
419                    outcome.disposition,
420                    Disposition::Conflict | Disposition::Missing
421                )
422        })
423        .map(|outcome| outcome.path.as_str())
424        .collect();
425    if conflicts.is_empty() {
426        return Ok(());
427    }
428    Err(RkError::refusal(
429        Diagnostic::new(
430            Reason::StateDrift,
431            format!(
432                "these files exist with different content, and nothing was written: {}",
433                conflicts.join(", ")
434            ),
435        )
436        .expected("every rendered destination absent, or holding this landing's bytes")
437        .target_state("unchanged"),
438    ))
439}
440
441/// Collect every judgment-sentinel line one landed file carries, so
442/// nothing stays half-configured silently.
443fn collect_sentinels(
444    target: &Utf8Path,
445    destination: &str,
446    bytes: &[u8],
447    found: &mut Vec<SentinelEntry>,
448) {
449    let text = String::from_utf8_lossy(bytes);
450    for (idx, line) in text.lines().enumerate() {
451        if line.contains(embedded::SENTINEL) {
452            found.push(SentinelEntry {
453                path: target.join(destination).to_string(),
454                line: idx + 1,
455                text: line.trim().to_owned(),
456            });
457        }
458    }
459}
460
461#[cfg(test)]
462mod tests {
463    use super::{FileEntry, Report, SentinelEntry};
464    use crate::digest::Digest;
465
466    /// The complete `rk.init/6` shape, held by snapshot in both modes: a
467    /// field rename or removal fails here and becomes a schema-version
468    /// bump instead of a silent parser break at some agent.
469    #[test]
470    fn the_init_report_schema_snapshot_holds() {
471        let apply = Report {
472            schema: "rk.init/6",
473            config: crate::config::Plan {
474                action: "added",
475                changes: vec![],
476                content: "schema_version = 1\n".into(),
477            },
478            mode: "apply",
479            tech: "rust".into(),
480            forge: "github".into(),
481            target: "/tmp/t".into(),
482            repo: Some("acme/widget".into()),
483            workflow: "worktree",
484            style: "trunk",
485            nix: true,
486            withheld: Some(vec![crate::landing::Withheld {
487                path: "flake.nix".into(),
488                reason: "the target already carries flake.nix".into(),
489            }]),
490            files: vec![FileEntry {
491                path: "release-plz.toml".into(),
492                kind: "seeded",
493                action: "write",
494            }],
495            sentinels: Some(vec![SentinelEntry {
496                path: "/tmp/t/release-plz.toml".into(),
497                line: 3,
498                text: "# TODO(release-kit): keep false for a binary-only crate".into(),
499            }]),
500            plan: Some(crate::commands::reconcile::Trace {
501                plan_id: "0123456789abcdef".into(),
502                input_fingerprint: Digest::of(b"a"),
503                stored: true,
504                run_id: Some("run".into()),
505            }),
506            next: vec!["commit the landed files, the record included".into()],
507        };
508        assert_eq!(
509            serde_json::to_string(&apply).expect("a report serializes"),
510            format!(
511                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"]}}"##,
512                Digest::of(b"a")
513            )
514        );
515        let preview = Report {
516            sentinels: None,
517            repo: None,
518            mode: "preview",
519            nix: false,
520            withheld: None,
521            plan: None,
522            ..apply
523        };
524        assert_eq!(
525            serde_json::to_string(&preview).expect("a report serializes"),
526            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"]}"#,
527            "a preview omits the sentinels, the unresolved repo, the plan, and an empty withheld list rather than serializing null"
528        );
529    }
530}