Skip to main content

cmakefmt/
error.rs

1// SPDX-FileCopyrightText: Copyright 2026 Puneet Matharu
2//
3// SPDX-License-Identifier: MIT OR Apache-2.0
4
5//! Structured error types returned by parsing, config loading, and formatting.
6
7use std::fmt;
8use std::path::PathBuf;
9
10use thiserror::Error;
11
12/// Structured config/spec deserialization failure metadata used for
13/// user-facing diagnostics.
14#[derive(Debug, Clone, PartialEq, Eq)]
15#[non_exhaustive]
16pub struct FileParseError {
17    /// Parser format name, such as `TOML` or `YAML`.
18    pub format: &'static str,
19    /// Human-readable parser message.
20    pub message: Box<str>,
21    /// Optional 1-based line number.
22    pub line: Option<usize>,
23    /// Optional 1-based column number.
24    pub column: Option<usize>,
25}
26
27impl fmt::Display for FileParseError {
28    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
29        f.write_str(&self.message)
30    }
31}
32
33/// Crate-owned parser diagnostics used by [`enum@Error`] without exposing `pest`
34/// internals in the public API.
35#[derive(Debug, Clone, PartialEq, Eq)]
36#[non_exhaustive]
37pub struct ParseDiagnostic {
38    /// Human-readable parser detail.
39    pub message: Box<str>,
40    /// 1-based source line number.
41    pub line: usize,
42    /// 1-based source column number.
43    pub column: usize,
44}
45
46impl fmt::Display for ParseDiagnostic {
47    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
48        f.write_str(&self.message)
49    }
50}
51
52/// Stable parse error returned by the public library API.
53#[derive(Debug, Clone, PartialEq, Eq, Error)]
54#[error("parse error in {display_name}: {diagnostic}")]
55#[non_exhaustive]
56pub struct ParseError {
57    /// Human-facing source name, for example a path or `<stdin>`.
58    pub display_name: String,
59    /// The source text that failed to parse.
60    pub source_text: Box<str>,
61    /// The 1-based source line number where this parser chunk started.
62    pub start_line: usize,
63    /// Structured parser diagnostic.
64    pub diagnostic: ParseDiagnostic,
65}
66
67impl ParseError {
68    fn with_display_name(mut self, display_name: impl Into<String>) -> Self {
69        self.display_name = display_name.into();
70        self
71    }
72}
73
74/// Stable config-file parse error returned by the public library API.
75#[derive(Debug, Clone, PartialEq, Eq, Error)]
76#[error("config error in {path}: {details}")]
77#[non_exhaustive]
78pub struct ConfigError {
79    /// The config file that failed to deserialize.
80    pub path: PathBuf,
81    /// Structured parser details for the failure.
82    pub details: FileParseError,
83}
84
85/// Stable command-spec parse error returned by the public library API.
86#[derive(Debug, Clone, PartialEq, Eq, Error)]
87#[error("spec error in {path}: {details}")]
88#[non_exhaustive]
89pub struct SpecError {
90    /// The spec file that failed to deserialize.
91    pub path: PathBuf,
92    /// Structured parser details for the failure.
93    pub details: FileParseError,
94}
95
96/// Errors that can be returned by parsing, config loading, spec loading, or
97/// formatting operations.
98#[derive(Debug, Error)]
99#[non_exhaustive]
100pub enum Error {
101    /// A parser error annotated with source text and line-offset context.
102    #[error("{0}")]
103    Parse(#[from] ParseError),
104
105    /// A user config parse error.
106    #[error("{0}")]
107    Config(#[from] ConfigError),
108
109    /// A built-in or user override spec parse error.
110    #[error("{0}")]
111    Spec(#[from] SpecError),
112
113    /// A filesystem or stream I/O failure.
114    #[error("I/O error: {0}")]
115    Io(#[from] std::io::Error),
116
117    /// A higher-level formatter or CLI error that does not fit another
118    /// structured variant.
119    #[error("formatter error: {0}")]
120    Formatter(String),
121
122    /// A formatted line exceeded the configured line width and
123    /// `require_valid_layout` is enabled.
124    #[error(
125        "line {line_no} is {width} characters wide, exceeding the configured limit of {limit}"
126    )]
127    LayoutTooWide {
128        /// 1-based line number in the formatted output.
129        line_no: usize,
130        /// Actual character width of the offending line.
131        width: usize,
132        /// Configured [`crate::Config::line_width`] limit.
133        limit: usize,
134    },
135}
136
137/// Convenience alias for crate-level results.
138pub type Result<T> = std::result::Result<T, Error>;
139
140impl Error {
141    /// Attach a human-facing source name to a contextual parser error.
142    pub fn with_display_name(self, display_name: impl Into<String>) -> Self {
143        match self {
144            Self::Parse(parse) => Self::Parse(parse.with_display_name(display_name)),
145            other => other,
146        }
147    }
148}
149
150#[cfg(test)]
151mod tests {
152    use super::*;
153
154    #[test]
155    fn parse_diagnostic_display_shows_message() {
156        let diag = ParseDiagnostic {
157            message: "expected argument part".into(),
158            line: 5,
159            column: 10,
160        };
161        assert_eq!(diag.to_string(), "expected argument part");
162    }
163
164    #[test]
165    fn parse_diagnostic_from_parse_error() {
166        let source = "if(\n";
167        let err = crate::parser::parse(source).unwrap_err();
168        if let Error::Parse(ParseError { diagnostic, .. }) = err {
169            assert!(diagnostic.line >= 1);
170            assert!(diagnostic.column >= 1);
171            assert!(!diagnostic.message.is_empty());
172        } else {
173            panic!("expected Parse, got {err:?}");
174        }
175    }
176
177    #[test]
178    fn error_parse_display() {
179        let err = Error::Parse(ParseError {
180            display_name: "test.cmake".to_owned(),
181            source_text: "if(\n".into(),
182            start_line: 1,
183            diagnostic: ParseDiagnostic {
184                message: "expected argument part".into(),
185                line: 1,
186                column: 4,
187            },
188        });
189        let msg = err.to_string();
190        assert!(msg.contains("test.cmake"));
191        assert!(msg.contains("expected argument part"));
192    }
193
194    #[test]
195    fn error_config_display() {
196        let err = Error::Config(ConfigError {
197            path: std::path::PathBuf::from("bad.yaml"),
198            details: FileParseError {
199                format: "YAML",
200                message: "unexpected key".into(),
201                line: Some(3),
202                column: Some(1),
203            },
204        });
205        let msg = err.to_string();
206        assert!(msg.contains("bad.yaml"));
207        assert!(msg.contains("unexpected key"));
208    }
209
210    #[test]
211    fn error_spec_display() {
212        let err = Error::Spec(SpecError {
213            path: std::path::PathBuf::from("commands.yaml"),
214            details: FileParseError {
215                format: "YAML",
216                message: "invalid nargs".into(),
217                line: None,
218                column: None,
219            },
220        });
221        let msg = err.to_string();
222        assert!(msg.contains("commands.yaml"));
223        assert!(msg.contains("invalid nargs"));
224    }
225
226    #[test]
227    fn error_io_display() {
228        let err = Error::Io(std::io::Error::new(
229            std::io::ErrorKind::NotFound,
230            "file not found",
231        ));
232        assert!(err.to_string().contains("file not found"));
233    }
234
235    #[test]
236    fn error_formatter_display() {
237        let err = Error::Formatter("something went wrong".to_owned());
238        assert!(err.to_string().contains("something went wrong"));
239    }
240
241    #[test]
242    fn error_layout_too_wide_display() {
243        let err = Error::LayoutTooWide {
244            line_no: 42,
245            width: 120,
246            limit: 80,
247        };
248        let msg = err.to_string();
249        assert!(msg.contains("42"));
250        assert!(msg.contains("120"));
251        assert!(msg.contains("80"));
252    }
253
254    #[test]
255    fn with_display_name_updates_parse() {
256        let err = Error::Parse(ParseError {
257            display_name: "original".to_owned(),
258            source_text: "set(\n".into(),
259            start_line: 1,
260            diagnostic: ParseDiagnostic {
261                message: "test".into(),
262                line: 1,
263                column: 5,
264            },
265        });
266        let renamed = err.with_display_name("renamed.cmake");
267        match renamed {
268            Error::Parse(ParseError { display_name, .. }) => {
269                assert_eq!(display_name, "renamed.cmake");
270            }
271            _ => panic!("expected Parse"),
272        }
273    }
274
275    #[test]
276    fn with_display_name_passes_through_non_parse_errors() {
277        let err = Error::Formatter("test".to_owned());
278        let result = err.with_display_name("ignored");
279        match result {
280            Error::Formatter(msg) => assert_eq!(msg, "test"),
281            _ => panic!("expected Formatter to pass through"),
282        }
283    }
284
285    #[test]
286    fn io_error_converts_from_std() {
287        let io_err = std::io::Error::new(std::io::ErrorKind::PermissionDenied, "denied");
288        let err: Error = io_err.into();
289        match err {
290            Error::Io(e) => assert_eq!(e.kind(), std::io::ErrorKind::PermissionDenied),
291            _ => panic!("expected Io variant"),
292        }
293    }
294}