Skip to main content

usage/
error.rs

1use crate::kdl;
2use crate::miette::{NamedSource, SourceSpan};
3use thiserror::Error;
4
5#[derive(Debug, Error)]
6#[error("invalid enum value `{0}`")]
7pub struct EnumParseError(pub String);
8
9/// Everything that can go wrong reading a spec or a command line against one.
10///
11/// `#[non_exhaustive]`, so a caller matching on it needs a `_` arm. That is the point:
12/// this enum grows every time the spec learns to say something new — `MissingGroup`
13/// arrived with groups, `ArgRequiresDoubleDash` with `double_dash` — and without this
14/// each one is a major release for everyone downstream.
15///
16/// With the `miette` feature enabled, this implements `miette::Diagnostic` so applications can
17/// pass it directly to their existing miette reporter. The feature is disabled by default.
18#[derive(Error, Debug)]
19#[non_exhaustive]
20pub enum UsageErr {
21    #[error("Invalid flag `{token}`: {reason}")]
22    InvalidFlag {
23        token: String,
24        reason: String,
25        span: SourceSpan,
26        input: String,
27    },
28
29    #[error("Missing required flag: --{0} <{0}>")]
30    MissingFlag(String),
31
32    #[error("Flag --{0} cannot be used multiple times")]
33    DuplicateFlag(String),
34
35    /// A required group had none of its members given.
36    ///
37    /// Its own variant rather than a [`UsageErr::MissingFlag`] holding a sentence,
38    /// because there is no one flag to name: the group is the thing that was not
39    /// satisfied, and a caller that renders errors itself needs the members as members.
40    #[error("Missing one of the required flags in group {group}: {members}")]
41    MissingGroup { group: String, members: String },
42
43    #[error("Invalid usage config")]
44    InvalidInput(String, SourceSpan, NamedSource<String>),
45
46    #[error("Invalid usage spec: {0}")]
47    InvalidSpec(String),
48
49    #[error("Missing required arg: <{0}>")]
50    MissingArg(String),
51
52    #[error("Missing required arg <{arg}> in clause {clause} instance {instance}")]
53    MissingClauseArg {
54        clause: String,
55        instance: usize,
56        arg: String,
57    },
58
59    /// A command that declares `subcommand_required` was given none.
60    ///
61    /// The spec could say this and the parser did not read it, so `mise generate` — which
62    /// declares it — parsed as though it were a complete invocation. usage-argv and clap both
63    /// refuse it.
64    #[error("`{0}` needs a subcommand: one of {1}")]
65    MissingSubcommand(String, String),
66
67    #[error("Argument <{0}> can only be set after a `--` separator")]
68    ArgRequiresDoubleDash(String),
69
70    #[error("{0}")]
71    Help(String),
72
73    #[error("{0}")]
74    Version(String),
75
76    #[error("Invalid usage config: {0}")]
77    Miette(#[from] crate::miette::MietteError),
78
79    #[error(transparent)]
80    IO(#[from] std::io::Error),
81
82    #[error(transparent)]
83    Strum(#[from] EnumParseError),
84
85    #[error(transparent)]
86    FromUtf8Error(#[from] std::string::FromUtf8Error),
87
88    #[cfg(feature = "tera")]
89    #[error(transparent)]
90    TeraError(#[from] tera::Error),
91
92    #[error(transparent)]
93    KdlError(#[from] kdl::KdlError),
94
95    /// A file the spec model was asked to read could not be read.
96    ///
97    /// Carries the path as well as the io error: "No such file or directory" on its own
98    /// names nothing, and this is reported for spec files given on a command line.
99    #[error("{0}\nFile: {1}")]
100    FileError(std::io::Error, std::path::PathBuf),
101
102    /// A `run=` script could not be run, exited non-zero, or produced output usage
103    /// could not read. The message names the shell and the script.
104    #[error("{0}")]
105    ShellError(String),
106
107    #[error("Variadic argument <{name}> requires at least {min} value(s), got {got}")]
108    VarArgTooFew {
109        name: String,
110        min: usize,
111        got: usize,
112    },
113
114    #[error("Variadic argument <{name}> accepts at most {max} value(s), got {got}")]
115    VarArgTooMany {
116        name: String,
117        max: usize,
118        got: usize,
119    },
120
121    #[error("Variadic flag --{name} requires at least {min} value(s), got {got}")]
122    VarFlagTooFew {
123        name: String,
124        min: usize,
125        got: usize,
126    },
127
128    #[error("Variadic flag --{name} accepts at most {max} value(s), got {got}")]
129    VarFlagTooMany {
130        name: String,
131        max: usize,
132        got: usize,
133    },
134
135    #[error("Invalid file path: {0}")]
136    InvalidPath(String),
137
138    #[error("Invalid spec view: {0}")]
139    InvalidView(String),
140
141    /// A command's `output`/`select` declarations do not agree with each other, or with
142    /// the flags around them. Spanless like [`UsageErr::InvalidView`], because selection
143    /// is resolved once the whole document is read — a `select` may name a flag declared
144    /// on an ancestor, so the node spans are long gone by the time it can be checked.
145    #[error("Invalid output declaration: {0}")]
146    InvalidOutput(String),
147
148    #[error("Invalid value for {name}: {value}: {reason}")]
149    InvalidValue {
150        name: String,
151        value: String,
152        reason: String,
153    },
154
155    #[error("Unsupported shell: {0}")]
156    UnsupportedShell(String),
157
158    #[error("No injected output was provided for mount command: {0}")]
159    MissingMountOutput(String),
160
161    /// Variables under the spec's `unknown_env` prefix that nothing declares, all of them at
162    /// once: a deployment with two typos should not need two restarts to find them.
163    #[error(
164        "Unknown environment variable{} under {prefix}: {}",
165        if names.len() == 1 { "" } else { "s" },
166        names.join(", ")
167    )]
168    UnknownEnv { prefix: String, names: Vec<String> },
169}
170pub type Result<T> = std::result::Result<T, UsageErr>;
171
172impl UsageErr {
173    fn code_name(&self) -> Option<&'static str> {
174        match self {
175            Self::FileError(..) => Some("usage::file"),
176            Self::ShellError(..) => Some("usage::shell"),
177            _ => None,
178        }
179    }
180
181    pub(crate) fn render(&self) -> String {
182        let rendered = match self {
183            Self::InvalidInput(message, span, source) => crate::miette::render_source(
184                "Invalid usage config",
185                source.name(),
186                source.inner(),
187                *span,
188                message,
189                None,
190            ),
191            Self::InvalidFlag {
192                reason,
193                span,
194                input,
195                ..
196            } => crate::miette::render_source(&self.to_string(), "", input, *span, reason, None),
197            Self::KdlError(error) => error.render(),
198            _ => self.to_string(),
199        };
200        if let Some(code) = self.code_name() {
201            format!("  {code}\n\n{rendered}")
202        } else {
203            rendered
204        }
205    }
206}
207
208#[cfg(feature = "miette")]
209impl ::miette::Diagnostic for UsageErr {
210    fn code<'a>(&'a self) -> Option<Box<dyn std::fmt::Display + 'a>> {
211        self.code_name()
212            .map(|code| Box::new(code) as Box<dyn std::fmt::Display>)
213    }
214
215    fn source_code(&self) -> Option<&dyn ::miette::SourceCode> {
216        match self {
217            Self::InvalidInput(_, _, source) => Some(source),
218            Self::InvalidFlag { input, .. } => Some(input),
219            _ => None,
220        }
221    }
222
223    fn labels(&self) -> Option<Box<dyn Iterator<Item = ::miette::LabeledSpan> + '_>> {
224        let (span, label) = match self {
225            Self::InvalidInput(message, span, _) => (*span, message.as_str()),
226            Self::InvalidFlag { reason, span, .. } => (*span, reason.as_str()),
227            _ => return None,
228        };
229        let label = ::miette::LabeledSpan::at(span.offset()..span.offset() + span.len(), label);
230        Some(Box::new(std::iter::once(label)))
231    }
232
233    fn related<'a>(
234        &'a self,
235    ) -> Option<Box<dyn Iterator<Item = &'a dyn ::miette::Diagnostic> + 'a>> {
236        match self {
237            Self::KdlError(error) => Some(Box::new(
238                error
239                    .diagnostics
240                    .iter()
241                    .map(|diagnostic| diagnostic as &dyn ::miette::Diagnostic),
242            )),
243            _ => None,
244        }
245    }
246}
247
248#[macro_export]
249macro_rules! bail_parse {
250    ($ctx:expr, $span:expr, $fmt:literal) => {{
251        let span: $crate::miette::SourceSpan = ($span.offset(), $span.len()).into();
252        let msg = format!($fmt);
253        let err = $ctx.build_err(msg, span);
254        return std::result::Result::Err(err);
255    }};
256    ($ctx:expr, $span:expr, $fmt:literal, $($arg:tt)*) => {{
257        let span: $crate::miette::SourceSpan = ($span.offset(), $span.len()).into();
258        let msg = format!($fmt, $($arg)*);
259        let err = $ctx.build_err(msg, span);
260        return std::result::Result::Err(err);
261    }};
262}
263
264#[cfg(test)]
265mod tests {
266    use super::UsageErr;
267
268    #[test]
269    fn native_renderer_preserves_diagnostic_codes() {
270        let file = UsageErr::FileError(
271            std::io::Error::new(std::io::ErrorKind::NotFound, "missing"),
272            "missing.kdl".into(),
273        );
274        assert!(file.render().contains("usage::file"));
275        assert!(
276            UsageErr::ShellError("failed".into())
277                .render()
278                .contains("usage::shell")
279        );
280    }
281
282    #[cfg(feature = "miette")]
283    #[test]
284    fn miette_interop_preserves_diagnostic_codes() {
285        use miette::Diagnostic;
286
287        let error = UsageErr::ShellError("failed".into());
288        assert_eq!(error.code().unwrap().to_string(), "usage::shell");
289    }
290}