use serde::{Deserialize, Deserializer, Serialize, Serializer, de};
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub enum CompilerTargetSelection {
#[default]
All,
None,
Explicit(Vec<String>),
}
impl Serialize for CompilerTargetSelection {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
match self {
Self::All => serializer.serialize_str("all"),
Self::None => serializer.serialize_str("none"),
Self::Explicit(targets) => targets.serialize(serializer),
}
}
}
impl<'de> Deserialize<'de> for CompilerTargetSelection {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
#[derive(Deserialize)]
#[serde(untagged)]
enum Representation {
Mode(String),
Explicit(Vec<String>),
}
match Representation::deserialize(deserializer)? {
Representation::Mode(mode) if mode == "all" => Ok(Self::All),
Representation::Mode(mode) if mode == "none" => Ok(Self::None),
Representation::Mode(mode) => Err(de::Error::custom(format!(
"unify.compiler_targets mode must be 'all' or 'none', found '{mode}'"
))),
Representation::Explicit(targets) if targets.is_empty() => Ok(Self::All),
Representation::Explicit(targets) => Ok(Self::Explicit(targets)),
}
}
}
impl CompilerTargetSelection {
pub(crate) fn inherits_all(&self) -> bool {
matches!(self, Self::All)
}
fn explicit(&self) -> &[String] {
match self {
Self::Explicit(targets) => targets,
Self::All | Self::None => &[],
}
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum ConsumerScope {
#[default]
Open,
Workspace,
}
impl ConsumerScope {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Open => "open",
Self::Workspace => "workspace",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct UnifyConfig {
#[serde(default)]
pub cargo_manifests: Vec<std::path::PathBuf>,
#[serde(default)]
pub compiler_targets: CompilerTargetSelection,
#[serde(default = "default_include_paths")]
pub include_paths: bool,
#[serde(default)]
pub include_renamed: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub transitive_pinning: Option<TransitivePinning>,
#[serde(default)]
pub exclude: Vec<String>,
#[serde(default)]
pub include: Vec<String>,
#[serde(default = "default_max_backups")]
pub max_backups: usize,
#[serde(default = "default_compiler_artifact_soft_limit_bytes")]
pub compiler_artifact_soft_limit_bytes: u64,
#[serde(default = "default_compiler_artifact_hard_limit_bytes")]
pub compiler_artifact_hard_limit_bytes: u64,
#[serde(default)]
pub msrv_policy: MsrvPolicy,
#[serde(default)]
pub consumer_scope: ConsumerScope,
#[serde(default)]
pub preserve_features: Vec<String>,
#[serde(default = "default_true")]
pub strict_version_compat: bool,
#[serde(default)]
pub exact_pin_handling: ExactPinHandling,
#[serde(default)]
pub major_version_conflict: MajorVersionConflict,
#[serde(default = "default_skip_undeclared_patterns")]
pub skip_undeclared_patterns: Vec<String>,
}
impl Default for UnifyConfig {
fn default() -> Self {
Self {
cargo_manifests: Vec::new(),
compiler_targets: CompilerTargetSelection::All,
include_paths: default_include_paths(),
include_renamed: false,
transitive_pinning: None,
exclude: Vec::new(),
include: Vec::new(),
max_backups: default_max_backups(),
compiler_artifact_soft_limit_bytes: default_compiler_artifact_soft_limit_bytes(),
compiler_artifact_hard_limit_bytes: default_compiler_artifact_hard_limit_bytes(),
msrv_policy: MsrvPolicy::default(),
consumer_scope: ConsumerScope::default(),
preserve_features: Vec::new(),
strict_version_compat: true,
exact_pin_handling: ExactPinHandling::default(),
major_version_conflict: MajorVersionConflict::default(),
skip_undeclared_patterns: default_skip_undeclared_patterns(),
}
}
}
impl UnifyConfig {
pub fn effective_compiler_targets<'a>(&self, workspace_targets: &[&'a str]) -> Vec<&'a str> {
match &self.compiler_targets {
CompilerTargetSelection::All => workspace_targets.to_vec(),
CompilerTargetSelection::None => Vec::new(),
CompilerTargetSelection::Explicit(selected) => workspace_targets
.iter()
.copied()
.filter(|target| selected.iter().any(|configured| configured == target))
.collect(),
}
}
pub fn validate_workspace_targets(&self, workspace_targets: &[String]) -> Result<(), crate::error::ConfigError> {
let mut unique = std::collections::BTreeSet::new();
for target in self.compiler_targets.explicit() {
if target.trim().is_empty() || target != target.trim() {
return Err(crate::error::ConfigError::InvalidValue {
field: "unify.compiler_targets".to_string(),
message: "targets must be non-empty and have no surrounding whitespace".to_string(),
});
}
if !unique.insert(target) {
return Err(crate::error::ConfigError::InvalidValue {
field: "unify.compiler_targets".to_string(),
message: format!("contains duplicate target '{target}'"),
});
}
if !workspace_targets.contains(target) {
return Err(crate::error::ConfigError::InvalidValue {
field: "unify.compiler_targets".to_string(),
message: format!("target '{target}' is not declared in top-level targets"),
});
}
}
Ok(())
}
pub fn should_exclude(&self, dep_name: &str) -> bool {
self.exclude.iter().any(|e| e == dep_name)
}
pub fn should_include(&self, dep_name: &str) -> bool {
self.include.iter().any(|i| i == dep_name)
}
pub fn should_preserve_feature(&self, feature_name: &str) -> bool {
matches_feature_pattern(&self.preserve_features, feature_name)
}
pub fn should_skip_undeclared_feature(&self, feature_name: &str) -> bool {
matches_feature_pattern(&self.skip_undeclared_patterns, feature_name)
}
pub fn validate(&self, workspace_root: &std::path::Path) -> Result<(), crate::error::ConfigError> {
self.validate_policy()?;
self.validate_host(workspace_root)
}
pub(crate) fn validate_policy(&self) -> Result<(), crate::error::ConfigError> {
let mut manifests = std::collections::BTreeSet::new();
for path in &self.cargo_manifests {
if path.is_absolute()
|| path
.components()
.any(|component| !matches!(component, std::path::Component::Normal(_)))
|| path.file_name().is_none_or(|name| name != "Cargo.toml")
|| path == std::path::Path::new("Cargo.toml")
|| !manifests.insert(path)
{
return Err(crate::error::ConfigError::InvalidField {
field: "unify.cargo_manifests".into(),
reason: "use unique workspace-relative standalone Cargo.toml paths without traversal".into(),
});
}
}
validate_glob_patterns("unify.preserve_features", &self.preserve_features)?;
validate_glob_patterns("unify.skip_undeclared_patterns", &self.skip_undeclared_patterns)?;
if self.compiler_artifact_soft_limit_bytes == 0 {
return Err(crate::error::ConfigError::InvalidValue {
field: "unify.compiler_artifact_soft_limit_bytes".to_string(),
message: "must be greater than zero".to_string(),
});
}
if self.compiler_artifact_hard_limit_bytes < self.compiler_artifact_soft_limit_bytes {
return Err(crate::error::ConfigError::InvalidValue {
field: "unify.compiler_artifact_hard_limit_bytes".to_string(),
message: "must be greater than or equal to compiler_artifact_soft_limit_bytes".to_string(),
});
}
if let Some(TransitivePinning {
host: TransitiveFeatureHost::Path(p),
}) = &self.transitive_pinning
{
if p.contains("..") {
return Err(crate::error::ConfigError::InvalidValue {
field: "unify.transitive_pinning.host".to_string(),
message: format!("path '{}' contains '..' traversal, which is not allowed", p),
});
}
if std::path::Path::new(p).is_absolute() {
return Err(crate::error::ConfigError::InvalidValue {
field: "unify.transitive_pinning.host".to_string(),
message: format!("path '{}' is absolute, must be relative to workspace root", p),
});
}
}
Ok(())
}
pub(crate) fn transitive_host_path(&self) -> Option<&str> {
match self.transitive_pinning.as_ref().map(|pinning| &pinning.host) {
Some(TransitiveFeatureHost::Path(path)) => Some(path),
_ => None,
}
}
pub(crate) fn validate_host(&self, workspace_root: &std::path::Path) -> Result<(), crate::error::ConfigError> {
if let Some(p) = self.transitive_host_path() {
let full_path = workspace_root.join(p);
if !full_path.exists() {
return Err(crate::error::ConfigError::InvalidValue {
field: "unify.transitive_pinning.host".to_string(),
message: format!("path '{}' does not exist", p),
});
}
let cargo_toml = full_path.join("Cargo.toml");
if !cargo_toml.exists() {
return Err(crate::error::ConfigError::InvalidValue {
field: "unify.transitive_pinning.host".to_string(),
message: format!("path '{}' does not contain a Cargo.toml", p),
});
}
}
Ok(())
}
}
fn matches_feature_pattern(patterns: &[String], feature_name: &str) -> bool {
patterns.iter().any(|pattern| {
if pattern.contains(['*', '?', '[']) {
glob::Pattern::new(pattern).is_ok_and(|pattern| pattern.matches(feature_name))
} else {
pattern == feature_name
}
})
}
fn validate_glob_patterns(field: &str, patterns: &[String]) -> Result<(), crate::error::ConfigError> {
for pattern in patterns {
glob::Pattern::new(pattern).map_err(|error| crate::error::ConfigError::InvalidGlobPattern {
pattern: pattern.clone(),
message: format!("{field}: {error}"),
})?;
}
Ok(())
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum MsrvSource {
Deps,
Workspace,
#[default]
Max,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ExactPinHandling {
Skip,
Preserve,
#[default]
Warn,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum MajorVersionConflict {
#[default]
Warn,
Bump,
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub enum TransitiveFeatureHost {
#[default]
Root,
Path(String),
}
impl Serialize for TransitiveFeatureHost {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
match self {
TransitiveFeatureHost::Root => serializer.serialize_str("root"),
TransitiveFeatureHost::Path(path) => serializer.serialize_str(path),
}
}
}
impl<'de> Deserialize<'de> for TransitiveFeatureHost {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
struct TransitiveFeatureHostVisitor;
impl serde::de::Visitor<'_> for TransitiveFeatureHostVisitor {
type Value = TransitiveFeatureHost;
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
formatter.write_str("'root' or a path string")
}
fn visit_str<E>(self, value: &str) -> Result<TransitiveFeatureHost, E>
where
E: serde::de::Error,
{
match value {
"root" => Ok(TransitiveFeatureHost::Root),
path => Ok(TransitiveFeatureHost::Path(path.to_string())),
}
}
}
deserializer.deserialize_any(TransitiveFeatureHostVisitor)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct TransitivePinning {
#[serde(default)]
pub host: TransitiveFeatureHost,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(tag = "mode", rename_all = "lowercase", deny_unknown_fields)]
pub enum MsrvPolicy {
Disabled,
Compute {
#[serde(default)]
source: MsrvSource,
#[serde(default)]
inherit: bool,
},
}
#[derive(Debug, Clone, Copy, Default, Deserialize)]
#[serde(rename_all = "lowercase")]
enum MsrvPolicyMode {
Disabled,
#[default]
Compute,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct MsrvPolicyInput {
#[serde(default)]
mode: MsrvPolicyMode,
source: Option<MsrvSource>,
inherit: Option<bool>,
}
impl<'de> Deserialize<'de> for MsrvPolicy {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let input = MsrvPolicyInput::deserialize(deserializer)?;
match input.mode {
MsrvPolicyMode::Disabled if input.source.is_some() || input.inherit.is_some() => Err(
serde::de::Error::custom("disabled MSRV policy does not accept `source` or `inherit`"),
),
MsrvPolicyMode::Disabled => Ok(Self::Disabled),
MsrvPolicyMode::Compute => Ok(Self::Compute {
source: input.source.unwrap_or_default(),
inherit: input.inherit.unwrap_or_default(),
}),
}
}
}
impl Default for MsrvPolicy {
fn default() -> Self {
Self::Compute {
source: MsrvSource::default(),
inherit: false,
}
}
}
impl MsrvPolicy {
pub const fn source(self) -> Option<MsrvSource> {
match self {
Self::Disabled => None,
Self::Compute { source, .. } => Some(source),
}
}
pub const fn inherits(self) -> bool {
matches!(self, Self::Compute { inherit: true, .. })
}
}
fn default_max_backups() -> usize {
3
}
const fn default_compiler_artifact_soft_limit_bytes() -> u64 {
32 * 1024 * 1024 * 1024
}
const fn default_compiler_artifact_hard_limit_bytes() -> u64 {
64 * 1024 * 1024 * 1024
}
fn default_include_paths() -> bool {
true
}
pub(crate) fn default_true() -> bool {
true
}
fn default_skip_undeclared_patterns() -> Vec<String> {
const PATTERNS: &[&str] = &["default", "std", "alloc", "*_backend", "*_impl"];
PATTERNS.iter().map(|&s| String::from(s)).collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_unify_config_defaults() {
let config = UnifyConfig::default();
assert_eq!(config.compiler_targets, CompilerTargetSelection::All);
assert!(config.include_paths); assert!(!config.include_renamed); assert!(config.transitive_pinning.is_none());
assert!(config.exclude.is_empty());
assert!(config.include.is_empty());
assert_eq!(config.msrv_policy.source(), Some(MsrvSource::Max));
assert_eq!(config.consumer_scope, ConsumerScope::Open);
assert_eq!(config.compiler_artifact_soft_limit_bytes, 32 * 1024 * 1024 * 1024);
assert_eq!(config.compiler_artifact_hard_limit_bytes, 64 * 1024 * 1024 * 1024);
}
#[test]
fn compiler_targets_are_an_exact_resolution_subset() {
let config: UnifyConfig =
toml_edit::de::from_str(r#"compiler_targets = ["aarch64-apple-darwin", "x86_64-unknown-linux-gnu"]"#)
.unwrap();
let workspace_targets = [
"aarch64-apple-darwin".to_string(),
"thumbv6m-none-eabi".to_string(),
"x86_64-unknown-linux-gnu".to_string(),
];
config.validate_workspace_targets(&workspace_targets).unwrap();
let resolution_targets = workspace_targets.iter().map(String::as_str).collect::<Vec<_>>();
assert_eq!(
config.effective_compiler_targets(&resolution_targets),
["aarch64-apple-darwin", "x86_64-unknown-linux-gnu"]
);
let outside: UnifyConfig = toml_edit::de::from_str(r#"compiler_targets = ["wasm32-wasip1"]"#).unwrap();
assert!(outside.validate_workspace_targets(&workspace_targets).is_err());
let duplicate: UnifyConfig =
toml_edit::de::from_str(r#"compiler_targets = ["aarch64-apple-darwin", "aarch64-apple-darwin"]"#).unwrap();
assert!(duplicate.validate_workspace_targets(&workspace_targets).is_err());
}
#[test]
fn compiler_target_modes_keep_legacy_empty_lists_on_every_target() {
let resolution_targets = ["aarch64-apple-darwin", "thumbv6m-none-eabi"];
for input in ["", "compiler_targets = \"all\"", "compiler_targets = []"] {
let config: UnifyConfig = toml_edit::de::from_str(input).unwrap();
assert_eq!(config.compiler_targets, CompilerTargetSelection::All, "{input}");
assert_eq!(
config.effective_compiler_targets(&resolution_targets),
resolution_targets,
"{input}"
);
assert!(
toml_edit::ser::to_string(&config)
.unwrap()
.contains("compiler_targets = \"all\""),
"{input}"
);
}
let none: UnifyConfig = toml_edit::de::from_str("compiler_targets = \"none\"").unwrap();
none.validate_workspace_targets(&resolution_targets.map(str::to_string))
.unwrap();
assert!(none.effective_compiler_targets(&resolution_targets).is_empty());
assert!(
toml_edit::ser::to_string(&none)
.unwrap()
.contains("compiler_targets = \"none\"")
);
let error = toml_edit::de::from_str::<UnifyConfig>("compiler_targets = \"host\"").unwrap_err();
assert!(error.to_string().contains("must be 'all' or 'none'"), "{error}");
}
#[test]
fn compiler_artifact_budget_rejects_zero_or_reversed_limits() {
let workspace = tempfile::tempdir().expect("workspace");
let zero = UnifyConfig {
compiler_artifact_soft_limit_bytes: 0,
..UnifyConfig::default()
};
assert!(
zero.validate(workspace.path())
.expect_err("zero soft limit")
.to_string()
.contains("soft_limit_bytes")
);
let reversed = UnifyConfig {
compiler_artifact_soft_limit_bytes: 2048,
compiler_artifact_hard_limit_bytes: 1024,
..UnifyConfig::default()
};
assert!(
reversed
.validate(workspace.path())
.expect_err("reversed limits")
.to_string()
.contains("hard_limit_bytes")
);
}
#[test]
fn test_unify_config_should_exclude() {
let config = UnifyConfig {
exclude: vec!["tokio".to_string(), "serde".to_string()],
..Default::default()
};
assert!(config.should_exclude("tokio"));
assert!(config.should_exclude("serde"));
assert!(!config.should_exclude("regex"));
}
#[test]
fn test_unify_config_should_include() {
let config = UnifyConfig {
include: vec!["special-dep".to_string()],
..Default::default()
};
assert!(config.should_include("special-dep"));
assert!(!config.should_include("normal-dep"));
}
#[test]
fn test_unify_config_default_transitive_host() {
let config = UnifyConfig::default();
assert!(config.transitive_pinning.is_none());
}
#[test]
fn typed_unify_policies_parse_without_invalid_combinations() {
let config: UnifyConfig = toml_edit::de::from_str(
r#"
transitive_pinning = { host = "crates/host" }
msrv_policy = { mode = "compute", source = "workspace", inherit = true }
"#,
)
.unwrap();
assert_eq!(
config.transitive_pinning,
Some(TransitivePinning {
host: TransitiveFeatureHost::Path("crates/host".to_string())
})
);
assert_eq!(
config.msrv_policy,
MsrvPolicy::Compute {
source: MsrvSource::Workspace,
inherit: true
}
);
}
#[test]
fn msrv_policy_defaults_partial_compute_and_preserves_explicit_modes() {
let partial: UnifyConfig = toml_edit::de::from_str("msrv_policy = { inherit = true }").unwrap();
assert_eq!(
partial.msrv_policy,
MsrvPolicy::Compute {
source: MsrvSource::Max,
inherit: true,
}
);
let complete: UnifyConfig =
toml_edit::de::from_str(r#"msrv_policy = { mode = "compute", source = "workspace", inherit = false }"#)
.unwrap();
assert_eq!(
complete.msrv_policy,
MsrvPolicy::Compute {
source: MsrvSource::Workspace,
inherit: false,
}
);
let disabled: UnifyConfig = toml_edit::de::from_str(r#"msrv_policy = { mode = "disabled" }"#).unwrap();
assert_eq!(disabled.msrv_policy, MsrvPolicy::Disabled);
assert!(
toml_edit::de::from_str::<UnifyConfig>(r#"msrv_policy = { mode = "disabled", inherit = false }"#,).is_err(),
"disabled policy must reject compute-only fields"
);
}
#[test]
fn test_consumer_scope_requires_explicit_workspace_contract() {
let default = UnifyConfig::default();
assert_eq!(default.consumer_scope, ConsumerScope::Open);
let config: UnifyConfig = toml_edit::de::from_str("consumer_scope = \"workspace\"").unwrap();
assert_eq!(config.consumer_scope, ConsumerScope::Workspace);
let invalid = toml_edit::de::from_str::<UnifyConfig>("consumer_scope = \"private\"");
assert!(
invalid.is_err(),
"unknown trust boundaries must fail configuration parsing"
);
}
#[test]
fn test_strict_version_compat_default() {
let config = UnifyConfig::default();
assert!(config.strict_version_compat); }
#[test]
fn test_exact_pin_handling_default() {
let config = UnifyConfig::default();
assert_eq!(config.exact_pin_handling, ExactPinHandling::Warn);
}
#[test]
fn test_exact_pin_handling_parsing() {
let toml = r#"exact_pin_handling = "skip""#;
let config: UnifyConfig = toml_edit::de::from_str(toml).unwrap();
assert_eq!(config.exact_pin_handling, ExactPinHandling::Skip);
let toml = r#"exact_pin_handling = "preserve""#;
let config: UnifyConfig = toml_edit::de::from_str(toml).unwrap();
assert_eq!(config.exact_pin_handling, ExactPinHandling::Preserve);
let toml = r#"exact_pin_handling = "warn""#;
let config: UnifyConfig = toml_edit::de::from_str(toml).unwrap();
assert_eq!(config.exact_pin_handling, ExactPinHandling::Warn);
}
#[test]
fn test_new_config_options_parsing() {
let toml = r#"
strict_version_compat = false
exact_pin_handling = "preserve"
"#;
let config: UnifyConfig = toml_edit::de::from_str(toml).unwrap();
assert!(!config.strict_version_compat);
assert_eq!(config.exact_pin_handling, ExactPinHandling::Preserve);
}
#[test]
fn test_major_version_conflict_default() {
let config = UnifyConfig::default();
assert_eq!(config.major_version_conflict, MajorVersionConflict::Warn);
}
#[test]
fn test_major_version_conflict_parsing() {
let toml = r#"major_version_conflict = "warn""#;
let config: UnifyConfig = toml_edit::de::from_str(toml).unwrap();
assert_eq!(config.major_version_conflict, MajorVersionConflict::Warn);
let toml = r#"major_version_conflict = "bump""#;
let config: UnifyConfig = toml_edit::de::from_str(toml).unwrap();
assert_eq!(config.major_version_conflict, MajorVersionConflict::Bump);
}
#[test]
fn test_major_version_conflict_with_other_options() {
let toml = r#"
strict_version_compat = false
exact_pin_handling = "preserve"
major_version_conflict = "bump"
"#;
let config: UnifyConfig = toml_edit::de::from_str(toml).unwrap();
assert!(!config.strict_version_compat);
assert_eq!(config.exact_pin_handling, ExactPinHandling::Preserve);
assert_eq!(config.major_version_conflict, MajorVersionConflict::Bump);
}
#[test]
fn test_transitive_host_validate_root() {
let config = UnifyConfig {
transitive_pinning: Some(TransitivePinning {
host: TransitiveFeatureHost::Root,
}),
..Default::default()
};
let workspace = std::env::current_dir().unwrap();
config.validate(&workspace).unwrap();
}
#[test]
fn test_transitive_host_validate_valid_path() {
let config = UnifyConfig::default();
let workspace = std::env::current_dir().unwrap();
config.validate(&workspace).unwrap();
}
#[test]
fn test_transitive_host_validate_nonexistent_path() {
let config = UnifyConfig {
transitive_pinning: Some(TransitivePinning {
host: TransitiveFeatureHost::Path("nonexistent/path".to_string()),
}),
..Default::default()
};
let workspace = std::env::current_dir().unwrap();
let result = config.validate(&workspace);
assert!(result.is_err());
let err = result.unwrap_err();
assert!(matches!(err, crate::error::ConfigError::InvalidValue { .. }));
}
#[test]
fn test_transitive_host_validate_path_traversal() {
let config = UnifyConfig {
transitive_pinning: Some(TransitivePinning {
host: TransitiveFeatureHost::Path("../somewhere".to_string()),
}),
..Default::default()
};
let workspace = std::env::current_dir().unwrap();
let result = config.validate(&workspace);
assert!(result.is_err());
let err = result.unwrap_err();
if let crate::error::ConfigError::InvalidValue { message, .. } = err {
assert!(message.contains(".."));
} else {
panic!("Expected InvalidValue error");
}
}
#[test]
fn invalid_feature_globs_fail_validation() {
for (field, config) in [
(
"unify.preserve_features",
UnifyConfig {
preserve_features: vec!["[".to_string()],
..Default::default()
},
),
(
"unify.skip_undeclared_patterns",
UnifyConfig {
skip_undeclared_patterns: vec!["[".to_string()],
..Default::default()
},
),
] {
let error = config.validate(std::path::Path::new(".")).unwrap_err();
assert!(
matches!(
error,
crate::error::ConfigError::InvalidGlobPattern {
ref message,
..
} if message.contains(field)
),
"{field} should reject malformed glob patterns"
);
}
}
#[test]
fn test_preserve_features_default() {
let config = UnifyConfig::default();
assert!(config.preserve_features.is_empty());
}
#[test]
fn test_preserve_features_parsing() {
let toml = r#"
preserve_features = ["future-api", "unstable-*", "bench*"]
"#;
let config: UnifyConfig = toml_edit::de::from_str(toml).unwrap();
assert_eq!(config.preserve_features.len(), 3);
assert!(config.preserve_features.contains(&"future-api".to_string()));
assert!(config.preserve_features.contains(&"unstable-*".to_string()));
assert!(config.preserve_features.contains(&"bench*".to_string()));
}
#[test]
fn test_should_preserve_feature_exact_match() {
let config = UnifyConfig {
preserve_features: vec!["future-api".to_string(), "experimental".to_string()],
..Default::default()
};
assert!(config.should_preserve_feature("future-api"));
assert!(config.should_preserve_feature("experimental"));
assert!(!config.should_preserve_feature("other-feature"));
}
#[test]
fn test_should_preserve_feature_glob_wildcard() {
let config = UnifyConfig {
preserve_features: vec!["unstable-*".to_string()],
..Default::default()
};
assert!(config.should_preserve_feature("unstable-api"));
assert!(config.should_preserve_feature("unstable-feature"));
assert!(config.should_preserve_feature("unstable-"));
assert!(!config.should_preserve_feature("unstable")); assert!(!config.should_preserve_feature("stable-api"));
}
#[test]
fn test_should_preserve_feature_glob_suffix() {
let config = UnifyConfig {
preserve_features: vec!["bench*".to_string()],
..Default::default()
};
assert!(config.should_preserve_feature("bench"));
assert!(config.should_preserve_feature("benchmark"));
assert!(config.should_preserve_feature("benchmarks"));
assert!(!config.should_preserve_feature("prebench"));
}
#[test]
fn test_should_preserve_feature_glob_question_mark() {
let config = UnifyConfig {
preserve_features: vec!["test-?".to_string()],
..Default::default()
};
assert!(config.should_preserve_feature("test-a"));
assert!(config.should_preserve_feature("test-1"));
assert!(!config.should_preserve_feature("test-ab")); assert!(!config.should_preserve_feature("test-")); }
#[test]
fn test_should_preserve_feature_multiple_patterns() {
let config = UnifyConfig {
preserve_features: vec!["future-api".to_string(), "unstable-*".to_string(), "bench*".to_string()],
..Default::default()
};
assert!(config.should_preserve_feature("future-api"));
assert!(config.should_preserve_feature("unstable-feature"));
assert!(config.should_preserve_feature("benchmark"));
assert!(!config.should_preserve_feature("stable-api"));
assert!(!config.should_preserve_feature("other"));
}
#[test]
fn test_should_preserve_feature_empty_list() {
let config = UnifyConfig::default();
assert!(!config.should_preserve_feature("any-feature"));
}
#[test]
fn test_msrv_source_default() {
let config = UnifyConfig::default();
assert_eq!(config.msrv_policy.source(), Some(MsrvSource::Max));
}
#[test]
fn test_skip_undeclared_patterns_default() {
let config = UnifyConfig::default();
assert!(!config.skip_undeclared_patterns.is_empty());
assert!(config.skip_undeclared_patterns.contains(&"default".to_string()));
assert!(config.skip_undeclared_patterns.contains(&"std".to_string()));
assert!(config.skip_undeclared_patterns.contains(&"alloc".to_string()));
assert!(config.skip_undeclared_patterns.contains(&"*_backend".to_string()));
assert!(config.skip_undeclared_patterns.contains(&"*_impl".to_string()));
}
#[test]
fn test_skip_undeclared_patterns_parsing() {
let toml = r#"
skip_undeclared_patterns = ["default", "std", "custom-*"]
"#;
let config: UnifyConfig = toml_edit::de::from_str(toml).unwrap();
assert_eq!(config.skip_undeclared_patterns.len(), 3);
assert!(config.skip_undeclared_patterns.contains(&"default".to_string()));
assert!(config.skip_undeclared_patterns.contains(&"std".to_string()));
assert!(config.skip_undeclared_patterns.contains(&"custom-*".to_string()));
}
#[test]
fn test_skip_undeclared_patterns_empty() {
let toml = r#"
skip_undeclared_patterns = []
"#;
let config: UnifyConfig = toml_edit::de::from_str(toml).unwrap();
assert!(config.skip_undeclared_patterns.is_empty());
}
#[test]
fn test_should_skip_undeclared_feature_exact_match() {
let config = UnifyConfig {
skip_undeclared_patterns: vec!["default".to_string(), "std".to_string()],
..Default::default()
};
assert!(config.should_skip_undeclared_feature("default"));
assert!(config.should_skip_undeclared_feature("std"));
assert!(!config.should_skip_undeclared_feature("derive"));
}
#[test]
fn test_should_skip_undeclared_feature_glob_suffix() {
let config = UnifyConfig {
skip_undeclared_patterns: vec!["*_backend".to_string()],
..Default::default()
};
assert!(config.should_skip_undeclared_feature("sqlite_backend"));
assert!(config.should_skip_undeclared_feature("postgres_backend"));
assert!(config.should_skip_undeclared_feature("_backend")); assert!(!config.should_skip_undeclared_feature("backend"));
assert!(!config.should_skip_undeclared_feature("backend_"));
}
#[test]
fn test_should_skip_undeclared_feature_glob_prefix() {
let config = UnifyConfig {
skip_undeclared_patterns: vec!["unstable-*".to_string()],
..Default::default()
};
assert!(config.should_skip_undeclared_feature("unstable-api"));
assert!(config.should_skip_undeclared_feature("unstable-internal"));
assert!(config.should_skip_undeclared_feature("unstable-")); assert!(!config.should_skip_undeclared_feature("unstable"));
}
#[test]
fn test_should_skip_undeclared_feature_glob_question_mark() {
let config = UnifyConfig {
skip_undeclared_patterns: vec!["test-?".to_string()],
..Default::default()
};
assert!(config.should_skip_undeclared_feature("test-1"));
assert!(config.should_skip_undeclared_feature("test-a"));
assert!(!config.should_skip_undeclared_feature("test-12"));
assert!(!config.should_skip_undeclared_feature("test-"));
}
#[test]
fn test_should_skip_undeclared_feature_multiple_patterns() {
let config = UnifyConfig {
skip_undeclared_patterns: vec![
"default".to_string(),
"std".to_string(),
"*_backend".to_string(),
"*_impl".to_string(),
],
..Default::default()
};
assert!(config.should_skip_undeclared_feature("default"));
assert!(config.should_skip_undeclared_feature("std"));
assert!(config.should_skip_undeclared_feature("sqlite_backend"));
assert!(config.should_skip_undeclared_feature("sync_impl"));
assert!(!config.should_skip_undeclared_feature("derive"));
assert!(!config.should_skip_undeclared_feature("serde"));
}
#[test]
fn test_should_skip_undeclared_feature_empty_patterns() {
let config = UnifyConfig {
skip_undeclared_patterns: vec![],
..Default::default()
};
assert!(!config.should_skip_undeclared_feature("default"));
assert!(!config.should_skip_undeclared_feature("std"));
assert!(!config.should_skip_undeclared_feature("anything"));
}
}