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 {component} cannot be empty")]
26    #[diagnostic(
27        code(pitchfork::daemon::empty_component),
28        url("https://pitchfork.jdx.dev/configuration"),
29        help("both namespace and name must be non-empty")
30    )]
31    EmptyComponent { component: String },
32
33    #[error("daemon ID '{id}' contains path separator '{sep}'")]
34    #[diagnostic(
35        code(pitchfork::daemon::path_separator),
36        url("https://pitchfork.jdx.dev/configuration"),
37        help("daemon IDs cannot contain '/' or '\\' to prevent path traversal")
38    )]
39    PathSeparator { id: String, sep: char },
40
41    #[error("daemon ID '{id}' contains parent directory reference '..'")]
42    #[diagnostic(
43        code(pitchfork::daemon::parent_dir_ref),
44        url("https://pitchfork.jdx.dev/configuration"),
45        help("daemon IDs cannot contain '..' to prevent path traversal")
46    )]
47    ParentDirRef { id: String },
48
49    #[error("daemon ID '{id}' contains reserved sequence '--'")]
50    #[diagnostic(
51        code(pitchfork::daemon::reserved_sequence),
52        url("https://pitchfork.jdx.dev/configuration"),
53        help("'--' is reserved for internal path encoding; use single dashes instead")
54    )]
55    ReservedSequence { id: String },
56
57    #[error("daemon ID component '{id}' starts or ends with a dash '-'")]
58    #[diagnostic(
59        code(pitchfork::daemon::leading_trailing_dash),
60        url("https://pitchfork.jdx.dev/configuration"),
61        help(
62            "remove the leading or trailing dash (e.g. 'my-daemon' not '-my-daemon' or 'my-daemon-')"
63        )
64    )]
65    LeadingTrailingDash { id: String },
66
67    #[error("daemon ID '{id}' contains spaces")]
68    #[diagnostic(
69        code(pitchfork::daemon::contains_space),
70        url("https://pitchfork.jdx.dev/configuration"),
71        help("use hyphens or underscores instead of spaces (e.g., 'my-daemon' or 'my_daemon')")
72    )]
73    ContainsSpace { id: String },
74
75    #[error("daemon ID cannot be '.'")]
76    #[diagnostic(
77        code(pitchfork::daemon::current_dir),
78        url("https://pitchfork.jdx.dev/configuration"),
79        help("'.' refers to the current directory; use a descriptive name instead")
80    )]
81    CurrentDir,
82
83    #[error("daemon ID '{id}' contains non-printable or non-ASCII character")]
84    #[diagnostic(
85        code(pitchfork::daemon::invalid_chars),
86        url("https://pitchfork.jdx.dev/configuration"),
87        help(
88            "daemon IDs must contain only printable ASCII characters (letters, numbers, hyphens, underscores, dots)"
89        )
90    )]
91    InvalidChars { id: String },
92
93    #[error("daemon ID '{id}' is missing namespace (expected format: namespace/name)")]
94    #[diagnostic(
95        code(pitchfork::daemon::missing_namespace),
96        url("https://pitchfork.jdx.dev/configuration"),
97        help("use qualified format like 'global/myapp' or 'project-name/daemon'")
98    )]
99    MissingNamespace { id: String },
100
101    #[error("invalid safe path format '{path}' (expected namespace--name)")]
102    #[diagnostic(
103        code(pitchfork::daemon::invalid_safe_path),
104        help("safe paths use '--' to separate namespace and name")
105    )]
106    InvalidSafePath { path: String },
107}
108
109/// Errors related to daemon operations.
110#[derive(Debug, Error, Diagnostic)]
111pub enum DaemonError {
112    #[error("failed to stop daemon '{id}': {error}")]
113    #[diagnostic(
114        code(pitchfork::daemon::stop_failed),
115        help("the process may be stuck or require manual intervention. Try: kill -9 <pid>")
116    )]
117    StopFailed { id: String, error: String },
118}
119
120/// Errors related to dependency resolution.
121#[derive(Debug, Error, Diagnostic)]
122pub enum DependencyError {
123    #[error("daemon '{name}' not found in configuration")]
124    #[diagnostic(
125        code(pitchfork::deps::not_found),
126        url("https://pitchfork.jdx.dev/configuration#depends")
127    )]
128    DaemonNotFound {
129        name: String,
130        #[help]
131        suggestion: Option<String>,
132    },
133
134    #[error("daemon '{daemon}' depends on '{dependency}' which is not defined")]
135    #[diagnostic(
136        code(pitchfork::deps::missing_dependency),
137        url("https://pitchfork.jdx.dev/configuration#depends"),
138        help("add the missing daemon to your pitchfork.toml or remove it from the depends list")
139    )]
140    MissingDependency { daemon: String, dependency: String },
141
142    #[error("circular dependency detected involving: {}", involved.join(", "))]
143    #[diagnostic(
144        code(pitchfork::deps::circular),
145        url("https://pitchfork.jdx.dev/configuration#depends"),
146        help("break the cycle by removing one of the dependencies")
147    )]
148    CircularDependency {
149        /// The daemons involved in the cycle
150        involved: Vec<String>,
151    },
152}
153
154/// The message for a port found in use: on which local address, and by which
155/// process. `pid` 0 means no process holding the port could be found, which
156/// happens when it belongs to another user, whose sockets cannot be read, or
157/// when no process owns the socket any more, as with a connection that was
158/// never accepted or is still closing.
159pub fn port_in_use_message(port: u16, address: Option<&str>, process: &str, pid: u32) -> String {
160    let on = address.map(|a| format!(" on {a}")).unwrap_or_default();
161    if pid == 0 {
162        format!(
163            "port {port} is already in use{on}, but no process holding it could be found; \
164             it may belong to another user, or be a connection no process owns any more"
165        )
166    } else {
167        format!("port {port} is already in use{on} by process '{process}' (PID: {pid})")
168    }
169}
170
171/// Errors related to port binding and availability.
172#[derive(Debug, Error, Diagnostic)]
173pub enum PortError {
174    #[error("{}", port_in_use_message(*port, address.as_deref(), process, *pid))]
175    #[diagnostic(
176        code(pitchfork::port::in_use),
177        url("https://pitchfork.jdx.dev/configuration#port"),
178        help(
179            "choose a different port, stop the existing process, or enable auto_bump_port to automatically find an available port"
180        )
181    )]
182    InUse {
183        port: u16,
184        /// The local address whose bind found the port taken.
185        address: Option<String>,
186        process: String,
187        pid: u32,
188    },
189
190    #[error(
191        "could not find an available port after {attempts} attempts starting from {start_port}"
192    )]
193    #[diagnostic(
194        code(pitchfork::port::no_available_port),
195        url("https://pitchfork.jdx.dev/configuration#port"),
196        help("manually specify an available port or reduce the number of concurrent services")
197    )]
198    NoAvailablePort { start_port: u16, attempts: u32 },
199}
200
201/// Error for TOML configuration parse failures with source code highlighting.
202#[derive(Debug, Error, Diagnostic)]
203pub enum ConfigParseError {
204    #[error("failed to parse configuration")]
205    #[diagnostic(code(pitchfork::config::parse_error))]
206    TomlError {
207        /// The source file contents for display
208        #[source_code]
209        src: NamedSource<String>,
210
211        /// The location of the error in the source
212        #[label("{message}")]
213        span: SourceSpan,
214
215        /// The error message from the TOML parser
216        message: String,
217
218        /// Additional help text
219        #[help]
220        help: Option<String>,
221    },
222
223    #[error("invalid daemon name '{name}' in {}", path.display())]
224    #[diagnostic(
225        code(pitchfork::config::invalid_daemon_name),
226        url("https://pitchfork.jdx.dev/configuration"),
227        help("daemon names must be valid identifiers without spaces, '--', or special characters")
228    )]
229    InvalidDaemonName {
230        name: String,
231        path: PathBuf,
232        reason: String,
233    },
234
235    #[error(
236        "daemon '{daemon}' in {} sets proxy_tls = \"passthrough\" but has no port",
237        path.display()
238    )]
239    #[diagnostic(
240        code(pitchfork::config::passthrough_without_port),
241        url("https://pitchfork.jdx.dev/guides/port-management#tls-passthrough"),
242        help(
243            "TLS passthrough splices the raw stream to a port on 127.0.0.1, so the daemon's port must be known up front; add `port = <number>` to the daemon, or remove proxy_tls"
244        )
245    )]
246    PassthroughWithoutPort { daemon: String, path: PathBuf },
247
248    #[error("daemon '{daemon}' in {} has an empty run array", path.display())]
249    #[diagnostic(
250        code(pitchfork::config::empty_run),
251        url("https://pitchfork.jdx.dev/reference/configuration#run-required"),
252        help("the first element of `run` is the program to start; give it one, or use a string")
253    )]
254    EmptyRunArgv { daemon: String, path: PathBuf },
255
256    #[error(
257        "daemon '{daemon}' in {} starts its run array with \"exec\"",
258        path.display()
259    )]
260    #[diagnostic(
261        code(pitchfork::config::exec_in_run_array),
262        url("https://pitchfork.jdx.dev/reference/configuration#run-required"),
263        help(
264            "a run array starts the program directly, without a shell, so there is no shell for `exec` to replace; remove \"exec\" and start the array with the program"
265        )
266    )]
267    ExecInRunArgv { daemon: String, path: PathBuf },
268
269    #[error(
270        "daemon '{daemon}' in {} has no program in its ready_cmd array",
271        path.display()
272    )]
273    #[diagnostic(
274        code(pitchfork::config::empty_ready_cmd),
275        url("https://pitchfork.jdx.dev/reference/configuration#ready-cmd"),
276        help(
277            "the first element of a `ready_cmd` array is the program to run; give it one, or use a string"
278        )
279    )]
280    EmptyReadyCmdArgv { daemon: String, path: PathBuf },
281
282    #[error(
283        "daemon '{daemon}' in {} starts its ready_cmd array with \"exec\"",
284        path.display()
285    )]
286    #[diagnostic(
287        code(pitchfork::config::exec_in_ready_cmd_array),
288        url("https://pitchfork.jdx.dev/reference/configuration#ready-cmd"),
289        help(
290            "a ready_cmd array runs the program directly, without a shell, so there is no shell for `exec` to replace; remove \"exec\" and start the array with the program"
291        )
292    )]
293    ExecInReadyCmdArgv { daemon: String, path: PathBuf },
294
295    #[error(
296        "daemon '{daemon}' in {} has no program in its health_cmd array",
297        path.display()
298    )]
299    #[diagnostic(
300        code(pitchfork::config::empty_health_cmd),
301        url("https://pitchfork.jdx.dev/reference/configuration#health-cmd"),
302        help(
303            "the first element of a `health_cmd` array is the program to run; give it one, or use a string"
304        )
305    )]
306    EmptyHealthCmdArgv { daemon: String, path: PathBuf },
307
308    #[error(
309        "daemon '{daemon}' in {} starts its health_cmd array with \"exec\"",
310        path.display()
311    )]
312    #[diagnostic(
313        code(pitchfork::config::exec_in_health_cmd_array),
314        url("https://pitchfork.jdx.dev/reference/configuration#health-cmd"),
315        help(
316            "a health_cmd array runs the program directly, without a shell, so there is no shell for `exec` to replace; remove \"exec\" and start the array with the program"
317        )
318    )]
319    ExecInHealthCmdArgv { daemon: String, path: PathBuf },
320
321    #[error(
322        "daemon '{daemon}' in {} sets proxy_tls_port = {port}, which is not one of its ports {declared:?}",
323        path.display()
324    )]
325    #[diagnostic(
326        code(pitchfork::config::proxy_port_not_declared),
327        url(
328            "https://pitchfork.jdx.dev/guides/port-management#choosing-a-port-on-a-multi-port-daemon"
329        ),
330        help(
331            "the proxy hostname maps to one of the daemon's own ports, so name a port from `port`, or add this one to it"
332        )
333    )]
334    ProxyPortNotDeclared {
335        daemon: String,
336        port: u16,
337        declared: Vec<u16>,
338        path: PathBuf,
339    },
340
341    #[error(
342        "daemon '{daemon}' in {} sets {key} = 0, which is not a port a hostname can be routed to",
343        path.display()
344    )]
345    #[diagnostic(
346        code(pitchfork::config::proxy_port_zero),
347        url(
348            "https://pitchfork.jdx.dev/guides/port-management#choosing-a-port-on-a-multi-port-daemon"
349        ),
350        help(
351            "port 0 asks the operating system to pick a port, so there is no fixed port for the proxy to send a hostname to; name the port the daemon actually listens on"
352        )
353    )]
354    ProxyPortZero {
355        daemon: String,
356        key: &'static str,
357        path: PathBuf,
358    },
359
360    #[error(
361        "invalid dependency '{dependency}' in daemon '{daemon}' ({}): {reason}",
362        path.display()
363    )]
364    #[diagnostic(
365        code(pitchfork::config::invalid_dependency),
366        url("https://pitchfork.jdx.dev/configuration#depends"),
367        help(
368            "dependency IDs must be valid daemon IDs; use 'name' for same namespace or 'namespace/name' for cross-namespace"
369        )
370    )]
371    InvalidDependency {
372        daemon: String,
373        dependency: String,
374        path: PathBuf,
375        reason: String,
376    },
377
378    #[error(
379        "daemon '{daemon}' in {} sets oneshot = true together with {}",
380        path.display(),
381        conflicts.join(", ")
382    )]
383    #[diagnostic(
384        code(pitchfork::config::oneshot_conflict),
385        url("https://pitchfork.jdx.dev/guides/ready-checks#oneshot-tasks"),
386        help(
387            "a oneshot daemon is ready when its process exits 0, so readiness and health checks do not apply; remove them or drop oneshot = true"
388        )
389    )]
390    OneshotConflict {
391        daemon: String,
392        path: PathBuf,
393        conflicts: Vec<String>,
394    },
395
396    #[error(
397        "namespace collision: '{}' and '{}' both resolve to namespace '{ns}'",
398        path_a.display(),
399        path_b.display()
400    )]
401    #[diagnostic(
402        code(pitchfork::config::namespace_collision),
403        url("https://pitchfork.jdx.dev/concepts/namespaces"),
404        help(
405            "rename one of the directories so that no two project configs share the same namespace"
406        )
407    )]
408    NamespaceCollision {
409        path_a: PathBuf,
410        path_b: PathBuf,
411        ns: String,
412    },
413
414    #[error(
415        "invalid namespace '{namespace}' in {}: {reason}",
416        path.display()
417    )]
418    #[diagnostic(
419        code(pitchfork::config::invalid_namespace),
420        url("https://pitchfork.jdx.dev/concepts/namespaces"),
421        help(
422            "set a valid top-level namespace in your pitchfork.toml, e.g. namespace = \"my-project\""
423        )
424    )]
425    InvalidNamespace {
426        path: PathBuf,
427        namespace: String,
428        reason: String,
429    },
430}
431
432impl ConfigParseError {
433    /// Create a new ConfigParseError from a toml parse error
434    pub fn from_toml_error(path: &std::path::Path, contents: String, err: toml::de::Error) -> Self {
435        let message = err.message().to_string();
436
437        // Try to get span information from the TOML error
438        let span = err
439            .span()
440            .map(|r| SourceSpan::from(r.start..r.end))
441            .unwrap_or_else(|| SourceSpan::from(0..0));
442
443        Self::TomlError {
444            src: NamedSource::new(path.display().to_string(), contents),
445            span,
446            message,
447            help: Some("check TOML syntax at https://toml.io".to_string()),
448        }
449    }
450}
451
452/// Errors related to file operations (config and state files).
453#[derive(Debug, Error, Diagnostic)]
454pub enum FileError {
455    #[error("failed to read file: {}", path.display())]
456    #[diagnostic(code(pitchfork::file::read_error))]
457    ReadError {
458        path: PathBuf,
459        #[source]
460        source: io::Error,
461    },
462
463    #[error("failed to write file: {}", path.display())]
464    #[diagnostic(code(pitchfork::file::write_error))]
465    WriteError {
466        path: PathBuf,
467        #[help]
468        details: Option<String>,
469    },
470
471    #[error("failed to serialize data for file: {}", path.display())]
472    #[diagnostic(
473        code(pitchfork::file::serialize_error),
474        help("this is likely an internal error; please report it")
475    )]
476    SerializeError {
477        path: PathBuf,
478        #[source]
479        source: toml::ser::Error,
480    },
481
482    #[error("no file path specified")]
483    #[diagnostic(
484        code(pitchfork::file::no_path),
485        help("ensure a pitchfork.toml file exists in your project or specify a path")
486    )]
487    NoPath,
488}
489
490/// Errors related to IPC communication with the supervisor.
491#[derive(Debug, Error, Diagnostic)]
492pub enum IpcError {
493    #[error("failed to connect to supervisor after {attempts} attempts")]
494    #[diagnostic(
495        code(pitchfork::ipc::connection_failed),
496        url("https://pitchfork.jdx.dev/supervisor")
497    )]
498    ConnectionFailed {
499        attempts: u32,
500        #[source]
501        source: Option<io::Error>,
502        #[help]
503        help: String,
504    },
505
506    /// The socket path does not fit in `sockaddr_un.sun_path`.
507    #[cfg(unix)]
508    #[error(
509        "the supervisor socket path is too long: {len} bytes, but this platform allows {limit}"
510    )]
511    #[diagnostic(
512        code(pitchfork::ipc::socket_path_too_long),
513        url("https://pitchfork.jdx.dev/reference/file-locations"),
514        help("{help}")
515    )]
516    SocketPathTooLong {
517        path: PathBuf,
518        len: usize,
519        limit: usize,
520        help: String,
521    },
522
523    #[error("IPC request timed out after {seconds}s")]
524    #[diagnostic(
525        code(pitchfork::ipc::timeout),
526        url("https://pitchfork.jdx.dev/supervisor"),
527        help(
528            "the supervisor may be unresponsive or overloaded.\nCheck supervisor status: pitchfork supervisor status\nView logs: pitchfork logs"
529        )
530    )]
531    Timeout { seconds: u64 },
532
533    #[error("IPC connection closed unexpectedly")]
534    #[diagnostic(
535        code(pitchfork::ipc::connection_closed),
536        url("https://pitchfork.jdx.dev/supervisor"),
537        help(
538            "the supervisor may have crashed or been stopped.\nRestart with: pitchfork supervisor start"
539        )
540    )]
541    ConnectionClosed,
542
543    #[error("failed to read IPC response")]
544    #[diagnostic(code(pitchfork::ipc::read_failed))]
545    ReadFailed {
546        #[source]
547        source: io::Error,
548    },
549
550    #[error("failed to send IPC request")]
551    #[diagnostic(code(pitchfork::ipc::send_failed))]
552    SendFailed {
553        #[source]
554        source: io::Error,
555    },
556
557    #[error("unexpected response from supervisor: expected {expected}, got {actual}")]
558    #[diagnostic(
559        code(pitchfork::ipc::unexpected_response),
560        help("this may indicate a version mismatch between the CLI and supervisor")
561    )]
562    UnexpectedResponse { expected: String, actual: String },
563
564    #[error("IPC message is invalid: {reason}")]
565    #[diagnostic(code(pitchfork::ipc::invalid_message))]
566    InvalidMessage { reason: String },
567}
568
569/// A collection of multiple errors that occurred during validation or processing.
570///
571/// This is useful when you want to collect and report all validation errors at once
572/// instead of failing on the first error.
573#[derive(Debug, Error, Diagnostic)]
574#[error("multiple errors occurred ({} total)", errors.len())]
575#[diagnostic(code(pitchfork::multiple_errors))]
576#[allow(dead_code)]
577pub struct MultipleErrors {
578    #[related]
579    pub errors: Vec<Box<dyn Diagnostic + Send + Sync + 'static>>,
580}
581
582#[allow(dead_code)]
583impl MultipleErrors {
584    /// Create a new MultipleErrors from a vector of diagnostics
585    pub fn new(errors: Vec<Box<dyn Diagnostic + Send + Sync + 'static>>) -> Self {
586        Self { errors }
587    }
588
589    /// Returns true if there are no errors
590    pub fn is_empty(&self) -> bool {
591        self.errors.is_empty()
592    }
593
594    /// Returns the number of errors
595    pub fn len(&self) -> usize {
596        self.errors.len()
597    }
598}
599
600/// Find the most similar daemon name for suggestions.
601pub fn find_similar_daemon<'a>(
602    name: &str,
603    available: impl Iterator<Item = &'a str>,
604) -> Option<String> {
605    use fuzzy_matcher::FuzzyMatcher;
606    use fuzzy_matcher::skim::SkimMatcherV2;
607
608    let matcher = SkimMatcherV2::default();
609    available
610        .filter_map(|candidate| {
611            matcher
612                .fuzzy_match(candidate, name)
613                .map(|score| (candidate, score))
614        })
615        .max_by_key(|(_, score)| *score)
616        .filter(|(_, score)| *score > 0)
617        .map(|(candidate, _)| format!("did you mean '{candidate}'?"))
618}
619
620#[cfg(test)]
621mod tests {
622    #[test]
623    fn port_in_use_message_names_the_address_and_the_holder() {
624        assert_eq!(
625            port_in_use_message(3000, Some("127.0.0.1"), "node", 42),
626            "port 3000 is already in use on 127.0.0.1 by process 'node' (PID: 42)"
627        );
628        // No listening process found: say so instead of naming "unknown".
629        let message = port_in_use_message(3000, Some("0.0.0.0"), "unknown", 0);
630        assert!(message.starts_with("port 3000 is already in use on 0.0.0.0, but no process"));
631        assert!(!message.contains("unknown"));
632        // A supervisor that does not report the address.
633        assert_eq!(
634            port_in_use_message(3000, None, "node", 42),
635            "port 3000 is already in use by process 'node' (PID: 42)"
636        );
637    }
638
639    use super::*;
640
641    #[test]
642    fn test_daemon_id_error_display() {
643        let err = DaemonIdError::Empty;
644        assert_eq!(err.to_string(), "daemon ID cannot be empty");
645
646        let err = DaemonIdError::PathSeparator {
647            id: "foo/bar".to_string(),
648            sep: '/',
649        };
650        assert_eq!(
651            err.to_string(),
652            "daemon ID 'foo/bar' contains path separator '/'"
653        );
654
655        let err = DaemonIdError::ContainsSpace {
656            id: "my app".to_string(),
657        };
658        assert_eq!(err.to_string(), "daemon ID 'my app' contains spaces");
659    }
660
661    #[test]
662    fn test_dependency_error_display() {
663        let err = DependencyError::DaemonNotFound {
664            name: "postgres".to_string(),
665            suggestion: None,
666        };
667        assert_eq!(
668            err.to_string(),
669            "daemon 'postgres' not found in configuration"
670        );
671
672        let err = DependencyError::MissingDependency {
673            daemon: "api".to_string(),
674            dependency: "db".to_string(),
675        };
676        assert_eq!(
677            err.to_string(),
678            "daemon 'api' depends on 'db' which is not defined"
679        );
680
681        let err = DependencyError::CircularDependency {
682            involved: vec!["a".to_string(), "b".to_string(), "c".to_string()],
683        };
684        assert!(err.to_string().contains("circular dependency"));
685        assert!(err.to_string().contains("a, b, c"));
686    }
687
688    #[test]
689    fn test_find_similar_daemon() {
690        let daemons = ["postgres", "redis", "api", "worker"];
691
692        // Close match
693        let suggestion = find_similar_daemon("postgre", daemons.iter().copied());
694        assert_eq!(suggestion, Some("did you mean 'postgres'?".to_string()));
695
696        // No reasonable match
697        let suggestion = find_similar_daemon("xyz123", daemons.iter().copied());
698        assert!(suggestion.is_none());
699    }
700
701    #[test]
702    fn test_file_error_display() {
703        let err = FileError::ReadError {
704            path: PathBuf::from("/path/to/config.toml"),
705            source: io::Error::new(io::ErrorKind::NotFound, "file not found"),
706        };
707        assert!(err.to_string().contains("failed to read file"));
708        assert!(err.to_string().contains("config.toml"));
709
710        let err = FileError::NoPath;
711        assert!(err.to_string().contains("no file path"));
712    }
713
714    #[test]
715    fn test_ipc_error_display() {
716        let err = IpcError::ConnectionFailed {
717            attempts: 5,
718            source: None,
719            help: "ensure the supervisor is running".to_string(),
720        };
721        assert!(err.to_string().contains("failed to connect"));
722        assert!(err.to_string().contains("5 attempts"));
723
724        let err = IpcError::Timeout { seconds: 30 };
725        assert!(err.to_string().contains("timed out"));
726        assert!(err.to_string().contains("30s"));
727
728        let err = IpcError::UnexpectedResponse {
729            expected: "Ok".to_string(),
730            actual: "Error".to_string(),
731        };
732        assert!(err.to_string().contains("unexpected response"));
733        assert!(err.to_string().contains("Ok"));
734        assert!(err.to_string().contains("Error"));
735    }
736
737    #[test]
738    fn test_config_parse_error() {
739        let contents = "[daemons.test]\nrun = ".to_string();
740        let err = toml::from_str::<toml::Value>(&contents).unwrap_err();
741        let parse_err =
742            ConfigParseError::from_toml_error(std::path::Path::new("test.toml"), contents, err);
743
744        assert!(parse_err.to_string().contains("failed to parse"));
745    }
746
747    #[test]
748    fn test_multiple_errors() {
749        let errors: Vec<Box<dyn Diagnostic + Send + Sync>> = vec![
750            Box::new(DaemonIdError::Empty),
751            Box::new(DaemonIdError::CurrentDir),
752        ];
753        let multi = MultipleErrors::new(errors);
754
755        assert_eq!(multi.len(), 2);
756        assert!(!multi.is_empty());
757        assert!(multi.to_string().contains("2 total"));
758    }
759}