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}