Skip to main content

zccache_artifact/
rust_plan.rs

1//! Plan-driven Rust target artifact save/restore.
2
3use std::collections::{BTreeMap, BTreeSet};
4use std::ffi::OsStr;
5use std::path::{Component, Path};
6use std::time::{SystemTime, UNIX_EPOCH};
7
8use rayon::prelude::*;
9use serde::ser::SerializeStruct;
10use serde::{Deserialize, Serialize};
11use zccache_core::{normalize_for_key, NormalizedPath};
12
13/// Upper bound on save-time worker threads. Beyond this Windows filter-driver
14/// serialization dominates and extra threads stop helping (see issue #177 and
15/// the linked soldr#272 analysis).
16const DEFAULT_RUST_PLAN_TAR_THREADS_CAP: usize = 8;
17/// Hard upper bound regardless of caller request — protects small runners from
18/// per-thread buffer blowup if someone passes a huge value.
19const MAX_RUST_PLAN_TAR_THREADS: usize = 64;
20
21/// Supported Rust artifact plan schema version.
22pub const RUST_ARTIFACT_PLAN_SCHEMA_VERSION: u32 = 1;
23/// Supported cache bundle schema version.
24pub const RUST_ARTIFACT_CACHE_SCHEMA_VERSION: u32 = 1;
25
26const BUNDLE_MANIFEST_NAME: &str = "manifest.json";
27const BUNDLE_FILES_DIR: &str = "files";
28
29/// Errors returned by plan loading and execution.
30#[derive(Debug, thiserror::Error)]
31pub enum RustPlanError {
32    #[error("I/O error: {0}")]
33    Io(#[from] std::io::Error),
34    #[error("JSON error: {0}")]
35    Json(#[from] serde_json::Error),
36    #[error(
37        "unsupported Rust artifact plan schema version {found}; supported version is {supported}"
38    )]
39    UnsupportedSchemaVersion { found: u32, supported: u32 },
40    #[error(
41        "unsupported Rust artifact cache schema version {found}; supported version is {supported}"
42    )]
43    UnsupportedCacheSchemaVersion { found: u32, supported: u32 },
44    #[error("invalid Rust artifact plan: {0}")]
45    InvalidPlan(String),
46    #[error("Rust artifact bundle is missing: {0}")]
47    BundleMissing(NormalizedPath),
48    #[error("invalid Rust artifact bundle manifest: {0}")]
49    InvalidManifest(String),
50    #[error("unsafe relative artifact path in bundle: {0}")]
51    UnsafeRelativePath(String),
52}
53
54/// Rust artifact plan mode.
55#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
56#[serde(rename_all = "snake_case")]
57pub enum RustPlanMode {
58    /// Restore/save bounded dependency artifacts and Cargo freshness metadata.
59    Thin,
60    /// Restore/save the target tree explicitly, except transient state.
61    Full,
62}
63
64impl std::fmt::Display for RustPlanMode {
65    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
66        match self {
67            Self::Thin => write!(f, "thin"),
68            Self::Full => write!(f, "full"),
69        }
70    }
71}
72
73/// Artifact classes that a plan may allow.
74#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
75#[serde(rename_all = "snake_case")]
76pub enum RustArtifactClass {
77    Rlib,
78    Rmeta,
79    DepInfo,
80    ProcMacro,
81    SharedLib,
82    CargoFingerprint,
83    BuildScriptMetadata,
84    BuildScriptOutput,
85    FullTarget,
86}
87
88/// Toolchain identity supplied by soldr.
89#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
90#[serde(deny_unknown_fields)]
91pub struct RustToolchainIdentity {
92    pub rustc: String,
93    pub cargo: String,
94    pub channel: String,
95    pub host: String,
96}
97
98/// Input hashes that affect Cargo build outputs.
99#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
100#[serde(deny_unknown_fields)]
101pub struct RustPlanInputs {
102    pub features_hash: String,
103    pub rustflags_hash: String,
104    pub env_hash: String,
105    pub lockfile_hash: String,
106    pub cargo_config_hash: String,
107    #[serde(default)]
108    pub manifest_hashes: Vec<String>,
109}
110
111/// Package IDs selected or excluded by the planner.
112#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
113#[serde(deny_unknown_fields)]
114pub struct RustPlanPackages {
115    #[serde(default)]
116    pub selected_package_ids: Vec<String>,
117    #[serde(default)]
118    pub workspace_package_ids: Vec<String>,
119    #[serde(default)]
120    pub excluded_path_package_ids: Vec<String>,
121}
122
123/// Versioned v1 Rust artifact cache plan.
124#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
125#[serde(deny_unknown_fields)]
126pub struct RustArtifactPlanV1 {
127    pub schema_version: u32,
128    pub mode: RustPlanMode,
129    pub workspace_root: NormalizedPath,
130    pub target_dir: NormalizedPath,
131    pub toolchain: RustToolchainIdentity,
132    pub target_triple: String,
133    pub profile: String,
134    pub inputs: RustPlanInputs,
135    pub packages: RustPlanPackages,
136    #[serde(default)]
137    pub allowed_artifact_classes: Vec<RustArtifactClass>,
138    pub cache_schema_version: u32,
139    #[serde(default)]
140    pub journal_log_path: Option<NormalizedPath>,
141}
142
143impl RustArtifactPlanV1 {
144    /// Load, version-check, and validate a plan from a JSON file.
145    pub fn load(path: &Path) -> Result<Self, RustPlanError> {
146        let raw = std::fs::read_to_string(path)?;
147        Self::from_json_str(&raw)
148    }
149
150    /// Load, version-check, and validate a plan from a JSON string.
151    pub fn from_json_str(raw: &str) -> Result<Self, RustPlanError> {
152        let value: serde_json::Value = serde_json::from_str(raw.trim_start_matches('\u{feff}'))?;
153        Self::from_json_value(value)
154    }
155
156    /// Load, version-check, and validate a plan from a JSON value.
157    pub fn from_json_value(value: serde_json::Value) -> Result<Self, RustPlanError> {
158        let schema_version = json_u32_field(&value, "schema_version")?;
159        if schema_version != RUST_ARTIFACT_PLAN_SCHEMA_VERSION {
160            return Err(RustPlanError::UnsupportedSchemaVersion {
161                found: schema_version,
162                supported: RUST_ARTIFACT_PLAN_SCHEMA_VERSION,
163            });
164        }
165
166        let cache_schema_version = json_u32_field(&value, "cache_schema_version")?;
167        if cache_schema_version != RUST_ARTIFACT_CACHE_SCHEMA_VERSION {
168            return Err(RustPlanError::UnsupportedCacheSchemaVersion {
169                found: cache_schema_version,
170                supported: RUST_ARTIFACT_CACHE_SCHEMA_VERSION,
171            });
172        }
173
174        let plan: Self = serde_json::from_value(value)?;
175        plan.validate()?;
176        Ok(plan)
177    }
178
179    /// Validate fields whose constraints are outside serde's type checks.
180    pub fn validate(&self) -> Result<(), RustPlanError> {
181        let mut errors = Vec::new();
182        if self.profile.trim().is_empty() {
183            errors.push("profile must not be empty");
184        }
185        if self.target_triple.trim().is_empty() {
186            errors.push("target_triple must not be empty");
187        }
188        if self.toolchain.rustc.trim().is_empty() {
189            errors.push("toolchain.rustc must not be empty");
190        }
191        if self.toolchain.cargo.trim().is_empty() {
192            errors.push("toolchain.cargo must not be empty");
193        }
194        if self.toolchain.channel.trim().is_empty() {
195            errors.push("toolchain.channel must not be empty");
196        }
197        if self.toolchain.host.trim().is_empty() {
198            errors.push("toolchain.host must not be empty");
199        }
200        if self.workspace_root.as_os_str().is_empty() {
201            errors.push("workspace_root must not be empty");
202        }
203        if self.target_dir.as_os_str().is_empty() {
204            errors.push("target_dir must not be empty");
205        }
206        if errors.is_empty() {
207            Ok(())
208        } else {
209            Err(RustPlanError::InvalidPlan(errors.join("; ")))
210        }
211    }
212
213    /// Effective allowed classes for thin mode.
214    #[must_use]
215    pub fn effective_allowed_classes(&self) -> BTreeSet<RustArtifactClass> {
216        if self.allowed_artifact_classes.is_empty() {
217            default_thin_classes()
218        } else {
219            self.allowed_artifact_classes.iter().copied().collect()
220        }
221    }
222}
223
224/// Backend operation represented in summaries.
225#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
226#[serde(rename_all = "snake_case")]
227pub enum RustPlanOperation {
228    Validate,
229    Restore,
230    Save,
231}
232
233/// Compatibility section in a machine-readable summary.
234#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
235pub struct RustPlanCompatibility {
236    pub status: String,
237    #[serde(default)]
238    pub errors: Vec<String>,
239}
240
241/// Representative skipped artifact.
242#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
243pub struct RustPlanSkippedSample {
244    pub path: String,
245    pub reason: String,
246}
247
248/// Artifact restore effectiveness, independent from compile-cache hit rate.
249#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
250pub struct RustPlanArtifactEffectiveness {
251    pub eligible_file_count: u64,
252    pub restored_file_count: u64,
253    pub reuse_ratio: f64,
254}
255
256/// Machine-readable operation summary for soldr/setup-soldr.
257#[derive(Debug, Clone, PartialEq, Deserialize)]
258pub struct RustPlanSummary {
259    pub operation: RustPlanOperation,
260    pub mode: RustPlanMode,
261    pub plan_schema_version: u32,
262    pub cache_schema_version: u32,
263    pub compatibility: RustPlanCompatibility,
264    pub restored_file_count: u64,
265    pub restored_bytes: u64,
266    pub saved_file_count: u64,
267    pub saved_bytes: u64,
268    pub skipped_count: u64,
269    #[serde(default)]
270    pub skipped_reasons: BTreeMap<String, u64>,
271    #[serde(default)]
272    pub skipped_samples: Vec<RustPlanSkippedSample>,
273    #[serde(default)]
274    pub key_input_mismatches: Vec<String>,
275    #[serde(default)]
276    pub miss_classifications: BTreeMap<String, u64>,
277    pub backend: String,
278    pub cache_key: String,
279    pub backend_cache_key: Option<String>,
280    pub backend_cache_version: Option<String>,
281    pub archive_path: Option<NormalizedPath>,
282    pub journal_log_path: Option<NormalizedPath>,
283    pub target_artifact_effectiveness: RustPlanArtifactEffectiveness,
284    pub compile_cache_stats: Option<serde_json::Value>,
285}
286
287impl Serialize for RustPlanSummary {
288    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
289    where
290        S: serde::Serializer,
291    {
292        let miss_classifications = self.computed_miss_classifications();
293        let mut state = serializer.serialize_struct("RustPlanSummary", 22)?;
294        state.serialize_field("operation", &self.operation)?;
295        state.serialize_field("mode", &self.mode)?;
296        state.serialize_field("plan_schema_version", &self.plan_schema_version)?;
297        state.serialize_field("cache_schema_version", &self.cache_schema_version)?;
298        state.serialize_field("compatibility", &self.compatibility)?;
299        state.serialize_field("restored_file_count", &self.restored_file_count)?;
300        state.serialize_field("restored_bytes", &self.restored_bytes)?;
301        state.serialize_field("saved_file_count", &self.saved_file_count)?;
302        state.serialize_field("saved_bytes", &self.saved_bytes)?;
303        state.serialize_field("skipped_count", &self.skipped_count)?;
304        state.serialize_field("skipped_reasons", &self.skipped_reasons)?;
305        state.serialize_field("skipped_samples", &self.skipped_samples)?;
306        state.serialize_field("key_input_mismatches", &self.key_input_mismatches)?;
307        state.serialize_field("miss_classifications", &miss_classifications)?;
308        state.serialize_field("backend", &self.backend)?;
309        state.serialize_field("cache_key", &self.cache_key)?;
310        state.serialize_field("backend_cache_key", &self.backend_cache_key)?;
311        state.serialize_field("backend_cache_version", &self.backend_cache_version)?;
312        state.serialize_field("archive_path", &self.archive_path)?;
313        state.serialize_field("journal_log_path", &self.journal_log_path)?;
314        state.serialize_field(
315            "target_artifact_effectiveness",
316            &self.target_artifact_effectiveness,
317        )?;
318        state.serialize_field("compile_cache_stats", &self.compile_cache_stats)?;
319        state.end()
320    }
321}
322
323impl RustPlanSummary {
324    /// Create a validation-only success summary.
325    #[must_use]
326    pub fn validation_success(plan: &RustArtifactPlanV1, cache_dir: &Path) -> Self {
327        let cache_key = rust_plan_cache_key(plan);
328        let archive_path = rust_plan_bundle_dir(cache_dir, &cache_key);
329        Self::new(
330            RustPlanOperation::Validate,
331            plan.mode,
332            plan.schema_version,
333            plan.cache_schema_version,
334            cache_key,
335            Some(archive_path),
336            plan.journal_log_path.clone(),
337        )
338    }
339
340    /// Create a compatibility failure summary for JSON CLI output.
341    #[must_use]
342    pub fn compatibility_failure(operation: RustPlanOperation, err: &RustPlanError) -> Self {
343        Self {
344            operation,
345            mode: RustPlanMode::Thin,
346            plan_schema_version: 0,
347            cache_schema_version: 0,
348            compatibility: RustPlanCompatibility {
349                status: "error".to_string(),
350                errors: vec![err.to_string()],
351            },
352            restored_file_count: 0,
353            restored_bytes: 0,
354            saved_file_count: 0,
355            saved_bytes: 0,
356            skipped_count: 0,
357            skipped_reasons: BTreeMap::new(),
358            skipped_samples: Vec::new(),
359            key_input_mismatches: Vec::new(),
360            miss_classifications: BTreeMap::new(),
361            backend: "unknown".to_string(),
362            cache_key: String::new(),
363            backend_cache_key: None,
364            backend_cache_version: None,
365            archive_path: None,
366            journal_log_path: None,
367            target_artifact_effectiveness: RustPlanArtifactEffectiveness {
368                eligible_file_count: 0,
369                restored_file_count: 0,
370                reuse_ratio: 0.0,
371            },
372            compile_cache_stats: None,
373        }
374    }
375
376    fn new(
377        operation: RustPlanOperation,
378        mode: RustPlanMode,
379        plan_schema_version: u32,
380        cache_schema_version: u32,
381        cache_key: String,
382        archive_path: Option<NormalizedPath>,
383        journal_log_path: Option<NormalizedPath>,
384    ) -> Self {
385        Self {
386            operation,
387            mode,
388            plan_schema_version,
389            cache_schema_version,
390            compatibility: RustPlanCompatibility {
391                status: "ok".to_string(),
392                errors: Vec::new(),
393            },
394            restored_file_count: 0,
395            restored_bytes: 0,
396            saved_file_count: 0,
397            saved_bytes: 0,
398            skipped_count: 0,
399            skipped_reasons: BTreeMap::new(),
400            skipped_samples: Vec::new(),
401            key_input_mismatches: Vec::new(),
402            miss_classifications: BTreeMap::new(),
403            backend: "local".to_string(),
404            cache_key,
405            backend_cache_key: None,
406            backend_cache_version: None,
407            archive_path,
408            journal_log_path,
409            target_artifact_effectiveness: RustPlanArtifactEffectiveness {
410                eligible_file_count: 0,
411                restored_file_count: 0,
412                reuse_ratio: 0.0,
413            },
414            compile_cache_stats: None,
415        }
416    }
417
418    fn skip(&mut self, path: impl Into<String>, reason: &'static str) {
419        self.skipped_count += 1;
420        *self.skipped_reasons.entry(reason.to_string()).or_insert(0) += 1;
421        if self.skipped_samples.len() < 16 {
422            self.skipped_samples.push(RustPlanSkippedSample {
423                path: path.into(),
424                reason: reason.to_string(),
425            });
426        }
427        self.refresh_miss_classifications();
428    }
429
430    /// Record a skipped artifact or backend miss in an operation summary.
431    pub fn record_skip(&mut self, path: impl Into<String>, reason: &'static str) {
432        self.skip(path, reason);
433    }
434
435    /// Set the backend identity fields in an operation summary.
436    pub fn set_backend(
437        &mut self,
438        backend: impl Into<String>,
439        backend_cache_key: Option<String>,
440        backend_cache_version: Option<String>,
441    ) {
442        self.backend = backend.into();
443        self.backend_cache_key = backend_cache_key;
444        self.backend_cache_version = backend_cache_version;
445    }
446
447    fn refresh_effectiveness(&mut self, eligible: u64) {
448        self.target_artifact_effectiveness.eligible_file_count = eligible;
449        self.target_artifact_effectiveness.restored_file_count = self.restored_file_count;
450        self.target_artifact_effectiveness.reuse_ratio = if eligible == 0 {
451            0.0
452        } else {
453            self.restored_file_count as f64 / eligible as f64
454        };
455        self.refresh_miss_classifications();
456    }
457
458    /// Recompute low-reuse classifications from already-recorded diagnostics.
459    pub fn refresh_miss_classifications(&mut self) {
460        self.miss_classifications = self.computed_miss_classifications();
461    }
462
463    #[must_use]
464    pub fn computed_miss_classifications(&self) -> BTreeMap<String, u64> {
465        let mut classifications = BTreeMap::new();
466
467        for (reason, count) in &self.skipped_reasons {
468            if let Some(classification) = skip_reason_miss_classification(reason) {
469                add_miss_classification(&mut classifications, classification, *count);
470            }
471        }
472
473        for mismatch in &self.key_input_mismatches {
474            for classification in key_mismatch_classifications(mismatch) {
475                add_miss_classification(&mut classifications, classification, 1);
476            }
477        }
478
479        if let Some(stats) = &self.compile_cache_stats {
480            let misses = compile_cache_misses(stats);
481            if misses > 0 {
482                add_miss_classification(
483                    &mut classifications,
484                    "zccache_compile_cache_miss_despite_equivalent_rustc_command",
485                    misses,
486                );
487            }
488        }
489
490        classifications
491    }
492}
493
494fn add_miss_classification(
495    classifications: &mut BTreeMap<String, u64>,
496    classification: &'static str,
497    count: u64,
498) {
499    *classifications
500        .entry(classification.to_string())
501        .or_insert(0) += count;
502}
503
504fn skip_reason_miss_classification(reason: &str) -> Option<&'static str> {
505    match reason {
506        "artifact_absent_from_restored_plan" => Some("artifact_absent_from_restored_plan"),
507        "artifact_class_disallowed_by_plan" => Some("artifact_class_disallowed_by_plan"),
508        "workspace_or_path_dependency_excluded_by_plan" => {
509            Some("workspace_or_path_dependency_excluded_by_plan")
510        }
511        "restored_payload_missing_or_corrupt" => Some("restored_payload_missing_or_corrupt"),
512        "backend_cache_miss" => Some("backend_cache_miss"),
513        _ => None,
514    }
515}
516
517fn key_mismatch_classifications(mismatch: &str) -> Vec<&'static str> {
518    let lower = mismatch.to_ascii_lowercase();
519    let mut classifications = Vec::new();
520
521    if lower.contains("cache key")
522        || lower.contains("mode")
523        || lower.contains("toolchain")
524        || lower.contains("profile")
525        || lower.contains("rustflags")
526        || lower.contains("target")
527    {
528        classifications.push("toolchain_profile_rustflags_target_mismatch");
529    }
530
531    if lower.contains("cache key")
532        || lower.contains("input hash")
533        || lower.contains("lockfile")
534        || lower.contains("config")
535        || lower.contains("manifest")
536    {
537        classifications.push("lockfile_config_manifest_hash_mismatch");
538    }
539
540    classifications
541}
542
543fn compile_cache_misses(stats: &serde_json::Value) -> u64 {
544    stats
545        .get("misses")
546        .and_then(serde_json::Value::as_u64)
547        .or_else(|| {
548            stats
549                .get("cache_misses")
550                .and_then(serde_json::Value::as_u64)
551        })
552        .unwrap_or(0)
553}
554
555/// File stored in a Rust artifact bundle.
556#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
557pub struct RustBundledArtifact {
558    pub relative_path: String,
559    pub class: RustArtifactClass,
560    pub size: u64,
561    pub content_hash: String,
562}
563
564/// Manifest for zccache-owned Rust artifact bundles.
565#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
566pub struct RustArtifactBundleManifest {
567    pub manifest_schema_version: u32,
568    pub plan_schema_version: u32,
569    pub cache_schema_version: u32,
570    pub mode: RustPlanMode,
571    pub cache_key: String,
572    pub created_at_secs: u64,
573    pub plan_identity_hash: String,
574    pub artifacts: Vec<RustBundledArtifact>,
575}
576
577/// Compute the stable cache key for a plan.
578#[must_use]
579pub fn rust_plan_cache_key(plan: &RustArtifactPlanV1) -> String {
580    let identity = rust_plan_identity_hash(plan);
581    format!("rust-plan-v1-{}", &identity[..32])
582}
583
584/// Compute the stable identity hash used by manifests.
585#[must_use]
586pub fn rust_plan_identity_hash(plan: &RustArtifactPlanV1) -> String {
587    let payload = serde_json::json!({
588        "schema_version": plan.schema_version,
589        "mode": plan.mode,
590        "workspace_root": normalize_for_key(plan.workspace_root.as_path()),
591        "target_dir": normalize_for_key(plan.target_dir.as_path()),
592        "toolchain": plan.toolchain,
593        "target_triple": plan.target_triple,
594        "profile": plan.profile,
595        "inputs": plan.inputs,
596        "packages": plan.packages,
597        "allowed_artifact_classes": plan.effective_allowed_classes().into_iter().collect::<Vec<_>>(),
598        "cache_schema_version": plan.cache_schema_version,
599    });
600    let bytes = serde_json::to_vec(&payload).unwrap_or_default();
601    zccache_hash::hash_bytes(&bytes).to_hex()
602}
603
604/// Bundle directory for a plan cache key.
605#[must_use]
606pub fn rust_plan_bundle_dir(cache_dir: &Path, cache_key: &str) -> NormalizedPath {
607    NormalizedPath::new(cache_dir.join("rust-plan").join(cache_key))
608}
609
610/// Execute local bundle save for a validated plan.
611pub fn save_rust_plan_local(
612    plan: &RustArtifactPlanV1,
613    cache_dir: &Path,
614) -> Result<RustPlanSummary, RustPlanError> {
615    plan.validate()?;
616    ensure_supported_cache_schema_version(plan.cache_schema_version)?;
617    let cache_key = rust_plan_cache_key(plan);
618    let bundle_dir = rust_plan_bundle_dir(cache_dir, &cache_key);
619    let files_dir = bundle_dir.join(BUNDLE_FILES_DIR);
620    let mut summary = RustPlanSummary::new(
621        RustPlanOperation::Save,
622        plan.mode,
623        plan.schema_version,
624        plan.cache_schema_version,
625        cache_key.clone(),
626        Some(bundle_dir.clone()),
627        plan.journal_log_path.clone(),
628    );
629
630    let mut candidates = Vec::new();
631    collect_files(plan.target_dir.as_path(), &mut candidates)?;
632    candidates.sort();
633
634    let selected = select_artifacts(plan, candidates, &mut summary);
635
636    if bundle_dir.exists() {
637        std::fs::remove_dir_all(&bundle_dir)?;
638    }
639    std::fs::create_dir_all(&files_dir)?;
640
641    let artifacts = bundle_selected_artifacts(&selected, &files_dir)?;
642    summary.saved_file_count += artifacts.len() as u64;
643    summary.saved_bytes += artifacts.iter().map(|a| a.size).sum::<u64>();
644
645    let manifest = RustArtifactBundleManifest {
646        manifest_schema_version: RUST_ARTIFACT_CACHE_SCHEMA_VERSION,
647        plan_schema_version: plan.schema_version,
648        cache_schema_version: plan.cache_schema_version,
649        mode: plan.mode,
650        cache_key,
651        created_at_secs: now_secs(),
652        plan_identity_hash: rust_plan_identity_hash(plan),
653        artifacts,
654    };
655    let manifest_bytes = serde_json::to_vec_pretty(&manifest)?;
656    std::fs::write(bundle_dir.join(BUNDLE_MANIFEST_NAME), manifest_bytes)?;
657    Ok(summary)
658}
659
660/// Copy + stat + hash every selected artifact into `files_dir`, returning the
661/// manifest entries in input order (which `select_artifacts` has already sorted
662/// by `relative_path` for determinism). Reads `resolve_rust_plan_tar_threads()`
663/// for the parallelism setting — see issue #177 for the Windows-CI motivation.
664fn bundle_selected_artifacts(
665    selected: &[SelectedArtifact],
666    files_dir: &Path,
667) -> Result<Vec<RustBundledArtifact>, RustPlanError> {
668    bundle_selected_artifacts_with_threads(selected, files_dir, resolve_rust_plan_tar_threads())
669}
670
671/// Same as `bundle_selected_artifacts`, but with `threads` injected so tests
672/// can exercise the parallel path without racing on process-global env vars.
673fn bundle_selected_artifacts_with_threads(
674    selected: &[SelectedArtifact],
675    files_dir: &Path,
676    threads: usize,
677) -> Result<Vec<RustBundledArtifact>, RustPlanError> {
678    if threads <= 1 || selected.len() < 2 {
679        return selected
680            .iter()
681            .map(|sel| bundle_one_artifact(sel, files_dir))
682            .collect();
683    }
684
685    let pool = rayon::ThreadPoolBuilder::new()
686        .num_threads(threads)
687        .thread_name(|idx| format!("zccache-rust-plan-{idx}"))
688        .build()
689        .map_err(|err| {
690            RustPlanError::Io(std::io::Error::other(format!(
691                "failed to build rust-plan thread pool: {err}"
692            )))
693        })?;
694
695    pool.install(|| {
696        selected
697            .par_iter()
698            .map(|sel| bundle_one_artifact(sel, files_dir))
699            .collect()
700    })
701}
702
703fn bundle_one_artifact(
704    sel: &SelectedArtifact,
705    files_dir: &Path,
706) -> Result<RustBundledArtifact, RustPlanError> {
707    let dst = safe_join(files_dir, &sel.relative_path)?;
708    if let Some(parent) = dst.parent() {
709        std::fs::create_dir_all(parent)?;
710    }
711    std::fs::copy(&sel.source_path, &dst)?;
712    let size = std::fs::metadata(&sel.source_path)?.len();
713    let content_hash = zccache_hash::hash_file(&sel.source_path)?.to_hex();
714    Ok(RustBundledArtifact {
715        relative_path: sel.relative_path.clone(),
716        class: sel.class,
717        size,
718        content_hash,
719    })
720}
721
722/// Decide how many worker threads to use for the rust-plan save copy+hash loop.
723///
724/// Grammar (mirrors `SOLDR_TARGET_CACHE_TAR_THREADS` validated upstream by
725/// soldr#273):
726/// - unset / `auto` / empty / unparseable → vCPU-bounded, capped at 8
727/// - `1` → sequential (regression escape hatch)
728/// - positive integer N → `min(N, MAX_RUST_PLAN_TAR_THREADS)`
729///
730/// `ZCCACHE_RUST_PLAN_TAR_THREADS` takes precedence over the soldr-side var so
731/// direct zccache invocations can override without touching the soldr env.
732pub fn resolve_rust_plan_tar_threads() -> usize {
733    let raw = std::env::var("ZCCACHE_RUST_PLAN_TAR_THREADS")
734        .ok()
735        .or_else(|| std::env::var("SOLDR_TARGET_CACHE_TAR_THREADS").ok());
736    parse_rust_plan_tar_threads(raw.as_deref())
737}
738
739fn parse_rust_plan_tar_threads(raw: Option<&str>) -> usize {
740    let trimmed = raw.map(str::trim).filter(|s| !s.is_empty());
741    match trimmed {
742        None => default_rust_plan_tar_threads(),
743        Some(s) if s.eq_ignore_ascii_case("auto") => default_rust_plan_tar_threads(),
744        Some(s) => match s.parse::<usize>() {
745            Ok(0) => default_rust_plan_tar_threads(),
746            Ok(n) => n.min(MAX_RUST_PLAN_TAR_THREADS),
747            Err(_) => default_rust_plan_tar_threads(),
748        },
749    }
750}
751
752fn default_rust_plan_tar_threads() -> usize {
753    std::thread::available_parallelism()
754        .map(std::num::NonZeroUsize::get)
755        .unwrap_or(1)
756        .min(DEFAULT_RUST_PLAN_TAR_THREADS_CAP)
757}
758
759/// Execute local bundle restore for a validated plan.
760pub fn restore_rust_plan_local(
761    plan: &RustArtifactPlanV1,
762    cache_dir: &Path,
763) -> Result<RustPlanSummary, RustPlanError> {
764    plan.validate()?;
765    ensure_supported_cache_schema_version(plan.cache_schema_version)?;
766    let cache_key = rust_plan_cache_key(plan);
767    let bundle_dir = rust_plan_bundle_dir(cache_dir, &cache_key);
768    let files_dir = bundle_dir.join(BUNDLE_FILES_DIR);
769    let mut summary = RustPlanSummary::new(
770        RustPlanOperation::Restore,
771        plan.mode,
772        plan.schema_version,
773        plan.cache_schema_version,
774        cache_key.clone(),
775        Some(bundle_dir.clone()),
776        plan.journal_log_path.clone(),
777    );
778
779    if !bundle_dir.exists() {
780        summary.skip("<bundle>", "artifact_absent_from_restored_plan");
781        summary.refresh_effectiveness(0);
782        return Ok(summary);
783    }
784
785    let manifest_path = bundle_dir.join(BUNDLE_MANIFEST_NAME);
786    let manifest: RustArtifactBundleManifest =
787        serde_json::from_slice(&std::fs::read(&manifest_path)?)?;
788    if !validate_manifest(plan, &cache_key, &manifest, &mut summary)? {
789        summary.refresh_effectiveness(0);
790        return Ok(summary);
791    }
792
793    let now = SystemTime::now();
794    let file_times = std::fs::FileTimes::new()
795        .set_accessed(now)
796        .set_modified(now);
797    let eligible = manifest.artifacts.len() as u64;
798
799    for artifact in &manifest.artifacts {
800        let src = match safe_join(&files_dir, &artifact.relative_path) {
801            Ok(path) => path,
802            Err(err) => {
803                summary.skip(&artifact.relative_path, "path_traversal");
804                summary.compatibility.errors.push(err.to_string());
805                continue;
806            }
807        };
808        let dst = match safe_join(plan.target_dir.as_path(), &artifact.relative_path) {
809            Ok(path) => path,
810            Err(err) => {
811                summary.skip(&artifact.relative_path, "path_traversal");
812                summary.compatibility.errors.push(err.to_string());
813                continue;
814            }
815        };
816        let Ok(metadata) = std::fs::metadata(&src) else {
817            summary.skip(
818                &artifact.relative_path,
819                "restored_payload_missing_or_corrupt",
820            );
821            continue;
822        };
823        if metadata.len() != artifact.size {
824            summary.skip(
825                &artifact.relative_path,
826                "restored_payload_missing_or_corrupt",
827            );
828            continue;
829        }
830        let Ok(content_hash) = zccache_hash::hash_file(&src).map(|hash| hash.to_hex()) else {
831            summary.skip(
832                &artifact.relative_path,
833                "restored_payload_missing_or_corrupt",
834            );
835            continue;
836        };
837        if content_hash != artifact.content_hash {
838            summary.skip(
839                &artifact.relative_path,
840                "restored_payload_missing_or_corrupt",
841            );
842            continue;
843        }
844        if let Some(parent) = dst.parent() {
845            std::fs::create_dir_all(parent)?;
846        }
847        if dst.exists() {
848            std::fs::remove_file(&dst)?;
849        }
850        if std::fs::hard_link(&src, &dst).is_err() {
851            std::fs::copy(&src, &dst)?;
852        }
853        if let Ok(file) = std::fs::File::open(&dst) {
854            let _ = file.set_times(file_times);
855        }
856        summary.restored_file_count += 1;
857        summary.restored_bytes += artifact.size;
858    }
859
860    summary.refresh_effectiveness(eligible);
861    Ok(summary)
862}
863
864fn validate_manifest(
865    plan: &RustArtifactPlanV1,
866    cache_key: &str,
867    manifest: &RustArtifactBundleManifest,
868    summary: &mut RustPlanSummary,
869) -> Result<bool, RustPlanError> {
870    if manifest.manifest_schema_version != RUST_ARTIFACT_CACHE_SCHEMA_VERSION {
871        return Err(RustPlanError::UnsupportedCacheSchemaVersion {
872            found: manifest.manifest_schema_version,
873            supported: RUST_ARTIFACT_CACHE_SCHEMA_VERSION,
874        });
875    }
876    let mut compatible = true;
877    if manifest.cache_key != cache_key {
878        summary
879            .key_input_mismatches
880            .push("bundle cache key does not match requested plan".to_string());
881        compatible = false;
882    }
883    if manifest.mode != plan.mode {
884        summary
885            .key_input_mismatches
886            .push("bundle mode does not match requested plan".to_string());
887        compatible = false;
888    }
889    let plan_identity_hash = rust_plan_identity_hash(plan);
890    if manifest.plan_identity_hash != plan_identity_hash {
891        summary
892            .key_input_mismatches
893            .push("bundle input hash does not match requested plan".to_string());
894        compatible = false;
895    }
896    if compatible {
897        Ok(true)
898    } else {
899        summary.compatibility.status = "warning".to_string();
900        summary.compatibility.errors = summary.key_input_mismatches.clone();
901        Ok(false)
902    }
903}
904
905#[derive(Debug, Clone, PartialEq, Eq)]
906struct SelectedArtifact {
907    source_path: NormalizedPath,
908    relative_path: String,
909    class: RustArtifactClass,
910}
911
912fn select_artifacts(
913    plan: &RustArtifactPlanV1,
914    candidates: Vec<NormalizedPath>,
915    summary: &mut RustPlanSummary,
916) -> Vec<SelectedArtifact> {
917    let allowed = plan.effective_allowed_classes();
918    let excluded_names = excluded_package_names(&plan.packages);
919    let mut selected = Vec::new();
920
921    for path in candidates {
922        let rel_path = match path.strip_prefix(plan.target_dir.as_path()) {
923            Ok(rel) => rel,
924            Err(_) => {
925                summary.skip(path.display().to_string(), "outside_target_dir");
926                continue;
927            }
928        };
929        let rel = relative_path_string(rel_path);
930
931        if has_component(rel_path, "incremental") {
932            summary.skip(rel, "transient_state");
933            continue;
934        }
935
936        let class = classify_artifact(rel_path, plan.mode);
937
938        if plan.mode == RustPlanMode::Thin {
939            let Some(class) = class else {
940                summary.skip(rel, "artifact_class_disallowed_by_plan");
941                continue;
942            };
943            if !allowed.contains(&class) {
944                summary.skip(rel, "artifact_class_disallowed_by_plan");
945                continue;
946            }
947            if artifact_matches_excluded_package(rel_path, &excluded_names) {
948                summary.skip(rel, "workspace_or_path_dependency_excluded_by_plan");
949                continue;
950            }
951            selected.push(SelectedArtifact {
952                source_path: path,
953                relative_path: rel,
954                class,
955            });
956            continue;
957        }
958
959        selected.push(SelectedArtifact {
960            source_path: path,
961            relative_path: rel,
962            class: class.unwrap_or(RustArtifactClass::FullTarget),
963        });
964    }
965
966    selected.sort_by(|a, b| a.relative_path.cmp(&b.relative_path));
967    selected
968}
969
970fn classify_artifact(rel: &Path, mode: RustPlanMode) -> Option<RustArtifactClass> {
971    if has_component(rel, ".fingerprint") {
972        return Some(RustArtifactClass::CargoFingerprint);
973    }
974    if has_component(rel, "build") {
975        if has_component(rel, "out") {
976            return Some(RustArtifactClass::BuildScriptOutput);
977        }
978        if let Some(name) = rel.file_name().and_then(OsStr::to_str) {
979            if matches!(name, "output" | "invoked.timestamp" | "root-output") {
980                return Some(RustArtifactClass::BuildScriptMetadata);
981            }
982        }
983    }
984
985    match rel.extension().and_then(OsStr::to_str) {
986        Some("rlib") => Some(RustArtifactClass::Rlib),
987        Some("rmeta") => Some(RustArtifactClass::Rmeta),
988        Some("d") => Some(RustArtifactClass::DepInfo),
989        Some("so" | "dylib" | "dll") if is_likely_proc_macro_dylib(rel) => {
990            Some(RustArtifactClass::ProcMacro)
991        }
992        Some("so" | "dylib" | "dll") => Some(RustArtifactClass::SharedLib),
993        _ if mode == RustPlanMode::Full => Some(RustArtifactClass::FullTarget),
994        _ => None,
995    }
996}
997
998fn is_likely_proc_macro_dylib(rel: &Path) -> bool {
999    if !has_component(rel, "deps") {
1000        return false;
1001    }
1002
1003    rel.file_stem()
1004        .and_then(OsStr::to_str)
1005        .map(|stem| {
1006            let stem = stem.to_ascii_lowercase();
1007            stem.contains("proc_macro") || stem.contains("proc-macro")
1008        })
1009        .unwrap_or(false)
1010}
1011
1012fn collect_files(root: &Path, files: &mut Vec<NormalizedPath>) -> Result<(), RustPlanError> {
1013    if !root.exists() {
1014        return Ok(());
1015    }
1016
1017    let mut entries = Vec::new();
1018    for entry in std::fs::read_dir(root)? {
1019        entries.push(entry?);
1020    }
1021    entries.sort_by_key(|entry| entry.file_name());
1022
1023    for entry in entries {
1024        let path = NormalizedPath::new(entry.path());
1025        let file_type = entry.file_type()?;
1026        if file_type.is_dir() {
1027            collect_files(path.as_path(), files)?;
1028        } else if file_type.is_file() {
1029            files.push(path);
1030        }
1031    }
1032    Ok(())
1033}
1034
1035fn safe_join(root: &Path, relative: &str) -> Result<NormalizedPath, RustPlanError> {
1036    let rel = Path::new(relative);
1037    if rel.as_os_str().is_empty() {
1038        return Err(RustPlanError::UnsafeRelativePath(relative.to_string()));
1039    }
1040    for component in rel.components() {
1041        match component {
1042            Component::Normal(_) | Component::CurDir => {}
1043            Component::ParentDir | Component::RootDir | Component::Prefix(_) => {
1044                return Err(RustPlanError::UnsafeRelativePath(relative.to_string()));
1045            }
1046        }
1047    }
1048    Ok(NormalizedPath::new(root.join(rel)))
1049}
1050
1051fn default_thin_classes() -> BTreeSet<RustArtifactClass> {
1052    [
1053        RustArtifactClass::Rlib,
1054        RustArtifactClass::Rmeta,
1055        RustArtifactClass::DepInfo,
1056        RustArtifactClass::ProcMacro,
1057        RustArtifactClass::SharedLib,
1058        RustArtifactClass::CargoFingerprint,
1059        RustArtifactClass::BuildScriptMetadata,
1060        RustArtifactClass::BuildScriptOutput,
1061    ]
1062    .into_iter()
1063    .collect()
1064}
1065
1066fn json_u32_field(value: &serde_json::Value, field: &'static str) -> Result<u32, RustPlanError> {
1067    let Some(raw) = value.get(field) else {
1068        return Err(RustPlanError::InvalidPlan(format!("{field} is required")));
1069    };
1070    let Some(n) = raw.as_u64() else {
1071        return Err(RustPlanError::InvalidPlan(format!(
1072            "{field} must be an unsigned integer"
1073        )));
1074    };
1075    u32::try_from(n).map_err(|_| RustPlanError::InvalidPlan(format!("{field} is too large")))
1076}
1077
1078fn ensure_supported_cache_schema_version(cache_schema_version: u32) -> Result<(), RustPlanError> {
1079    if cache_schema_version != RUST_ARTIFACT_CACHE_SCHEMA_VERSION {
1080        return Err(RustPlanError::UnsupportedCacheSchemaVersion {
1081            found: cache_schema_version,
1082            supported: RUST_ARTIFACT_CACHE_SCHEMA_VERSION,
1083        });
1084    }
1085    Ok(())
1086}
1087fn relative_path_string(path: &Path) -> String {
1088    path.components()
1089        .filter_map(|component| match component {
1090            Component::Normal(part) => Some(part.to_string_lossy().into_owned()),
1091            Component::CurDir => None,
1092            _ => Some(component.as_os_str().to_string_lossy().into_owned()),
1093        })
1094        .collect::<Vec<_>>()
1095        .join("/")
1096}
1097
1098fn has_component(path: &Path, needle: &str) -> bool {
1099    path.components()
1100        .any(|component| component.as_os_str() == OsStr::new(needle))
1101}
1102
1103fn excluded_package_names(packages: &RustPlanPackages) -> BTreeSet<String> {
1104    packages
1105        .workspace_package_ids
1106        .iter()
1107        .chain(packages.excluded_path_package_ids.iter())
1108        .filter_map(|id| package_name_from_id(id))
1109        .collect()
1110}
1111
1112fn package_name_from_id(id: &str) -> Option<String> {
1113    let candidate = if let Some(after_hash) = id.rsplit_once('#').map(|(_, right)| right) {
1114        after_hash.split('@').next().unwrap_or(after_hash)
1115    } else if let Some((left, _)) = id.split_once(' ') {
1116        left
1117    } else {
1118        id
1119    };
1120    let candidate = candidate
1121        .trim()
1122        .trim_matches('"')
1123        .trim_matches('\'')
1124        .replace('-', "_");
1125    if candidate.is_empty()
1126        || candidate.contains('/')
1127        || candidate.contains('\\')
1128        || candidate.contains(':')
1129    {
1130        None
1131    } else {
1132        Some(candidate)
1133    }
1134}
1135
1136fn artifact_matches_excluded_package(rel: &Path, excluded_names: &BTreeSet<String>) -> bool {
1137    if excluded_names.is_empty() {
1138        return false;
1139    }
1140    rel.components().any(|component| {
1141        let name = component.as_os_str().to_string_lossy();
1142        excluded_names.iter().any(|package| {
1143            let without_lib = name.strip_prefix("lib").unwrap_or(&name);
1144            without_lib == package
1145                || without_lib.starts_with(&format!("{package}-"))
1146                || without_lib.starts_with(&format!("{package}."))
1147        })
1148    })
1149}
1150
1151fn now_secs() -> u64 {
1152    SystemTime::now()
1153        .duration_since(UNIX_EPOCH)
1154        .unwrap_or_default()
1155        .as_secs()
1156}
1157
1158#[cfg(test)]
1159mod tests {
1160    use super::*;
1161
1162    fn sample_plan(root: &Path, mode: RustPlanMode) -> RustArtifactPlanV1 {
1163        RustArtifactPlanV1 {
1164            schema_version: 1,
1165            mode,
1166            workspace_root: root.into(),
1167            target_dir: root.join("target").into(),
1168            toolchain: RustToolchainIdentity {
1169                rustc: "rustc 1.94.1".to_string(),
1170                cargo: "cargo 1.94.1".to_string(),
1171                channel: "1.94.1".to_string(),
1172                host: "x86_64-pc-windows-msvc".to_string(),
1173            },
1174            target_triple: "x86_64-pc-windows-msvc".to_string(),
1175            profile: "debug".to_string(),
1176            inputs: RustPlanInputs {
1177                features_hash: "features".to_string(),
1178                rustflags_hash: "rustflags".to_string(),
1179                env_hash: "env".to_string(),
1180                lockfile_hash: "lock".to_string(),
1181                cargo_config_hash: "config".to_string(),
1182                manifest_hashes: vec!["manifest".to_string()],
1183            },
1184            packages: RustPlanPackages {
1185                selected_package_ids: vec!["app 0.1.0".to_string()],
1186                workspace_package_ids: vec!["app 0.1.0".to_string()],
1187                excluded_path_package_ids: vec!["local_dep 0.1.0".to_string()],
1188            },
1189            allowed_artifact_classes: vec![
1190                RustArtifactClass::Rlib,
1191                RustArtifactClass::Rmeta,
1192                RustArtifactClass::DepInfo,
1193                RustArtifactClass::CargoFingerprint,
1194                RustArtifactClass::BuildScriptMetadata,
1195                RustArtifactClass::BuildScriptOutput,
1196            ],
1197            cache_schema_version: 1,
1198            journal_log_path: Some(root.join("zccache-session.jsonl").into()),
1199        }
1200    }
1201
1202    fn write(path: &Path, bytes: &[u8]) {
1203        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
1204        std::fs::write(path, bytes).unwrap();
1205    }
1206
1207    fn load_manifest(bundle_dir: &Path) -> RustArtifactBundleManifest {
1208        let manifest_path = bundle_dir.join(BUNDLE_MANIFEST_NAME);
1209        serde_json::from_slice(&std::fs::read(manifest_path).unwrap()).unwrap()
1210    }
1211
1212    fn write_manifest(bundle_dir: &Path, manifest: &RustArtifactBundleManifest) {
1213        let manifest_path = bundle_dir.join(BUNDLE_MANIFEST_NAME);
1214        let bytes = serde_json::to_vec_pretty(manifest).unwrap();
1215        std::fs::write(manifest_path, bytes).unwrap();
1216    }
1217
1218    fn synthetic_target(root: &Path) {
1219        let target = root.join("target").join("debug");
1220        write(
1221            &target.join("deps").join("libserde-abc.rlib"),
1222            b"serde rlib",
1223        );
1224        write(
1225            &target.join("deps").join("libserde-abc.rmeta"),
1226            b"serde rmeta",
1227        );
1228        write(&target.join("deps").join("serde-abc.d"), b"serde depinfo");
1229        write(
1230            &target.join("deps").join("libapp-abc.rlib"),
1231            b"workspace rlib",
1232        );
1233        write(
1234            &target.join("deps").join("liblocal_dep-abc.rlib"),
1235            b"path dep rlib",
1236        );
1237        write(
1238            &target
1239                .join(".fingerprint")
1240                .join("serde-abc")
1241                .join("dep-lib-serde"),
1242            b"fingerprint",
1243        );
1244        write(
1245            &target
1246                .join("build")
1247                .join("serde-abc")
1248                .join("invoked.timestamp"),
1249            b"timestamp",
1250        );
1251        write(
1252            &target
1253                .join("build")
1254                .join("serde-abc")
1255                .join("out")
1256                .join("gen.rs"),
1257            b"generated",
1258        );
1259        write(&target.join("incremental").join("state.bin"), b"transient");
1260    }
1261
1262    fn synthetic_target_with_final_binary(root: &Path) {
1263        synthetic_target(root);
1264        let target = root.join("target").join("debug");
1265        #[cfg(windows)]
1266        write(&target.join("app.exe"), b"final binary");
1267        #[cfg(not(windows))]
1268        write(&target.join("app"), b"final binary");
1269    }
1270
1271    fn synthetic_target_with_proc_macro_outputs(root: &Path) {
1272        synthetic_target(root);
1273        let target = root.join("target").join("debug");
1274        #[cfg(windows)]
1275        let proc_macro = target.join("deps").join("libproc_macro2-def456.dll");
1276        #[cfg(not(windows))]
1277        let proc_macro = target.join("deps").join("libproc_macro2-def456.so");
1278        write(&proc_macro, b"proc-macro dylib");
1279
1280        #[cfg(windows)]
1281        let shared_lib = target.join("deps").join("libserde_shared-def456.dll");
1282        #[cfg(not(windows))]
1283        let shared_lib = target.join("deps").join("libserde_shared-def456.so");
1284        write(&shared_lib, b"shared lib");
1285    }
1286
1287    fn synthetic_target_with_package_exclusions(root: &Path) {
1288        synthetic_target(root);
1289        let target = root.join("target").join("debug");
1290        write(
1291            &target.join("deps").join("libapp-abc.rmeta"),
1292            b"workspace rmeta",
1293        );
1294        write(&target.join("deps").join("app-abc.d"), b"workspace depinfo");
1295        write(
1296            &target.join("deps").join("liblocal_dep-abc.rmeta"),
1297            b"path dep rmeta",
1298        );
1299        write(
1300            &target.join("deps").join("local_dep-abc.d"),
1301            b"path dep depinfo",
1302        );
1303        write(
1304            &target
1305                .join(".fingerprint")
1306                .join("app-abc")
1307                .join("dep-lib-app"),
1308            b"workspace fingerprint",
1309        );
1310        write(
1311            &target
1312                .join(".fingerprint")
1313                .join("local_dep-abc")
1314                .join("dep-lib-local_dep"),
1315            b"path dep fingerprint",
1316        );
1317        write(
1318            &target
1319                .join("build")
1320                .join("app-abc")
1321                .join("invoked.timestamp"),
1322            b"workspace timestamp",
1323        );
1324        write(
1325            &target
1326                .join("build")
1327                .join("local_dep-abc")
1328                .join("invoked.timestamp"),
1329            b"path dep timestamp",
1330        );
1331        write(
1332            &target
1333                .join("build")
1334                .join("app-abc")
1335                .join("out")
1336                .join("gen.rs"),
1337            b"workspace generated",
1338        );
1339        write(
1340            &target
1341                .join("build")
1342                .join("local_dep-abc")
1343                .join("out")
1344                .join("gen.rs"),
1345            b"path dep generated",
1346        );
1347    }
1348
1349    #[test]
1350    fn rejects_unsupported_schema_before_deserializing_unknown_fields() {
1351        let raw = serde_json::json!({
1352            "schema_version": 99,
1353            "cache_schema_version": 1,
1354            "unexpected_future_field": true
1355        });
1356        let err = RustArtifactPlanV1::from_json_value(raw).unwrap_err();
1357        assert!(matches!(
1358            err,
1359            RustPlanError::UnsupportedSchemaVersion {
1360                found: 99,
1361                supported: 1
1362            }
1363        ));
1364    }
1365
1366    #[test]
1367    fn rejects_unsupported_cache_schema_before_filesystem_mutation() {
1368        let dir = tempfile::tempdir().unwrap();
1369        synthetic_target(dir.path());
1370        let plan = RustArtifactPlanV1 {
1371            cache_schema_version: 99,
1372            ..sample_plan(dir.path(), RustPlanMode::Thin)
1373        };
1374        let cache = dir.path().join("cache");
1375
1376        let err = save_rust_plan_local(&plan, &cache).unwrap_err();
1377        assert!(matches!(
1378            err,
1379            RustPlanError::UnsupportedCacheSchemaVersion {
1380                found: 99,
1381                supported: 1
1382            }
1383        ));
1384        assert!(!cache.exists());
1385    }
1386
1387    #[test]
1388    fn restore_rejects_unsupported_cache_schema_before_filesystem_mutation() {
1389        let dir = tempfile::tempdir().unwrap();
1390        synthetic_target(dir.path());
1391        let plan = RustArtifactPlanV1 {
1392            cache_schema_version: 99,
1393            ..sample_plan(dir.path(), RustPlanMode::Thin)
1394        };
1395        let cache = dir.path().join("cache");
1396        std::fs::create_dir_all(&cache).unwrap();
1397        std::fs::create_dir_all(plan.target_dir.as_path()).unwrap();
1398        let sentinel = plan.target_dir.join("sentinel.txt");
1399        std::fs::write(&sentinel, b"keep me").unwrap();
1400
1401        let err = restore_rust_plan_local(&plan, &cache).unwrap_err();
1402        assert!(matches!(
1403            err,
1404            RustPlanError::UnsupportedCacheSchemaVersion {
1405                found: 99,
1406                supported: 1
1407            }
1408        ));
1409        assert!(sentinel.exists());
1410    }
1411
1412    #[test]
1413    fn omitted_or_empty_allowed_classes_default_to_thin_classes() {
1414        let dir = tempfile::tempdir().unwrap();
1415        let mut plan_value =
1416            serde_json::to_value(sample_plan(dir.path(), RustPlanMode::Thin)).unwrap();
1417
1418        plan_value
1419            .as_object_mut()
1420            .unwrap()
1421            .remove("allowed_artifact_classes");
1422        let omitted = RustArtifactPlanV1::from_json_value(plan_value.clone()).unwrap();
1423        assert_eq!(omitted.effective_allowed_classes(), default_thin_classes());
1424
1425        plan_value.as_object_mut().unwrap().insert(
1426            "allowed_artifact_classes".to_string(),
1427            serde_json::json!([]),
1428        );
1429        let empty = RustArtifactPlanV1::from_json_value(plan_value).unwrap();
1430        assert_eq!(empty.effective_allowed_classes(), default_thin_classes());
1431    }
1432    #[test]
1433    fn thin_save_restore_selects_dependency_artifacts_and_metadata() {
1434        let dir = tempfile::tempdir().unwrap();
1435        synthetic_target(dir.path());
1436        let plan = sample_plan(dir.path(), RustPlanMode::Thin);
1437        let cache = dir.path().join("cache");
1438
1439        let saved = save_rust_plan_local(&plan, &cache).unwrap();
1440        assert_eq!(saved.saved_file_count, 6);
1441        assert_eq!(
1442            saved
1443                .skipped_reasons
1444                .get("workspace_or_path_dependency_excluded_by_plan"),
1445            Some(&2)
1446        );
1447        assert_eq!(saved.skipped_reasons.get("transient_state"), Some(&1));
1448
1449        std::fs::remove_dir_all(plan.target_dir.as_path()).unwrap();
1450        let restored = restore_rust_plan_local(&plan, &cache).unwrap();
1451        assert_eq!(restored.restored_file_count, 6);
1452        assert!(plan
1453            .target_dir
1454            .join("debug/deps/libserde-abc.rlib")
1455            .exists());
1456        assert!(plan
1457            .target_dir
1458            .join("debug/.fingerprint/serde-abc/dep-lib-serde")
1459            .exists());
1460        assert!(!plan.target_dir.join("debug/deps/libapp-abc.rlib").exists());
1461        assert!(!plan.target_dir.join("debug/incremental/state.bin").exists());
1462    }
1463
1464    #[test]
1465    fn thin_plan_skips_final_binary_outputs() {
1466        let dir = tempfile::tempdir().unwrap();
1467        synthetic_target_with_final_binary(dir.path());
1468        let plan = sample_plan(dir.path(), RustPlanMode::Thin);
1469        let cache = dir.path().join("cache");
1470
1471        let saved = save_rust_plan_local(&plan, &cache).unwrap();
1472        assert_eq!(saved.saved_file_count, 6);
1473        assert_eq!(
1474            saved
1475                .skipped_reasons
1476                .get("artifact_class_disallowed_by_plan"),
1477            Some(&1)
1478        );
1479        assert!(saved.skipped_samples.iter().any(|sample| {
1480            sample.path.ends_with("debug/app.exe") || sample.path.ends_with("debug/app")
1481        }));
1482
1483        std::fs::remove_dir_all(plan.target_dir.as_path()).unwrap();
1484        let restored = restore_rust_plan_local(&plan, &cache).unwrap();
1485        assert_eq!(restored.restored_file_count, 6);
1486        assert!(!plan.target_dir.join("debug/app.exe").exists());
1487        assert!(!plan.target_dir.join("debug/app").exists());
1488    }
1489
1490    #[test]
1491    fn thin_plan_respects_explicit_class_gates_for_dependency_metadata_and_outputs() {
1492        let dir = tempfile::tempdir().unwrap();
1493        synthetic_target(dir.path());
1494        let mut plan = sample_plan(dir.path(), RustPlanMode::Thin);
1495        plan.allowed_artifact_classes = vec![RustArtifactClass::Rlib, RustArtifactClass::Rmeta];
1496        let cache = dir.path().join("cache");
1497
1498        let saved = save_rust_plan_local(&plan, &cache).unwrap();
1499        assert_eq!(saved.saved_file_count, 2);
1500        assert_eq!(
1501            saved
1502                .skipped_reasons
1503                .get("artifact_class_disallowed_by_plan"),
1504            Some(&4)
1505        );
1506        assert_eq!(
1507            saved
1508                .skipped_reasons
1509                .get("workspace_or_path_dependency_excluded_by_plan"),
1510            Some(&2)
1511        );
1512        assert!(saved
1513            .skipped_samples
1514            .iter()
1515            .any(|sample| sample.path.ends_with("debug/deps/serde-abc.d")));
1516        assert!(saved.skipped_samples.iter().any(|sample| sample
1517            .path
1518            .ends_with("debug/.fingerprint/serde-abc/dep-lib-serde")));
1519        assert!(saved.skipped_samples.iter().any(|sample| sample
1520            .path
1521            .ends_with("debug/build/serde-abc/invoked.timestamp")));
1522        assert!(saved
1523            .skipped_samples
1524            .iter()
1525            .any(|sample| sample.path.ends_with("debug/build/serde-abc/out/gen.rs")));
1526    }
1527
1528    #[test]
1529    fn thin_plan_saves_and_restores_likely_proc_macro_dylibs_without_shared_libs() {
1530        let dir = tempfile::tempdir().unwrap();
1531        synthetic_target_with_proc_macro_outputs(dir.path());
1532        let mut plan = sample_plan(dir.path(), RustPlanMode::Thin);
1533        plan.allowed_artifact_classes = vec![
1534            RustArtifactClass::Rlib,
1535            RustArtifactClass::Rmeta,
1536            RustArtifactClass::DepInfo,
1537            RustArtifactClass::ProcMacro,
1538            RustArtifactClass::CargoFingerprint,
1539            RustArtifactClass::BuildScriptMetadata,
1540            RustArtifactClass::BuildScriptOutput,
1541        ];
1542        let cache = dir.path().join("cache");
1543
1544        let saved = save_rust_plan_local(&plan, &cache).unwrap();
1545        assert_eq!(saved.saved_file_count, 7);
1546        assert_eq!(
1547            saved
1548                .skipped_reasons
1549                .get("artifact_class_disallowed_by_plan"),
1550            Some(&1)
1551        );
1552        assert!(saved.skipped_samples.iter().any(|sample| {
1553            sample
1554                .path
1555                .ends_with("debug/deps/libserde_shared-def456.dll")
1556                || sample
1557                    .path
1558                    .ends_with("debug/deps/libserde_shared-def456.so")
1559        }));
1560
1561        std::fs::remove_dir_all(plan.target_dir.as_path()).unwrap();
1562        let restored = restore_rust_plan_local(&plan, &cache).unwrap();
1563        assert_eq!(restored.restored_file_count, 7);
1564        assert!(plan
1565            .target_dir
1566            .join(if cfg!(windows) {
1567                "debug/deps/libproc_macro2-def456.dll"
1568            } else {
1569                "debug/deps/libproc_macro2-def456.so"
1570            })
1571            .exists());
1572        assert!(!plan
1573            .target_dir
1574            .join(if cfg!(windows) {
1575                "debug/deps/libserde_shared-def456.dll"
1576            } else {
1577                "debug/deps/libserde_shared-def456.so"
1578            })
1579            .exists());
1580    }
1581
1582    #[test]
1583    fn thin_plan_skips_likely_proc_macro_dylibs_when_disallowed() {
1584        let dir = tempfile::tempdir().unwrap();
1585        synthetic_target_with_proc_macro_outputs(dir.path());
1586        let mut plan = sample_plan(dir.path(), RustPlanMode::Thin);
1587        plan.allowed_artifact_classes = vec![
1588            RustArtifactClass::Rlib,
1589            RustArtifactClass::Rmeta,
1590            RustArtifactClass::DepInfo,
1591            RustArtifactClass::SharedLib,
1592            RustArtifactClass::CargoFingerprint,
1593            RustArtifactClass::BuildScriptMetadata,
1594            RustArtifactClass::BuildScriptOutput,
1595        ];
1596        let cache = dir.path().join("cache");
1597
1598        let saved = save_rust_plan_local(&plan, &cache).unwrap();
1599        assert_eq!(saved.saved_file_count, 7);
1600        assert_eq!(
1601            saved
1602                .skipped_reasons
1603                .get("artifact_class_disallowed_by_plan"),
1604            Some(&1)
1605        );
1606        assert!(saved.skipped_samples.iter().any(|sample| {
1607            sample
1608                .path
1609                .ends_with("debug/deps/libproc_macro2-def456.dll")
1610                || sample.path.ends_with("debug/deps/libproc_macro2-def456.so")
1611        }));
1612    }
1613
1614    #[test]
1615    fn restore_skips_mismatched_bundles_without_mutating_target_dir() {
1616        let dir = tempfile::tempdir().unwrap();
1617        synthetic_target(dir.path());
1618        let plan = sample_plan(dir.path(), RustPlanMode::Thin);
1619        let cache = dir.path().join("cache");
1620
1621        save_rust_plan_local(&plan, &cache).unwrap();
1622        let bundle_dir = rust_plan_bundle_dir(&cache, &rust_plan_cache_key(&plan));
1623        let mut manifest = load_manifest(&bundle_dir);
1624        manifest.cache_key = "rust-plan-v1-deadbeefdeadbeefdeadbeefdeadbeef".to_string();
1625        manifest.mode = RustPlanMode::Full;
1626        manifest.plan_identity_hash =
1627            "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef".to_string();
1628        write_manifest(&bundle_dir, &manifest);
1629
1630        std::fs::remove_dir_all(plan.target_dir.as_path()).unwrap();
1631        std::fs::create_dir_all(plan.target_dir.as_path()).unwrap();
1632
1633        let restored = restore_rust_plan_local(&plan, &cache).unwrap();
1634        assert_eq!(restored.restored_file_count, 0);
1635        assert_eq!(restored.compatibility.status, "warning");
1636        assert_eq!(restored.key_input_mismatches.len(), 3);
1637        assert_eq!(
1638            restored
1639                .miss_classifications
1640                .get("toolchain_profile_rustflags_target_mismatch"),
1641            Some(&2)
1642        );
1643        assert_eq!(
1644            restored
1645                .miss_classifications
1646                .get("lockfile_config_manifest_hash_mismatch"),
1647            Some(&2)
1648        );
1649        assert!(std::fs::read_dir(plan.target_dir.as_path())
1650            .unwrap()
1651            .next()
1652            .is_none());
1653    }
1654
1655    #[test]
1656    fn full_save_restore_includes_workspace_outputs_but_prunes_incremental() {
1657        let dir = tempfile::tempdir().unwrap();
1658        synthetic_target(dir.path());
1659        let plan = sample_plan(dir.path(), RustPlanMode::Full);
1660        let cache = dir.path().join("cache");
1661
1662        let saved = save_rust_plan_local(&plan, &cache).unwrap();
1663        assert_eq!(saved.skipped_reasons.get("transient_state"), Some(&1));
1664        assert!(saved.saved_file_count > 6);
1665
1666        std::fs::remove_dir_all(plan.target_dir.as_path()).unwrap();
1667        let restored = restore_rust_plan_local(&plan, &cache).unwrap();
1668        assert!(restored.restored_file_count > 6);
1669        assert!(plan.target_dir.join("debug/deps/libapp-abc.rlib").exists());
1670        assert!(!plan.target_dir.join("debug/incremental/state.bin").exists());
1671    }
1672
1673    #[test]
1674    fn restore_missing_bundle_is_a_diagnostic_cache_miss() {
1675        let dir = tempfile::tempdir().unwrap();
1676        let plan = sample_plan(dir.path(), RustPlanMode::Thin);
1677        let summary = restore_rust_plan_local(&plan, &dir.path().join("cache")).unwrap();
1678        assert_eq!(summary.backend, "local");
1679        assert_eq!(summary.restored_file_count, 0);
1680        assert_eq!(
1681            summary
1682                .skipped_reasons
1683                .get("artifact_absent_from_restored_plan"),
1684            Some(&1)
1685        );
1686        assert_eq!(
1687            summary
1688                .miss_classifications
1689                .get("artifact_absent_from_restored_plan"),
1690            Some(&1)
1691        );
1692    }
1693
1694    #[test]
1695    fn summary_records_backend_identity_and_manual_skips() {
1696        let dir = tempfile::tempdir().unwrap();
1697        let plan = sample_plan(dir.path(), RustPlanMode::Thin);
1698        let mut summary = RustPlanSummary::validation_success(&plan, &dir.path().join("cache"));
1699        assert_eq!(summary.backend, "local");
1700        assert!(summary.backend_cache_key.is_none());
1701
1702        summary.set_backend(
1703            "gha",
1704            Some("rust-plan-v1-key".to_string()),
1705            Some("version".to_string()),
1706        );
1707        summary.record_skip("<gha-cache>", "backend_cache_miss");
1708
1709        assert_eq!(summary.backend, "gha");
1710        assert_eq!(
1711            summary.backend_cache_key.as_deref(),
1712            Some("rust-plan-v1-key")
1713        );
1714        assert_eq!(summary.backend_cache_version.as_deref(), Some("version"));
1715        assert_eq!(summary.skipped_reasons.get("backend_cache_miss"), Some(&1));
1716        assert_eq!(
1717            summary.miss_classifications.get("backend_cache_miss"),
1718            Some(&1)
1719        );
1720    }
1721
1722    #[test]
1723    fn summary_derives_miss_classifications_from_existing_diagnostics() {
1724        let dir = tempfile::tempdir().unwrap();
1725        let plan = sample_plan(dir.path(), RustPlanMode::Thin);
1726        let mut summary = RustPlanSummary::validation_success(&plan, &dir.path().join("cache"));
1727
1728        summary.record_skip("debug/deps/app.exe", "artifact_class_disallowed_by_plan");
1729        summary.record_skip(
1730            "debug/deps/libapp-abc.rlib",
1731            "workspace_or_path_dependency_excluded_by_plan",
1732        );
1733        summary
1734            .key_input_mismatches
1735            .push("bundle mode does not match requested plan".to_string());
1736        summary
1737            .key_input_mismatches
1738            .push("bundle input hash does not match requested plan".to_string());
1739        summary.compile_cache_stats = Some(serde_json::json!({
1740            "compilations": 4,
1741            "hits": 1,
1742            "misses": 3,
1743        }));
1744        summary.refresh_miss_classifications();
1745
1746        assert_eq!(
1747            summary
1748                .miss_classifications
1749                .get("artifact_class_disallowed_by_plan"),
1750            Some(&1)
1751        );
1752        assert_eq!(
1753            summary
1754                .miss_classifications
1755                .get("workspace_or_path_dependency_excluded_by_plan"),
1756            Some(&1)
1757        );
1758        assert_eq!(
1759            summary
1760                .miss_classifications
1761                .get("toolchain_profile_rustflags_target_mismatch"),
1762            Some(&1)
1763        );
1764        assert_eq!(
1765            summary
1766                .miss_classifications
1767                .get("lockfile_config_manifest_hash_mismatch"),
1768            Some(&1)
1769        );
1770        assert_eq!(
1771            summary
1772                .miss_classifications
1773                .get("zccache_compile_cache_miss_despite_equivalent_rustc_command"),
1774            Some(&3)
1775        );
1776    }
1777
1778    #[test]
1779    fn serialized_summary_recomputes_miss_classifications_from_session_stats() {
1780        let dir = tempfile::tempdir().unwrap();
1781        let plan = sample_plan(dir.path(), RustPlanMode::Thin);
1782        let mut summary = RustPlanSummary::validation_success(&plan, &dir.path().join("cache"));
1783        summary.record_skip("<gha-cache>", "backend_cache_miss");
1784
1785        summary.compile_cache_stats = Some(serde_json::json!({
1786            "status": "ok",
1787            "cache_misses": 2,
1788        }));
1789
1790        let json = serde_json::to_value(&summary).unwrap();
1791        assert_eq!(
1792            json["miss_classifications"]["backend_cache_miss"].as_u64(),
1793            Some(1)
1794        );
1795        assert_eq!(
1796            json["miss_classifications"]
1797                ["zccache_compile_cache_miss_despite_equivalent_rustc_command"]
1798                .as_u64(),
1799            Some(2)
1800        );
1801    }
1802
1803    #[test]
1804    fn restore_skips_missing_wrong_size_and_wrong_hash_payloads() {
1805        let dir = tempfile::tempdir().unwrap();
1806        synthetic_target(dir.path());
1807        let plan = sample_plan(dir.path(), RustPlanMode::Thin);
1808        let cache = dir.path().join("cache");
1809
1810        let saved = save_rust_plan_local(&plan, &cache).unwrap();
1811        assert_eq!(saved.saved_file_count, 6);
1812
1813        let bundle_dir = rust_plan_bundle_dir(&cache, &rust_plan_cache_key(&plan));
1814        let mut manifest = load_manifest(&bundle_dir);
1815
1816        std::fs::remove_file(
1817            bundle_dir
1818                .join(BUNDLE_FILES_DIR)
1819                .join("debug/deps/libserde-abc.rlib"),
1820        )
1821        .unwrap();
1822        manifest.artifacts[1].size += 1;
1823        manifest.artifacts[2].content_hash = "not-the-right-hash".to_string();
1824        write_manifest(&bundle_dir, &manifest);
1825
1826        std::fs::remove_dir_all(plan.target_dir.as_path()).unwrap();
1827        let restored = restore_rust_plan_local(&plan, &cache).unwrap();
1828
1829        assert_eq!(restored.restored_file_count, 3);
1830        assert_eq!(
1831            restored
1832                .skipped_reasons
1833                .get("restored_payload_missing_or_corrupt"),
1834            Some(&3)
1835        );
1836        assert_eq!(
1837            restored
1838                .miss_classifications
1839                .get("restored_payload_missing_or_corrupt"),
1840            Some(&3)
1841        );
1842        assert!(!plan
1843            .target_dir
1844            .join("debug/deps/libserde-abc.rlib")
1845            .exists());
1846    }
1847
1848    #[test]
1849    fn restore_skips_manifest_path_traversal_entries() {
1850        let dir = tempfile::tempdir().unwrap();
1851        synthetic_target(dir.path());
1852        let plan = sample_plan(dir.path(), RustPlanMode::Thin);
1853        let cache = dir.path().join("cache");
1854
1855        save_rust_plan_local(&plan, &cache).unwrap();
1856
1857        let bundle_dir = rust_plan_bundle_dir(&cache, &rust_plan_cache_key(&plan));
1858        let mut manifest = load_manifest(&bundle_dir);
1859        manifest.artifacts[0].relative_path = "../escape.txt".to_string();
1860        write_manifest(&bundle_dir, &manifest);
1861
1862        std::fs::remove_dir_all(plan.target_dir.as_path()).unwrap();
1863        let restored = restore_rust_plan_local(&plan, &cache).unwrap();
1864
1865        assert_eq!(restored.restored_file_count, 5);
1866        assert_eq!(restored.skipped_count, 1);
1867        assert_eq!(restored.skipped_reasons.get("path_traversal"), Some(&1));
1868        assert!(!dir.path().join("escape.txt").exists());
1869    }
1870
1871    #[test]
1872    fn safe_join_rejects_path_traversal() {
1873        let err = safe_join(Path::new("root"), "../outside").unwrap_err();
1874        assert!(matches!(err, RustPlanError::UnsafeRelativePath(_)));
1875    }
1876
1877    #[test]
1878    fn package_name_parsing_handles_cargo_package_id_shapes() {
1879        assert_eq!(
1880            package_name_from_id(
1881                "registry+https://github.com/rust-lang/crates.io-index#serde@1.0.0"
1882            ),
1883            Some("serde".to_string())
1884        );
1885        assert_eq!(
1886            package_name_from_id("path+file:///repo#my-crate@0.1.0"),
1887            Some("my_crate".to_string())
1888        );
1889    }
1890
1891    #[test]
1892    fn thin_package_exclusions_match_deps_fingerprint_and_build_by_package_stem() {
1893        let dir = tempfile::tempdir().unwrap();
1894        synthetic_target_with_package_exclusions(dir.path());
1895        let mut plan = sample_plan(dir.path(), RustPlanMode::Thin);
1896        plan.packages.workspace_package_ids =
1897            vec!["registry+https://github.com/rust-lang/crates.io-index#app@0.1.0".to_string()];
1898        plan.packages.excluded_path_package_ids =
1899            vec!["path+file:///workspace/local_dep#local-dep@0.1.0".to_string()];
1900        let cache = dir.path().join("cache");
1901
1902        let saved = save_rust_plan_local(&plan, &cache).unwrap();
1903        assert_eq!(saved.saved_file_count, 6);
1904        assert_eq!(
1905            saved
1906                .skipped_reasons
1907                .get("workspace_or_path_dependency_excluded_by_plan"),
1908            Some(&12)
1909        );
1910        assert_eq!(saved.skipped_reasons.get("transient_state"), Some(&1));
1911        assert!(saved
1912            .skipped_samples
1913            .iter()
1914            .any(|sample| sample.path.ends_with("debug/deps/libapp-abc.rlib")));
1915        assert!(saved.skipped_samples.iter().any(|sample| sample
1916            .path
1917            .ends_with("debug/.fingerprint/app-abc/dep-lib-app")));
1918        assert!(saved.skipped_samples.iter().any(|sample| sample
1919            .path
1920            .ends_with("debug/build/local_dep-abc/out/gen.rs")));
1921    }
1922    #[test]
1923    fn from_json_str_accepts_utf8_bom() {
1924        let dir = tempfile::tempdir().unwrap();
1925        let plan = sample_plan(dir.path(), RustPlanMode::Thin);
1926        let json = serde_json::to_string(&plan).unwrap();
1927        let loaded = RustArtifactPlanV1::from_json_str(&format!("\u{feff}{json}")).unwrap();
1928        assert_eq!(loaded.schema_version, 1);
1929    }
1930
1931    // ─── rust-plan tar thread resolver (issue #177) ──────────────────────
1932
1933    #[test]
1934    fn tar_threads_parser_accepts_grammar_from_soldr_273() {
1935        // unset / auto / empty / whitespace → default (vCPU-bounded, capped at 8)
1936        let default = default_rust_plan_tar_threads();
1937        assert!((1..=DEFAULT_RUST_PLAN_TAR_THREADS_CAP).contains(&default));
1938        assert_eq!(parse_rust_plan_tar_threads(None), default);
1939        assert_eq!(parse_rust_plan_tar_threads(Some("auto")), default);
1940        assert_eq!(parse_rust_plan_tar_threads(Some("AUTO")), default);
1941        assert_eq!(parse_rust_plan_tar_threads(Some("")), default);
1942        assert_eq!(parse_rust_plan_tar_threads(Some("   ")), default);
1943
1944        // 1 → sequential escape hatch
1945        assert_eq!(parse_rust_plan_tar_threads(Some("1")), 1);
1946
1947        // Positive integer → clamped to MAX_RUST_PLAN_TAR_THREADS
1948        assert_eq!(parse_rust_plan_tar_threads(Some("4")), 4);
1949        assert_eq!(
1950            parse_rust_plan_tar_threads(Some("9999")),
1951            MAX_RUST_PLAN_TAR_THREADS
1952        );
1953
1954        // Garbage / 0 → default (defensive)
1955        assert_eq!(parse_rust_plan_tar_threads(Some("0")), default);
1956        assert_eq!(parse_rust_plan_tar_threads(Some("not-a-number")), default);
1957        assert_eq!(parse_rust_plan_tar_threads(Some("-1")), default);
1958    }
1959
1960    #[test]
1961    fn parallel_bundling_matches_sequential_byte_for_byte() {
1962        // `select_artifacts` pre-sorts by relative_path; with rayon's ordered
1963        // `par_iter().collect()` we must end up with the same artifact list,
1964        // same hashes, same sizes — regardless of thread count.
1965        fn bundle_with(threads: usize) -> Vec<RustBundledArtifact> {
1966            let dir = tempfile::tempdir().unwrap();
1967            synthetic_target(dir.path());
1968            let plan = sample_plan(dir.path(), RustPlanMode::Thin);
1969
1970            let mut candidates = Vec::new();
1971            collect_files(plan.target_dir.as_path(), &mut candidates).unwrap();
1972            candidates.sort();
1973            let mut summary = RustPlanSummary::new(
1974                RustPlanOperation::Save,
1975                plan.mode,
1976                plan.schema_version,
1977                plan.cache_schema_version,
1978                rust_plan_cache_key(&plan),
1979                None,
1980                None,
1981            );
1982            let selected = select_artifacts(&plan, candidates, &mut summary);
1983
1984            let files_dir = dir.path().join("out").join(format!("t{threads}"));
1985            std::fs::create_dir_all(&files_dir).unwrap();
1986            bundle_selected_artifacts_with_threads(&selected, &files_dir, threads).unwrap()
1987        }
1988
1989        let sequential = bundle_with(1);
1990        let parallel = bundle_with(4);
1991
1992        assert!(!sequential.is_empty());
1993        assert_eq!(sequential.len(), parallel.len());
1994        for (seq, par) in sequential.iter().zip(parallel.iter()) {
1995            assert_eq!(seq.relative_path, par.relative_path);
1996            assert_eq!(seq.size, par.size);
1997            assert_eq!(seq.content_hash, par.content_hash);
1998            assert_eq!(seq.class, par.class);
1999        }
2000    }
2001}