cuenv_core/
lib.rs

1//! Core types and utilities for cuenv
2//!
3//! This crate provides enhanced error handling with miette diagnostics,
4//! structured error reporting, and contextual information.
5//!
6//! ## Type-safe wrappers
7//!
8//! This crate provides validated newtype wrappers for common domain types:
9//!
10//! - [`PackageDir`] - A validated directory path that must exist and be a directory
11//! - [`PackageName`] - A validated package name following CUE package naming rules
12//!
13//! ## Examples
14//!
15//! ```rust
16//! use cuenv_core::{PackageDir, PackageName};
17//! use std::path::Path;
18//!
19//! // Validate a directory exists and is actually a directory
20//! let pkg_dir = match PackageDir::try_from(Path::new(".")) {
21//!     Ok(dir) => dir,
22//!     Err(e) => {
23//!         eprintln!("Invalid directory: {}", e);
24//!         return;
25//!     }
26//! };
27//!
28//! // Validate a package name follows naming rules
29//! let pkg_name = match PackageName::try_from("my-package") {
30//!     Ok(name) => name,
31//!     Err(e) => {
32//!         eprintln!("Invalid package name: {}", e);
33//!         return;
34//!     }
35//! };
36//! ```
37
38pub mod cache;
39pub mod config;
40pub mod environment;
41pub mod hooks;
42pub mod manifest;
43pub mod secrets;
44pub mod shell;
45pub mod tasks;
46
47/// Version of the `cuenv-core` crate (used by task cache metadata)
48pub const VERSION: &str = env!("CARGO_PKG_VERSION");
49
50#[cfg(test)]
51mod schema_tests;
52
53use miette::{Diagnostic, SourceSpan};
54use serde::{Deserialize, Serialize};
55use std::path::{Path, PathBuf};
56use thiserror::Error;
57
58/// Main error type for cuenv operations with enhanced diagnostics
59#[derive(Error, Debug, Diagnostic)]
60pub enum Error {
61    #[error("Configuration error: {message}")]
62    #[diagnostic(
63        code(cuenv::config::invalid),
64        help("Check your cuenv.cue configuration file for syntax errors or invalid values")
65    )]
66    Configuration {
67        #[source_code]
68        src: String,
69        #[label("invalid configuration")]
70        span: Option<SourceSpan>,
71        message: String,
72    },
73
74    #[error("FFI operation failed in {function}: {message}")]
75    #[diagnostic(code(cuenv::ffi::error))]
76    Ffi {
77        function: &'static str,
78        message: String,
79        #[help]
80        help: Option<String>,
81    },
82
83    #[error("CUE parsing failed: {message}")]
84    #[diagnostic(code(cuenv::cue::parse_error))]
85    CueParse {
86        path: Box<Path>,
87        #[source_code]
88        src: Option<String>,
89        #[label("parsing failed here")]
90        span: Option<SourceSpan>,
91        message: String,
92        suggestions: Option<Vec<String>>,
93    },
94
95    #[error("I/O operation failed")]
96    #[diagnostic(
97        code(cuenv::io::error),
98        help("Check file permissions and ensure the path exists")
99    )]
100    Io {
101        #[source]
102        source: std::io::Error,
103        path: Option<Box<Path>>,
104        operation: String,
105    },
106
107    #[error("Text encoding error")]
108    #[diagnostic(
109        code(cuenv::encoding::utf8),
110        help("The file contains invalid UTF-8. Ensure your files use UTF-8 encoding.")
111    )]
112    Utf8 {
113        #[source]
114        source: std::str::Utf8Error,
115        file: Option<Box<Path>>,
116    },
117
118    #[error("Operation timed out after {seconds} seconds")]
119    #[diagnostic(
120        code(cuenv::timeout),
121        help("Try increasing the timeout or check if the operation is stuck")
122    )]
123    Timeout { seconds: u64 },
124
125    #[error("Validation failed: {message}")]
126    #[diagnostic(code(cuenv::validation::failed))]
127    Validation {
128        #[source_code]
129        src: Option<String>,
130        #[label("validation failed")]
131        span: Option<SourceSpan>,
132        message: String,
133        #[related]
134        related: Vec<Error>,
135    },
136}
137
138impl Error {
139    pub fn configuration(msg: impl Into<String>) -> Self {
140        Error::Configuration {
141            src: String::new(),
142            span: None,
143            message: msg.into(),
144        }
145    }
146
147    pub fn configuration_with_source(
148        msg: impl Into<String>,
149        src: impl Into<String>,
150        span: Option<SourceSpan>,
151    ) -> Self {
152        Error::Configuration {
153            src: src.into(),
154            span,
155            message: msg.into(),
156        }
157    }
158
159    pub fn ffi(function: &'static str, message: impl Into<String>) -> Self {
160        Error::Ffi {
161            function,
162            message: message.into(),
163            help: None,
164        }
165    }
166
167    pub fn ffi_with_help(
168        function: &'static str,
169        message: impl Into<String>,
170        help: impl Into<String>,
171    ) -> Self {
172        Error::Ffi {
173            function,
174            message: message.into(),
175            help: Some(help.into()),
176        }
177    }
178
179    pub fn cue_parse(path: &Path, message: impl Into<String>) -> Self {
180        Error::CueParse {
181            path: path.into(),
182            src: None,
183            span: None,
184            message: message.into(),
185            suggestions: None,
186        }
187    }
188
189    pub fn cue_parse_with_source(
190        path: &Path,
191        message: impl Into<String>,
192        src: impl Into<String>,
193        span: Option<SourceSpan>,
194        suggestions: Option<Vec<String>>,
195    ) -> Self {
196        Error::CueParse {
197            path: path.into(),
198            src: Some(src.into()),
199            span,
200            message: message.into(),
201            suggestions,
202        }
203    }
204
205    pub fn validation(msg: impl Into<String>) -> Self {
206        Error::Validation {
207            src: None,
208            span: None,
209            message: msg.into(),
210            related: Vec::new(),
211        }
212    }
213
214    pub fn validation_with_source(
215        msg: impl Into<String>,
216        src: impl Into<String>,
217        span: Option<SourceSpan>,
218    ) -> Self {
219        Error::Validation {
220            src: Some(src.into()),
221            span,
222            message: msg.into(),
223            related: Vec::new(),
224        }
225    }
226}
227
228// Implement conversions for common error types
229impl From<std::io::Error> for Error {
230    fn from(source: std::io::Error) -> Self {
231        Error::Io {
232            source,
233            path: None,
234            operation: "unknown".to_string(),
235        }
236    }
237}
238
239impl From<std::str::Utf8Error> for Error {
240    fn from(source: std::str::Utf8Error) -> Self {
241        Error::Utf8 { source, file: None }
242    }
243}
244
245/// Result type alias for cuenv operations
246pub type Result<T> = std::result::Result<T, Error>;
247
248/// Configuration limits
249pub struct Limits {
250    pub max_path_length: usize,
251    pub max_package_name_length: usize,
252    pub max_output_size: usize,
253}
254
255impl Default for Limits {
256    fn default() -> Self {
257        Self {
258            max_path_length: 4096,
259            max_package_name_length: 256,
260            max_output_size: 100 * 1024 * 1024, // 100MB
261        }
262    }
263}
264
265/// A validated directory path that must exist and be a directory
266///
267/// This newtype wrapper ensures that any instance represents a path that:
268/// - Exists on the filesystem
269/// - Is actually a directory (not a file or symlink to file)
270/// - Can be accessed for metadata reading
271///
272/// # Examples
273///
274/// ```rust
275/// use cuenv_core::PackageDir;
276/// use std::path::Path;
277///
278/// // Try to create from current directory
279/// match PackageDir::try_from(Path::new(".")) {
280///     Ok(dir) => println!("Valid directory: {}", dir.as_path().display()),
281///     Err(e) => eprintln!("Invalid directory: {}", e),
282/// }
283/// ```
284#[derive(Clone, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
285pub struct PackageDir(PathBuf);
286
287impl PackageDir {
288    /// Get the path as a reference
289    #[must_use]
290    pub fn as_path(&self) -> &Path {
291        &self.0
292    }
293
294    /// Convert into the underlying PathBuf
295    #[must_use]
296    pub fn into_path_buf(self) -> PathBuf {
297        self.0
298    }
299}
300
301impl AsRef<Path> for PackageDir {
302    fn as_ref(&self) -> &Path {
303        &self.0
304    }
305}
306
307/// Errors that can occur when validating a PackageDir
308#[derive(Error, Debug, Clone, Diagnostic)]
309pub enum PackageDirError {
310    /// The path does not exist
311    #[error("path does not exist: {0}")]
312    #[diagnostic(
313        code(cuenv::package_dir::not_found),
314        help("Make sure the directory exists and you have permission to access it")
315    )]
316    NotFound(String),
317
318    /// The path exists but is not a directory
319    #[error("path is not a directory: {0}")]
320    #[diagnostic(
321        code(cuenv::package_dir::not_directory),
322        help("The path must point to a directory, not a file")
323    )]
324    NotADirectory(String),
325
326    /// An I/O error occurred while checking the path
327    #[error("io error accessing path: {0}")]
328    #[diagnostic(
329        code(cuenv::package_dir::io_error),
330        help("Check file permissions and ensure you have access to the path")
331    )]
332    Io(String),
333}
334
335impl TryFrom<&Path> for PackageDir {
336    type Error = PackageDirError;
337
338    /// Try to create a PackageDir from a path
339    ///
340    /// # Examples
341    ///
342    /// ```rust
343    /// use cuenv_core::PackageDir;
344    /// use std::path::Path;
345    ///
346    /// match PackageDir::try_from(Path::new(".")) {
347    ///     Ok(dir) => println!("Valid directory"),
348    ///     Err(e) => eprintln!("Error: {}", e),
349    /// }
350    /// ```
351    fn try_from(input: &Path) -> std::result::Result<Self, Self::Error> {
352        match std::fs::metadata(input) {
353            Ok(meta) => {
354                if meta.is_dir() {
355                    Ok(PackageDir(input.to_path_buf()))
356                } else {
357                    Err(PackageDirError::NotADirectory(input.display().to_string()))
358                }
359            }
360            Err(e) => {
361                if e.kind() == std::io::ErrorKind::NotFound {
362                    Err(PackageDirError::NotFound(input.display().to_string()))
363                } else {
364                    Err(PackageDirError::Io(e.to_string()))
365                }
366            }
367        }
368    }
369}
370
371/// A validated CUE package name
372///
373/// Package names must follow CUE naming conventions:
374/// - 1-64 characters in length
375/// - Start with alphanumeric character (A-Z, a-z, 0-9)
376/// - Contain only alphanumeric, hyphen (-), or underscore (_) characters
377///
378/// # Examples
379///
380/// ```rust
381/// use cuenv_core::PackageName;
382///
383/// // Valid package names
384/// assert!(PackageName::try_from("my-package").is_ok());
385/// assert!(PackageName::try_from("package_123").is_ok());
386/// assert!(PackageName::try_from("app").is_ok());
387///
388/// // Invalid package names
389/// assert!(PackageName::try_from("-invalid").is_err());  // starts with hyphen
390/// assert!(PackageName::try_from("invalid.name").is_err());  // contains dot
391/// assert!(PackageName::try_from("").is_err());  // empty
392/// ```
393#[derive(Clone, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
394pub struct PackageName(String);
395
396impl PackageName {
397    /// Get the package name as a string slice
398    #[must_use]
399    pub fn as_str(&self) -> &str {
400        &self.0
401    }
402
403    /// Convert into the underlying String
404    #[must_use]
405    pub fn into_string(self) -> String {
406        self.0
407    }
408}
409
410impl AsRef<str> for PackageName {
411    fn as_ref(&self) -> &str {
412        &self.0
413    }
414}
415
416impl std::fmt::Display for PackageName {
417    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
418        write!(f, "{}", self.0)
419    }
420}
421
422/// Errors that can occur when validating a PackageName
423#[derive(Error, Debug, Clone, Diagnostic)]
424pub enum PackageNameError {
425    /// The package name is invalid
426    #[error("invalid package name: {0}")]
427    #[diagnostic(
428        code(cuenv::package_name::invalid),
429        help(
430            "Package names must be 1-64 characters, start with alphanumeric, and contain only alphanumeric, hyphen, or underscore characters"
431        )
432    )]
433    Invalid(String),
434}
435
436impl TryFrom<&str> for PackageName {
437    type Error = PackageNameError;
438
439    /// Try to create a PackageName from a string
440    ///
441    /// # Examples
442    ///
443    /// ```rust
444    /// use cuenv_core::PackageName;
445    ///
446    /// match PackageName::try_from("my-package") {
447    ///     Ok(name) => println!("Valid package name: {}", name),
448    ///     Err(e) => eprintln!("Error: {}", e),
449    /// }
450    /// ```
451    fn try_from(s: &str) -> std::result::Result<Self, Self::Error> {
452        let bytes = s.as_bytes();
453
454        // Check length bounds
455        if bytes.is_empty() || bytes.len() > 64 {
456            return Err(PackageNameError::Invalid(s.to_string()));
457        }
458
459        // Check first character must be alphanumeric
460        let first = bytes[0];
461        let is_alnum =
462            |b: u8| b.is_ascii_uppercase() || b.is_ascii_lowercase() || b.is_ascii_digit();
463
464        if !is_alnum(first) {
465            return Err(PackageNameError::Invalid(s.to_string()));
466        }
467
468        // Check all characters are valid
469        let valid = |b: u8| is_alnum(b) || b == b'-' || b == b'_';
470        for &b in bytes {
471            if !valid(b) {
472                return Err(PackageNameError::Invalid(s.to_string()));
473            }
474        }
475
476        Ok(PackageName(s.to_string()))
477    }
478}
479
480impl TryFrom<String> for PackageName {
481    type Error = PackageNameError;
482
483    /// Try to create a PackageName from an owned String
484    fn try_from(s: String) -> std::result::Result<Self, Self::Error> {
485        Self::try_from(s.as_str())
486    }
487}
488
489#[cfg(test)]
490mod tests {
491    use super::*;
492    use miette::SourceSpan;
493    use std::path::Path;
494
495    #[test]
496    fn test_error_configuration() {
497        let err = Error::configuration("test message");
498        assert_eq!(err.to_string(), "Configuration error: test message");
499
500        if let Error::Configuration { message, .. } = err {
501            assert_eq!(message, "test message");
502        } else {
503            panic!("Expected Configuration error");
504        }
505    }
506
507    #[test]
508    fn test_error_configuration_with_source() {
509        let src = "test source code";
510        let span = SourceSpan::from(0..4);
511        let err = Error::configuration_with_source("config error", src, Some(span));
512
513        if let Error::Configuration {
514            src: source,
515            span: s,
516            message,
517        } = err
518        {
519            assert_eq!(source, "test source code");
520            assert_eq!(s, Some(SourceSpan::from(0..4)));
521            assert_eq!(message, "config error");
522        } else {
523            panic!("Expected Configuration error");
524        }
525    }
526
527    #[test]
528    fn test_error_ffi() {
529        let err = Error::ffi("test_function", "FFI failed");
530        assert_eq!(
531            err.to_string(),
532            "FFI operation failed in test_function: FFI failed"
533        );
534
535        if let Error::Ffi {
536            function,
537            message,
538            help,
539        } = err
540        {
541            assert_eq!(function, "test_function");
542            assert_eq!(message, "FFI failed");
543            assert!(help.is_none());
544        } else {
545            panic!("Expected Ffi error");
546        }
547    }
548
549    #[test]
550    fn test_error_ffi_with_help() {
551        let err = Error::ffi_with_help("test_func", "error msg", "try this instead");
552
553        if let Error::Ffi {
554            function,
555            message,
556            help,
557        } = err
558        {
559            assert_eq!(function, "test_func");
560            assert_eq!(message, "error msg");
561            assert_eq!(help, Some("try this instead".to_string()));
562        } else {
563            panic!("Expected Ffi error");
564        }
565    }
566
567    #[test]
568    fn test_error_cue_parse() {
569        let path = Path::new("/test/path.cue");
570        let err = Error::cue_parse(path, "parsing failed");
571        assert_eq!(err.to_string(), "CUE parsing failed: parsing failed");
572
573        if let Error::CueParse {
574            path: p, message, ..
575        } = err
576        {
577            assert_eq!(p.as_ref(), Path::new("/test/path.cue"));
578            assert_eq!(message, "parsing failed");
579        } else {
580            panic!("Expected CueParse error");
581        }
582    }
583
584    #[test]
585    fn test_error_cue_parse_with_source() {
586        let path = Path::new("/test/file.cue");
587        let src = "package test";
588        let span = SourceSpan::from(0..7);
589        let suggestions = vec!["Check syntax".to_string(), "Verify imports".to_string()];
590
591        let err = Error::cue_parse_with_source(
592            path,
593            "parse error",
594            src,
595            Some(span),
596            Some(suggestions.clone()),
597        );
598
599        if let Error::CueParse {
600            path: p,
601            src: source,
602            span: s,
603            message,
604            suggestions: sugg,
605        } = err
606        {
607            assert_eq!(p.as_ref(), Path::new("/test/file.cue"));
608            assert_eq!(source, Some("package test".to_string()));
609            assert_eq!(s, Some(SourceSpan::from(0..7)));
610            assert_eq!(message, "parse error");
611            assert_eq!(sugg, Some(suggestions));
612        } else {
613            panic!("Expected CueParse error");
614        }
615    }
616
617    #[test]
618    fn test_error_validation() {
619        let err = Error::validation("validation failed");
620        assert_eq!(err.to_string(), "Validation failed: validation failed");
621
622        if let Error::Validation {
623            message, related, ..
624        } = err
625        {
626            assert_eq!(message, "validation failed");
627            assert!(related.is_empty());
628        } else {
629            panic!("Expected Validation error");
630        }
631    }
632
633    #[test]
634    fn test_error_validation_with_source() {
635        let src = "test validation source";
636        let span = SourceSpan::from(5..15);
637        let err = Error::validation_with_source("validation error", src, Some(span));
638
639        if let Error::Validation {
640            src: source,
641            span: s,
642            message,
643            ..
644        } = err
645        {
646            assert_eq!(source, Some("test validation source".to_string()));
647            assert_eq!(s, Some(SourceSpan::from(5..15)));
648            assert_eq!(message, "validation error");
649        } else {
650            panic!("Expected Validation error");
651        }
652    }
653
654    #[test]
655    fn test_error_timeout() {
656        let err = Error::Timeout { seconds: 30 };
657        assert_eq!(err.to_string(), "Operation timed out after 30 seconds");
658    }
659
660    #[test]
661    fn test_error_from_io_error() {
662        let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found");
663        let err: Error = io_err.into();
664
665        if let Error::Io { operation, .. } = err {
666            assert_eq!(operation, "unknown");
667        } else {
668            panic!("Expected Io error");
669        }
670    }
671
672    #[test]
673    fn test_error_from_utf8_error() {
674        let bytes = vec![0xFF, 0xFE];
675        let utf8_err = std::str::from_utf8(&bytes).unwrap_err();
676        let err: Error = utf8_err.into();
677
678        assert!(matches!(err, Error::Utf8 { .. }));
679    }
680
681    #[test]
682    fn test_limits_default() {
683        let limits = Limits::default();
684        assert_eq!(limits.max_path_length, 4096);
685        assert_eq!(limits.max_package_name_length, 256);
686        assert_eq!(limits.max_output_size, 100 * 1024 * 1024);
687    }
688
689    #[test]
690    fn test_result_type_alias() {
691        let ok_result: Result<i32> = Ok(42);
692        assert!(ok_result.is_ok());
693        if let Ok(value) = ok_result {
694            assert_eq!(value, 42);
695        }
696
697        let err_result: Result<i32> = Err(Error::configuration("test"));
698        assert!(err_result.is_err());
699    }
700
701    #[test]
702    fn test_error_display() {
703        let errors = vec![
704            (Error::configuration("test"), "Configuration error: test"),
705            (
706                Error::ffi("func", "msg"),
707                "FFI operation failed in func: msg",
708            ),
709            (
710                Error::cue_parse(Path::new("/test"), "msg"),
711                "CUE parsing failed: msg",
712            ),
713            (Error::validation("msg"), "Validation failed: msg"),
714            (
715                Error::Timeout { seconds: 10 },
716                "Operation timed out after 10 seconds",
717            ),
718        ];
719
720        for (error, expected) in errors {
721            assert_eq!(error.to_string(), expected);
722        }
723    }
724
725    #[test]
726    fn test_error_diagnostic_codes() {
727        use miette::Diagnostic;
728
729        let config_err = Error::configuration("test");
730        assert_eq!(
731            config_err.code().unwrap().to_string(),
732            "cuenv::config::invalid"
733        );
734
735        let ffi_err = Error::ffi("func", "msg");
736        assert_eq!(ffi_err.code().unwrap().to_string(), "cuenv::ffi::error");
737
738        let cue_err = Error::cue_parse(Path::new("/test"), "msg");
739        assert_eq!(
740            cue_err.code().unwrap().to_string(),
741            "cuenv::cue::parse_error"
742        );
743
744        let validation_err = Error::validation("msg");
745        assert_eq!(
746            validation_err.code().unwrap().to_string(),
747            "cuenv::validation::failed"
748        );
749
750        let timeout_err = Error::Timeout { seconds: 5 };
751        assert_eq!(timeout_err.code().unwrap().to_string(), "cuenv::timeout");
752    }
753
754    #[test]
755    fn test_package_dir_validation() {
756        // Current directory should be valid
757        let result = PackageDir::try_from(Path::new("."));
758        assert!(result.is_ok(), "Current directory should be valid");
759
760        // Get methods should work
761        let pkg_dir = result.unwrap();
762        assert_eq!(pkg_dir.as_path(), Path::new("."));
763        assert_eq!(pkg_dir.as_ref(), Path::new("."));
764        assert_eq!(pkg_dir.into_path_buf(), PathBuf::from("."));
765
766        // Non-existent directory should fail with NotFound
767        let result = PackageDir::try_from(Path::new("/path/does/not/exist"));
768        assert!(result.is_err());
769        match result.unwrap_err() {
770            PackageDirError::NotFound(_) => {} // Expected
771            other => panic!("Expected NotFound error, got: {:?}", other),
772        }
773
774        // Path to a file should fail with NotADirectory
775        // Create a temporary file
776        let temp_path = std::env::temp_dir().join("cuenv_test_file");
777        let file = std::fs::File::create(&temp_path).unwrap();
778        drop(file);
779
780        let result = PackageDir::try_from(temp_path.as_path());
781        assert!(result.is_err());
782        match result.unwrap_err() {
783            PackageDirError::NotADirectory(_) => {} // Expected
784            other => panic!("Expected NotADirectory error, got: {:?}", other),
785        }
786
787        // Clean up
788        std::fs::remove_file(temp_path).ok();
789    }
790
791    #[test]
792    fn test_package_name_validation() {
793        // Valid package names
794        let max_len_string = "a".repeat(64);
795        let valid_names = vec![
796            "my-package",
797            "package_123",
798            "a",        // Single character
799            "A",        // Uppercase
800            "0package", // Starts with number
801            "package-with-hyphens",
802            "package_with_underscores",
803            max_len_string.as_str(), // Max length
804        ];
805
806        for name in valid_names {
807            let result = PackageName::try_from(name);
808            assert!(result.is_ok(), "'{}' should be valid", name);
809
810            // Test the String variant too
811            let result = PackageName::try_from(name.to_string());
812            assert!(result.is_ok(), "'{}' as String should be valid", name);
813
814            // Verify methods work correctly
815            let pkg_name = result.unwrap();
816            assert_eq!(pkg_name.as_str(), name);
817            assert_eq!(pkg_name.as_ref(), name);
818            assert_eq!(pkg_name.to_string(), name);
819            assert_eq!(pkg_name.into_string(), name.to_string());
820        }
821
822        // Invalid package names
823        let too_long_string = "a".repeat(65);
824        let invalid_names = vec![
825            "",                       // Empty
826            "-invalid",               // Starts with hyphen
827            "_invalid",               // Starts with underscore
828            "invalid.name",           // Contains dot
829            "invalid/name",           // Contains slash
830            "invalid:name",           // Contains colon
831            too_long_string.as_str(), // Too long
832            "invalid@name",           // Contains @
833            "invalid#name",           // Contains #
834            "invalid name",           // Contains space
835        ];
836
837        for name in invalid_names {
838            let result = PackageName::try_from(name);
839            assert!(result.is_err(), "'{}' should be invalid", name);
840
841            // Verify error type is correct
842            assert!(matches!(result.unwrap_err(), PackageNameError::Invalid(_)));
843        }
844    }
845}