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

facet-rs / facet / 19865211513

02 Dec 2025 04:06PM UTC coverage: 58.132% (-0.3%) from 58.475%
19865211513

push

github

fasterthanlime
Remove colors from error help text

Colors in error messages cause snapshot test failures because
the output varies depending on terminal capabilities. The help
text formatting is still nicely aligned, just without colors.

1 of 2 new or added lines in 1 file covered. (50.0%)

560 existing lines in 6 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✔
UNCOV
144
            ArgsErrorKind::NoFields { .. } => "args::no_fields",
×
145
            ArgsErrorKind::UnknownLongFlag { .. } => "args::unknown_long_flag",
3✔
UNCOV
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✔
UNCOV
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
            }
UNCOV
161
            ArgsErrorKind::NoFields { shape } => {
×
UNCOV
162
                format!("cannot parse arguments into `{}`", shape.type_identifier)
×
163
            }
164
            ArgsErrorKind::UnknownLongFlag { flag, .. } => {
3✔
165
                format!("unknown flag `--{flag}`")
3✔
166
            }
UNCOV
167
            ArgsErrorKind::UnknownShortFlag { flag, .. } => {
×
UNCOV
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 {
UNCOV
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
            }
UNCOV
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✔
UNCOV
202
                    return Some(Box::new(
×
UNCOV
203
                        "this command does not accept positional arguments",
×
UNCOV
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✔
UNCOV
213
                }
×
UNCOV
214
                if fields.is_empty() {
×
UNCOV
215
                    return None;
×
UNCOV
216
                }
×
UNCOV
217
                let flags = format_available_flags(fields);
×
UNCOV
218
                Some(Box::new(format!("available options:\n{flags}")))
×
219
            }
UNCOV
220
            ArgsErrorKind::UnknownShortFlag { flag, fields } => {
×
221
                // Try to find what flag the user might have meant
UNCOV
222
                let short_char = flag.chars().next();
×
UNCOV
223
                if let Some(field) = fields.iter().find(|f| get_short_flag(f) == short_char) {
×
UNCOV
224
                    return Some(Box::new(format!(
×
UNCOV
225
                        "`-{}` is `--{}`",
×
UNCOV
226
                        flag,
×
UNCOV
227
                        field.name.to_kebab_case()
×
UNCOV
228
                    )));
×
UNCOV
229
                }
×
UNCOV
230
                if fields.is_empty() {
×
UNCOV
231
                    return None;
×
UNCOV
232
                }
×
UNCOV
233
                let flags = format_available_flags(fields);
×
UNCOV
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 {
UNCOV
243
                    Some(Box::new(format!(
×
UNCOV
244
                        "provide a value with `--{kebab} <{type_name}>`"
×
UNCOV
245
                    )))
×
246
                }
247
            }
248
            ArgsErrorKind::UnknownSubcommand { provided, variants } => {
1✔
249
                if variants.is_empty() {
1✔
UNCOV
250
                    return None;
×
251
                }
1✔
252
                // Try to find a similar subcommand
253
                if let Some(suggestion) = find_similar_subcommand(provided, variants) {
1✔
UNCOV
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
            }
UNCOV
259
            ArgsErrorKind::MissingSubcommand { variants } => {
×
UNCOV
260
                if variants.is_empty() {
×
UNCOV
261
                    return None;
×
UNCOV
262
                }
×
UNCOV
263
                let cmds = format_available_subcommands(variants);
×
UNCOV
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✔
UNCOV
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✔
UNCOV
317
            match short {
×
UNCOV
318
                Some(s) => format!("-{s}, <{kebab}>"),
×
UNCOV
319
                None => format!("    <{kebab}>"),
×
320
            }
321
        } else {
322
            match short {
3✔
UNCOV
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✔
UNCOV
355
            if let crate::Attr::Short(c) = attr {
×
356
                // If explicit char provided, use it; otherwise use first char of field name
UNCOV
357
                c.or_else(|| field.name.chars().next())
×
358
            } else {
UNCOV
359
                None
×
360
            }
UNCOV
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
    }
UNCOV
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✔
UNCOV
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✔
UNCOV
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✔
UNCOV
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
                }
UNCOV
438
                _ => format!("`{inner_type}`: {operation}"),
×
439
            }
440
        }
UNCOV
441
        UninitializedField { shape, field_name } => {
×
UNCOV
442
            format!(
×
443
                "field `{}` of `{}` was not provided",
444
                field_name, shape.type_identifier
445
            )
446
        }
UNCOV
447
        WrongShape { expected, actual } => {
×
UNCOV
448
            format!(
×
449
                "expected `{}`, got `{}`",
450
                expected.type_identifier, actual.type_identifier
451
            )
452
        }
UNCOV
453
        _ => format!("{err}"),
×
454
    }
455
}
9✔
456

457
impl core::fmt::Display for ArgsErrorKind {
UNCOV
458
    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
×
UNCOV
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 {
UNCOV
477
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
×
UNCOV
478
        fmt::Debug::fmt(self, f)
×
UNCOV
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 {
UNCOV
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