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