• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

facet-rs / facet / 19864980647

02 Dec 2025 03:59PM UTC coverage: 58.132% (-0.3%) from 58.475%
19864980647

Pull #985

github

web-flow
Merge 1d9c2888a into b807f6358
Pull Request #985: Improve facet-args error handling and add comprehensive showcase

493 of 1043 new or added lines in 9 files covered. (47.27%)

6 existing lines in 4 files now uncovered.

19591 of 33701 relevant lines covered (58.13%)

180.97 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

73.68
/facet-args/src/error.rs
1
use crate::span::Span;
2
use core::fmt;
3
use facet_core::{Field, Shape, Type, UserType, Variant};
4
use facet_reflect::ReflectError;
5
use heck::ToKebabCase;
6
use miette::{Diagnostic, LabeledSpan};
7

8
/// An args parsing error, with input info, so that it can be formatted nicely
9
#[derive(Debug)]
10
pub struct ArgsErrorWithInput {
11
    /// The inner error
12
    pub(crate) inner: ArgsError,
13

14
    /// All CLI arguments joined by a space
15
    pub(crate) flattened_args: String,
16
}
17

18
impl core::fmt::Display for ArgsErrorWithInput {
19
    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
21✔
20
        write!(f, "Could not parse CLI arguments")
21✔
21
    }
21✔
22
}
23

24
impl core::error::Error for ArgsErrorWithInput {}
25

26
impl Diagnostic for ArgsErrorWithInput {
27
    fn code<'a>(&'a self) -> Option<Box<dyn core::fmt::Display + 'a>> {
20✔
28
        Some(Box::new(self.inner.kind.code()))
20✔
29
    }
20✔
30

31
    fn severity(&self) -> Option<miette::Severity> {
40✔
32
        Some(miette::Severity::Error)
40✔
33
    }
40✔
34

35
    fn help<'a>(&'a self) -> Option<Box<dyn core::fmt::Display + 'a>> {
20✔
36
        self.inner.kind.help()
20✔
37
    }
20✔
38

39
    fn url<'a>(&'a self) -> Option<Box<dyn core::fmt::Display + 'a>> {
20✔
40
        None
20✔
41
    }
20✔
42

43
    fn source_code(&self) -> Option<&dyn miette::SourceCode> {
80✔
44
        Some(&self.flattened_args)
80✔
45
    }
80✔
46

47
    fn labels(&self) -> Option<Box<dyn Iterator<Item = miette::LabeledSpan> + '_>> {
20✔
48
        Some(Box::new(core::iter::once(LabeledSpan::new(
20✔
49
            Some(self.inner.kind.label()),
20✔
50
            self.inner.span.start,
20✔
51
            self.inner.span.len(),
20✔
52
        ))))
20✔
53
    }
20✔
54

55
    fn related<'a>(&'a self) -> Option<Box<dyn Iterator<Item = &'a dyn Diagnostic> + 'a>> {
20✔
56
        None
20✔
57
    }
20✔
58

59
    fn diagnostic_source(&self) -> Option<&dyn Diagnostic> {
20✔
60
        None
20✔
61
    }
20✔
62
}
63

64
/// An args parsing error (without input info)
65
#[derive(Debug)]
66
pub struct ArgsError {
67
    /// Where the error occurred
68
    pub span: Span,
69

70
    /// The specific error that occurred while parsing arguments.
71
    pub kind: ArgsErrorKind,
72
}
73

74
/// An error kind for argument parsing.
75
///
76
/// Stores references to static shape/field/variant info for lazy formatting.
77
#[derive(Debug, Clone)]
78
#[non_exhaustive]
79
pub enum ArgsErrorKind {
80
    /// Did not expect a positional argument at this position
81
    UnexpectedPositionalArgument {
82
        /// Fields of the struct/variant being parsed (for help text)
83
        fields: &'static [Field],
84
    },
85

86
    /// Wanted to look up a field, for example `--something` in a struct,
87
    /// but the current shape was not a struct.
88
    NoFields {
89
        /// The shape that was being parsed
90
        shape: &'static Shape,
91
    },
92

93
    /// Passed `--something` (see span), no such long flag
94
    UnknownLongFlag {
95
        /// The flag that was passed
96
        flag: String,
97
        /// Fields of the struct/variant being parsed
98
        fields: &'static [Field],
99
    },
100

101
    /// Passed `-j` (see span), no such short flag
102
    UnknownShortFlag {
103
        /// The flag that was passed
104
        flag: String,
105
        /// Fields of the struct/variant being parsed
106
        fields: &'static [Field],
107
    },
108

109
    /// Struct/type expected a certain argument to be passed and it wasn't
110
    MissingArgument {
111
        /// The field that was missing
112
        field: &'static Field,
113
    },
114

115
    /// Expected a value of type shape, got EOF
116
    ExpectedValueGotEof {
117
        /// The type that was expected
118
        shape: &'static Shape,
119
    },
120

121
    /// Unknown subcommand name
122
    UnknownSubcommand {
123
        /// The subcommand that was provided
124
        provided: String,
125
        /// Variants of the enum (subcommands)
126
        variants: &'static [Variant],
127
    },
128

129
    /// Required subcommand was not provided
130
    MissingSubcommand {
131
        /// Variants of the enum (available subcommands)
132
        variants: &'static [Variant],
133
    },
134

135
    /// Generic reflection error: something went wrong
136
    ReflectError(ReflectError),
137
}
138

139
impl ArgsErrorKind {
140
    /// Returns an error code for this error kind.
141
    pub fn code(&self) -> &'static str {
20✔
142
        match self {
20✔
143
            ArgsErrorKind::UnexpectedPositionalArgument { .. } => "args::unexpected_positional",
3✔
NEW
144
            ArgsErrorKind::NoFields { .. } => "args::no_fields",
×
145
            ArgsErrorKind::UnknownLongFlag { .. } => "args::unknown_long_flag",
3✔
NEW
146
            ArgsErrorKind::UnknownShortFlag { .. } => "args::unknown_short_flag",
×
147
            ArgsErrorKind::MissingArgument { .. } => "args::missing_argument",
2✔
148
            ArgsErrorKind::ExpectedValueGotEof { .. } => "args::expected_value",
2✔
149
            ArgsErrorKind::UnknownSubcommand { .. } => "args::unknown_subcommand",
1✔
NEW
150
            ArgsErrorKind::MissingSubcommand { .. } => "args::missing_subcommand",
×
151
            ArgsErrorKind::ReflectError(_) => "args::reflect_error",
9✔
152
        }
153
    }
20✔
154

155
    /// Returns a short label for the error (shown inline in the source)
156
    pub fn label(&self) -> String {
20✔
157
        match self {
20✔
158
            ArgsErrorKind::UnexpectedPositionalArgument { .. } => {
159
                "unexpected positional argument".to_string()
3✔
160
            }
161
            ArgsErrorKind::NoFields { shape } => {
×
NEW
162
                format!("cannot parse arguments into `{}`", shape.type_identifier)
×
163
            }
164
            ArgsErrorKind::UnknownLongFlag { flag, .. } => {
3✔
165
                format!("unknown flag `--{flag}`")
3✔
166
            }
NEW
167
            ArgsErrorKind::UnknownShortFlag { flag, .. } => {
×
NEW
168
                format!("unknown flag `-{flag}`")
×
169
            }
170
            ArgsErrorKind::ExpectedValueGotEof { shape } => {
2✔
171
                // Unwrap Option to show the inner type
172
                let inner_type = unwrap_option_type(shape);
2✔
173
                format!("expected `{inner_type}` value")
2✔
174
            }
175
            ArgsErrorKind::ReflectError(err) => format_reflect_error(err),
9✔
176
            ArgsErrorKind::MissingArgument { field } => {
2✔
177
                let doc_hint = field
2✔
178
                    .doc
2✔
179
                    .first()
2✔
180
                    .map(|d| format!(" ({})", d.trim()))
2✔
181
                    .unwrap_or_default();
2✔
182
                let positional = field.has_attr(Some("args"), "positional");
2✔
183
                let arg_name = if positional {
2✔
184
                    format!("<{}>", field.name.to_kebab_case())
2✔
185
                } else {
NEW
186
                    format!("--{}", field.name.to_kebab_case())
×
187
                };
188
                format!("missing required argument `{arg_name}`{doc_hint}")
2✔
189
            }
190
            ArgsErrorKind::UnknownSubcommand { provided, .. } => {
1✔
191
                format!("unknown subcommand `{provided}`")
1✔
192
            }
NEW
193
            ArgsErrorKind::MissingSubcommand { .. } => "expected a subcommand".to_string(),
×
194
        }
195
    }
20✔
196

197
    /// Returns help text for this error
198
    pub fn help(&self) -> Option<Box<dyn core::fmt::Display + '_>> {
20✔
199
        match self {
20✔
200
            ArgsErrorKind::UnexpectedPositionalArgument { fields } => {
3✔
201
                if fields.is_empty() {
3✔
NEW
202
                    return Some(Box::new(
×
NEW
203
                        "this command does not accept positional arguments",
×
NEW
204
                    ));
×
205
                }
3✔
206
                let flags = format_available_flags(fields);
3✔
207
                Some(Box::new(format!("available options:\n{flags}")))
3✔
208
            }
209
            ArgsErrorKind::UnknownLongFlag { flag, fields } => {
3✔
210
                // Try to find a similar flag
211
                if let Some(suggestion) = find_similar_flag(flag, fields) {
3✔
212
                    return Some(Box::new(format!("did you mean `--{suggestion}`?")));
3✔
NEW
213
                }
×
NEW
214
                if fields.is_empty() {
×
NEW
215
                    return None;
×
NEW
216
                }
×
NEW
217
                let flags = format_available_flags(fields);
×
NEW
218
                Some(Box::new(format!("available options:\n{flags}")))
×
219
            }
NEW
220
            ArgsErrorKind::UnknownShortFlag { flag, fields } => {
×
221
                // Try to find what flag the user might have meant
NEW
222
                let short_char = flag.chars().next();
×
NEW
223
                if let Some(field) = fields.iter().find(|f| get_short_flag(f) == short_char) {
×
NEW
224
                    return Some(Box::new(format!(
×
NEW
225
                        "`-{}` is `--{}`",
×
NEW
226
                        flag,
×
NEW
227
                        field.name.to_kebab_case()
×
NEW
228
                    )));
×
NEW
229
                }
×
NEW
230
                if fields.is_empty() {
×
NEW
231
                    return None;
×
NEW
232
                }
×
NEW
233
                let flags = format_available_flags(fields);
×
NEW
234
                Some(Box::new(format!("available options:\n{flags}")))
×
235
            }
236
            ArgsErrorKind::MissingArgument { field } => {
2✔
237
                let kebab = field.name.to_kebab_case();
2✔
238
                let type_name = (field.shape)().type_identifier;
2✔
239
                let positional = field.has_attr(Some("args"), "positional");
2✔
240
                if positional {
2✔
241
                    Some(Box::new(format!("provide a value for `<{kebab}>`")))
2✔
242
                } else {
NEW
243
                    Some(Box::new(format!(
×
NEW
244
                        "provide a value with `--{kebab} <{type_name}>`"
×
NEW
245
                    )))
×
246
                }
247
            }
248
            ArgsErrorKind::UnknownSubcommand { provided, variants } => {
1✔
249
                if variants.is_empty() {
1✔
NEW
250
                    return None;
×
251
                }
1✔
252
                // Try to find a similar subcommand
253
                if let Some(suggestion) = find_similar_subcommand(provided, variants) {
1✔
NEW
254
                    return Some(Box::new(format!("did you mean `{suggestion}`?")));
×
255
                }
1✔
256
                let cmds = format_available_subcommands(variants);
1✔
257
                Some(Box::new(format!("available subcommands:\n{cmds}")))
1✔
258
            }
NEW
259
            ArgsErrorKind::MissingSubcommand { variants } => {
×
NEW
260
                if variants.is_empty() {
×
NEW
261
                    return None;
×
NEW
262
                }
×
NEW
263
                let cmds = format_available_subcommands(variants);
×
NEW
264
                Some(Box::new(format!("available subcommands:\n{cmds}")))
×
265
            }
266
            ArgsErrorKind::ExpectedValueGotEof { .. } => {
267
                Some(Box::new("provide a value after the flag"))
2✔
268
            }
269
            ArgsErrorKind::NoFields { .. } | ArgsErrorKind::ReflectError(_) => None,
9✔
270
        }
271
    }
20✔
272
}
273

274
/// Format a two-column list with aligned descriptions
275
fn format_two_column_list(
4✔
276
    items: impl IntoIterator<Item = (String, Option<&'static str>)>,
4✔
277
) -> String {
4✔
278
    use core::fmt::Write;
279

280
    let items: Vec<_> = items.into_iter().collect();
4✔
281

282
    // Find max width for alignment
283
    let max_width = items.iter().map(|(name, _)| name.len()).max().unwrap_or(0);
5✔
284

285
    let mut lines = Vec::new();
4✔
286
    for (name, doc) in items {
5✔
287
        let mut line = String::new();
5✔
288
        write!(line, "  {name}").unwrap();
5✔
289

290
        // Pad to alignment
291
        let padding = max_width.saturating_sub(name.len());
5✔
292
        for _ in 0..padding {
5✔
293
            line.push(' ');
5✔
294
        }
5✔
295

296
        if let Some(doc) = doc {
5✔
NEW
297
            write!(line, "  {}", doc.trim()).unwrap();
×
298
        }
5✔
299

300
        lines.push(line);
5✔
301
    }
302
    lines.join("\n")
4✔
303
}
4✔
304

305
/// Format available flags for help text (from static field info)
306
fn format_available_flags(fields: &'static [Field]) -> String {
3✔
307
    let items = fields.iter().filter_map(|field| {
3✔
308
        if field.has_attr(Some("args"), "subcommand") {
3✔
NEW
309
            return None;
×
310
        }
3✔
311

312
        let short = get_short_flag(field);
3✔
313
        let positional = field.has_attr(Some("args"), "positional");
3✔
314
        let kebab = field.name.to_kebab_case();
3✔
315

316
        let name = if positional {
3✔
NEW
317
            match short {
×
NEW
318
                Some(s) => format!("-{s}, <{kebab}>"),
×
NEW
319
                None => format!("    <{kebab}>"),
×
320
            }
321
        } else {
322
            match short {
3✔
NEW
323
                Some(s) => format!("-{s}, --{kebab}"),
×
324
                None => format!("    --{kebab}"),
3✔
325
            }
326
        };
327

328
        Some((name, field.doc.first().copied()))
3✔
329
    });
3✔
330

331
    format_two_column_list(items)
3✔
332
}
3✔
333

334
/// Format available subcommands for help text (from static variant info)
335
fn format_available_subcommands(variants: &'static [Variant]) -> String {
1✔
336
    let items = variants.iter().map(|variant| {
2✔
337
        let name = variant
2✔
338
            .get_builtin_attr("rename")
2✔
339
            .and_then(|attr| attr.get_as::<&str>())
2✔
340
            .map(|s| (*s).to_string())
2✔
341
            .unwrap_or_else(|| variant.name.to_kebab_case());
2✔
342

343
        (name, variant.doc.first().copied())
2✔
344
    });
2✔
345

346
    format_two_column_list(items)
1✔
347
}
1✔
348

349
/// Get the short flag character for a field, if any
350
fn get_short_flag(field: &Field) -> Option<char> {
3✔
351
    field
3✔
352
        .get_attr(Some("args"), "short")
3✔
353
        .and_then(|attr| attr.get_as::<crate::Attr>())
3✔
354
        .and_then(|attr| {
3✔
NEW
355
            if let crate::Attr::Short(c) = attr {
×
356
                // If explicit char provided, use it; otherwise use first char of field name
NEW
357
                c.or_else(|| field.name.chars().next())
×
358
            } else {
NEW
359
                None
×
360
            }
NEW
361
        })
×
362
}
3✔
363

364
/// Find a similar flag name using simple heuristics
365
fn find_similar_flag(input: &str, fields: &'static [Field]) -> Option<String> {
3✔
366
    for field in fields {
4✔
367
        let kebab = field.name.to_kebab_case();
4✔
368
        if is_similar(input, &kebab) {
4✔
369
            return Some(kebab);
3✔
370
        }
1✔
371
    }
NEW
372
    None
×
373
}
3✔
374

375
/// Find a similar subcommand name using simple heuristics
376
fn find_similar_subcommand(input: &str, variants: &'static [Variant]) -> Option<String> {
1✔
377
    for variant in variants {
2✔
378
        // Check for rename attribute first
379
        let name = variant
2✔
380
            .get_builtin_attr("rename")
2✔
381
            .and_then(|attr| attr.get_as::<&str>())
2✔
382
            .map(|s| (*s).to_string())
2✔
383
            .unwrap_or_else(|| variant.name.to_kebab_case());
2✔
384
        if is_similar(input, &name) {
2✔
NEW
385
            return Some(name);
×
386
        }
2✔
387
    }
388
    None
1✔
389
}
1✔
390

391
/// Check if two strings are similar (differ by at most 2 edits)
392
fn is_similar(a: &str, b: &str) -> bool {
6✔
393
    if a == b {
6✔
NEW
394
        return true;
×
395
    }
6✔
396
    let len_diff = (a.len() as isize - b.len() as isize).abs();
6✔
397
    if len_diff > 2 {
6✔
398
        return false;
1✔
399
    }
5✔
400

401
    // Simple check: count character differences
402
    let mut diffs = 0;
5✔
403
    let a_chars: Vec<char> = a.chars().collect();
5✔
404
    let b_chars: Vec<char> = b.chars().collect();
5✔
405

406
    for (ac, bc) in a_chars.iter().zip(b_chars.iter()) {
26✔
407
        if ac != bc {
26✔
408
            diffs += 1;
8✔
409
        }
18✔
410
    }
411
    diffs += len_diff as usize;
5✔
412
    diffs <= 2
5✔
413
}
6✔
414

415
/// Get the inner type identifier, unwrapping Option if present
416
fn unwrap_option_type(shape: &'static Shape) -> &'static str {
11✔
417
    match shape.def {
11✔
NEW
418
        facet_core::Def::Option(opt_def) => opt_def.t.type_identifier,
×
419
        _ => shape.type_identifier,
11✔
420
    }
421
}
11✔
422

423
/// Format a ReflectError into a user-friendly message
424
fn format_reflect_error(err: &ReflectError) -> String {
9✔
425
    use facet_reflect::ReflectError::*;
426
    match err {
9✔
427
        OperationFailed { shape, operation } => {
9✔
428
            // Improve common operation failure messages
429
            // Unwrap Option to show the inner type
430
            let inner_type = unwrap_option_type(shape);
9✔
431
            match *operation {
9✔
432
                "Type does not support parsing from string" => {
9✔
433
                    format!("`{inner_type}` cannot be parsed from a string value")
2✔
434
                }
435
                "Failed to parse string value" => {
7✔
436
                    format!("invalid value for `{inner_type}`")
7✔
437
                }
NEW
438
                _ => format!("`{inner_type}`: {operation}"),
×
439
            }
440
        }
NEW
441
        UninitializedField { shape, field_name } => {
×
NEW
442
            format!(
×
443
                "field `{}` of `{}` was not provided",
444
                field_name, shape.type_identifier
445
            )
446
        }
NEW
447
        WrongShape { expected, actual } => {
×
NEW
448
            format!(
×
449
                "expected `{}`, got `{}`",
450
                expected.type_identifier, actual.type_identifier
451
            )
452
        }
NEW
453
        _ => format!("{err}"),
×
454
    }
455
}
9✔
456

457
impl core::fmt::Display for ArgsErrorKind {
NEW
458
    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
×
NEW
459
        write!(f, "{}", self.label())
×
UNCOV
460
    }
×
461
}
462

463
impl From<ReflectError> for ArgsErrorKind {
464
    fn from(error: ReflectError) -> Self {
9✔
465
        ArgsErrorKind::ReflectError(error)
9✔
466
    }
9✔
467
}
468

469
impl ArgsError {
470
    /// Creates a new args error
471
    pub fn new(kind: ArgsErrorKind, span: Span) -> Self {
22✔
472
        Self { span, kind }
22✔
473
    }
22✔
474
}
475

476
impl fmt::Display for ArgsError {
477
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
×
478
        fmt::Debug::fmt(self, f)
×
479
    }
×
480
}
481

482
/// Extract variants from a shape (if it's an enum)
483
pub(crate) fn get_variants_from_shape(shape: &'static Shape) -> &'static [Variant] {
1✔
484
    if let Type::User(UserType::Enum(enum_type)) = shape.ty {
1✔
485
        enum_type.variants
1✔
486
    } else {
NEW
487
        &[]
×
488
    }
489
}
1✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc