1mod miette;
41
42pub use miette::{bundler_error_to_miette, cli_error_to_miette};
43
44use std::path::PathBuf;
45use thiserror::Error;
46
47#[derive(Debug, Error)]
52pub enum CliError {
53 #[error("Configuration error: {0}")]
55 Config(#[from] ConfigError),
56
57 #[error("Build error: {0}")]
59 Build(#[from] BuildError),
60
61 #[error("Invalid argument: {0}")]
63 InvalidArgument(String),
64
65 #[error("File not found: {}", .0.display())]
67 FileNotFound(PathBuf),
68
69 #[error("I/O error: {0}")]
71 Io(#[from] std::io::Error),
72
73 #[error("Server error: {0}")]
75 Server(String),
76
77 #[error("File watcher error: {0}")]
79 Watch(#[from] notify::Error),
80
81 #[error("JSON error: {0}")]
83 Json(#[from] serde_json::Error),
84
85 #[error("Core bundler error: {0}")]
87 Core(String),
88
89 #[error("{0}")]
91 Custom(String),
92}
93
94#[derive(Debug, Error)]
99pub enum ConfigError {
100 #[error("Config file not found: {}\n\nHint: Create a fob.config.json file or specify --config <path>", .0.display())]
102 NotFound(PathBuf),
103
104 #[error("Invalid JSON in config file: {0}\n\nHint: Use a JSON validator to check syntax")]
106 InvalidJson(#[from] serde_json::Error),
107
108 #[error("Schema validation failed:\n{errors}\n\nHint: Run 'fob config validate' to see detailed errors")]
110 ValidationFailed {
111 errors: String,
113 },
114
115 #[error("Profile '{0}' not found in config\n\nHint: Available profiles can be listed with 'fob config list-profiles'")]
117 ProfileNotFound(String),
118
119 #[error("Conflicting options: {0}\n\nHint: These options cannot be used together")]
121 ConflictingOptions(String),
122
123 #[error("Missing required field: {field}\n\nHint: {hint}")]
125 MissingField {
126 field: String,
128 hint: String,
130 },
131
132 #[error("Invalid value for '{field}': {value}\n\nHint: {hint}")]
134 InvalidValue {
135 field: String,
137 value: String,
139 hint: String,
141 },
142
143 #[error("Failed to read config file: {0}")]
145 Io(#[from] std::io::Error),
146}
147
148#[derive(Debug, Error)]
153pub enum BuildError {
154 #[error("Entry point not found: {}\n\nHint: Check the 'entry' field in your config or --entry argument", .0.display())]
156 EntryNotFound(PathBuf),
157
158 #[error("Failed to write asset: {0}\n\nHint: Check output directory permissions")]
160 AssetWriteFailed(String),
161
162 #[error("External dependency '{0}' is invalid\n\nHint: External dependencies should be package names or URL patterns")]
164 InvalidExternal(String),
165
166 #[error("Failed to resolve module: {module}\n\nImported from: {}\n\nHint: {hint}", .importer.display())]
168 ResolutionFailed {
169 module: String,
171 importer: PathBuf,
173 hint: String,
175 },
176
177 #[error("Circular dependency detected:\n{cycle}\n\nHint: Refactor to remove circular imports")]
179 CircularDependency {
180 cycle: String,
182 },
183
184 #[error("Transform error in {}: {error}\n\nHint: {hint}", .file.display())]
186 TransformError {
187 file: PathBuf,
189 error: String,
191 hint: String,
193 },
194
195 #[error("Source map error: {0}\n\nHint: Disable source maps with --no-sourcemap or check input source maps")]
197 SourceMapError(String),
198
199 #[error("Output directory is not writable: {}\n\nHint: Check directory permissions or specify a different --outdir", .0.display())]
201 OutputNotWritable(PathBuf),
202
203 #[error("{0}")]
205 Custom(String),
206}
207
208pub type Result<T, E = CliError> = std::result::Result<T, E>;
212
213pub trait ResultExt<T> {
218 fn with_path(self, path: impl AsRef<std::path::Path>) -> Result<T>;
233
234 fn with_hint(self, hint: impl std::fmt::Display) -> Result<T>;
251
252 fn context(self, msg: impl std::fmt::Display) -> Result<T>;
268}
269
270impl<T, E: Into<CliError>> ResultExt<T> for std::result::Result<T, E> {
271 fn with_path(self, path: impl AsRef<std::path::Path>) -> Result<T> {
272 self.map_err(|e| {
273 let err: CliError = e.into();
274 match err {
276 CliError::Io(io_err) if io_err.kind() == std::io::ErrorKind::NotFound => {
277 CliError::FileNotFound(path.as_ref().to_path_buf())
278 }
279 other => other,
280 }
281 })
282 }
283
284 fn with_hint(self, hint: impl std::fmt::Display) -> Result<T> {
285 self.map_err(|e| {
286 let err: CliError = e.into();
287 CliError::Custom(format!("{}\n\nHint: {}", err, hint))
289 })
290 }
291
292 fn context(self, msg: impl std::fmt::Display) -> Result<T> {
293 self.map_err(|e| {
294 let err: CliError = e.into();
295 CliError::Custom(format!("{}: {}", msg, err))
296 })
297 }
298}
299
300#[cfg(test)]
301mod tests {
302 use super::*;
303
304 #[test]
305 fn test_config_error_not_found() {
306 let err = ConfigError::NotFound(PathBuf::from("fob.config.json"));
307 let msg = err.to_string();
308 assert!(msg.contains("Config file not found"));
309 assert!(msg.contains("fob.config.json"));
310 assert!(msg.contains("Hint:"));
311 }
312
313 #[test]
314 fn test_config_error_profile_not_found() {
315 let err = ConfigError::ProfileNotFound("production".to_string());
316 let msg = err.to_string();
317 assert!(msg.contains("Profile 'production' not found"));
318 assert!(msg.contains("Hint:"));
319 }
320
321 #[test]
322 fn test_build_error_entry_not_found() {
323 let err = BuildError::EntryNotFound(PathBuf::from("src/index.ts"));
324 let msg = err.to_string();
325 assert!(msg.contains("Entry point not found"));
326 assert!(msg.contains("src/index.ts"));
327 assert!(msg.contains("Hint:"));
328 }
329
330 #[test]
331 fn test_build_error_resolution_failed() {
332 let err = BuildError::ResolutionFailed {
333 module: "@/components/Button".to_string(),
334 importer: PathBuf::from("src/App.tsx"),
335 hint: "Check your path aliases in config".to_string(),
336 };
337 let msg = err.to_string();
338 assert!(msg.contains("Failed to resolve module"));
339 assert!(msg.contains("@/components/Button"));
340 assert!(msg.contains("src/App.tsx"));
341 assert!(msg.contains("Hint:"));
342 }
343
344 #[test]
345 fn test_cli_error_from_config_error() {
346 let config_err = ConfigError::NotFound(PathBuf::from("test.json"));
347 let cli_err: CliError = config_err.into();
348 assert!(matches!(cli_err, CliError::Config(_)));
349 }
350
351 #[test]
352 fn test_cli_error_from_build_error() {
353 let build_err = BuildError::EntryNotFound(PathBuf::from("index.ts"));
354 let cli_err: CliError = build_err.into();
355 assert!(matches!(cli_err, CliError::Build(_)));
356 }
357
358 #[test]
359 fn test_result_ext_with_path() {
360 let result: std::io::Result<()> = Err(std::io::Error::new(
361 std::io::ErrorKind::NotFound,
362 "file not found",
363 ));
364
365 let err = result.with_path("/test/path.txt").unwrap_err();
366 assert!(matches!(err, CliError::FileNotFound(_)));
367 }
368
369 #[test]
370 fn test_result_ext_with_hint() {
371 let result: std::result::Result<(), ConfigError> =
372 Err(ConfigError::NotFound(PathBuf::from("test.json")));
373
374 let err = result.with_hint("Try creating the file").unwrap_err();
375 let msg = err.to_string();
376 assert!(msg.contains("Hint: Try creating the file"));
377 }
378
379 #[test]
380 fn test_result_ext_context() {
381 let result: std::result::Result<(), ConfigError> =
382 Err(ConfigError::NotFound(PathBuf::from("test.json")));
383
384 let err = result.context("Failed to initialize").unwrap_err();
385 let msg = err.to_string();
386 assert!(msg.contains("Failed to initialize"));
387 }
388
389 #[test]
390 fn test_config_error_missing_field() {
391 let err = ConfigError::MissingField {
392 field: "entry".to_string(),
393 hint: "Add 'entry' field to your config".to_string(),
394 };
395 let msg = err.to_string();
396 assert!(msg.contains("Missing required field: entry"));
397 assert!(msg.contains("Hint: Add 'entry' field"));
398 }
399
400 #[test]
401 fn test_config_error_invalid_value() {
402 let err = ConfigError::InvalidValue {
403 field: "format".to_string(),
404 value: "invalid".to_string(),
405 hint: "Must be 'esm' or 'cjs'".to_string(),
406 };
407 let msg = err.to_string();
408 assert!(msg.contains("Invalid value for 'format'"));
409 assert!(msg.contains("invalid"));
410 assert!(msg.contains("Must be 'esm' or 'cjs'"));
411 }
412
413 #[test]
414 fn test_build_error_circular_dependency() {
415 let err = BuildError::CircularDependency {
416 cycle: "A -> B -> C -> A".to_string(),
417 };
418 let msg = err.to_string();
419 assert!(msg.contains("Circular dependency"));
420 assert!(msg.contains("A -> B -> C -> A"));
421 }
422}