Skip to main content

crossbuild_core/
cache.rs

1//! Cache management for cross-build artifacts, downloads, and sysroots.
2
3use std::collections::BTreeMap;
4use std::fs;
5use std::path::{Path, PathBuf};
6use std::time::{Duration, SystemTime, UNIX_EPOCH};
7
8use anyhow::Result;
9use crate::error::CrossBuildError;
10
11/// Cache policy configuration.
12#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
13pub struct CachePolicy {
14    pub root: PathBuf,
15    pub max_size_bytes: Option<u64>,
16    pub max_age: Option<Duration>,
17    pub compress: bool,
18}
19
20impl Default for CachePolicy {
21    fn default() -> Self {
22        Self {
23            root: PathBuf::from("target").join("crossbuild-cache"),
24            max_size_bytes: Some(10 * 1024 * 1024 * 1024), // 10 GB
25            max_age: Some(Duration::from_secs(30 * 24 * 60 * 60)), // 30 days
26            compress: true,
27        }
28    }
29}
30
31impl CachePolicy {
32    pub fn new(root: impl Into<PathBuf>) -> Self {
33        Self {
34            root: root.into(),
35            ..Default::default()
36        }
37    }
38
39    pub fn with_max_size(mut self, bytes: u64) -> Self {
40        self.max_size_bytes = Some(bytes);
41        self
42    }
43
44    pub fn with_max_age(mut self, age: Duration) -> Self {
45        self.max_age = Some(age);
46        self
47    }
48
49    pub fn with_compression(mut self, compress: bool) -> Self {
50        self.compress = compress;
51        self
52    }
53
54    pub fn absolute_root(&self, workspace_root: &Path) -> PathBuf {
55        if self.root.is_absolute() {
56            self.root.clone()
57        } else {
58            workspace_root.join(&self.root)
59        }
60    }
61
62    pub fn cache_key(&self, workspace_root: &Path, target: &crate::model::TargetTriple) -> String {
63        let workspace_label = workspace_root
64            .to_string_lossy()
65            .replace(['\\', '/', ':'], "_");
66        format!("{}::{}", workspace_label, target.triple)
67    }
68
69    pub fn download_dir(&self, workspace_root: &Path) -> PathBuf {
70        self.absolute_root(workspace_root).join("downloads")
71    }
72
73    pub fn sysroot_dir(&self, workspace_root: &Path) -> PathBuf {
74        self.absolute_root(workspace_root).join("sysroots")
75    }
76
77    pub fn toolchain_dir(&self, workspace_root: &Path) -> PathBuf {
78        self.absolute_root(workspace_root).join("toolchains")
79    }
80
81    pub fn build_dir(&self, workspace_root: &Path, target: &crate::model::TargetTriple) -> PathBuf {
82        self.absolute_root(workspace_root)
83            .join("builds")
84            .join(&target.triple)
85    }
86
87    pub fn metadata_path(&self, workspace_root: &Path) -> PathBuf {
88        self.absolute_root(workspace_root).join("metadata.json")
89    }
90}
91
92/// Cache metadata for tracking entries.
93#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
94pub struct CacheMetadata {
95    pub entries: BTreeMap<String, CacheEntry>,
96    pub total_size_bytes: u64,
97    pub last_cleanup: u64,
98}
99
100impl Default for CacheMetadata {
101    fn default() -> Self {
102        Self {
103            entries: BTreeMap::new(),
104            total_size_bytes: 0,
105            last_cleanup: SystemTime::now()
106                .duration_since(UNIX_EPOCH)
107                .unwrap_or_default()
108                .as_secs(),
109        }
110    }
111}
112
113/// Individual cache entry.
114#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
115pub struct CacheEntry {
116    pub key: String,
117    pub path: PathBuf,
118    pub size_bytes: u64,
119    pub created: u64,
120    pub last_accessed: u64,
121    pub access_count: u64,
122    pub entry_type: CacheEntryType,
123    pub metadata: BTreeMap<String, String>,
124}
125
126#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, serde::Serialize, serde::Deserialize)]
127pub enum CacheEntryType {
128    Download,
129    Sysroot,
130    Toolchain,
131    BuildArtifact,
132    Other,
133}
134
135/// Cache manager for handling all cache operations.
136pub struct CacheManager {
137    policy: CachePolicy,
138    workspace_root: PathBuf,
139    metadata: CacheMetadata,
140}
141
142impl CacheManager {
143    /// Returns a reference to the cache policy.
144    pub fn policy(&self) -> &CachePolicy {
145        &self.policy
146    }
147
148    /// Returns current cache statistics.
149    pub fn stats(&self) -> CacheStats {
150        let mut by_type = std::collections::BTreeMap::new();
151        for entry in self.metadata.entries.values() {
152            *by_type.entry(entry.entry_type).or_insert(0) += 1;
153        }
154        CacheStats {
155            total_entries: self.metadata.entries.len(),
156            total_size_bytes: self.metadata.total_size_bytes,
157            by_type,
158            root: self.policy.absolute_root(&self.workspace_root),
159        }
160    }
161
162    /// Creates a new cache manager.
163    pub fn new(policy: CachePolicy, workspace_root: impl AsRef<Path>) -> Result<Self, CrossBuildError> {
164        let workspace_root = workspace_root.as_ref().to_path_buf();
165        let root = policy.absolute_root(&workspace_root);
166        fs::create_dir_all(&root).map_err(|source| CrossBuildError::Io {
167            path: Some(root),
168            source,
169        })?;
170
171        fs::create_dir_all(policy.download_dir(&workspace_root)).map_err(|source| CrossBuildError::Io {
172            path: Some(policy.download_dir(&workspace_root)),
173            source,
174        })?;
175        fs::create_dir_all(policy.sysroot_dir(&workspace_root)).map_err(|source| CrossBuildError::Io {
176            path: Some(policy.sysroot_dir(&workspace_root)),
177            source,
178        })?;
179        fs::create_dir_all(policy.toolchain_dir(&workspace_root)).map_err(|source| CrossBuildError::Io {
180            path: Some(policy.toolchain_dir(&workspace_root)),
181            source,
182        })?;
183
184        let metadata_path = policy.metadata_path(&workspace_root);
185        let metadata = if metadata_path.exists() {
186            let content = fs::read_to_string(&metadata_path).map_err(|source| CrossBuildError::Io {
187                path: Some(metadata_path),
188                source,
189            })?;
190            serde_json::from_str(&content).unwrap_or_default()
191        } else {
192            CacheMetadata::default()
193        };
194
195        Ok(Self {
196            policy,
197            workspace_root,
198            metadata,
199        })
200    }
201
202    /// Gets the path for a cached download.
203    pub fn get_download(&self, url: &str, checksum: Option<&str>) -> Option<PathBuf> {
204        let key = self.download_key(url, checksum);
205        self.metadata.entries.get(&key).and_then(|entry| {
206            if entry.path.exists() {
207                Some(entry.path.clone())
208            } else {
209                None
210            }
211        })
212    }
213
214    /// Stores a downloaded file in the cache.
215    pub fn store_download(
216        &mut self,
217        url: &str,
218        checksum: Option<&str>,
219        source_path: &Path,
220    ) -> Result<PathBuf, CrossBuildError> {
221        let key = self.download_key(url, checksum);
222        let dest = self.policy.download_dir(&self.workspace_root).join(&key);
223
224        fs::copy(source_path, &dest).map_err(|source| CrossBuildError::Io {
225            path: Some(dest.clone()),
226            source,
227        })?;
228
229        let size = fs::metadata(&dest).map(|m| m.len()).unwrap_or(0);
230        let now = current_timestamp();
231
232        self.metadata.entries.insert(
233            key.clone(),
234            CacheEntry {
235                key: key.clone(),
236                path: dest.clone(),
237                size_bytes: size,
238                created: now,
239                last_accessed: now,
240                access_count: 1,
241                entry_type: CacheEntryType::Download,
242                metadata: {
243                    let mut m = BTreeMap::new();
244                    m.insert("url".to_string(), url.to_string());
245                    if let Some(cs) = checksum {
246                        m.insert("checksum".to_string(), cs.to_string());
247                    }
248                    m
249                },
250            },
251        );
252        self.metadata.total_size_bytes += size;
253        self.save_metadata()?;
254
255        Ok(dest)
256    }
257
258    /// Gets or creates a sysroot cache entry.
259    pub fn get_sysroot(&self, target: &crate::model::TargetTriple, provider: &str) -> Option<PathBuf> {
260        let key = self.sysroot_key(target, provider);
261        self.metadata.entries.get(&key).and_then(|entry| {
262            if entry.path.exists() {
263                Some(entry.path.clone())
264            } else {
265                None
266            }
267        })
268    }
269
270    /// Stores a sysroot in the cache.
271    pub fn store_sysroot(
272        &mut self,
273        target: &crate::model::TargetTriple,
274        provider: &str,
275        source_path: &Path,
276    ) -> Result<PathBuf, CrossBuildError> {
277        let key = self.sysroot_key(target, provider);
278        let dest = self.policy.sysroot_dir(&self.workspace_root).join(&key);
279
280        if source_path.is_dir() {
281            copy_dir(source_path, &dest)?;
282        } else {
283            fs::copy(source_path, &dest).map_err(|source| CrossBuildError::Io {
284                path: Some(dest.clone()),
285                source,
286            })?;
287        }
288
289        let size = dir_size(&dest).unwrap_or(0);
290        let now = current_timestamp();
291
292        self.metadata.entries.insert(
293            key.clone(),
294            CacheEntry {
295                key: key.clone(),
296                path: dest.clone(),
297                size_bytes: size,
298                created: now,
299                last_accessed: now,
300                access_count: 1,
301                entry_type: CacheEntryType::Sysroot,
302                metadata: {
303                    let mut m = BTreeMap::new();
304                    m.insert("target".to_string(), target.triple.clone());
305                    m.insert("provider".to_string(), provider.to_string());
306                    m
307                },
308            },
309        );
310        self.metadata.total_size_bytes += size;
311        self.save_metadata()?;
312
313        Ok(dest)
314    }
315
316    /// Gets the build directory for a target.
317    pub fn build_dir(&self, target: &crate::model::TargetTriple) -> PathBuf {
318        self.policy.build_dir(&self.workspace_root, target)
319    }
320
321    /// Cleans up old or excess cache entries.
322    pub fn cleanup(&mut self) -> Result<CleanupReport, CrossBuildError> {
323        let mut report = CleanupReport::default();
324        let now = current_timestamp();
325
326        // Remove expired entries
327        if let Some(max_age) = self.policy.max_age {
328            let cutoff = now - max_age.as_secs();
329            let expired: Vec<_> = self.metadata.entries
330                .iter()
331                .filter(|(_, entry)| entry.last_accessed < cutoff)
332                .map(|(k, _)| k.clone())
333                .collect();
334
335            for key in expired {
336                if let Some(entry) = self.metadata.entries.remove(&key) {
337                    if entry.path.exists() {
338                        remove_entry(&entry.path)?;
339                    }
340                    report.removed_entries += 1;
341                    report.freed_bytes += entry.size_bytes;
342                    self.metadata.total_size_bytes = self.metadata.total_size_bytes.saturating_sub(entry.size_bytes);
343                }
344            }
345        }
346
347        // Enforce size limit
348        if let Some(max_size) = self.policy.max_size_bytes {
349            if self.metadata.total_size_bytes > max_size {
350                // Sort by last accessed (LRU)
351                let mut entries: Vec<_> = self.metadata.entries
352                    .iter()
353                    .map(|(k, v)| (k.clone(), v.last_accessed, v.size_bytes, v.path.clone()))
354                    .collect();
355                entries.sort_by_key(|(_, last_accessed, _, _)| *last_accessed);
356
357                for (key, _, _size, path) in entries {
358                    if self.metadata.total_size_bytes <= max_size {
359                        break;
360                    }
361                    if let Some(entry) = self.metadata.entries.remove(&key) {
362                        if path.exists() {
363                            remove_entry(&path)?;
364                        }
365                        report.removed_entries += 1;
366                        report.freed_bytes += entry.size_bytes;
367                        self.metadata.total_size_bytes = self.metadata.total_size_bytes.saturating_sub(entry.size_bytes);
368                    }
369                }
370            }
371        }
372
373        self.metadata.last_cleanup = now;
374        self.save_metadata()?;
375
376        Ok(report)
377    }
378
379    fn download_key(&self, url: &str, checksum: Option<&str>) -> String {
380        use sha2::{Digest, Sha256};
381        let mut hasher = Sha256::new();
382        hasher.update(url.as_bytes());
383        if let Some(cs) = checksum {
384            hasher.update(cs.as_bytes());
385        }
386        hex::encode(hasher.finalize())[..16].to_string()
387    }
388
389    fn sysroot_key(&self, target: &crate::model::TargetTriple, provider: &str) -> String {
390        use sha2::{Digest, Sha256};
391        let mut hasher = Sha256::new();
392        hasher.update(target.triple.as_bytes());
393        hasher.update(provider.as_bytes());
394        format!("sysroot-{}", &hex::encode(hasher.finalize())[..16])
395    }
396
397    fn save_metadata(&self) -> Result<(), CrossBuildError> {
398        let path = self.policy.metadata_path(&self.workspace_root);
399        let content = serde_json::to_string_pretty(&self.metadata)
400            .map_err(|e| CrossBuildError::configuration(e.to_string()))?;
401        fs::write(&path, content).map_err(|source| CrossBuildError::Io {
402            path: Some(path),
403            source,
404        })
405    }
406}
407
408/// Cache statistics.
409#[derive(Debug, Clone)]
410pub struct CacheStats {
411    pub total_entries: usize,
412    pub total_size_bytes: u64,
413    pub by_type: BTreeMap<CacheEntryType, usize>,
414    pub root: PathBuf,
415}
416
417/// Cleanup report.
418#[derive(Debug, Default, Clone)]
419pub struct CleanupReport {
420    pub removed_entries: usize,
421    pub freed_bytes: u64,
422}
423
424fn current_timestamp() -> u64 {
425    SystemTime::now()
426        .duration_since(UNIX_EPOCH)
427        .unwrap_or_default()
428        .as_secs()
429}
430
431fn copy_dir(src: &Path, dest: &Path) -> Result<(), CrossBuildError> {
432    fs::create_dir_all(dest).map_err(|source| CrossBuildError::Io {
433        path: Some(dest.to_path_buf()),
434        source,
435    })?;
436
437    for entry in fs::read_dir(src).map_err(|source| CrossBuildError::Io {
438        path: Some(src.to_path_buf()),
439        source,
440    })? {
441        let entry = entry.map_err(|source| CrossBuildError::Io {
442            path: Some(src.to_path_buf()),
443            source,
444        })?;
445        let src_path = entry.path();
446        let dest_path = dest.join(entry.file_name());
447
448        if src_path.is_dir() {
449            copy_dir(&src_path, &dest_path)?;
450        } else {
451            fs::copy(&src_path, &dest_path).map_err(|source| CrossBuildError::Io {
452                path: Some(dest_path),
453                source,
454            })?;
455        }
456    }
457    Ok(())
458}
459
460fn dir_size(path: &Path) -> Result<u64, CrossBuildError> {
461    let mut size = 0;
462    for entry in fs::read_dir(path).map_err(|source| CrossBuildError::Io {
463        path: Some(path.to_path_buf()),
464        source,
465    })? {
466        let entry = entry.map_err(|source| CrossBuildError::Io {
467            path: Some(path.to_path_buf()),
468            source,
469        })?;
470        let path = entry.path();
471        if path.is_dir() {
472            size += dir_size(&path)?;
473        } else {
474            size += fs::metadata(&path).map(|m| m.len()).unwrap_or(0);
475        }
476    }
477    Ok(size)
478}
479
480fn remove_entry(path: &Path) -> Result<(), CrossBuildError> {
481    if path.is_dir() {
482        fs::remove_dir_all(path).map_err(|source| CrossBuildError::Io {
483            path: Some(path.to_path_buf()),
484            source,
485        })
486    } else {
487        fs::remove_file(path).map_err(|source| CrossBuildError::Io {
488            path: Some(path.to_path_buf()),
489            source,
490        })
491    }
492}
493
494#[cfg(test)]
495mod tests {
496    use super::*;
497    use crate::model::TargetTriple;
498    use tempfile::tempdir;
499
500    #[test]
501    fn cache_policy_default() {
502        let policy = CachePolicy::default();
503        assert_eq!(policy.root, PathBuf::from("target").join("crossbuild-cache"));
504        assert_eq!(policy.max_size_bytes, Some(10 * 1024 * 1024 * 1024));
505    }
506
507    #[test]
508    fn cache_key_generation() {
509        let policy = CachePolicy::default();
510        let workspace = PathBuf::from("/home/user/project");
511        let target = TargetTriple::parse("x86_64-unknown-linux-gnu").unwrap();
512
513        let key = policy.cache_key(&workspace, &target);
514        assert!(key.contains("home_user_project"));
515        assert!(key.contains("x86_64-unknown-linux-gnu"));
516    }
517
518    #[test]
519    fn cache_manager_creation() {
520        let dir = tempdir().unwrap();
521        let policy = CachePolicy::new(dir.path().join("cache"));
522        let manager = CacheManager::new(policy, dir.path()).unwrap();
523
524        let stats = manager.stats();
525        assert_eq!(stats.total_entries, 0);
526        assert_eq!(stats.total_size_bytes, 0);
527    }
528
529    #[test]
530    fn download_caching() {
531        let dir = tempdir().unwrap();
532        let policy = CachePolicy::new(dir.path().join("cache"));
533        let mut manager = CacheManager::new(policy, dir.path()).unwrap();
534
535        // Create a test file
536        let source = dir.path().join("test-download");
537        fs::write(&source, b"test content").unwrap();
538
539        // Store in cache
540        let cached = manager.store_download(
541            "https://example.com/file",
542            Some("sha256:abc123"),
543            &source,
544        ).unwrap();
545
546        assert!(cached.exists());
547
548        // Retrieve from cache
549        let retrieved = manager.get_download("https://example.com/file", Some("sha256:abc123"));
550        assert_eq!(retrieved, Some(cached));
551
552        // Different checksum should not match
553        let retrieved2 = manager.get_download("https://example.com/file", Some("sha256:different"));
554        assert_eq!(retrieved2, None);
555    }
556
557    #[test]
558    fn sysroot_caching() {
559        let dir = tempdir().unwrap();
560        let policy = CachePolicy::new(dir.path().join("cache"));
561        let mut manager = CacheManager::new(policy, dir.path()).unwrap();
562
563        let target = TargetTriple::parse("x86_64-unknown-linux-gnu").unwrap();
564
565        // Create a test sysroot
566        let sysroot = dir.path().join("sysroot");
567        fs::create_dir_all(sysroot.join("lib")).unwrap();
568        fs::write(sysroot.join("lib").join("libc.so"), b"fake").unwrap();
569
570        // Store in cache
571        let cached = manager.store_sysroot(&target, "rustup", &sysroot).unwrap();
572
573        assert!(cached.exists());
574        assert!(cached.join("lib").join("libc.so").exists());
575
576        // Retrieve from cache
577        let retrieved = manager.get_sysroot(&target, "rustup");
578        assert_eq!(retrieved, Some(cached));
579    }
580
581    #[test]
582    fn cleanup_removes_old_entries() {
583        let dir = tempdir().unwrap();
584        let policy = CachePolicy::new(dir.path().join("cache"))
585            .with_max_age(Duration::from_secs(60)); // 1 minute
586        let mut manager = CacheManager::new(policy, dir.path()).unwrap();
587
588        let target = TargetTriple::parse("x86_64-unknown-linux-gnu").unwrap();
589
590        // Create entries with old timestamps
591        let sysroot = dir.path().join("sysroot");
592        fs::create_dir_all(sysroot.join("lib")).unwrap();
593        fs::write(sysroot.join("lib").join("libc.so"), b"fake").unwrap();
594
595        let cached = manager.store_sysroot(&target, "rustup", &sysroot).unwrap();
596
597        // Manually set old timestamp
598        if let Some(entry) = manager.metadata.entries.get_mut(&manager.sysroot_key(&target, "rustup")) {
599            entry.last_accessed = current_timestamp() - 120; // 2 minutes ago
600        }
601        manager.save_metadata().unwrap();
602
603        // Run cleanup
604        let report = manager.cleanup().unwrap();
605        assert_eq!(report.removed_entries, 1);
606        assert!(!cached.exists());
607    }
608}