Skip to main content

figue/
error.rs

1use crate::span::Span;
2use facet_core::{Field, Shape, Type, UserType, Variant};
3use facet_reflect::ReflectError;
4use heck::ToKebabCase;
5use std::fmt;
6
7/// An args parsing error, with input info, so that it can be formatted nicely
8#[derive(Debug)]
9pub struct ArgsErrorWithInput {
10    /// The inner error
11    pub(crate) inner: ArgsError,
12
13    /// All CLI arguments joined by a space
14    #[allow(unused)]
15    pub(crate) flattened_args: String,
16}
17
18#[derive(Clone, Copy, Debug)]
19pub struct ShapeDiagnostics {
20    pub shape: &'static Shape,
21    pub field: &'static Field,
22}
23
24impl ArgsErrorWithInput {
25    /// Returns true if this is a help request (not a real error)
26    pub const fn is_help_request(&self) -> bool {
27        self.inner.kind.is_help_request()
28    }
29
30    /// If this is a help request, returns the help text
31    pub fn help_text(&self) -> Option<&str> {
32        self.inner.kind.help_text()
33    }
34
35    /// Returns shape diagnostics if the error includes schema context.
36    pub fn shape_diagnostics(&self) -> Option<ShapeDiagnostics> {
37        match self.inner.kind {
38            ArgsErrorKind::MissingArgsAnnotation { shape, field } => {
39                Some(ShapeDiagnostics { shape, field })
40            }
41            _ => None,
42        }
43    }
44}
45
46impl core::fmt::Display for ArgsErrorWithInput {
47    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
48        // For help requests, just display the help text directly
49        if let Some(help) = self.help_text() {
50            return write!(f, "{}", help);
51        }
52
53        // Write the main error message
54        write!(f, "error: {}", self.inner.kind.label())?;
55
56        // If we have help text, add it
57        if let Some(help) = self.inner.kind.help() {
58            write!(f, "\n\n{help}")?;
59        }
60
61        Ok(())
62    }
63}
64
65impl core::error::Error for ArgsErrorWithInput {}
66
67/// An args parsing error (without input info)
68#[derive(Debug)]
69pub struct ArgsError {
70    /// Where the error occurred
71    #[allow(unused)]
72    pub span: Span,
73
74    /// The specific error that occurred while parsing arguments.
75    pub kind: ArgsErrorKind,
76}
77
78/// An error kind for argument parsing.
79///
80/// Stores references to static shape/field/variant info for lazy formatting.
81#[derive(Debug, Clone)]
82#[non_exhaustive]
83pub enum ArgsErrorKind {
84    /// Help was requested via -h, --help, -help, or /?
85    ///
86    /// This is not really an "error" but uses the error path to return
87    /// help text when the user explicitly requests it.
88    HelpRequested {
89        /// The generated help text
90        help_text: String,
91    },
92
93    /// Version was requested via --version or -V
94    VersionRequested {
95        /// The version text
96        version_text: String,
97    },
98
99    /// Shell completions were requested via --completions
100    CompletionsRequested {
101        /// The generated completion script
102        script: String,
103    },
104
105    /// Did not expect a positional argument at this position
106    UnexpectedPositionalArgument {
107        /// Fields of the struct/variant being parsed (for help text)
108        fields: &'static [Field],
109    },
110
111    /// Wanted to look up a field, for example `--something` in a struct,
112    /// but the current shape was not a struct.
113    NoFields {
114        /// The shape that was being parsed
115        shape: &'static Shape,
116    },
117
118    /// Found an enum field without the args::subcommand attribute.
119    /// Enums can only be used as subcommands when explicitly marked.
120    EnumWithoutSubcommandAttribute {
121        /// The field that has the enum type
122        field: &'static Field,
123    },
124
125    /// A field was not annotated with any args attribute.
126    MissingArgsAnnotation {
127        /// The field missing an args annotation
128        field: &'static Field,
129        /// The shape where the field is defined
130        shape: &'static Shape,
131    },
132
133    /// Passed `--something` (see span), no such long flag
134    UnknownLongFlag {
135        /// The flag that was passed
136        flag: String,
137        /// Fields of the struct/variant being parsed
138        fields: &'static [Field],
139    },
140
141    /// Passed `-j` (see span), no such short flag
142    UnknownShortFlag {
143        /// The flag that was passed
144        flag: String,
145        /// Fields of the struct/variant being parsed
146        fields: &'static [Field],
147        /// Precise span for the invalid flag (used for chained short flags like `-axc` where `x` is invalid)
148        precise_span: Option<Span>,
149    },
150
151    /// Struct/type expected a certain argument to be passed and it wasn't
152    MissingArgument {
153        /// The field that was missing
154        field: &'static Field,
155    },
156
157    /// Expected a value of type shape, got EOF
158    ExpectedValueGotEof {
159        /// The type that was expected
160        shape: &'static Shape,
161    },
162
163    /// Unknown subcommand name
164    UnknownSubcommand {
165        /// The subcommand that was provided
166        provided: String,
167        /// Variants of the enum (subcommands)
168        variants: &'static [Variant],
169    },
170
171    /// Required subcommand was not provided
172    MissingSubcommand {
173        /// Variants of the enum (available subcommands)
174        variants: &'static [Variant],
175    },
176
177    /// Generic reflection error: something went wrong
178    ReflectError(ReflectError),
179}
180
181impl ArgsErrorKind {
182    /// Returns a precise span override if the error kind has one.
183    /// This is used for errors like `UnknownShortFlag` in chained flags
184    /// where we want to highlight just the invalid character, not the whole arg.
185    pub const fn precise_span(&self) -> Option<Span> {
186        match self {
187            ArgsErrorKind::UnknownShortFlag { precise_span, .. } => *precise_span,
188            _ => None,
189        }
190    }
191
192    /// Returns an error code for this error kind.
193    pub const fn code(&self) -> &'static str {
194        match self {
195            ArgsErrorKind::HelpRequested { .. } => "args::help",
196            ArgsErrorKind::VersionRequested { .. } => "args::version",
197            ArgsErrorKind::CompletionsRequested { .. } => "args::completions",
198            ArgsErrorKind::UnexpectedPositionalArgument { .. } => "args::unexpected_positional",
199            ArgsErrorKind::NoFields { .. } => "args::no_fields",
200            ArgsErrorKind::EnumWithoutSubcommandAttribute { .. } => {
201                "args::enum_without_subcommand_attribute"
202            }
203            ArgsErrorKind::MissingArgsAnnotation { .. } => "args::missing_args_annotation",
204            ArgsErrorKind::UnknownLongFlag { .. } => "args::unknown_long_flag",
205            ArgsErrorKind::UnknownShortFlag { .. } => "args::unknown_short_flag",
206            ArgsErrorKind::MissingArgument { .. } => "args::missing_argument",
207            ArgsErrorKind::ExpectedValueGotEof { .. } => "args::expected_value",
208            ArgsErrorKind::UnknownSubcommand { .. } => "args::unknown_subcommand",
209            ArgsErrorKind::MissingSubcommand { .. } => "args::missing_subcommand",
210            ArgsErrorKind::ReflectError(_) => "args::reflect_error",
211        }
212    }
213
214    /// Returns a short label for the error (shown inline in the source)
215    pub fn label(&self) -> String {
216        match self {
217            ArgsErrorKind::HelpRequested { .. } => "help requested".to_string(),
218            ArgsErrorKind::VersionRequested { .. } => "version requested".to_string(),
219            ArgsErrorKind::CompletionsRequested { .. } => "completions requested".to_string(),
220            ArgsErrorKind::UnexpectedPositionalArgument { .. } => {
221                "unexpected positional argument".to_string()
222            }
223            ArgsErrorKind::NoFields { shape } => {
224                format!("cannot parse arguments into `{}`", shape.type_identifier)
225            }
226            ArgsErrorKind::EnumWithoutSubcommandAttribute { field } => {
227                format!(
228                    "enum field `{}` must be marked with `#[facet(args::subcommand)]` to be used as subcommands",
229                    field.name
230                )
231            }
232            ArgsErrorKind::MissingArgsAnnotation { field, shape } => {
233                format!(
234                    "field `{}` in `{}` is missing a `#[facet(args::...)]` annotation",
235                    field.name, shape.type_identifier
236                )
237            }
238            ArgsErrorKind::UnknownLongFlag { flag, .. } => {
239                format!("unknown flag `--{flag}`")
240            }
241            ArgsErrorKind::UnknownShortFlag { flag, .. } => {
242                format!("unknown flag `-{flag}`")
243            }
244            ArgsErrorKind::ExpectedValueGotEof { shape } => {
245                // Unwrap Option to show the inner type
246                let inner_type = unwrap_option_type(shape);
247                format!("expected `{inner_type}` value")
248            }
249            ArgsErrorKind::ReflectError(err) => format_reflect_error(err),
250            ArgsErrorKind::MissingArgument { field } => {
251                let doc_hint = field
252                    .doc
253                    .first()
254                    .map(|d| format!(" ({})", d.trim()))
255                    .unwrap_or_default();
256                let positional = field.has_attr(Some("args"), "positional");
257                let arg_name = if positional {
258                    format!("<{}>", field.name.to_kebab_case())
259                } else {
260                    format!("--{}", field.name.to_kebab_case())
261                };
262                format!("missing required argument `{arg_name}`{doc_hint}")
263            }
264            ArgsErrorKind::UnknownSubcommand { provided, .. } => {
265                format!("unknown subcommand `{provided}`")
266            }
267            ArgsErrorKind::MissingSubcommand { .. } => "expected a subcommand".to_string(),
268        }
269    }
270
271    /// Returns help text for this error
272    pub fn help(&self) -> Option<Box<dyn core::fmt::Display + '_>> {
273        match self {
274            ArgsErrorKind::UnexpectedPositionalArgument { fields } => {
275                if fields.is_empty() {
276                    return Some(Box::new(
277                        "this command does not accept positional arguments",
278                    ));
279                }
280
281                // Check if any of the fields are enums without subcommand attributes
282                if let Some(enum_field) = fields.iter().find(|f| {
283                    matches!(f.shape().ty, Type::User(UserType::Enum(_)))
284                        && !f.has_attr(Some("args"), "subcommand")
285                }) {
286                    return Some(Box::new(format!(
287                        "available options:\n{}\n\nnote: field `{}` is an enum but missing `#[facet(args::subcommand)]` attribute. Enums must be marked as subcommands to accept positional arguments.",
288                        format_available_flags(fields),
289                        enum_field.name
290                    )));
291                }
292
293                let flags = format_available_flags(fields);
294                Some(Box::new(format!("available options:\n{flags}")))
295            }
296            ArgsErrorKind::UnknownLongFlag { flag, fields } => {
297                // Try to find a similar flag
298                if let Some(suggestion) = find_similar_flag(flag, fields) {
299                    return Some(Box::new(format!("did you mean `--{suggestion}`?")));
300                }
301                if fields.is_empty() {
302                    return None;
303                }
304                let flags = format_available_flags(fields);
305                Some(Box::new(format!("available options:\n{flags}")))
306            }
307            ArgsErrorKind::UnknownShortFlag { flag, fields, .. } => {
308                // Try to find what flag the user might have meant
309                let short_char = flag.chars().next();
310                if let Some(field) = fields.iter().find(|f| get_short_flag(f) == short_char) {
311                    return Some(Box::new(format!(
312                        "`-{}` is `--{}`",
313                        flag,
314                        field.name.to_kebab_case()
315                    )));
316                }
317                if fields.is_empty() {
318                    return None;
319                }
320                let flags = format_available_flags(fields);
321                Some(Box::new(format!("available options:\n{flags}")))
322            }
323            ArgsErrorKind::MissingArgument { field } => {
324                let kebab = field.name.to_kebab_case();
325                let type_name = field.shape().type_identifier;
326                let positional = field.has_attr(Some("args"), "positional");
327                if positional {
328                    Some(Box::new(format!("provide a value for `<{kebab}>`")))
329                } else {
330                    Some(Box::new(format!(
331                        "provide a value with `--{kebab} <{type_name}>`"
332                    )))
333                }
334            }
335            ArgsErrorKind::UnknownSubcommand { provided, variants } => {
336                if variants.is_empty() {
337                    return None;
338                }
339                // Try to find a similar subcommand
340                if let Some(suggestion) = find_similar_subcommand(provided, variants) {
341                    return Some(Box::new(format!("did you mean `{suggestion}`?")));
342                }
343                let cmds = format_available_subcommands(variants);
344                Some(Box::new(format!("available subcommands:\n{cmds}")))
345            }
346            ArgsErrorKind::MissingSubcommand { variants } => {
347                if variants.is_empty() {
348                    return None;
349                }
350                let cmds = format_available_subcommands(variants);
351                Some(Box::new(format!("available subcommands:\n{cmds}")))
352            }
353            ArgsErrorKind::ExpectedValueGotEof { .. } => {
354                Some(Box::new("provide a value after the flag"))
355            }
356            ArgsErrorKind::HelpRequested { .. }
357            | ArgsErrorKind::VersionRequested { .. }
358            | ArgsErrorKind::CompletionsRequested { .. }
359            | ArgsErrorKind::NoFields { .. }
360            | ArgsErrorKind::EnumWithoutSubcommandAttribute { .. }
361            | ArgsErrorKind::MissingArgsAnnotation { .. }
362            | ArgsErrorKind::ReflectError(_) => None,
363        }
364    }
365
366    /// Returns true if this is a help request (not a real error)
367    pub const fn is_help_request(&self) -> bool {
368        matches!(self, ArgsErrorKind::HelpRequested { .. })
369    }
370
371    /// If this is a help request, returns the help text
372    pub fn help_text(&self) -> Option<&str> {
373        match self {
374            ArgsErrorKind::HelpRequested { help_text } => Some(help_text),
375            _ => None,
376        }
377    }
378}
379
380/// Format a two-column list with aligned descriptions
381fn format_two_column_list(
382    items: impl IntoIterator<Item = (String, Option<&'static str>)>,
383) -> String {
384    use std::fmt::Write;
385
386    let items: Vec<_> = items.into_iter().collect();
387
388    // Find max width for alignment
389    let max_width = items.iter().map(|(name, _)| name.len()).max().unwrap_or(0);
390
391    let mut lines = Vec::new();
392    for (name, doc) in items {
393        let mut line = String::new();
394        write!(line, "  {name}").unwrap();
395
396        // Pad to alignment
397        let padding = max_width.saturating_sub(name.len());
398        for _ in 0..padding {
399            line.push(' ');
400        }
401
402        if let Some(doc) = doc {
403            write!(line, "  {}", doc.trim()).unwrap();
404        }
405
406        lines.push(line);
407    }
408    lines.join("\n")
409}
410
411/// Format available flags for help text (from static field info)
412fn format_available_flags(fields: &'static [Field]) -> String {
413    let items = fields.iter().filter_map(|field| {
414        if field.has_attr(Some("args"), "subcommand") {
415            return None;
416        }
417
418        let short = get_short_flag(field);
419        let positional = field.has_attr(Some("args"), "positional");
420        let kebab = field.name.to_kebab_case();
421
422        let name = if positional {
423            match short {
424                Some(s) => format!("-{s}, <{kebab}>"),
425                None => format!("    <{kebab}>"),
426            }
427        } else {
428            match short {
429                Some(s) => format!("-{s}, --{kebab}"),
430                None => format!("    --{kebab}"),
431            }
432        };
433
434        Some((name, field.doc.first().copied()))
435    });
436
437    format_two_column_list(items)
438}
439
440/// Format available subcommands for help text (from static variant info)
441fn format_available_subcommands(variants: &'static [Variant]) -> String {
442    let items = variants.iter().map(|variant| {
443        let name = variant
444            .get_builtin_attr("rename")
445            .and_then(|attr| attr.get_as::<&str>())
446            .map(|s| (*s).to_string())
447            .unwrap_or_else(|| variant.name.to_kebab_case());
448
449        (name, variant.doc.first().copied())
450    });
451
452    format_two_column_list(items)
453}
454
455/// Get the short flag character for a field, if any
456fn get_short_flag(field: &Field) -> Option<char> {
457    field
458        .get_attr(Some("args"), "short")
459        .and_then(|attr| attr.get_as::<crate::Attr>())
460        .and_then(|attr| {
461            if let crate::Attr::Short(c) = attr {
462                // If explicit char provided, use it; otherwise use first char of field name
463                c.or_else(|| field.name.chars().next())
464            } else {
465                None
466            }
467        })
468}
469
470/// Find a similar flag name using simple heuristics
471fn find_similar_flag(input: &str, fields: &'static [Field]) -> Option<String> {
472    for field in fields {
473        let kebab = field.name.to_kebab_case();
474        if is_similar(input, &kebab) {
475            return Some(kebab);
476        }
477    }
478    None
479}
480
481/// Find a similar subcommand name using simple heuristics
482fn find_similar_subcommand(input: &str, variants: &'static [Variant]) -> Option<String> {
483    for variant in variants {
484        // Check for rename attribute first
485        let name = variant
486            .get_builtin_attr("rename")
487            .and_then(|attr| attr.get_as::<&str>())
488            .map(|s| (*s).to_string())
489            .unwrap_or_else(|| variant.name.to_kebab_case());
490        if is_similar(input, &name) {
491            return Some(name);
492        }
493    }
494    None
495}
496
497/// Check if two strings are similar (differ by at most 2 edits)
498fn is_similar(a: &str, b: &str) -> bool {
499    if a == b {
500        return true;
501    }
502    let len_diff = (a.len() as isize - b.len() as isize).abs();
503    if len_diff > 2 {
504        return false;
505    }
506
507    // Simple check: count character differences
508    let mut diffs = 0;
509    let a_chars: Vec<char> = a.chars().collect();
510    let b_chars: Vec<char> = b.chars().collect();
511
512    for (ac, bc) in a_chars.iter().zip(b_chars.iter()) {
513        if ac != bc {
514            diffs += 1;
515        }
516    }
517    diffs += len_diff as usize;
518    diffs <= 2
519}
520
521/// Get the inner type identifier, unwrapping Option if present
522const fn unwrap_option_type(shape: &'static Shape) -> &'static str {
523    match shape.def {
524        facet_core::Def::Option(opt_def) => opt_def.t.type_identifier,
525        _ => shape.type_identifier,
526    }
527}
528
529/// Format a ReflectError into a user-friendly message
530fn format_reflect_error(err: &ReflectError) -> String {
531    use facet_reflect::ReflectErrorKind::*;
532    match &err.kind {
533        ParseFailed { shape, .. } => {
534            // Use the same nice message format as OperationFailed with "Failed to parse"
535            let inner_type = unwrap_option_type(shape);
536            format!("invalid value for `{inner_type}`")
537        }
538        OperationFailed { shape, operation } => {
539            // Improve common operation failure messages
540            // Unwrap Option to show the inner type
541            let inner_type = unwrap_option_type(shape);
542
543            // Check for subcommand-specific error message
544            if operation.starts_with("Subcommands must be provided") {
545                return operation.to_string();
546            }
547
548            match *operation {
549                "Type does not support parsing from string" => {
550                    format!("`{inner_type}` cannot be parsed from a string value")
551                }
552                "Failed to parse string value" => {
553                    format!("invalid value for `{inner_type}`")
554                }
555                _ => format!("`{inner_type}`: {operation}"),
556            }
557        }
558        UninitializedField { shape, field_name } => {
559            format!(
560                "field `{}` of `{}` was not provided",
561                field_name, shape.type_identifier
562            )
563        }
564        WrongShape { expected, actual } => {
565            format!(
566                "expected `{}`, got `{}`",
567                expected.type_identifier, actual.type_identifier
568            )
569        }
570        // Format the error kind with a nicely formatted path (if non-empty)
571        _ => {
572            if err.path.is_empty() {
573                format!("{}", err.kind)
574            } else {
575                format!("{} at {}", err.kind, err.path)
576            }
577        }
578    }
579}
580
581impl core::fmt::Display for ArgsErrorKind {
582    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
583        write!(f, "{}", self.label())
584    }
585}
586
587impl From<ReflectError> for ArgsErrorKind {
588    fn from(error: ReflectError) -> Self {
589        ArgsErrorKind::ReflectError(error)
590    }
591}
592
593impl ArgsError {
594    /// Creates a new args error
595    #[cfg(test)]
596    pub const fn new(kind: ArgsErrorKind, span: Span) -> Self {
597        Self { span, kind }
598    }
599}
600
601impl fmt::Display for ArgsError {
602    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
603        fmt::Debug::fmt(self, f)
604    }
605}
606
607mod ariadne_impl {
608    use super::*;
609    use crate::color::should_use_color;
610    use ariadne::{Color, Config, Label, Report, ReportKind, Source};
611    use facet_pretty::{PathSegment, format_shape_with_spans};
612    use std::borrow::Cow;
613
614    impl ArgsErrorWithInput {
615        /// Returns an Ariadne report builder for this error.
616        ///
617        /// The report uses `std::ops::Range<usize>` as the span type, suitable for
618        /// use with `ariadne::Source::from(&self.flattened_args)`.
619        pub fn to_ariadne_report(&self) -> Report<'static, core::ops::Range<usize>> {
620            let should_use_color = should_use_color();
621
622            // Skip help requests - they're not real errors
623            if self.is_help_request() {
624                return Report::build(ReportKind::Custom("Help", Color::Cyan), 0..0)
625                    .with_config(Config::default().with_color(should_use_color))
626                    .with_message(self.help_text().unwrap_or(""))
627                    .finish();
628            }
629
630            if let Some(diag) = self.shape_diagnostics() {
631                let formatted = format_shape_with_spans(diag.shape);
632                let missing_path = vec![PathSegment::Field(Cow::Borrowed(diag.field.name))];
633
634                if let Some(field_span) = formatted.spans.get(&missing_path) {
635                    let span = field_span.key.0..field_span.value.1;
636
637                    let mut builder = Report::build(ReportKind::Error, span.clone())
638                        .with_config(Config::default().with_color(should_use_color))
639                        .with_code(self.inner.kind.code())
640                        .with_message(self.inner.kind.label());
641
642                    let def_end_span = formatted.type_end_span.map(|(start, end)| start..end);
643                    if let Some(type_name_span) = formatted.type_name_span {
644                        let type_label_span = type_name_span.0..type_name_span.1;
645
646                        let source_label = diag
647                            .shape
648                            .source_file
649                            .zip(diag.shape.source_line)
650                            .map(|(file, line)| format!("defined at {file}:{line}"))
651                            .unwrap_or_else(|| {
652                                "definition location unavailable (enable facet/doc)".to_string()
653                            });
654
655                        let mut label = Label::new(type_label_span).with_message(source_label);
656                        if should_use_color {
657                            label = label.with_color(Color::Blue);
658                        }
659
660                        builder = builder.with_label(label);
661                    }
662
663                    let mut label = Label::new(span)
664                        .with_message("THIS IS WHERE YOU FORGOT A facet(args::) annotation");
665                    if should_use_color {
666                        label = label.with_color(Color::Red);
667                    }
668                    builder = builder.with_label(label);
669
670                    if let Some(def_end_span) = def_end_span {
671                        let mut label = Label::new(def_end_span).with_message("end of definition");
672                        if should_use_color {
673                            label = label.with_color(Color::Blue);
674                        }
675                        builder = builder.with_label(label);
676                    }
677
678                    return builder.finish();
679                }
680            }
681
682            // Use precise_span if available (e.g., for chained short flags)
683            let span = self.inner.kind.precise_span().unwrap_or(self.inner.span);
684            let range = span.start..(span.start + span.len);
685
686            let mut builder = Report::build(ReportKind::Error, range.clone())
687                .with_config(Config::default().with_color(should_use_color))
688                .with_code(self.inner.kind.code())
689                .with_message(self.inner.kind.label());
690
691            // Add the primary label
692            let mut label = Label::new(range).with_message(self.inner.kind.label());
693            if should_use_color {
694                label = label.with_color(Color::Red);
695            }
696            builder = builder.with_label(label);
697
698            // Add help text as a note if available
699            if let Some(help) = self.inner.kind.help() {
700                builder = builder.with_help(help.to_string());
701            }
702
703            builder.finish()
704        }
705
706        /// Writes the error as a pretty-printed Ariadne diagnostic to the given writer.
707        ///
708        /// This creates a source from the flattened CLI arguments and renders the
709        /// error report with source context.
710        pub fn write_ariadne(&self, writer: impl std::io::Write) -> std::io::Result<()> {
711            if let Some(diag) = self.shape_diagnostics() {
712                let formatted = format_shape_with_spans(diag.shape);
713                let source = Source::from(&formatted.text);
714                return self.to_ariadne_report().write(source, writer);
715            }
716
717            let source = Source::from(&self.flattened_args);
718            self.to_ariadne_report().write(source, writer)
719        }
720
721        /// Returns the error as a pretty-printed Ariadne diagnostic string.
722        ///
723        /// This is a convenience method that calls [`write_ariadne`](Self::write_ariadne)
724        /// with an in-memory buffer.
725        pub fn to_ariadne_string(&self) -> String {
726            let mut buf = Vec::new();
727            // write_ariadne only fails on IO errors, which won't happen with Vec
728            self.write_ariadne(&mut buf).expect("write to Vec failed");
729            String::from_utf8(buf).expect("ariadne output is valid UTF-8")
730        }
731    }
732}
733
734#[cfg(test)]
735mod tests {
736    use super::*;
737    use crate as args;
738    use facet::Facet;
739
740    #[test]
741    fn debug_missing_args_annotation_example() {
742        #[derive(Facet)]
743        struct App {
744            #[facet(args::named)]
745            verbose: bool,
746            config_path: String,
747        }
748
749        let shape = App::SHAPE;
750        let field = match &shape.ty {
751            Type::User(UserType::Struct(s)) => s
752                .fields
753                .iter()
754                .find(|f| f.name == "config_path")
755                .expect("config_path field"),
756            _ => panic!("expected struct shape"),
757        };
758
759        let err = ArgsErrorWithInput {
760            inner: ArgsError::new(
761                ArgsErrorKind::MissingArgsAnnotation { field, shape },
762                Span::new(0, 0),
763            ),
764            flattened_args: String::new(),
765        };
766
767        // Verify error renders correctly
768        let rendered = err.to_ariadne_string();
769        assert!(
770            rendered.contains("config_path"),
771            "error should mention the field name: {}",
772            rendered
773        );
774        assert!(
775            rendered.contains("args::"),
776            "error should mention args annotation: {}",
777            rendered
778        );
779    }
780}