Skip to main content

cuenv_core/tools/
provider.rs

1//! Tool provider trait for extensible tool fetching.
2//!
3//! This module defines the `ToolProvider` trait that allows different sources
4//! (GitHub releases, Nix packages, OCI images) to be registered and used
5//! uniformly for fetching development tools.
6
7use async_trait::async_trait;
8use serde::{Deserialize, Serialize};
9use std::path::PathBuf;
10
11use crate::Result;
12
13/// Platform identifier combining OS and architecture.
14#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
15pub struct Platform {
16    pub os: Os,
17    pub arch: Arch,
18}
19
20impl Platform {
21    /// Create a new platform.
22    #[must_use]
23    pub fn new(os: Os, arch: Arch) -> Self {
24        Self { os, arch }
25    }
26
27    /// Get the current platform.
28    #[must_use]
29    pub fn current() -> Self {
30        Self {
31            os: Os::current(),
32            arch: Arch::current(),
33        }
34    }
35
36    /// Parse from string like "darwin-arm64".
37    pub fn parse(s: &str) -> Option<Self> {
38        let parts: Vec<&str> = s.split('-').collect();
39        if parts.len() != 2 {
40            return None;
41        }
42        Some(Self {
43            os: Os::parse(parts[0])?,
44            arch: Arch::parse(parts[1])?,
45        })
46    }
47}
48
49impl std::fmt::Display for Platform {
50    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
51        write!(f, "{}-{}", self.os, self.arch)
52    }
53}
54
55/// Operating system.
56#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
57#[serde(rename_all = "lowercase")]
58pub enum Os {
59    Darwin,
60    Linux,
61}
62
63impl Os {
64    /// Get the current OS.
65    #[must_use]
66    pub fn current() -> Self {
67        #[cfg(target_os = "macos")]
68        return Self::Darwin;
69        #[cfg(target_os = "linux")]
70        return Self::Linux;
71        #[cfg(not(any(target_os = "macos", target_os = "linux")))]
72        compile_error!("Unsupported OS");
73    }
74
75    /// Parse from string.
76    #[must_use]
77    pub fn parse(s: &str) -> Option<Self> {
78        match s.to_lowercase().as_str() {
79            "darwin" | "macos" => Some(Self::Darwin),
80            "linux" => Some(Self::Linux),
81            _ => None,
82        }
83    }
84}
85
86impl std::fmt::Display for Os {
87    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
88        match self {
89            Self::Darwin => write!(f, "darwin"),
90            Self::Linux => write!(f, "linux"),
91        }
92    }
93}
94
95/// CPU architecture.
96#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
97#[serde(rename_all = "lowercase")]
98pub enum Arch {
99    Arm64,
100    X86_64,
101}
102
103impl Arch {
104    /// Get the current architecture.
105    #[must_use]
106    pub fn current() -> Self {
107        #[cfg(target_arch = "aarch64")]
108        return Self::Arm64;
109        #[cfg(target_arch = "x86_64")]
110        return Self::X86_64;
111        #[cfg(not(any(target_arch = "aarch64", target_arch = "x86_64")))]
112        compile_error!("Unsupported architecture");
113    }
114
115    /// Parse from string.
116    #[must_use]
117    pub fn parse(s: &str) -> Option<Self> {
118        match s.to_lowercase().as_str() {
119            "arm64" | "aarch64" => Some(Self::Arm64),
120            "x86_64" | "amd64" | "x64" => Some(Self::X86_64),
121            _ => None,
122        }
123    }
124}
125
126impl std::fmt::Display for Arch {
127    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
128        match self {
129            Self::Arm64 => write!(f, "arm64"),
130            Self::X86_64 => write!(f, "x86_64"),
131        }
132    }
133}
134
135/// Source-specific resolution data.
136///
137/// This enum contains the provider-specific information needed to fetch a tool.
138#[derive(Debug, Clone, Serialize, Deserialize)]
139#[serde(tag = "type", rename_all = "lowercase")]
140pub enum ToolSource {
141    /// Binary extracted from an OCI container image.
142    Oci { image: String, path: String },
143    /// Asset from a GitHub release.
144    GitHub {
145        repo: String,
146        tag: String,
147        asset: String,
148        #[serde(default, skip_serializing_if = "Vec::is_empty")]
149        extract: Vec<ToolExtract>,
150    },
151    /// Package from a Nix flake.
152    Nix {
153        flake: String,
154        package: String,
155        #[serde(skip_serializing_if = "Option::is_none")]
156        output: Option<String>,
157    },
158    /// Rust toolchain managed by rustup.
159    Rustup {
160        /// Toolchain identifier (e.g., "stable", "1.83.0", "nightly-2024-01-01").
161        toolchain: String,
162        /// Installation profile: minimal, default, complete.
163        #[serde(skip_serializing_if = "Option::is_none")]
164        profile: Option<String>,
165        /// Additional components to install (e.g., "clippy", "rustfmt", "rust-src").
166        #[serde(skip_serializing_if = "Vec::is_empty", default)]
167        components: Vec<String>,
168        /// Additional targets to install (e.g., "x86_64-unknown-linux-gnu").
169        #[serde(skip_serializing_if = "Vec::is_empty", default)]
170        targets: Vec<String>,
171    },
172    /// Asset from an arbitrary HTTP URL.
173    #[serde(rename = "url")]
174    Url {
175        /// Fully-resolved download URL.
176        url: String,
177        /// Typed extraction rules for archive/binary assets.
178        #[serde(default, skip_serializing_if = "Vec::is_empty")]
179        extract: Vec<ToolExtract>,
180    },
181}
182
183/// Typed extract rule for GitHub release assets.
184#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
185#[serde(tag = "kind", rename_all = "lowercase")]
186pub enum ToolExtract {
187    /// Extract to `bin/`.
188    Bin {
189        /// Path within archive/pkg payload.
190        path: String,
191        /// Optional binary rename.
192        #[serde(rename = "as", skip_serializing_if = "Option::is_none")]
193        as_name: Option<String>,
194    },
195    /// Extract to `lib/`.
196    Lib {
197        /// Path within archive/pkg payload.
198        path: String,
199        /// Optional env var for exact path export.
200        #[serde(skip_serializing_if = "Option::is_none")]
201        env: Option<String>,
202    },
203    /// Extract to `include/`.
204    Include {
205        /// Path within archive/pkg payload.
206        path: String,
207    },
208    /// Extract to `lib/pkgconfig/`.
209    PkgConfig {
210        /// Path within archive/pkg payload.
211        path: String,
212    },
213    /// Extract to `files/`.
214    File {
215        /// Path within archive/pkg payload.
216        path: String,
217        /// Optional env var for exact path export.
218        #[serde(skip_serializing_if = "Option::is_none")]
219        env: Option<String>,
220    },
221}
222
223impl ToolSource {
224    /// Get the provider type name.
225    #[must_use]
226    pub fn provider_type(&self) -> &'static str {
227        match self {
228            Self::Oci { .. } => "oci",
229            Self::GitHub { .. } => "github",
230            Self::Nix { .. } => "nix",
231            Self::Rustup { .. } => "rustup",
232            Self::Url { .. } => "url",
233        }
234    }
235}
236
237/// A resolved tool ready to be fetched.
238///
239/// This represents a fully resolved tool specification with all information
240/// needed to download and cache the binary.
241#[derive(Debug, Clone, Serialize, Deserialize)]
242pub struct ResolvedTool {
243    /// Tool name (e.g., "jq", "bun").
244    pub name: String,
245    /// Version string.
246    pub version: String,
247    /// Target platform.
248    pub platform: Platform,
249    /// Source-specific data.
250    pub source: ToolSource,
251}
252
253/// Result of fetching a tool.
254#[derive(Debug)]
255pub struct FetchedTool {
256    /// Tool name.
257    pub name: String,
258    /// Path to the cached binary.
259    pub binary_path: PathBuf,
260    /// SHA256 hash of the binary.
261    pub sha256: String,
262}
263
264/// Options for tool operations.
265#[derive(Debug, Clone, Default)]
266pub struct ToolOptions {
267    /// Custom cache directory.
268    pub cache_dir: Option<PathBuf>,
269    /// Force re-fetch even if cached.
270    pub force_refetch: bool,
271}
272
273impl ToolOptions {
274    /// Create new options with default cache directory.
275    #[must_use]
276    pub fn new() -> Self {
277        Self::default()
278    }
279
280    /// Set the cache directory.
281    #[must_use]
282    pub fn with_cache_dir(mut self, path: PathBuf) -> Self {
283        self.cache_dir = Some(path);
284        self
285    }
286
287    /// Set force refetch.
288    #[must_use]
289    pub fn with_force_refetch(mut self, force: bool) -> Self {
290        self.force_refetch = force;
291        self
292    }
293
294    /// Get the cache directory, defaulting to ~/.cache/cuenv/tools.
295    #[must_use]
296    pub fn cache_dir(&self) -> PathBuf {
297        self.cache_dir.clone().unwrap_or_else(default_cache_dir)
298    }
299}
300
301/// Get the default cache directory for tools.
302#[must_use]
303pub fn default_cache_dir() -> PathBuf {
304    dirs::cache_dir()
305        .unwrap_or_else(|| PathBuf::from(".cache"))
306        .join("cuenv")
307        .join("tools")
308}
309
310/// Request parameters for tool resolution.
311pub struct ToolResolveRequest<'a> {
312    /// Name of the tool (e.g., "jq").
313    pub tool_name: &'a str,
314    /// Version string from the manifest.
315    pub version: &'a str,
316    /// Target platform.
317    pub platform: &'a Platform,
318    /// Provider-specific configuration from CUE.
319    pub config: &'a serde_json::Value,
320    /// Optional authentication token (e.g., GitHub token for rate limiting).
321    pub token: Option<&'a str>,
322}
323
324/// Trait for tool providers (GitHub, OCI, Nix).
325///
326/// Each provider implements this trait to handle resolution and fetching
327/// of tools from a specific source type. Providers are registered with
328/// the `ToolRegistry` and selected based on the source configuration.
329///
330/// # Example
331///
332/// ```ignore
333/// pub struct GitHubToolProvider { /* ... */ }
334///
335/// #[async_trait]
336/// impl ToolProvider for GitHubToolProvider {
337///     fn name(&self) -> &'static str { "github" }
338///     fn description(&self) -> &'static str { "Fetch tools from GitHub releases" }
339///     // ...
340/// }
341/// ```
342#[async_trait]
343pub trait ToolProvider: Send + Sync {
344    /// Provider name (e.g., "github", "nix", "oci").
345    ///
346    /// This should match the `type` field in the CUE schema.
347    fn name(&self) -> &'static str;
348
349    /// Human-readable description for help text.
350    fn description(&self) -> &'static str;
351
352    /// Check if this provider can handle the given source type.
353    fn can_handle(&self, source: &ToolSource) -> bool;
354
355    /// Resolve a tool specification to a fetchable artifact.
356    ///
357    /// This performs version resolution, platform matching, and returns
358    /// the concrete artifact reference (image digest, release URL, etc.)
359    ///
360    /// # Arguments
361    ///
362    /// * `request` - Resolution parameters including tool name, version, platform,
363    ///   provider-specific config, and optional authentication token
364    ///
365    /// # Errors
366    ///
367    /// Returns an error if resolution fails (version not found, etc.)
368    async fn resolve(&self, request: &ToolResolveRequest<'_>) -> Result<ResolvedTool>;
369
370    /// Fetch and cache a resolved tool.
371    ///
372    /// Downloads the artifact, extracts binaries, and returns the local path.
373    /// If the tool is already cached and `force_refetch` is false, returns
374    /// the cached path without re-downloading.
375    ///
376    /// # Arguments
377    ///
378    /// * `resolved` - A previously resolved tool
379    /// * `options` - Fetch options (cache dir, force refetch)
380    ///
381    /// # Errors
382    ///
383    /// Returns an error if fetching or extraction fails.
384    async fn fetch(&self, resolved: &ResolvedTool, options: &ToolOptions) -> Result<FetchedTool>;
385
386    /// Check if a tool is already cached.
387    ///
388    /// Returns true if the tool binary exists in the cache directory.
389    fn is_cached(&self, resolved: &ResolvedTool, options: &ToolOptions) -> bool;
390
391    /// Check if provider prerequisites are available.
392    ///
393    /// Called early during runtime activation to fail fast if required
394    /// dependencies are missing (e.g., Nix CLI not installed).
395    ///
396    /// # Default Implementation
397    ///
398    /// Returns `Ok(())` - most providers only need HTTP access.
399    ///
400    /// # Errors
401    ///
402    /// Returns an error with a helpful message if prerequisites are not met.
403    async fn check_prerequisites(&self) -> Result<()> {
404        Ok(())
405    }
406}
407
408#[cfg(test)]
409mod tests {
410    use super::*;
411
412    #[test]
413    fn test_platform_parse() {
414        let p = Platform::parse("darwin-arm64").unwrap();
415        assert_eq!(p.os, Os::Darwin);
416        assert_eq!(p.arch, Arch::Arm64);
417
418        let p = Platform::parse("linux-x86_64").unwrap();
419        assert_eq!(p.os, Os::Linux);
420        assert_eq!(p.arch, Arch::X86_64);
421
422        assert!(Platform::parse("invalid").is_none());
423    }
424
425    #[test]
426    fn test_platform_parse_edge_cases() {
427        // Too few parts
428        assert!(Platform::parse("darwin").is_none());
429        // Too many parts
430        assert!(Platform::parse("darwin-arm64-extra").is_none());
431        // Empty string
432        assert!(Platform::parse("").is_none());
433        // Invalid OS
434        assert!(Platform::parse("windows-arm64").is_none());
435        // Invalid arch
436        assert!(Platform::parse("darwin-mips").is_none());
437    }
438
439    #[test]
440    fn test_platform_display() {
441        let p = Platform::new(Os::Darwin, Arch::Arm64);
442        assert_eq!(p.to_string(), "darwin-arm64");
443    }
444
445    #[test]
446    fn test_platform_display_all_combinations() {
447        assert_eq!(
448            Platform::new(Os::Darwin, Arch::Arm64).to_string(),
449            "darwin-arm64"
450        );
451        assert_eq!(
452            Platform::new(Os::Darwin, Arch::X86_64).to_string(),
453            "darwin-x86_64"
454        );
455        assert_eq!(
456            Platform::new(Os::Linux, Arch::Arm64).to_string(),
457            "linux-arm64"
458        );
459        assert_eq!(
460            Platform::new(Os::Linux, Arch::X86_64).to_string(),
461            "linux-x86_64"
462        );
463    }
464
465    #[test]
466    fn test_platform_current() {
467        let p = Platform::current();
468        // Should return a valid platform for the current system
469        assert!(matches!(p.os, Os::Darwin | Os::Linux));
470        assert!(matches!(p.arch, Arch::Arm64 | Arch::X86_64));
471    }
472
473    #[test]
474    fn test_os_parse() {
475        assert_eq!(Os::parse("darwin"), Some(Os::Darwin));
476        assert_eq!(Os::parse("macos"), Some(Os::Darwin));
477        assert_eq!(Os::parse("linux"), Some(Os::Linux));
478        assert_eq!(Os::parse("windows"), None);
479    }
480
481    #[test]
482    fn test_os_parse_case_insensitive() {
483        assert_eq!(Os::parse("DARWIN"), Some(Os::Darwin));
484        assert_eq!(Os::parse("Darwin"), Some(Os::Darwin));
485        assert_eq!(Os::parse("LINUX"), Some(Os::Linux));
486        assert_eq!(Os::parse("Linux"), Some(Os::Linux));
487        assert_eq!(Os::parse("MACOS"), Some(Os::Darwin));
488        assert_eq!(Os::parse("MacOS"), Some(Os::Darwin));
489    }
490
491    #[test]
492    fn test_os_display() {
493        assert_eq!(Os::Darwin.to_string(), "darwin");
494        assert_eq!(Os::Linux.to_string(), "linux");
495    }
496
497    #[test]
498    fn test_os_current() {
499        let os = Os::current();
500        // Should return a valid OS for the current system
501        assert!(matches!(os, Os::Darwin | Os::Linux));
502    }
503
504    #[test]
505    fn test_arch_parse() {
506        assert_eq!(Arch::parse("arm64"), Some(Arch::Arm64));
507        assert_eq!(Arch::parse("aarch64"), Some(Arch::Arm64));
508        assert_eq!(Arch::parse("x86_64"), Some(Arch::X86_64));
509        assert_eq!(Arch::parse("amd64"), Some(Arch::X86_64));
510    }
511
512    #[test]
513    fn test_arch_parse_case_insensitive() {
514        assert_eq!(Arch::parse("ARM64"), Some(Arch::Arm64));
515        assert_eq!(Arch::parse("Arm64"), Some(Arch::Arm64));
516        assert_eq!(Arch::parse("AARCH64"), Some(Arch::Arm64));
517        assert_eq!(Arch::parse("X86_64"), Some(Arch::X86_64));
518        assert_eq!(Arch::parse("AMD64"), Some(Arch::X86_64));
519    }
520
521    #[test]
522    fn test_arch_parse_x64_alias() {
523        assert_eq!(Arch::parse("x64"), Some(Arch::X86_64));
524        assert_eq!(Arch::parse("X64"), Some(Arch::X86_64));
525    }
526
527    #[test]
528    fn test_arch_parse_invalid() {
529        assert!(Arch::parse("mips").is_none());
530        assert!(Arch::parse("riscv").is_none());
531        assert!(Arch::parse("").is_none());
532    }
533
534    #[test]
535    fn test_arch_display() {
536        assert_eq!(Arch::Arm64.to_string(), "arm64");
537        assert_eq!(Arch::X86_64.to_string(), "x86_64");
538    }
539
540    #[test]
541    fn test_arch_current() {
542        let arch = Arch::current();
543        // Should return a valid arch for the current system
544        assert!(matches!(arch, Arch::Arm64 | Arch::X86_64));
545    }
546
547    #[test]
548    fn test_tool_source_provider_type() {
549        let s = ToolSource::GitHub {
550            repo: "jqlang/jq".into(),
551            tag: "jq-1.7.1".into(),
552            asset: "jq-macos-arm64".into(),
553            extract: vec![],
554        };
555        assert_eq!(s.provider_type(), "github");
556
557        let s = ToolSource::Nix {
558            flake: "nixpkgs".into(),
559            package: "jq".into(),
560            output: None,
561        };
562        assert_eq!(s.provider_type(), "nix");
563
564        let s = ToolSource::Rustup {
565            toolchain: "1.83.0".into(),
566            profile: Some("default".into()),
567            components: vec!["clippy".into(), "rustfmt".into()],
568            targets: vec!["x86_64-unknown-linux-gnu".into()],
569        };
570        assert_eq!(s.provider_type(), "rustup");
571
572        let s = ToolSource::Url {
573            url: "https://example.com/tool-1.0.0.tar.gz".into(),
574            extract: vec![],
575        };
576        assert_eq!(s.provider_type(), "url");
577    }
578
579    #[test]
580    fn test_tool_source_oci_provider_type() {
581        let s = ToolSource::Oci {
582            image: "docker.io/library/alpine:latest".into(),
583            path: "/usr/bin/jq".into(),
584        };
585        assert_eq!(s.provider_type(), "oci");
586    }
587
588    #[test]
589    fn test_tool_source_serialization() {
590        let source = ToolSource::GitHub {
591            repo: "jqlang/jq".into(),
592            tag: "jq-1.7.1".into(),
593            asset: "jq-macos-arm64".into(),
594            extract: vec![ToolExtract::Bin {
595                path: "jq-macos-arm64/jq".into(),
596                as_name: None,
597            }],
598        };
599        let json = serde_json::to_string(&source).unwrap();
600        assert!(json.contains("\"type\":\"github\""));
601        assert!(json.contains("\"repo\":\"jqlang/jq\""));
602        assert!(json.contains("\"kind\":\"bin\""));
603        assert!(json.contains("\"path\":\"jq-macos-arm64/jq\""));
604    }
605
606    #[test]
607    fn test_tool_source_deserialization() {
608        let json =
609            r#"{"type":"github","repo":"jqlang/jq","tag":"jq-1.7.1","asset":"jq-macos-arm64"}"#;
610        let source: ToolSource = serde_json::from_str(json).unwrap();
611        match source {
612            ToolSource::GitHub {
613                repo, tag, asset, ..
614            } => {
615                assert_eq!(repo, "jqlang/jq");
616                assert_eq!(tag, "jq-1.7.1");
617                assert_eq!(asset, "jq-macos-arm64");
618            }
619            _ => panic!("Expected GitHub source"),
620        }
621    }
622
623    #[test]
624    fn test_tool_source_nix_serialization() {
625        let source = ToolSource::Nix {
626            flake: "nixpkgs".into(),
627            package: "jq".into(),
628            output: Some("bin".into()),
629        };
630        let json = serde_json::to_string(&source).unwrap();
631        assert!(json.contains("\"type\":\"nix\""));
632        assert!(json.contains("\"output\":\"bin\""));
633    }
634
635    #[test]
636    fn test_tool_source_rustup_serialization() {
637        let source = ToolSource::Rustup {
638            toolchain: "stable".into(),
639            profile: None,
640            components: vec![],
641            targets: vec![],
642        };
643        let json = serde_json::to_string(&source).unwrap();
644        assert!(json.contains("\"type\":\"rustup\""));
645        // Empty vecs should not be serialized
646        assert!(!json.contains("components"));
647        assert!(!json.contains("targets"));
648    }
649
650    #[test]
651    fn test_tool_source_url_serialization() {
652        let source = ToolSource::Url {
653            url: "https://example.com/tool-1.0.0.tar.gz".into(),
654            extract: vec![ToolExtract::Bin {
655                path: "tool".into(),
656                as_name: None,
657            }],
658        };
659        let json = serde_json::to_string(&source).unwrap();
660        assert!(json.contains("\"type\":\"url\""));
661        assert!(json.contains("\"url\":\"https://example.com/tool-1.0.0.tar.gz\""));
662        assert!(json.contains("\"kind\":\"bin\""));
663    }
664
665    #[test]
666    fn test_tool_source_url_deserialization() {
667        let json = r#"{"type":"url","url":"https://example.com/tool-1.0.0.tar.gz"}"#;
668        let source: ToolSource = serde_json::from_str(json).unwrap();
669        match source {
670            ToolSource::Url { url, extract } => {
671                assert_eq!(url, "https://example.com/tool-1.0.0.tar.gz");
672                assert!(extract.is_empty());
673            }
674            _ => panic!("Expected URL source"),
675        }
676    }
677
678    #[test]
679    fn test_resolved_tool_serialization() {
680        let tool = ResolvedTool {
681            name: "jq".into(),
682            version: "1.7.1".into(),
683            platform: Platform::new(Os::Darwin, Arch::Arm64),
684            source: ToolSource::GitHub {
685                repo: "jqlang/jq".into(),
686                tag: "jq-1.7.1".into(),
687                asset: "jq-macos-arm64".into(),
688                extract: vec![],
689            },
690        };
691        let json = serde_json::to_string(&tool).unwrap();
692        assert!(json.contains("\"name\":\"jq\""));
693        assert!(json.contains("\"version\":\"1.7.1\""));
694    }
695
696    #[test]
697    fn test_tool_options_default() {
698        let opts = ToolOptions::default();
699        assert!(opts.cache_dir.is_none());
700        assert!(!opts.force_refetch);
701    }
702
703    #[test]
704    fn test_tool_options_new() {
705        let opts = ToolOptions::new();
706        assert!(opts.cache_dir.is_none());
707        assert!(!opts.force_refetch);
708    }
709
710    #[test]
711    fn test_tool_options_builder() {
712        let opts = ToolOptions::new()
713            .with_cache_dir(PathBuf::from("/custom/cache"))
714            .with_force_refetch(true);
715
716        assert_eq!(opts.cache_dir, Some(PathBuf::from("/custom/cache")));
717        assert!(opts.force_refetch);
718    }
719
720    #[test]
721    fn test_tool_options_cache_dir_default() {
722        let opts = ToolOptions::new();
723        let cache_dir = opts.cache_dir();
724        // Should end with cuenv/tools
725        assert!(cache_dir.ends_with("cuenv/tools"));
726    }
727
728    #[test]
729    fn test_tool_options_cache_dir_custom() {
730        let opts = ToolOptions::new().with_cache_dir(PathBuf::from("/my/cache"));
731        assert_eq!(opts.cache_dir(), PathBuf::from("/my/cache"));
732    }
733
734    #[test]
735    fn test_default_cache_dir() {
736        let cache_dir = default_cache_dir();
737        // Should end with cuenv/tools
738        assert!(cache_dir.ends_with("cuenv/tools"));
739    }
740
741    #[test]
742    fn test_platform_equality() {
743        let p1 = Platform::new(Os::Darwin, Arch::Arm64);
744        let p2 = Platform::new(Os::Darwin, Arch::Arm64);
745        let p3 = Platform::new(Os::Linux, Arch::Arm64);
746
747        assert_eq!(p1, p2);
748        assert_ne!(p1, p3);
749    }
750
751    #[test]
752    fn test_platform_hash() {
753        use std::collections::HashSet;
754
755        let mut set = HashSet::new();
756        set.insert(Platform::new(Os::Darwin, Arch::Arm64));
757        set.insert(Platform::new(Os::Darwin, Arch::Arm64)); // Duplicate
758
759        assert_eq!(set.len(), 1);
760
761        set.insert(Platform::new(Os::Linux, Arch::Arm64));
762        assert_eq!(set.len(), 2);
763    }
764
765    #[test]
766    fn test_os_equality() {
767        assert_eq!(Os::Darwin, Os::Darwin);
768        assert_eq!(Os::Linux, Os::Linux);
769        assert_ne!(Os::Darwin, Os::Linux);
770    }
771
772    #[test]
773    fn test_arch_equality() {
774        assert_eq!(Arch::Arm64, Arch::Arm64);
775        assert_eq!(Arch::X86_64, Arch::X86_64);
776        assert_ne!(Arch::Arm64, Arch::X86_64);
777    }
778}