use glob::Pattern;
use log::warn;
use serde::{Deserialize, Serialize};
use std::path::Path;
#[derive(Debug, Serialize, Clone, PartialEq)]
pub struct OverrideBlock {
pub paths: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub dictionaries: Option<Vec<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub words: Option<Vec<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub flag_words: Option<Vec<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ignore_patterns: Option<Vec<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub extra_dictionaries: Option<Vec<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub extra_words: Option<Vec<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub extra_flag_words: Option<Vec<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub extra_ignore_patterns: Option<Vec<String>>,
}
impl<'de> Deserialize<'de> for OverrideBlock {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
fn to_lowercase_vec(v: Vec<String>) -> Vec<String> {
v.into_iter().map(|s| s.to_ascii_lowercase()).collect()
}
fn to_lowercase_opt(v: Option<Vec<String>>) -> Option<Vec<String>> {
v.map(to_lowercase_vec)
}
#[derive(Deserialize)]
struct Helper {
#[serde(default)]
paths: Vec<String>,
#[serde(default)]
dictionaries: Option<Vec<String>>,
#[serde(default)]
words: Option<Vec<String>>,
#[serde(default)]
flag_words: Option<Vec<String>>,
#[serde(default)]
ignore_patterns: Option<Vec<String>>,
#[serde(default)]
extra_dictionaries: Option<Vec<String>>,
#[serde(default)]
extra_words: Option<Vec<String>>,
#[serde(default)]
extra_flag_words: Option<Vec<String>>,
#[serde(default)]
extra_ignore_patterns: Option<Vec<String>>,
}
let helper = Helper::deserialize(deserializer)?;
Ok(OverrideBlock {
paths: helper.paths,
dictionaries: to_lowercase_opt(helper.dictionaries),
words: to_lowercase_opt(helper.words),
flag_words: to_lowercase_opt(helper.flag_words),
extra_dictionaries: to_lowercase_opt(helper.extra_dictionaries),
extra_words: to_lowercase_opt(helper.extra_words),
extra_flag_words: to_lowercase_opt(helper.extra_flag_words),
ignore_patterns: helper.ignore_patterns,
extra_ignore_patterns: helper.extra_ignore_patterns,
})
}
}
impl OverrideBlock {
pub fn is_valid(&self) -> bool {
if self.paths.is_empty() {
return false;
}
self.paths.iter().any(|p| Pattern::new(p).is_ok())
}
pub fn matches_path(&self, relative_path: &Path) -> bool {
let path_str = relative_path.to_string_lossy();
self.paths.iter().any(|pattern| {
Pattern::new(pattern)
.map(|p| p.matches(&path_str))
.unwrap_or(false)
})
}
pub fn has_effect(&self) -> bool {
self.dictionaries.is_some()
|| self.words.is_some()
|| self.flag_words.is_some()
|| self.ignore_patterns.is_some()
|| self.extra_dictionaries.is_some()
|| self.extra_words.is_some()
|| self.extra_flag_words.is_some()
|| self.extra_ignore_patterns.is_some()
}
}
#[derive(Debug, Serialize, Clone, PartialEq)]
pub struct ConfigSettings {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub dictionaries: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub words: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub flag_words: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub include_paths: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub ignore_paths: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub ignore_patterns: Vec<String>,
#[serde(
default = "default_use_global",
skip_serializing_if = "is_default_use_global"
)]
pub use_global: bool,
#[serde(
default = "default_min_word_length",
skip_serializing_if = "is_default_min_word_length"
)]
pub min_word_length: usize,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub include_tags: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub exclude_tags: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub overrides: Vec<OverrideBlock>,
}
fn default_use_global() -> bool {
true
}
fn is_default_use_global(value: &bool) -> bool {
*value == default_use_global()
}
fn default_min_word_length() -> usize {
3
}
fn is_default_min_word_length(value: &usize) -> bool {
*value == default_min_word_length()
}
impl Default for ConfigSettings {
fn default() -> Self {
Self {
dictionaries: vec![],
words: Vec::new(),
flag_words: Vec::new(),
include_paths: Vec::new(),
ignore_paths: Vec::new(),
ignore_patterns: Vec::new(),
use_global: true,
min_word_length: default_min_word_length(),
include_tags: Vec::new(),
exclude_tags: Vec::new(),
overrides: Vec::new(),
}
}
}
impl<'de> Deserialize<'de> for ConfigSettings {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
#[derive(Deserialize)]
struct Helper {
#[serde(default)]
dictionaries: Vec<String>,
#[serde(default)]
words: Vec<String>,
#[serde(default)]
flag_words: Vec<String>,
#[serde(default)]
include_paths: Vec<String>,
#[serde(default)]
ignore_paths: Vec<String>,
#[serde(default)]
ignore_patterns: Vec<String>,
#[serde(default = "default_use_global")]
use_global: bool,
#[serde(default = "default_min_word_length")]
min_word_length: usize,
#[serde(default)]
include_tags: Vec<String>,
#[serde(default)]
exclude_tags: Vec<String>,
#[serde(default)]
overrides: Vec<OverrideBlock>,
}
let helper = Helper::deserialize(deserializer)?;
let overrides: Vec<OverrideBlock> = helper
.overrides
.into_iter()
.filter(|o| {
if !o.is_valid() {
warn!("Skipping invalid override block (empty or invalid paths)");
return false;
}
if !o.has_effect() {
warn!("Skipping no-op override block (no settings specified)");
return false;
}
true
})
.collect();
Ok(ConfigSettings {
dictionaries: helper
.dictionaries
.into_iter()
.map(|s| s.to_ascii_lowercase())
.collect(),
words: helper.words,
flag_words: helper.flag_words,
include_paths: helper.include_paths,
ignore_paths: helper.ignore_paths,
ignore_patterns: helper.ignore_patterns,
use_global: helper.use_global,
min_word_length: helper.min_word_length,
include_tags: helper.include_tags,
exclude_tags: helper.exclude_tags,
overrides,
})
}
}
impl ConfigSettings {
pub fn merge(&mut self, other: ConfigSettings) {
self.dictionaries.extend(other.dictionaries);
self.words.extend(other.words);
self.flag_words.extend(other.flag_words);
self.include_paths.extend(other.include_paths);
self.ignore_paths.extend(other.ignore_paths);
self.ignore_patterns.extend(other.ignore_patterns);
self.include_tags.extend(other.include_tags);
self.exclude_tags.extend(other.exclude_tags);
self.overrides.extend(other.overrides);
if other.min_word_length != default_min_word_length() {
self.min_word_length = other.min_word_length;
}
self.sort_and_dedup();
}
pub fn sort_and_dedup(&mut self) {
sort_and_dedup(&mut self.dictionaries);
sort_and_dedup(&mut self.words);
sort_and_dedup(&mut self.flag_words);
sort_and_dedup(&mut self.include_paths);
sort_and_dedup(&mut self.ignore_paths);
sort_and_dedup(&mut self.ignore_patterns);
sort_and_dedup(&mut self.include_tags);
sort_and_dedup(&mut self.exclude_tags);
}
pub fn apply_override(&mut self, ovr: &OverrideBlock) {
if let Some(ref v) = ovr.dictionaries {
self.dictionaries = v.clone();
}
if let Some(ref v) = ovr.words {
self.words = v.clone();
}
if let Some(ref v) = ovr.flag_words {
self.flag_words = v.clone();
}
if let Some(ref v) = ovr.ignore_patterns {
self.ignore_patterns = v.clone();
}
if let Some(ref v) = ovr.extra_dictionaries {
self.dictionaries.extend(v.clone());
}
if let Some(ref v) = ovr.extra_words {
self.words.extend(v.clone());
}
if let Some(ref v) = ovr.extra_flag_words {
self.flag_words.extend(v.clone());
}
if let Some(ref v) = ovr.extra_ignore_patterns {
self.ignore_patterns.extend(v.clone());
}
}
pub fn resolve_for_path(&self, path: &Path) -> ConfigSettings {
let mut resolved = self.clone();
resolved.overrides = vec![];
for ovr in &self.overrides {
if ovr.matches_path(path) {
resolved.apply_override(ovr);
}
}
resolved
}
}
fn tag_matches_pattern(tag: &str, pattern: &str) -> bool {
tag == pattern || tag.starts_with(pattern) && tag.as_bytes().get(pattern.len()) == Some(&b'.')
}
impl ConfigSettings {
pub fn should_check_tag(&self, tag: &str) -> bool {
if self
.exclude_tags
.iter()
.any(|p| tag_matches_pattern(tag, p))
{
return false;
}
if !self.include_tags.is_empty() {
return self
.include_tags
.iter()
.any(|p| tag_matches_pattern(tag, p));
}
true
}
pub fn insert_word(&mut self, word: &str) -> bool {
if self.words.iter().any(|w| unicase::eq(w.as_str(), word)) {
return false;
}
self.words.push(word.to_string());
self.words.sort();
true
}
pub fn insert_ignore(&mut self, file: &str) -> bool {
let file = file.to_string();
if self.ignore_paths.contains(&file) {
return false;
}
self.ignore_paths.push(file);
self.ignore_paths.sort();
self.ignore_paths.dedup();
true
}
pub fn insert_include(&mut self, file: &str) -> bool {
let file = file.to_string();
if self.include_paths.contains(&file) {
return false;
}
self.include_paths.push(file);
self.include_paths.sort();
self.include_paths.dedup();
true
}
pub fn dictionary_ids(&self) -> Vec<String> {
if self.dictionaries.is_empty() {
vec!["en_us".to_string()]
} else {
self.dictionaries.clone()
}
}
pub fn should_include_path(&self, path: &Path) -> bool {
if self.include_paths.is_empty() {
return true;
}
let path_str = path.to_string_lossy();
match_pattern(&self.include_paths, &path_str)
}
pub fn should_ignore_path(&self, path: &Path) -> bool {
let path_str = path.to_string_lossy();
match_pattern(&self.ignore_paths, &path_str)
}
pub fn is_allowed_word(&self, word: &str) -> bool {
self.words.iter().any(|w| unicase::eq(w.as_str(), word))
}
pub fn should_flag_word(&self, word: &str) -> bool {
self.flag_words.iter().any(|w| unicase::eq(w.as_str(), word))
}
pub fn min_word_length(&self) -> usize {
self.min_word_length
}
}
fn match_pattern(patterns: &[String], path_str: &str) -> bool {
patterns.iter().any(|pattern| {
Pattern::new(pattern)
.map(|p| p.matches(path_str))
.unwrap_or(false)
})
}
fn sort_and_dedup(vec: &mut Vec<String>) {
vec.sort();
vec.dedup();
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_default() {
let config = ConfigSettings::default();
assert_eq!(config.dictionaries, Vec::<String>::new());
assert_eq!(config.words, Vec::<String>::new());
assert_eq!(config.flag_words, Vec::<String>::new());
assert_eq!(config.include_paths, Vec::<String>::new());
assert_eq!(config.ignore_paths, Vec::<String>::new());
assert_eq!(config.ignore_patterns, Vec::<String>::new());
assert!(config.use_global);
assert_eq!(config.min_word_length, 3);
assert!(config.overrides.is_empty());
}
#[test]
fn test_deserialization() {
let toml_str = r#"
dictionaries = ["EN_US", "en_GB"]
words = ["CodeBook", "Rust", "Апгрейдить"]
flag_words = ["TODO", "FIXME", "Ошибка"]
include_paths = ["src/**/*.rs", "lib/"]
ignore_paths = ["**/*.md", "target/"]
ignore_patterns = ["^```.*$", "^//.*$"]
use_global = false
"#;
let config: ConfigSettings = toml::from_str(toml_str).unwrap();
assert_eq!(config.dictionaries, vec!["en_us", "en_gb"]);
assert_eq!(config.words, vec!["CodeBook", "Rust", "Апгрейдить"]);
assert_eq!(config.flag_words, vec!["TODO", "FIXME", "Ошибка"]);
assert_eq!(config.include_paths, vec!["src/**/*.rs", "lib/"]);
assert_eq!(config.ignore_paths, vec!["**/*.md", "target/"]);
assert_eq!(config.ignore_patterns.len(), 2);
assert!(config.ignore_patterns.contains(&"^```.*$".to_string()));
assert!(config.ignore_patterns.contains(&"^//.*$".to_string()));
assert!(!config.use_global);
}
#[test]
fn test_min_word_length_deserialization() {
let toml_str = r#"
min_word_length = 2
"#;
let config: ConfigSettings = toml::from_str(toml_str).unwrap();
assert_eq!(config.min_word_length, 2);
let toml_str = r#"
dictionaries = ["en_us"]
"#;
let config: ConfigSettings = toml::from_str(toml_str).unwrap();
assert_eq!(config.min_word_length, 3);
}
#[test]
fn test_serialization() {
let config = ConfigSettings {
dictionaries: vec!["en_us".to_string()],
words: vec!["rust".to_string()],
..Default::default()
};
let serialized = toml::to_string(&config).unwrap();
assert!(serialized.contains("dictionaries = [\"en_us\"]"));
assert!(serialized.contains("words = [\"rust\"]"));
assert!(!serialized.contains("use_global = true"));
assert!(!serialized.contains("min_word_length = 3"));
}
#[test]
fn test_serialization_roundtrip_include_paths() {
let config = ConfigSettings {
include_paths: vec!["src/**/*.rs".to_string(), "lib/".to_string()],
..Default::default()
};
let serialized = toml::to_string(&config).unwrap();
assert!(serialized.contains("include_paths"));
let deserialized: ConfigSettings = toml::from_str(&serialized).unwrap();
assert_eq!(deserialized.include_paths, vec!["src/**/*.rs", "lib/"]);
}
#[test]
fn test_merge() {
let mut base = ConfigSettings {
dictionaries: vec!["en_us".to_string()],
words: vec!["codebook".to_string()],
flag_words: vec!["todo".to_string()],
include_paths: vec!["src/".to_string()],
ignore_paths: vec!["**/*.md".to_string()],
ignore_patterns: vec!["^```.*$".to_string()],
use_global: true,
min_word_length: 3,
..Default::default()
};
let other = ConfigSettings {
dictionaries: vec!["en_gb".to_string(), "en_us".to_string()],
words: vec!["rust".to_string()],
flag_words: vec!["fixme".to_string()],
include_paths: vec!["lib/".to_string(), "src/".to_string()],
ignore_paths: vec!["target/".to_string()],
ignore_patterns: vec!["^//.*$".to_string()],
use_global: false,
min_word_length: 2,
..Default::default()
};
base.merge(other);
assert_eq!(base.dictionaries, vec!["en_gb", "en_us"]);
assert_eq!(base.words, vec!["codebook", "rust"]);
assert_eq!(base.flag_words, vec!["fixme", "todo"]);
assert_eq!(base.include_paths, vec!["lib/", "src/"]);
assert_eq!(base.ignore_paths, vec!["**/*.md", "target/"]);
assert_eq!(base.ignore_patterns.len(), 2);
assert!(base.ignore_patterns.contains(&"^```.*$".to_string()));
assert!(base.ignore_patterns.contains(&"^//.*$".to_string()));
assert!(base.use_global);
assert_eq!(base.min_word_length, 2);
}
#[test]
fn test_merge_min_word_length_default() {
let mut base = ConfigSettings {
dictionaries: vec!["en_us".to_string()],
min_word_length: 5,
..Default::default()
};
let other = ConfigSettings {
dictionaries: vec!["en_gb".to_string()],
min_word_length: 3, ..Default::default()
};
base.merge(other);
assert_eq!(base.min_word_length, 5);
}
#[test]
fn test_sort_and_dedup() {
let mut config = ConfigSettings {
dictionaries: vec![
"en_gb".to_string(),
"en_us".to_string(),
"en_gb".to_string(),
],
words: vec![
"rust".to_string(),
"codebook".to_string(),
"rust".to_string(),
],
flag_words: vec!["fixme".to_string(), "todo".to_string(), "fixme".to_string()],
include_paths: vec![],
ignore_paths: vec![
"target/".to_string(),
"**/*.md".to_string(),
"target/".to_string(),
],
ignore_patterns: vec![
"^//.*$".to_string(),
"^```.*$".to_string(),
"^//.*$".to_string(),
],
use_global: true,
min_word_length: 3,
..Default::default()
};
config.sort_and_dedup();
assert_eq!(config.dictionaries, vec!["en_gb", "en_us"]);
assert_eq!(config.words, vec!["codebook", "rust"]);
assert_eq!(config.flag_words, vec!["fixme", "todo"]);
assert_eq!(config.ignore_paths, vec!["**/*.md", "target/"]);
assert_eq!(config.ignore_patterns.len(), 2);
assert!(config.ignore_patterns.contains(&"^```.*$".to_string()));
assert!(config.ignore_patterns.contains(&"^//.*$".to_string()));
}
#[test]
fn test_use_global_default() {
let toml_str = r#"
dictionaries = ["EN_US"]
"#;
let config: ConfigSettings = toml::from_str(toml_str).unwrap();
assert!(config.use_global);
}
#[test]
fn test_empty_deserialization() {
let toml_str = "";
let config: ConfigSettings = toml::from_str(toml_str).unwrap();
assert_eq!(config, ConfigSettings::default());
}
#[test]
fn test_unicode_words_ignore_case() {
let mut config = ConfigSettings::default();
assert!(config.insert_word("Апгрейдить"));
assert!(!config.insert_word("апгрейдить"));
assert_eq!(config.words, vec!["Апгрейдить"]);
assert!(config.is_allowed_word("АПГРЕЙДИТЬ"));
assert!(config.is_allowed_word("апгрейдить"));
config.flag_words.push("ошибка".to_string());
assert!(config.should_flag_word("Ошибка"));
}
#[test]
fn test_include_tags_deserialization() {
let toml_str = r#"
include_tags = ["comment", "string"]
"#;
let config: ConfigSettings = toml::from_str(toml_str).unwrap();
assert_eq!(config.include_tags, vec!["comment", "string"]);
assert!(config.exclude_tags.is_empty());
}
#[test]
fn test_exclude_tags_deserialization() {
let toml_str = r#"
exclude_tags = ["identifier.variable", "identifier.parameter"]
"#;
let config: ConfigSettings = toml::from_str(toml_str).unwrap();
assert!(config.include_tags.is_empty());
assert_eq!(
config.exclude_tags,
vec!["identifier.variable", "identifier.parameter"]
);
}
#[test]
fn test_tags_default_empty() {
let config = ConfigSettings::default();
assert!(config.include_tags.is_empty());
assert!(config.exclude_tags.is_empty());
}
#[test]
fn test_tags_serialization_omitted_when_empty() {
let config = ConfigSettings::default();
let serialized = toml::to_string(&config).unwrap();
assert!(!serialized.contains("include_tags"));
assert!(!serialized.contains("exclude_tags"));
}
#[test]
fn test_tags_serialization_present_when_set() {
let config = ConfigSettings {
include_tags: vec!["comment".to_string()],
..Default::default()
};
let serialized = toml::to_string(&config).unwrap();
assert!(serialized.contains("include_tags"));
}
#[test]
fn test_tags_merge() {
let mut base = ConfigSettings {
include_tags: vec!["comment".to_string()],
exclude_tags: vec!["identifier.type".to_string()],
..Default::default()
};
let other = ConfigSettings {
include_tags: vec!["string".to_string(), "comment".to_string()],
exclude_tags: vec!["identifier.module".to_string()],
..Default::default()
};
base.merge(other);
assert_eq!(base.include_tags, vec!["comment", "string"]);
assert_eq!(
base.exclude_tags,
vec!["identifier.module", "identifier.type"]
);
}
#[test]
fn test_should_check_tag_no_filters() {
let config = ConfigSettings::default();
assert!(config.should_check_tag("comment"));
assert!(config.should_check_tag("string"));
assert!(config.should_check_tag("identifier.function"));
}
#[test]
fn test_should_check_tag_include_only() {
let config = ConfigSettings {
include_tags: vec!["comment".to_string(), "string".to_string()],
..Default::default()
};
assert!(config.should_check_tag("comment"));
assert!(config.should_check_tag("comment.line"));
assert!(config.should_check_tag("comment.block"));
assert!(config.should_check_tag("string"));
assert!(config.should_check_tag("string.special"));
assert!(!config.should_check_tag("identifier"));
assert!(!config.should_check_tag("identifier.function"));
}
#[test]
fn test_should_check_tag_exclude_only() {
let config = ConfigSettings {
exclude_tags: vec!["identifier.variable".to_string()],
..Default::default()
};
assert!(config.should_check_tag("comment"));
assert!(config.should_check_tag("identifier.function"));
assert!(!config.should_check_tag("identifier.variable"));
}
#[test]
fn test_should_check_tag_both_include_and_exclude() {
let config = ConfigSettings {
include_tags: vec!["comment".to_string(), "string".to_string()],
exclude_tags: vec!["string.heredoc".to_string()],
..Default::default()
};
assert!(config.should_check_tag("comment"));
assert!(config.should_check_tag("comment.line"));
assert!(config.should_check_tag("string"));
assert!(config.should_check_tag("string.special"));
assert!(!config.should_check_tag("string.heredoc"));
assert!(!config.should_check_tag("identifier.function"));
}
#[test]
fn test_should_check_tag_exclude_prefix() {
let config = ConfigSettings {
exclude_tags: vec!["identifier".to_string()],
..Default::default()
};
assert!(config.should_check_tag("comment"));
assert!(config.should_check_tag("string"));
assert!(!config.should_check_tag("identifier"));
assert!(!config.should_check_tag("identifier.function"));
assert!(!config.should_check_tag("identifier.type"));
}
#[test]
fn test_partial_deserialization() {
let toml_str = r#"
dictionaries = ["EN_US"]
words = ["CodeBook"]
"#;
let config: ConfigSettings = toml::from_str(toml_str).unwrap();
assert_eq!(config.dictionaries, vec!["en_us"]);
assert_eq!(config.words, vec!["CodeBook"]);
assert_eq!(config.flag_words, Vec::<String>::new());
assert_eq!(config.ignore_paths, Vec::<String>::new());
assert_eq!(config.ignore_patterns, Vec::<String>::new());
assert!(config.use_global);
}
#[test]
fn test_override_block_deserialization() {
let toml_str = r#"
words = ["base"]
[[overrides]]
paths = ["**/*.md"]
extra_words = ["Markdown"]
dictionaries = ["EN_GB"]
"#;
let config: ConfigSettings = toml::from_str(toml_str).unwrap();
assert_eq!(config.overrides.len(), 1);
let ovr = &config.overrides[0];
assert_eq!(ovr.paths, vec!["**/*.md"]);
assert_eq!(ovr.extra_words, Some(vec!["markdown".to_string()])); assert_eq!(ovr.dictionaries, Some(vec!["en_gb".to_string()])); assert_eq!(ovr.words, None);
assert_eq!(ovr.ignore_patterns, None);
}
#[test]
fn test_override_block_empty_paths_skipped() {
let toml_str = r#"
[[overrides]]
paths = []
extra_words = ["test"]
"#;
let config: ConfigSettings = toml::from_str(toml_str).unwrap();
assert!(config.overrides.is_empty());
}
#[test]
fn test_override_block_no_effect_skipped() {
let toml_str = r#"
[[overrides]]
paths = ["**/*.md"]
"#;
let config: ConfigSettings = toml::from_str(toml_str).unwrap();
assert!(config.overrides.is_empty());
}
#[test]
fn test_override_matches_path() {
let ovr = OverrideBlock {
paths: vec!["**/*.md".to_string(), "docs/**/*".to_string()],
extra_words: Some(vec!["test".to_string()]),
..OverrideBlock::default_for_test()
};
assert!(ovr.matches_path(Path::new("README.md")));
assert!(ovr.matches_path(Path::new("src/guide.md")));
assert!(ovr.matches_path(Path::new("docs/api/index.html")));
assert!(!ovr.matches_path(Path::new("src/main.rs")));
}
#[test]
fn test_apply_override_replace() {
let mut settings = ConfigSettings {
words: vec!["alpha".to_string(), "beta".to_string()],
..Default::default()
};
let ovr = OverrideBlock {
paths: vec!["**/*.md".to_string()],
words: Some(vec!["gamma".to_string()]),
..OverrideBlock::default_for_test()
};
settings.apply_override(&ovr);
assert_eq!(settings.words, vec!["gamma"]);
}
#[test]
fn test_apply_override_append() {
let mut settings = ConfigSettings {
words: vec!["alpha".to_string(), "beta".to_string()],
..Default::default()
};
let ovr = OverrideBlock {
paths: vec!["**/*.md".to_string()],
extra_words: Some(vec!["gamma".to_string()]),
..OverrideBlock::default_for_test()
};
settings.apply_override(&ovr);
assert_eq!(settings.words, vec!["alpha", "beta", "gamma"]);
}
#[test]
fn test_apply_override_replace_then_append() {
let mut settings = ConfigSettings {
words: vec!["alpha".to_string(), "beta".to_string()],
..Default::default()
};
let ovr = OverrideBlock {
paths: vec!["**/*.md".to_string()],
words: Some(vec!["gamma".to_string()]),
extra_words: Some(vec!["delta".to_string()]),
..OverrideBlock::default_for_test()
};
settings.apply_override(&ovr);
assert_eq!(settings.words, vec!["gamma", "delta"]);
}
#[test]
fn test_apply_override_no_change() {
let mut settings = ConfigSettings {
words: vec!["alpha".to_string()],
dictionaries: vec!["en_us".to_string()],
..Default::default()
};
let ovr = OverrideBlock {
paths: vec!["**/*.md".to_string()],
extra_flag_words: Some(vec!["hack".to_string()]),
..OverrideBlock::default_for_test()
};
settings.apply_override(&ovr);
assert_eq!(settings.words, vec!["alpha"]);
assert_eq!(settings.dictionaries, vec!["en_us"]);
assert_eq!(settings.flag_words, vec!["hack"]);
}
#[test]
fn test_resolve_for_path_no_match() {
let settings = ConfigSettings {
words: vec!["base".to_string()],
overrides: vec![OverrideBlock {
paths: vec!["**/*.md".to_string()],
extra_words: Some(vec!["markdown".to_string()]),
..OverrideBlock::default_for_test()
}],
..Default::default()
};
let resolved = settings.resolve_for_path(Path::new("src/main.rs"));
assert_eq!(resolved.words, vec!["base"]);
assert!(resolved.overrides.is_empty());
}
#[test]
fn test_resolve_for_path_single_match() {
let settings = ConfigSettings {
words: vec!["base".to_string()],
overrides: vec![OverrideBlock {
paths: vec!["**/*.md".to_string()],
extra_words: Some(vec!["markdown".to_string()]),
..OverrideBlock::default_for_test()
}],
..Default::default()
};
let resolved = settings.resolve_for_path(Path::new("README.md"));
assert_eq!(resolved.words, vec!["base", "markdown"]);
assert!(resolved.overrides.is_empty());
}
#[test]
fn test_resolve_for_path_multiple_matches() {
let settings = ConfigSettings {
words: vec!["base".to_string()],
overrides: vec![
OverrideBlock {
paths: vec!["**/*.md".to_string()],
extra_words: Some(vec!["markdown".to_string()]),
..OverrideBlock::default_for_test()
},
OverrideBlock {
paths: vec!["docs/**/*".to_string()],
extra_words: Some(vec!["documentation".to_string()]),
..OverrideBlock::default_for_test()
},
],
..Default::default()
};
let resolved = settings.resolve_for_path(Path::new("docs/guide.md"));
assert_eq!(resolved.words, vec!["base", "markdown", "documentation"]);
}
#[test]
fn test_resolve_for_path_replace_overrides_base() {
let settings = ConfigSettings {
dictionaries: vec!["en_us".to_string()],
overrides: vec![OverrideBlock {
paths: vec!["docs/de/**/*".to_string()],
dictionaries: Some(vec!["de".to_string()]),
extra_words: Some(vec!["codebook".to_string()]),
..OverrideBlock::default_for_test()
}],
..Default::default()
};
let resolved = settings.resolve_for_path(Path::new("docs/de/guide.md"));
assert_eq!(resolved.dictionaries, vec!["de"]);
assert_eq!(resolved.words, vec!["codebook"]);
}
#[test]
fn test_merge_preserves_override_order() {
let mut global = ConfigSettings {
words: vec!["global".to_string()],
overrides: vec![OverrideBlock {
paths: vec!["**/*.md".to_string()],
extra_words: Some(vec!["from_global".to_string()]),
..OverrideBlock::default_for_test()
}],
..Default::default()
};
let project = ConfigSettings {
words: vec!["project".to_string()],
overrides: vec![OverrideBlock {
paths: vec!["**/*.md".to_string()],
extra_words: Some(vec!["from_project".to_string()]),
..OverrideBlock::default_for_test()
}],
..Default::default()
};
global.merge(project);
assert_eq!(global.overrides.len(), 2);
assert_eq!(
global.overrides[0].extra_words,
Some(vec!["from_global".to_string()])
);
assert_eq!(
global.overrides[1].extra_words,
Some(vec!["from_project".to_string()])
);
}
#[test]
fn test_serialization_with_overrides() {
let config = ConfigSettings {
words: vec!["base".to_string()],
overrides: vec![OverrideBlock {
paths: vec!["**/*.md".to_string()],
extra_words: Some(vec!["markdown".to_string()]),
..OverrideBlock::default_for_test()
}],
..Default::default()
};
let serialized = toml::to_string_pretty(&config).unwrap();
let deserialized: ConfigSettings = toml::from_str(&serialized).unwrap();
assert_eq!(config, deserialized);
}
#[test]
fn test_config_settings_query_methods() {
let settings = ConfigSettings {
dictionaries: vec!["en_us".to_string()],
words: vec!["codebook".to_string()],
flag_words: vec!["todo".to_string()],
min_word_length: 4,
..Default::default()
};
assert_eq!(settings.dictionary_ids(), vec!["en_us"]);
assert!(settings.is_allowed_word("codebook"));
assert!(settings.is_allowed_word("CODEBOOK")); assert!(!settings.is_allowed_word("unknown"));
assert!(settings.should_flag_word("todo"));
assert!(settings.should_flag_word("TODO")); assert!(!settings.should_flag_word("done"));
assert_eq!(settings.min_word_length(), 4);
}
#[test]
fn test_dictionary_ids_default() {
let settings = ConfigSettings::default();
assert_eq!(settings.dictionary_ids(), vec!["en_us"]);
}
impl OverrideBlock {
fn default_for_test() -> Self {
Self {
paths: vec![],
dictionaries: None,
words: None,
flag_words: None,
ignore_patterns: None,
extra_dictionaries: None,
extra_words: None,
extra_flag_words: None,
extra_ignore_patterns: None,
}
}
}
}