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 '{id}' contains path separator '{sep}'")]
26 #[diagnostic(
27 code(pitchfork::daemon::path_separator),
28 url("https://pitchfork.jdx.dev/configuration"),
29 help("daemon IDs cannot contain '/' or '\\' to prevent path traversal")
30 )]
31 PathSeparator { id: String, sep: char },
32
33 #[error("daemon ID '{id}' contains parent directory reference '..'")]
34 #[diagnostic(
35 code(pitchfork::daemon::parent_dir_ref),
36 url("https://pitchfork.jdx.dev/configuration"),
37 help("daemon IDs cannot contain '..' to prevent path traversal")
38 )]
39 ParentDirRef { id: String },
40
41 #[error("daemon ID '{id}' contains spaces")]
42 #[diagnostic(
43 code(pitchfork::daemon::contains_space),
44 url("https://pitchfork.jdx.dev/configuration"),
45 help("use hyphens or underscores instead of spaces (e.g., 'my-daemon' or 'my_daemon')")
46 )]
47 ContainsSpace { id: String },
48
49 #[error("daemon ID cannot be '.'")]
50 #[diagnostic(
51 code(pitchfork::daemon::current_dir),
52 url("https://pitchfork.jdx.dev/configuration"),
53 help("'.' refers to the current directory; use a descriptive name instead")
54 )]
55 CurrentDir,
56
57 #[error("daemon ID '{id}' contains non-printable or non-ASCII character")]
58 #[diagnostic(
59 code(pitchfork::daemon::invalid_chars),
60 url("https://pitchfork.jdx.dev/configuration"),
61 help(
62 "daemon IDs must contain only printable ASCII characters (letters, numbers, hyphens, underscores, dots)"
63 )
64 )]
65 InvalidChars { id: String },
66}
67
68#[derive(Debug, Error, Diagnostic)]
70pub enum DaemonError {
71 #[error("failed to stop daemon '{id}': {error}")]
72 #[diagnostic(
73 code(pitchfork::daemon::stop_failed),
74 help("the process may be stuck or require manual intervention. Try: kill -9 <pid>")
75 )]
76 StopFailed { id: String, error: String },
77}
78
79#[derive(Debug, Error, Diagnostic)]
81pub enum DependencyError {
82 #[error("daemon '{name}' not found in configuration")]
83 #[diagnostic(
84 code(pitchfork::deps::not_found),
85 url("https://pitchfork.jdx.dev/configuration#depends")
86 )]
87 DaemonNotFound {
88 name: String,
89 #[help]
90 suggestion: Option<String>,
91 },
92
93 #[error("daemon '{daemon}' depends on '{dependency}' which is not defined")]
94 #[diagnostic(
95 code(pitchfork::deps::missing_dependency),
96 url("https://pitchfork.jdx.dev/configuration#depends"),
97 help("add the missing daemon to your pitchfork.toml or remove it from the depends list")
98 )]
99 MissingDependency { daemon: String, dependency: String },
100
101 #[error("circular dependency detected involving: {}", involved.join(", "))]
102 #[diagnostic(
103 code(pitchfork::deps::circular),
104 url("https://pitchfork.jdx.dev/configuration#depends"),
105 help("break the cycle by removing one of the dependencies")
106 )]
107 CircularDependency {
108 involved: Vec<String>,
110 },
111}
112
113#[derive(Debug, Error, Diagnostic)]
115#[error("failed to parse configuration")]
116#[diagnostic(code(pitchfork::config::parse_error))]
117pub struct ConfigParseError {
118 #[source_code]
120 pub src: NamedSource<String>,
121
122 #[label("{message}")]
124 pub span: SourceSpan,
125
126 pub message: String,
128
129 #[help]
131 pub help: Option<String>,
132}
133
134impl ConfigParseError {
135 pub fn from_toml_error(path: &std::path::Path, contents: String, err: toml::de::Error) -> Self {
137 let message = err.message().to_string();
138
139 let span = err
141 .span()
142 .map(|r| SourceSpan::from(r.start..r.end))
143 .unwrap_or_else(|| SourceSpan::from(0..0));
144
145 Self {
146 src: NamedSource::new(path.display().to_string(), contents),
147 span,
148 message,
149 help: Some("check TOML syntax at https://toml.io".to_string()),
150 }
151 }
152}
153
154#[derive(Debug, Error, Diagnostic)]
156pub enum FileError {
157 #[error("failed to read file: {}", path.display())]
158 #[diagnostic(code(pitchfork::file::read_error))]
159 ReadError {
160 path: PathBuf,
161 #[source]
162 source: io::Error,
163 },
164
165 #[error("failed to write file: {}", path.display())]
166 #[diagnostic(code(pitchfork::file::write_error))]
167 WriteError {
168 path: PathBuf,
169 #[help]
170 details: Option<String>,
171 },
172
173 #[error("failed to serialize data for file: {}", path.display())]
174 #[diagnostic(
175 code(pitchfork::file::serialize_error),
176 help("this is likely an internal error; please report it")
177 )]
178 SerializeError {
179 path: PathBuf,
180 #[source]
181 source: toml::ser::Error,
182 },
183
184 #[error("no file path specified")]
185 #[diagnostic(
186 code(pitchfork::file::no_path),
187 help("ensure a pitchfork.toml file exists in your project or specify a path")
188 )]
189 NoPath,
190}
191
192#[derive(Debug, Error, Diagnostic)]
194pub enum IpcError {
195 #[error("failed to connect to supervisor after {attempts} attempts")]
196 #[diagnostic(
197 code(pitchfork::ipc::connection_failed),
198 url("https://pitchfork.jdx.dev/supervisor")
199 )]
200 ConnectionFailed {
201 attempts: u32,
202 #[source]
203 source: Option<io::Error>,
204 #[help]
205 help: String,
206 },
207
208 #[error("IPC request timed out after {seconds}s")]
209 #[diagnostic(
210 code(pitchfork::ipc::timeout),
211 url("https://pitchfork.jdx.dev/supervisor"),
212 help(
213 "the supervisor may be unresponsive or overloaded.\nCheck supervisor status: pitchfork supervisor status\nView logs: pitchfork logs"
214 )
215 )]
216 Timeout { seconds: u64 },
217
218 #[error("IPC connection closed unexpectedly")]
219 #[diagnostic(
220 code(pitchfork::ipc::connection_closed),
221 url("https://pitchfork.jdx.dev/supervisor"),
222 help(
223 "the supervisor may have crashed or been stopped.\nRestart with: pitchfork supervisor start"
224 )
225 )]
226 ConnectionClosed,
227
228 #[error("failed to read IPC response")]
229 #[diagnostic(code(pitchfork::ipc::read_failed))]
230 ReadFailed {
231 #[source]
232 source: io::Error,
233 },
234
235 #[error("failed to send IPC request")]
236 #[diagnostic(code(pitchfork::ipc::send_failed))]
237 SendFailed {
238 #[source]
239 source: io::Error,
240 },
241
242 #[error("unexpected response from supervisor: expected {expected}, got {actual}")]
243 #[diagnostic(
244 code(pitchfork::ipc::unexpected_response),
245 help("this may indicate a version mismatch between the CLI and supervisor")
246 )]
247 UnexpectedResponse { expected: String, actual: String },
248
249 #[error("IPC message is invalid: {reason}")]
250 #[diagnostic(code(pitchfork::ipc::invalid_message))]
251 InvalidMessage { reason: String },
252}
253
254#[derive(Debug, Error, Diagnostic)]
259#[error("multiple errors occurred ({} total)", errors.len())]
260#[diagnostic(code(pitchfork::multiple_errors))]
261#[allow(dead_code)]
262pub struct MultipleErrors {
263 #[related]
264 pub errors: Vec<Box<dyn Diagnostic + Send + Sync + 'static>>,
265}
266
267#[allow(dead_code)]
268impl MultipleErrors {
269 pub fn new(errors: Vec<Box<dyn Diagnostic + Send + Sync + 'static>>) -> Self {
271 Self { errors }
272 }
273
274 pub fn is_empty(&self) -> bool {
276 self.errors.is_empty()
277 }
278
279 pub fn len(&self) -> usize {
281 self.errors.len()
282 }
283}
284
285pub fn find_similar_daemon<'a>(
287 name: &str,
288 available: impl Iterator<Item = &'a str>,
289) -> Option<String> {
290 use fuzzy_matcher::FuzzyMatcher;
291 use fuzzy_matcher::skim::SkimMatcherV2;
292
293 let matcher = SkimMatcherV2::default();
294 available
295 .filter_map(|candidate| {
296 matcher
297 .fuzzy_match(candidate, name)
298 .map(|score| (candidate, score))
299 })
300 .max_by_key(|(_, score)| *score)
301 .filter(|(_, score)| *score > 0)
302 .map(|(candidate, _)| format!("did you mean '{candidate}'?"))
303}
304
305#[cfg(test)]
306mod tests {
307 use super::*;
308
309 #[test]
310 fn test_daemon_id_error_display() {
311 let err = DaemonIdError::Empty;
312 assert_eq!(err.to_string(), "daemon ID cannot be empty");
313
314 let err = DaemonIdError::PathSeparator {
315 id: "foo/bar".to_string(),
316 sep: '/',
317 };
318 assert_eq!(
319 err.to_string(),
320 "daemon ID 'foo/bar' contains path separator '/'"
321 );
322
323 let err = DaemonIdError::ContainsSpace {
324 id: "my app".to_string(),
325 };
326 assert_eq!(err.to_string(), "daemon ID 'my app' contains spaces");
327 }
328
329 #[test]
330 fn test_dependency_error_display() {
331 let err = DependencyError::DaemonNotFound {
332 name: "postgres".to_string(),
333 suggestion: None,
334 };
335 assert_eq!(
336 err.to_string(),
337 "daemon 'postgres' not found in configuration"
338 );
339
340 let err = DependencyError::MissingDependency {
341 daemon: "api".to_string(),
342 dependency: "db".to_string(),
343 };
344 assert_eq!(
345 err.to_string(),
346 "daemon 'api' depends on 'db' which is not defined"
347 );
348
349 let err = DependencyError::CircularDependency {
350 involved: vec!["a".to_string(), "b".to_string(), "c".to_string()],
351 };
352 assert!(err.to_string().contains("circular dependency"));
353 assert!(err.to_string().contains("a, b, c"));
354 }
355
356 #[test]
357 fn test_find_similar_daemon() {
358 let daemons = ["postgres", "redis", "api", "worker"];
359
360 let suggestion = find_similar_daemon("postgre", daemons.iter().copied());
362 assert_eq!(suggestion, Some("did you mean 'postgres'?".to_string()));
363
364 let suggestion = find_similar_daemon("xyz123", daemons.iter().copied());
366 assert!(suggestion.is_none());
367 }
368
369 #[test]
370 fn test_file_error_display() {
371 let err = FileError::ReadError {
372 path: PathBuf::from("/path/to/config.toml"),
373 source: io::Error::new(io::ErrorKind::NotFound, "file not found"),
374 };
375 assert!(err.to_string().contains("failed to read file"));
376 assert!(err.to_string().contains("config.toml"));
377
378 let err = FileError::NoPath;
379 assert!(err.to_string().contains("no file path"));
380 }
381
382 #[test]
383 fn test_ipc_error_display() {
384 let err = IpcError::ConnectionFailed {
385 attempts: 5,
386 source: None,
387 help: "ensure the supervisor is running".to_string(),
388 };
389 assert!(err.to_string().contains("failed to connect"));
390 assert!(err.to_string().contains("5 attempts"));
391
392 let err = IpcError::Timeout { seconds: 30 };
393 assert!(err.to_string().contains("timed out"));
394 assert!(err.to_string().contains("30s"));
395
396 let err = IpcError::UnexpectedResponse {
397 expected: "Ok".to_string(),
398 actual: "Error".to_string(),
399 };
400 assert!(err.to_string().contains("unexpected response"));
401 assert!(err.to_string().contains("Ok"));
402 assert!(err.to_string().contains("Error"));
403 }
404
405 #[test]
406 fn test_config_parse_error() {
407 let contents = "[daemons.test]\nrun = ".to_string();
408 let err = toml::from_str::<toml::Value>(&contents).unwrap_err();
409 let parse_err =
410 ConfigParseError::from_toml_error(std::path::Path::new("test.toml"), contents, err);
411
412 assert!(parse_err.to_string().contains("failed to parse"));
413 }
414
415 #[test]
416 fn test_multiple_errors() {
417 let errors: Vec<Box<dyn Diagnostic + Send + Sync>> = vec![
418 Box::new(DaemonIdError::Empty),
419 Box::new(DaemonIdError::CurrentDir),
420 ];
421 let multi = MultipleErrors::new(errors);
422
423 assert_eq!(multi.len(), 2);
424 assert!(!multi.is_empty());
425 assert!(multi.to_string().contains("2 total"));
426 }
427}