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(skip_serializing_if = "Option::is_none")]
149        path: Option<String>,
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}
173
174impl ToolSource {
175    /// Get the provider type name.
176    #[must_use]
177    pub fn provider_type(&self) -> &'static str {
178        match self {
179            Self::Oci { .. } => "oci",
180            Self::GitHub { .. } => "github",
181            Self::Nix { .. } => "nix",
182            Self::Rustup { .. } => "rustup",
183        }
184    }
185}
186
187/// A resolved tool ready to be fetched.
188///
189/// This represents a fully resolved tool specification with all information
190/// needed to download and cache the binary.
191#[derive(Debug, Clone, Serialize, Deserialize)]
192pub struct ResolvedTool {
193    /// Tool name (e.g., "jq", "bun").
194    pub name: String,
195    /// Version string.
196    pub version: String,
197    /// Target platform.
198    pub platform: Platform,
199    /// Source-specific data.
200    pub source: ToolSource,
201}
202
203/// Result of fetching a tool.
204#[derive(Debug)]
205pub struct FetchedTool {
206    /// Tool name.
207    pub name: String,
208    /// Path to the cached binary.
209    pub binary_path: PathBuf,
210    /// SHA256 hash of the binary.
211    pub sha256: String,
212}
213
214/// Options for tool operations.
215#[derive(Debug, Clone, Default)]
216pub struct ToolOptions {
217    /// Custom cache directory.
218    pub cache_dir: Option<PathBuf>,
219    /// Force re-fetch even if cached.
220    pub force_refetch: bool,
221}
222
223impl ToolOptions {
224    /// Create new options with default cache directory.
225    #[must_use]
226    pub fn new() -> Self {
227        Self::default()
228    }
229
230    /// Set the cache directory.
231    #[must_use]
232    pub fn with_cache_dir(mut self, path: PathBuf) -> Self {
233        self.cache_dir = Some(path);
234        self
235    }
236
237    /// Set force refetch.
238    #[must_use]
239    pub fn with_force_refetch(mut self, force: bool) -> Self {
240        self.force_refetch = force;
241        self
242    }
243
244    /// Get the cache directory, defaulting to ~/.cache/cuenv/tools.
245    #[must_use]
246    pub fn cache_dir(&self) -> PathBuf {
247        self.cache_dir.clone().unwrap_or_else(default_cache_dir)
248    }
249}
250
251/// Get the default cache directory for tools.
252#[must_use]
253pub fn default_cache_dir() -> PathBuf {
254    dirs::cache_dir()
255        .unwrap_or_else(|| PathBuf::from(".cache"))
256        .join("cuenv")
257        .join("tools")
258}
259
260/// Trait for tool providers (GitHub, OCI, Nix).
261///
262/// Each provider implements this trait to handle resolution and fetching
263/// of tools from a specific source type. Providers are registered with
264/// the `ToolRegistry` and selected based on the source configuration.
265///
266/// # Example
267///
268/// ```ignore
269/// pub struct GitHubToolProvider { /* ... */ }
270///
271/// #[async_trait]
272/// impl ToolProvider for GitHubToolProvider {
273///     fn name(&self) -> &'static str { "github" }
274///     fn description(&self) -> &'static str { "Fetch tools from GitHub releases" }
275///     // ...
276/// }
277/// ```
278#[async_trait]
279pub trait ToolProvider: Send + Sync {
280    /// Provider name (e.g., "github", "nix", "oci").
281    ///
282    /// This should match the `type` field in the CUE schema.
283    fn name(&self) -> &'static str;
284
285    /// Human-readable description for help text.
286    fn description(&self) -> &'static str;
287
288    /// Check if this provider can handle the given source type.
289    fn can_handle(&self, source: &ToolSource) -> bool;
290
291    /// Resolve a tool specification to a fetchable artifact.
292    ///
293    /// This performs version resolution, platform matching, and returns
294    /// the concrete artifact reference (image digest, release URL, etc.)
295    ///
296    /// # Arguments
297    ///
298    /// * `tool_name` - Name of the tool (e.g., "jq")
299    /// * `version` - Version string from the manifest
300    /// * `platform` - Target platform
301    /// * `config` - Provider-specific configuration from CUE
302    ///
303    /// # Errors
304    ///
305    /// Returns an error if resolution fails (version not found, etc.)
306    async fn resolve(
307        &self,
308        tool_name: &str,
309        version: &str,
310        platform: &Platform,
311        config: &serde_json::Value,
312    ) -> Result<ResolvedTool>;
313
314    /// Resolve a tool with optional runtime token.
315    ///
316    /// This variant accepts a runtime-level authentication token that can be
317    /// used by providers that require authentication (e.g., GitHub for rate limiting).
318    ///
319    /// # Default Implementation
320    ///
321    /// Ignores the token and calls `resolve()`. Providers that support tokens
322    /// should override this method.
323    ///
324    /// # Arguments
325    ///
326    /// * `tool_name` - Name of the tool (e.g., "jq")
327    /// * `version` - Version string from the manifest
328    /// * `platform` - Target platform
329    /// * `config` - Provider-specific configuration from CUE
330    /// * `token` - Optional authentication token from runtime config
331    #[allow(clippy::too_many_arguments)]
332    async fn resolve_with_token(
333        &self,
334        tool_name: &str,
335        version: &str,
336        platform: &Platform,
337        config: &serde_json::Value,
338        _token: Option<&str>,
339    ) -> Result<ResolvedTool> {
340        self.resolve(tool_name, version, platform, config).await
341    }
342
343    /// Fetch and cache a resolved tool.
344    ///
345    /// Downloads the artifact, extracts binaries, and returns the local path.
346    /// If the tool is already cached and `force_refetch` is false, returns
347    /// the cached path without re-downloading.
348    ///
349    /// # Arguments
350    ///
351    /// * `resolved` - A previously resolved tool
352    /// * `options` - Fetch options (cache dir, force refetch)
353    ///
354    /// # Errors
355    ///
356    /// Returns an error if fetching or extraction fails.
357    async fn fetch(&self, resolved: &ResolvedTool, options: &ToolOptions) -> Result<FetchedTool>;
358
359    /// Check if a tool is already cached.
360    ///
361    /// Returns true if the tool binary exists in the cache directory.
362    fn is_cached(&self, resolved: &ResolvedTool, options: &ToolOptions) -> bool;
363
364    /// Check if provider prerequisites are available.
365    ///
366    /// Called early during runtime activation to fail fast if required
367    /// dependencies are missing (e.g., Nix CLI not installed).
368    ///
369    /// # Default Implementation
370    ///
371    /// Returns `Ok(())` - most providers only need HTTP access.
372    ///
373    /// # Errors
374    ///
375    /// Returns an error with a helpful message if prerequisites are not met.
376    async fn check_prerequisites(&self) -> Result<()> {
377        Ok(())
378    }
379}
380
381#[cfg(test)]
382mod tests {
383    use super::*;
384
385    #[test]
386    fn test_platform_parse() {
387        let p = Platform::parse("darwin-arm64").unwrap();
388        assert_eq!(p.os, Os::Darwin);
389        assert_eq!(p.arch, Arch::Arm64);
390
391        let p = Platform::parse("linux-x86_64").unwrap();
392        assert_eq!(p.os, Os::Linux);
393        assert_eq!(p.arch, Arch::X86_64);
394
395        assert!(Platform::parse("invalid").is_none());
396    }
397
398    #[test]
399    fn test_platform_parse_edge_cases() {
400        // Too few parts
401        assert!(Platform::parse("darwin").is_none());
402        // Too many parts
403        assert!(Platform::parse("darwin-arm64-extra").is_none());
404        // Empty string
405        assert!(Platform::parse("").is_none());
406        // Invalid OS
407        assert!(Platform::parse("windows-arm64").is_none());
408        // Invalid arch
409        assert!(Platform::parse("darwin-mips").is_none());
410    }
411
412    #[test]
413    fn test_platform_display() {
414        let p = Platform::new(Os::Darwin, Arch::Arm64);
415        assert_eq!(p.to_string(), "darwin-arm64");
416    }
417
418    #[test]
419    fn test_platform_display_all_combinations() {
420        assert_eq!(
421            Platform::new(Os::Darwin, Arch::Arm64).to_string(),
422            "darwin-arm64"
423        );
424        assert_eq!(
425            Platform::new(Os::Darwin, Arch::X86_64).to_string(),
426            "darwin-x86_64"
427        );
428        assert_eq!(
429            Platform::new(Os::Linux, Arch::Arm64).to_string(),
430            "linux-arm64"
431        );
432        assert_eq!(
433            Platform::new(Os::Linux, Arch::X86_64).to_string(),
434            "linux-x86_64"
435        );
436    }
437
438    #[test]
439    fn test_platform_current() {
440        let p = Platform::current();
441        // Should return a valid platform for the current system
442        assert!(matches!(p.os, Os::Darwin | Os::Linux));
443        assert!(matches!(p.arch, Arch::Arm64 | Arch::X86_64));
444    }
445
446    #[test]
447    fn test_os_parse() {
448        assert_eq!(Os::parse("darwin"), Some(Os::Darwin));
449        assert_eq!(Os::parse("macos"), Some(Os::Darwin));
450        assert_eq!(Os::parse("linux"), Some(Os::Linux));
451        assert_eq!(Os::parse("windows"), None);
452    }
453
454    #[test]
455    fn test_os_parse_case_insensitive() {
456        assert_eq!(Os::parse("DARWIN"), Some(Os::Darwin));
457        assert_eq!(Os::parse("Darwin"), Some(Os::Darwin));
458        assert_eq!(Os::parse("LINUX"), Some(Os::Linux));
459        assert_eq!(Os::parse("Linux"), Some(Os::Linux));
460        assert_eq!(Os::parse("MACOS"), Some(Os::Darwin));
461        assert_eq!(Os::parse("MacOS"), Some(Os::Darwin));
462    }
463
464    #[test]
465    fn test_os_display() {
466        assert_eq!(Os::Darwin.to_string(), "darwin");
467        assert_eq!(Os::Linux.to_string(), "linux");
468    }
469
470    #[test]
471    fn test_os_current() {
472        let os = Os::current();
473        // Should return a valid OS for the current system
474        assert!(matches!(os, Os::Darwin | Os::Linux));
475    }
476
477    #[test]
478    fn test_arch_parse() {
479        assert_eq!(Arch::parse("arm64"), Some(Arch::Arm64));
480        assert_eq!(Arch::parse("aarch64"), Some(Arch::Arm64));
481        assert_eq!(Arch::parse("x86_64"), Some(Arch::X86_64));
482        assert_eq!(Arch::parse("amd64"), Some(Arch::X86_64));
483    }
484
485    #[test]
486    fn test_arch_parse_case_insensitive() {
487        assert_eq!(Arch::parse("ARM64"), Some(Arch::Arm64));
488        assert_eq!(Arch::parse("Arm64"), Some(Arch::Arm64));
489        assert_eq!(Arch::parse("AARCH64"), Some(Arch::Arm64));
490        assert_eq!(Arch::parse("X86_64"), Some(Arch::X86_64));
491        assert_eq!(Arch::parse("AMD64"), Some(Arch::X86_64));
492    }
493
494    #[test]
495    fn test_arch_parse_x64_alias() {
496        assert_eq!(Arch::parse("x64"), Some(Arch::X86_64));
497        assert_eq!(Arch::parse("X64"), Some(Arch::X86_64));
498    }
499
500    #[test]
501    fn test_arch_parse_invalid() {
502        assert!(Arch::parse("mips").is_none());
503        assert!(Arch::parse("riscv").is_none());
504        assert!(Arch::parse("").is_none());
505    }
506
507    #[test]
508    fn test_arch_display() {
509        assert_eq!(Arch::Arm64.to_string(), "arm64");
510        assert_eq!(Arch::X86_64.to_string(), "x86_64");
511    }
512
513    #[test]
514    fn test_arch_current() {
515        let arch = Arch::current();
516        // Should return a valid arch for the current system
517        assert!(matches!(arch, Arch::Arm64 | Arch::X86_64));
518    }
519
520    #[test]
521    fn test_tool_source_provider_type() {
522        let s = ToolSource::GitHub {
523            repo: "jqlang/jq".into(),
524            tag: "jq-1.7.1".into(),
525            asset: "jq-macos-arm64".into(),
526            path: None,
527        };
528        assert_eq!(s.provider_type(), "github");
529
530        let s = ToolSource::Nix {
531            flake: "nixpkgs".into(),
532            package: "jq".into(),
533            output: None,
534        };
535        assert_eq!(s.provider_type(), "nix");
536
537        let s = ToolSource::Rustup {
538            toolchain: "1.83.0".into(),
539            profile: Some("default".into()),
540            components: vec!["clippy".into(), "rustfmt".into()],
541            targets: vec!["x86_64-unknown-linux-gnu".into()],
542        };
543        assert_eq!(s.provider_type(), "rustup");
544    }
545
546    #[test]
547    fn test_tool_source_oci_provider_type() {
548        let s = ToolSource::Oci {
549            image: "docker.io/library/alpine:latest".into(),
550            path: "/usr/bin/jq".into(),
551        };
552        assert_eq!(s.provider_type(), "oci");
553    }
554
555    #[test]
556    fn test_tool_source_serialization() {
557        let source = ToolSource::GitHub {
558            repo: "jqlang/jq".into(),
559            tag: "jq-1.7.1".into(),
560            asset: "jq-macos-arm64".into(),
561            path: Some("jq-macos-arm64/jq".into()),
562        };
563        let json = serde_json::to_string(&source).unwrap();
564        assert!(json.contains("\"type\":\"github\""));
565        assert!(json.contains("\"repo\":\"jqlang/jq\""));
566        assert!(json.contains("\"path\":\"jq-macos-arm64/jq\""));
567    }
568
569    #[test]
570    fn test_tool_source_deserialization() {
571        let json =
572            r#"{"type":"github","repo":"jqlang/jq","tag":"jq-1.7.1","asset":"jq-macos-arm64"}"#;
573        let source: ToolSource = serde_json::from_str(json).unwrap();
574        match source {
575            ToolSource::GitHub {
576                repo, tag, asset, ..
577            } => {
578                assert_eq!(repo, "jqlang/jq");
579                assert_eq!(tag, "jq-1.7.1");
580                assert_eq!(asset, "jq-macos-arm64");
581            }
582            _ => panic!("Expected GitHub source"),
583        }
584    }
585
586    #[test]
587    fn test_tool_source_nix_serialization() {
588        let source = ToolSource::Nix {
589            flake: "nixpkgs".into(),
590            package: "jq".into(),
591            output: Some("bin".into()),
592        };
593        let json = serde_json::to_string(&source).unwrap();
594        assert!(json.contains("\"type\":\"nix\""));
595        assert!(json.contains("\"output\":\"bin\""));
596    }
597
598    #[test]
599    fn test_tool_source_rustup_serialization() {
600        let source = ToolSource::Rustup {
601            toolchain: "stable".into(),
602            profile: None,
603            components: vec![],
604            targets: vec![],
605        };
606        let json = serde_json::to_string(&source).unwrap();
607        assert!(json.contains("\"type\":\"rustup\""));
608        // Empty vecs should not be serialized
609        assert!(!json.contains("components"));
610        assert!(!json.contains("targets"));
611    }
612
613    #[test]
614    fn test_resolved_tool_serialization() {
615        let tool = ResolvedTool {
616            name: "jq".into(),
617            version: "1.7.1".into(),
618            platform: Platform::new(Os::Darwin, Arch::Arm64),
619            source: ToolSource::GitHub {
620                repo: "jqlang/jq".into(),
621                tag: "jq-1.7.1".into(),
622                asset: "jq-macos-arm64".into(),
623                path: None,
624            },
625        };
626        let json = serde_json::to_string(&tool).unwrap();
627        assert!(json.contains("\"name\":\"jq\""));
628        assert!(json.contains("\"version\":\"1.7.1\""));
629    }
630
631    #[test]
632    fn test_tool_options_default() {
633        let opts = ToolOptions::default();
634        assert!(opts.cache_dir.is_none());
635        assert!(!opts.force_refetch);
636    }
637
638    #[test]
639    fn test_tool_options_new() {
640        let opts = ToolOptions::new();
641        assert!(opts.cache_dir.is_none());
642        assert!(!opts.force_refetch);
643    }
644
645    #[test]
646    fn test_tool_options_builder() {
647        let opts = ToolOptions::new()
648            .with_cache_dir(PathBuf::from("/custom/cache"))
649            .with_force_refetch(true);
650
651        assert_eq!(opts.cache_dir, Some(PathBuf::from("/custom/cache")));
652        assert!(opts.force_refetch);
653    }
654
655    #[test]
656    fn test_tool_options_cache_dir_default() {
657        let opts = ToolOptions::new();
658        let cache_dir = opts.cache_dir();
659        // Should end with cuenv/tools
660        assert!(cache_dir.ends_with("cuenv/tools"));
661    }
662
663    #[test]
664    fn test_tool_options_cache_dir_custom() {
665        let opts = ToolOptions::new().with_cache_dir(PathBuf::from("/my/cache"));
666        assert_eq!(opts.cache_dir(), PathBuf::from("/my/cache"));
667    }
668
669    #[test]
670    fn test_default_cache_dir() {
671        let cache_dir = default_cache_dir();
672        // Should end with cuenv/tools
673        assert!(cache_dir.ends_with("cuenv/tools"));
674    }
675
676    #[test]
677    fn test_platform_equality() {
678        let p1 = Platform::new(Os::Darwin, Arch::Arm64);
679        let p2 = Platform::new(Os::Darwin, Arch::Arm64);
680        let p3 = Platform::new(Os::Linux, Arch::Arm64);
681
682        assert_eq!(p1, p2);
683        assert_ne!(p1, p3);
684    }
685
686    #[test]
687    fn test_platform_hash() {
688        use std::collections::HashSet;
689
690        let mut set = HashSet::new();
691        set.insert(Platform::new(Os::Darwin, Arch::Arm64));
692        set.insert(Platform::new(Os::Darwin, Arch::Arm64)); // Duplicate
693
694        assert_eq!(set.len(), 1);
695
696        set.insert(Platform::new(Os::Linux, Arch::Arm64));
697        assert_eq!(set.len(), 2);
698    }
699
700    #[test]
701    fn test_os_equality() {
702        assert_eq!(Os::Darwin, Os::Darwin);
703        assert_eq!(Os::Linux, Os::Linux);
704        assert_ne!(Os::Darwin, Os::Linux);
705    }
706
707    #[test]
708    fn test_arch_equality() {
709        assert_eq!(Arch::Arm64, Arch::Arm64);
710        assert_eq!(Arch::X86_64, Arch::X86_64);
711        assert_ne!(Arch::Arm64, Arch::X86_64);
712    }
713}