Skip to main content

polykit_core/remote_cache/
mod.rs

1//! Remote caching system for shared cache across CI/CD and team members.
2
3mod artifact;
4mod backend;
5mod cache_key;
6mod config;
7mod filesystem;
8mod http;
9mod integrity;
10
11pub use artifact::Artifact;
12pub use backend::{BackendError, RemoteCacheBackend};
13pub use cache_key::{detect_toolchain_version, CacheKey, CacheKeyBuilder};
14pub use config::RemoteCacheConfig;
15pub use filesystem::FilesystemBackend;
16pub use http::HttpBackend;
17pub use integrity::ArtifactVerifier;
18
19use crate::error::Result;
20use crate::graph::DependencyGraph;
21use crate::package::Package;
22
23/// Remote cache orchestrator.
24///
25/// Handles cache operations and integrates with task execution.
26pub struct RemoteCache {
27    backend: Box<dyn RemoteCacheBackend>,
28    config: RemoteCacheConfig,
29}
30
31impl RemoteCache {
32    /// Creates a new remote cache with the given backend and configuration.
33    pub fn new(backend: Box<dyn RemoteCacheBackend>, config: RemoteCacheConfig) -> Self {
34        Self { backend, config }
35    }
36
37    /// Creates a remote cache from configuration.
38    ///
39    /// Automatically selects the appropriate backend based on the URL.
40    ///
41    /// # Errors
42    ///
43    /// Returns an error if backend creation fails.
44    pub fn from_config(config: RemoteCacheConfig) -> Result<Self> {
45        let backend: Box<dyn RemoteCacheBackend> = if config.is_http() {
46            Box::new(HttpBackend::new(&config)?)
47        } else {
48            Box::new(FilesystemBackend::new(&config.url)?)
49        };
50
51        Ok(Self::new(backend, config))
52    }
53
54    /// Creates a disabled remote cache (no-op).
55    pub fn disabled() -> Self {
56        Self {
57            backend: Box::new(DisabledBackend),
58            config: RemoteCacheConfig::default(),
59        }
60    }
61
62    /// Checks if remote cache is enabled.
63    pub fn is_enabled(&self) -> bool {
64        !self.config.url.is_empty()
65    }
66
67    /// Fetches an artifact from the remote cache.
68    ///
69    /// # Arguments
70    ///
71    /// * `key` - The cache key to fetch
72    ///
73    /// # Returns
74    ///
75    /// Returns `Some(artifact)` if found, `None` if not found.
76    ///
77    /// # Errors
78    ///
79    /// Returns an error only for unexpected failures. Cache misses return `Ok(None)`.
80    pub async fn fetch_artifact(&self, key: &CacheKey) -> Result<Option<Artifact>> {
81        if !self.is_enabled() {
82            return Ok(None);
83        }
84
85        self.backend.fetch_artifact(key).await
86    }
87
88    /// Uploads an artifact to the remote cache.
89    ///
90    /// # Arguments
91    ///
92    /// * `key` - The cache key for this artifact
93    /// * `artifact` - The artifact to upload
94    ///
95    /// # Errors
96    ///
97    /// Returns an error if upload fails. Errors are non-fatal.
98    pub async fn upload_artifact(&self, key: &CacheKey, artifact: &Artifact) -> Result<()> {
99        if !self.is_enabled() || self.config.read_only {
100            return Ok(());
101        }
102
103        self.backend.upload_artifact(key, artifact).await
104    }
105
106    /// Checks if an artifact exists in the remote cache.
107    ///
108    /// # Arguments
109    ///
110    /// * `key` - The cache key to check
111    ///
112    /// # Returns
113    ///
114    /// Returns `true` if the artifact exists, `false` otherwise.
115    ///
116    /// # Errors
117    ///
118    /// Returns an error only for unexpected failures.
119    pub async fn has_artifact(&self, key: &CacheKey) -> Result<bool> {
120        if !self.is_enabled() {
121            return Ok(false);
122        }
123
124        self.backend.has_artifact(key).await
125    }
126
127    /// Builds a cache key for a task execution.
128    ///
129    /// # Arguments
130    ///
131    /// * `package` - The package being executed
132    /// * `task_name` - The task name
133    /// * `command` - The command string
134    /// * `graph` - The dependency graph
135    /// * `package_path` - Path to the package directory
136    ///
137    /// # Errors
138    ///
139    /// Returns an error if cache key construction fails.
140    pub async fn build_cache_key(
141        &self,
142        package: &Package,
143        task_name: &str,
144        command: &str,
145        graph: &DependencyGraph,
146        package_path: &std::path::Path,
147    ) -> Result<CacheKey> {
148        use std::collections::BTreeMap;
149        use std::env;
150        use std::fs;
151        use sha2::{Digest, Sha256};
152        use walkdir::WalkDir;
153
154        // Build dependency graph hash
155        let deps = graph.dependencies(&package.name).unwrap_or_default();
156        let mut dep_hash_input = format!("{}:{}", package.name, task_name);
157        for dep in &deps {
158            dep_hash_input.push_str(&format!(":{}", dep));
159        }
160        let mut dep_hasher = Sha256::new();
161        dep_hasher.update(dep_hash_input.as_bytes());
162        let dependency_graph_hash = format!("{:x}", dep_hasher.finalize());
163
164        // Collect environment variables (only from allowlist)
165        let mut env_vars = BTreeMap::new();
166        for var_name in &self.config.env_vars {
167            if let Ok(value) = env::var(var_name) {
168                env_vars.insert(var_name.clone(), value);
169            }
170        }
171
172        // Collect input file hashes
173        let mut input_file_hashes = rustc_hash::FxHashMap::default();
174        if !self.config.input_files.is_empty() {
175            for pattern in &self.config.input_files {
176                // Simple glob matching (can be enhanced)
177                let pattern_path = package_path.join(pattern);
178                if pattern_path.exists() {
179                    if pattern_path.is_file() {
180                        if let Ok(content) = fs::read(&pattern_path) {
181                            let mut hasher = Sha256::new();
182                            hasher.update(&content);
183                            let hash = format!("{:x}", hasher.finalize());
184                            input_file_hashes.insert(
185                                pattern_path
186                                    .strip_prefix(package_path)
187                                    .unwrap_or(&pattern_path)
188                                    .to_path_buf(),
189                                hash,
190                            );
191                        }
192                    } else if pattern_path.is_dir() {
193                        // Walk directory
194                        for entry in WalkDir::new(&pattern_path).into_iter().flatten() {
195                            if entry.file_type().is_file() {
196                                if let Ok(content) = fs::read(entry.path()) {
197                                    let mut hasher = Sha256::new();
198                                    hasher.update(&content);
199                                    let hash = format!("{:x}", hasher.finalize());
200                                    if let Ok(relative) = entry.path().strip_prefix(package_path) {
201                                        input_file_hashes.insert(relative.to_path_buf(), hash);
202                                    }
203                                }
204                            }
205                        }
206                    }
207                }
208            }
209        }
210
211        // Detect toolchain version
212        let toolchain_version = detect_toolchain_version(package.language)?;
213
214        // Build package ID (name + path hash)
215        let package_path_str = package_path.to_string_lossy();
216        let mut package_hasher = Sha256::new();
217        package_hasher.update(package_path_str.as_bytes());
218        let package_path_hash = format!("{:x}", package_hasher.finalize())[..8].to_string();
219        let package_id = format!("{}-{}", package.name, package_path_hash);
220
221        CacheKey::builder()
222            .package_id(package_id)
223            .task_name(task_name.to_string())
224            .command(command.to_string())
225            .env_vars(env_vars)
226            .input_files(input_file_hashes)
227            .dependency_graph_hash(dependency_graph_hash)
228            .toolchain_version(toolchain_version)
229            .build()
230    }
231
232    /// Returns the configuration.
233    pub fn config(&self) -> &RemoteCacheConfig {
234        &self.config
235    }
236}
237
238/// Disabled backend that does nothing.
239struct DisabledBackend;
240
241#[async_trait::async_trait]
242impl RemoteCacheBackend for DisabledBackend {
243    async fn upload_artifact(&self, _key: &CacheKey, _artifact: &Artifact) -> Result<()> {
244        Ok(())
245    }
246
247    async fn fetch_artifact(&self, _key: &CacheKey) -> Result<Option<Artifact>> {
248        Ok(None)
249    }
250
251    async fn has_artifact(&self, _key: &CacheKey) -> Result<bool> {
252        Ok(false)
253    }
254}