1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
//! Errors a wrapped command's declaration raises against a call.
//!
//! Every variant is a refusal to run: the declaration does not describe the
//! call, so nothing spawns. All of them exit 2 ([`WrappedError::exit_code`]).
//! `Display` renders the whole line the agent sees, command prefix included,
//! so a caller writes the message straight to stderr.
use std::path::PathBuf;
/// A call the declaration refuses.
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[non_exhaustive]
pub enum WrappedError {
/// The first word names no declared verb.
#[error("{command}: unknown verb '{word}'. Allowed: {}", allowed_list(allowed))]
UnknownVerb {
/// The wrapped command's name.
command: String,
/// The word that named no verb.
word: String,
/// Every declared verb name, sorted.
allowed: Vec<String>,
},
/// The command declares verbs and no root, and the call named none.
#[error("{command}: no verb given. Allowed: {}", allowed_list(allowed))]
MissingVerb {
/// The wrapped command's name.
command: String,
/// Every declared verb name, sorted.
allowed: Vec<String>,
},
/// A node (a verb with children) was called with no leaf to run it. A
/// node only selects among its children; it is never itself callable.
#[error("{command}: '{scope}' needs a verb. Allowed: {}", allowed_list(allowed))]
BareNode {
/// The wrapped command's name.
command: String,
/// The node's full path, e.g. `git worktree`.
scope: String,
/// The node's own children, sorted.
allowed: Vec<String>,
},
/// The word after a node names none of its children.
#[error("{command}: unknown verb '{word}' for '{scope}'. Allowed: {}", allowed_list(allowed))]
UnknownChildVerb {
/// The wrapped command's name.
command: String,
/// The node's full path, e.g. `git worktree`.
scope: String,
/// The word that named no child.
word: String,
/// The node's own children, sorted — never the top level's.
allowed: Vec<String>,
},
/// A word in flag position names no declared flag or alias.
#[error("{command}: unknown flag '{word}' for '{scope}'. Allowed: {}", allowed_list(allowed))]
UnknownFlag {
/// The wrapped command's name.
command: String,
/// `git log` for a named verb, `python` for the root verb.
scope: String,
/// The word that named no flag.
word: String,
/// Every declared flag, sorted by name and spelled `-n/--max-count`.
allowed: Vec<String>,
},
/// Short flags were written as one word (`-sv`).
#[error("{command}: '{word}' is not a flag for '{scope}'. Use {separated}.")]
ClusteredShort {
/// The wrapped command's name.
command: String,
/// `git status` for a named verb, `python` for the root verb.
scope: String,
/// The word as written.
word: String,
/// The same flags as separate words (`-s -v`).
separated: String,
},
/// A short flag and its value were written as one word (`-n5`).
#[error("{command}: '{word}' is not a flag for '{scope}'. Use {separated}.")]
GluedShortValue {
/// The wrapped command's name.
command: String,
/// `git log` for a named verb, `python` for the root verb.
scope: String,
/// The word as written.
word: String,
/// The flag and its value as separate words (`-n 5`).
separated: String,
},
/// A value flag ended the argv with nothing to bind.
#[error("{command}: '{flag}' needs a value for '{scope}'.")]
MissingFlagValue {
/// The wrapped command's name.
command: String,
/// `git log` for a named verb, `python` for the root verb.
scope: String,
/// The flag as written.
flag: String,
},
/// A switch was written with an attached value (`--oneline=1`).
#[error("{command}: '{flag}' takes no value for '{scope}'.")]
UnexpectedFlagValue {
/// The wrapped command's name.
command: String,
/// `git log` for a named verb, `python` for the root verb.
scope: String,
/// The flag as written.
flag: String,
},
/// A flag the declaration did not mark repeatable appeared twice.
#[error("{command}: '{flag}' given more than once for '{scope}'.")]
RepeatedFlag {
/// The wrapped command's name.
command: String,
/// `git log` for a named verb, `python` for the root verb.
scope: String,
/// The flag's declared name, written as the child sees it.
flag: String,
},
/// A word filled no declared slot and the verb denies a tail.
#[error("{command}: unexpected argument '{word}'")]
UnexpectedArgument {
/// The wrapped command's name.
command: String,
/// The word that filled no slot.
word: String,
},
/// A word filled no declared slot, and the verb takes undescribed argv
/// only past the `--` the agent writes.
#[error("{command}: unexpected argument '{word}' for '{scope}'. Write -- before arguments meant for the program.")]
UndeclaredPositional {
/// The wrapped command's name.
command: String,
/// `cargo test` for a named verb, `python` for the root verb.
scope: String,
/// The word that filled no slot.
word: String,
},
/// A required flag was absent.
#[error("{command}: required flag '{flag}' not given for '{scope}'.")]
MissingRequiredFlag {
/// The wrapped command's name.
command: String,
/// `git commit` for a named verb, `python` for the root verb.
scope: String,
/// The flag's declared name, written as the child sees it.
flag: String,
},
/// A required positional was absent.
#[error("{command}: required argument '{positional}' not given for '{scope}'.")]
MissingRequiredPositional {
/// The wrapped command's name.
command: String,
/// `git push` for a named verb, `python` for the root verb.
scope: String,
/// The positional's declared name.
positional: String,
},
/// An `int()` flag's value does not parse as an `i64`.
#[error("{command}: '{flag}' takes an integer. Got '{value}'.")]
NotAnInteger {
/// The wrapped command's name.
command: String,
/// The flag's declared name, written as the child sees it.
flag: String,
/// The value as written.
value: String,
},
/// A `choices()` flag's value is outside the set.
#[error("{command}: '{flag}' must be one of: {}. Got '{value}'.", choices.join(", "))]
NotInChoices {
/// The wrapped command's name.
command: String,
/// The flag's declared name, written as the child sees it.
flag: String,
/// The value as written.
value: String,
/// The declared set, in declaration order.
choices: Vec<String>,
},
/// A `path_under()` positional resolved outside its root.
#[error("{command}: '{positional}' must be under {}. Got '{value}'.", root.display())]
PathOutsideRoot {
/// The wrapped command's name.
command: String,
/// The positional's declared name.
positional: String,
/// The declared root.
root: PathBuf,
/// The value as written.
value: String,
},
/// A `path_under()` root does not resolve on the real filesystem, so
/// containment cannot be proven either way.
#[error("{command}: '{positional}' must be under {}, which does not resolve.", root.display())]
PathRootUnresolvable {
/// The wrapped command's name.
command: String,
/// The positional's declared name.
positional: String,
/// The declared root.
root: PathBuf,
},
/// A word carries a NUL byte, which no child's argv can hold.
///
/// The declaration refuses the call here rather than at spawn: the parse
/// runs before anything is created, and a refusal that early cannot
/// truncate the value on its way to the child.
#[error("{command}: argument {position} contains a NUL byte. argv cannot carry NUL; remove it.")]
NulByte {
/// The wrapped command's name.
command: String,
/// 1-based index among the words the tool received.
position: usize,
},
/// A word is binary, which does not cross the argv text boundary.
#[error("{command}: argument {position} is binary ({byte_len} bytes). argv carries text; encode it or write it to a file.")]
BinaryArgument {
/// The wrapped command's name.
command: String,
/// 1-based index among the words the tool received.
position: usize,
/// How many bytes it held. Never the bytes themselves.
byte_len: usize,
},
}
impl WrappedError {
/// Exit code for every refusal: 2, the shell's usage-error code. Nothing
/// spawned, so the child's own codes are not in play.
pub fn exit_code(&self) -> i64 {
2
}
/// The wrapped command this refusal is about. Empty only for a path
/// failure that `attributed_to` has not filled in yet.
pub fn command(&self) -> &str {
match self {
WrappedError::UnknownVerb { command, .. }
| WrappedError::MissingVerb { command, .. }
| WrappedError::BareNode { command, .. }
| WrappedError::UnknownChildVerb { command, .. }
| WrappedError::UnknownFlag { command, .. }
| WrappedError::ClusteredShort { command, .. }
| WrappedError::GluedShortValue { command, .. }
| WrappedError::MissingFlagValue { command, .. }
| WrappedError::UnexpectedFlagValue { command, .. }
| WrappedError::RepeatedFlag { command, .. }
| WrappedError::UnexpectedArgument { command, .. }
| WrappedError::UndeclaredPositional { command, .. }
| WrappedError::MissingRequiredFlag { command, .. }
| WrappedError::MissingRequiredPositional { command, .. }
| WrappedError::NotAnInteger { command, .. }
| WrappedError::NotInChoices { command, .. }
| WrappedError::PathOutsideRoot { command, .. }
| WrappedError::PathRootUnresolvable { command, .. }
| WrappedError::NulByte { command, .. }
| WrappedError::BinaryArgument { command, .. } => command,
}
}
/// Fill in the command and positional a bare path failure belongs to.
///
/// [`crate::tools::wrapped::path_is_under`]'s companion
/// [`crate::tools::wrapped::resolve_under`] is a general helper: it knows
/// the value and the root, not which declaration asked. The caller
/// attributes the failure before it reaches an agent.
pub(crate) fn attributed_to(mut self, command: &str, positional: &str) -> Self {
match &mut self {
WrappedError::PathOutsideRoot {
command: c,
positional: p,
..
}
| WrappedError::PathRootUnresolvable {
command: c,
positional: p,
..
} => {
c.clear();
c.push_str(command);
p.clear();
p.push_str(positional);
}
_ => {}
}
self
}
}
/// Render an allowed set for an error message: comma-joined, or `(none)` when
/// the declaration allows nothing here. Deny-by-default makes the empty set
/// common enough that it needs a spelling of its own.
fn allowed_list(allowed: &[String]) -> String {
if allowed.is_empty() {
"(none)".to_string()
} else {
allowed.join(", ")
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn unknown_verb_reads_as_the_declaration_documents() {
let error = WrappedError::UnknownVerb {
command: "git".into(),
word: "comit".into(),
allowed: vec![
"commit".into(),
"diff".into(),
"log".into(),
"push".into(),
"status".into(),
],
};
assert_eq!(
error.to_string(),
"git: unknown verb 'comit'. Allowed: commit, diff, log, push, status"
);
}
#[test]
fn unknown_flag_names_the_scope_and_the_allowed_set() {
let error = WrappedError::UnknownFlag {
command: "git".into(),
scope: "git log".into(),
word: "--output".into(),
allowed: vec!["-n/--max-count".into(), "--oneline".into(), "--since".into()],
};
assert_eq!(
error.to_string(),
"git: unknown flag '--output' for 'git log'. Allowed: -n/--max-count, --oneline, --since"
);
}
#[test]
fn an_empty_allowed_set_reads_as_none() {
let error = WrappedError::UnknownFlag {
command: "python".into(),
scope: "python".into(),
word: "-c".into(),
allowed: Vec::new(),
};
assert_eq!(
error.to_string(),
"python: unknown flag '-c' for 'python'. Allowed: (none)"
);
}
#[test]
fn every_refusal_exits_two() {
let error = WrappedError::UnexpectedArgument {
command: "cargo".into(),
word: "extra".into(),
};
assert_eq!(error.exit_code(), 2);
assert_eq!(error.to_string(), "cargo: unexpected argument 'extra'");
}
#[test]
fn attribution_fills_a_bare_path_failure() {
let error = WrappedError::PathOutsideRoot {
command: String::new(),
positional: String::new(),
root: PathBuf::from("/opt/app/scripts"),
value: "/etc/passwd".into(),
}
.attributed_to("python", "script");
assert_eq!(
error.to_string(),
"python: 'script' must be under /opt/app/scripts. Got '/etc/passwd'."
);
}
}