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