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}