fob_cli/
error.rs

1//! Comprehensive error handling for the Joy CLI.
2//!
3//! This module provides a hierarchical error type system using `thiserror` for
4//! structured error handling with excellent error messages. Each error variant
5//! is designed to be actionable and provide context to help users resolve issues.
6//!
7//! # Architecture
8//!
9//! The error hierarchy follows these principles:
10//! - **Top-level errors** (`CliError`) represent broad categories of failures
11//! - **Domain-specific errors** (`ConfigError`, `BuildError`) provide detailed context
12//! - **Error conversion** is automatic via `#[from]` attributes
13//! - **Context helpers** allow attaching additional information to errors
14//!
15//! # Example
16//!
17//! ```rust,no_run
18//! use fob_cli::error::{Result, ResultExt, CliError};
19//! use std::path::Path;
20//! use std::str::FromStr;
21//!
22//! struct Config;
23//!
24//! impl FromStr for Config {
25//!     type Err = CliError;
26//!
27//!     fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
28//!         Ok(Config)
29//!     }
30//! }
31//!
32//! fn load_config(path: &Path) -> Result<Config> {
33//!     std::fs::read_to_string(path)
34//!         .with_path(path)?
35//!         .parse()
36//!         .with_hint("Check JSON syntax")
37//! }
38//! ```
39
40mod miette;
41
42pub use miette::{bundler_error_to_miette, cli_error_to_miette};
43
44use std::path::PathBuf;
45use thiserror::Error;
46
47/// Top-level CLI error type.
48///
49/// This is the primary error type returned by CLI commands. It automatically
50/// converts from domain-specific errors via `From` implementations.
51#[derive(Debug, Error)]
52pub enum CliError {
53    /// Configuration-related errors (file not found, invalid syntax, etc.)
54    #[error("Configuration error: {0}")]
55    Config(#[from] ConfigError),
56
57    /// Build process errors (missing entry points, asset failures, etc.)
58    #[error("Build error: {0}")]
59    Build(#[from] BuildError),
60
61    /// Invalid command-line arguments or options
62    #[error("Invalid argument: {0}")]
63    InvalidArgument(String),
64
65    /// File or directory not found
66    #[error("File not found: {}", .0.display())]
67    FileNotFound(PathBuf),
68
69    /// I/O errors from file system operations
70    #[error("I/O error: {0}")]
71    Io(#[from] std::io::Error),
72
73    /// Development server errors
74    #[error("Server error: {0}")]
75    Server(String),
76
77    /// File watching errors
78    #[error("File watcher error: {0}")]
79    Watch(#[from] notify::Error),
80
81    /// JSON serialization/deserialization errors
82    #[error("JSON error: {0}")]
83    Json(#[from] serde_json::Error),
84
85    /// Errors from the core bundler
86    #[error("Core bundler error: {0}")]
87    Core(String),
88
89    /// Generic errors with custom messages
90    #[error("{0}")]
91    Custom(String),
92}
93
94/// Configuration-specific errors.
95///
96/// These errors occur during config file loading, parsing, and validation.
97/// Each variant provides specific guidance on what went wrong.
98#[derive(Debug, Error)]
99pub enum ConfigError {
100    /// Config file doesn't exist at the expected location
101    #[error("Config file not found: {}\n\nHint: Create a fob.config.json file or specify --config <path>", .0.display())]
102    NotFound(PathBuf),
103
104    /// Config file has invalid JSON syntax
105    #[error("Invalid JSON in config file: {0}\n\nHint: Use a JSON validator to check syntax")]
106    InvalidJson(#[from] serde_json::Error),
107
108    /// Config file fails JSON schema validation
109    #[error("Schema validation failed:\n{errors}\n\nHint: Run 'fob config validate' to see detailed errors")]
110    ValidationFailed {
111        /// Formatted validation error messages
112        errors: String,
113    },
114
115    /// Requested profile doesn't exist in config
116    #[error("Profile '{0}' not found in config\n\nHint: Available profiles can be listed with 'fob config list-profiles'")]
117    ProfileNotFound(String),
118
119    /// Mutually exclusive options were specified
120    #[error("Conflicting options: {0}\n\nHint: These options cannot be used together")]
121    ConflictingOptions(String),
122
123    /// Missing required configuration field
124    #[error("Missing required field: {field}\n\nHint: {hint}")]
125    MissingField {
126        /// Name of the missing field
127        field: String,
128        /// Helpful hint for providing the field
129        hint: String,
130    },
131
132    /// Invalid value for a configuration option
133    #[error("Invalid value for '{field}': {value}\n\nHint: {hint}")]
134    InvalidValue {
135        /// Name of the field with invalid value
136        field: String,
137        /// The invalid value
138        value: String,
139        /// Helpful hint for correct values
140        hint: String,
141    },
142
143    /// I/O error while reading config
144    #[error("Failed to read config file: {0}")]
145    Io(#[from] std::io::Error),
146}
147
148/// Build process errors.
149///
150/// These errors occur during the bundling process, from entry point resolution
151/// to asset generation.
152#[derive(Debug, Error)]
153pub enum BuildError {
154    /// Entry point file doesn't exist
155    #[error("Entry point not found: {}\n\nHint: Check the 'entry' field in your config or --entry argument", .0.display())]
156    EntryNotFound(PathBuf),
157
158    /// Failed to write output file or asset
159    #[error("Failed to write asset: {0}\n\nHint: Check output directory permissions")]
160    AssetWriteFailed(String),
161
162    /// Invalid external dependency specification
163    #[error("External dependency '{0}' is invalid\n\nHint: External dependencies should be package names or URL patterns")]
164    InvalidExternal(String),
165
166    /// Module resolution failed
167    #[error("Failed to resolve module: {module}\n\nImported from: {}\n\nHint: {hint}", .importer.display())]
168    ResolutionFailed {
169        /// The module specifier that couldn't be resolved
170        module: String,
171        /// The file that tried to import it
172        importer: PathBuf,
173        /// Helpful hint for resolution
174        hint: String,
175    },
176
177    /// Circular dependency detected
178    #[error("Circular dependency detected:\n{cycle}\n\nHint: Refactor to remove circular imports")]
179    CircularDependency {
180        /// Formatted cycle path
181        cycle: String,
182    },
183
184    /// Transform/transpilation error
185    #[error("Transform error in {}: {error}\n\nHint: {hint}", .file.display())]
186    TransformError {
187        /// File that failed to transform
188        file: PathBuf,
189        /// The transformation error
190        error: String,
191        /// Helpful hint for fixing
192        hint: String,
193    },
194
195    /// Invalid source map
196    #[error("Source map error: {0}\n\nHint: Disable source maps with --no-sourcemap or check input source maps")]
197    SourceMapError(String),
198
199    /// Output directory is not writable
200    #[error("Output directory is not writable: {}\n\nHint: Check directory permissions or specify a different --outdir", .0.display())]
201    OutputNotWritable(PathBuf),
202
203    /// Generic build error
204    #[error("{0}")]
205    Custom(String),
206}
207
208/// Result type alias using `CliError` as the default error type.
209///
210/// This simplifies function signatures throughout the CLI.
211pub type Result<T, E = CliError> = std::result::Result<T, E>;
212
213/// Extension trait for adding context to `Result` types.
214///
215/// This trait provides convenient methods for enriching errors with additional
216/// context like file paths or helpful hints.
217pub trait ResultExt<T> {
218    /// Add a file path to the error context.
219    ///
220    /// # Example
221    ///
222    /// ```rust,no_run
223    /// # use std::path::Path;
224    /// # use fob_cli::error::{Result, ResultExt};
225    /// # fn run() -> Result<()> {
226    /// let path = Path::new("non_existent_file.txt");
227    /// std::fs::read_to_string(path)
228    ///     .with_path(path)?;
229    /// # Ok(())
230    /// # }
231    /// ```
232    fn with_path(self, path: impl AsRef<std::path::Path>) -> Result<T>;
233
234    /// Add a helpful hint to the error context.
235    ///
236    /// # Example
237    ///
238    /// ```rust,no_run
239    /// # use fob_cli::error::{Result, ResultExt, CliError};
240    /// # fn run() -> Result<()> {
241    /// fn parse_config(content: &str) -> Result<()> {
242    ///     Err(CliError::Custom("parsing failed".into()))
243    /// }
244    /// let content = r#"{ "key": "value" }"#;
245    /// parse_config(&content)
246    ///     .with_hint("Check for trailing commas in JSON")?;
247    /// # Ok(())
248    /// # }
249    /// ```
250    fn with_hint(self, hint: impl std::fmt::Display) -> Result<T>;
251
252    /// Convert to a custom error message.
253    ///
254    /// # Example
255    ///
256    /// ```rust,no_run
257    /// # use fob_cli::error::{Result, ResultExt, CliError};
258    /// # fn run() -> Result<()> {
259    /// fn operation() -> Result<()> {
260    ///     Err(CliError::Custom("something went wrong".into()))
261    /// }
262    /// operation()
263    ///     .context("Failed to initialize bundler")?;
264    /// # Ok(())
265    /// # }
266    /// ```
267    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            // Enhance the error with path information if it's an I/O error
275            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            // Wrap in custom error with hint
288            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}