use std::fmt;
use std::path::PathBuf;
use serde::Deserialize;
pub const MAX_SUBSIGNATURES: u128 = 512;
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Config {
pub scan: Scan,
pub locales: Locales,
pub match_: Match,
pub output: Output,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct Scan {
pub po_patterns: Vec<String>,
pub ignore_dirs: Vec<String>,
pub roots: Vec<PathBuf>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct Locales {
pub exclude: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Match {
pub tiers: Vec<Tier>,
pub fuzzy_max_distance: usize,
pub fuzzy_min_length: usize,
pub empty_policy: EmptyPolicy,
pub min_locales_agree: usize,
pub normalize: Normalize,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct Normalize {
pub case_fold: bool,
pub collapse_whitespace: bool,
pub strip_trailing_punct: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct Output {
pub format: Format,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[cfg_attr(feature = "cli", derive(clap::ValueEnum))]
#[serde(rename_all = "lowercase")]
pub enum Tier {
Exact,
Normalized,
Fuzzy,
}
impl Tier {
pub const ALL: [Tier; 3] = [Tier::Exact, Tier::Normalized, Tier::Fuzzy];
pub fn label(self) -> &'static str {
match self {
Tier::Exact => "exact",
Tier::Normalized => "normalized",
Tier::Fuzzy => "fuzzy",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum EmptyPolicy {
Own,
Skip,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[cfg_attr(feature = "cli", derive(clap::ValueEnum))]
#[serde(rename_all = "lowercase")]
pub enum Format {
Text,
Json,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[cfg_attr(feature = "cli", derive(clap::ValueEnum))]
#[serde(rename_all = "lowercase")]
pub enum Preset {
Strict,
Balanced,
Loose,
}
impl Preset {
pub fn baseline(self) -> Match {
let normalize_all = Normalize {
case_fold: true,
collapse_whitespace: true,
strip_trailing_punct: true,
};
match self {
Preset::Strict => Match {
tiers: vec![Tier::Exact],
fuzzy_max_distance: 2,
fuzzy_min_length: 5,
empty_policy: EmptyPolicy::Own,
min_locales_agree: 5,
normalize: Normalize {
case_fold: false,
collapse_whitespace: false,
strip_trailing_punct: false,
},
},
Preset::Balanced => Match {
tiers: Tier::ALL.to_vec(),
fuzzy_max_distance: 2,
fuzzy_min_length: 5,
empty_policy: EmptyPolicy::Own,
min_locales_agree: 5,
normalize: normalize_all,
},
Preset::Loose => Match {
tiers: Tier::ALL.to_vec(),
fuzzy_max_distance: 3,
fuzzy_min_length: 4,
empty_policy: EmptyPolicy::Own,
min_locales_agree: 5,
normalize: normalize_all,
},
}
}
}
impl Default for Scan {
fn default() -> Self {
Self {
po_patterns: vec!["**/*.po".to_string()],
ignore_dirs: vec![
"vendor".to_string(),
"node_modules".to_string(),
".git".to_string(),
],
roots: vec![PathBuf::from(".")],
}
}
}
impl Default for Match {
fn default() -> Self {
Preset::Balanced.baseline()
}
}
#[derive(Debug, Default, Deserialize)]
#[serde(default, deny_unknown_fields)]
struct RawConfig {
scan: Scan,
locales: Locales,
#[serde(rename = "match")]
match_: MatchOverride,
output: Output,
}
#[derive(Debug, Default, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub(crate) struct MatchOverride {
pub(crate) preset: Option<Preset>,
pub(crate) tiers: Option<Vec<Tier>>,
pub(crate) fuzzy_max_distance: Option<usize>,
pub(crate) fuzzy_min_length: Option<usize>,
pub(crate) empty_policy: Option<EmptyPolicy>,
pub(crate) min_locales_agree: Option<usize>,
pub(crate) normalize: Option<NormalizeOverride>,
}
impl MatchOverride {
fn overlay(self, base: Match) -> Match {
Match {
tiers: self.tiers.unwrap_or(base.tiers),
fuzzy_max_distance: self.fuzzy_max_distance.unwrap_or(base.fuzzy_max_distance),
fuzzy_min_length: self.fuzzy_min_length.unwrap_or(base.fuzzy_min_length),
empty_policy: self.empty_policy.unwrap_or(base.empty_policy),
min_locales_agree: self.min_locales_agree.unwrap_or(base.min_locales_agree),
normalize: merge_normalize(base.normalize, self.normalize),
}
}
}
#[derive(Debug, Default, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub(crate) struct NormalizeOverride {
pub(crate) case_fold: Option<bool>,
pub(crate) collapse_whitespace: Option<bool>,
pub(crate) strip_trailing_punct: Option<bool>,
}
fn resolve(file: MatchOverride, cli: MatchOverride) -> Match {
let preset = cli.preset.or(file.preset).unwrap_or(Preset::Balanced);
let base = preset.baseline();
cli.overlay(file.overlay(base))
}
fn merge_normalize(base: Normalize, raw: Option<NormalizeOverride>) -> Normalize {
match raw {
None => base,
Some(raw) => Normalize {
case_fold: raw.case_fold.unwrap_or(base.case_fold),
collapse_whitespace: raw.collapse_whitespace.unwrap_or(base.collapse_whitespace),
strip_trailing_punct: raw
.strip_trailing_punct
.unwrap_or(base.strip_trailing_punct),
},
}
}
impl Default for Normalize {
fn default() -> Self {
Self {
case_fold: true,
collapse_whitespace: true,
strip_trailing_punct: true,
}
}
}
impl Default for Output {
fn default() -> Self {
Self {
format: Format::Text,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ConfigError {
ZeroMinLocalesAgree,
SubsignatureBlowup {
locales: usize,
min_agree: usize,
depth: usize,
subsignatures: u128,
cap: u128,
},
}
impl fmt::Display for ConfigError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::ZeroMinLocalesAgree => {
write!(f, "match.min_locales_agree must be >= 1")
}
Self::SubsignatureBlowup {
locales,
min_agree,
cap,
..
} => {
let suggested = suggested_floor(*locales, *cap);
write!(
f,
"match.min_locales_agree = {min_agree} is too low for {locales} locales — \
the near-duplicate search would blow up combinatorially. \
Raise match.min_locales_agree to at least {suggested}.",
)
}
}
}
}
impl std::error::Error for ConfigError {}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct FloorDecision {
pub effective: usize,
pub clamped: bool,
}
impl Config {
pub fn from_toml(input: &str) -> Result<Self, toml::de::Error> {
Self::from_toml_with(input, MatchOverride::default())
}
pub(crate) fn from_toml_with(input: &str, cli: MatchOverride) -> Result<Self, toml::de::Error> {
let raw: RawConfig = toml::from_str(input)?;
Ok(Config {
scan: raw.scan,
locales: raw.locales,
match_: resolve(raw.match_, cli),
output: raw.output,
})
}
pub fn reconcile_floor(&self, locale_count: usize) -> Result<FloorDecision, ConfigError> {
let min_agree = self.match_.min_locales_agree;
if min_agree == 0 {
return Err(ConfigError::ZeroMinLocalesAgree);
}
let depth = locale_count.saturating_sub(min_agree);
let subsignatures = subsignature_count(locale_count, depth);
if subsignatures > MAX_SUBSIGNATURES {
return Err(ConfigError::SubsignatureBlowup {
locales: locale_count,
min_agree,
depth,
subsignatures,
cap: MAX_SUBSIGNATURES,
});
}
let effective = min_agree.min(locale_count);
Ok(FloorDecision {
effective,
clamped: effective < min_agree,
})
}
}
fn suggested_floor(m: usize, cap: u128) -> usize {
(1..=m)
.find(|&floor| subsignature_count(m, m - floor) <= cap)
.unwrap_or(m)
}
fn subsignature_count(m: usize, t: usize) -> u128 {
let mut total: u128 = 1; let mut current: u128 = 1;
for i in 1..=t {
current = current.saturating_mul((m - i + 1) as u128) / (i as u128);
total = total.saturating_add(current);
}
total
}
#[cfg(test)]
mod tests {
use super::*;
const EXAMPLE: &str = r#"
[scan]
po_patterns = ["**/*.po"]
ignore_dirs = ["vendor", "node_modules", ".git"]
roots = ["."]
[locales]
exclude = []
[match]
tiers = ["exact", "normalized", "fuzzy"]
fuzzy_max_distance = 2
fuzzy_min_length = 5
empty_policy = "own"
min_locales_agree = 5
[match.normalize]
case_fold = true
collapse_whitespace = true
strip_trailing_punct = true
[output]
format = "text"
"#;
#[test]
fn default_matches_expected_values() {
let config = Config::default();
assert_eq!(config.scan.po_patterns, vec!["**/*.po".to_string()]);
assert_eq!(
config.scan.ignore_dirs,
vec![
"vendor".to_string(),
"node_modules".to_string(),
".git".to_string()
]
);
assert_eq!(config.scan.roots, vec![PathBuf::from(".")]);
assert!(config.locales.exclude.is_empty());
assert_eq!(
config.match_.tiers,
vec![Tier::Exact, Tier::Normalized, Tier::Fuzzy]
);
assert_eq!(config.match_.fuzzy_max_distance, 2);
assert_eq!(config.match_.fuzzy_min_length, 5);
assert_eq!(config.match_.empty_policy, EmptyPolicy::Own);
assert_eq!(config.match_.min_locales_agree, 5);
assert!(config.match_.normalize.case_fold);
assert!(config.match_.normalize.collapse_whitespace);
assert!(config.match_.normalize.strip_trailing_punct);
assert_eq!(config.output.format, Format::Text);
}
#[test]
fn example_config_parses_to_defaults() {
let config = Config::from_toml(EXAMPLE).expect("example config parses");
assert_eq!(config, Config::default());
}
#[test]
fn empty_toml_uses_all_defaults() {
assert_eq!(Config::from_toml("").unwrap(), Config::default());
}
#[test]
fn unknown_field_is_rejected() {
let result = Config::from_toml("[scan]\nbogus = 1\n");
assert!(result.is_err());
}
#[test]
fn reconcile_accepts_default_floor() {
let decision = Config::default().reconcile_floor(6).unwrap();
assert_eq!(decision.effective, 5);
assert!(!decision.clamped);
}
#[test]
fn reconcile_rejects_floor_too_low_for_locale_count() {
let mut config = Config::default();
config.match_.min_locales_agree = 1;
let error = config.reconcile_floor(20).unwrap_err();
assert!(matches!(error, ConfigError::SubsignatureBlowup { .. }));
assert!(error.to_string().contains("min_locales_agree"));
}
#[test]
fn reconcile_rejects_zero_floor() {
let mut config = Config::default();
config.match_.min_locales_agree = 0;
assert_eq!(
config.reconcile_floor(6).unwrap_err(),
ConfigError::ZeroMinLocalesAgree
);
}
#[test]
fn reconcile_clamps_floor_above_locale_count() {
let decision = Config::default().reconcile_floor(2).unwrap();
assert_eq!(decision.effective, 2);
assert!(decision.clamped);
}
#[test]
fn balanced_preset_equals_default() {
assert_eq!(Preset::Balanced.baseline(), Match::default());
}
#[test]
fn preset_selects_a_baseline() {
let strict = Config::from_toml("[match]\npreset = \"strict\"\n").unwrap();
assert_eq!(strict.match_.tiers, vec![Tier::Exact]);
assert!(!strict.match_.normalize.case_fold);
let loose = Config::from_toml("[match]\npreset = \"loose\"\n").unwrap();
assert_eq!(loose.match_.tiers, Tier::ALL.to_vec());
assert_eq!(loose.match_.fuzzy_max_distance, 3);
assert_eq!(loose.match_.fuzzy_min_length, 4);
}
#[test]
fn explicit_field_overrides_preset() {
let config = Config::from_toml(
"[match]\npreset = \"strict\"\ntiers = [\"exact\", \"normalized\"]\n",
)
.unwrap();
assert_eq!(config.match_.tiers, vec![Tier::Exact, Tier::Normalized]);
assert!(!config.match_.normalize.collapse_whitespace); }
#[test]
fn explicit_normalize_toggle_merges_onto_preset() {
let config = Config::from_toml("[match.normalize]\ncase_fold = false\n").unwrap();
assert!(!config.match_.normalize.case_fold);
assert!(config.match_.normalize.collapse_whitespace);
assert!(config.match_.normalize.strip_trailing_punct);
}
#[test]
fn suggested_floor_is_within_cap() {
let suggested = suggested_floor(20, MAX_SUBSIGNATURES);
assert!(subsignature_count(20, 20 - suggested) <= MAX_SUBSIGNATURES);
assert!(subsignature_count(20, 20 - (suggested - 1)) > MAX_SUBSIGNATURES);
}
}