Skip to main content

noyalib/
i18n.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2// Copyright (c) 2026 Noyalib. All rights reserved.
3
4//! Pluggable error-message formatters for user-facing rendering.
5//!
6//! [`crate::Error`]'s `Display` impl is developer-facing — it
7//! preserves the noyalib-internal vocabulary (`!!binary`, "merge
8//! key", "recursion depth limit") that's useful for debugging but
9//! noisy for end-users of a config-loading binary. The
10//! `MessageFormatter` trait lets callers plug in their own
11//! formatting strategy: localisation tables, simplification,
12//! richer formatting, or anything else.
13//!
14//! Two implementations ship in-tree:
15//!
16//! * `DefaultFormatter` — preserves the standard developer-facing
17//!   message verbatim. Equivalent to `format!("{err}")`.
18//! * `UserFormatter` — collapses noyalib's diagnostic vocabulary
19//!   into short user-friendly sentences ("The configuration file
20//!   has a syntax error on line 5.") suitable for surfacing in
21//!   GUIs and `--help`-style command output.
22//!
23//! Use [`crate::Error::render_with_formatter`] to render an error
24//! through a chosen formatter.
25//!
26//! # Examples
27//!
28//! ```
29//! use noyalib::i18n::{DefaultFormatter, UserFormatter};
30//! use noyalib::{from_str, Value};
31//!
32//! let err = from_str::<Value>("a: [unclosed").unwrap_err();
33//! let dev = err.render_with_formatter(&DefaultFormatter);
34//! let user = err.render_with_formatter(&UserFormatter);
35//! assert!(!dev.is_empty());
36//! assert!(!user.is_empty());
37//! ```
38
39use crate::error::Error;
40use crate::prelude::*;
41
42/// Pluggable formatter for converting an [`Error`] into a
43/// user-visible message.
44///
45/// Implement this trait to plug in localisation, simplification,
46/// or rich formatting strategies. The trait is `Send + Sync` so a
47/// single formatter instance can be shared across threads.
48pub trait MessageFormatter: Send + Sync {
49    /// Render the supplied error as a single-string message.
50    fn format(&self, error: &Error) -> String;
51}
52
53/// Default formatter — preserves the standard developer-facing
54/// message verbatim.
55///
56/// Equivalent to `format!("{err}")`. The reference implementation
57/// `MessageFormatter` consumers should compare against.
58///
59/// # Examples
60///
61/// ```
62/// use noyalib::i18n::{DefaultFormatter, MessageFormatter};
63/// use noyalib::{from_str, Value};
64///
65/// let err = from_str::<Value>("a: [unclosed").unwrap_err();
66/// let s = DefaultFormatter.format(&err);
67/// assert_eq!(s, err.to_string());
68/// ```
69#[derive(Debug, Default, Clone, Copy)]
70pub struct DefaultFormatter;
71
72impl MessageFormatter for DefaultFormatter {
73    fn format(&self, error: &Error) -> String {
74        error.to_string()
75    }
76}
77
78/// User-facing formatter — collapses noyalib's diagnostic
79/// vocabulary into short, plain-language sentences appropriate
80/// for non-developer audiences (CLI `--help` text, GUI alert
81/// dialogs).
82///
83/// Maps the major [`Error`] variants onto user-readable
84/// templates:
85///
86/// | Variant family | User message |
87/// | :--- | :--- |
88/// | `Parse`, `ParseWithLocation` | `"The configuration file has a syntax error at line N."` |
89/// | `Deserialize`, `DeserializeWithLocation` | `"The configuration file does not match the expected shape."` |
90/// | `Io` | `"Could not read the configuration file."` |
91/// | `RecursionLimitExceeded`, `Budget`, `RepetitionLimitExceeded` | `"The configuration file is too large or deeply nested."` |
92/// | `DuplicateKey` | `"A configuration key appears twice."` |
93/// | `UnknownAnchor`, `UnknownAnchorAt` | `"A configuration reference points at something that does not exist."` |
94/// | `MissingField` | `"A required configuration field is missing."` |
95/// | `TypeMismatch` | `"A configuration value has the wrong type."` |
96/// | other | `"The configuration file is invalid."` |
97///
98/// Line numbers are included when the source location is
99/// available; sensitive field names and noyalib internal terms
100/// (`!!binary`, "merge key") are stripped.
101///
102/// # Examples
103///
104/// ```
105/// use noyalib::i18n::{MessageFormatter, UserFormatter};
106/// use noyalib::{from_str, Value};
107///
108/// let err = from_str::<Value>("a: [unclosed").unwrap_err();
109/// let msg = UserFormatter.format(&err);
110/// assert!(msg.contains("syntax error"));
111/// ```
112#[derive(Debug, Default, Clone, Copy)]
113pub struct UserFormatter;
114
115impl MessageFormatter for UserFormatter {
116    fn format(&self, error: &Error) -> String {
117        match error {
118            Error::Parse(_) => "The configuration file has a syntax error.".to_string(),
119            Error::ParseWithLocation { location, .. } => format!(
120                "The configuration file has a syntax error at line {}.",
121                location.line()
122            ),
123            Error::Deserialize(_) => {
124                "The configuration file does not match the expected shape.".to_string()
125            }
126            Error::DeserializeWithLocation { location, .. } => format!(
127                "The configuration file does not match the expected shape at line {}.",
128                location.line()
129            ),
130            #[cfg(feature = "std")]
131            Error::Io(_) => "Could not read the configuration file.".to_string(),
132            Error::RecursionLimitExceeded { .. }
133            | Error::Budget(_)
134            | Error::RepetitionLimitExceeded => {
135                "The configuration file is too large or deeply nested.".to_string()
136            }
137            Error::DuplicateKey(_) => "A configuration key appears twice.".to_string(),
138            Error::UnknownAnchor(_) | Error::UnknownAnchorAt { .. } => {
139                "A configuration reference points at something that does not exist.".to_string()
140            }
141            Error::MissingField(_) => "A required configuration field is missing.".to_string(),
142            Error::TypeMismatch { .. } => "A configuration value has the wrong type.".to_string(),
143            _ => "The configuration file is invalid.".to_string(),
144        }
145    }
146}
147
148impl Error {
149    /// Render this error via a custom [`MessageFormatter`].
150    ///
151    /// Pairs with [`DefaultFormatter`] (developer-facing,
152    /// verbatim) and [`UserFormatter`] (user-facing, simplified).
153    /// Callers needing localisation or rich formatting plug in
154    /// their own `MessageFormatter` impl.
155    ///
156    /// # Examples
157    ///
158    /// ```
159    /// use noyalib::i18n::UserFormatter;
160    /// use noyalib::{from_str, Value};
161    ///
162    /// let err = from_str::<Value>("a: [unclosed").unwrap_err();
163    /// let msg = err.render_with_formatter(&UserFormatter);
164    /// assert!(msg.contains("syntax error"));
165    /// ```
166    #[must_use]
167    pub fn render_with_formatter(&self, formatter: &dyn MessageFormatter) -> String {
168        formatter.format(self)
169    }
170}