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