1use 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#[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), max_age: Some(Duration::from_secs(30 * 24 * 60 * 60)), 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#[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#[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
135pub struct CacheManager {
137 policy: CachePolicy,
138 workspace_root: PathBuf,
139 metadata: CacheMetadata,
140}
141
142impl CacheManager {
143 pub fn policy(&self) -> &CachePolicy {
145 &self.policy
146 }
147
148 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 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 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 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 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 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 pub fn build_dir(&self, target: &crate::model::TargetTriple) -> PathBuf {
318 self.policy.build_dir(&self.workspace_root, target)
319 }
320
321 pub fn cleanup(&mut self) -> Result<CleanupReport, CrossBuildError> {
323 let mut report = CleanupReport::default();
324 let now = current_timestamp();
325
326 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 if let Some(max_size) = self.policy.max_size_bytes {
349 if self.metadata.total_size_bytes > max_size {
350 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#[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#[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 let source = dir.path().join("test-download");
537 fs::write(&source, b"test content").unwrap();
538
539 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 let retrieved = manager.get_download("https://example.com/file", Some("sha256:abc123"));
550 assert_eq!(retrieved, Some(cached));
551
552 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 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 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 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)); let mut manager = CacheManager::new(policy, dir.path()).unwrap();
587
588 let target = TargetTriple::parse("x86_64-unknown-linux-gnu").unwrap();
589
590 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 if let Some(entry) = manager.metadata.entries.get_mut(&manager.sysroot_key(&target, "rustup")) {
599 entry.last_accessed = current_timestamp() - 120; }
601 manager.save_metadata().unwrap();
602
603 let report = manager.cleanup().unwrap();
605 assert_eq!(report.removed_entries, 1);
606 assert!(!cached.exists());
607 }
608}