Skip to main content

pitchfork_cli/
error.rs

1//! Custom diagnostic error types for rich error reporting via miette.
2//!
3//! This module provides structured error types that leverage miette's diagnostic
4//! features including error codes, help text, source code highlighting, and suggestions.
5
6// False positive: fields are used in #[error] format strings and miette derive macros
7#![allow(unused_assignments)]
8
9use miette::{Diagnostic, NamedSource, SourceSpan};
10use std::io;
11use std::path::PathBuf;
12use thiserror::Error;
13
14/// Errors related to daemon ID validation.
15#[derive(Debug, Error, Diagnostic)]
16pub enum DaemonIdError {
17    #[error("daemon ID cannot be empty")]
18    #[diagnostic(
19        code(pitchfork::daemon::empty_id),
20        url("https://pitchfork.jdx.dev/configuration"),
21        help("provide a non-empty identifier for the daemon")
22    )]
23    Empty,
24
25    #[error("daemon ID '{id}' contains path separator '{sep}'")]
26    #[diagnostic(
27        code(pitchfork::daemon::path_separator),
28        url("https://pitchfork.jdx.dev/configuration"),
29        help("daemon IDs cannot contain '/' or '\\' to prevent path traversal")
30    )]
31    PathSeparator { id: String, sep: char },
32
33    #[error("daemon ID '{id}' contains parent directory reference '..'")]
34    #[diagnostic(
35        code(pitchfork::daemon::parent_dir_ref),
36        url("https://pitchfork.jdx.dev/configuration"),
37        help("daemon IDs cannot contain '..' to prevent path traversal")
38    )]
39    ParentDirRef { id: String },
40
41    #[error("daemon ID '{id}' contains spaces")]
42    #[diagnostic(
43        code(pitchfork::daemon::contains_space),
44        url("https://pitchfork.jdx.dev/configuration"),
45        help("use hyphens or underscores instead of spaces (e.g., 'my-daemon' or 'my_daemon')")
46    )]
47    ContainsSpace { id: String },
48
49    #[error("daemon ID cannot be '.'")]
50    #[diagnostic(
51        code(pitchfork::daemon::current_dir),
52        url("https://pitchfork.jdx.dev/configuration"),
53        help("'.' refers to the current directory; use a descriptive name instead")
54    )]
55    CurrentDir,
56
57    #[error("daemon ID '{id}' contains non-printable or non-ASCII character")]
58    #[diagnostic(
59        code(pitchfork::daemon::invalid_chars),
60        url("https://pitchfork.jdx.dev/configuration"),
61        help(
62            "daemon IDs must contain only printable ASCII characters (letters, numbers, hyphens, underscores, dots)"
63        )
64    )]
65    InvalidChars { id: String },
66}
67
68/// Errors related to daemon operations.
69#[derive(Debug, Error, Diagnostic)]
70pub enum DaemonError {
71    #[error("failed to stop daemon '{id}': {error}")]
72    #[diagnostic(
73        code(pitchfork::daemon::stop_failed),
74        help("the process may be stuck or require manual intervention. Try: kill -9 <pid>")
75    )]
76    StopFailed { id: String, error: String },
77}
78
79/// Errors related to dependency resolution.
80#[derive(Debug, Error, Diagnostic)]
81pub enum DependencyError {
82    #[error("daemon '{name}' not found in configuration")]
83    #[diagnostic(
84        code(pitchfork::deps::not_found),
85        url("https://pitchfork.jdx.dev/configuration#depends")
86    )]
87    DaemonNotFound {
88        name: String,
89        #[help]
90        suggestion: Option<String>,
91    },
92
93    #[error("daemon '{daemon}' depends on '{dependency}' which is not defined")]
94    #[diagnostic(
95        code(pitchfork::deps::missing_dependency),
96        url("https://pitchfork.jdx.dev/configuration#depends"),
97        help("add the missing daemon to your pitchfork.toml or remove it from the depends list")
98    )]
99    MissingDependency { daemon: String, dependency: String },
100
101    #[error("circular dependency detected involving: {}", involved.join(", "))]
102    #[diagnostic(
103        code(pitchfork::deps::circular),
104        url("https://pitchfork.jdx.dev/configuration#depends"),
105        help("break the cycle by removing one of the dependencies")
106    )]
107    CircularDependency {
108        /// The daemons involved in the cycle
109        involved: Vec<String>,
110    },
111}
112
113/// Error for TOML configuration parse failures with source code highlighting.
114#[derive(Debug, Error, Diagnostic)]
115#[error("failed to parse configuration")]
116#[diagnostic(code(pitchfork::config::parse_error))]
117pub struct ConfigParseError {
118    /// The source file contents for display
119    #[source_code]
120    pub src: NamedSource<String>,
121
122    /// The location of the error in the source
123    #[label("{message}")]
124    pub span: SourceSpan,
125
126    /// The error message from the TOML parser
127    pub message: String,
128
129    /// Additional help text
130    #[help]
131    pub help: Option<String>,
132}
133
134impl ConfigParseError {
135    /// Create a new ConfigParseError from a toml parse error
136    pub fn from_toml_error(path: &std::path::Path, contents: String, err: toml::de::Error) -> Self {
137        let message = err.message().to_string();
138
139        // Try to get span information from the TOML error
140        let span = err
141            .span()
142            .map(|r| SourceSpan::from(r.start..r.end))
143            .unwrap_or_else(|| SourceSpan::from(0..0));
144
145        Self {
146            src: NamedSource::new(path.display().to_string(), contents),
147            span,
148            message,
149            help: Some("check TOML syntax at https://toml.io".to_string()),
150        }
151    }
152}
153
154/// Errors related to file operations (config and state files).
155#[derive(Debug, Error, Diagnostic)]
156pub enum FileError {
157    #[error("failed to read file: {}", path.display())]
158    #[diagnostic(code(pitchfork::file::read_error))]
159    ReadError {
160        path: PathBuf,
161        #[source]
162        source: io::Error,
163    },
164
165    #[error("failed to write file: {}", path.display())]
166    #[diagnostic(code(pitchfork::file::write_error))]
167    WriteError {
168        path: PathBuf,
169        #[help]
170        details: Option<String>,
171    },
172
173    #[error("failed to serialize data for file: {}", path.display())]
174    #[diagnostic(
175        code(pitchfork::file::serialize_error),
176        help("this is likely an internal error; please report it")
177    )]
178    SerializeError {
179        path: PathBuf,
180        #[source]
181        source: toml::ser::Error,
182    },
183
184    #[error("no file path specified")]
185    #[diagnostic(
186        code(pitchfork::file::no_path),
187        help("ensure a pitchfork.toml file exists in your project or specify a path")
188    )]
189    NoPath,
190}
191
192/// Errors related to IPC communication with the supervisor.
193#[derive(Debug, Error, Diagnostic)]
194pub enum IpcError {
195    #[error("failed to connect to supervisor after {attempts} attempts")]
196    #[diagnostic(
197        code(pitchfork::ipc::connection_failed),
198        url("https://pitchfork.jdx.dev/supervisor")
199    )]
200    ConnectionFailed {
201        attempts: u32,
202        #[source]
203        source: Option<io::Error>,
204        #[help]
205        help: String,
206    },
207
208    #[error("IPC request timed out after {seconds}s")]
209    #[diagnostic(
210        code(pitchfork::ipc::timeout),
211        url("https://pitchfork.jdx.dev/supervisor"),
212        help(
213            "the supervisor may be unresponsive or overloaded.\nCheck supervisor status: pitchfork supervisor status\nView logs: pitchfork logs"
214        )
215    )]
216    Timeout { seconds: u64 },
217
218    #[error("IPC connection closed unexpectedly")]
219    #[diagnostic(
220        code(pitchfork::ipc::connection_closed),
221        url("https://pitchfork.jdx.dev/supervisor"),
222        help(
223            "the supervisor may have crashed or been stopped.\nRestart with: pitchfork supervisor start"
224        )
225    )]
226    ConnectionClosed,
227
228    #[error("failed to read IPC response")]
229    #[diagnostic(code(pitchfork::ipc::read_failed))]
230    ReadFailed {
231        #[source]
232        source: io::Error,
233    },
234
235    #[error("failed to send IPC request")]
236    #[diagnostic(code(pitchfork::ipc::send_failed))]
237    SendFailed {
238        #[source]
239        source: io::Error,
240    },
241
242    #[error("unexpected response from supervisor: expected {expected}, got {actual}")]
243    #[diagnostic(
244        code(pitchfork::ipc::unexpected_response),
245        help("this may indicate a version mismatch between the CLI and supervisor")
246    )]
247    UnexpectedResponse { expected: String, actual: String },
248
249    #[error("IPC message is invalid: {reason}")]
250    #[diagnostic(code(pitchfork::ipc::invalid_message))]
251    InvalidMessage { reason: String },
252}
253
254/// A collection of multiple errors that occurred during validation or processing.
255///
256/// This is useful when you want to collect and report all validation errors at once
257/// instead of failing on the first error.
258#[derive(Debug, Error, Diagnostic)]
259#[error("multiple errors occurred ({} total)", errors.len())]
260#[diagnostic(code(pitchfork::multiple_errors))]
261#[allow(dead_code)]
262pub struct MultipleErrors {
263    #[related]
264    pub errors: Vec<Box<dyn Diagnostic + Send + Sync + 'static>>,
265}
266
267#[allow(dead_code)]
268impl MultipleErrors {
269    /// Create a new MultipleErrors from a vector of diagnostics
270    pub fn new(errors: Vec<Box<dyn Diagnostic + Send + Sync + 'static>>) -> Self {
271        Self { errors }
272    }
273
274    /// Returns true if there are no errors
275    pub fn is_empty(&self) -> bool {
276        self.errors.is_empty()
277    }
278
279    /// Returns the number of errors
280    pub fn len(&self) -> usize {
281        self.errors.len()
282    }
283}
284
285/// Find the most similar daemon name for suggestions.
286pub fn find_similar_daemon<'a>(
287    name: &str,
288    available: impl Iterator<Item = &'a str>,
289) -> Option<String> {
290    use fuzzy_matcher::FuzzyMatcher;
291    use fuzzy_matcher::skim::SkimMatcherV2;
292
293    let matcher = SkimMatcherV2::default();
294    available
295        .filter_map(|candidate| {
296            matcher
297                .fuzzy_match(candidate, name)
298                .map(|score| (candidate, score))
299        })
300        .max_by_key(|(_, score)| *score)
301        .filter(|(_, score)| *score > 0)
302        .map(|(candidate, _)| format!("did you mean '{candidate}'?"))
303}
304
305#[cfg(test)]
306mod tests {
307    use super::*;
308
309    #[test]
310    fn test_daemon_id_error_display() {
311        let err = DaemonIdError::Empty;
312        assert_eq!(err.to_string(), "daemon ID cannot be empty");
313
314        let err = DaemonIdError::PathSeparator {
315            id: "foo/bar".to_string(),
316            sep: '/',
317        };
318        assert_eq!(
319            err.to_string(),
320            "daemon ID 'foo/bar' contains path separator '/'"
321        );
322
323        let err = DaemonIdError::ContainsSpace {
324            id: "my app".to_string(),
325        };
326        assert_eq!(err.to_string(), "daemon ID 'my app' contains spaces");
327    }
328
329    #[test]
330    fn test_dependency_error_display() {
331        let err = DependencyError::DaemonNotFound {
332            name: "postgres".to_string(),
333            suggestion: None,
334        };
335        assert_eq!(
336            err.to_string(),
337            "daemon 'postgres' not found in configuration"
338        );
339
340        let err = DependencyError::MissingDependency {
341            daemon: "api".to_string(),
342            dependency: "db".to_string(),
343        };
344        assert_eq!(
345            err.to_string(),
346            "daemon 'api' depends on 'db' which is not defined"
347        );
348
349        let err = DependencyError::CircularDependency {
350            involved: vec!["a".to_string(), "b".to_string(), "c".to_string()],
351        };
352        assert!(err.to_string().contains("circular dependency"));
353        assert!(err.to_string().contains("a, b, c"));
354    }
355
356    #[test]
357    fn test_find_similar_daemon() {
358        let daemons = ["postgres", "redis", "api", "worker"];
359
360        // Close match
361        let suggestion = find_similar_daemon("postgre", daemons.iter().copied());
362        assert_eq!(suggestion, Some("did you mean 'postgres'?".to_string()));
363
364        // No reasonable match
365        let suggestion = find_similar_daemon("xyz123", daemons.iter().copied());
366        assert!(suggestion.is_none());
367    }
368
369    #[test]
370    fn test_file_error_display() {
371        let err = FileError::ReadError {
372            path: PathBuf::from("/path/to/config.toml"),
373            source: io::Error::new(io::ErrorKind::NotFound, "file not found"),
374        };
375        assert!(err.to_string().contains("failed to read file"));
376        assert!(err.to_string().contains("config.toml"));
377
378        let err = FileError::NoPath;
379        assert!(err.to_string().contains("no file path"));
380    }
381
382    #[test]
383    fn test_ipc_error_display() {
384        let err = IpcError::ConnectionFailed {
385            attempts: 5,
386            source: None,
387            help: "ensure the supervisor is running".to_string(),
388        };
389        assert!(err.to_string().contains("failed to connect"));
390        assert!(err.to_string().contains("5 attempts"));
391
392        let err = IpcError::Timeout { seconds: 30 };
393        assert!(err.to_string().contains("timed out"));
394        assert!(err.to_string().contains("30s"));
395
396        let err = IpcError::UnexpectedResponse {
397            expected: "Ok".to_string(),
398            actual: "Error".to_string(),
399        };
400        assert!(err.to_string().contains("unexpected response"));
401        assert!(err.to_string().contains("Ok"));
402        assert!(err.to_string().contains("Error"));
403    }
404
405    #[test]
406    fn test_config_parse_error() {
407        let contents = "[daemons.test]\nrun = ".to_string();
408        let err = toml::from_str::<toml::Value>(&contents).unwrap_err();
409        let parse_err =
410            ConfigParseError::from_toml_error(std::path::Path::new("test.toml"), contents, err);
411
412        assert!(parse_err.to_string().contains("failed to parse"));
413    }
414
415    #[test]
416    fn test_multiple_errors() {
417        let errors: Vec<Box<dyn Diagnostic + Send + Sync>> = vec![
418            Box::new(DaemonIdError::Empty),
419            Box::new(DaemonIdError::CurrentDir),
420        ];
421        let multi = MultipleErrors::new(errors);
422
423        assert_eq!(multi.len(), 2);
424        assert!(!multi.is_empty());
425        assert!(multi.to_string().contains("2 total"));
426    }
427}