Skip to main content

fallow_core/
errors.rs

1use std::path::PathBuf;
2
3/// The underlying error kind, describing what went wrong.
4#[derive(Debug)]
5#[expect(
6    clippy::enum_variant_names,
7    reason = "Error suffix is intentional for error variants"
8)]
9pub(crate) enum FallowErrorKind {
10    /// Failed to read a source file.
11    FileReadError {
12        path: PathBuf,
13        source: std::io::Error,
14    },
15    /// Failed to parse a source file (syntax errors).
16    ParseError { path: PathBuf, errors: Vec<String> },
17    /// Failed to resolve an import.
18    ResolveError {
19        from_file: PathBuf,
20        specifier: String,
21    },
22    /// Configuration error.
23    ConfigError { message: String },
24}
25
26/// Errors that can occur during analysis.
27///
28/// Wraps a `FallowErrorKind` with optional diagnostic metadata:
29/// an error code, actionable help text, and additional context.
30#[derive(Debug)]
31pub struct FallowError {
32    /// The underlying error kind (boxed to keep `Result<T, FallowError>` small).
33    kind: Box<FallowErrorKind>,
34    /// Optional error code (e.g. `"E001"`).
35    code: Option<String>,
36    /// Actionable suggestion for the user.
37    help: Option<String>,
38    /// Additional context about the error.
39    context: Option<String>,
40}
41
42impl FallowError {
43    /// Create a new `FallowError` from a kind.
44    #[must_use]
45    fn new(kind: FallowErrorKind) -> Self {
46        Self {
47            kind: Box::new(kind),
48            code: None,
49            help: None,
50            context: None,
51        }
52    }
53
54    /// Create a file-read error with default help text.
55    pub fn file_read(path: impl Into<PathBuf>, source: std::io::Error) -> Self {
56        Self::new(FallowErrorKind::FileReadError {
57            path: path.into(),
58            source,
59        })
60        .with_code("E001")
61        .with_help("Check that the file exists and is readable")
62    }
63
64    /// Create a parse error with default help text.
65    pub fn parse(path: impl Into<PathBuf>, errors: Vec<String>) -> Self {
66        Self::new(FallowErrorKind::ParseError {
67            path: path.into(),
68            errors,
69        })
70        .with_code("E002")
71        .with_help(
72            "This may indicate unsupported syntax. Consider adding the file to the ignore list.",
73        )
74    }
75
76    /// Create a resolve error with default help text.
77    pub fn resolve(from_file: impl Into<PathBuf>, specifier: impl Into<String>) -> Self {
78        Self::new(FallowErrorKind::ResolveError {
79            from_file: from_file.into(),
80            specifier: specifier.into(),
81        })
82        .with_code("E003")
83        .with_help("Check that the module is installed and the import path is correct")
84    }
85
86    /// Create a config error with default error code.
87    pub fn config(message: impl Into<String>) -> Self {
88        Self::new(FallowErrorKind::ConfigError {
89            message: message.into(),
90        })
91        .with_code("E004")
92    }
93
94    /// Attach an error code (e.g. `"E001"`).
95    #[must_use]
96    pub fn with_code(mut self, code: impl Into<String>) -> Self {
97        self.code = Some(code.into());
98        self
99    }
100
101    /// Attach actionable help text.
102    #[must_use]
103    pub fn with_help(mut self, help: impl Into<String>) -> Self {
104        self.help = Some(help.into());
105        self
106    }
107
108    /// Attach additional context about the error.
109    #[must_use]
110    pub fn with_context(mut self, context: impl Into<String>) -> Self {
111        self.context = Some(context.into());
112        self
113    }
114
115    /// Returns the error kind.
116    #[cfg(test)]
117    fn kind(&self) -> &FallowErrorKind {
118        &self.kind
119    }
120
121    /// Returns the error code, if set.
122    #[must_use]
123    pub fn code(&self) -> Option<&str> {
124        self.code.as_deref()
125    }
126
127    /// Returns the help text, if set.
128    #[must_use]
129    pub fn help(&self) -> Option<&str> {
130        self.help.as_deref()
131    }
132
133    /// Returns the context string, if set.
134    #[must_use]
135    pub fn context(&self) -> Option<&str> {
136        self.context.as_deref()
137    }
138}
139
140impl std::fmt::Display for FallowError {
141    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
142        // Error code prefix: "error[E001]: ..." or "error: ..."
143        if let Some(ref code) = self.code {
144            write!(f, "error[{code}]: ")?;
145        } else {
146            write!(f, "error: ")?;
147        }
148
149        // Main message from the kind
150        match &*self.kind {
151            FallowErrorKind::FileReadError { path, source } => {
152                write!(f, "Failed to read {}: {source}", path.display())?;
153            }
154            FallowErrorKind::ParseError { path, errors } => match errors.len() {
155                0 | 1 => write!(f, "Parse error in {}", path.display())?,
156                n => write!(f, "Parse errors in {} ({n} errors)", path.display())?,
157            },
158            FallowErrorKind::ResolveError {
159                from_file,
160                specifier,
161            } => {
162                write!(
163                    f,
164                    "Cannot resolve '{}' from {}",
165                    specifier,
166                    from_file.display()
167                )?;
168            }
169            FallowErrorKind::ConfigError { message } => {
170                write!(f, "Configuration error: {message}")?;
171            }
172        }
173
174        // Context line
175        if let Some(ref context) = self.context {
176            write!(f, "\n  context: {context}")?;
177        }
178
179        // Help line
180        if let Some(ref help) = self.help {
181            write!(f, "\n  help: {help}")?;
182        }
183
184        Ok(())
185    }
186}
187
188impl std::error::Error for FallowError {
189    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
190        match &*self.kind {
191            FallowErrorKind::FileReadError { source, .. } => Some(source),
192            _ => None,
193        }
194    }
195}
196
197#[cfg(test)]
198mod tests {
199    use super::*;
200
201    // ── Display tests (struct variants via constructors) ──────────
202
203    #[test]
204    fn fallow_error_display_file_read() {
205        let err = FallowError::file_read(
206            PathBuf::from("test.ts"),
207            std::io::Error::new(std::io::ErrorKind::NotFound, "not found"),
208        );
209        let msg = format!("{err}");
210        assert!(msg.contains("test.ts"));
211        assert!(msg.contains("not found"));
212        assert!(msg.contains("E001"));
213        assert!(msg.contains("help:"));
214    }
215
216    #[test]
217    fn fallow_error_display_parse() {
218        let err = FallowError::parse(
219            PathBuf::from("bad.ts"),
220            vec![
221                "unexpected token".to_string(),
222                "missing semicolon".to_string(),
223            ],
224        );
225        let msg = format!("{err}");
226        assert!(msg.contains("bad.ts"));
227        assert!(msg.contains("2 errors"));
228        assert!(msg.contains("E002"));
229        assert!(msg.contains("help:"));
230    }
231
232    #[test]
233    fn fallow_error_display_resolve() {
234        let err = FallowError::resolve(PathBuf::from("src/index.ts"), "./missing");
235        let msg = format!("{err}");
236        assert!(msg.contains("./missing"));
237        assert!(msg.contains("src/index.ts"));
238        assert!(msg.contains("E003"));
239    }
240
241    #[test]
242    fn fallow_error_display_config() {
243        let err = FallowError::config("invalid TOML");
244        let msg = format!("{err}");
245        assert!(msg.contains("invalid TOML"));
246        assert!(msg.contains("E004"));
247    }
248
249    // ── Builder method tests ─────────────────────────────────────
250
251    #[test]
252    fn with_help_appends_help_line() {
253        let err =
254            FallowError::config("bad config").with_help("Check the configuration file syntax");
255        let msg = format!("{err}");
256        assert!(msg.contains("help: Check the configuration file syntax"));
257    }
258
259    #[test]
260    fn with_context_appends_context_line() {
261        let err = FallowError::config("bad config").with_context("while loading fallow.toml");
262        let msg = format!("{err}");
263        assert!(msg.contains("context: while loading fallow.toml"));
264    }
265
266    #[test]
267    fn with_code_overrides_default_code() {
268        let err = FallowError::config("bad config").with_code("E999");
269        let msg = format!("{err}");
270        assert!(msg.contains("error[E999]:"));
271        assert!(!msg.contains("E004"));
272    }
273
274    #[test]
275    fn builder_methods_chain() {
276        let err = FallowError::config("parse failure")
277            .with_code("E100")
278            .with_help("Try running `fallow init`")
279            .with_context("in fallow.jsonc at line 5");
280        let msg = format!("{err}");
281        assert!(msg.contains("error[E100]:"));
282        assert!(msg.contains("parse failure"));
283        assert!(msg.contains("context: in fallow.jsonc at line 5"));
284        assert!(msg.contains("help: Try running `fallow init`"));
285    }
286
287    #[test]
288    fn error_without_code_shows_plain_prefix() {
289        let err = FallowError::new(FallowErrorKind::ConfigError {
290            message: "test".into(),
291        });
292        let msg = format!("{err}");
293        assert!(msg.starts_with("error: "));
294        assert!(!msg.contains('['));
295    }
296
297    // ── Accessor tests ───────────────────────────────────────────
298
299    #[test]
300    fn accessors_return_expected_values() {
301        let err = FallowError::file_read(
302            "a.ts",
303            std::io::Error::new(std::io::ErrorKind::PermissionDenied, "denied"),
304        )
305        .with_context("ctx");
306
307        assert_eq!(err.code(), Some("E001"));
308        assert!(err.help().is_some());
309        assert_eq!(err.context(), Some("ctx"));
310        assert!(matches!(err.kind(), FallowErrorKind::FileReadError { .. }));
311    }
312
313    #[test]
314    fn accessors_none_when_unset() {
315        let err = FallowError::new(FallowErrorKind::ConfigError {
316            message: "x".into(),
317        });
318        assert!(err.code().is_none());
319        assert!(err.help().is_none());
320        assert!(err.context().is_none());
321    }
322
323    // ── Display format tests ─────────────────────────────────────
324
325    #[test]
326    fn context_appears_before_help() {
327        let err = FallowError::config("oops")
328            .with_context("loading config")
329            .with_help("fix it");
330        let msg = format!("{err}");
331        let ctx_pos = msg.find("context:").expect("context present");
332        let help_pos = msg.find("help:").expect("help present");
333        assert!(ctx_pos < help_pos, "context should appear before help");
334    }
335
336    #[test]
337    fn file_read_default_help_mentions_exists() {
338        let err = FallowError::file_read(
339            "x.ts",
340            std::io::Error::new(std::io::ErrorKind::NotFound, "missing"),
341        );
342        assert!(err.help().unwrap().contains("exists"));
343    }
344
345    #[test]
346    fn parse_default_help_mentions_ignore() {
347        let err = FallowError::parse("x.ts", vec!["err".into()]);
348        assert!(err.help().unwrap().contains("ignore"));
349    }
350
351    #[test]
352    fn resolve_default_help_mentions_installed() {
353        let err = FallowError::resolve("a.ts", "./b");
354        assert!(err.help().unwrap().contains("installed"));
355    }
356
357    // ── std::error::Error trait ─────────────────────────────────
358
359    #[test]
360    fn file_read_error_has_source() {
361        let err = FallowError::file_read(
362            "a.ts",
363            std::io::Error::new(std::io::ErrorKind::NotFound, "missing"),
364        );
365        assert!(
366            std::error::Error::source(&err).is_some(),
367            "FileReadError should expose the underlying io::Error"
368        );
369    }
370
371    #[test]
372    fn non_io_errors_have_no_source() {
373        let err = FallowError::config("bad");
374        assert!(std::error::Error::source(&err).is_none());
375
376        let err = FallowError::resolve("a.ts", "./b");
377        assert!(std::error::Error::source(&err).is_none());
378
379        let err = FallowError::parse("a.ts", vec!["err".into()]);
380        assert!(std::error::Error::source(&err).is_none());
381    }
382
383    // ── Parse error edge cases ──────────────────────────────────
384
385    #[test]
386    fn parse_single_error_no_count() {
387        let err = FallowError::parse("bad.ts", vec!["unexpected token".into()]);
388        let msg = format!("{err}");
389        // Single error: no "(N errors)" suffix
390        assert!(!msg.contains("errors)"));
391        assert!(msg.contains("Parse error in"));
392    }
393
394    #[test]
395    fn parse_zero_errors_no_count() {
396        let err = FallowError::parse("bad.ts", vec![]);
397        let msg = format!("{err}");
398        assert!(!msg.contains("errors)"));
399        assert!(msg.contains("Parse error in"));
400    }
401}