1#![allow(unused_assignments)]
8
9use miette::{Diagnostic, NamedSource, SourceSpan};
10use std::io;
11use std::path::PathBuf;
12use thiserror::Error;
13
14#[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#[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#[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 involved: Vec<String>,
151 },
152}
153
154pub 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#[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 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#[derive(Debug, Error, Diagnostic)]
203pub enum ConfigParseError {
204 #[error("failed to parse configuration")]
205 #[diagnostic(code(pitchfork::config::parse_error))]
206 TomlError {
207 #[source_code]
209 src: NamedSource<String>,
210
211 #[label("{message}")]
213 span: SourceSpan,
214
215 message: String,
217
218 #[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 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 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#[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#[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 #[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#[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 pub fn new(errors: Vec<Box<dyn Diagnostic + Send + Sync + 'static>>) -> Self {
586 Self { errors }
587 }
588
589 pub fn is_empty(&self) -> bool {
591 self.errors.is_empty()
592 }
593
594 pub fn len(&self) -> usize {
596 self.errors.len()
597 }
598}
599
600pub 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 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 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 let suggestion = find_similar_daemon("postgre", daemons.iter().copied());
694 assert_eq!(suggestion, Some("did you mean 'postgres'?".to_string()));
695
696 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}