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/// Errors related to port binding and availability.
155#[derive(Debug, Error, Diagnostic)]
156pub enum PortError {
157    #[error("port {port} is already in use by process '{process}' (PID: {pid})")]
158    #[diagnostic(
159        code(pitchfork::port::in_use),
160        url("https://pitchfork.jdx.dev/configuration#port"),
161        help(
162            "choose a different port, stop the existing process, or enable auto_bump_port to automatically find an available port"
163        )
164    )]
165    InUse {
166        port: u16,
167        process: String,
168        pid: u32,
169    },
170
171    #[error(
172        "could not find an available port after {attempts} attempts starting from {start_port}"
173    )]
174    #[diagnostic(
175        code(pitchfork::port::no_available_port),
176        url("https://pitchfork.jdx.dev/configuration#port"),
177        help("manually specify an available port or reduce the number of concurrent services")
178    )]
179    NoAvailablePort { start_port: u16, attempts: u32 },
180}
181
182/// Error for TOML configuration parse failures with source code highlighting.
183#[derive(Debug, Error, Diagnostic)]
184pub enum ConfigParseError {
185    #[error("failed to parse configuration")]
186    #[diagnostic(code(pitchfork::config::parse_error))]
187    TomlError {
188        /// The source file contents for display
189        #[source_code]
190        src: NamedSource<String>,
191
192        /// The location of the error in the source
193        #[label("{message}")]
194        span: SourceSpan,
195
196        /// The error message from the TOML parser
197        message: String,
198
199        /// Additional help text
200        #[help]
201        help: Option<String>,
202    },
203
204    #[error("invalid daemon name '{name}' in {}", path.display())]
205    #[diagnostic(
206        code(pitchfork::config::invalid_daemon_name),
207        url("https://pitchfork.jdx.dev/configuration"),
208        help("daemon names must be valid identifiers without spaces, '--', or special characters")
209    )]
210    InvalidDaemonName {
211        name: String,
212        path: PathBuf,
213        reason: String,
214    },
215
216    #[error(
217        "daemon '{daemon}' in {} sets proxy_tls = \"passthrough\" but has no port",
218        path.display()
219    )]
220    #[diagnostic(
221        code(pitchfork::config::passthrough_without_port),
222        url("https://pitchfork.jdx.dev/guides/port-management#tls-passthrough"),
223        help(
224            "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"
225        )
226    )]
227    PassthroughWithoutPort { daemon: String, path: PathBuf },
228
229    #[error("daemon '{daemon}' in {} has an empty run array", path.display())]
230    #[diagnostic(
231        code(pitchfork::config::empty_run),
232        url("https://pitchfork.jdx.dev/reference/configuration#run-required"),
233        help("the first element of `run` is the program to start; give it one, or use a string")
234    )]
235    EmptyRunArgv { daemon: String, path: PathBuf },
236
237    #[error(
238        "daemon '{daemon}' in {} starts its run array with \"exec\"",
239        path.display()
240    )]
241    #[diagnostic(
242        code(pitchfork::config::exec_in_run_array),
243        url("https://pitchfork.jdx.dev/reference/configuration#run-required"),
244        help(
245            "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"
246        )
247    )]
248    ExecInRunArgv { daemon: String, path: PathBuf },
249
250    #[error(
251        "daemon '{daemon}' in {} has no program in its ready_cmd array",
252        path.display()
253    )]
254    #[diagnostic(
255        code(pitchfork::config::empty_ready_cmd),
256        url("https://pitchfork.jdx.dev/reference/configuration#ready-cmd"),
257        help(
258            "the first element of a `ready_cmd` array is the program to run; give it one, or use a string"
259        )
260    )]
261    EmptyReadyCmdArgv { daemon: String, path: PathBuf },
262
263    #[error(
264        "daemon '{daemon}' in {} starts its ready_cmd array with \"exec\"",
265        path.display()
266    )]
267    #[diagnostic(
268        code(pitchfork::config::exec_in_ready_cmd_array),
269        url("https://pitchfork.jdx.dev/reference/configuration#ready-cmd"),
270        help(
271            "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"
272        )
273    )]
274    ExecInReadyCmdArgv { daemon: String, path: PathBuf },
275
276    #[error(
277        "daemon '{daemon}' in {} has no program in its health_cmd array",
278        path.display()
279    )]
280    #[diagnostic(
281        code(pitchfork::config::empty_health_cmd),
282        url("https://pitchfork.jdx.dev/reference/configuration#health-cmd"),
283        help(
284            "the first element of a `health_cmd` array is the program to run; give it one, or use a string"
285        )
286    )]
287    EmptyHealthCmdArgv { daemon: String, path: PathBuf },
288
289    #[error(
290        "daemon '{daemon}' in {} starts its health_cmd array with \"exec\"",
291        path.display()
292    )]
293    #[diagnostic(
294        code(pitchfork::config::exec_in_health_cmd_array),
295        url("https://pitchfork.jdx.dev/reference/configuration#health-cmd"),
296        help(
297            "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"
298        )
299    )]
300    ExecInHealthCmdArgv { daemon: String, path: PathBuf },
301
302    #[error(
303        "daemon '{daemon}' in {} sets proxy_tls_port = {port}, which is not one of its ports {declared:?}",
304        path.display()
305    )]
306    #[diagnostic(
307        code(pitchfork::config::proxy_port_not_declared),
308        url(
309            "https://pitchfork.jdx.dev/guides/port-management#choosing-a-port-on-a-multi-port-daemon"
310        ),
311        help(
312            "the proxy hostname maps to one of the daemon's own ports, so name a port from `port`, or add this one to it"
313        )
314    )]
315    ProxyPortNotDeclared {
316        daemon: String,
317        port: u16,
318        declared: Vec<u16>,
319        path: PathBuf,
320    },
321
322    #[error(
323        "daemon '{daemon}' in {} sets {key} = 0, which is not a port a hostname can be routed to",
324        path.display()
325    )]
326    #[diagnostic(
327        code(pitchfork::config::proxy_port_zero),
328        url(
329            "https://pitchfork.jdx.dev/guides/port-management#choosing-a-port-on-a-multi-port-daemon"
330        ),
331        help(
332            "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"
333        )
334    )]
335    ProxyPortZero {
336        daemon: String,
337        key: &'static str,
338        path: PathBuf,
339    },
340
341    #[error(
342        "invalid dependency '{dependency}' in daemon '{daemon}' ({}): {reason}",
343        path.display()
344    )]
345    #[diagnostic(
346        code(pitchfork::config::invalid_dependency),
347        url("https://pitchfork.jdx.dev/configuration#depends"),
348        help(
349            "dependency IDs must be valid daemon IDs; use 'name' for same namespace or 'namespace/name' for cross-namespace"
350        )
351    )]
352    InvalidDependency {
353        daemon: String,
354        dependency: String,
355        path: PathBuf,
356        reason: String,
357    },
358
359    #[error(
360        "daemon '{daemon}' in {} sets oneshot = true together with {}",
361        path.display(),
362        conflicts.join(", ")
363    )]
364    #[diagnostic(
365        code(pitchfork::config::oneshot_conflict),
366        url("https://pitchfork.jdx.dev/guides/ready-checks#oneshot-tasks"),
367        help(
368            "a oneshot daemon is ready when its process exits 0, so readiness and health checks do not apply; remove them or drop oneshot = true"
369        )
370    )]
371    OneshotConflict {
372        daemon: String,
373        path: PathBuf,
374        conflicts: Vec<String>,
375    },
376
377    #[error(
378        "namespace collision: '{}' and '{}' both resolve to namespace '{ns}'",
379        path_a.display(),
380        path_b.display()
381    )]
382    #[diagnostic(
383        code(pitchfork::config::namespace_collision),
384        url("https://pitchfork.jdx.dev/concepts/namespaces"),
385        help(
386            "rename one of the directories so that no two project configs share the same namespace"
387        )
388    )]
389    NamespaceCollision {
390        path_a: PathBuf,
391        path_b: PathBuf,
392        ns: String,
393    },
394
395    #[error(
396        "invalid namespace '{namespace}' in {}: {reason}",
397        path.display()
398    )]
399    #[diagnostic(
400        code(pitchfork::config::invalid_namespace),
401        url("https://pitchfork.jdx.dev/concepts/namespaces"),
402        help(
403            "set a valid top-level namespace in your pitchfork.toml, e.g. namespace = \"my-project\""
404        )
405    )]
406    InvalidNamespace {
407        path: PathBuf,
408        namespace: String,
409        reason: String,
410    },
411}
412
413impl ConfigParseError {
414    /// Create a new ConfigParseError from a toml parse error
415    pub fn from_toml_error(path: &std::path::Path, contents: String, err: toml::de::Error) -> Self {
416        let message = err.message().to_string();
417
418        // Try to get span information from the TOML error
419        let span = err
420            .span()
421            .map(|r| SourceSpan::from(r.start..r.end))
422            .unwrap_or_else(|| SourceSpan::from(0..0));
423
424        Self::TomlError {
425            src: NamedSource::new(path.display().to_string(), contents),
426            span,
427            message,
428            help: Some("check TOML syntax at https://toml.io".to_string()),
429        }
430    }
431}
432
433/// Errors related to file operations (config and state files).
434#[derive(Debug, Error, Diagnostic)]
435pub enum FileError {
436    #[error("failed to read file: {}", path.display())]
437    #[diagnostic(code(pitchfork::file::read_error))]
438    ReadError {
439        path: PathBuf,
440        #[source]
441        source: io::Error,
442    },
443
444    #[error("failed to write file: {}", path.display())]
445    #[diagnostic(code(pitchfork::file::write_error))]
446    WriteError {
447        path: PathBuf,
448        #[help]
449        details: Option<String>,
450    },
451
452    #[error("failed to serialize data for file: {}", path.display())]
453    #[diagnostic(
454        code(pitchfork::file::serialize_error),
455        help("this is likely an internal error; please report it")
456    )]
457    SerializeError {
458        path: PathBuf,
459        #[source]
460        source: toml::ser::Error,
461    },
462
463    #[error("no file path specified")]
464    #[diagnostic(
465        code(pitchfork::file::no_path),
466        help("ensure a pitchfork.toml file exists in your project or specify a path")
467    )]
468    NoPath,
469}
470
471/// Errors related to IPC communication with the supervisor.
472#[derive(Debug, Error, Diagnostic)]
473pub enum IpcError {
474    #[error("failed to connect to supervisor after {attempts} attempts")]
475    #[diagnostic(
476        code(pitchfork::ipc::connection_failed),
477        url("https://pitchfork.jdx.dev/supervisor")
478    )]
479    ConnectionFailed {
480        attempts: u32,
481        #[source]
482        source: Option<io::Error>,
483        #[help]
484        help: String,
485    },
486
487    /// The socket path does not fit in `sockaddr_un.sun_path`.
488    #[cfg(unix)]
489    #[error(
490        "the supervisor socket path is too long: {len} bytes, but this platform allows {limit}"
491    )]
492    #[diagnostic(
493        code(pitchfork::ipc::socket_path_too_long),
494        url("https://pitchfork.jdx.dev/reference/file-locations"),
495        help("{help}")
496    )]
497    SocketPathTooLong {
498        path: PathBuf,
499        len: usize,
500        limit: usize,
501        help: String,
502    },
503
504    #[error("IPC request timed out after {seconds}s")]
505    #[diagnostic(
506        code(pitchfork::ipc::timeout),
507        url("https://pitchfork.jdx.dev/supervisor"),
508        help(
509            "the supervisor may be unresponsive or overloaded.\nCheck supervisor status: pitchfork supervisor status\nView logs: pitchfork logs"
510        )
511    )]
512    Timeout { seconds: u64 },
513
514    #[error("IPC connection closed unexpectedly")]
515    #[diagnostic(
516        code(pitchfork::ipc::connection_closed),
517        url("https://pitchfork.jdx.dev/supervisor"),
518        help(
519            "the supervisor may have crashed or been stopped.\nRestart with: pitchfork supervisor start"
520        )
521    )]
522    ConnectionClosed,
523
524    #[error("failed to read IPC response")]
525    #[diagnostic(code(pitchfork::ipc::read_failed))]
526    ReadFailed {
527        #[source]
528        source: io::Error,
529    },
530
531    #[error("failed to send IPC request")]
532    #[diagnostic(code(pitchfork::ipc::send_failed))]
533    SendFailed {
534        #[source]
535        source: io::Error,
536    },
537
538    #[error("unexpected response from supervisor: expected {expected}, got {actual}")]
539    #[diagnostic(
540        code(pitchfork::ipc::unexpected_response),
541        help("this may indicate a version mismatch between the CLI and supervisor")
542    )]
543    UnexpectedResponse { expected: String, actual: String },
544
545    #[error("IPC message is invalid: {reason}")]
546    #[diagnostic(code(pitchfork::ipc::invalid_message))]
547    InvalidMessage { reason: String },
548}
549
550/// A collection of multiple errors that occurred during validation or processing.
551///
552/// This is useful when you want to collect and report all validation errors at once
553/// instead of failing on the first error.
554#[derive(Debug, Error, Diagnostic)]
555#[error("multiple errors occurred ({} total)", errors.len())]
556#[diagnostic(code(pitchfork::multiple_errors))]
557#[allow(dead_code)]
558pub struct MultipleErrors {
559    #[related]
560    pub errors: Vec<Box<dyn Diagnostic + Send + Sync + 'static>>,
561}
562
563#[allow(dead_code)]
564impl MultipleErrors {
565    /// Create a new MultipleErrors from a vector of diagnostics
566    pub fn new(errors: Vec<Box<dyn Diagnostic + Send + Sync + 'static>>) -> Self {
567        Self { errors }
568    }
569
570    /// Returns true if there are no errors
571    pub fn is_empty(&self) -> bool {
572        self.errors.is_empty()
573    }
574
575    /// Returns the number of errors
576    pub fn len(&self) -> usize {
577        self.errors.len()
578    }
579}
580
581/// Find the most similar daemon name for suggestions.
582pub fn find_similar_daemon<'a>(
583    name: &str,
584    available: impl Iterator<Item = &'a str>,
585) -> Option<String> {
586    use fuzzy_matcher::FuzzyMatcher;
587    use fuzzy_matcher::skim::SkimMatcherV2;
588
589    let matcher = SkimMatcherV2::default();
590    available
591        .filter_map(|candidate| {
592            matcher
593                .fuzzy_match(candidate, name)
594                .map(|score| (candidate, score))
595        })
596        .max_by_key(|(_, score)| *score)
597        .filter(|(_, score)| *score > 0)
598        .map(|(candidate, _)| format!("did you mean '{candidate}'?"))
599}
600
601#[cfg(test)]
602mod tests {
603    use super::*;
604
605    #[test]
606    fn test_daemon_id_error_display() {
607        let err = DaemonIdError::Empty;
608        assert_eq!(err.to_string(), "daemon ID cannot be empty");
609
610        let err = DaemonIdError::PathSeparator {
611            id: "foo/bar".to_string(),
612            sep: '/',
613        };
614        assert_eq!(
615            err.to_string(),
616            "daemon ID 'foo/bar' contains path separator '/'"
617        );
618
619        let err = DaemonIdError::ContainsSpace {
620            id: "my app".to_string(),
621        };
622        assert_eq!(err.to_string(), "daemon ID 'my app' contains spaces");
623    }
624
625    #[test]
626    fn test_dependency_error_display() {
627        let err = DependencyError::DaemonNotFound {
628            name: "postgres".to_string(),
629            suggestion: None,
630        };
631        assert_eq!(
632            err.to_string(),
633            "daemon 'postgres' not found in configuration"
634        );
635
636        let err = DependencyError::MissingDependency {
637            daemon: "api".to_string(),
638            dependency: "db".to_string(),
639        };
640        assert_eq!(
641            err.to_string(),
642            "daemon 'api' depends on 'db' which is not defined"
643        );
644
645        let err = DependencyError::CircularDependency {
646            involved: vec!["a".to_string(), "b".to_string(), "c".to_string()],
647        };
648        assert!(err.to_string().contains("circular dependency"));
649        assert!(err.to_string().contains("a, b, c"));
650    }
651
652    #[test]
653    fn test_find_similar_daemon() {
654        let daemons = ["postgres", "redis", "api", "worker"];
655
656        // Close match
657        let suggestion = find_similar_daemon("postgre", daemons.iter().copied());
658        assert_eq!(suggestion, Some("did you mean 'postgres'?".to_string()));
659
660        // No reasonable match
661        let suggestion = find_similar_daemon("xyz123", daemons.iter().copied());
662        assert!(suggestion.is_none());
663    }
664
665    #[test]
666    fn test_file_error_display() {
667        let err = FileError::ReadError {
668            path: PathBuf::from("/path/to/config.toml"),
669            source: io::Error::new(io::ErrorKind::NotFound, "file not found"),
670        };
671        assert!(err.to_string().contains("failed to read file"));
672        assert!(err.to_string().contains("config.toml"));
673
674        let err = FileError::NoPath;
675        assert!(err.to_string().contains("no file path"));
676    }
677
678    #[test]
679    fn test_ipc_error_display() {
680        let err = IpcError::ConnectionFailed {
681            attempts: 5,
682            source: None,
683            help: "ensure the supervisor is running".to_string(),
684        };
685        assert!(err.to_string().contains("failed to connect"));
686        assert!(err.to_string().contains("5 attempts"));
687
688        let err = IpcError::Timeout { seconds: 30 };
689        assert!(err.to_string().contains("timed out"));
690        assert!(err.to_string().contains("30s"));
691
692        let err = IpcError::UnexpectedResponse {
693            expected: "Ok".to_string(),
694            actual: "Error".to_string(),
695        };
696        assert!(err.to_string().contains("unexpected response"));
697        assert!(err.to_string().contains("Ok"));
698        assert!(err.to_string().contains("Error"));
699    }
700
701    #[test]
702    fn test_config_parse_error() {
703        let contents = "[daemons.test]\nrun = ".to_string();
704        let err = toml::from_str::<toml::Value>(&contents).unwrap_err();
705        let parse_err =
706            ConfigParseError::from_toml_error(std::path::Path::new("test.toml"), contents, err);
707
708        assert!(parse_err.to_string().contains("failed to parse"));
709    }
710
711    #[test]
712    fn test_multiple_errors() {
713        let errors: Vec<Box<dyn Diagnostic + Send + Sync>> = vec![
714            Box::new(DaemonIdError::Empty),
715            Box::new(DaemonIdError::CurrentDir),
716        ];
717        let multi = MultipleErrors::new(errors);
718
719        assert_eq!(multi.len(), 2);
720        assert!(!multi.is_empty());
721        assert!(multi.to_string().contains("2 total"));
722    }
723}