Skip to main content

usage/spec/
effect.rs

1use serde::Serialize;
2use strum::{Display as StrumDisplay, EnumString};
3
4/// What running a command does to the world.
5///
6/// This is a coarse, three-way classification rather than a permission model.
7/// It exists so a spec can distinguish "safe to run to find something out" from
8/// "changes state" from "may destroy something", which is the distinction
9/// consumers keep reinventing:
10///
11/// - documentation and `--help` can mark destructive commands
12/// - a shell wrapper can require confirmation
13/// - an AI coding agent can be given an allowlist of read-only commands rather
14///   than asking about every invocation
15///
16/// It is deliberately not inherited by subcommands. `git remote` and
17/// `git remote remove` do different things, and silently inheriting an effect
18/// from a parent would make the strictest reading of a spec the wrong one.
19///
20/// Flags and arguments may carry an effect too, for commands whose danger
21/// depends on how they are invoked — `pitchfork logs` reads, `pitchfork logs
22/// --clear` deletes. A flag or argument can only ever *raise* the effect, never
23/// lower it, so a consumer that cannot parse the invocation may fall back to
24/// the maximum over the command and all of its flags and arguments and still be
25/// safe. `--dry-run` is the tempting counterexample; lowering is the dangerous
26/// direction, because a bug in the dry-run path would then be a spec that
27/// claims a command is safe when it is not.
28/// Ordered least to most dangerous, so `Ord` gives the combining rule: the
29/// effect of an invocation is the maximum of the command's effect and the
30/// effect of every flag and argument actually supplied.
31#[derive(
32    Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, EnumString, StrumDisplay, Serialize,
33)]
34#[strum(serialize_all = "snake_case")]
35#[serde(rename_all = "snake_case")]
36pub enum SpecCommandEffect {
37    /// Only inspects state. Running it twice is the same as running it once,
38    /// and not running it changes nothing.
39    Read,
40    /// Creates or modifies state, but does not remove anything the user cannot
41    /// recreate by running another command.
42    Write,
43    /// May delete or irreversibly overwrite something. Deserves a confirmation
44    /// prompt.
45    Destructive,
46}
47
48impl SpecCommandEffect {
49    pub fn as_str(&self) -> &'static str {
50        match self {
51            Self::Read => "read",
52            Self::Write => "write",
53            Self::Destructive => "destructive",
54        }
55    }
56
57    /// Human-readable label used in generated documentation.
58    pub fn label(&self) -> &'static str {
59        match self {
60            Self::Read => "read-only",
61            Self::Write => "modifies state",
62            Self::Destructive => "destructive",
63        }
64    }
65}
66
67/// The set of values accepted by `effect=`, for error messages.
68pub(crate) const EFFECT_VALUES: &str = "read, write, destructive";
69
70impl crate::SpecCommand {
71    /// The effect of running this command with `flags` and `args` supplied,
72    /// as the maximum of the command's own effect and theirs.
73    ///
74    /// A flag contributes both its own effect and that of its value argument,
75    /// so `--output <file>` can declare the danger on either.
76    ///
77    /// Pass only what the command line actually supplied. Feeding in values
78    /// that came from defaults or the environment is safe — the result can
79    /// only be too high, never too low — but it will over-report.
80    ///
81    /// Returns `None` when nothing involved declares an effect, which means
82    /// "unknown" — consumers should treat that as "ask", not as safe.
83    pub fn effect_of<'a>(
84        &self,
85        flags: impl IntoIterator<Item = &'a crate::SpecFlag>,
86        args: impl IntoIterator<Item = &'a crate::SpecArg>,
87    ) -> Option<SpecCommandEffect> {
88        flags
89            .into_iter()
90            .flat_map(|f| [f.effect, f.arg.as_ref().and_then(|a| a.effect)])
91            .flatten()
92            .chain(args.into_iter().filter_map(|a| a.effect))
93            .chain(self.effect)
94            .max()
95    }
96
97    /// The worst effect any invocation of this command could have: its own
98    /// effect combined with *every* flag and argument it declares.
99    ///
100    /// This is the safe fallback for a consumer that has a spec but not a
101    /// parsed command line.
102    pub fn max_effect(&self) -> Option<SpecCommandEffect> {
103        self.effect_of(&self.flags, &self.args)
104    }
105}
106
107#[cfg(test)]
108mod tests {
109    use super::*;
110    use std::str::FromStr;
111
112    #[test]
113    fn test_parse() {
114        assert_eq!(
115            SpecCommandEffect::from_str("read").unwrap(),
116            SpecCommandEffect::Read
117        );
118        assert_eq!(
119            SpecCommandEffect::from_str("destructive").unwrap(),
120            SpecCommandEffect::Destructive
121        );
122        assert!(SpecCommandEffect::from_str("readonly").is_err());
123    }
124
125    #[test]
126    fn test_ordering_is_least_to_most_dangerous() {
127        assert!(SpecCommandEffect::Read < SpecCommandEffect::Write);
128        assert!(SpecCommandEffect::Write < SpecCommandEffect::Destructive);
129    }
130
131    #[test]
132    fn test_effect_of_takes_the_maximum() {
133        use crate::Spec;
134        let spec: Spec = r#"
135bin "pitchfork"
136cmd "logs" effect="read" {
137    flag "--clear" effect="destructive"
138    flag "--follow"
139    arg "[daemon]"
140}
141cmd "quiet" {
142    flag "--verbose"
143}
144        "#
145        .parse()
146        .unwrap();
147
148        let logs = &spec.cmd.subcommands["logs"];
149        let clear = logs.flags.iter().find(|f| f.name == "clear").unwrap();
150        let follow = logs.flags.iter().find(|f| f.name == "follow").unwrap();
151
152        // No flags supplied: just the command's own effect.
153        assert_eq!(
154            logs.effect_of(vec![], vec![]),
155            Some(SpecCommandEffect::Read)
156        );
157        // A flag with no effect of its own does not change anything.
158        assert_eq!(
159            logs.effect_of(vec![follow], vec![]),
160            Some(SpecCommandEffect::Read)
161        );
162        // A dangerous flag raises it, even though the command reads.
163        assert_eq!(
164            logs.effect_of(vec![clear], vec![]),
165            Some(SpecCommandEffect::Destructive)
166        );
167        // The pessimistic fallback assumes every flag was supplied.
168        assert_eq!(logs.max_effect(), Some(SpecCommandEffect::Destructive));
169
170        // Nothing declared anywhere stays unknown rather than becoming `read`.
171        assert_eq!(spec.cmd.subcommands["quiet"].max_effect(), None);
172    }
173
174    /// A flag's value argument can carry the effect instead of the flag, and
175    /// the pessimistic bound has to see it or it under-reports danger.
176    #[test]
177    fn test_effect_on_a_flag_value_arg_counts() {
178        use crate::Spec;
179        let spec: Spec = r#"
180bin "x"
181cmd "write-to" effect="read" {
182    flag "--output <file>" {
183        arg "<file>" effect="destructive"
184    }
185}
186        "#
187        .parse()
188        .unwrap();
189        let cmd = &spec.cmd.subcommands["write-to"];
190        let output = cmd.flags.iter().find(|f| f.name == "output").unwrap();
191        assert_eq!(
192            cmd.effect_of(vec![output], vec![]),
193            Some(SpecCommandEffect::Destructive)
194        );
195        assert_eq!(cmd.max_effect(), Some(SpecCommandEffect::Destructive));
196    }
197
198    #[test]
199    fn test_effect_on_an_arg_raises_too() {
200        use crate::Spec;
201        // `mise settings foo` reads; `mise settings foo=bar` writes, and the
202        // discriminator is a positional rather than a flag.
203        let spec: Spec = r#"
204bin "mise"
205cmd "settings" effect="read" {
206    arg "[setting]"
207    arg "[value]" effect="write"
208}
209        "#
210        .parse()
211        .unwrap();
212        let cmd = &spec.cmd.subcommands["settings"];
213        let value = cmd.args.iter().find(|a| a.name == "value").unwrap();
214        assert_eq!(cmd.effect_of(vec![], vec![]), Some(SpecCommandEffect::Read));
215        assert_eq!(
216            cmd.effect_of(vec![], vec![value]),
217            Some(SpecCommandEffect::Write)
218        );
219    }
220
221    #[test]
222    fn test_unknown_effect_on_a_flag_is_an_error() {
223        use crate::Spec;
224        let err = r#"
225bin "x"
226cmd "y" {
227    flag "--z" effect="readonly"
228}
229        "#
230        .parse::<Spec>()
231        .unwrap_err();
232        assert!(err.to_string().contains("Invalid usage config"));
233    }
234
235    #[test]
236    fn test_display_roundtrips() {
237        for effect in [
238            SpecCommandEffect::Read,
239            SpecCommandEffect::Write,
240            SpecCommandEffect::Destructive,
241        ] {
242            assert_eq!(
243                SpecCommandEffect::from_str(&effect.to_string()).unwrap(),
244                effect
245            );
246            assert_eq!(effect.to_string(), effect.as_str());
247        }
248    }
249}