#![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.en.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.en.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.en.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.en.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.en.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.en.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.en.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.en.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.en.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.en.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.en.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.en.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.en.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.en.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.en.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.en.dev/configuration"),
help("daemon names must be valid identifiers without spaces, '--', or special characters")
)]
InvalidDaemonName {
name: String,
path: PathBuf,
reason: String,
},
#[error(
"invalid dependency '{dependency}' in daemon '{daemon}' ({}): {reason}",
path.display()
)]
#[diagnostic(
code(pitchfork::config::invalid_dependency),
url("https://pitchfork.en.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(
"namespace collision: '{}' and '{}' both resolve to namespace '{ns}'",
path_a.display(),
path_b.display()
)]
#[diagnostic(
code(pitchfork::config::namespace_collision),
url("https://pitchfork.en.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.en.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.en.dev/supervisor")
)]
ConnectionFailed {
attempts: u32,
#[source]
source: Option<io::Error>,
#[help]
help: String,
},
#[error("IPC request timed out after {seconds}s")]
#[diagnostic(
code(pitchfork::ipc::timeout),
url("https://pitchfork.en.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.en.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"));
}
}