1use std::path::PathBuf;
2
3#[derive(Debug)]
5#[expect(
6 clippy::enum_variant_names,
7 reason = "Error suffix is intentional for error variants"
8)]
9pub(crate) enum FallowErrorKind {
10 FileReadError {
12 path: PathBuf,
13 source: std::io::Error,
14 },
15 ParseError { path: PathBuf, errors: Vec<String> },
17 ResolveError {
19 from_file: PathBuf,
20 specifier: String,
21 },
22 ConfigError { message: String },
24}
25
26#[derive(Debug)]
31pub struct FallowError {
32 kind: Box<FallowErrorKind>,
34 code: Option<String>,
36 help: Option<String>,
38 context: Option<String>,
40}
41
42impl FallowError {
43 #[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 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 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 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 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 #[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 #[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 #[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 #[cfg(test)]
117 fn kind(&self) -> &FallowErrorKind {
118 &self.kind
119 }
120
121 #[must_use]
123 pub fn code(&self) -> Option<&str> {
124 self.code.as_deref()
125 }
126
127 #[must_use]
129 pub fn help(&self) -> Option<&str> {
130 self.help.as_deref()
131 }
132
133 #[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 if let Some(ref code) = self.code {
144 write!(f, "error[{code}]: ")?;
145 } else {
146 write!(f, "error: ")?;
147 }
148
149 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 if let Some(ref context) = self.context {
176 write!(f, "\n context: {context}")?;
177 }
178
179 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 #[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 #[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 #[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 #[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 #[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 #[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 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}