#![allow(unused_assignments)]
use miette::{Diagnostic, NamedSource, SourceSpan};
use std::io;
use std::path::PathBuf;
use thiserror::Error;
#[derive(Debug, Error, Diagnostic)]
pub enum DaemonIdError {
#[error("daemon ID cannot be empty")]
#[diagnostic(
code(pitchfork::daemon::empty_id),
url("https://pitchfork.jdx.dev/configuration"),
help("provide a non-empty identifier for the daemon")
)]
Empty,
#[error("daemon ID {component} cannot be empty")]
#[diagnostic(
code(pitchfork::daemon::empty_component),
url("https://pitchfork.jdx.dev/configuration"),
help("both namespace and name must be non-empty")
)]
EmptyComponent { component: String },
#[error("daemon ID '{id}' contains path separator '{sep}'")]
#[diagnostic(
code(pitchfork::daemon::path_separator),
url("https://pitchfork.jdx.dev/configuration"),
help("daemon IDs cannot contain '/' or '\\' to prevent path traversal")
)]
PathSeparator { id: String, sep: char },
#[error("daemon ID '{id}' contains parent directory reference '..'")]
#[diagnostic(
code(pitchfork::daemon::parent_dir_ref),
url("https://pitchfork.jdx.dev/configuration"),
help("daemon IDs cannot contain '..' to prevent path traversal")
)]
ParentDirRef { id: String },
#[error("daemon ID '{id}' contains reserved sequence '--'")]
#[diagnostic(
code(pitchfork::daemon::reserved_sequence),
url("https://pitchfork.jdx.dev/configuration"),
help("'--' is reserved for internal path encoding; use single dashes instead")
)]
ReservedSequence { id: String },
#[error("daemon ID component '{id}' starts or ends with a dash '-'")]
#[diagnostic(
code(pitchfork::daemon::leading_trailing_dash),
url("https://pitchfork.jdx.dev/configuration"),
help(
"remove the leading or trailing dash (e.g. 'my-daemon' not '-my-daemon' or 'my-daemon-')"
)
)]
LeadingTrailingDash { id: String },
#[error("daemon ID '{id}' contains spaces")]
#[diagnostic(
code(pitchfork::daemon::contains_space),
url("https://pitchfork.jdx.dev/configuration"),
help("use hyphens or underscores instead of spaces (e.g., 'my-daemon' or 'my_daemon')")
)]
ContainsSpace { id: String },
#[error("daemon ID cannot be '.'")]
#[diagnostic(
code(pitchfork::daemon::current_dir),
url("https://pitchfork.jdx.dev/configuration"),
help("'.' refers to the current directory; use a descriptive name instead")
)]
CurrentDir,
#[error("daemon ID '{id}' contains non-printable or non-ASCII character")]
#[diagnostic(
code(pitchfork::daemon::invalid_chars),
url("https://pitchfork.jdx.dev/configuration"),
help(
"daemon IDs must contain only printable ASCII characters (letters, numbers, hyphens, underscores, dots)"
)
)]
InvalidChars { id: String },
#[error("daemon ID '{id}' is missing namespace (expected format: namespace/name)")]
#[diagnostic(
code(pitchfork::daemon::missing_namespace),
url("https://pitchfork.jdx.dev/configuration"),
help("use qualified format like 'global/myapp' or 'project-name/daemon'")
)]
MissingNamespace { id: String },
#[error("invalid safe path format '{path}' (expected namespace--name)")]
#[diagnostic(
code(pitchfork::daemon::invalid_safe_path),
help("safe paths use '--' to separate namespace and name")
)]
InvalidSafePath { path: String },
}
#[derive(Debug, Error, Diagnostic)]
pub enum DaemonError {
#[error("failed to stop daemon '{id}': {error}")]
#[diagnostic(
code(pitchfork::daemon::stop_failed),
help("the process may be stuck or require manual intervention. Try: kill -9 <pid>")
)]
StopFailed { id: String, error: String },
}
#[derive(Debug, Error, Diagnostic)]
pub enum DependencyError {
#[error("daemon '{name}' not found in configuration")]
#[diagnostic(
code(pitchfork::deps::not_found),
url("https://pitchfork.jdx.dev/configuration#depends")
)]
DaemonNotFound {
name: String,
#[help]
suggestion: Option<String>,
},
#[error("daemon '{daemon}' depends on '{dependency}' which is not defined")]
#[diagnostic(
code(pitchfork::deps::missing_dependency),
url("https://pitchfork.jdx.dev/configuration#depends"),
help("add the missing daemon to your pitchfork.toml or remove it from the depends list")
)]
MissingDependency { daemon: String, dependency: String },
#[error("circular dependency detected involving: {}", involved.join(", "))]
#[diagnostic(
code(pitchfork::deps::circular),
url("https://pitchfork.jdx.dev/configuration#depends"),
help("break the cycle by removing one of the dependencies")
)]
CircularDependency {
involved: Vec<String>,
},
}
#[derive(Debug, Error, Diagnostic)]
pub enum PortError {
#[error("port {port} is already in use by process '{process}' (PID: {pid})")]
#[diagnostic(
code(pitchfork::port::in_use),
url("https://pitchfork.jdx.dev/configuration#port"),
help(
"choose a different port, stop the existing process, or enable auto_bump_port to automatically find an available port"
)
)]
InUse {
port: u16,
process: String,
pid: u32,
},
#[error(
"could not find an available port after {attempts} attempts starting from {start_port}"
)]
#[diagnostic(
code(pitchfork::port::no_available_port),
url("https://pitchfork.jdx.dev/configuration#port"),
help("manually specify an available port or reduce the number of concurrent services")
)]
NoAvailablePort { start_port: u16, attempts: u32 },
}
#[derive(Debug, Error, Diagnostic)]
pub enum ConfigParseError {
#[error("failed to parse configuration")]
#[diagnostic(code(pitchfork::config::parse_error))]
TomlError {
#[source_code]
src: NamedSource<String>,
#[label("{message}")]
span: SourceSpan,
message: String,
#[help]
help: Option<String>,
},
#[error("invalid daemon name '{name}' in {}", path.display())]
#[diagnostic(
code(pitchfork::config::invalid_daemon_name),
url("https://pitchfork.jdx.dev/configuration"),
help("daemon names must be valid identifiers without spaces, '--', or special characters")
)]
InvalidDaemonName {
name: String,
path: PathBuf,
reason: String,
},
#[error(
"daemon '{daemon}' in {} sets proxy_tls = \"passthrough\" but has no port",
path.display()
)]
#[diagnostic(
code(pitchfork::config::passthrough_without_port),
url("https://pitchfork.jdx.dev/guides/port-management#tls-passthrough"),
help(
"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"
)
)]
PassthroughWithoutPort { daemon: String, path: PathBuf },
#[error("daemon '{daemon}' in {} has an empty run array", path.display())]
#[diagnostic(
code(pitchfork::config::empty_run),
url("https://pitchfork.jdx.dev/reference/configuration#run-required"),
help("the first element of `run` is the program to start; give it one, or use a string")
)]
EmptyRunArgv { daemon: String, path: PathBuf },
#[error(
"daemon '{daemon}' in {} starts its run array with \"exec\"",
path.display()
)]
#[diagnostic(
code(pitchfork::config::exec_in_run_array),
url("https://pitchfork.jdx.dev/reference/configuration#run-required"),
help(
"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"
)
)]
ExecInRunArgv { daemon: String, path: PathBuf },
#[error(
"daemon '{daemon}' in {} sets proxy_tls_port = {port}, which is not one of its ports {declared:?}",
path.display()
)]
#[diagnostic(
code(pitchfork::config::proxy_port_not_declared),
url(
"https://pitchfork.jdx.dev/guides/port-management#choosing-a-port-on-a-multi-port-daemon"
),
help(
"the proxy hostname maps to one of the daemon's own ports, so name a port from `port`, or add this one to it"
)
)]
ProxyPortNotDeclared {
daemon: String,
port: u16,
declared: Vec<u16>,
path: PathBuf,
},
#[error(
"daemon '{daemon}' in {} sets {key} = 0, which is not a port a hostname can be routed to",
path.display()
)]
#[diagnostic(
code(pitchfork::config::proxy_port_zero),
url(
"https://pitchfork.jdx.dev/guides/port-management#choosing-a-port-on-a-multi-port-daemon"
),
help(
"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"
)
)]
ProxyPortZero {
daemon: String,
key: &'static str,
path: PathBuf,
},
#[error(
"invalid dependency '{dependency}' in daemon '{daemon}' ({}): {reason}",
path.display()
)]
#[diagnostic(
code(pitchfork::config::invalid_dependency),
url("https://pitchfork.jdx.dev/configuration#depends"),
help(
"dependency IDs must be valid daemon IDs; use 'name' for same namespace or 'namespace/name' for cross-namespace"
)
)]
InvalidDependency {
daemon: String,
dependency: String,
path: PathBuf,
reason: String,
},
#[error(
"daemon '{daemon}' in {} sets oneshot = true together with {}",
path.display(),
conflicts.join(", ")
)]
#[diagnostic(
code(pitchfork::config::oneshot_conflict),
url("https://pitchfork.jdx.dev/guides/ready-checks#oneshot-tasks"),
help(
"a oneshot daemon is ready when its process exits 0, so readiness and health checks do not apply; remove them or drop oneshot = true"
)
)]
OneshotConflict {
daemon: String,
path: PathBuf,
conflicts: Vec<String>,
},
#[error(
"namespace collision: '{}' and '{}' both resolve to namespace '{ns}'",
path_a.display(),
path_b.display()
)]
#[diagnostic(
code(pitchfork::config::namespace_collision),
url("https://pitchfork.jdx.dev/concepts/namespaces"),
help(
"rename one of the directories so that no two project configs share the same namespace"
)
)]
NamespaceCollision {
path_a: PathBuf,
path_b: PathBuf,
ns: String,
},
#[error(
"invalid namespace '{namespace}' in {}: {reason}",
path.display()
)]
#[diagnostic(
code(pitchfork::config::invalid_namespace),
url("https://pitchfork.jdx.dev/concepts/namespaces"),
help(
"set a valid top-level namespace in your pitchfork.toml, e.g. namespace = \"my-project\""
)
)]
InvalidNamespace {
path: PathBuf,
namespace: String,
reason: String,
},
}
impl ConfigParseError {
pub fn from_toml_error(path: &std::path::Path, contents: String, err: toml::de::Error) -> Self {
let message = err.message().to_string();
let span = err
.span()
.map(|r| SourceSpan::from(r.start..r.end))
.unwrap_or_else(|| SourceSpan::from(0..0));
Self::TomlError {
src: NamedSource::new(path.display().to_string(), contents),
span,
message,
help: Some("check TOML syntax at https://toml.io".to_string()),
}
}
}
#[derive(Debug, Error, Diagnostic)]
pub enum FileError {
#[error("failed to read file: {}", path.display())]
#[diagnostic(code(pitchfork::file::read_error))]
ReadError {
path: PathBuf,
#[source]
source: io::Error,
},
#[error("failed to write file: {}", path.display())]
#[diagnostic(code(pitchfork::file::write_error))]
WriteError {
path: PathBuf,
#[help]
details: Option<String>,
},
#[error("failed to serialize data for file: {}", path.display())]
#[diagnostic(
code(pitchfork::file::serialize_error),
help("this is likely an internal error; please report it")
)]
SerializeError {
path: PathBuf,
#[source]
source: toml::ser::Error,
},
#[error("no file path specified")]
#[diagnostic(
code(pitchfork::file::no_path),
help("ensure a pitchfork.toml file exists in your project or specify a path")
)]
NoPath,
}
#[derive(Debug, Error, Diagnostic)]
pub enum IpcError {
#[error("failed to connect to supervisor after {attempts} attempts")]
#[diagnostic(
code(pitchfork::ipc::connection_failed),
url("https://pitchfork.jdx.dev/supervisor")
)]
ConnectionFailed {
attempts: u32,
#[source]
source: Option<io::Error>,
#[help]
help: String,
},
#[cfg(unix)]
#[error(
"the supervisor socket path is too long: {len} bytes, but this platform allows {limit}"
)]
#[diagnostic(
code(pitchfork::ipc::socket_path_too_long),
url("https://pitchfork.jdx.dev/reference/file-locations"),
help("{help}")
)]
SocketPathTooLong {
path: PathBuf,
len: usize,
limit: usize,
help: String,
},
#[error("IPC request timed out after {seconds}s")]
#[diagnostic(
code(pitchfork::ipc::timeout),
url("https://pitchfork.jdx.dev/supervisor"),
help(
"the supervisor may be unresponsive or overloaded.\nCheck supervisor status: pitchfork supervisor status\nView logs: pitchfork logs"
)
)]
Timeout { seconds: u64 },
#[error("IPC connection closed unexpectedly")]
#[diagnostic(
code(pitchfork::ipc::connection_closed),
url("https://pitchfork.jdx.dev/supervisor"),
help(
"the supervisor may have crashed or been stopped.\nRestart with: pitchfork supervisor start"
)
)]
ConnectionClosed,
#[error("failed to read IPC response")]
#[diagnostic(code(pitchfork::ipc::read_failed))]
ReadFailed {
#[source]
source: io::Error,
},
#[error("failed to send IPC request")]
#[diagnostic(code(pitchfork::ipc::send_failed))]
SendFailed {
#[source]
source: io::Error,
},
#[error("unexpected response from supervisor: expected {expected}, got {actual}")]
#[diagnostic(
code(pitchfork::ipc::unexpected_response),
help("this may indicate a version mismatch between the CLI and supervisor")
)]
UnexpectedResponse { expected: String, actual: String },
#[error("IPC message is invalid: {reason}")]
#[diagnostic(code(pitchfork::ipc::invalid_message))]
InvalidMessage { reason: String },
}
#[derive(Debug, Error, Diagnostic)]
#[error("multiple errors occurred ({} total)", errors.len())]
#[diagnostic(code(pitchfork::multiple_errors))]
#[allow(dead_code)]
pub struct MultipleErrors {
#[related]
pub errors: Vec<Box<dyn Diagnostic + Send + Sync + 'static>>,
}
#[allow(dead_code)]
impl MultipleErrors {
pub fn new(errors: Vec<Box<dyn Diagnostic + Send + Sync + 'static>>) -> Self {
Self { errors }
}
pub fn is_empty(&self) -> bool {
self.errors.is_empty()
}
pub fn len(&self) -> usize {
self.errors.len()
}
}
pub fn find_similar_daemon<'a>(
name: &str,
available: impl Iterator<Item = &'a str>,
) -> Option<String> {
use fuzzy_matcher::FuzzyMatcher;
use fuzzy_matcher::skim::SkimMatcherV2;
let matcher = SkimMatcherV2::default();
available
.filter_map(|candidate| {
matcher
.fuzzy_match(candidate, name)
.map(|score| (candidate, score))
})
.max_by_key(|(_, score)| *score)
.filter(|(_, score)| *score > 0)
.map(|(candidate, _)| format!("did you mean '{candidate}'?"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_daemon_id_error_display() {
let err = DaemonIdError::Empty;
assert_eq!(err.to_string(), "daemon ID cannot be empty");
let err = DaemonIdError::PathSeparator {
id: "foo/bar".to_string(),
sep: '/',
};
assert_eq!(
err.to_string(),
"daemon ID 'foo/bar' contains path separator '/'"
);
let err = DaemonIdError::ContainsSpace {
id: "my app".to_string(),
};
assert_eq!(err.to_string(), "daemon ID 'my app' contains spaces");
}
#[test]
fn test_dependency_error_display() {
let err = DependencyError::DaemonNotFound {
name: "postgres".to_string(),
suggestion: None,
};
assert_eq!(
err.to_string(),
"daemon 'postgres' not found in configuration"
);
let err = DependencyError::MissingDependency {
daemon: "api".to_string(),
dependency: "db".to_string(),
};
assert_eq!(
err.to_string(),
"daemon 'api' depends on 'db' which is not defined"
);
let err = DependencyError::CircularDependency {
involved: vec!["a".to_string(), "b".to_string(), "c".to_string()],
};
assert!(err.to_string().contains("circular dependency"));
assert!(err.to_string().contains("a, b, c"));
}
#[test]
fn test_find_similar_daemon() {
let daemons = ["postgres", "redis", "api", "worker"];
let suggestion = find_similar_daemon("postgre", daemons.iter().copied());
assert_eq!(suggestion, Some("did you mean 'postgres'?".to_string()));
let suggestion = find_similar_daemon("xyz123", daemons.iter().copied());
assert!(suggestion.is_none());
}
#[test]
fn test_file_error_display() {
let err = FileError::ReadError {
path: PathBuf::from("/path/to/config.toml"),
source: io::Error::new(io::ErrorKind::NotFound, "file not found"),
};
assert!(err.to_string().contains("failed to read file"));
assert!(err.to_string().contains("config.toml"));
let err = FileError::NoPath;
assert!(err.to_string().contains("no file path"));
}
#[test]
fn test_ipc_error_display() {
let err = IpcError::ConnectionFailed {
attempts: 5,
source: None,
help: "ensure the supervisor is running".to_string(),
};
assert!(err.to_string().contains("failed to connect"));
assert!(err.to_string().contains("5 attempts"));
let err = IpcError::Timeout { seconds: 30 };
assert!(err.to_string().contains("timed out"));
assert!(err.to_string().contains("30s"));
let err = IpcError::UnexpectedResponse {
expected: "Ok".to_string(),
actual: "Error".to_string(),
};
assert!(err.to_string().contains("unexpected response"));
assert!(err.to_string().contains("Ok"));
assert!(err.to_string().contains("Error"));
}
#[test]
fn test_config_parse_error() {
let contents = "[daemons.test]\nrun = ".to_string();
let err = toml::from_str::<toml::Value>(&contents).unwrap_err();
let parse_err =
ConfigParseError::from_toml_error(std::path::Path::new("test.toml"), contents, err);
assert!(parse_err.to_string().contains("failed to parse"));
}
#[test]
fn test_multiple_errors() {
let errors: Vec<Box<dyn Diagnostic + Send + Sync>> = vec![
Box::new(DaemonIdError::Empty),
Box::new(DaemonIdError::CurrentDir),
];
let multi = MultipleErrors::new(errors);
assert_eq!(multi.len(), 2);
assert!(!multi.is_empty());
assert!(multi.to_string().contains("2 total"));
}
}