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