Skip to main content

usage/spec/
effect.rs

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