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