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 direct writer: the target's evidence is gathered
4//! once, this binary's projection is computed from its embedded sources,
5//! every destination is decided by elementary ownership, and on `--apply`
6//! the writer lands the files and the receipt last under one target lock.
7//! Dry-run by default: without `--apply` the destinations and what a
8//! fresh production invocation would do with them are listed and nothing
9//! is touched; `rk stage` is the full-byte comparison surface. A
10//! whole-file destination present on disk with no receipt naming it
11//! refuses before any write, every collision collected in one pass, and
12//! no force flag exists.
13//!
14//! SATISFIES landing:a-landing-leaves-a-record
15//! SATISFIES landing:a-missing-receipt-is-a-classification
16
17use camino::Utf8Path;
18use serde::Serialize;
19
20use crate::cli::init::InitArgs;
21use crate::diagnostic::{Diagnostic, Reason};
22use crate::embedded;
23use crate::error::RkError;
24use crate::held;
25use crate::landing::apply::{self, Action, Collision, Prepared};
26use crate::landing::manifest::{self, Style, Workflow};
27use crate::landing::{self, lock};
28use crate::output::Output;
29use crate::release::EmbeddedReleaseSource;
30
31/// One destination and what happened to it.
32#[derive(Debug, Serialize)]
33struct FileEntry {
34    /// The destination, relative to the target.
35    path: String,
36    /// The declared ownership kind.
37    kind: &'static str,
38    /// `created`, `replaced`, `matched`, `preserved`, `drift`, `released`,
39    /// or `collision` in a preview.
40    action: &'static str,
41}
42
43/// One sentinel line left for the operator.
44#[derive(Debug, Serialize)]
45struct SentinelEntry {
46    /// The landed file holding the sentinel.
47    path: String,
48    /// The 1-indexed line.
49    line: usize,
50    /// The line's text, trimmed.
51    text: String,
52}
53
54/// The machine form of a landing report.
55#[derive(Debug, Serialize)]
56struct Report {
57    /// The shape version of this document.
58    schema: &'static str,
59    /// `preview` or `apply`.
60    mode: &'static str,
61    /// The technology whose files land.
62    tech: String,
63    /// The forge whose subtree lands.
64    forge: String,
65    /// The target directory.
66    target: String,
67    /// The resolved project path, where detection or `--repo` named one.
68    #[serde(skip_serializing_if = "Option::is_none")]
69    repo: Option<String>,
70    /// The working-copy mode the landing records and renders under.
71    workflow: &'static str,
72    style: &'static str,
73    /// Whether the landing carries the Nix capability.
74    nix: bool,
75    /// The Nix destinations this target could not take, each with why;
76    /// absent where nothing was withheld.
77    #[serde(skip_serializing_if = "Option::is_none")]
78    withheld: Option<Vec<landing::Withheld>>,
79    /// The destinations a production landing refuses as they stand;
80    /// absent where there is none. A preview lists them and exits 0.
81    #[serde(skip_serializing_if = "Option::is_none")]
82    collisions: Option<Vec<Collision>>,
83    config: crate::config::Plan,
84    /// Every destination, with its kind and action.
85    files: Vec<FileEntry>,
86    /// The sentinels an apply left to fill; absent in a preview.
87    #[serde(skip_serializing_if = "Option::is_none")]
88    sentinels: Option<Vec<SentinelEntry>>,
89    /// What plausibly follows.
90    next: Vec<String>,
91}
92
93/// The withheld list a report carries.
94fn withheld_of(prepared: &Prepared) -> Option<Vec<landing::Withheld>> {
95    let withheld: Vec<landing::Withheld> = prepared
96        .projection
97        .omissions
98        .iter()
99        .map(|omission| landing::Withheld {
100            path: omission.destination.clone(),
101            reason: omission.reason.clone(),
102        })
103        .collect();
104    (!withheld.is_empty()).then_some(withheld)
105}
106
107/// Land the files for `--tech` into `--target`.
108///
109/// # Errors
110///
111/// Returns [`RkError::Usage`] for an unknown technology or pair,
112/// [`RkError::Refusal`] when the target is missing, already carries a
113/// receipt, or a destination collides, [`RkError::Missing`] when an apply
114/// resolves no repository, and [`RkError::Io`] on filesystem failure.
115pub fn run(args: &InitArgs) -> Result<(), RkError> {
116    let out = Output::new(args.json);
117    if !args.target.is_dir() {
118        return Err(RkError::refusal(
119            Diagnostic::new(
120                Reason::TargetNotFound,
121                format!(
122                    "target {} is not a directory; nothing was written",
123                    args.target
124                ),
125            )
126            .expected("an existing directory to land into")
127            .target_state("unchanged"),
128        ));
129    }
130    // An apply takes the target before it reads anything of it, the
131    // receipt and the configuration included, so the world the decisions
132    // describe is the world the writer writes. A preview holds nothing.
133    let lock = args
134        .apply
135        .then(|| lock::acquire(&args.target))
136        .transpose()?;
137    // One directory descriptor, held from here through the receipt write:
138    // every read and every write goes through it, so a root exchanged
139    // under the pathname later receives nothing.
140    // The proof's pause: the lock is held and the directory is not yet.
141    held::pause(apply::PAUSE_VAR, "locked", "proceed-locked");
142    let held = lock.as_ref().map_or_else(
143        || apply::Held::open(&args.target),
144        |lock| apply::Held::open_locked(&args.target, lock),
145    )?;
146    // The proof's pause: the target is held, and nothing has been read.
147    held::pause(apply::PAUSE_VAR, "held", "proceed-held");
148    let config = crate::config::load(held.base().as_std_path())?;
149    let params = landing::Params::resolve(
150        &EmbeddedReleaseSource,
151        held.base(),
152        &landing::Inputs {
153            tech: args.tech.as_deref(),
154            forge: args.forge.as_deref(),
155            repo: args.repo.as_deref(),
156            workflow: args.workflow.as_deref().map(Workflow::parse).transpose()?,
157            style: args.style.as_deref().map(Style::parse).transpose()?,
158            nix: args.nix.then_some(true),
159        },
160        config.as_ref(),
161        None,
162        if args.apply {
163            landing::Purpose::Init
164        } else {
165            landing::Purpose::Preview
166        },
167    )?;
168    let style = params
169        .style()
170        .ok_or_else(|| RkError::Usage("landing style is unresolved".into()))?;
171    if let Some(lock) = lock {
172        refuse_a_recorded_target(&held)?;
173        let prepared = apply::prepare(&held, None, &params, config.as_ref())?;
174        let landed = apply::land(&held, None, &prepared, apply::Origin::Init, &lock)?;
175        drop(lock);
176        report_apply(out, args, &held, &params, style, &prepared, &landed)
177    } else {
178        let prepared = apply::prepare(&held, None, &params, config.as_ref())?;
179        let repo = (params.repo() != landing::REPO_PLACEHOLDER).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(out, args, &params, repo, style, &prepared)
186    }
187}
188
189/// List every destination with what a production landing would do, and
190/// write nothing.
191fn preview(
192    out: Output,
193    args: &InitArgs,
194    params: &landing::Params,
195    repo: Option<String>,
196    style: Style,
197    prepared: &Prepared,
198) -> Result<(), RkError> {
199    let repo_argument = repo.as_deref().unwrap_or("<owner/name>");
200    let nix_flag = if params.nix() { " --nix" } else { "" };
201    let mut next = vec![format!(
202        "rk init --tech {} --forge {} --repo {repo_argument} --workflow {} --style {}{nix_flag} --target {} --apply",
203        params.tech(),
204        params.forge(),
205        params.workflow().as_str(),
206        style.as_str(),
207        args.target
208    )];
209    if !prepared.collisions.is_empty() {
210        next.insert(
211            0,
212            "resolve each collision above through the rk-setup skill; the apply refuses until then"
213                .to_owned(),
214        );
215    }
216    next.push(format!(
217        "rk stage --target {} stages the complete candidate for a byte comparison",
218        args.target
219    ));
220    out.result_line(format!(
221        "DRY RUN: rk init writes these files into {}; re-run with --apply",
222        args.target
223    ));
224    for decision in &prepared.decisions {
225        out.result_line(format!(
226            "{} {}",
227            decision.action.as_str(),
228            decision.destination
229        ));
230    }
231    for collision in &prepared.collisions {
232        out.result_line(format!(
233            "collision {}: {}",
234            collision.path, collision.reason
235        ));
236    }
237    out.result_line(format!(
238        "{} {}\n{}",
239        prepared.config.action,
240        crate::config::CONFIG_PATH,
241        prepared.config.content
242    ));
243    for entry in &prepared.projection.omissions {
244        out.result_line(format!("withheld {}: {}", entry.destination, entry.reason));
245    }
246    out.next(&next);
247    out.emit(&Report {
248        schema: "rk.init/7",
249        config: prepared.config.clone(),
250        mode: "preview",
251        tech: params.tech().to_owned(),
252        forge: params.forge().to_owned(),
253        target: args.target.to_string(),
254        repo,
255        workflow: params.workflow().as_str(),
256        style: style.as_str(),
257        nix: params.nix(),
258        withheld: withheld_of(prepared),
259        collisions: (!prepared.collisions.is_empty()).then(|| prepared.collisions.clone()),
260        files: prepared
261            .decisions
262            .iter()
263            .map(|decision| FileEntry {
264                path: decision.destination.clone(),
265                kind: decision.kind.as_str(),
266                action: decision.action.as_str(),
267            })
268            .chain(prepared.collisions.iter().map(|collision| FileEntry {
269                path: collision.path.clone(),
270                kind: landing::kind_of(&collision.path).map_or("unknown", landing::Kind::as_str),
271                action: "collision",
272            }))
273            .collect(),
274        sentinels: None,
275        next,
276    })
277}
278
279/// Report a landing the writer completed, with the judgment sentinels the
280/// operator still owes.
281#[allow(
282    clippy::too_many_arguments,
283    reason = "the report reads the landed files through the held target and names them by the path the operator gave, which are two arguments for one target"
284)]
285fn report_apply(
286    out: Output,
287    args: &InitArgs,
288    held: &apply::Held,
289    params: &landing::Params,
290    style: Style,
291    prepared: &Prepared,
292    landed: &apply::Landed,
293) -> Result<(), RkError> {
294    let mut file_entries = Vec::new();
295    let mut sentinels = Vec::new();
296    for decision in &prepared.decisions {
297        out.result_line(describe(decision));
298        if decision.action != Action::Released {
299            let bytes =
300                landing::read_recorded(held.base(), &decision.destination)?.unwrap_or_default();
301            collect_sentinels(
302                held.display(),
303                &decision.destination,
304                &bytes,
305                &mut sentinels,
306            );
307        }
308        file_entries.push(FileEntry {
309            path: decision.destination.clone(),
310            kind: decision.kind.as_str(),
311            action: decision.action.as_str(),
312        });
313    }
314    for entry in &prepared.projection.omissions {
315        out.result_line(format!("withheld {}: {}", entry.destination, entry.reason));
316    }
317    out.result_line(format!(
318        "{} {}",
319        prepared.config.action,
320        crate::config::CONFIG_PATH
321    ));
322    for (key, empty_line) in [
323        ("setup.required_check", "required_check = \"\""),
324        ("setup.bot.app_id", "app_id = \"\""),
325    ] {
326        if let Some((index, _)) = prepared
327            .config
328            .content
329            .lines()
330            .enumerate()
331            .find(|(_, line)| line.starts_with(empty_line))
332        {
333            sentinels.push(SentinelEntry {
334                path: crate::config::CONFIG_PATH.into(),
335                line: index + 1,
336                text: format!("set {key} before forge setup"),
337            });
338        }
339    }
340    out.result_line(format!("wrote {}", manifest::MANIFEST_PATH));
341    debug_assert_eq!(
342        landed.completed.last().map(String::as_str),
343        Some(manifest::MANIFEST_PATH)
344    );
345
346    if sentinels.is_empty() {
347        out.result_line("no sentinels to fill");
348    } else {
349        out.result_line("fill these sentinels before the workflow runs:");
350        for sentinel in &sentinels {
351            out.result_line(format!(
352                "{}:{}: {}",
353                sentinel.path, sentinel.line, sentinel.text
354            ));
355        }
356    }
357    let next = vec![
358        if sentinels.is_empty() {
359            "commit the landed files, the receipt included".to_owned()
360        } else {
361            "fill each sentinel above, then commit the landed files, the receipt included"
362                .to_owned()
363        },
364        format!("rk status --target {} reports this landing", args.target),
365        "rk method setup orders what follows".to_owned(),
366    ];
367    out.next(&next);
368    out.emit(&Report {
369        schema: "rk.init/7",
370        config: prepared.config.clone(),
371        mode: "apply",
372        tech: params.tech().to_owned(),
373        forge: params.forge().to_owned(),
374        target: args.target.to_string(),
375        repo: Some(params.repo().to_owned()),
376        workflow: params.workflow().as_str(),
377        style: style.as_str(),
378        nix: params.nix(),
379        withheld: withheld_of(prepared),
380        collisions: None,
381        files: file_entries,
382        sentinels: Some(sentinels),
383        next,
384    })
385}
386
387/// The human line for one decision.
388pub(crate) fn describe(decision: &apply::Decision) -> String {
389    match decision.action {
390        Action::Preserved | Action::Drift => format!(
391            "{} {} ({}, target-owned)",
392            decision.action.as_str(),
393            decision.destination,
394            decision.kind.as_str()
395        ),
396        Action::Released => format!(
397            "released {} (no longer produced; target-owned from this landing)",
398            decision.destination
399        ),
400        Action::Matched => format!(
401            "matched {} (already holds the candidate's bytes)",
402            decision.destination
403        ),
404        Action::Created | Action::Replaced => {
405            format!("{} {}", decision.action.as_str(), decision.destination)
406        }
407    }
408}
409
410/// A re-landing over an existing receipt is `rk upgrade`'s job, not a
411/// second `rk init`.
412fn refuse_a_recorded_target(held: &apply::Held) -> Result<(), RkError> {
413    if landing::manifest::load(held.base())?.is_none() {
414        return Ok(());
415    }
416    let target = held.display();
417    Err(RkError::refusal(
418        Diagnostic::new(
419            Reason::StateDrift,
420            format!(
421                "{target} already carries {}, and nothing was written",
422                manifest::MANIFEST_PATH
423            ),
424        )
425        .expected("a target without a landing receipt")
426        .action(format!(
427            "rk upgrade --target {target} takes it to this binary's projection"
428        ))
429        .target_state("unchanged"),
430    ))
431}
432
433/// Collect every judgment-sentinel line one landed file carries, so
434/// nothing stays half-configured silently.
435fn collect_sentinels(
436    target: &Utf8Path,
437    destination: &str,
438    bytes: &[u8],
439    found: &mut Vec<SentinelEntry>,
440) {
441    let text = String::from_utf8_lossy(bytes);
442    for (idx, line) in text.lines().enumerate() {
443        if line.contains(embedded::SENTINEL) {
444            found.push(SentinelEntry {
445                path: target.join(destination).to_string(),
446                line: idx + 1,
447                text: line.trim().to_owned(),
448            });
449        }
450    }
451}
452
453#[cfg(test)]
454mod tests {
455    use super::{FileEntry, Report, SentinelEntry};
456
457    /// The complete `rk.init/7` shape, held by snapshot in both modes: a
458    /// field rename or removal fails here and becomes a schema-version
459    /// bump instead of a silent parser break at some agent.
460    #[test]
461    fn the_init_report_schema_snapshot_holds() {
462        let apply = Report {
463            schema: "rk.init/7",
464            config: crate::config::Plan {
465                action: "added",
466                changes: vec![],
467                content: "schema_version = 1\n".into(),
468            },
469            mode: "apply",
470            tech: "rust".into(),
471            forge: "github".into(),
472            target: "/tmp/t".into(),
473            repo: Some("acme/widget".into()),
474            workflow: "worktree",
475            style: "trunk",
476            nix: true,
477            withheld: Some(vec![crate::landing::Withheld {
478                path: "flake.nix".into(),
479                reason: "the target already carries flake.nix".into(),
480            }]),
481            collisions: None,
482            files: vec![FileEntry {
483                path: "release-plz.toml".into(),
484                kind: "seeded",
485                action: "created",
486            }],
487            sentinels: Some(vec![SentinelEntry {
488                path: "/tmp/t/release-plz.toml".into(),
489                line: 3,
490                text: "# TODO(release-kit): keep false for a binary-only crate".into(),
491            }]),
492            next: vec!["commit the landed files, the receipt included".into()],
493        };
494        assert_eq!(
495            serde_json::to_string(&apply).expect("a report serializes"),
496            r##"{"schema":"rk.init/7","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":"created"}],"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 receipt included"]}"##
497        );
498        let preview = Report {
499            sentinels: None,
500            repo: None,
501            mode: "preview",
502            nix: false,
503            withheld: None,
504            collisions: Some(vec![super::Collision {
505                path: "SECURITY.md".into(),
506                reason: "exists, and no receipt attributes it to release-kit".into(),
507            }]),
508            ..apply
509        };
510        assert_eq!(
511            serde_json::to_string(&preview).expect("a report serializes"),
512            r#"{"schema":"rk.init/7","mode":"preview","tech":"rust","forge":"github","target":"/tmp/t","workflow":"worktree","style":"trunk","nix":false,"collisions":[{"path":"SECURITY.md","reason":"exists, and no receipt attributes it to release-kit"}],"config":{"action":"added","changes":[],"content":"schema_version = 1\n"},"files":[{"path":"release-plz.toml","kind":"seeded","action":"created"}],"next":["commit the landed files, the receipt included"]}"#,
513            "a preview omits the sentinels, the unresolved repo, and an empty withheld list rather than serializing null"
514        );
515    }
516}