Skip to main content

release_kit/landing/
gate.rs

1//! The version gate: every command's class, and the check a gated command
2//! makes against its target's landing record before its handler runs.
3//!
4//! [`CLASSES`] is the one table of every action and its class. [`classify`]
5//! resolves a parsed command to its class, its effect, and its target, and
6//! [`require_aligned`] judges the record against this binary. The gate
7//! never writes, never moves a pin, and never runs `init` or `upgrade` by
8//! itself.
9//!
10//! SATISFIES version-gate:every-command-has-a-gate-class
11
12use camino::{Utf8Path, Utf8PathBuf};
13
14use super::manifest::{self, Alignment};
15use crate::cli::Commands;
16use crate::cli::branches::BranchesAction;
17use crate::cli::issue::IssueAction;
18use crate::cli::lines::LinesAction;
19use crate::cli::migrate::MigrateAction;
20use crate::cli::release::ReleaseAction;
21use crate::cli::setup::SetupAction;
22use crate::cli::stage::StageAction;
23use crate::cli::worktree::WorktreeAction;
24use crate::diagnostic::{Diagnostic, Reason};
25use crate::error::RkError;
26
27/// The class of one action.
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub enum Class {
30    /// Needs no target, or must never fail: never reads the record.
31    Exempt,
32    /// Runs on a target with no record by design. On a target with a
33    /// record it follows its own effect.
34    PreLanding,
35    /// `upgrade` alone, which keeps its own checks.
36    Upgrade,
37    /// Reads a landed target and writes nothing.
38    ReadOnly,
39    /// Writes a landed target or its forge.
40    Writing,
41}
42
43impl Class {
44    /// The table form.
45    #[must_use]
46    pub const fn as_str(self) -> &'static str {
47        match self {
48            Self::Exempt => "exempt",
49            Self::PreLanding => "pre-landing",
50            Self::Upgrade => "upgrade",
51            Self::ReadOnly => "read-only",
52            Self::Writing => "writing",
53        }
54    }
55}
56
57/// Every action of every command family and its class.
58///
59/// A key is the space-joined path of subcommand names. A writing action's
60/// preview is read-only, and a pre-landing action follows its own effect
61/// on a target with a record.
62pub const CLASSES: &[(&str, Class)] = &[
63    ("method", Class::Exempt),
64    ("binding", Class::Exempt),
65    ("snippet", Class::Exempt),
66    ("guide", Class::Exempt),
67    ("forge", Class::Exempt),
68    ("versions", Class::Exempt),
69    ("init", Class::PreLanding),
70    ("profile", Class::PreLanding),
71    ("status", Class::Exempt),
72    ("upgrade", Class::Upgrade),
73    ("adopt", Class::PreLanding),
74    ("assess", Class::PreLanding),
75    ("stage", Class::PreLanding),
76    ("stage clean", Class::Exempt),
77    ("stage compare", Class::PreLanding),
78    ("setup", Class::PreLanding),
79    ("setup check", Class::PreLanding),
80    ("setup checkpoint", Class::PreLanding),
81    ("setup status", Class::PreLanding),
82    ("setup step", Class::PreLanding),
83    ("setup script", Class::Exempt),
84    ("branches prune", Class::Writing),
85    ("lines list", Class::ReadOnly),
86    ("lines open", Class::Writing),
87    ("lines mint", Class::Writing),
88    ("lines rc", Class::ReadOnly),
89    ("lines retire", Class::Writing),
90    ("release status", Class::ReadOnly),
91    ("release merge", Class::Writing),
92    ("release request", Class::Writing),
93    ("release build", Class::Writing),
94    ("release eligible", Class::ReadOnly),
95    ("release publish", Class::Writing),
96    ("integrate", Class::Writing),
97    ("message", Class::ReadOnly),
98    ("migrate inventory", Class::PreLanding),
99    ("migrate archive", Class::PreLanding),
100    ("migrate mark", Class::PreLanding),
101    ("migrate status", Class::PreLanding),
102    ("migrate close", Class::PreLanding),
103    ("worktree list", Class::ReadOnly),
104    ("worktree add", Class::Writing),
105    ("worktree prune", Class::Writing),
106    ("issue start", Class::Writing),
107    ("runs", Class::Exempt),
108    ("skill", Class::Exempt),
109    ("self-depend", Class::Exempt),
110    ("doctor", Class::Exempt),
111    ("usage", Class::Exempt),
112    ("license", Class::Exempt),
113    ("completions", Class::Exempt),
114];
115
116/// What a gated action does to its target, which decides its row.
117#[derive(Debug, Clone, Copy, PartialEq, Eq)]
118pub enum Effect {
119    /// Reads, or previews a write.
120    ReadOnly,
121    /// Writes the target or its forge.
122    Writing,
123}
124
125/// One gated action: its class, its effect, and the target it judges.
126#[derive(Debug, Clone, PartialEq, Eq)]
127pub struct Gated {
128    /// The class the table gives it.
129    pub class: Class,
130    /// What this invocation does.
131    pub effect: Effect,
132    /// The repository whose record the gate reads.
133    pub target: Utf8PathBuf,
134}
135
136const fn effect(apply: bool) -> Effect {
137    if apply {
138        Effect::Writing
139    } else {
140        Effect::ReadOnly
141    }
142}
143
144#[allow(
145    clippy::unnecessary_wraps,
146    reason = "every arm of classify answers an Option, and this builds the Some arm"
147)]
148fn gated(class: Class, effect: Effect, target: &Utf8Path) -> Option<Gated> {
149    Some(Gated {
150        class,
151        effect,
152        target: target.to_owned(),
153    })
154}
155
156/// The class, effect, and target of one parsed command, or `None` for an
157/// action the gate never judges: an exempt action, `upgrade`, and an
158/// action with no target.
159#[must_use]
160pub fn classify(command: &Commands) -> Option<Gated> {
161    use Class::{PreLanding, ReadOnly, Writing};
162    use Effect::{ReadOnly as Reads, Writing as Writes};
163    match command {
164        Commands::Method(_)
165        | Commands::Binding(_)
166        | Commands::Snippet(_)
167        | Commands::Guide(_)
168        | Commands::Forge(_)
169        | Commands::Versions(_)
170        | Commands::Status(_)
171        | Commands::Upgrade(_)
172        | Commands::Runs(_)
173        | Commands::Skill(_)
174        | Commands::SelfDepend(_)
175        | Commands::Doctor(_)
176        | Commands::Usage
177        | Commands::License
178        | Commands::Completions(_) => None,
179        Commands::Init(args) => gated(PreLanding, effect(args.apply), &args.target),
180        Commands::Adopt(args) => gated(PreLanding, effect(args.apply), &args.target),
181        Commands::Profile(args) => gated(PreLanding, Reads, &args.target),
182        Commands::Assess(args) => gated(PreLanding, Reads, &args.target),
183        Commands::Stage(args) => match &args.action {
184            None => gated(PreLanding, Reads, &args.target),
185            Some(StageAction::Clean { .. }) => None,
186            Some(StageAction::Compare { target, .. }) => gated(PreLanding, Reads, target),
187        },
188        Commands::Setup(args) => match &args.action {
189            None if args.list => None,
190            None => args
191                .target
192                .as_deref()
193                .and_then(|target| gated(PreLanding, effect(args.apply), target)),
194            Some(SetupAction::Check { target, .. } | SetupAction::Status { target, .. }) => {
195                gated(PreLanding, Reads, target)
196            }
197            Some(SetupAction::Checkpoint { target, .. }) => gated(PreLanding, Writes, target),
198            Some(SetupAction::Step { target, apply, .. }) => {
199                gated(PreLanding, effect(*apply), target)
200            }
201            Some(SetupAction::Script { .. }) => None,
202        },
203        Commands::Migrate(args) => match &args.action {
204            MigrateAction::Inventory { target, .. } | MigrateAction::Status { target, .. } => {
205                gated(PreLanding, Reads, target)
206            }
207            MigrateAction::Archive { target, apply, .. }
208            | MigrateAction::Close { target, apply, .. } => {
209                gated(PreLanding, effect(*apply), target)
210            }
211            MigrateAction::Mark { target, .. } => gated(PreLanding, Writes, target),
212        },
213        Commands::Branches(args) => match &args.action {
214            BranchesAction::Prune { target, apply, .. } => gated(Writing, effect(*apply), target),
215        },
216        Commands::Lines(args) => match &args.action {
217            LinesAction::List { target, .. } | LinesAction::Rc { target, .. } => {
218                gated(ReadOnly, Reads, target)
219            }
220            LinesAction::Open { target, apply, .. }
221            | LinesAction::Mint { target, apply, .. }
222            | LinesAction::Retire { target, apply, .. } => gated(Writing, effect(*apply), target),
223        },
224        Commands::Release(args) => match &args.action {
225            ReleaseAction::Status { target, .. } | ReleaseAction::Eligible { target, .. } => {
226                gated(ReadOnly, Reads, target)
227            }
228            ReleaseAction::Merge { target, apply, .. } => gated(Writing, effect(*apply), target),
229            ReleaseAction::Request { target, .. } | ReleaseAction::Publish { target, .. } => {
230                gated(Writing, Writes, target)
231            }
232            ReleaseAction::Build { target, list, .. } => gated(Writing, effect(!*list), target),
233        },
234        Commands::Integrate(args) => gated(Writing, effect(args.apply), &args.target),
235        Commands::Message(args) => gated(ReadOnly, Reads, &args.target),
236        Commands::Worktree(args) => match &args.action {
237            WorktreeAction::List { target, .. } => gated(ReadOnly, Reads, target),
238            WorktreeAction::Add { target, apply, .. }
239            | WorktreeAction::Prune { target, apply, .. } => gated(Writing, effect(*apply), target),
240        },
241        Commands::Issue(args) => match &args.action {
242            IssueAction::Start { target, apply, .. } => gated(Writing, effect(*apply), target),
243        },
244    }
245}
246
247/// Judge `command`'s target record against this binary before its handler
248/// runs, by the rows of `version-gate:a-gated-command-names-its-fix`.
249///
250/// SATISFIES version-gate:a-gated-command-names-its-fix
251///
252/// # Errors
253///
254/// A refusal with the reason `StateDrift` and exit 73 naming the fix, and
255/// the record's own taxonomy for a record that does not load.
256pub fn require_aligned(command: &Commands) -> Result<(), RkError> {
257    let Some(gated) = classify(command) else {
258        return Ok(());
259    };
260    // A target that is no directory is the handler's to report, under its
261    // own reason: the gate judges a record, and there is no place for one.
262    if !gated.target.is_dir() {
263        return Ok(());
264    }
265    // A record that does not load is the handler's to report, under the
266    // record's own taxonomy and after any lock the handler takes first.
267    let Ok(loaded) = manifest::load(&gated.target) else {
268        return Ok(());
269    };
270    let Some(record) = loaded else {
271        if gated.class == Class::PreLanding {
272            return Ok(());
273        }
274        return Err(no_record(&gated.target));
275    };
276    let binary = env!("CARGO_PKG_VERSION");
277    let recorded = record.rk_version.as_str();
278    match manifest::alignment(recorded, binary) {
279        Alignment::Aligned => Ok(()),
280        Alignment::BinaryNewer => {
281            let pin = crate::self_depend::pinned_version(&gated.target);
282            let outside = pin
283                .as_deref()
284                .is_some_and(|pin| manifest::alignment(pin, binary) == Alignment::BinaryNewer);
285            if gated.effect == Effect::ReadOnly {
286                crate::output::Output::human().warn(binary_newer_warning(recorded, outside));
287                return Ok(());
288            }
289            Err(binary_newer(&gated.target, recorded, outside))
290        }
291        Alignment::TargetNewer => {
292            let pin = crate::self_depend::pinned_version(&gated.target)
293                .filter(|pin| manifest::alignment(recorded, pin) == Alignment::TargetNewer);
294            Err(record_newer(&gated.target, recorded, pin.as_deref()))
295        }
296    }
297}
298
299/// The run hint for the project's own rk.
300const OWN_RK: &str =
301    "run the rk of this project's environment, for example through `direnv exec . rk ...`";
302
303fn no_record(target: &Utf8Path) -> RkError {
304    RkError::refusal(
305        Diagnostic::new(
306            Reason::StateDrift,
307            format!("{target} has no release-kit landing, and this command reads one"),
308        )
309        .expected("a landing record at .release-kit/manifest.json")
310        .action(format!(
311            "run `rk init --target {target}` to land it. Read the setup skill with `rk skill show rk-setup`, and each file it names with `rk skill show rk-setup --file <path>`"
312        ))
313        .target_state("unchanged"),
314    )
315}
316
317fn binary_newer(target: &Utf8Path, recorded: &str, outside: bool) -> RkError {
318    let binary = env!("CARGO_PKG_VERSION");
319    let action = if outside {
320        format!(
321            "this rk comes from outside the project's environment, which pins rk {recorded}: {OWN_RK}. Where the project moved its pin to {binary}, run `rk upgrade --target {target} --apply` instead, then run this command again"
322        )
323    } else {
324        format!(
325            "run `rk upgrade --target {target} --apply` to move the landing, then run this command again. Where the project pins rk {recorded}, {OWN_RK}"
326        )
327    };
328    RkError::refusal(
329        Diagnostic::new(
330            Reason::StateDrift,
331            format!("the landing is at rk {recorded} and this rk is {binary}"),
332        )
333        .expected("a binary at the landing's rk_version")
334        .action(action)
335        .target_state("unchanged"),
336    )
337}
338
339fn binary_newer_warning(recorded: &str, outside: bool) -> String {
340    let binary = env!("CARGO_PKG_VERSION");
341    if outside {
342        format!(
343            "the landing is at rk {recorded} and this rk is {binary}, from outside the project's environment; {OWN_RK}, or run `rk upgrade` where the pin moved"
344        )
345    } else {
346        format!(
347            "the landing is at rk {recorded} and this rk is {binary}; run `rk upgrade` to move the landing"
348        )
349    }
350}
351
352fn record_newer(target: &Utf8Path, recorded: &str, pin: Option<&str>) -> RkError {
353    let binary = env!("CARGO_PKG_VERSION");
354    let mut action = OWN_RK.to_owned();
355    if let Some(pin) = pin {
356        action = format!("{action}. The pin of {target} names {pin}. Move it to {recorded}");
357    }
358    RkError::refusal(
359        Diagnostic::new(
360            Reason::StateDrift,
361            format!("the landing is at rk {recorded} and this rk is {binary}, which is older"),
362        )
363        .expected("a binary at the landing's rk_version")
364        .action(action)
365        .target_state("unchanged"),
366    )
367}
368
369#[cfg(test)]
370mod tests {
371    use super::CLASSES;
372    use clap::CommandFactory as _;
373
374    /// Every command path of the clap tree, as the space-joined names a
375    /// [`CLASSES`] key uses, leaving out a command whose subcommand is
376    /// required and the generated help.
377    fn paths(command: &clap::Command, prefix: &str, out: &mut Vec<String>) {
378        for sub in command.get_subcommands() {
379            if sub.get_name() == "help" {
380                continue;
381            }
382            let path = if prefix.is_empty() {
383                sub.get_name().to_owned()
384            } else {
385                format!("{prefix} {}", sub.get_name())
386            };
387            if !sub.is_subcommand_required_set() || sub.get_subcommands().next().is_none() {
388                out.push(path.clone());
389            }
390            paths(sub, &path, out);
391        }
392    }
393
394    /// VERIFIES version-gate:every-command-has-a-gate-class
395    ///
396    /// A new action without a class fails here, and so does a class whose
397    /// action is gone.
398    #[test]
399    fn every_action_has_a_gate_class() {
400        let mut all = Vec::new();
401        paths(&crate::cli::Cli::command(), "", &mut all);
402        let classed = |path: &str| {
403            let words: Vec<&str> = path.split(' ').collect();
404            (1..=words.len()).any(|end| {
405                let prefix = words[..end].join(" ");
406                CLASSES.iter().any(|(key, _)| *key == prefix)
407            })
408        };
409        for path in &all {
410            assert!(classed(path), "{path} has no gate class");
411        }
412        for (key, _) in CLASSES {
413            assert!(
414                all.iter().any(|path| path == key)
415                    || all.iter().any(|path| path.starts_with(&format!("{key} "))),
416                "{key} names no command"
417            );
418        }
419    }
420}