Skip to main content

agent_first_data/cli_spec/
error.rs

1use serde::Serialize;
2
3/// Stable closed set of invocation-error rules.
4#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
5#[serde(rename_all = "snake_case")]
6pub enum CliErrorRule {
7    InvalidUtf8,
8    UnknownCommand,
9    UnknownArgument,
10    MissingArgumentValue,
11    InvalidArgumentValue,
12    DuplicateArgument,
13    UnexpectedPositional,
14    UnregisteredCombination,
15}
16
17impl CliErrorRule {
18    pub const fn as_str(self) -> &'static str {
19        match self {
20            Self::InvalidUtf8 => "invalid_utf8",
21            Self::UnknownCommand => "unknown_command",
22            Self::UnknownArgument => "unknown_argument",
23            Self::MissingArgumentValue => "missing_argument_value",
24            Self::InvalidArgumentValue => "invalid_argument_value",
25            Self::DuplicateArgument => "duplicate_argument",
26            Self::UnexpectedPositional => "unexpected_positional",
27            Self::UnregisteredCombination => "unregistered_combination",
28        }
29    }
30
31    /// The `error.code` this failure is reported under.
32    ///
33    /// The classification belongs in the code, beside `document_type_mismatch`
34    /// and the rest — not in a second field next to a generic `cli_error`,
35    /// which asked an agent to learn a second place to look and cost a closed
36    /// enum kept in step across four files and four mirrored schemas.
37    pub const fn code(self) -> &'static str {
38        match self {
39            Self::InvalidUtf8 => "cli_invalid_utf8",
40            Self::UnknownCommand => "cli_unknown_command",
41            Self::UnknownArgument => "cli_unknown_argument",
42            Self::MissingArgumentValue => "cli_missing_argument_value",
43            Self::InvalidArgumentValue => "cli_invalid_argument_value",
44            Self::DuplicateArgument => "cli_duplicate_argument",
45            Self::UnexpectedPositional => "cli_unexpected_positional",
46            Self::UnregisteredCombination => "cli_unregistered_combination",
47        }
48    }
49}
50
51/// Structured CLI-resolution error. It never contains raw argument values.
52///
53/// `rule` is the classification and the only thing worth branching on; the two
54/// strings are for a human. The command path and the implicated argument names
55/// are not fields: both are already inside `hint` and `message` respectively,
56/// and a second, stringly-typed copy of either is a place for them to drift.
57#[derive(Clone, Debug, PartialEq, Eq)]
58pub struct CliError {
59    pub rule: CliErrorRule,
60    pub message: String,
61    pub hint: String,
62}
63
64impl CliError {
65    pub(super) fn new(
66        rule: CliErrorRule,
67        command_path: String,
68        message: impl Into<String>,
69    ) -> Self {
70        // The remedy follows the rule: only a mixture of known arguments is
71        // fixed by choosing among the registered combinations. A bad or
72        // missing value, a repeat, or an unknown name each has its own next
73        // step, and pointing them all at "combinations" sends a caller who
74        // typed one wrong number off to compare argument sets.
75        let hint = match rule {
76            CliErrorRule::UnregisteredCombination => {
77                format!("run `{command_path} --help` and choose one registered combination")
78            }
79            CliErrorRule::UnknownCommand => {
80                format!("run `{command_path} --help` to list the registered commands")
81            }
82            CliErrorRule::UnknownArgument | CliErrorRule::UnexpectedPositional => {
83                format!("run `{command_path} --help` to list the arguments each shape accepts")
84            }
85            CliErrorRule::InvalidArgumentValue => format!(
86                "pass a value of the type and range `{command_path} --help` lists for that argument"
87            ),
88            CliErrorRule::MissingArgumentValue => format!(
89                "pass a value right after the argument; `{command_path} --help` shows its value name"
90            ),
91            CliErrorRule::DuplicateArgument => format!(
92                "pass the argument once; `{command_path} --help` shows which arguments repeat"
93            ),
94            CliErrorRule::InvalidUtf8 => {
95                format!("pass every argument as valid UTF-8; `{command_path} --help` lists them")
96            }
97        };
98        Self {
99            rule,
100            message: message.into(),
101            hint,
102        }
103    }
104
105    pub(super) fn unregistered(command_path: String) -> Self {
106        Self::new(
107            CliErrorRule::UnregisteredCombination,
108            command_path,
109            "arguments do not match a registered CLI combination",
110        )
111    }
112
113    /// Fixed process exit status for every closed-world CLI error.
114    ///
115    /// Derived rather than stored: every rule in [`CliErrorRule`] exits 2, and
116    /// a field would only be somewhere for that to drift.
117    pub const fn exit_code(&self) -> u8 {
118        2
119    }
120}
121
122impl std::fmt::Display for CliError {
123    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
124        f.write_str(&self.message)
125    }
126}
127
128impl std::error::Error for CliError {}
129
130pub(super) fn invalid_value(command_path: &str, name: String, message: &str) -> CliError {
131    CliError::new(
132        CliErrorRule::InvalidArgumentValue,
133        command_path.to_string(),
134        format!("invalid value for `{name}`: {message}"),
135    )
136}
137
138pub(super) fn missing_value(command_path: &str, name: &str) -> CliError {
139    CliError::new(
140        CliErrorRule::MissingArgumentValue,
141        command_path.to_string(),
142        format!("argument `{name}` requires a value"),
143    )
144}
145
146pub(super) fn duplicate_error(command_path: &str, name: &str) -> CliError {
147    CliError::new(
148        CliErrorRule::DuplicateArgument,
149        command_path.to_string(),
150        format!("argument `{name}` may not be repeated"),
151    )
152}