1use std::path::PathBuf;
2
3#[derive(Debug)]
5#[expect(clippy::enum_variant_names)]
6pub(crate) enum FallowErrorKind {
7 FileReadError {
9 path: PathBuf,
10 source: std::io::Error,
11 },
12 ParseError { path: PathBuf, errors: Vec<String> },
14 ResolveError {
16 from_file: PathBuf,
17 specifier: String,
18 },
19 ConfigError { message: String },
21}
22
23#[derive(Debug)]
28pub struct FallowError {
29 kind: Box<FallowErrorKind>,
31 code: Option<String>,
33 help: Option<String>,
35 context: Option<String>,
37}
38
39impl FallowError {
40 #[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 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 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 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 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 #[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 #[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 #[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 #[cfg(test)]
114 fn kind(&self) -> &FallowErrorKind {
115 &self.kind
116 }
117
118 #[must_use]
120 pub fn code(&self) -> Option<&str> {
121 self.code.as_deref()
122 }
123
124 #[must_use]
126 pub fn help(&self) -> Option<&str> {
127 self.help.as_deref()
128 }
129
130 #[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 if let Some(ref code) = self.code {
141 write!(f, "error[{code}]: ")?;
142 } else {
143 write!(f, "error: ")?;
144 }
145
146 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 if let Some(ref context) = self.context {
173 write!(f, "\n context: {context}")?;
174 }
175
176 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 #[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 #[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 #[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 #[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}