use std::fmt;
use std::path::PathBuf;
use serde::Deserialize;
pub const MAX_SUBSIGNATURES: u128 = 512;
#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct Config {
pub scan: Scan,
pub locales: Locales,
#[serde(rename = "match")]
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, Deserialize)]
#[serde(default, deny_unknown_fields)]
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)]
#[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)]
#[serde(rename_all = "lowercase")]
pub enum Format {
Text,
Json,
}
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 {
Self {
tiers: Tier::ALL.to_vec(),
fuzzy_max_distance: 2,
fuzzy_min_length: 5,
empty_policy: EmptyPolicy::Own,
min_locales_agree: 5,
normalize: Normalize::default(),
}
}
}
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,
depth,
subsignatures,
cap,
} => write!(
f,
"match.min_locales_agree = {min_agree} is too low for {locales} locales: \
leave-one-out depth T = {depth} yields {subsignatures} sub-signatures per key \
(cap {cap}). Raise min_locales_agree to reduce T = M - K.",
),
}
}
}
impl std::error::Error for ConfigError {}
impl Config {
pub fn from_toml(input: &str) -> Result<Self, toml::de::Error> {
toml::from_str(input)
}
pub fn validate(&self, locale_count: usize) -> Result<(), 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,
});
}
Ok(())
}
}
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 validate_accepts_default_floor() {
assert!(Config::default().validate(6).is_ok());
}
#[test]
fn validate_rejects_floor_too_low_for_locale_count() {
let mut config = Config::default();
config.match_.min_locales_agree = 1;
let error = config.validate(20).unwrap_err();
assert!(matches!(error, ConfigError::SubsignatureBlowup { .. }));
assert!(error.to_string().contains("min_locales_agree"));
}
#[test]
fn validate_rejects_zero_floor() {
let mut config = Config::default();
config.match_.min_locales_agree = 0;
assert_eq!(
config.validate(6).unwrap_err(),
ConfigError::ZeroMinLocalesAgree
);
}
}