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