Skip to main content

cargo_rail/config/
unify.rs

1//! Unify configuration - controls workspace dependency unification behavior
2
3use serde::{Deserialize, Serialize};
4
5/// Unify configuration - controls workspace dependency unification behavior
6#[derive(Debug, Clone, Serialize, Deserialize)]
7pub struct UnifyConfig {
8  /// Handle path dependencies? (default: true)
9  /// If false, path dependencies are excluded from unification
10  #[serde(default = "default_include_paths")]
11  pub include_paths: bool,
12
13  /// Handle renamed dependencies (package = "...")? (default: false)
14  /// Renamed deps are tricky to unify correctly, opt-in only
15  #[serde(default)]
16  pub include_renamed: bool,
17
18  /// Pin transitive-only deps with fragmented features? (default: false)
19  /// This is cargo-rail's workspace-hack replacement
20  /// When enabled, transitive deps with multiple feature sets are pinned in workspace.dependencies
21  /// Only enable if your project uses cargo-hakari or a workspace-hack crate
22  #[serde(default)]
23  pub pin_transitives: bool,
24
25  /// Where to put pinned transitive dev-deps? (default: "root")
26  /// Options: "root" or a path like "crates/foo"
27  #[serde(default = "default_transitive_host")]
28  pub transitive_host: TransitiveFeatureHost,
29
30  /// Dependencies to exclude from unification (safety hatch)
31  #[serde(default)]
32  pub exclude: Vec<String>,
33
34  /// Dependencies to force-include in unification (safety hatch)
35  #[serde(default)]
36  pub include: Vec<String>,
37
38  /// Maximum number of backups to keep (default: 3)
39  /// Older backups are automatically cleaned up after successful unify operations
40  #[serde(default = "default_max_backups")]
41  pub max_backups: usize,
42
43  /// Compute and write MSRV to workspace manifest? (default: true)
44  /// When enabled, cargo-rail computes the maximum rust-version from all
45  /// resolved dependencies and writes it to [workspace.package].rust-version
46  #[serde(default = "default_true")]
47  pub msrv: bool,
48
49  /// Enforce MSRV inheritance on workspace members (default: false)
50  ///
51  /// When enabled, `cargo rail unify` ensures every workspace member's Cargo.toml
52  /// has `package.rust-version = { workspace = true }`, so that
53  /// `[workspace.package].rust-version` is actually enforced across the workspace.
54  ///
55  /// Note: this requires `msrv = true` and a workspace MSRV that exists or can be computed.
56  #[serde(default)]
57  pub enforce_msrv_inheritance: bool,
58
59  /// How to determine the final MSRV value (default: "max")
60  /// - "deps": Use maximum from dependencies only (original behavior)
61  /// - "workspace": Preserve existing rust-version, warn if deps need higher
62  /// - "max": Take max(workspace, deps) - explicit workspace setting wins if higher
63  #[serde(default)]
64  pub msrv_source: MsrvSource,
65
66  /// Prune features not referenced in source code? (default: true)
67  /// When enabled, analyzes the resolved dependency graph to detect features
68  /// that are declared but never enabled by any consumer across all targets.
69  /// This produces the absolute leanest feature set for the workspace.
70  #[serde(default = "default_true")]
71  pub prune_dead_features: bool,
72
73  /// Features to preserve from dead feature pruning (glob patterns supported)
74  /// Use this to keep features intended for future use or external consumers.
75  /// Examples: ["future-api", "unstable-*", "bench*"]
76  #[serde(default)]
77  pub preserve_features: Vec<String>,
78
79  /// Strict version compatibility checking (default: true)
80  /// When true, version mismatches between member manifests and existing
81  /// workspace.dependencies are reported as blocking errors.
82  /// When false, they are warnings only.
83  #[serde(default = "default_true")]
84  pub strict_version_compat: bool,
85
86  /// How to handle exact version pins like "=0.8.0" (default: "warn")
87  /// - "skip": Exclude exact-pinned deps from unification
88  /// - "preserve": Keep the exact pin operator in workspace.dependencies
89  /// - "warn": Convert to caret but emit a warning
90  #[serde(default)]
91  pub exact_pin_handling: ExactPinHandling,
92
93  /// How to handle major version conflicts (default: "warn")
94  /// - "warn": Skip unification and emit a warning (both versions stay in graph)
95  /// - "bump": Force unify to highest resolved version (user accepts breakage risk)
96  #[serde(default)]
97  pub major_version_conflict: MajorVersionConflict,
98
99  /// Detect unused dependencies in workspace members (default: true)
100  /// When enabled, compares declared deps against the resolved cargo graph
101  /// to find deps that are declared but never actually used.
102  #[serde(default = "default_true")]
103  pub detect_unused: bool,
104
105  /// Automatically remove unused dependencies when applying (default: true)
106  /// Requires detect_unused = true. When enabled, unused deps are removed
107  /// from member Cargo.toml files during unify.
108  #[serde(default = "default_true")]
109  pub remove_unused: bool,
110
111  /// Detect undeclared feature dependencies (default: true)
112  /// When enabled, compares resolved features against declared features in Cargo.toml
113  /// to find crates that rely on Cargo's feature unification to "borrow" features
114  /// from other workspace members. After unification, standalone builds of these
115  /// crates will fail. Reports as warnings to help fix before unification.
116  #[serde(default = "default_true")]
117  pub detect_undeclared_features: bool,
118
119  /// Auto-fix undeclared feature dependencies (default: true)
120  /// When enabled (and detect_undeclared_features is true), automatically adds
121  /// missing features to each crate's Cargo.toml instead of just warning.
122  /// This produces a cleaner graph where standalone builds work correctly.
123  #[serde(default = "default_true")]
124  pub fix_undeclared_features: bool,
125
126  /// Patterns for features to skip in undeclared feature detection (glob supported)
127  /// Default: ["default", "std", "alloc", "*_backend", "*_impl"]
128  /// These are features that are typically not actionable or are implementation details.
129  #[serde(default = "default_skip_undeclared_patterns")]
130  pub skip_undeclared_patterns: Vec<String>,
131
132  /// Sort dependencies alphabetically when writing Cargo.toml files (default: true)
133  /// When false, preserves existing order and appends new deps at end.
134  #[serde(default = "default_true")]
135  pub sort_dependencies: bool,
136}
137
138impl Default for UnifyConfig {
139  fn default() -> Self {
140    Self {
141      include_paths: default_include_paths(),
142      include_renamed: false,
143      pin_transitives: false,
144      transitive_host: default_transitive_host(),
145      exclude: Vec::new(),
146      include: Vec::new(),
147      max_backups: default_max_backups(),
148      msrv: true,
149      enforce_msrv_inheritance: false,
150      msrv_source: MsrvSource::default(),
151      prune_dead_features: true,
152      preserve_features: Vec::new(),
153      strict_version_compat: true,
154      exact_pin_handling: ExactPinHandling::default(),
155      major_version_conflict: MajorVersionConflict::default(),
156      detect_unused: true,
157      remove_unused: true,
158      detect_undeclared_features: true,
159      fix_undeclared_features: true,
160      skip_undeclared_patterns: default_skip_undeclared_patterns(),
161      sort_dependencies: true,
162    }
163  }
164}
165
166impl UnifyConfig {
167  /// Check if a dependency should be excluded from unification
168  pub fn should_exclude(&self, dep_name: &str) -> bool {
169    self.exclude.iter().any(|e| e == dep_name)
170  }
171
172  /// Check if a dependency should be force-included in unification
173  pub fn should_include(&self, dep_name: &str) -> bool {
174    self.include.iter().any(|i| i == dep_name)
175  }
176
177  /// Check if a feature should be preserved from dead feature pruning
178  ///
179  /// Supports glob patterns (e.g., "unstable-*", "bench*")
180  pub fn should_preserve_feature(&self, feature_name: &str) -> bool {
181    self.preserve_features.iter().any(|pattern| {
182      if pattern.contains('*') || pattern.contains('?') || pattern.contains('[') {
183        // Use glob matching for patterns with wildcards
184        glob::Pattern::new(pattern)
185          .map(|p| p.matches(feature_name))
186          .unwrap_or(false)
187      } else {
188        // Exact match for literal patterns
189        pattern == feature_name
190      }
191    })
192  }
193
194  /// Check if a feature should be skipped in undeclared feature detection
195  ///
196  /// Supports glob patterns (e.g., "*_backend", "*_impl")
197  pub fn should_skip_undeclared_feature(&self, feature_name: &str) -> bool {
198    self.skip_undeclared_patterns.iter().any(|pattern| {
199      if pattern.contains('*') || pattern.contains('?') || pattern.contains('[') {
200        // Use glob matching for patterns with wildcards
201        glob::Pattern::new(pattern)
202          .map(|p| p.matches(feature_name))
203          .unwrap_or(false)
204      } else {
205        // Exact match for literal patterns
206        pattern == feature_name
207      }
208    })
209  }
210
211  /// Validate unify configuration against the workspace
212  ///
213  /// Checks:
214  /// - transitive_host path exists if configured as a path (not "root")
215  /// - transitive_host path contains a Cargo.toml (is a valid crate/workspace)
216  pub fn validate(&self, workspace_root: &std::path::Path) -> Result<(), crate::error::ConfigError> {
217    // Only validate if pin_transitives is enabled and transitive_host is a path
218    if self.pin_transitives
219      && let TransitiveFeatureHost::Path(p) = &self.transitive_host
220    {
221      // Check for path traversal (security/consistency)
222      if p.contains("..") {
223        return Err(crate::error::ConfigError::InvalidValue {
224          field: "unify.transitive_host".to_string(),
225          message: format!("path '{}' contains '..' traversal, which is not allowed", p),
226        });
227      }
228
229      // Check path is not absolute
230      if std::path::Path::new(p).is_absolute() {
231        return Err(crate::error::ConfigError::InvalidValue {
232          field: "unify.transitive_host".to_string(),
233          message: format!("path '{}' is absolute, must be relative to workspace root", p),
234        });
235      }
236
237      // Check directory exists
238      let full_path = workspace_root.join(p);
239      if !full_path.exists() {
240        return Err(crate::error::ConfigError::InvalidValue {
241          field: "unify.transitive_host".to_string(),
242          message: format!("path '{}' does not exist", p),
243        });
244      }
245
246      // Check Cargo.toml exists at that path
247      let cargo_toml = full_path.join("Cargo.toml");
248      if !cargo_toml.exists() {
249        return Err(crate::error::ConfigError::InvalidValue {
250          field: "unify.transitive_host".to_string(),
251          message: format!("path '{}' does not contain a Cargo.toml", p),
252        });
253      }
254    }
255
256    Ok(())
257  }
258}
259
260// Helper Types
261
262/// How to determine the final MSRV (Minimum Supported Rust Version)
263///
264/// Controls how cargo-rail computes the rust-version to write to [workspace.package].
265#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
266#[serde(rename_all = "lowercase")]
267pub enum MsrvSource {
268  /// Use maximum from dependencies only (original behavior)
269  ///
270  /// Computes the highest rust-version from all resolved dependencies.
271  /// Overwrites any existing workspace rust-version.
272  Deps,
273  /// Preserve existing workspace rust-version
274  ///
275  /// Keeps the existing [workspace.package].rust-version unchanged.
276  /// Emits a warning if dependencies require a higher version.
277  Workspace,
278  /// Take max(workspace, deps) - default
279  ///
280  /// Uses the higher of the existing workspace rust-version or the
281  /// maximum from dependencies. Your explicit workspace setting wins
282  /// if it requires a higher Rust version than your dependencies.
283  #[default]
284  Max,
285}
286
287/// How to handle exact version pins ("=x.y.z") during unification
288#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
289#[serde(rename_all = "lowercase")]
290pub enum ExactPinHandling {
291  /// Exclude exact-pinned deps from unification entirely
292  Skip,
293  /// Preserve the exact pin operator in workspace.dependencies
294  Preserve,
295  /// Convert to caret (^) but emit a warning (default)
296  #[default]
297  Warn,
298}
299
300/// How to handle major version conflicts during unification
301///
302/// Major version conflicts occur when the same dependency is declared with
303/// different major versions across workspace members (e.g., `serde = "1.0"` and `serde = "2.0"`).
304#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
305#[serde(rename_all = "lowercase")]
306pub enum MajorVersionConflict {
307  /// Skip unification and emit a warning (default)
308  ///
309  /// Both versions remain in the build graph. This is the safe choice when
310  /// you want to avoid breaking changes but may result in duplicate compilation.
311  #[default]
312  Warn,
313  /// Force unify to the highest resolved version
314  ///
315  /// Uses the highest version from the resolved metadata across all target triples.
316  /// This works in ~85% of cases; the remaining ~15% may break the codebase.
317  /// Use when you want the leanest build graph and accept breakage risk.
318  Bump,
319}
320
321/// Configuration for where to add dev-dependencies when consolidating transitive features
322#[derive(Debug, Clone, PartialEq, Default)]
323pub enum TransitiveFeatureHost {
324  /// Use workspace root Cargo.toml (default)
325  #[default]
326  Root,
327  /// Use a specific member crate (relative path from workspace root)
328  Path(String),
329}
330
331// Custom serialization/deserialization for TransitiveFeatureHost
332impl Serialize for TransitiveFeatureHost {
333  fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
334  where
335    S: serde::Serializer,
336  {
337    match self {
338      TransitiveFeatureHost::Root => serializer.serialize_str("root"),
339      TransitiveFeatureHost::Path(path) => serializer.serialize_str(path),
340    }
341  }
342}
343
344impl<'de> Deserialize<'de> for TransitiveFeatureHost {
345  fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
346  where
347    D: serde::Deserializer<'de>,
348  {
349    struct TransitiveFeatureHostVisitor;
350
351    impl serde::de::Visitor<'_> for TransitiveFeatureHostVisitor {
352      type Value = TransitiveFeatureHost;
353
354      fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
355        formatter.write_str("'root' or a path string")
356      }
357
358      fn visit_str<E>(self, value: &str) -> Result<TransitiveFeatureHost, E>
359      where
360        E: serde::de::Error,
361      {
362        match value {
363          "root" => Ok(TransitiveFeatureHost::Root),
364          path => Ok(TransitiveFeatureHost::Path(path.to_string())),
365        }
366      }
367    }
368
369    deserializer.deserialize_any(TransitiveFeatureHostVisitor)
370  }
371}
372
373// Default Functions
374
375fn default_max_backups() -> usize {
376  3
377}
378
379fn default_include_paths() -> bool {
380  true
381}
382
383fn default_transitive_host() -> TransitiveFeatureHost {
384  TransitiveFeatureHost::Root
385}
386
387pub(crate) fn default_true() -> bool {
388  true
389}
390
391fn default_skip_undeclared_patterns() -> Vec<String> {
392  const PATTERNS: &[&str] = &["default", "std", "alloc", "*_backend", "*_impl"];
393  PATTERNS.iter().map(|&s| String::from(s)).collect()
394}
395
396// Tests
397
398#[test]
399fn test_transitive_feature_host_path() {
400  // Test that path format works with simplified config
401  let toml = r#"
402      include_paths = true
403      include_renamed = false
404      pin_transitives = false
405      transitive_host = "path/to/crate"
406      exclude = []
407      include = []
408    "#;
409
410  let config: UnifyConfig = toml_edit::de::from_str(toml).unwrap();
411  assert_eq!(
412    config.transitive_host,
413    TransitiveFeatureHost::Path("path/to/crate".to_string())
414  );
415}
416
417#[cfg(test)]
418mod tests {
419  use super::*;
420
421  #[test]
422  fn test_unify_config_defaults() {
423    let config = UnifyConfig::default();
424    assert!(config.include_paths); // Default: true
425    assert!(!config.include_renamed); // Default: false
426    assert!(!config.pin_transitives); // Default: false (only true for hakari users)
427    assert_eq!(config.transitive_host, TransitiveFeatureHost::Root);
428    assert!(config.exclude.is_empty());
429    assert!(config.include.is_empty());
430    assert!(config.msrv); // Default: true
431    assert!(config.detect_unused); // Default: true
432    assert!(config.remove_unused); // Default: true
433  }
434
435  #[test]
436  fn test_unify_config_should_exclude() {
437    let config = UnifyConfig {
438      exclude: vec!["tokio".to_string(), "serde".to_string()],
439      ..Default::default()
440    };
441    assert!(config.should_exclude("tokio"));
442    assert!(config.should_exclude("serde"));
443    assert!(!config.should_exclude("regex"));
444  }
445
446  #[test]
447  fn test_unify_config_should_include() {
448    let config = UnifyConfig {
449      include: vec!["special-dep".to_string()],
450      ..Default::default()
451    };
452    assert!(config.should_include("special-dep"));
453    assert!(!config.should_include("normal-dep"));
454  }
455
456  #[test]
457  fn test_transitive_feature_host_in_full_config() {
458    let toml = r#"
459      include_paths = true
460      include_renamed = false
461      pin_transitives = true
462      transitive_host = "root"
463      exclude = []
464      include = []
465    "#;
466
467    let config: UnifyConfig = toml_edit::de::from_str(toml).unwrap();
468    assert_eq!(config.transitive_host, TransitiveFeatureHost::Root);
469    assert!(config.include_paths);
470    assert!(config.pin_transitives);
471  }
472
473  #[test]
474  fn test_unify_config_default_transitive_host() {
475    let config = UnifyConfig::default();
476    assert_eq!(config.transitive_host, TransitiveFeatureHost::Root);
477    assert!(!config.pin_transitives); // Default is false (opt-in for hakari users)
478  }
479
480  #[test]
481  fn test_prune_dead_features_default() {
482    let config = UnifyConfig::default();
483    assert!(config.prune_dead_features); // Default: true
484  }
485
486  #[test]
487  fn test_prune_dead_features_parsing() {
488    let toml = r#"prune_dead_features = true"#;
489    let config: UnifyConfig = toml_edit::de::from_str(toml).unwrap();
490    assert!(config.prune_dead_features);
491
492    let toml = r#"prune_dead_features = false"#;
493    let config: UnifyConfig = toml_edit::de::from_str(toml).unwrap();
494    assert!(!config.prune_dead_features);
495  }
496
497  #[test]
498  fn test_strict_version_compat_default() {
499    let config = UnifyConfig::default();
500    assert!(config.strict_version_compat); // Default is true
501  }
502
503  #[test]
504  fn test_exact_pin_handling_default() {
505    let config = UnifyConfig::default();
506    assert_eq!(config.exact_pin_handling, ExactPinHandling::Warn);
507  }
508
509  #[test]
510  fn test_exact_pin_handling_parsing() {
511    let toml = r#"exact_pin_handling = "skip""#;
512    let config: UnifyConfig = toml_edit::de::from_str(toml).unwrap();
513    assert_eq!(config.exact_pin_handling, ExactPinHandling::Skip);
514
515    let toml = r#"exact_pin_handling = "preserve""#;
516    let config: UnifyConfig = toml_edit::de::from_str(toml).unwrap();
517    assert_eq!(config.exact_pin_handling, ExactPinHandling::Preserve);
518
519    let toml = r#"exact_pin_handling = "warn""#;
520    let config: UnifyConfig = toml_edit::de::from_str(toml).unwrap();
521    assert_eq!(config.exact_pin_handling, ExactPinHandling::Warn);
522  }
523
524  #[test]
525  fn test_detect_unused_default() {
526    let config = UnifyConfig::default();
527    assert!(config.detect_unused); // Default: true
528    assert!(config.remove_unused); // Default: true
529  }
530
531  #[test]
532  fn test_new_config_options_parsing() {
533    let toml = r#"
534      strict_version_compat = false
535      exact_pin_handling = "preserve"
536      detect_unused = true
537      remove_unused = true
538    "#;
539    let config: UnifyConfig = toml_edit::de::from_str(toml).unwrap();
540    assert!(!config.strict_version_compat);
541    assert_eq!(config.exact_pin_handling, ExactPinHandling::Preserve);
542    assert!(config.detect_unused);
543    assert!(config.remove_unused);
544  }
545
546  #[test]
547  fn test_major_version_conflict_default() {
548    let config = UnifyConfig::default();
549    assert_eq!(config.major_version_conflict, MajorVersionConflict::Warn);
550  }
551
552  #[test]
553  fn test_major_version_conflict_parsing() {
554    let toml = r#"major_version_conflict = "warn""#;
555    let config: UnifyConfig = toml_edit::de::from_str(toml).unwrap();
556    assert_eq!(config.major_version_conflict, MajorVersionConflict::Warn);
557
558    let toml = r#"major_version_conflict = "bump""#;
559    let config: UnifyConfig = toml_edit::de::from_str(toml).unwrap();
560    assert_eq!(config.major_version_conflict, MajorVersionConflict::Bump);
561  }
562
563  #[test]
564  fn test_major_version_conflict_with_other_options() {
565    let toml = r#"
566      strict_version_compat = false
567      exact_pin_handling = "preserve"
568      major_version_conflict = "bump"
569    "#;
570    let config: UnifyConfig = toml_edit::de::from_str(toml).unwrap();
571    assert!(!config.strict_version_compat);
572    assert_eq!(config.exact_pin_handling, ExactPinHandling::Preserve);
573    assert_eq!(config.major_version_conflict, MajorVersionConflict::Bump);
574  }
575
576  #[test]
577  fn test_transitive_host_validate_root() {
578    // Root should always be valid
579    let config = UnifyConfig {
580      pin_transitives: true,
581      transitive_host: TransitiveFeatureHost::Root,
582      ..Default::default()
583    };
584    let workspace = std::env::current_dir().unwrap();
585    assert!(config.validate(&workspace).is_ok());
586  }
587
588  #[test]
589  fn test_transitive_host_validate_valid_path() {
590    // src/ exists and doesn't have a Cargo.toml - but pin_transitives=false skips validation
591    let config = UnifyConfig {
592      pin_transitives: false,
593      transitive_host: TransitiveFeatureHost::Path("src".to_string()),
594      ..Default::default()
595    };
596    let workspace = std::env::current_dir().unwrap();
597    assert!(config.validate(&workspace).is_ok());
598  }
599
600  #[test]
601  fn test_transitive_host_validate_nonexistent_path() {
602    let config = UnifyConfig {
603      pin_transitives: true,
604      transitive_host: TransitiveFeatureHost::Path("nonexistent/path".to_string()),
605      ..Default::default()
606    };
607    let workspace = std::env::current_dir().unwrap();
608    let result = config.validate(&workspace);
609    assert!(result.is_err());
610    let err = result.unwrap_err();
611    assert!(matches!(err, crate::error::ConfigError::InvalidValue { .. }));
612  }
613
614  #[test]
615  fn test_transitive_host_validate_path_traversal() {
616    let config = UnifyConfig {
617      pin_transitives: true,
618      transitive_host: TransitiveFeatureHost::Path("../somewhere".to_string()),
619      ..Default::default()
620    };
621    let workspace = std::env::current_dir().unwrap();
622    let result = config.validate(&workspace);
623    assert!(result.is_err());
624    let err = result.unwrap_err();
625    if let crate::error::ConfigError::InvalidValue { message, .. } = err {
626      assert!(message.contains(".."));
627    } else {
628      panic!("Expected InvalidValue error");
629    }
630  }
631
632  #[test]
633  fn test_preserve_features_default() {
634    let config = UnifyConfig::default();
635    assert!(config.preserve_features.is_empty());
636  }
637
638  #[test]
639  fn test_preserve_features_parsing() {
640    let toml = r#"
641      preserve_features = ["future-api", "unstable-*", "bench*"]
642    "#;
643    let config: UnifyConfig = toml_edit::de::from_str(toml).unwrap();
644    assert_eq!(config.preserve_features.len(), 3);
645    assert!(config.preserve_features.contains(&"future-api".to_string()));
646    assert!(config.preserve_features.contains(&"unstable-*".to_string()));
647    assert!(config.preserve_features.contains(&"bench*".to_string()));
648  }
649
650  #[test]
651  fn test_should_preserve_feature_exact_match() {
652    let config = UnifyConfig {
653      preserve_features: vec!["future-api".to_string(), "experimental".to_string()],
654      ..Default::default()
655    };
656    assert!(config.should_preserve_feature("future-api"));
657    assert!(config.should_preserve_feature("experimental"));
658    assert!(!config.should_preserve_feature("other-feature"));
659  }
660
661  #[test]
662  fn test_should_preserve_feature_glob_wildcard() {
663    let config = UnifyConfig {
664      preserve_features: vec!["unstable-*".to_string()],
665      ..Default::default()
666    };
667    assert!(config.should_preserve_feature("unstable-api"));
668    assert!(config.should_preserve_feature("unstable-feature"));
669    assert!(config.should_preserve_feature("unstable-"));
670    assert!(!config.should_preserve_feature("unstable")); // No trailing dash
671    assert!(!config.should_preserve_feature("stable-api"));
672  }
673
674  #[test]
675  fn test_should_preserve_feature_glob_suffix() {
676    let config = UnifyConfig {
677      preserve_features: vec!["bench*".to_string()],
678      ..Default::default()
679    };
680    assert!(config.should_preserve_feature("bench"));
681    assert!(config.should_preserve_feature("benchmark"));
682    assert!(config.should_preserve_feature("benchmarks"));
683    assert!(!config.should_preserve_feature("prebench"));
684  }
685
686  #[test]
687  fn test_should_preserve_feature_glob_question_mark() {
688    let config = UnifyConfig {
689      preserve_features: vec!["test-?".to_string()],
690      ..Default::default()
691    };
692    assert!(config.should_preserve_feature("test-a"));
693    assert!(config.should_preserve_feature("test-1"));
694    assert!(!config.should_preserve_feature("test-ab")); // Two chars
695    assert!(!config.should_preserve_feature("test-")); // No char
696  }
697
698  #[test]
699  fn test_should_preserve_feature_multiple_patterns() {
700    let config = UnifyConfig {
701      preserve_features: vec!["future-api".to_string(), "unstable-*".to_string(), "bench*".to_string()],
702      ..Default::default()
703    };
704    // Exact match
705    assert!(config.should_preserve_feature("future-api"));
706    // Glob matches
707    assert!(config.should_preserve_feature("unstable-feature"));
708    assert!(config.should_preserve_feature("benchmark"));
709    // Non-matches
710    assert!(!config.should_preserve_feature("stable-api"));
711    assert!(!config.should_preserve_feature("other"));
712  }
713
714  #[test]
715  fn test_should_preserve_feature_empty_list() {
716    let config = UnifyConfig::default();
717    assert!(!config.should_preserve_feature("any-feature"));
718  }
719
720  #[test]
721  fn test_msrv_source_default() {
722    let config = UnifyConfig::default();
723    assert_eq!(config.msrv_source, MsrvSource::Max);
724  }
725
726  #[test]
727  fn test_msrv_source_parsing_deps() {
728    let toml = r#"msrv_source = "deps""#;
729    let config: UnifyConfig = toml_edit::de::from_str(toml).unwrap();
730    assert_eq!(config.msrv_source, MsrvSource::Deps);
731  }
732
733  #[test]
734  fn test_msrv_source_parsing_workspace() {
735    let toml = r#"msrv_source = "workspace""#;
736    let config: UnifyConfig = toml_edit::de::from_str(toml).unwrap();
737    assert_eq!(config.msrv_source, MsrvSource::Workspace);
738  }
739
740  #[test]
741  fn test_msrv_source_parsing_max() {
742    let toml = r#"msrv_source = "max""#;
743    let config: UnifyConfig = toml_edit::de::from_str(toml).unwrap();
744    assert_eq!(config.msrv_source, MsrvSource::Max);
745  }
746
747  #[test]
748  fn test_msrv_source_with_msrv_enabled() {
749    let toml = r#"
750      msrv = true
751      msrv_source = "workspace"
752    "#;
753    let config: UnifyConfig = toml_edit::de::from_str(toml).unwrap();
754    assert!(config.msrv);
755    assert_eq!(config.msrv_source, MsrvSource::Workspace);
756  }
757
758  #[test]
759  fn test_msrv_source_with_msrv_disabled() {
760    let toml = r#"
761      msrv = false
762      msrv_source = "deps"
763    "#;
764    let config: UnifyConfig = toml_edit::de::from_str(toml).unwrap();
765    assert!(!config.msrv);
766    assert_eq!(config.msrv_source, MsrvSource::Deps);
767  }
768
769  #[test]
770  fn test_detect_undeclared_features_default() {
771    let config = UnifyConfig::default();
772    assert!(config.detect_undeclared_features); // Default: true
773  }
774
775  #[test]
776  fn test_fix_undeclared_features_default() {
777    let config = UnifyConfig::default();
778    assert!(config.fix_undeclared_features); // Default: true
779  }
780
781  #[test]
782  fn test_detect_undeclared_features_parsing_true() {
783    let toml = r#"detect_undeclared_features = true"#;
784    let config: UnifyConfig = toml_edit::de::from_str(toml).unwrap();
785    assert!(config.detect_undeclared_features);
786  }
787
788  #[test]
789  fn test_detect_undeclared_features_parsing_false() {
790    let toml = r#"detect_undeclared_features = false"#;
791    let config: UnifyConfig = toml_edit::de::from_str(toml).unwrap();
792    assert!(!config.detect_undeclared_features);
793  }
794
795  #[test]
796  fn test_fix_undeclared_features_parsing_true() {
797    let toml = r#"fix_undeclared_features = true"#;
798    let config: UnifyConfig = toml_edit::de::from_str(toml).unwrap();
799    assert!(config.fix_undeclared_features);
800  }
801
802  #[test]
803  fn test_fix_undeclared_features_parsing_false() {
804    let toml = r#"fix_undeclared_features = false"#;
805    let config: UnifyConfig = toml_edit::de::from_str(toml).unwrap();
806    assert!(!config.fix_undeclared_features);
807  }
808
809  #[test]
810  fn test_undeclared_features_both_options() {
811    let toml = r#"
812      detect_undeclared_features = true
813      fix_undeclared_features = false
814    "#;
815    let config: UnifyConfig = toml_edit::de::from_str(toml).unwrap();
816    assert!(config.detect_undeclared_features);
817    assert!(!config.fix_undeclared_features);
818  }
819
820  #[test]
821  fn test_undeclared_features_with_other_options() {
822    let toml = r#"
823      detect_unused = true
824      remove_unused = true
825      detect_undeclared_features = true
826      fix_undeclared_features = true
827      prune_dead_features = false
828    "#;
829    let config: UnifyConfig = toml_edit::de::from_str(toml).unwrap();
830    assert!(config.detect_unused);
831    assert!(config.remove_unused);
832    assert!(config.detect_undeclared_features);
833    assert!(config.fix_undeclared_features);
834    assert!(!config.prune_dead_features);
835  }
836
837  #[test]
838  fn test_undeclared_features_detect_disabled_fix_enabled() {
839    // Edge case: fix enabled but detect disabled
840    // This is a valid config but fix won't do anything if detect is off
841    let toml = r#"
842      detect_undeclared_features = false
843      fix_undeclared_features = true
844    "#;
845    let config: UnifyConfig = toml_edit::de::from_str(toml).unwrap();
846    assert!(!config.detect_undeclared_features);
847    assert!(config.fix_undeclared_features);
848  }
849
850  // skip_undeclared_patterns Tests
851
852  #[test]
853  fn test_skip_undeclared_patterns_default() {
854    let config = UnifyConfig::default();
855    assert!(!config.skip_undeclared_patterns.is_empty());
856    assert!(config.skip_undeclared_patterns.contains(&"default".to_string()));
857    assert!(config.skip_undeclared_patterns.contains(&"std".to_string()));
858    assert!(config.skip_undeclared_patterns.contains(&"alloc".to_string()));
859    assert!(config.skip_undeclared_patterns.contains(&"*_backend".to_string()));
860    assert!(config.skip_undeclared_patterns.contains(&"*_impl".to_string()));
861  }
862
863  #[test]
864  fn test_skip_undeclared_patterns_parsing() {
865    let toml = r#"
866      skip_undeclared_patterns = ["default", "std", "custom-*"]
867    "#;
868    let config: UnifyConfig = toml_edit::de::from_str(toml).unwrap();
869    assert_eq!(config.skip_undeclared_patterns.len(), 3);
870    assert!(config.skip_undeclared_patterns.contains(&"default".to_string()));
871    assert!(config.skip_undeclared_patterns.contains(&"std".to_string()));
872    assert!(config.skip_undeclared_patterns.contains(&"custom-*".to_string()));
873  }
874
875  #[test]
876  fn test_skip_undeclared_patterns_empty() {
877    let toml = r#"
878      skip_undeclared_patterns = []
879    "#;
880    let config: UnifyConfig = toml_edit::de::from_str(toml).unwrap();
881    assert!(config.skip_undeclared_patterns.is_empty());
882  }
883
884  #[test]
885  fn test_should_skip_undeclared_feature_exact_match() {
886    let config = UnifyConfig {
887      skip_undeclared_patterns: vec!["default".to_string(), "std".to_string()],
888      ..Default::default()
889    };
890    assert!(config.should_skip_undeclared_feature("default"));
891    assert!(config.should_skip_undeclared_feature("std"));
892    assert!(!config.should_skip_undeclared_feature("derive"));
893  }
894
895  #[test]
896  fn test_should_skip_undeclared_feature_glob_suffix() {
897    let config = UnifyConfig {
898      skip_undeclared_patterns: vec!["*_backend".to_string()],
899      ..Default::default()
900    };
901    assert!(config.should_skip_undeclared_feature("sqlite_backend"));
902    assert!(config.should_skip_undeclared_feature("postgres_backend"));
903    assert!(config.should_skip_undeclared_feature("_backend")); // Just suffix
904    assert!(!config.should_skip_undeclared_feature("backend"));
905    assert!(!config.should_skip_undeclared_feature("backend_"));
906  }
907
908  #[test]
909  fn test_should_skip_undeclared_feature_glob_prefix() {
910    let config = UnifyConfig {
911      skip_undeclared_patterns: vec!["unstable-*".to_string()],
912      ..Default::default()
913    };
914    assert!(config.should_skip_undeclared_feature("unstable-api"));
915    assert!(config.should_skip_undeclared_feature("unstable-internal"));
916    assert!(config.should_skip_undeclared_feature("unstable-")); // Just prefix
917    assert!(!config.should_skip_undeclared_feature("unstable"));
918  }
919
920  #[test]
921  fn test_should_skip_undeclared_feature_glob_question_mark() {
922    let config = UnifyConfig {
923      skip_undeclared_patterns: vec!["test-?".to_string()],
924      ..Default::default()
925    };
926    assert!(config.should_skip_undeclared_feature("test-1"));
927    assert!(config.should_skip_undeclared_feature("test-a"));
928    assert!(!config.should_skip_undeclared_feature("test-12"));
929    assert!(!config.should_skip_undeclared_feature("test-"));
930  }
931
932  #[test]
933  fn test_should_skip_undeclared_feature_multiple_patterns() {
934    let config = UnifyConfig {
935      skip_undeclared_patterns: vec![
936        "default".to_string(),
937        "std".to_string(),
938        "*_backend".to_string(),
939        "*_impl".to_string(),
940      ],
941      ..Default::default()
942    };
943    assert!(config.should_skip_undeclared_feature("default"));
944    assert!(config.should_skip_undeclared_feature("std"));
945    assert!(config.should_skip_undeclared_feature("sqlite_backend"));
946    assert!(config.should_skip_undeclared_feature("sync_impl"));
947    assert!(!config.should_skip_undeclared_feature("derive"));
948    assert!(!config.should_skip_undeclared_feature("serde"));
949  }
950
951  #[test]
952  fn test_should_skip_undeclared_feature_empty_patterns() {
953    let config = UnifyConfig {
954      skip_undeclared_patterns: vec![],
955      ..Default::default()
956    };
957    // Nothing should be skipped with empty patterns
958    assert!(!config.should_skip_undeclared_feature("default"));
959    assert!(!config.should_skip_undeclared_feature("std"));
960    assert!(!config.should_skip_undeclared_feature("anything"));
961  }
962
963  // sort_dependencies Tests
964
965  #[test]
966  fn test_sort_dependencies_default() {
967    let config = UnifyConfig::default();
968    assert!(config.sort_dependencies); // Default is true (alphabetical)
969  }
970
971  #[test]
972  fn test_sort_dependencies_parsing_true() {
973    let toml = r#"sort_dependencies = true"#;
974    let config: UnifyConfig = toml_edit::de::from_str(toml).unwrap();
975    assert!(config.sort_dependencies);
976  }
977
978  #[test]
979  fn test_sort_dependencies_parsing_false() {
980    let toml = r#"sort_dependencies = false"#;
981    let config: UnifyConfig = toml_edit::de::from_str(toml).unwrap();
982    assert!(!config.sort_dependencies);
983  }
984
985  #[test]
986  fn test_sort_dependencies_with_other_options() {
987    let toml = r#"
988      detect_unused = true
989      remove_unused = true
990      sort_dependencies = false
991    "#;
992    let config: UnifyConfig = toml_edit::de::from_str(toml).unwrap();
993    assert!(config.detect_unused);
994    assert!(config.remove_unused);
995    assert!(!config.sort_dependencies);
996  }
997}