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};
27use crate::landing::{self, lock};
28use crate::output::Output;
29use crate::profile::{CapabilityRequests, GitWorkflow, ProfileSnapshot};
30use crate::stage::CapabilityNote;
31
32/// One destination and what happened to it.
33#[derive(Debug, Serialize)]
34struct FileEntry {
35    /// The destination, relative to the target.
36    path: String,
37    /// The declared ownership kind.
38    kind: &'static str,
39    /// `created`, `replaced`, `matched`, `preserved`, `drift`, `released`,
40    /// or `collision` in a preview.
41    action: &'static str,
42}
43
44/// One sentinel line left for the operator.
45#[derive(Debug, Serialize)]
46struct SentinelEntry {
47    /// The landed file holding the sentinel.
48    path: String,
49    /// The 1-indexed line.
50    line: usize,
51    /// The line's text, trimmed.
52    text: String,
53}
54
55/// The machine form of a landing report.
56#[derive(Debug, Serialize)]
57struct Report {
58    /// The shape version of this document.
59    schema: &'static str,
60    /// `preview` or `apply`.
61    mode: &'static str,
62    /// The target directory.
63    target: String,
64    /// What the project is.
65    profile: ProfileSnapshot,
66    /// How topic branches reach the trunk.
67    git: GitWorkflow,
68    /// Which optional products the target requested.
69    capabilities: CapabilityRequests,
70    /// The resolved project path, where detection or `--repo` named one.
71    #[serde(skip_serializing_if = "Option::is_none")]
72    repo: Option<String>,
73    /// Every capability, in catalog order, with its status.
74    selection: Vec<CapabilityNote>,
75    /// Why the selected release automation cannot land in this release,
76    /// absent where it can or none is selected. A preview reports it and
77    /// exits 0; the apply refuses on it.
78    #[serde(skip_serializing_if = "Option::is_none")]
79    release_unavailable: Option<String>,
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            nix: args.nix_packaging.then_some(true),
163            reporting_policy: args.reporting_policy.then_some(true),
164            scorecard: args.scorecard.then_some(true),
165            code_scanning: args
166                .code_scanning
167                .as_deref()
168                .map(Provider::parse)
169                .transpose()?,
170            ..args.profile.inputs()?
171        },
172        config.as_ref(),
173        None,
174        if args.apply {
175            landing::Purpose::Init
176        } else {
177            landing::Purpose::Preview
178        },
179    )?;
180    if let Some(lock) = lock {
181        refuse_a_recorded_target(&held)?;
182        let prepared = apply::prepare(&held, None, &params, config.as_ref())?;
183        let landed = apply::land(&held, None, &prepared, apply::Origin::Init, &lock)?;
184        drop(lock);
185        report_apply(out, args, &held, &params, &prepared, &landed)
186    } else {
187        let prepared = apply::prepare(&held, None, &params, config.as_ref())?;
188        let repo = (params.repo() != landing::REPO_PLACEHOLDER && !params.repo().is_empty())
189            .then(|| params.repo().to_owned());
190        if params.repo() == landing::REPO_PLACEHOLDER {
191            out.frame(
192                "note: no repository detected; an apply derives the owner from --repo <path>",
193            );
194        }
195        preview(out, args, &params, repo, &prepared)
196    }
197}
198
199/// The capability notes a report carries, in catalog order.
200fn selection_of(prepared: &Prepared) -> Vec<CapabilityNote> {
201    prepared
202        .projection
203        .capabilities
204        .iter()
205        .map(|selection| CapabilityNote::of(selection, &prepared.projection))
206        .collect()
207}
208
209/// The human lines naming every capability's answer.
210pub(crate) fn describe_selection(out: Output, prepared: &Prepared) {
211    for note in selection_of(prepared) {
212        let mut line = format!("capability {}: {}", note.id, note.status);
213        if let Some(reason) = &note.reason {
214            line.push_str(" (");
215            line.push_str(reason);
216            line.push(')');
217        }
218        out.result_line(line);
219        if let Some(action) = &note.action {
220            out.result_line(format!("  action: {action}"));
221        }
222    }
223}
224
225/// List every destination with what a production landing would do, and
226/// write nothing.
227#[allow(
228    clippy::too_many_lines,
229    reason = "one pass prints the profile, the selection, every decision, and the follow-up, and splitting it would separate a line from the value behind it"
230)]
231fn preview(
232    out: Output,
233    args: &InitArgs,
234    params: &landing::Params,
235    repo: Option<String>,
236    prepared: &Prepared,
237) -> Result<(), RkError> {
238    let flags = params.canonical_flags();
239    let flags = if params.forge().is_some() && repo.is_none() {
240        format!("{flags} --repo <owner/name>")
241    } else {
242        flags
243    };
244    let capabilities = params.capability_flags();
245    let mut next = vec![format!(
246        "rk init{flags}{capabilities} --target {} --apply",
247        args.target
248    )];
249    if let Some(reason) = prepared.projection.release_unavailable() {
250        next.insert(
251            0,
252            format!("the apply refuses until the release automation resolves: {reason}"),
253        );
254    }
255    if !prepared.collisions.is_empty() {
256        next.insert(
257            0,
258            "resolve each collision above through the rk-setup skill; the apply refuses until then"
259                .to_owned(),
260        );
261    }
262    if let Some(reason) = prepared.projection.licence_refusal.as_deref() {
263        next.insert(
264            0,
265            format!("the apply refuses until the licence condition is answered: {reason}"),
266        );
267    }
268    next.push(format!(
269        "rk stage --target {} stages the complete candidate for a byte comparison",
270        args.target
271    ));
272    out.result_line(format!(
273        "DRY RUN: rk init writes these files into {}; re-run with --apply",
274        args.target
275    ));
276    out.result_line(format!(
277        "profile: {}",
278        crate::commands::profile::describe(
279            params.profile(),
280            params.git(),
281            params.capabilities(),
282            params.repo()
283        )
284    ));
285    describe_selection(out, prepared);
286    for decision in &prepared.decisions {
287        out.result_line(format!(
288            "{} {}",
289            decision.action.as_str(),
290            decision.destination
291        ));
292    }
293    for collision in &prepared.collisions {
294        out.result_line(format!(
295            "collision {}: {}",
296            collision.path, collision.reason
297        ));
298    }
299    out.result_line(format!(
300        "{} {}\n{}",
301        prepared.config.action,
302        crate::config::CONFIG_PATH,
303        prepared.config.content
304    ));
305    for entry in &prepared.projection.omissions {
306        out.result_line(format!("withheld {}: {}", entry.destination, entry.reason));
307        if let Some(action) = &entry.action {
308            out.result_line(format!("  action: {action}"));
309        }
310    }
311    out.next(&next);
312    out.emit(&Report {
313        schema: "rk.init/10",
314        config: prepared.config.clone(),
315        mode: "preview",
316        target: args.target.to_string(),
317        profile: params.profile().clone(),
318        git: params.git().clone(),
319        capabilities: params.capabilities().clone(),
320        repo,
321        selection: selection_of(prepared),
322        release_unavailable: prepared.projection.release_unavailable().map(str::to_owned),
323        licence_refusal: prepared.projection.licence_refusal.clone(),
324        withheld: withheld_of(prepared),
325        collisions: (!prepared.collisions.is_empty()).then(|| prepared.collisions.clone()),
326        files: prepared
327            .decisions
328            .iter()
329            .map(|decision| FileEntry {
330                path: decision.destination.clone(),
331                kind: decision.kind.as_str(),
332                action: decision.action.as_str(),
333            })
334            .chain(prepared.collisions.iter().map(|collision| FileEntry {
335                path: collision.path.clone(),
336                kind: landing::kind_of(&collision.path).map_or("unknown", landing::Kind::as_str),
337                action: "collision",
338            }))
339            .collect(),
340        sentinels: None,
341        next,
342    })
343}
344
345/// Report a landing the writer completed, with the judgment sentinels the
346/// operator still owes.
347#[allow(
348    clippy::too_many_arguments,
349    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"
350)]
351#[allow(
352    clippy::too_many_lines,
353    reason = "one pass reports the profile, the selection, every decision, and the sentinels the operator still owes"
354)]
355fn report_apply(
356    out: Output,
357    args: &InitArgs,
358    held: &apply::Held,
359    params: &landing::Params,
360    prepared: &Prepared,
361    landed: &apply::Landed,
362) -> Result<(), RkError> {
363    let mut file_entries = Vec::new();
364    let mut sentinels = Vec::new();
365    out.result_line(format!(
366        "profile: {}",
367        crate::commands::profile::describe(
368            params.profile(),
369            params.git(),
370            params.capabilities(),
371            params.repo()
372        )
373    ));
374    describe_selection(out, prepared);
375    for decision in &prepared.decisions {
376        out.result_line(describe(decision));
377        if decision.action != Action::Released {
378            let bytes =
379                landing::read_recorded(held.base(), &decision.destination)?.unwrap_or_default();
380            collect_sentinels(
381                held.display(),
382                &decision.destination,
383                &bytes,
384                &mut sentinels,
385            );
386        }
387        file_entries.push(FileEntry {
388            path: decision.destination.clone(),
389            kind: decision.kind.as_str(),
390            action: decision.action.as_str(),
391        });
392    }
393    for entry in &prepared.projection.omissions {
394        out.result_line(format!("withheld {}: {}", entry.destination, entry.reason));
395        if let Some(action) = &entry.action {
396            out.result_line(format!("  action: {action}"));
397        }
398    }
399    out.result_line(format!(
400        "{} {}",
401        prepared.config.action,
402        crate::config::CONFIG_PATH
403    ));
404    for (key, empty_line) in [
405        ("setup.required_check", "required_check = \"\""),
406        ("setup.bot.app_id", "app_id = \"\""),
407    ] {
408        if let Some((index, _)) = prepared
409            .config
410            .content
411            .lines()
412            .enumerate()
413            .find(|(_, line)| line.starts_with(empty_line))
414        {
415            sentinels.push(SentinelEntry {
416                path: crate::config::CONFIG_PATH.into(),
417                line: index + 1,
418                text: format!("set {key} before forge setup"),
419            });
420        }
421    }
422    out.result_line(format!("wrote {}", manifest::MANIFEST_PATH));
423    debug_assert_eq!(
424        landed.completed.last().map(String::as_str),
425        Some(manifest::MANIFEST_PATH)
426    );
427
428    if sentinels.is_empty() {
429        out.result_line("no sentinels to fill");
430    } else {
431        out.result_line("fill these sentinels before the workflow runs:");
432        for sentinel in &sentinels {
433            out.result_line(format!(
434                "{}:{}: {}",
435                sentinel.path, sentinel.line, sentinel.text
436            ));
437        }
438    }
439    let mut next = vec![
440        if sentinels.is_empty() {
441            "commit the landed files, the receipt included".to_owned()
442        } else {
443            "fill each sentinel above, then commit the landed files, the receipt included"
444                .to_owned()
445        },
446        format!("rk status --target {} reports this landing", args.target),
447    ];
448    // Only an automatic release routes to the bot-operate chapter; an
449    // external or none release has no bot to operate.
450    if params.release_mode() == crate::profile::ReleaseMode::Automatic {
451        next.push("rk method setup orders what follows".to_owned());
452    } else if params.forge().is_some() {
453        next.push("rk setup --target . previews the applicable forge steps".to_owned());
454    }
455    out.next(&next);
456    out.emit(&Report {
457        schema: "rk.init/10",
458        config: prepared.config.clone(),
459        mode: "apply",
460        target: args.target.to_string(),
461        profile: params.profile().clone(),
462        git: params.git().clone(),
463        capabilities: params.capabilities().clone(),
464        repo: (!params.repo().is_empty()).then(|| params.repo().to_owned()),
465        selection: selection_of(prepared),
466        release_unavailable: None,
467        licence_refusal: prepared.projection.licence_refusal.clone(),
468        withheld: withheld_of(prepared),
469        collisions: None,
470        files: file_entries,
471        sentinels: Some(sentinels),
472        next,
473    })
474}
475
476/// The human line for one decision.
477pub(crate) fn describe(decision: &apply::Decision) -> String {
478    match decision.action {
479        Action::Preserved | Action::Drift => format!(
480            "{} {} ({}, target-owned)",
481            decision.action.as_str(),
482            decision.destination,
483            decision.kind.as_str()
484        ),
485        Action::Released => format!(
486            "released {} (no longer produced; target-owned from this landing)",
487            decision.destination
488        ),
489        Action::Matched => format!(
490            "matched {} (already holds the candidate's bytes)",
491            decision.destination
492        ),
493        Action::Created | Action::Replaced => {
494            format!("{} {}", decision.action.as_str(), decision.destination)
495        }
496    }
497}
498
499/// A re-landing over an existing receipt is `rk upgrade`'s job, not a
500/// second `rk init`.
501fn refuse_a_recorded_target(held: &apply::Held) -> Result<(), RkError> {
502    if landing::manifest::load(held.base())?.is_none() {
503        return Ok(());
504    }
505    let target = held.display();
506    Err(RkError::refusal(
507        Diagnostic::new(
508            Reason::StateDrift,
509            format!(
510                "{target} already carries {}, and nothing was written",
511                manifest::MANIFEST_PATH
512            ),
513        )
514        .expected("a target without a landing receipt")
515        .action(format!(
516            "rk upgrade --target {target} takes it to this binary's projection"
517        ))
518        .target_state("unchanged"),
519    ))
520}
521
522/// Collect every judgment-sentinel line one landed file carries, so
523/// nothing stays half-configured silently.
524fn collect_sentinels(
525    target: &Utf8Path,
526    destination: &str,
527    bytes: &[u8],
528    found: &mut Vec<SentinelEntry>,
529) {
530    let text = String::from_utf8_lossy(bytes);
531    for (idx, line) in text.lines().enumerate() {
532        if line.contains(embedded::SENTINEL) {
533            found.push(SentinelEntry {
534                path: target.join(destination).to_string(),
535                line: idx + 1,
536                text: line.trim().to_owned(),
537            });
538        }
539    }
540}
541
542#[cfg(test)]
543mod tests {
544    use super::{FileEntry, Report, SentinelEntry};
545    use crate::landing::CheckoutMode;
546    use crate::landing::Integration;
547    use crate::profile::{
548        CapabilityRequests, GitWorkflow, ProfileSnapshot, ReleaseIntent, ReleaseMode,
549    };
550    use crate::stage::CapabilityNote;
551
552    /// The complete `rk.init/10` shape, held by snapshot in both modes: a
553    /// field rename or removal fails here and becomes a schema-version
554    /// bump instead of a silent parser break at some agent.
555    #[test]
556    fn the_init_report_schema_snapshot_holds() {
557        let apply = Report {
558            schema: "rk.init/10",
559            config: crate::config::Plan {
560                action: "added",
561                changes: vec![],
562                content: "schema_version = 2\n".into(),
563            },
564            mode: "apply",
565            target: "/tmp/t".into(),
566            profile: ProfileSnapshot {
567                technologies: vec!["rust".into()],
568                forge: Some("github".into()),
569                release: ReleaseIntent {
570                    mode: ReleaseMode::Automatic,
571                    driver: Some("rust".into()),
572                    style: Some(crate::landing::Style::Trunk),
573                    line_prefix: Some("release/".into()),
574                },
575            },
576            git: GitWorkflow {
577                trunk: "master".into(),
578                checkout_mode: CheckoutMode::LinkedWorktree,
579                integration: Integration::Local,
580            },
581            capabilities: CapabilityRequests {
582                nix_packaging: true,
583                reporting_policy: true,
584                scorecard: true,
585                code_scanning: Some(crate::landing::Provider::CodeQl),
586            },
587            repo: Some("acme/widget".into()),
588            selection: vec![CapabilityNote {
589                id: "git.guards".into(),
590                status: "selected".into(),
591                reason: None,
592                action: None,
593                destinations: vec!["AGENTS.md".into()],
594            }],
595            release_unavailable: None,
596            licence_refusal: None,
597            withheld: Some(vec![crate::landing::Withheld {
598                path: "flake.nix".into(),
599                reason: "the target already carries flake.nix".into(),
600            }]),
601            collisions: None,
602            files: vec![FileEntry {
603                path: "release-plz.toml".into(),
604                kind: "seeded",
605                action: "created",
606            }],
607            sentinels: Some(vec![SentinelEntry {
608                path: "/tmp/t/release-plz.toml".into(),
609                line: 3,
610                text: "# TODO(release-kit): keep false for a binary-only crate".into(),
611            }]),
612            next: vec!["commit the landed files, the receipt included".into()],
613        };
614        assert_eq!(
615            serde_json::to_string(&apply).expect("a report serializes"),
616            r##"{"schema":"rk.init/10","mode":"apply","target":"/tmp/t","profile":{"technologies":["rust"],"forge":"github","release":{"mode":"automatic","driver":"rust","style":"trunk","line_prefix":"release/"}},"git":{"trunk":"master","checkout_mode":"linked-worktree","integration":"local"},"capabilities":{"nix_packaging":true,"reporting_policy":true,"scorecard":true,"code_scanning":"codeql"},"repo":"acme/widget","selection":[{"id":"git.guards","status":"selected","destinations":["AGENTS.md"]}],"withheld":[{"path":"flake.nix","reason":"the target already carries flake.nix"}],"config":{"action":"added","changes":[],"content":"schema_version = 2\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"]}"##
617        );
618        let preview = Report {
619            sentinels: None,
620            repo: None,
621            mode: "preview",
622            capabilities: CapabilityRequests {
623                nix_packaging: false,
624                reporting_policy: false,
625                scorecard: false,
626                code_scanning: None,
627            },
628            selection: vec![],
629            release_unavailable: Some(
630                "the release automation at (python, gitlab) has no landable files".to_owned(),
631            ),
632            licence_refusal: Some("the target's Cargo.toml declares no license field".to_owned()),
633            withheld: None,
634            collisions: Some(vec![super::Collision {
635                path: "SECURITY.md".into(),
636                reason: "exists, and no receipt attributes it to release-kit".into(),
637            }]),
638            ..apply
639        };
640        assert_eq!(
641            serde_json::to_string(&preview).expect("a report serializes"),
642            r#"{"schema":"rk.init/10","mode":"preview","target":"/tmp/t","profile":{"technologies":["rust"],"forge":"github","release":{"mode":"automatic","driver":"rust","style":"trunk","line_prefix":"release/"}},"git":{"trunk":"master","checkout_mode":"linked-worktree","integration":"local"},"capabilities":{"nix_packaging":false,"reporting_policy":false,"scorecard":false},"selection":[],"release_unavailable":"the release automation at (python, gitlab) has no landable files","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 = 2\n"},"files":[{"path":"release-plz.toml","kind":"seeded","action":"created"}],"next":["commit the landed files, the receipt included"]}"#,
643            "a preview omits the sentinels, the unresolved repo, and an empty withheld list rather than serializing null"
644        );
645    }
646}