Skip to main content

crossbuild_core/
model.rs

1use std::collections::BTreeMap;
2use std::fmt::{self, Display, Formatter};
3use std::fs;
4use std::path::{Path, PathBuf};
5use std::str::FromStr;
6use std::time::{Duration, SystemTime, UNIX_EPOCH};
7
8
9use crate::CrossBuildError;
10
11/// Error parsing a target triple component.
12#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
13pub enum TargetParseError {
14    #[error("empty target triple")]
15    Empty,
16    #[error("target triple contains whitespace")]
17    Whitespace,
18    #[error("invalid target triple format: expected at least {expected_min} components, got {actual} for `{triple}`")]
19    InvalidFormat { triple: String, expected_min: usize, actual: usize },
20    #[error("unknown architecture: {0}")]
21    UnknownArchitecture(String),
22    #[error("unknown vendor: {0}")]
23    UnknownVendor(String),
24    #[error("unknown operating system: {0}")]
25    UnknownOs(String),
26    #[error("unknown abi: {0}")]
27    UnknownAbi(String),
28}
29
30/// CPU architecture.
31#[derive(Debug, Clone, PartialEq, Eq, Hash)]
32pub enum Architecture {
33    X86_64,
34    AArch64,
35    X86,
36    Arm,
37    Arm64,
38    RiscV64,
39    PowerPC64,
40    S390x,
41    Mips64,
42    LoongArch64,
43    Wasm32,
44    Wasm64,
45    Other(String),
46}
47
48impl Architecture {
49    pub fn parse(s: &str) -> Result<Self, TargetParseError> {
50        Ok(match s.to_lowercase().as_str() {
51            "x86_64" | "amd64" => Architecture::X86_64,
52            "aarch64" | "arm64" => Architecture::AArch64,
53            "i686" | "i586" | "i386" | "x86" => Architecture::X86,
54            "arm" | "armv7" | "armv7a" | "armv7hf" => Architecture::Arm,
55            "riscv64" | "riscv64gc" => Architecture::RiscV64,
56            "powerpc64le" | "ppc64le" => Architecture::PowerPC64,
57            "s390x" => Architecture::S390x,
58            "mips64" | "mips64el" => Architecture::Mips64,
59            "loongarch64" => Architecture::LoongArch64,
60            "wasm32" => Architecture::Wasm32,
61            "wasm64" => Architecture::Wasm64,
62            other => Architecture::Other(other.to_string()),
63        })
64    }
65
66    pub fn pointer_width(&self) -> u8 {
67        match self {
68            Architecture::X86_64
69            | Architecture::AArch64
70            | Architecture::Arm64
71            | Architecture::RiscV64
72            | Architecture::PowerPC64
73            | Architecture::S390x
74            | Architecture::Mips64
75            | Architecture::LoongArch64
76            | Architecture::Wasm64 => 64,
77            Architecture::X86
78            | Architecture::Arm
79            | Architecture::Wasm32 => 32,
80            Architecture::Other(_) => 64,
81        }
82    }
83
84    pub fn endianness(&self) -> Endianness {
85        match self {
86            Architecture::X86_64 | Architecture::X86 | Architecture::Wasm32 | Architecture::Wasm64 => Endianness::Little,
87            Architecture::AArch64 | Architecture::Arm | Architecture::Arm64 | Architecture::RiscV64 => Endianness::Little,
88            Architecture::PowerPC64 => Endianness::Little,
89            Architecture::S390x => Endianness::Big,
90            Architecture::Mips64 => Endianness::Big,
91            Architecture::LoongArch64 => Endianness::Little,
92            Architecture::Other(_) => Endianness::Little,
93        }
94    }
95
96    pub fn name(&self) -> &str {
97        match self {
98            Architecture::X86_64 => "x86_64",
99            Architecture::AArch64 => "aarch64",
100            Architecture::X86 => "i686",
101            Architecture::Arm => "arm",
102            Architecture::Arm64 => "aarch64",
103            Architecture::RiscV64 => "riscv64",
104            Architecture::PowerPC64 => "powerpc64le",
105            Architecture::S390x => "s390x",
106            Architecture::Mips64 => "mips64",
107            Architecture::LoongArch64 => "loongarch64",
108            Architecture::Wasm32 => "wasm32",
109            Architecture::Wasm64 => "wasm64",
110            Architecture::Other(name) => name,
111        }
112    }
113}
114
115impl Display for Architecture {
116    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
117        f.write_str(self.name())
118    }
119}
120
121/// Byte order.
122#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
123pub enum Endianness {
124    Little,
125    Big,
126}
127
128/// Vendor field in target triple.
129#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
130pub enum Vendor {
131    Unknown,
132    Pc,
133    Apple,
134    Linux,
135    Uwp,
136    Fuchsia,
137    PlayStation,
138    Nintendo,
139    Sony,
140    Microsoft,
141    Other,
142}
143
144impl Vendor {
145    pub fn parse(s: &str) -> Result<Self, TargetParseError> {
146        Ok(match s.to_lowercase().as_str() {
147            "unknown" => Vendor::Unknown,
148            "pc" => Vendor::Pc,
149            "apple" => Vendor::Apple,
150            "linux" => Vendor::Linux,
151            "uwp" => Vendor::Uwp,
152            "fuchsia" => Vendor::Fuchsia,
153            "playstation" => Vendor::PlayStation,
154            "nintendo" => Vendor::Nintendo,
155            "sony" => Vendor::Sony,
156            "microsoft" => Vendor::Microsoft,
157            _ => Vendor::Other,
158        })
159    }
160
161    pub fn name(&self) -> &str {
162        match self {
163            Vendor::Unknown => "unknown",
164            Vendor::Pc => "pc",
165            Vendor::Apple => "apple",
166            Vendor::Linux => "linux",
167            Vendor::Uwp => "uwp",
168            Vendor::Fuchsia => "fuchsia",
169            Vendor::PlayStation => "playstation",
170            Vendor::Nintendo => "nintendo",
171            Vendor::Sony => "sony",
172            Vendor::Microsoft => "microsoft",
173            Vendor::Other => "unknown",
174        }
175    }
176}
177
178impl Display for Vendor {
179    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
180        f.write_str(self.name())
181    }
182}
183
184/// Operating system in target triple.
185#[derive(Debug, Clone, PartialEq, Eq, Hash)]
186pub enum OperatingSystem {
187    None,
188    Linux,
189    Windows,
190    MacOs,
191    FreeBSD,
192    NetBSD,
193    OpenBSD,
194    DragonflyBSD,
195    Solaris,
196    Illumos,
197    Android,
198    Ios,
199    TvOS,
200    WatchOS,
201    Wasm,
202    Wasi,
203    Uefi,
204    Redox,
205    Heron,
206    Fuchsia,
207    Zos,
208    Other(String),
209}
210
211impl OperatingSystem {
212    pub fn parse(s: &str) -> Result<Self, TargetParseError> {
213        Ok(match s.to_lowercase().as_str() {
214            "none" => OperatingSystem::None,
215            "linux" => OperatingSystem::Linux,
216            "windows" => OperatingSystem::Windows,
217            "darwin" | "macos" => OperatingSystem::MacOs,
218            "freebsd" => OperatingSystem::FreeBSD,
219            "openbsd" => OperatingSystem::OpenBSD,
220            "netbsd" => OperatingSystem::NetBSD,
221            "dragonflybsd" | "dragonfly" => OperatingSystem::DragonflyBSD,
222            "solaris" => OperatingSystem::Solaris,
223            "illumos" => OperatingSystem::Illumos,
224            "android" => OperatingSystem::Android,
225            "ios" => OperatingSystem::Ios,
226            "tvos" => OperatingSystem::TvOS,
227            "watchos" => OperatingSystem::WatchOS,
228            "wasm" => OperatingSystem::Wasm,
229            "wasi" => OperatingSystem::Wasi,
230            "fuchsia" => OperatingSystem::Fuchsia,
231            "redox" => OperatingSystem::Redox,
232            "heron" => OperatingSystem::Heron,
233            "zos" => OperatingSystem::Zos,
234            other => OperatingSystem::Other(other.to_string()),
235        })
236    }
237
238    pub fn name(&self) -> &str {
239        match self {
240            OperatingSystem::None => "none",
241            OperatingSystem::Linux => "linux",
242            OperatingSystem::Windows => "windows",
243            OperatingSystem::MacOs => "darwin",
244            OperatingSystem::FreeBSD => "freebsd",
245            OperatingSystem::OpenBSD => "openbsd",
246            OperatingSystem::NetBSD => "netbsd",
247            OperatingSystem::DragonflyBSD => "dragonflybsd",
248            OperatingSystem::Solaris => "solaris",
249            OperatingSystem::Illumos => "illumos",
250            OperatingSystem::Android => "android",
251            OperatingSystem::Ios => "ios",
252            OperatingSystem::TvOS => "tvos",
253            OperatingSystem::WatchOS => "watchos",
254            OperatingSystem::Wasm => "wasi",
255            OperatingSystem::Wasi => "wasi",
256            OperatingSystem::Uefi => "uefi",
257            OperatingSystem::Redox => "redox",
258            OperatingSystem::Heron => "heron",
259            OperatingSystem::Fuchsia => "fuchsia",
260            OperatingSystem::Zos => "zos",
261            OperatingSystem::Other(s) => s,
262        }
263    }
264
265    pub fn is_unix_like(&self) -> bool {
266        matches!(
267            self,
268            OperatingSystem::Linux
269                | OperatingSystem::MacOs
270                | OperatingSystem::FreeBSD
271                | OperatingSystem::OpenBSD
272                | OperatingSystem::NetBSD
273                | OperatingSystem::DragonflyBSD
274                | OperatingSystem::Solaris
275                | OperatingSystem::Illumos
276                | OperatingSystem::Android
277                | OperatingSystem::Redox
278                | OperatingSystem::Fuchsia
279        )
280    }
281}
282
283impl Display for OperatingSystem {
284    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
285        f.write_str(self.name())
286    }
287}
288
289/// ABI (Application Binary Interface) specification.
290#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
291pub enum Abi {
292    None,
293    Gnu,
294    Musl,
295    Msvc,
296    Uwp,
297    Wasm32,
298    Wasm64,
299    Eabi,
300    Eabihf,
301    Android,
302    Simulator,
303}
304
305impl Abi {
306    pub fn parse(s: &str) -> Result<Self, TargetParseError> {
307        Ok(match s.to_lowercase().as_str() {
308            "none" => Abi::None,
309            "gnu" => Abi::Gnu,
310            "musl" => Abi::Musl,
311            "msvc" => Abi::Msvc,
312            "uwp" => Abi::Uwp,
313            "wasm32" => Abi::Wasm32,
314            "wasm64" => Abi::Wasm64,
315            "eabi" => Abi::Eabi,
316            "eabihf" => Abi::Eabihf,
317            "android" => Abi::Android,
318            "simulator" => Abi::Simulator,
319            other => return Err(TargetParseError::UnknownAbi(other.to_string())),
320        })
321    }
322
323    pub fn default_for_os(os: &OperatingSystem) -> Self {
324        match os {
325            OperatingSystem::Linux => Abi::Gnu,
326            OperatingSystem::Windows => Abi::Msvc,
327            OperatingSystem::MacOs => Abi::None,
328            OperatingSystem::Wasm => Abi::Wasm32,
329            OperatingSystem::Wasi => Abi::None,
330            OperatingSystem::Android => Abi::Android,
331            OperatingSystem::FreeBSD
332            | OperatingSystem::OpenBSD
333            | OperatingSystem::NetBSD
334            | OperatingSystem::DragonflyBSD => Abi::Gnu,
335            _ => Abi::None,
336        }
337    }
338
339    pub fn name(&self) -> &str {
340        match self {
341            Abi::None => "none",
342            Abi::Gnu => "gnu",
343            Abi::Musl => "musl",
344            Abi::Msvc => "msvc",
345            Abi::Uwp => "uwp",
346            Abi::Wasm32 => "wasm32",
347            Abi::Wasm64 => "wasm64",
348            Abi::Eabi => "eabi",
349            Abi::Eabihf => "eabihf",
350            Abi::Android => "android",
351            Abi::Simulator => "simulator",
352        }
353    }
354}
355
356impl Display for Abi {
357    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
358        f.write_str(self.name())
359    }
360}
361
362impl Default for Abi {
363    fn default() -> Self {
364        Abi::None
365    }
366}
367
368/// Target family classification for provider routing.
369#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
370pub enum TargetFamily {
371    Windows,
372    Linux,
373    MacOs,
374    Wasm,
375    BareMetal,
376    Other,
377}
378
379impl TargetFamily {
380    fn from_os_abi(os: &OperatingSystem, abi: &Abi) -> Self {
381        match (os, abi) {
382            (OperatingSystem::Windows, _) => TargetFamily::Windows,
383            (OperatingSystem::Linux, _) => TargetFamily::Linux,
384            (OperatingSystem::MacOs, _) => TargetFamily::MacOs,
385            (OperatingSystem::Wasm, _) | (OperatingSystem::Wasi, _) => TargetFamily::Wasm,
386            (OperatingSystem::None, _) => TargetFamily::BareMetal,
387            _ => TargetFamily::Other,
388        }
389    }
390}
391
392/// A fully qualified target triple with parsed components.
393#[derive(Debug, Clone, PartialEq, Eq, Hash)]
394pub struct TargetTriple {
395    pub triple: String,
396    pub arch: Architecture,
397    pub vendor: Vendor,
398    pub os: OperatingSystem,
399    pub abi: Abi,
400    pub family: TargetFamily,
401}
402
403impl TargetTriple {
404    pub fn parse(triple: &str) -> Result<Self, TargetParseError> {
405        let trimmed = triple.trim();
406        if trimmed.is_empty() {
407            return Err(TargetParseError::Empty);
408        }
409
410        if trimmed.chars().any(char::is_whitespace) {
411            return Err(TargetParseError::Whitespace);
412        }
413
414        let parts: Vec<&str> = trimmed.split('-').collect();
415        if parts.len() < 2 {
416            return Err(TargetParseError::InvalidFormat {
417                triple: trimmed.to_string(),
418                expected_min: 2,
419                actual: parts.len(),
420            });
421        }
422
423        let arch = Architecture::parse(parts[0])?;
424
425        let (vendor, os, abi, family) = if parts.len() == 2 {
426            // 2-component target: arch-os (e.g. wasm32-wasi, wasm32-unknown)
427            let os = OperatingSystem::parse(parts[1])?;
428            let abi = Abi::default_for_os(&os);
429            let family = TargetFamily::from_os_abi(&os, &abi);
430            (Vendor::Unknown, os, abi, family)
431        } else if parts.len() == 3 {
432            // 3-component target: arch-vendor-os
433            let vendor = Vendor::parse(parts[1])?;
434            let os = OperatingSystem::parse(parts[2])?;
435            let abi = Abi::default_for_os(&os);
436            let family = TargetFamily::from_os_abi(&os, &abi);
437            (vendor, os, abi, family)
438        } else {
439            let vendor = Vendor::parse(parts[1])?;
440            let os = OperatingSystem::parse(parts[2])?;
441            let abi = Abi::parse(parts[3])?;
442            let family = TargetFamily::from_os_abi(&os, &abi);
443            (vendor, os, abi, family)
444        };
445
446        Ok(Self {
447            triple: trimmed.to_string(),
448            arch,
449            vendor,
450            os,
451            abi,
452            family,
453        })
454    }
455
456    pub fn as_str(&self) -> &str {
457        &self.triple
458    }
459
460    pub fn is_windows(&self) -> bool {
461        matches!(self.family, TargetFamily::Windows)
462    }
463
464    pub fn is_linux(&self) -> bool {
465        matches!(self.family, TargetFamily::Linux)
466    }
467
468    pub fn is_macos(&self) -> bool {
469        matches!(self.family, TargetFamily::MacOs)
470    }
471
472    pub fn is_wasm(&self) -> bool {
473        matches!(self.family, TargetFamily::Wasm)
474    }
475
476    pub fn is_bare_metal(&self) -> bool {
477        matches!(self.family, TargetFamily::BareMetal)
478    }
479
480    pub fn pointer_width(&self) -> u8 {
481        self.arch.pointer_width()
482    }
483
484    pub fn endianness(&self) -> Endianness {
485        self.arch.endianness()
486    }
487}
488
489impl FromStr for TargetTriple {
490    type Err = TargetParseError;
491
492    fn from_str(s: &str) -> Result<Self, Self::Err> {
493        Self::parse(s)
494    }
495}
496
497impl Display for TargetTriple {
498    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
499        f.write_str(&self.triple)
500    }
501}
502
503impl PartialOrd for TargetTriple {
504    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
505        Some(self.cmp(other))
506    }
507}
508
509impl Ord for TargetTriple {
510    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
511        self.triple.cmp(&other.triple)
512    }
513}
514
515/// Target family trait for TargetTriple.
516impl TargetTriple {
517    pub fn family(&self) -> TargetFamily {
518        self.family
519    }
520}
521
522/// Trait for target family classification.
523pub trait TargetFamilyExt {
524    fn family(&self) -> TargetFamily;
525}
526
527impl TargetFamilyExt for TargetTriple {
528    fn family(&self) -> TargetFamily {
529        self.family
530    }
531}
532
533/// Known target triples organized by tier.
534pub struct KnownTargets;
535
536impl KnownTargets {
537    /// Returns all tier 1 target triples.
538    pub fn tier1() -> &'static [&'static str] {
539        &[
540            "x86_64-unknown-linux-gnu",
541            "x86_64-pc-windows-msvc",
542            "aarch64-unknown-linux-gnu",
543            "aarch64-apple-darwin",
544            "x86_64-apple-darwin",
545        ]
546    }
547
548    /// Returns all tier 2 target triples.
549    pub fn tier2() -> &'static [&'static str] {
550        &[
551            "x86_64-unknown-linux-musl",
552            "aarch64-unknown-linux-musl",
553            "x86_64-unknown-freebsd",
554            "aarch64-unknown-freebsd",
555            "x86_64-unknown-netbsd",
556            "aarch64-unknown-netbsd",
557            "x86_64-unknown-openbsd",
558            "x86_64-unknown-illumos",
559            "powerpc64le-unknown-linux-gnu",
560            "s390x-unknown-linux-gnu",
561            "riscv64gc-unknown-linux-gnu",
562            "x86_64-pc-windows-gnu",
563            "i686-pc-windows-msvc",
564            "i686-pc-windows-gnu",
565            "aarch64-pc-windows-msvc",
566            "wasm32-wasi",
567            "wasm32-unknown-unknown",
568            "wasm32-unknown-emscripten",
569        ]
570    }
571
572    /// Checks if a target is a known tier 1 target.
573    pub fn is_tier1(target: &str) -> bool {
574        Self::tier1().contains(&target)
575    }
576
577    /// Checks if a target is a known tier 2 target.
578    pub fn is_tier2(target: &str) -> bool {
579        Self::tier2().contains(&target)
580    }
581
582    /// Returns all known targets (tier 1 + tier 2).
583    pub fn all_known() -> Vec<&'static str> {
584        let mut targets = Vec::new();
585        targets.extend_from_slice(Self::tier1());
586        targets.extend_from_slice(Self::tier2());
587        targets
588    }
589}
590
591/// Execution mode for a build request.
592#[derive(Debug, Clone, Copy, PartialEq, Eq)]
593pub enum ExecutionMode {
594    DryRun,
595    Execute,
596}
597
598/// Build profile configuration.
599#[derive(Debug, Clone, Copy, PartialEq, Eq)]
600pub enum Profile {
601    Dev,
602    Release,
603    Custom(&'static str),
604}
605
606impl Default for Profile {
607    fn default() -> Self {
608        Profile::Dev
609    }
610}
611
612impl Display for Profile {
613    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
614        match self {
615            Profile::Dev => f.write_str("dev"),
616            Profile::Release => f.write_str("release"),
617            Profile::Custom(name) => f.write_str(name),
618        }
619    }
620}
621
622/// A user request submitted through the CLI.
623#[derive(Debug, Clone, PartialEq, Eq)]
624pub struct BuildRequest {
625    pub manifest_path: PathBuf,
626    pub target_triple: TargetTriple,
627    pub cargo_args: Vec<String>,
628    pub execution_mode: ExecutionMode,
629    pub verbose: bool,
630    pub profile: Profile,
631    pub features: Vec<String>,
632    pub no_default_features: bool,
633    pub workspace: bool,
634    pub exclude: Vec<String>,
635}
636
637impl BuildRequest {
638    pub fn new(manifest_path: PathBuf, target_triple: TargetTriple) -> Self {
639        Self {
640            manifest_path,
641            target_triple,
642            cargo_args: Vec::new(),
643            execution_mode: ExecutionMode::Execute,
644            verbose: false,
645            profile: Profile::default(),
646            features: Vec::new(),
647            no_default_features: false,
648            workspace: false,
649            exclude: Vec::new(),
650        }
651    }
652
653    pub fn with_cargo_args(mut self, args: Vec<String>) -> Self {
654        self.cargo_args = args;
655        self
656    }
657
658    pub fn with_execution_mode(mut self, mode: ExecutionMode) -> Self {
659        self.execution_mode = mode;
660        self
661    }
662
663    pub fn with_verbose(mut self, verbose: bool) -> Self {
664        self.verbose = verbose;
665        self
666    }
667
668    pub fn with_profile(mut self, profile: Profile) -> Self {
669        self.profile = profile;
670        self
671    }
672
673    pub fn with_features(mut self, features: Vec<String>) -> Self {
674        self.features = features;
675        self
676    }
677
678    pub fn with_no_default_features(mut self, no_default_features: bool) -> Self {
679        self.no_default_features = no_default_features;
680        self
681    }
682
683    pub fn with_workspace(mut self, workspace: bool) -> Self {
684        self.workspace = workspace;
685        self
686    }
687
688    pub fn with_exclude(mut self, exclude: Vec<String>) -> Self {
689        self.exclude = exclude;
690        self
691    }
692}
693
694/// Host information derived from the running process.
695#[derive(Debug, Clone, PartialEq, Eq)]
696pub struct HostInfo {
697    pub host_triple: TargetTriple,
698    pub os: OperatingSystem,
699    pub arch: Architecture,
700    pub rustc_version: Option<String>,
701    pub cargo_version: Option<String>,
702    pub target_dir: PathBuf,
703}
704
705impl HostInfo {
706    pub fn detect() -> Result<Self, HostDetectError> {
707        let host_triple_str = Self::detect_host_triple_from_rustc()?;
708        let host_triple = TargetTriple::parse(&host_triple_str)
709            .map_err(|e| HostDetectError::ParseError(e.to_string()))?;
710
711        let rustc_version = Self::detect_rustc_version();
712        let cargo_version = Self::detect_cargo_version();
713        let target_dir = std::env::current_dir()
714            .unwrap_or_else(|_| PathBuf::from("."))
715            .join("target")
716            .join("crossbuild");
717
718        Ok(Self {
719            os: host_triple.os.clone(),
720            arch: host_triple.arch.clone(),
721            host_triple,
722            rustc_version,
723            cargo_version,
724            target_dir,
725        })
726    }
727
728    fn detect_host_triple_from_rustc() -> Result<String, HostDetectError> {
729        let output = std::process::Command::new("rustc")
730            .arg("-vV")
731            .output()
732            .map_err(|_| HostDetectError::RustcNotFound)?;
733
734        let stdout = String::from_utf8(output.stdout)
735            .map_err(|_| HostDetectError::ParseError("invalid UTF-8".to_string()))?;
736
737        for line in stdout.lines() {
738            if line.starts_with("host: ") {
739                return Ok(line.strip_prefix("host: ").unwrap().to_string());
740            }
741        }
742
743        Err(HostDetectError::ParseError("host triple not found in rustc -vV output".to_string()))
744    }
745
746    fn detect_rustc_version() -> Option<String> {
747        std::process::Command::new("rustc")
748            .arg("--version")
749            .output()
750            .ok()
751            .and_then(|o| String::from_utf8(o.stdout).ok())
752            .map(|s| s.trim().to_string())
753    }
754
755    fn detect_cargo_version() -> Option<String> {
756        std::process::Command::new("cargo")
757            .arg("--version")
758            .output()
759            .ok()
760            .and_then(|o| String::from_utf8(o.stdout).ok())
761            .map(|s| s.trim().to_string())
762    }
763}
764
765/// Errors during host detection.
766#[derive(Debug, Clone, PartialEq, Eq)]
767pub enum HostDetectError {
768    ParseError(String),
769    RustcNotFound,
770    CargoNotFound,
771}
772
773impl Display for HostDetectError {
774    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
775        match self {
776            HostDetectError::ParseError(e) => write!(f, "failed to parse host triple: {e}"),
777            HostDetectError::RustcNotFound => f.write_str("rustc not found in PATH"),
778            HostDetectError::CargoNotFound => f.write_str("cargo not found in PATH"),
779        }
780    }
781}
782
783impl std::error::Error for HostDetectError {}
784
785/// Target information with capability assessment.
786#[derive(Debug, Clone, PartialEq, Eq)]
787pub struct TargetInfo {
788    pub triple: TargetTriple,
789    pub is_native: bool,
790    pub requires_cross: bool,
791    pub supported: TargetSupport,
792    pub toolchain_hint: ToolchainHint,
793    pub sysroot_hint: SysrootHint,
794    pub linker_hint: LinkerHint,
795}
796
797/// Target support level.
798#[derive(Debug, Clone, Copy, PartialEq, Eq)]
799pub enum TargetSupport {
800    /// Tier 1: Guaranteed to build and pass tests
801    Tier1,
802    /// Tier 2: Guaranteed to build, tests may not run
803    Tier2,
804    /// Tier 3: No guarantees, community maintained
805    Tier3,
806    /// Not supported by rustup
807    Unsupported,
808}
809
810/// Suggested toolchain provider.
811#[derive(Debug, Clone, Copy, PartialEq, Eq)]
812pub enum ToolchainHint {
813    Rustup,
814    Zig,
815    CrossDocker,
816    Custom,
817}
818
819/// Suggested sysroot provider.
820#[derive(Debug, Clone, Copy, PartialEq, Eq)]
821pub enum SysrootHint {
822    Rustup,
823    Zig,
824    Custom,
825    None,
826}
827
828/// Suggested linker provider.
829#[derive(Debug, Clone, Copy, PartialEq, Eq)]
830pub enum LinkerHint {
831    SystemDefault,
832    Lld,
833    Mold,
834    Zig,
835    MSVC,
836    Custom,
837}
838
839/// A resolved command line with explicit environment overrides.
840#[derive(Debug, Clone, PartialEq, Eq)]
841pub struct CommandLine {
842    pub program: String,
843    pub args: Vec<String>,
844    pub env: BTreeMap<String, String>,
845    pub current_dir: PathBuf,
846}
847
848impl CommandLine {
849    pub fn new(program: impl Into<String>, current_dir: impl Into<PathBuf>) -> Self {
850        Self {
851            program: program.into(),
852            args: Vec::new(),
853            env: BTreeMap::new(),
854            current_dir: current_dir.into(),
855        }
856    }
857
858    pub fn push_arg(&mut self, arg: impl Into<String>) {
859        self.args.push(arg.into());
860    }
861
862    pub fn extend_args(&mut self, args: impl IntoIterator<Item = impl Into<String>>) {
863        self.args.extend(args.into_iter().map(Into::into));
864    }
865
866    pub fn set_env(&mut self, key: impl Into<String>, value: impl Into<String>) {
867        self.env.insert(key.into(), value.into());
868    }
869
870    pub fn extend_env(&mut self, env: impl IntoIterator<Item = (impl Into<String>, impl Into<String>)>) {
871        self.env.extend(env.into_iter().map(|(k, v)| (k.into(), v.into())));
872    }
873}
874
875impl Display for CommandLine {
876    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
877        write!(f, "{}", self.program)?;
878        for arg in &self.args {
879            write!(f, " ")?;
880            if arg.contains(' ') || arg.contains('"') {
881                write!(f, "{:?}", arg)?;
882            } else {
883                write!(f, "{arg}")?;
884            }
885        }
886        Ok(())
887    }
888}
889
890/// A provider contribution to the final build plan.
891#[derive(Debug, Clone, PartialEq)]
892pub struct ProviderAction {
893    pub provider_name: String,
894    pub notes: Vec<String>,
895    pub env: BTreeMap<String, String>,
896    pub cargo_config: Option<toml::Table>,
897}
898
899/// A snippet of Cargo configuration to merge.
900#[derive(Debug, Clone, PartialEq)]
901pub struct CargoConfigSnippet {
902    pub target_section: Option<String>,
903    pub config: toml::Table,
904}
905
906/// A single step in the build plan.
907#[derive(Debug, Clone, PartialEq, Eq)]
908pub enum PlanStep {
909    ValidateManifest { path: PathBuf },
910    ValidateTarget { target: TargetTriple },
911    DetectHost,
912    ResolveProviders,
913    PrepareEnvironment,
914    GenerateCargoConfig,
915    ResolveLinker,
916    PrepareCache,
917    InvokeCargo,
918    CaptureDiagnostics,
919    VerifyArtifacts,
920}
921
922/// A fully resolved build plan.
923#[derive(Debug, Clone, PartialEq)]
924pub struct BuildPlan {
925    pub request: BuildRequest,
926    pub host: HostInfo,
927    pub target: TargetInfo,
928    pub command: CommandLine,
929    pub steps: Vec<PlanStep>,
930    pub provider_actions: Vec<ProviderAction>,
931    pub cargo_config: Option<toml::Table>,
932    pub cache_key: String,
933}
934
935impl BuildPlan {
936    pub fn manifest_directory(&self) -> &Path {
937        self.request
938            .manifest_path
939            .parent()
940            .unwrap_or_else(|| Path::new("."))
941    }
942
943    pub fn target_triple(&self) -> &TargetTriple {
944        &self.request.target_triple
945    }
946
947    pub fn is_cross_compilation(&self) -> bool {
948        self.target.requires_cross
949    }
950}
951
952/// Execution report returned by the engine.
953#[derive(Debug, Clone, PartialEq)]
954pub struct ExecutionReport {
955    pub plan: BuildPlan,
956    pub run: RunReport,
957}
958
959/// Report from the runner.
960#[derive(Debug, Clone, PartialEq, Eq)]
961pub struct RunReport {
962    pub executed: bool,
963    pub command: String,
964    pub working_directory: PathBuf,
965    pub exit_code: Option<i32>,
966    pub duration_ms: u64,
967}
968
969/// Validation plan for cross-compilation testing.
970#[derive(Debug, Clone, PartialEq, Eq)]
971pub struct ValidationPlan {
972    pub label: String,
973    pub target: TargetTriple,
974    pub test_command: Vec<String>,
975    pub expected_artifacts: Vec<PathBuf>,
976    pub requires_release: bool,
977    pub env: BTreeMap<String, String>,
978    pub timeout_secs: u64,
979}
980
981impl ValidationPlan {
982    pub fn new(label: impl Into<String>, target: TargetTriple) -> Self {
983        Self {
984            label: label.into(),
985            target,
986            test_command: vec!["cargo".into(), "test".into()],
987            expected_artifacts: Vec::new(),
988            requires_release: false,
989            env: BTreeMap::new(),
990            timeout_secs: 300,
991        }
992    }
993
994    pub fn with_test_command(mut self, cmd: Vec<String>) -> Self {
995        self.test_command = cmd;
996        self
997    }
998
999    pub fn with_artifacts(mut self, artifacts: Vec<PathBuf>) -> Self {
1000        self.expected_artifacts = artifacts;
1001        self
1002    }
1003
1004    pub fn with_release_mode(mut self, requires_release: bool) -> Self {
1005        self.requires_release = requires_release;
1006        self
1007    }
1008
1009    pub fn with_env(mut self, env: BTreeMap<String, String>) -> Self {
1010        self.env = env;
1011        self
1012    }
1013
1014    pub fn with_timeout(mut self, secs: u64) -> Self {
1015        self.timeout_secs = secs;
1016        self
1017    }
1018
1019    pub fn requires_release_mode(&self) -> bool {
1020        self.requires_release
1021    }
1022}
1023
1024/// Pre-defined validation plans for common scenarios.
1025pub struct StandardValidations;
1026
1027impl StandardValidations {
1028    /// Validation for a basic library crate.
1029    pub fn library(target: TargetTriple) -> ValidationPlan {
1030        ValidationPlan::new("library", target)
1031            .with_test_command(vec!["cargo".into(), "test".into(), "--lib".into()])
1032            .with_artifacts(vec![
1033                PathBuf::from("libtest.rlib"),
1034                PathBuf::from("deps"),
1035            ])
1036    }
1037
1038    /// Validation for a binary crate.
1039    pub fn binary(target: TargetTriple) -> ValidationPlan {
1040        ValidationPlan::new("binary", target)
1041            .with_test_command(vec!["cargo".into(), "test".into(), "--bin".into(), "main".into()])
1042            .with_artifacts(vec![PathBuf::from("main")])
1043    }
1044
1045    /// Validation for a crate with both library and binary.
1046    pub fn mixed(target: TargetTriple) -> ValidationPlan {
1047        ValidationPlan::new("mixed", target)
1048            .with_test_command(vec!["cargo".into(), "test".into()])
1049            .with_artifacts(vec![
1050                PathBuf::from("libtest.rlib"),
1051                PathBuf::from("main"),
1052            ])
1053    }
1054
1055    /// Validation for no_std crate.
1056    pub fn no_std(target: TargetTriple) -> ValidationPlan {
1057        ValidationPlan::new("no_std", target)
1058            .with_test_command(vec!["cargo".into(), "build".into()])
1059            .with_artifacts(vec![PathBuf::from("libnostd.rlib")])
1060            .with_env({
1061                let mut env = BTreeMap::new();
1062                env.insert("RUSTFLAGS".to_string(), "--cfg=no_std".to_string());
1063                env
1064            })
1065    }
1066
1067    /// All standard validations for a target.
1068    pub fn all(target: TargetTriple) -> Vec<ValidationPlan> {
1069        vec![
1070            Self::library(target.clone()),
1071            Self::binary(target.clone()),
1072            Self::mixed(target.clone()),
1073        ]
1074    }
1075}
1076
1077/// Wrapper command and its target invocation.
1078#[derive(Debug, Clone, PartialEq, Eq)]
1079pub struct WrapperPlan {
1080    pub wrapper: String,
1081    pub target: String,
1082}
1083
1084impl WrapperPlan {
1085    pub fn new(wrapper: impl Into<String>, target: impl Into<String>) -> Self {
1086        Self {
1087            wrapper: wrapper.into(),
1088            target: target.into(),
1089        }
1090    }
1091
1092    pub fn invocation(&self) -> String {
1093        format!("{} {}", self.wrapper, self.target)
1094    }
1095}
1096
1097/// Cache policy for cross-build artifacts and downloaded assets.
1098#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
1099pub struct CachePolicy {
1100    pub root: PathBuf,
1101    pub max_size_bytes: Option<u64>,
1102    pub max_age: Option<Duration>,
1103    pub compress: bool,
1104}
1105
1106impl Default for CachePolicy {
1107    fn default() -> Self {
1108        Self {
1109            root: PathBuf::from("target").join("crossbuild-cache"),
1110            max_size_bytes: Some(10 * 1024 * 1024 * 1024), // 10 GB
1111            max_age: Some(Duration::from_secs(30 * 24 * 60 * 60)), // 30 days
1112            compress: true,
1113        }
1114    }
1115}
1116
1117impl CachePolicy {
1118    pub fn new(root: impl Into<PathBuf>) -> Self {
1119        Self {
1120            root: root.into(),
1121            ..Default::default()
1122        }
1123    }
1124
1125    pub fn with_max_size(mut self, bytes: u64) -> Self {
1126        self.max_size_bytes = Some(bytes);
1127        self
1128    }
1129
1130    pub fn with_max_age(mut self, age: Duration) -> Self {
1131        self.max_age = Some(age);
1132        self
1133    }
1134
1135    pub fn with_compression(mut self, compress: bool) -> Self {
1136        self.compress = compress;
1137        self
1138    }
1139
1140    pub fn absolute_root(&self, workspace_root: &Path) -> PathBuf {
1141        if self.root.is_absolute() {
1142            self.root.clone()
1143        } else {
1144            workspace_root.join(&self.root)
1145        }
1146    }
1147
1148    pub fn cache_key(&self, workspace_root: &Path, target: &TargetTriple) -> String {
1149        let workspace_label = workspace_root
1150            .to_string_lossy()
1151            .replace(['\\', '/', ':'], "_");
1152        format!("{}::{}", workspace_label, target.triple)
1153    }
1154
1155    pub fn download_dir(&self, workspace_root: &Path) -> PathBuf {
1156        self.absolute_root(workspace_root).join("downloads")
1157    }
1158
1159    pub fn sysroot_dir(&self, workspace_root: &Path) -> PathBuf {
1160        self.absolute_root(workspace_root).join("sysroots")
1161    }
1162
1163    pub fn toolchain_dir(&self, workspace_root: &Path) -> PathBuf {
1164        self.absolute_root(workspace_root).join("toolchains")
1165    }
1166
1167    pub fn build_dir(&self, workspace_root: &Path, target: &TargetTriple) -> PathBuf {
1168        self.absolute_root(workspace_root)
1169            .join("builds")
1170            .join(&target.triple)
1171    }
1172
1173    pub fn metadata_path(&self, workspace_root: &Path) -> PathBuf {
1174        self.absolute_root(workspace_root).join("metadata.json")
1175    }
1176}
1177
1178/// Cache metadata for tracking entries.
1179#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
1180pub struct CacheMetadata {
1181    pub entries: BTreeMap<String, CacheEntry>,
1182    pub total_size_bytes: u64,
1183    pub last_cleanup: u64,
1184}
1185
1186impl Default for CacheMetadata {
1187    fn default() -> Self {
1188        Self {
1189            entries: BTreeMap::new(),
1190            total_size_bytes: 0,
1191            last_cleanup: SystemTime::now()
1192                .duration_since(UNIX_EPOCH)
1193                .unwrap_or_default()
1194                .as_secs(),
1195        }
1196    }
1197}
1198
1199/// Individual cache entry.
1200#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
1201pub struct CacheEntry {
1202    pub key: String,
1203    pub path: PathBuf,
1204    pub size_bytes: u64,
1205    pub created: u64,
1206    pub last_accessed: u64,
1207    pub access_count: u64,
1208    pub entry_type: CacheEntryType,
1209    pub metadata: BTreeMap<String, String>,
1210}
1211
1212#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, serde::Serialize, serde::Deserialize)]
1213pub enum CacheEntryType {
1214    Download,
1215    Sysroot,
1216    Toolchain,
1217    BuildArtifact,
1218    Other,
1219}
1220
1221/// Cache manager for handling all cache operations.
1222pub struct CacheManager {
1223    policy: CachePolicy,
1224    workspace_root: PathBuf,
1225    metadata: CacheMetadata,
1226}
1227
1228impl CacheManager {
1229    /// Creates a new cache manager.
1230    pub fn new(policy: CachePolicy, workspace_root: impl AsRef<Path>) -> Result<Self, CrossBuildError> {
1231        let workspace_root = workspace_root.as_ref().to_path_buf();
1232        let root = policy.absolute_root(&workspace_root);
1233        fs::create_dir_all(&root).map_err(|source| CrossBuildError::Io {
1234            path: Some(root),
1235            source,
1236        })?;
1237
1238        fs::create_dir_all(policy.download_dir(&workspace_root)).map_err(|source| CrossBuildError::Io {
1239            path: Some(policy.download_dir(&workspace_root)),
1240            source,
1241        })?;
1242        fs::create_dir_all(policy.sysroot_dir(&workspace_root)).map_err(|source| CrossBuildError::Io {
1243            path: Some(policy.sysroot_dir(&workspace_root)),
1244            source,
1245        })?;
1246        fs::create_dir_all(policy.toolchain_dir(&workspace_root)).map_err(|source| CrossBuildError::Io {
1247            path: Some(policy.toolchain_dir(&workspace_root)),
1248            source,
1249        })?;
1250
1251        let metadata_path = policy.metadata_path(&workspace_root);
1252        let metadata = if metadata_path.exists() {
1253            let content = fs::read_to_string(&metadata_path).map_err(|source| CrossBuildError::Io {
1254                path: Some(metadata_path),
1255                source,
1256            })?;
1257            serde_json::from_str(&content).unwrap_or_default()
1258        } else {
1259            CacheMetadata::default()
1260        };
1261
1262        Ok(Self {
1263            policy,
1264            workspace_root,
1265            metadata,
1266        })
1267    }
1268
1269    /// Gets the path for a cached download.
1270    pub fn get_download(&self, url: &str, checksum: Option<&str>) -> Option<PathBuf> {
1271        let key = self.download_key(url, checksum);
1272        self.metadata.entries.get(&key).and_then(|entry| {
1273            if entry.path.exists() {
1274                Some(entry.path.clone())
1275            } else {
1276                None
1277            }
1278        })
1279    }
1280
1281    /// Stores a downloaded file in the cache.
1282    pub fn store_download(
1283        &mut self,
1284        url: &str,
1285        checksum: Option<&str>,
1286        source_path: &Path,
1287    ) -> Result<PathBuf, CrossBuildError> {
1288        let key = self.download_key(url, checksum);
1289        let dest = self.policy.download_dir(&self.workspace_root).join(&key);
1290
1291        fs::copy(source_path, &dest).map_err(|source| CrossBuildError::Io {
1292            path: Some(dest.clone()),
1293            source,
1294        })?;
1295
1296        let size = fs::metadata(&dest).map(|m| m.len()).unwrap_or(0);
1297        let now = current_timestamp();
1298
1299        self.metadata.entries.insert(
1300            key.clone(),
1301            CacheEntry {
1302                key: key.clone(),
1303                path: dest.clone(),
1304                size_bytes: size,
1305                created: now,
1306                last_accessed: now,
1307                access_count: 1,
1308                entry_type: CacheEntryType::Download,
1309                metadata: {
1310                    let mut m = BTreeMap::new();
1311                    m.insert("url".to_string(), url.to_string());
1312                    if let Some(cs) = checksum {
1313                        m.insert("checksum".to_string(), cs.to_string());
1314                    }
1315                    m
1316                },
1317            },
1318        );
1319        self.metadata.total_size_bytes += size;
1320        self.save_metadata()?;
1321
1322        Ok(dest)
1323    }
1324
1325    /// Gets or creates a sysroot cache entry.
1326    pub fn get_sysroot(&self, target: &TargetTriple, provider: &str) -> Option<PathBuf> {
1327        let key = self.sysroot_key(target, provider);
1328        self.metadata.entries.get(&key).and_then(|entry| {
1329            if entry.path.exists() {
1330                Some(entry.path.clone())
1331            } else {
1332                None
1333            }
1334        })
1335    }
1336
1337    /// Stores a sysroot in the cache.
1338    pub fn store_sysroot(
1339        &mut self,
1340        target: &TargetTriple,
1341        provider: &str,
1342        source_path: &Path,
1343    ) -> Result<PathBuf, CrossBuildError> {
1344        let key = self.sysroot_key(target, provider);
1345        let dest = self.policy.sysroot_dir(&self.workspace_root).join(&key);
1346
1347        if source_path.is_dir() {
1348            copy_dir(source_path, &dest)?;
1349        } else {
1350            fs::copy(source_path, &dest).map_err(|source| CrossBuildError::Io {
1351                path: Some(dest.clone()),
1352                source,
1353            })?;
1354        }
1355
1356        let size = dir_size(&dest).unwrap_or(0);
1357        let now = current_timestamp();
1358
1359        self.metadata.entries.insert(
1360            key.clone(),
1361            CacheEntry {
1362                key: key.clone(),
1363                path: dest.clone(),
1364                size_bytes: size,
1365                created: now,
1366                last_accessed: now,
1367                access_count: 1,
1368                entry_type: CacheEntryType::Sysroot,
1369                metadata: {
1370                    let mut m = BTreeMap::new();
1371                    m.insert("target".to_string(), target.triple.clone());
1372                    m.insert("provider".to_string(), provider.to_string());
1373                    m
1374                },
1375            },
1376        );
1377        self.metadata.total_size_bytes += size;
1378        self.save_metadata()?;
1379
1380        Ok(dest)
1381    }
1382
1383    /// Gets the build directory for a target.
1384    pub fn build_dir(&self, target: &TargetTriple) -> PathBuf {
1385        self.policy.build_dir(&self.workspace_root, target)
1386    }
1387
1388    /// Cleans up old or excess cache entries.
1389    pub fn cleanup(&mut self) -> Result<CleanupReport, CrossBuildError> {
1390        let mut report = CleanupReport::default();
1391        let now = current_timestamp();
1392
1393        // Remove expired entries
1394        if let Some(max_age) = self.policy.max_age {
1395            let cutoff = now - max_age.as_secs();
1396            let expired: Vec<_> = self.metadata.entries
1397                .iter()
1398                .filter(|(_, entry)| entry.last_accessed < cutoff)
1399                .map(|(k, _)| k.clone())
1400                .collect();
1401
1402            for key in expired {
1403                if let Some(entry) = self.metadata.entries.remove(&key) {
1404                    if entry.path.exists() {
1405                        remove_entry(&entry.path)?;
1406                    }
1407                    report.removed_entries += 1;
1408                    report.freed_bytes += entry.size_bytes;
1409                    self.metadata.total_size_bytes = self.metadata.total_size_bytes.saturating_sub(entry.size_bytes);
1410                }
1411            }
1412        }
1413
1414        // Enforce size limit
1415        if let Some(max_size) = self.policy.max_size_bytes {
1416            if self.metadata.total_size_bytes > max_size {
1417                // Sort by last accessed (LRU)
1418                let mut entries: Vec<_> = self.metadata.entries
1419                    .iter()
1420                    .map(|(k, v)| (k.clone(), v.last_accessed, v.size_bytes, v.path.clone()))
1421                    .collect();
1422                entries.sort_by_key(|(_, last_accessed, _, _)| *last_accessed);
1423
1424                for (key, _, _size, path) in entries {
1425                    if self.metadata.total_size_bytes <= max_size {
1426                        break;
1427                    }
1428                    if let Some(entry) = self.metadata.entries.remove(&key) {
1429                        if path.exists() {
1430                            remove_entry(&path)?;
1431                        }
1432                        report.removed_entries += 1;
1433                        report.freed_bytes += entry.size_bytes;
1434                        self.metadata.total_size_bytes = self.metadata.total_size_bytes.saturating_sub(entry.size_bytes);
1435                    }
1436                }
1437            }
1438        }
1439
1440        self.metadata.last_cleanup = now;
1441        self.save_metadata()?;
1442
1443        Ok(report)
1444    }
1445
1446    fn download_key(&self, url: &str, checksum: Option<&str>) -> String {
1447        use sha2::{Digest, Sha256};
1448        let mut hasher = Sha256::new();
1449        hasher.update(url.as_bytes());
1450        if let Some(cs) = checksum {
1451            hasher.update(cs.as_bytes());
1452        }
1453        hex::encode(hasher.finalize())[..16].to_string()
1454    }
1455
1456    fn sysroot_key(&self, target: &TargetTriple, provider: &str) -> String {
1457        use sha2::{Digest, Sha256};
1458        let mut hasher = Sha256::new();
1459        hasher.update(target.triple.as_bytes());
1460        hasher.update(provider.as_bytes());
1461        format!("sysroot-{}", &hex::encode(hasher.finalize())[..16])
1462    }
1463
1464    /// Returns a reference to the cache policy.
1465    pub fn policy(&self) -> &CachePolicy {
1466        &self.policy
1467    }
1468
1469    /// Returns current cache statistics.
1470    pub fn stats(&self) -> CacheStats {
1471        let mut by_type = BTreeMap::new();
1472        for entry in self.metadata.entries.values() {
1473            *by_type.entry(entry.entry_type).or_insert(0) += 1;
1474        }
1475        CacheStats {
1476            total_entries: self.metadata.entries.len(),
1477            total_size_bytes: self.metadata.total_size_bytes,
1478            by_type,
1479            root: self.policy.absolute_root(&self.workspace_root),
1480        }
1481    }
1482
1483    fn save_metadata(&self) -> Result<(), CrossBuildError> {
1484        let path = self.policy.metadata_path(&self.workspace_root);
1485        let content = serde_json::to_string_pretty(&self.metadata)
1486            .map_err(|e| CrossBuildError::configuration(e.to_string()))?;
1487        fs::write(&path, content).map_err(|source| CrossBuildError::Io {
1488            path: Some(path),
1489            source,
1490        })
1491    }
1492}
1493
1494/// Cache statistics.
1495#[derive(Debug, Clone)]
1496pub struct CacheStats {
1497    pub total_entries: usize,
1498    pub total_size_bytes: u64,
1499    pub by_type: BTreeMap<CacheEntryType, usize>,
1500    pub root: PathBuf,
1501}
1502
1503/// Cleanup report.
1504#[derive(Debug, Default, Clone)]
1505pub struct CleanupReport {
1506    pub removed_entries: usize,
1507    pub freed_bytes: u64,
1508}
1509
1510fn current_timestamp() -> u64 {
1511    SystemTime::now()
1512        .duration_since(UNIX_EPOCH)
1513        .unwrap_or_default()
1514        .as_secs()
1515}
1516
1517fn copy_dir(src: &Path, dest: &Path) -> Result<(), CrossBuildError> {
1518    fs::create_dir_all(dest).map_err(|source| CrossBuildError::Io {
1519        path: Some(dest.to_path_buf()),
1520        source,
1521    })?;
1522
1523    for entry in fs::read_dir(src).map_err(|source| CrossBuildError::Io {
1524        path: Some(src.to_path_buf()),
1525        source,
1526    })? {
1527        let entry = entry.map_err(|source| CrossBuildError::Io {
1528            path: Some(src.to_path_buf()),
1529            source,
1530        })?;
1531        let src_path = entry.path();
1532        let dest_path = dest.join(entry.file_name());
1533
1534        if src_path.is_dir() {
1535            copy_dir(&src_path, &dest_path)?;
1536        } else {
1537            fs::copy(&src_path, &dest_path).map_err(|source| CrossBuildError::Io {
1538                path: Some(dest_path),
1539                source,
1540            })?;
1541        }
1542    }
1543    Ok(())
1544}
1545
1546fn dir_size(path: &Path) -> Result<u64, CrossBuildError> {
1547    let mut size = 0;
1548    for entry in fs::read_dir(path).map_err(|source| CrossBuildError::Io {
1549        path: Some(path.to_path_buf()),
1550        source,
1551    })? {
1552        let entry = entry.map_err(|source| CrossBuildError::Io {
1553            path: Some(path.to_path_buf()),
1554            source,
1555        })?;
1556        let path = entry.path();
1557        if path.is_dir() {
1558            size += dir_size(&path)?;
1559        } else {
1560            size += fs::metadata(&path).map(|m| m.len()).unwrap_or(0);
1561        }
1562    }
1563    Ok(size)
1564}
1565
1566fn remove_entry(path: &Path) -> Result<(), CrossBuildError> {
1567    if path.is_dir() {
1568        fs::remove_dir_all(path).map_err(|source| CrossBuildError::Io {
1569            path: Some(path.to_path_buf()),
1570            source,
1571        })
1572    } else {
1573        fs::remove_file(path).map_err(|source| CrossBuildError::Io {
1574            path: Some(path.to_path_buf()),
1575            source,
1576        })
1577    }
1578}
1579
1580#[cfg(test)]
1581mod tests {
1582    use super::*;
1583    use crate::model::TargetTriple;
1584    use tempfile::tempdir;
1585
1586    #[test]
1587    fn cache_policy_default() {
1588        let policy = CachePolicy::default();
1589        assert_eq!(policy.root, PathBuf::from("target").join("crossbuild-cache"));
1590        assert_eq!(policy.max_size_bytes, Some(10 * 1024 * 1024 * 1024));
1591    }
1592
1593    #[test]
1594    fn cache_key_generation() {
1595        let policy = CachePolicy::default();
1596        let workspace = PathBuf::from("/home/user/project");
1597        let target = TargetTriple::parse("x86_64-unknown-linux-gnu").unwrap();
1598
1599        let key = policy.cache_key(&workspace, &target);
1600        assert!(key.contains("home_user_project"));
1601        assert!(key.contains("x86_64-unknown-linux-gnu"));
1602    }
1603
1604    #[test]
1605    fn cache_manager_creation() {
1606        let dir = tempdir().unwrap();
1607        let policy = CachePolicy::new(dir.path().join("cache"));
1608        let manager = CacheManager::new(policy, dir.path()).unwrap();
1609
1610        let stats = manager.stats();
1611        assert_eq!(stats.total_entries, 0);
1612        assert_eq!(stats.total_size_bytes, 0);
1613    }
1614
1615    #[test]
1616    fn download_caching() {
1617        let dir = tempdir().unwrap();
1618        let policy = CachePolicy::new(dir.path().join("cache"));
1619        let mut manager = CacheManager::new(policy, dir.path()).unwrap();
1620
1621        // Create a test file
1622        let source = dir.path().join("test-download");
1623        fs::write(&source, b"test content").unwrap();
1624
1625        // Store in cache
1626        let cached = manager.store_download(
1627            "https://example.com/file",
1628            Some("sha256:abc123"),
1629            &source,
1630        ).unwrap();
1631
1632        assert!(cached.exists());
1633
1634        // Retrieve from cache
1635        let retrieved = manager.get_download("https://example.com/file", Some("sha256:abc123"));
1636        assert_eq!(retrieved, Some(cached));
1637
1638        // Different checksum should not match
1639        let retrieved2 = manager.get_download("https://example.com/file", Some("sha256:different"));
1640        assert_eq!(retrieved2, None);
1641    }
1642
1643    #[test]
1644    fn sysroot_caching() {
1645        let dir = tempdir().unwrap();
1646        let policy = CachePolicy::new(dir.path().join("cache"));
1647        let mut manager = CacheManager::new(policy, dir.path()).unwrap();
1648
1649        let target = TargetTriple::parse("x86_64-unknown-linux-gnu").unwrap();
1650
1651        // Create a test sysroot
1652        let sysroot = dir.path().join("sysroot");
1653        fs::create_dir_all(sysroot.join("lib")).unwrap();
1654        fs::write(sysroot.join("lib").join("libc.so"), b"fake").unwrap();
1655
1656        // Store in cache
1657        let cached = manager.store_sysroot(&target, "rustup", &sysroot).unwrap();
1658
1659        assert!(cached.exists());
1660        assert!(cached.join("lib").join("libc.so").exists());
1661
1662        // Retrieve from cache
1663        let retrieved = manager.get_sysroot(&target, "rustup");
1664        assert_eq!(retrieved, Some(cached));
1665    }
1666
1667    #[test]
1668    fn cleanup_removes_old_entries() {
1669        let dir = tempdir().unwrap();
1670        let policy = CachePolicy::new(dir.path().join("cache"))
1671            .with_max_age(Duration::from_secs(60)); // 1 minute
1672        let mut manager = CacheManager::new(policy, dir.path()).unwrap();
1673
1674        let target = TargetTriple::parse("x86_64-unknown-linux-gnu").unwrap();
1675
1676        // Create entries with old timestamps
1677        let sysroot = dir.path().join("sysroot");
1678        fs::create_dir_all(sysroot.join("lib")).unwrap();
1679        fs::write(sysroot.join("lib").join("libc.so"), b"fake").unwrap();
1680
1681        let cached = manager.store_sysroot(&target, "rustup", &sysroot).unwrap();
1682
1683        // Manually set old timestamp
1684        if let Some(entry) = manager.metadata.entries.get_mut(&manager.sysroot_key(&target, "rustup")) {
1685            entry.last_accessed = current_timestamp() - 120; // 2 minutes ago
1686        }
1687        manager.save_metadata().unwrap();
1688
1689        // Run cleanup
1690        let report = manager.cleanup().unwrap();
1691        assert_eq!(report.removed_entries, 1);
1692        assert!(!cached.exists());
1693    }
1694}