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