Skip to main content

cuenv_workspaces/
error.rs

1//! Error types for workspace operations.
2
3use miette::Diagnostic;
4use std::path::PathBuf;
5use thiserror::Error;
6
7/// Result type for workspace operations.
8pub type Result<T> = std::result::Result<T, Error>;
9
10/// Errors that can occur during workspace operations.
11#[derive(Error, Debug, Diagnostic)]
12pub enum Error {
13    /// Workspace root directory not found.
14    #[error("Workspace not found at path: {path}")]
15    #[diagnostic(
16        code(cuenv::workspaces::workspace_not_found),
17        help(
18            "Ensure the path points to a valid workspace root directory with a workspace configuration file"
19        )
20    )]
21    WorkspaceNotFound {
22        /// The path that was searched.
23        path: PathBuf,
24    },
25
26    /// Invalid workspace configuration.
27    #[error("Invalid workspace configuration at {path}: {message}")]
28    #[diagnostic(
29        code(cuenv::workspaces::invalid_config),
30        help(
31            "Check the workspace configuration file for syntax errors or missing required fields"
32        )
33    )]
34    InvalidWorkspaceConfig {
35        /// Path to the invalid configuration file.
36        path: PathBuf,
37        /// Description of what is invalid.
38        message: String,
39    },
40
41    /// Lockfile not found.
42    #[error("Lockfile not found at path: {path}")]
43    #[diagnostic(
44        code(cuenv::workspaces::lockfile_not_found),
45        help(
46            "Run your package manager's install command to generate a lockfile (e.g., 'npm install', 'cargo build')"
47        )
48    )]
49    LockfileNotFound {
50        /// The path where the lockfile was expected.
51        path: PathBuf,
52    },
53
54    /// Manifest file not found.
55    #[error("Manifest file not found at path: {path}")]
56    #[diagnostic(
57        code(cuenv::workspaces::manifest_not_found),
58        help(
59            "Ensure the manifest file exists at the expected location (e.g., 'package.json', 'Cargo.toml')"
60        )
61    )]
62    ManifestNotFound {
63        /// The path where the manifest was expected.
64        path: PathBuf,
65    },
66
67    /// Failed to parse lockfile.
68    #[error("Failed to parse lockfile at {path}: {message}")]
69    #[diagnostic(
70        code(cuenv::workspaces::lockfile_parse_failed),
71        help("The lockfile may be corrupted. Try regenerating it with your package manager")
72    )]
73    LockfileParseFailed {
74        /// Path to the lockfile.
75        path: PathBuf,
76        /// Description of the parse error.
77        message: String,
78    },
79
80    /// Workspace member not found.
81    #[error("Workspace member '{name}' not found in workspace at {workspace_root}")]
82    #[diagnostic(
83        code(cuenv::workspaces::member_not_found),
84        help(
85            "Check that the member name is correct and the member is listed in the workspace configuration"
86        )
87    )]
88    MemberNotFound {
89        /// Name of the missing member.
90        name: String,
91        /// Root of the workspace where the member was expected.
92        workspace_root: PathBuf,
93    },
94
95    /// Dependency resolution failed.
96    #[error("Failed to resolve dependencies: {message}")]
97    #[diagnostic(
98        code(cuenv::workspaces::dependency_resolution_failed),
99        help("Check for circular dependencies or missing dependencies in the lockfile")
100    )]
101    DependencyResolutionFailed {
102        /// Description of the resolution failure.
103        message: String,
104    },
105
106    /// Unsupported package manager.
107    #[error("Unsupported package manager: {manager}")]
108    #[diagnostic(
109        code(cuenv::workspaces::unsupported_manager),
110        help("Supported package managers: npm, bun, pnpm, yarn, cargo")
111    )]
112    UnsupportedPackageManager {
113        /// The unsupported package manager name.
114        manager: String,
115    },
116
117    /// I/O error occurred.
118    #[error("I/O error during {operation}{}: {source}", path.as_ref().map(|p| format!(" at {}", p.display())).unwrap_or_default())]
119    #[diagnostic(
120        code(cuenv::workspaces::io_error),
121        help(
122            "Check that the referenced paths exist and that you have permission to read or write them"
123        )
124    )]
125    Io {
126        /// The underlying I/O error.
127        #[source]
128        source: std::io::Error,
129        /// Optional path where the error occurred.
130        path: Option<PathBuf>,
131        /// Description of the operation being performed.
132        operation: String,
133    },
134
135    /// JSON parsing error.
136    #[error("JSON parsing error{}: {source}", path.as_ref().map(|p| format!(" in {}", p.display())).unwrap_or_default())]
137    #[diagnostic(
138        code(cuenv::workspaces::json_error),
139        help(
140            "Ensure the JSON has valid syntax and matches the expected schema for workspace metadata"
141        )
142    )]
143    Json {
144        /// The underlying JSON error.
145        #[source]
146        source: serde_json::Error,
147        /// Optional path to the file being parsed.
148        path: Option<PathBuf>,
149    },
150
151    /// YAML parsing error.
152    #[cfg(feature = "serde_yaml")]
153    #[error("YAML parsing error{}: {source}", path.as_ref().map(|p| format!(" in {}", p.display())).unwrap_or_default())]
154    #[diagnostic(
155        code(cuenv::workspaces::yaml_error),
156        help(
157            "Ensure the YAML has valid syntax and matches the expected schema for workspace metadata"
158        )
159    )]
160    Yaml {
161        /// The underlying YAML error.
162        #[source]
163        source: serde_yaml::Error,
164        /// Optional path to the file being parsed.
165        path: Option<PathBuf>,
166    },
167
168    /// TOML parsing error.
169    #[cfg(feature = "toml")]
170    #[error("TOML parsing error{}: {source}", path.as_ref().map(|p| format!(" in {}", p.display())).unwrap_or_default())]
171    #[diagnostic(
172        code(cuenv::workspaces::toml_error),
173        help(
174            "Ensure the TOML has valid syntax and matches the expected schema for Cargo manifests"
175        )
176    )]
177    Toml {
178        /// The underlying TOML error.
179        #[source]
180        source: toml::de::Error,
181        /// Optional path to the file being parsed.
182        path: Option<PathBuf>,
183    },
184}
185
186impl From<std::io::Error> for Error {
187    fn from(source: std::io::Error) -> Self {
188        Self::Io {
189            source,
190            path: None,
191            operation: "file operation".to_string(),
192        }
193    }
194}
195
196impl From<serde_json::Error> for Error {
197    fn from(source: serde_json::Error) -> Self {
198        Self::Json { source, path: None }
199    }
200}
201
202#[cfg(feature = "serde_yaml")]
203impl From<serde_yaml::Error> for Error {
204    fn from(source: serde_yaml::Error) -> Self {
205        Self::Yaml { source, path: None }
206    }
207}
208
209#[cfg(feature = "toml")]
210impl From<toml::de::Error> for Error {
211    fn from(source: toml::de::Error) -> Self {
212        Self::Toml { source, path: None }
213    }
214}
215
216#[cfg(test)]
217#[allow(clippy::unnecessary_wraps)]
218mod tests {
219    use super::*;
220    use std::path::PathBuf;
221
222    #[test]
223    fn test_workspace_not_found_error() {
224        let error = Error::WorkspaceNotFound {
225            path: PathBuf::from("/nonexistent"),
226        };
227
228        let message = error.to_string();
229        assert!(message.contains("Workspace not found"));
230        assert!(message.contains("/nonexistent"));
231    }
232
233    #[test]
234    fn test_invalid_workspace_config_error() {
235        let error = Error::InvalidWorkspaceConfig {
236            path: PathBuf::from("/workspace/package.json"),
237            message: "Missing 'workspaces' field".to_string(),
238        };
239
240        let message = error.to_string();
241        assert!(message.contains("Invalid workspace configuration"));
242        assert!(message.contains("package.json"));
243        assert!(message.contains("Missing 'workspaces' field"));
244    }
245
246    #[test]
247    fn test_lockfile_not_found_error() {
248        let error = Error::LockfileNotFound {
249            path: PathBuf::from("/workspace/package-lock.json"),
250        };
251
252        let message = error.to_string();
253        assert!(message.contains("Lockfile not found"));
254        assert!(message.contains("package-lock.json"));
255    }
256
257    #[test]
258    fn test_lockfile_parse_failed_error() {
259        let error = Error::LockfileParseFailed {
260            path: PathBuf::from("/workspace/Cargo.lock"),
261            message: "Invalid TOML syntax".to_string(),
262        };
263
264        let message = error.to_string();
265        assert!(message.contains("Failed to parse lockfile"));
266        assert!(message.contains("Cargo.lock"));
267        assert!(message.contains("Invalid TOML syntax"));
268    }
269
270    #[test]
271    fn test_member_not_found_error() {
272        let error = Error::MemberNotFound {
273            name: "my-package".to_string(),
274            workspace_root: PathBuf::from("/workspace"),
275        };
276
277        let message = error.to_string();
278        assert!(message.contains("Workspace member"));
279        assert!(message.contains("my-package"));
280        assert!(message.contains("not found"));
281    }
282
283    #[test]
284    fn test_dependency_resolution_failed_error() {
285        let error = Error::DependencyResolutionFailed {
286            message: "Circular dependency detected".to_string(),
287        };
288
289        let message = error.to_string();
290        assert!(message.contains("Failed to resolve dependencies"));
291        assert!(message.contains("Circular dependency"));
292    }
293
294    #[test]
295    fn test_unsupported_package_manager_error() {
296        let error = Error::UnsupportedPackageManager {
297            manager: "poetry".to_string(),
298        };
299
300        let message = error.to_string();
301        assert!(message.contains("Unsupported package manager"));
302        assert!(message.contains("poetry"));
303    }
304
305    #[test]
306    fn test_io_error_display() {
307        let io_error = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found");
308        let error = Error::Io {
309            source: io_error,
310            path: Some(PathBuf::from("/test/file.txt")),
311            operation: "reading file".to_string(),
312        };
313
314        let message = error.to_string();
315        assert!(message.contains("I/O error during reading file"));
316        assert!(message.contains("/test/file.txt"));
317    }
318
319    #[test]
320    fn test_io_error_no_path() {
321        let io_error = std::io::Error::new(std::io::ErrorKind::PermissionDenied, "access denied");
322        let error = Error::Io {
323            source: io_error,
324            path: None,
325            operation: "opening directory".to_string(),
326        };
327
328        let message = error.to_string();
329        assert!(message.contains("I/O error during opening directory"));
330        assert!(!message.contains(" at "));
331    }
332
333    #[test]
334    fn test_json_error_display() {
335        let json_str = "{ invalid json }";
336        let json_error = serde_json::from_str::<serde_json::Value>(json_str).unwrap_err();
337        let error = Error::Json {
338            source: json_error,
339            path: Some(PathBuf::from("/workspace/package.json")),
340        };
341
342        let message = error.to_string();
343        assert!(message.contains("JSON parsing error"));
344        assert!(message.contains("package.json"));
345    }
346
347    #[test]
348    fn test_json_error_no_path() {
349        let json_str = "{ invalid json }";
350        let json_error = serde_json::from_str::<serde_json::Value>(json_str).unwrap_err();
351        let error = Error::Json {
352            source: json_error,
353            path: None,
354        };
355
356        let message = error.to_string();
357        assert!(message.contains("JSON parsing error"));
358        assert!(!message.contains(" in "));
359    }
360
361    #[test]
362    fn test_io_error_conversion() {
363        let io_error = std::io::Error::new(std::io::ErrorKind::NotFound, "not found");
364        let error: Error = io_error.into();
365
366        match error {
367            Error::Io {
368                source: _,
369                path,
370                operation,
371            } => {
372                assert_eq!(path, None);
373                assert_eq!(operation, "file operation");
374            }
375            _ => panic!("Expected Io error variant"),
376        }
377    }
378
379    #[test]
380    fn test_json_error_conversion() {
381        let json_error = serde_json::from_str::<serde_json::Value>("invalid").unwrap_err();
382        let error: Error = json_error.into();
383
384        match error {
385            Error::Json { source: _, path } => {
386                assert_eq!(path, None);
387            }
388            _ => panic!("Expected Json error variant"),
389        }
390    }
391
392    #[cfg(feature = "serde_yaml")]
393    #[test]
394    fn test_yaml_error_display() {
395        let yaml_error = serde_yaml::from_str::<serde_yaml::Value>("invalid: : yaml").unwrap_err();
396        let error = Error::Yaml {
397            source: yaml_error,
398            path: Some(PathBuf::from("/workspace/pnpm-lock.yaml")),
399        };
400
401        let message = error.to_string();
402        assert!(message.contains("YAML parsing error"));
403        assert!(message.contains("pnpm-lock.yaml"));
404    }
405
406    #[cfg(feature = "serde_yaml")]
407    #[test]
408    fn test_yaml_error_no_path() {
409        let yaml_error = serde_yaml::from_str::<serde_yaml::Value>("invalid: : yaml").unwrap_err();
410        let error = Error::Yaml {
411            source: yaml_error,
412            path: None,
413        };
414
415        let message = error.to_string();
416        assert!(message.contains("YAML parsing error"));
417        // Check that it doesn't have the path context prefix
418        assert!(message.starts_with("YAML parsing error: "));
419    }
420
421    #[cfg(feature = "serde_yaml")]
422    #[test]
423    fn test_yaml_error_conversion() {
424        let yaml_error = serde_yaml::from_str::<serde_yaml::Value>("invalid: : yaml").unwrap_err();
425        let error: Error = yaml_error.into();
426
427        match error {
428            Error::Yaml { source: _, path } => {
429                assert_eq!(path, None);
430            }
431            _ => panic!("Expected Yaml error variant"),
432        }
433    }
434
435    #[cfg(feature = "serde_yaml")]
436    #[test]
437    fn test_yaml_error_diagnostics() {
438        use miette::Diagnostic;
439
440        let yaml_error = serde_yaml::from_str::<serde_yaml::Value>("invalid: : yaml").unwrap_err();
441        let error = Error::Yaml {
442            source: yaml_error,
443            path: None,
444        };
445
446        assert_eq!(
447            error.code().map(|c| c.to_string()),
448            Some("cuenv::workspaces::yaml_error".to_string())
449        );
450        assert!(error.help().is_some());
451    }
452
453    #[cfg(feature = "toml")]
454    #[test]
455    fn test_toml_error_display() {
456        let toml_error = toml::from_str::<toml::Value>("not valid = [").unwrap_err();
457        let error = Error::Toml {
458            source: toml_error,
459            path: Some(PathBuf::from("/workspace/Cargo.toml")),
460        };
461
462        let message = error.to_string();
463        assert!(message.contains("TOML parsing error"));
464        assert!(message.contains("Cargo.toml"));
465    }
466
467    #[cfg(feature = "toml")]
468    #[test]
469    fn test_toml_error_no_path() {
470        let toml_error = toml::from_str::<toml::Value>("not valid = [").unwrap_err();
471        let error = Error::Toml {
472            source: toml_error,
473            path: None,
474        };
475
476        let message = error.to_string();
477        assert!(message.contains("TOML parsing error"));
478        assert!(!message.contains(" in "));
479    }
480
481    #[cfg(feature = "toml")]
482    #[test]
483    fn test_toml_error_conversion() {
484        let toml_error = toml::from_str::<toml::Value>("not valid = [").unwrap_err();
485        let error: Error = toml_error.into();
486
487        match error {
488            Error::Toml { source: _, path } => {
489                assert_eq!(path, None);
490            }
491            _ => panic!("Expected Toml error variant"),
492        }
493    }
494
495    #[cfg(feature = "toml")]
496    #[test]
497    fn test_toml_error_diagnostics() {
498        use miette::Diagnostic;
499
500        let toml_error = toml::from_str::<toml::Value>("not valid = [").unwrap_err();
501        let error = Error::Toml {
502            source: toml_error,
503            path: None,
504        };
505
506        assert_eq!(
507            error.code().map(|c| c.to_string()),
508            Some("cuenv::workspaces::toml_error".to_string())
509        );
510        assert!(error.help().is_some());
511    }
512
513    #[test]
514    fn test_result_type_with_question_mark() {
515        fn returns_result() -> Result<String> {
516            Ok("success".to_string())
517        }
518
519        fn uses_result() -> Result<String> {
520            let value = returns_result()?;
521            Ok(value)
522        }
523
524        assert!(uses_result().is_ok());
525    }
526
527    #[test]
528    fn test_diagnostic_codes() {
529        use miette::Diagnostic;
530
531        let error = Error::WorkspaceNotFound {
532            path: PathBuf::from("/test"),
533        };
534        assert!(error.code().is_some());
535
536        let error = Error::InvalidWorkspaceConfig {
537            path: PathBuf::from("/test"),
538            message: "test".to_string(),
539        };
540        assert!(error.code().is_some());
541
542        let error = Error::LockfileNotFound {
543            path: PathBuf::from("/test"),
544        };
545        assert!(error.code().is_some());
546
547        let error = Error::LockfileParseFailed {
548            path: PathBuf::from("/test"),
549            message: "test".to_string(),
550        };
551        assert!(error.code().is_some());
552
553        let error = Error::MemberNotFound {
554            name: "test".to_string(),
555            workspace_root: PathBuf::from("/test"),
556        };
557        assert!(error.code().is_some());
558
559        let error = Error::DependencyResolutionFailed {
560            message: "test".to_string(),
561        };
562        assert!(error.code().is_some());
563
564        let error = Error::UnsupportedPackageManager {
565            manager: "test".to_string(),
566        };
567        assert!(error.code().is_some());
568    }
569
570    #[test]
571    fn test_diagnostic_help_messages() {
572        use miette::Diagnostic;
573
574        let error = Error::WorkspaceNotFound {
575            path: PathBuf::from("/test"),
576        };
577        assert!(error.help().is_some());
578
579        let error = Error::InvalidWorkspaceConfig {
580            path: PathBuf::from("/test"),
581            message: "test".to_string(),
582        };
583        assert!(error.help().is_some());
584
585        let error = Error::LockfileNotFound {
586            path: PathBuf::from("/test"),
587        };
588        assert!(error.help().is_some());
589
590        let error = Error::LockfileParseFailed {
591            path: PathBuf::from("/test"),
592            message: "test".to_string(),
593        };
594        assert!(error.help().is_some());
595    }
596}