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
154#[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#[derive(Debug, Error, Diagnostic)]
184pub enum ConfigParseError {
185 #[error("failed to parse configuration")]
186 #[diagnostic(code(pitchfork::config::parse_error))]
187 TomlError {
188 #[source_code]
190 src: NamedSource<String>,
191
192 #[label("{message}")]
194 span: SourceSpan,
195
196 message: String,
198
199 #[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 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 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#[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#[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 #[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#[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 pub fn new(errors: Vec<Box<dyn Diagnostic + Send + Sync + 'static>>) -> Self {
567 Self { errors }
568 }
569
570 pub fn is_empty(&self) -> bool {
572 self.errors.is_empty()
573 }
574
575 pub fn len(&self) -> usize {
577 self.errors.len()
578 }
579}
580
581pub 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 let suggestion = find_similar_daemon("postgre", daemons.iter().copied());
658 assert_eq!(suggestion, Some("did you mean 'postgres'?".to_string()));
659
660 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}