Skip to main content

cargo_rail/cargo/
multi_target_metadata.rs

1//! Multi-target metadata loading with clean caching
2//!
3//! This replaces the old WorkspaceMetadata that was confused about --all-features.
4//! We load metadata per target (in parallel) and cache it for reuse.
5
6use crate::error::RailResult;
7use cargo_metadata::{Metadata, MetadataCommand, Package, PackageId};
8use rayon::prelude::*;
9use semver::Version;
10use std::collections::{HashMap, HashSet};
11use std::path::Path;
12
13#[derive(Clone)]
14struct TargetMetadataEntry {
15  metadata: Metadata,
16  package_id_index: HashMap<PackageId, usize>,
17}
18
19impl TargetMetadataEntry {
20  fn new(metadata: Metadata) -> Self {
21    let package_id_index = metadata
22      .packages
23      .iter()
24      .enumerate()
25      .map(|(idx, pkg)| (pkg.id.clone(), idx))
26      .collect();
27
28    Self {
29      metadata,
30      package_id_index,
31    }
32  }
33
34  fn package_by_id(&self, id: &PackageId) -> Option<&Package> {
35    self.package_id_index.get(id).map(|&idx| &self.metadata.packages[idx])
36  }
37}
38
39/// Multi-target metadata cache for the HYBRID approach
40///
41/// Loads metadata for each target in parallel WITHOUT --all-features.
42/// This gives us accurate version resolution per target while avoiding
43/// the maximal feature set problem.
44#[derive(Clone)]
45pub struct MultiTargetMetadata {
46  /// Metadata per target (or "default" if no targets specified)
47  cache: HashMap<String, TargetMetadataEntry>,
48}
49
50impl MultiTargetMetadata {
51  /// Load metadata for all targets in parallel
52  pub fn load_parallel(workspace_root: &Path, targets: &[String]) -> RailResult<Self> {
53    let workspace_root = workspace_root.to_path_buf();
54
55    // If no targets specified, load default metadata
56    if targets.is_empty() {
57      let metadata = Self::load_single_target(&workspace_root, None)?;
58      let mut cache = HashMap::new();
59      cache.insert("default".to_string(), TargetMetadataEntry::new(metadata));
60      return Ok(Self { cache });
61    }
62
63    // Load all targets in parallel using Rayon
64    let results: Vec<RailResult<(String, Metadata)>> = targets
65      .par_iter()
66      .map(|target| {
67        let metadata = Self::load_single_target(&workspace_root, Some(target))?;
68        Ok((target.clone(), metadata))
69      })
70      .collect();
71
72    // Collect results, propagating any errors
73    let mut cache = HashMap::new();
74    for result in results {
75      let (target, metadata) = result?;
76      cache.insert(target, TargetMetadataEntry::new(metadata));
77    }
78
79    Ok(Self { cache })
80  }
81
82  /// Load metadata for a single target
83  fn load_single_target(workspace_root: &Path, target: Option<&str>) -> RailResult<Metadata> {
84    let manifest_path = workspace_root.join("Cargo.toml");
85
86    let mut cmd = MetadataCommand::new();
87    cmd.manifest_path(&manifest_path);
88
89    // Add target filtering if specified
90    if let Some(target_triple) = target {
91      cmd.other_options(vec!["--filter-platform".to_string(), target_triple.to_string()]);
92    }
93
94    // IMPORTANT: NO --all-features! We want cargo's default resolution
95    // Features come from manifest analysis (intersection of unconditional)
96
97    let metadata = cmd.exec().map_err(|e| {
98      if let Some(t) = target {
99        let err_str = e.to_string();
100        // Detect missing target scenario
101        if err_str.contains("error[E0463]")
102          || err_str.contains("can't find crate")
103          || err_str.contains("target may not be installed")
104        {
105          crate::error::RailError::with_help(
106            format!("Target '{}' is not installed on this machine", t),
107            format!(
108              "Install the target with: rustup target add {}\n\
109               Or remove it from rail.toml [targets] if not needed for this workspace.",
110              t
111            ),
112          )
113        } else {
114          crate::error::RailError::with_help(
115            format!("Failed to load cargo metadata for target '{}'", t),
116            format!("Error: {}\n\nCheck that the target is valid and installed.", e),
117          )
118        }
119      } else {
120        crate::error::RailError::with_help("Failed to load cargo metadata".to_string(), format!("Error: {}", e))
121      }
122    })?;
123
124    Ok(metadata)
125  }
126
127  /// Get metadata for a specific target
128  pub fn get(&self, target: &str) -> Option<&Metadata> {
129    self.cache.get(target).map(|e| &e.metadata)
130  }
131
132  /// Get metadata for any target (useful when they should all be the same)
133  pub fn any(&self) -> Option<&Metadata> {
134    self.cache.values().next().map(|e| &e.metadata)
135  }
136
137  /// Get all targets we have metadata for (sorted for deterministic output)
138  pub fn targets(&self) -> Vec<&str> {
139    let mut targets: Vec<_> = self.cache.keys().map(|s| s.as_str()).collect();
140    targets.sort_unstable();
141    targets
142  }
143
144  /// Get workspace packages (same across all targets)
145  pub fn workspace_packages(&self) -> Vec<&Package> {
146    self.any().map(|m| m.workspace_packages()).unwrap_or_default()
147  }
148
149  /// Get all versions of a dependency across targets (includes transitive deps)
150  /// Returns map of target -> version
151  /// NOTE: This includes transitive dependencies - use direct_dep_versions() for direct deps only
152  pub fn all_versions(&self, dep_name: &str) -> HashMap<String, Version> {
153    let mut versions = HashMap::new();
154
155    for (target, entry) in &self.cache {
156      let metadata = &entry.metadata;
157      if let Some(resolve) = &metadata.resolve {
158        // Find the package in the resolved graph
159        for node in &resolve.nodes {
160          if let Some(pkg) = entry.package_by_id(&node.id)
161            && pkg.name == dep_name
162          {
163            versions.insert(target.clone(), pkg.version.clone());
164            break; // Found it for this target
165          }
166        }
167      }
168    }
169
170    versions
171  }
172
173  /// Get versions of a dependency that are DIRECT dependencies of workspace members only
174  ///
175  /// This filters out transitive dependencies, ensuring we only unify versions
176  /// that workspace members explicitly depend on. Returns map of target -> version.
177  ///
178  /// Note: Within each target, cargo's resolver produces exactly ONE version per crate.
179  /// We return that resolved version for each target where the dep is a direct dependency.
180  pub fn direct_dep_versions(&self, dep_name: &str) -> HashMap<String, Version> {
181    let mut versions = HashMap::new();
182
183    for (target, entry) in &self.cache {
184      let metadata = &entry.metadata;
185      // Get workspace member package IDs
186      let workspace_member_ids: HashSet<_> = metadata.workspace_packages().iter().map(|p| &p.id).collect();
187
188      if let Some(resolve) = &metadata.resolve {
189        // Look at direct dependencies of workspace members only
190        for node in &resolve.nodes {
191          // Skip if not a workspace member
192          if !workspace_member_ids.contains(&node.id) {
193            continue;
194          }
195
196          // Check if this workspace member has dep_name as a direct dependency
197          for dep in &node.deps {
198            if dep.name == dep_name {
199              // Found a direct dependency - get its resolved version
200              if let Some(pkg) = entry.package_by_id(&dep.pkg) {
201                // Cargo resolves to exactly one version per target - just record it
202                versions.insert(target.clone(), pkg.version.clone());
203                break; // Found for this workspace member, move on
204              }
205            }
206          }
207        }
208      }
209    }
210
211    versions
212  }
213
214  /// Check if a dependency is transitive-only (never in direct deps)
215  pub fn is_transitive_only(&self, dep_name: &str) -> bool {
216    // Check all workspace packages to see if any directly depend on this
217    for entry in self.cache.values() {
218      let metadata = &entry.metadata;
219      for pkg in metadata.workspace_packages() {
220        for dep in &pkg.dependencies {
221          if dep.name == dep_name {
222            return false; // Found in direct deps
223          }
224        }
225      }
226    }
227
228    // Check if it exists in the resolved graph at all
229    for entry in self.cache.values() {
230      let metadata = &entry.metadata;
231      if let Some(resolve) = &metadata.resolve {
232        for node in &resolve.nodes {
233          if let Some(pkg) = entry.package_by_id(&node.id)
234            && pkg.name == dep_name
235          {
236            return true; // In graph but not direct = transitive
237          }
238        }
239      }
240    }
241
242    false // Not in graph at all
243  }
244
245  /// Check if a dependency is a path/workspace dependency (not from a registry)
246  ///
247  /// Path dependencies have `source: None` in cargo metadata.
248  /// These cannot be pinned in workspace.dependencies without a registry source,
249  /// so we skip them during transitive pinning.
250  pub fn is_path_dependency(&self, dep_name: &str) -> bool {
251    for entry in self.cache.values() {
252      let metadata = &entry.metadata;
253      for pkg in &metadata.packages {
254        if pkg.name == dep_name {
255          // source is None for path deps and workspace members
256          // source is Some("registry+...") for published deps
257          return pkg.source.is_none();
258        }
259      }
260    }
261    false
262  }
263
264  /// Get features enabled for a package across all targets
265  /// Returns map of target -> set of features
266  pub fn all_features(&self, dep_name: &str) -> HashMap<String, HashSet<String>> {
267    let mut features = HashMap::new();
268
269    for (target, entry) in &self.cache {
270      let metadata = &entry.metadata;
271      if let Some(resolve) = &metadata.resolve {
272        for node in &resolve.nodes {
273          // Find the package
274          if let Some(pkg) = entry.package_by_id(&node.id)
275            && pkg.name == dep_name
276          {
277            // Get the features for this node
278            let feat_set: HashSet<String> = node
279              .features
280              .iter()
281              .filter(|f| {
282                // Filter out non-existent features (cargo metadata quirk)
283                pkg.features.contains_key(f.as_str())
284              })
285              .map(|f| f.to_string())
286              .collect();
287
288            features.insert(target.clone(), feat_set);
289            break;
290          }
291        }
292      }
293    }
294
295    features
296  }
297
298  /// Check which targets include a specific dependency (sorted for deterministic output)
299  pub fn targets_with_dep(&self, dep_name: &str) -> Vec<String> {
300    let mut targets = Vec::new();
301
302    for (target, entry) in &self.cache {
303      let metadata = &entry.metadata;
304      if let Some(resolve) = &metadata.resolve {
305        for node in &resolve.nodes {
306          if let Some(pkg) = entry.package_by_id(&node.id)
307            && pkg.name == dep_name
308          {
309            targets.push(target.clone());
310            break;
311          }
312        }
313      }
314    }
315
316    targets.sort_unstable();
317    targets
318  }
319
320  /// Detect transitive dependencies with fragmented features
321  /// These are candidates for pinning (workspace-hack replacement)
322  pub fn find_fragmented_transitives(&self) -> Vec<FragmentedTransitive> {
323    let mut transitives = Vec::new();
324
325    // Find all transitive-only deps
326    let mut all_deps = HashSet::new();
327    for entry in self.cache.values() {
328      let metadata = &entry.metadata;
329      if let Some(resolve) = &metadata.resolve {
330        for node in &resolve.nodes {
331          if let Some(pkg) = entry.package_by_id(&node.id) {
332            all_deps.insert(pkg.name.clone());
333          }
334        }
335      }
336    }
337
338    for dep_name in all_deps {
339      if !self.is_transitive_only(&dep_name) {
340        continue; // Skip direct deps
341      }
342
343      // Skip path dependencies - they can't be pinned from a registry
344      if self.is_path_dependency(&dep_name) {
345        continue;
346      }
347
348      let features = self.all_features(&dep_name);
349      // Convert to sorted Vecs for stable comparison (HashSet iteration is non-deterministic)
350      let unique_sets: HashSet<_> = features
351        .values()
352        .map(|set| {
353          let mut vec: Vec<_> = set.iter().cloned().collect();
354          vec.sort_unstable();
355          vec
356        })
357        .collect();
358
359      if unique_sets.len() > 1 {
360        // This dep has different features across builds = fragmented
361        //
362        // IMPORTANT: Use INTERSECTION of features, not union!
363        // Using union can enable features that pull in new transitive deps
364        // that aren't in the current Cargo.lock, breaking resolution.
365        // The intersection approach is safe - it only pins features that
366        // are already enabled everywhere, avoiding new dep introduction.
367        let common_features: HashSet<String> = features
368          .values()
369          .fold(None, |acc: Option<HashSet<String>>, set| match acc {
370            None => Some(set.clone()),
371            Some(existing) => Some(existing.intersection(set).cloned().collect()),
372          })
373          .unwrap_or_default();
374
375        // Get the resolved version (use highest across all targets)
376        let versions = self.all_versions(&dep_name);
377        let version = match versions.values().max() {
378          Some(v) => v.clone(),
379          None => continue, // Skip if we can't determine version
380        };
381
382        // Sort unified_features for deterministic output
383        let mut unified_features: Vec<_> = common_features.into_iter().collect();
384        unified_features.sort_unstable();
385
386        transitives.push(FragmentedTransitive {
387          name: dep_name.to_string(),
388          version,
389          feature_sets: features,
390          unified_features,
391        });
392      }
393    }
394
395    // Sort by name for deterministic output (we iterate over HashSet above)
396    transitives.sort_unstable_by(|a, b| a.name.cmp(&b.name));
397
398    transitives
399  }
400
401  /// Compute the MSRV from dependencies only (internal helper)
402  ///
403  /// Returns the maximum rust-version across all resolved dependencies,
404  /// along with the list of deps that contributed to that maximum.
405  fn compute_deps_msrv(&self) -> Option<(Version, Vec<String>, usize)> {
406    let mut max_version: Option<Version> = None;
407    let mut contributors: Vec<String> = Vec::new();
408    let mut deps_with_msrv = 0;
409    let mut seen_packages: HashSet<String> = HashSet::new();
410
411    // Iterate through all packages in the resolved graph
412    for entry in self.cache.values() {
413      let metadata = &entry.metadata;
414      for pkg in &metadata.packages {
415        // Skip if we've already processed this package (may appear in multiple targets)
416        let pkg_key = format!("{}@{}", pkg.name, pkg.version);
417        if seen_packages.contains(&pkg_key) {
418          continue;
419        }
420        seen_packages.insert(pkg_key);
421
422        // Check if this package has rust-version specified
423        if let Some(ref rust_version) = pkg.rust_version {
424          deps_with_msrv += 1;
425
426          match &max_version {
427            None => {
428              max_version = Some(rust_version.clone());
429              contributors = vec![pkg.name.to_string()];
430            }
431            Some(current_max) => {
432              if rust_version > current_max {
433                max_version = Some(rust_version.clone());
434                contributors = vec![pkg.name.to_string()];
435              } else if rust_version == current_max {
436                contributors.push(pkg.name.to_string());
437              }
438            }
439          }
440        }
441      }
442    }
443
444    max_version.map(|v| (v, contributors, deps_with_msrv))
445  }
446
447  /// Compute the workspace MSRV with config-driven source selection
448  ///
449  /// Takes into account the existing workspace rust-version and the msrv_source
450  /// configuration to determine the final MSRV value.
451  ///
452  /// # Arguments
453  /// * `workspace_root` - Path to workspace root (for reading existing rust-version)
454  /// * `msrv_source` - How to determine the final MSRV (deps, workspace, or max)
455  ///
456  /// # Returns
457  /// * `None` if no MSRV can be determined (no deps have rust-version AND no workspace rust-version)
458  /// * `Some(ComputedMsrv)` with the final MSRV and metadata about the computation
459  pub fn compute_msrv_with_config(
460    &self,
461    workspace_root: &Path,
462    msrv_source: crate::config::MsrvSource,
463  ) -> Option<ComputedMsrv> {
464    use crate::config::MsrvSource;
465
466    // Get MSRV from dependencies
467    let deps_result = self.compute_deps_msrv();
468
469    // Read existing rust-version baseline (prefer workspace.package, fallback to root package)
470    let (workspace_msrv, used_package_fallback) = read_workspace_rust_version(workspace_root);
471
472    // Apply msrv_source logic
473    match msrv_source {
474      MsrvSource::Deps => {
475        // Original behavior: use deps only, ignore workspace
476        deps_result.map(|(version, contributors, deps_with_msrv)| ComputedMsrv {
477          version: version.clone(),
478          contributors,
479          deps_with_msrv,
480          deps_msrv: Some(version),
481          workspace_msrv,
482          source_used: MsrvSourceUsed::Deps,
483          warning: None,
484        })
485      }
486
487      MsrvSource::Workspace => {
488        // Preserve workspace rust-version, warn if deps need higher
489        match (&workspace_msrv, &deps_result) {
490          (Some(ws_ver), Some((deps_ver, contributors, deps_with_msrv))) => {
491            let warning = if deps_ver > ws_ver {
492              Some(format!(
493                "workspace rust-version ({}.{}.{}) is lower than deps require ({}.{}.{}); \
494                 deps {} need the higher version",
495                ws_ver.major,
496                ws_ver.minor,
497                ws_ver.patch,
498                deps_ver.major,
499                deps_ver.minor,
500                deps_ver.patch,
501                contributors.first().unwrap_or(&"unknown".to_string())
502              ))
503            } else if used_package_fallback {
504              Some(
505                "no [workspace.package].rust-version found; using [package].rust-version as baseline and writing it to [workspace.package].rust-version. \
506consider enabling MSRV inheritance (rust-version = { workspace = true }) to avoid drift."
507                  .to_string(),
508              )
509            } else {
510              None
511            };
512            Some(ComputedMsrv {
513              version: ws_ver.clone(),
514              contributors: contributors.clone(),
515              deps_with_msrv: *deps_with_msrv,
516              deps_msrv: Some(deps_ver.clone()),
517              workspace_msrv: Some(ws_ver.clone()),
518              source_used: MsrvSourceUsed::Workspace,
519              warning,
520            })
521          }
522          (Some(ws_ver), None) => {
523            // Workspace has rust-version but no deps do
524            Some(ComputedMsrv {
525              version: ws_ver.clone(),
526              contributors: Vec::new(),
527              deps_with_msrv: 0,
528              deps_msrv: None,
529              workspace_msrv: Some(ws_ver.clone()),
530              source_used: MsrvSourceUsed::Workspace,
531              warning: if used_package_fallback {
532                Some(
533                  "no [workspace.package].rust-version found; using [package].rust-version as baseline and writing it to [workspace.package].rust-version. \
534consider enabling MSRV inheritance (rust-version = { workspace = true }) to avoid drift."
535                    .to_string(),
536                )
537              } else {
538                None
539              },
540            })
541          }
542          (None, Some((deps_ver, contributors, deps_with_msrv))) => {
543            // No workspace rust-version, fall back to deps
544            Some(ComputedMsrv {
545              version: deps_ver.clone(),
546              contributors: contributors.clone(),
547              deps_with_msrv: *deps_with_msrv,
548              deps_msrv: Some(deps_ver.clone()),
549              workspace_msrv: None,
550              source_used: MsrvSourceUsed::Deps,
551              warning: Some("no workspace rust-version found, using deps MSRV".to_string()),
552            })
553          }
554          (None, None) => None,
555        }
556      }
557
558      MsrvSource::Max => {
559        // Take max(workspace, deps) - explicit workspace setting wins if higher
560        match (&workspace_msrv, &deps_result) {
561          (Some(ws_ver), Some((deps_ver, contributors, deps_with_msrv))) => {
562            let (version, source_used) = if ws_ver >= deps_ver {
563              (ws_ver.clone(), MsrvSourceUsed::MaxWorkspace)
564            } else {
565              (deps_ver.clone(), MsrvSourceUsed::MaxDeps)
566            };
567            Some(ComputedMsrv {
568              version,
569              contributors: contributors.clone(),
570              deps_with_msrv: *deps_with_msrv,
571              deps_msrv: Some(deps_ver.clone()),
572              workspace_msrv: Some(ws_ver.clone()),
573              source_used,
574              warning: if used_package_fallback {
575                Some(
576                  "no [workspace.package].rust-version found; using [package].rust-version as baseline. \
577consider enabling MSRV inheritance (rust-version = { workspace = true }) to avoid drift."
578                    .to_string(),
579                )
580              } else {
581                None
582              },
583            })
584          }
585          (Some(ws_ver), None) => {
586            // Workspace has rust-version but no deps do
587            Some(ComputedMsrv {
588              version: ws_ver.clone(),
589              contributors: Vec::new(),
590              deps_with_msrv: 0,
591              deps_msrv: None,
592              workspace_msrv: Some(ws_ver.clone()),
593              source_used: MsrvSourceUsed::MaxWorkspace,
594              warning: if used_package_fallback {
595                Some(
596                  "no [workspace.package].rust-version found; using [package].rust-version as baseline. \
597consider enabling MSRV inheritance (rust-version = { workspace = true }) to avoid drift."
598                    .to_string(),
599                )
600              } else {
601                None
602              },
603            })
604          }
605          (None, Some((deps_ver, contributors, deps_with_msrv))) => {
606            // No workspace rust-version, use deps
607            Some(ComputedMsrv {
608              version: deps_ver.clone(),
609              contributors: contributors.clone(),
610              deps_with_msrv: *deps_with_msrv,
611              deps_msrv: Some(deps_ver.clone()),
612              workspace_msrv: None,
613              source_used: MsrvSourceUsed::MaxDeps,
614              warning: None,
615            })
616          }
617          (None, None) => None,
618        }
619      }
620    }
621  }
622}
623
624/// Read the existing rust-version baseline from workspace root Cargo.toml.
625///
626/// Prefers `[workspace.package].rust-version`. If absent, falls back to
627/// `[package].rust-version` (if it is a string value).
628///
629/// Returns `(version, used_package_fallback)`.
630fn read_workspace_rust_version(workspace_root: &Path) -> (Option<Version>, bool) {
631  let cargo_toml_path = workspace_root.join("Cargo.toml");
632  let Ok(content) = std::fs::read_to_string(&cargo_toml_path) else {
633    return (None, false);
634  };
635  let Ok(doc) = content.parse::<toml_edit::DocumentMut>() else {
636    return (None, false);
637  };
638
639  // Try [workspace.package].rust-version
640  let workspace_rust_version_str = doc
641    .get("workspace")
642    .and_then(|ws| ws.get("package"))
643    .and_then(|pkg| pkg.get("rust-version"))
644    .and_then(|v| v.as_str());
645
646  if let Some(s) = workspace_rust_version_str {
647    return (parse_rust_version(s), false);
648  }
649
650  // Fallback: root [package].rust-version (string only, not workspace inheritance)
651  let package_rust_version_str = doc
652    .get("package")
653    .and_then(|pkg| pkg.get("rust-version"))
654    .and_then(|v| v.as_str());
655
656  if let Some(s) = package_rust_version_str {
657    return (parse_rust_version(s), true);
658  }
659
660  (None, false)
661}
662
663/// Parse a rust-version string into a semver Version
664///
665/// Handles formats like "1.70", "1.70.0", etc.
666fn parse_rust_version(s: &str) -> Option<Version> {
667  // Try parsing directly
668  if let Ok(v) = Version::parse(s) {
669    return Some(v);
670  }
671
672  // Handle "1.70" format (missing patch)
673  let parts: Vec<&str> = s.split('.').collect();
674  if parts.len() == 2
675    && let (Ok(major), Ok(minor)) = (parts[0].parse::<u64>(), parts[1].parse::<u64>())
676  {
677    return Some(Version::new(major, minor, 0));
678  }
679
680  None
681}
682
683/// A transitive dependency with fragmented features across targets
684#[derive(Debug, Clone)]
685pub struct FragmentedTransitive {
686  /// Dependency name
687  pub name: String,
688  /// Resolved version (highest across all targets)
689  pub version: Version,
690  /// Features per target
691  pub feature_sets: HashMap<String, HashSet<String>>,
692  /// Union of all features (for pinning)
693  pub unified_features: Vec<String>,
694}
695
696impl FragmentedTransitive {
697  /// Calculate the compilation overhead from fragmentation
698  pub fn overhead_factor(&self) -> usize {
699    self.feature_sets.len()
700  }
701}
702
703/// Result of MSRV computation from dependency graph
704#[derive(Debug, Clone)]
705pub struct ComputedMsrv {
706  /// The final MSRV to write (after applying msrv_source logic)
707  pub version: Version,
708  /// Dependencies that contributed to the deps-based MSRV
709  pub contributors: Vec<String>,
710  /// Total number of deps with rust-version specified
711  pub deps_with_msrv: usize,
712  /// The MSRV computed from dependencies (before applying msrv_source logic)
713  pub deps_msrv: Option<Version>,
714  /// The existing workspace rust-version (if any)
715  pub workspace_msrv: Option<Version>,
716  /// Which source was used to determine the final version
717  pub source_used: MsrvSourceUsed,
718  /// Warning message if workspace MSRV is lower than deps require
719  pub warning: Option<String>,
720}
721
722/// Which source determined the final MSRV
723#[derive(Debug, Clone, Copy, PartialEq, Eq)]
724pub enum MsrvSourceUsed {
725  /// Used the maximum from dependencies
726  Deps,
727  /// Preserved existing workspace rust-version
728  Workspace,
729  /// Used max of workspace and deps (workspace was higher)
730  MaxWorkspace,
731  /// Used max of workspace and deps (deps was higher)
732  MaxDeps,
733}
734
735impl MultiTargetMetadata {
736  /// Build a mapping from package name to library name
737  ///
738  /// In Rust, a crate's package name (in Cargo.toml) can differ from its
739  /// library name (what you `use` in code). For example:
740  /// - Package: `mopa-maintained`
741  /// - Library: `mopa` (what you write as `use mopa::...`)
742  ///
743  /// The resolved dependency graph uses library names, but Cargo.toml uses
744  /// package names. This mapping allows correct lookup when detecting unused deps.
745  ///
746  /// Returns a map where:
747  /// - Key: package name (e.g., "mopa-maintained")
748  /// - Value: library name normalized with underscores (e.g., "mopa")
749  pub fn package_to_lib_name_map(&self) -> HashMap<String, String> {
750    use cargo_metadata::TargetKind;
751
752    let mut map = HashMap::new();
753
754    for entry in self.cache.values() {
755      let metadata = &entry.metadata;
756      for pkg in &metadata.packages {
757        // Find the lib target to get the actual library name
758        let lib_name = pkg
759          .targets
760          .iter()
761          .find(|t| t.kind.contains(&TargetKind::Lib))
762          .map(|t| t.name.clone())
763          .unwrap_or_else(|| pkg.name.to_string());
764
765        // Normalize to match cargo's internal format (underscores)
766        let normalized_lib = lib_name.replace('-', "_");
767        map.insert(pkg.name.to_string(), normalized_lib);
768      }
769    }
770
771    map
772  }
773}
774
775#[cfg(test)]
776mod tests {
777  use super::*;
778
779  #[test]
780  fn test_parse_rust_version_full() {
781    let v = parse_rust_version("1.70.0").unwrap();
782    assert_eq!(v.major, 1);
783    assert_eq!(v.minor, 70);
784    assert_eq!(v.patch, 0);
785  }
786
787  #[test]
788  fn test_parse_rust_version_two_parts() {
789    let v = parse_rust_version("1.70").unwrap();
790    assert_eq!(v.major, 1);
791    assert_eq!(v.minor, 70);
792    assert_eq!(v.patch, 0);
793  }
794
795  #[test]
796  fn test_parse_rust_version_high_minor() {
797    let v = parse_rust_version("1.91").unwrap();
798    assert_eq!(v.major, 1);
799    assert_eq!(v.minor, 91);
800    assert_eq!(v.patch, 0);
801  }
802
803  #[test]
804  fn test_parse_rust_version_invalid() {
805    assert!(parse_rust_version("invalid").is_none());
806    assert!(parse_rust_version("").is_none());
807    assert!(parse_rust_version("1").is_none());
808    assert!(parse_rust_version("a.b.c").is_none());
809  }
810
811  #[test]
812  fn test_msrv_source_used_variants() {
813    // Just ensure the enum variants exist and are distinct
814    assert_ne!(MsrvSourceUsed::Deps, MsrvSourceUsed::Workspace);
815    assert_ne!(MsrvSourceUsed::MaxWorkspace, MsrvSourceUsed::MaxDeps);
816  }
817
818  // Determinism Regression Tests
819  // These tests verify that outputs are deterministic (sorted) to prevent
820  // non-deterministic behavior from HashMap/HashSet iteration order.
821
822  #[test]
823  fn test_targets_returns_sorted_output() {
824    // Verify targets() sorting contract by checking our sort implementation
825    // We can't easily mock Metadata, but we can verify the sorting logic
826    let mut keys = vec!["z-target", "a-target", "m-target"];
827    keys.sort_unstable();
828    assert_eq!(keys, vec!["a-target", "m-target", "z-target"]);
829  }
830
831  #[test]
832  fn test_fragmented_transitive_unified_features_sorting_contract() {
833    // Verify the sorting contract: when constructing FragmentedTransitive,
834    // the caller (find_fragmented_transitives) must sort unified_features.
835    // This test demonstrates the correct construction pattern.
836
837    // Simulate what find_fragmented_transitives does: sort before storing
838    let mut features = vec!["zebra".to_string(), "alpha".to_string(), "beta".to_string()];
839    features.sort_unstable(); // This is what find_fragmented_transitives does
840
841    let transitive = FragmentedTransitive {
842      name: "test-dep".to_string(),
843      version: Version::new(1, 0, 0),
844      feature_sets: HashMap::new(),
845      unified_features: features,
846    };
847
848    // Verify the features are sorted (contract fulfilled)
849    assert!(
850      is_sorted(&transitive.unified_features),
851      "unified_features should be sorted for deterministic output"
852    );
853    assert_eq!(
854      transitive.unified_features,
855      vec!["alpha", "beta", "zebra"],
856      "Features should be in alphabetical order"
857    );
858  }
859
860  #[test]
861  fn test_feature_set_comparison_is_deterministic() {
862    // Regression test: verify that comparing feature sets uses sorted Vecs
863    // This was the bug: HashSet iteration order is non-deterministic, so
864    // comparing HashSets by converting to Vec could give different results.
865
866    let mut set1: HashSet<String> = HashSet::new();
867    set1.insert("c".to_string());
868    set1.insert("a".to_string());
869    set1.insert("b".to_string());
870
871    let mut set2: HashSet<String> = HashSet::new();
872    set2.insert("a".to_string());
873    set2.insert("b".to_string());
874    set2.insert("c".to_string());
875
876    // Convert to sorted Vecs (the fix we implemented)
877    let mut vec1: Vec<_> = set1.iter().cloned().collect();
878    vec1.sort_unstable();
879    let mut vec2: Vec<_> = set2.iter().cloned().collect();
880    vec2.sort_unstable();
881
882    // Now they should be equal regardless of insertion order
883    assert_eq!(vec1, vec2, "Sorted feature sets should be equal");
884    assert_eq!(vec1, vec!["a", "b", "c"]);
885  }
886
887  #[test]
888  fn test_find_fragmented_transitives_output_is_sorted() {
889    // This test verifies the contract that find_fragmented_transitives returns
890    // results sorted by name. We test the sorting logic directly since we can't
891    // easily construct a full MultiTargetMetadata.
892
893    let mut transitives = [
894      FragmentedTransitive {
895        name: "zebra-crate".to_string(),
896        version: Version::new(1, 0, 0),
897        feature_sets: HashMap::new(),
898        unified_features: vec![],
899      },
900      FragmentedTransitive {
901        name: "alpha-crate".to_string(),
902        version: Version::new(1, 0, 0),
903        feature_sets: HashMap::new(),
904        unified_features: vec![],
905      },
906      FragmentedTransitive {
907        name: "middle-crate".to_string(),
908        version: Version::new(1, 0, 0),
909        feature_sets: HashMap::new(),
910        unified_features: vec![],
911      },
912    ];
913
914    // Apply the same sort we use in find_fragmented_transitives
915    transitives.sort_unstable_by(|a, b| a.name.cmp(&b.name));
916
917    assert_eq!(transitives[0].name, "alpha-crate");
918    assert_eq!(transitives[1].name, "middle-crate");
919    assert_eq!(transitives[2].name, "zebra-crate");
920  }
921
922  /// Helper to check if a slice is sorted
923  fn is_sorted(slice: &[String]) -> bool {
924    slice.windows(2).all(|w| w[0] <= w[1])
925  }
926}