use serde::{Deserialize, Serialize};
use std::error::Error;
use std::fs::read_to_string;
use std::path::{Path, PathBuf};
use crate::args;
use crate::diagnostic::Severity;
use crate::dict;
pub const DEFAULT_PATH_MSGFMT: &str = "/usr/bin/msgfmt";
#[derive(Default, Debug, Serialize, Deserialize)]
pub struct Config {
#[serde(skip)]
pub path: Option<PathBuf>,
#[serde(default)]
pub check: CheckConfig,
}
#[derive(Debug, Serialize, Deserialize)]
#[allow(clippy::struct_excessive_bools)]
pub struct CheckConfig {
#[serde(default)]
pub fuzzy: bool,
#[serde(default)]
pub noqa: bool,
#[serde(default)]
pub obsolete: bool,
#[serde(default = "default_check_select")]
pub select: Vec<String>,
#[serde(default)]
pub ignore: Vec<String>,
#[serde(default = "default_check_path_msgfmt")]
pub path_msgfmt: PathBuf,
#[serde(default = "default_check_path_dicts")]
pub path_dicts: PathBuf,
#[serde(default)]
pub path_words: Option<PathBuf>,
#[serde(default = "default_check_lang_id")]
pub lang_id: String,
#[serde(default)]
pub langs: Vec<String>,
#[serde(default = "default_check_short_factor")]
pub short_factor: u16,
#[serde(default = "default_check_long_factor")]
pub long_factor: u16,
#[serde(default)]
pub severity: Vec<Severity>,
#[serde(default)]
pub punc_ignore_ellipsis: bool,
}
fn default_check_select() -> Vec<String> {
vec![String::from("default")]
}
fn default_check_path_msgfmt() -> PathBuf {
PathBuf::from(DEFAULT_PATH_MSGFMT)
}
fn default_check_path_dicts() -> PathBuf {
PathBuf::from(dict::DEFAULT_PATH_DICTS)
}
fn default_check_lang_id() -> String {
String::from(dict::DEFAULT_LANG_ID)
}
fn default_check_short_factor() -> u16 {
8
}
fn default_check_long_factor() -> u16 {
8
}
impl Default for CheckConfig {
fn default() -> Self {
Self {
fuzzy: false,
noqa: false,
obsolete: false,
select: default_check_select(),
ignore: vec![],
path_msgfmt: default_check_path_msgfmt(),
path_dicts: default_check_path_dicts(),
path_words: None,
lang_id: default_check_lang_id(),
langs: vec![],
short_factor: default_check_short_factor(),
long_factor: default_check_long_factor(),
severity: vec![],
punc_ignore_ellipsis: false,
}
}
}
impl Config {
pub fn new(path: Option<&PathBuf>) -> Result<Self, Box<dyn Error>> {
let content = match path {
Some(cfg_path) => match read_to_string(cfg_path) {
Ok(content) => content,
Err(err) => return Err(format!("could not read config: {err}").into()),
},
None => String::new(),
};
let mut config: Self = toml::from_str(&content)?;
if config.check.short_factor < 2 {
return Err(format!(
"invalid `check.short_factor`: {} (min: 2)",
config.check.short_factor,
)
.into());
}
if config.check.long_factor < 2 {
return Err(format!(
"invalid `check.long_factor`: {} (min: 2)",
config.check.long_factor,
)
.into());
}
if let Some(path) = path {
config.path = Some(PathBuf::from(path));
}
Ok(config)
}
pub fn with_args_check(mut self, args: &args::CheckArgs) -> Self {
if args.fuzzy {
self.check.fuzzy = true;
}
if args.noqa {
self.check.noqa = true;
}
if args.obsolete {
self.check.obsolete = true;
}
if let Some(select) = &args.select {
self.check.select = select.split(',').map(|s| s.trim().to_string()).collect();
}
if let Some(ignore) = &args.ignore {
self.check.ignore = ignore.split(',').map(|s| s.trim().to_string()).collect();
}
if let Some(path_msgfmt) = &args.path_msgfmt {
self.check.path_msgfmt = PathBuf::from(path_msgfmt);
}
if let Some(path_dicts) = &args.path_dicts {
self.check.path_dicts = PathBuf::from(path_dicts);
}
if let Some(path_words) = &args.path_words {
self.check.path_words = Some(PathBuf::from(path_words));
} else if let Some(path_words) = &self.check.path_words
&& path_words.is_relative()
&& let Some(config_path) = &self.path
&& let Some(config_dir) = config_path.parent()
{
let path = PathBuf::from(config_dir).join(path_words);
self.check.path_words = path.canonicalize().map_or(Some(path), Some);
}
if let Some(lang_id) = &args.lang_id {
self.check.lang_id = String::from(lang_id);
}
if let Some(langs) = &args.langs {
self.check.langs = langs.split(',').map(|s| s.trim().to_string()).collect();
}
if let Some(short_factor) = args.short_factor {
self.check.short_factor = short_factor;
}
if let Some(long_factor) = args.long_factor {
self.check.long_factor = long_factor;
}
if !args.severity.is_empty() {
self.check.severity.clone_from(&args.severity);
}
if args.punc_ignore_ellipsis {
self.check.punc_ignore_ellipsis = true;
}
self
}
}
pub fn find_config_path(po_path: &Path) -> Option<PathBuf> {
let Ok(abs_path) = po_path.canonicalize() else {
return None;
};
for path in abs_path.ancestors() {
let p = path.join(".poexam/poexam.toml");
if p.exists() {
return Some(p);
}
let p = path.join("poexam.toml");
if p.exists() {
return Some(p);
}
let p = path.join(".poexam.toml");
if p.exists() {
return Some(p);
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
fn tmp_dir(label: &str) -> (tempfile::TempDir, PathBuf) {
let tmp = tempfile::TempDir::with_prefix(format!("poexam-cfg-{label}-"))
.expect("create temp dir");
let canonical = tmp.path().canonicalize().expect("canonicalize temp dir");
(tmp, canonical)
}
fn default_check_args() -> args::CheckArgs {
args::CheckArgs {
files: vec![],
show_settings: false,
config: None,
no_config: false,
fuzzy: false,
noqa: false,
obsolete: false,
select: None,
ignore: None,
path_msgfmt: None,
path_dicts: None,
path_words: None,
lang_id: None,
langs: None,
short_factor: None,
long_factor: None,
severity: vec![],
punc_ignore_ellipsis: false,
no_errors: false,
sort: args::CheckSort::default(),
rule_stats: false,
file_stats: false,
output: args::CheckOutputFormat::default(),
quiet: false,
}
}
#[test]
fn test_default_helpers() {
assert_eq!(default_check_select(), vec!["default".to_string()]);
assert_eq!(
default_check_path_msgfmt(),
PathBuf::from(DEFAULT_PATH_MSGFMT)
);
assert_eq!(
default_check_path_dicts(),
PathBuf::from(dict::DEFAULT_PATH_DICTS),
);
assert_eq!(default_check_lang_id(), dict::DEFAULT_LANG_ID);
}
#[test]
fn test_check_config_default() {
let c = CheckConfig::default();
assert!(!c.fuzzy);
assert!(!c.noqa);
assert!(!c.obsolete);
assert_eq!(c.select, vec!["default".to_string()]);
assert!(c.ignore.is_empty());
assert_eq!(c.path_msgfmt, PathBuf::from(DEFAULT_PATH_MSGFMT));
assert_eq!(c.path_dicts, PathBuf::from(dict::DEFAULT_PATH_DICTS));
assert!(c.path_words.is_none());
assert_eq!(c.lang_id, dict::DEFAULT_LANG_ID);
assert!(c.langs.is_empty());
assert!(c.severity.is_empty());
assert!(!c.punc_ignore_ellipsis);
}
#[test]
fn test_config_new_no_path_yields_defaults() {
let c = Config::new(None).expect("config builds without a path");
assert!(c.path.is_none());
assert_eq!(c.check.select, vec!["default".to_string()]);
assert_eq!(c.check.lang_id, dict::DEFAULT_LANG_ID);
assert!(!c.check.fuzzy);
}
#[test]
fn test_config_new_reads_toml_and_keeps_path() {
let (_tmp, root) = tmp_dir("cfg-read");
let cfg_path = root.join("poexam.toml");
std::fs::write(
&cfg_path,
r#"
[check]
fuzzy = true
select = ["spelling", "html-tags"]
ignore = ["urls"]
lang_id = "fr"
punc_ignore_ellipsis = true
"#,
)
.expect("write config file");
let c = Config::new(Some(&cfg_path)).expect("parse config");
assert_eq!(c.path.as_deref(), Some(cfg_path.as_path()));
assert!(c.check.fuzzy);
assert_eq!(
c.check.select,
vec!["spelling".to_string(), "html-tags".to_string()],
);
assert_eq!(c.check.ignore, vec!["urls".to_string()]);
assert_eq!(c.check.lang_id, "fr");
assert!(c.check.punc_ignore_ellipsis);
assert!(!c.check.noqa);
assert_eq!(c.check.path_msgfmt, PathBuf::from(DEFAULT_PATH_MSGFMT));
}
#[test]
fn test_config_new_missing_file_returns_err() {
let missing = PathBuf::from("/this/path/should/not/exist/poexam.toml");
let err = Config::new(Some(&missing)).expect_err("missing file is an error");
assert!(err.to_string().contains("could not read config"));
}
#[test]
fn test_config_new_invalid_toml_returns_err() {
let (_tmp, root) = tmp_dir("cfg-bad");
let cfg_path = root.join("poexam.toml");
std::fs::write(&cfg_path, "not = valid = toml").expect("write file");
assert!(Config::new(Some(&cfg_path)).is_err());
}
#[test]
fn test_config_new_rejects_factor_below_min() {
let (_tmp, root) = tmp_dir("cfg-factor");
let cfg_path = root.join("poexam.toml");
std::fs::write(&cfg_path, "[check]\nshort_factor = 1\n").expect("write config");
let err = Config::new(Some(&cfg_path)).expect_err("short_factor below min is an error");
let msg = err.to_string();
assert!(msg.contains("check.short_factor"));
assert!(msg.contains("min: 2"));
std::fs::write(&cfg_path, "[check]\nlong_factor = 0\n").expect("rewrite config");
let err = Config::new(Some(&cfg_path)).expect_err("long_factor below min is an error");
let msg = err.to_string();
assert!(msg.contains("check.long_factor"));
assert!(msg.contains("min: 2"));
}
#[test]
fn test_with_args_check_no_overrides_keeps_defaults() {
let cfg = Config::default().with_args_check(&default_check_args());
assert!(!cfg.check.fuzzy);
assert_eq!(cfg.check.select, vec!["default".to_string()]);
assert!(cfg.check.ignore.is_empty());
assert_eq!(cfg.check.path_msgfmt, PathBuf::from(DEFAULT_PATH_MSGFMT));
assert!(cfg.check.path_words.is_none());
assert_eq!(cfg.check.lang_id, dict::DEFAULT_LANG_ID);
assert!(cfg.check.severity.is_empty());
}
#[test]
fn test_with_args_check_booleans() {
let mut args = default_check_args();
args.fuzzy = true;
args.noqa = true;
args.obsolete = true;
args.punc_ignore_ellipsis = true;
let cfg = Config::default().with_args_check(&args);
assert!(cfg.check.fuzzy);
assert!(cfg.check.noqa);
assert!(cfg.check.obsolete);
assert!(cfg.check.punc_ignore_ellipsis);
}
#[test]
fn test_with_args_check_booleans_do_not_unset_existing() {
let cfg = Config {
check: CheckConfig {
fuzzy: true,
noqa: true,
..CheckConfig::default()
},
..Config::default()
};
let cfg = cfg.with_args_check(&default_check_args());
assert!(cfg.check.fuzzy);
assert!(cfg.check.noqa);
}
#[test]
fn test_with_args_check_comma_lists_split_and_trim() {
let mut args = default_check_args();
args.select = Some(" spelling , html-tags ".to_string());
args.ignore = Some("urls,paths".to_string());
args.langs = Some("en_US, fr ,de".to_string());
let cfg = Config::default().with_args_check(&args);
assert_eq!(
cfg.check.select,
vec!["spelling".to_string(), "html-tags".to_string()],
);
assert_eq!(
cfg.check.ignore,
vec!["urls".to_string(), "paths".to_string()],
);
assert_eq!(
cfg.check.langs,
vec!["en_US".to_string(), "fr".to_string(), "de".to_string()],
);
}
#[test]
fn test_with_args_check_paths_and_lang_id() {
let mut args = default_check_args();
args.path_msgfmt = Some(PathBuf::from("/opt/bin/msgfmt"));
args.path_dicts = Some(PathBuf::from("/opt/share/hunspell"));
args.path_words = Some(PathBuf::from("/opt/words"));
args.lang_id = Some("de".to_string());
let cfg = Config::default().with_args_check(&args);
assert_eq!(cfg.check.path_msgfmt, PathBuf::from("/opt/bin/msgfmt"));
assert_eq!(cfg.check.path_dicts, PathBuf::from("/opt/share/hunspell"));
assert_eq!(cfg.check.path_words, Some(PathBuf::from("/opt/words")));
assert_eq!(cfg.check.lang_id, "de");
}
#[test]
fn test_with_args_check_severity_replaces_when_non_empty() {
let mut args = default_check_args();
args.severity = vec![Severity::Warning, Severity::Error];
let cfg = Config::default().with_args_check(&args);
assert_eq!(cfg.check.severity, vec![Severity::Warning, Severity::Error]);
}
#[test]
fn test_with_args_check_resolves_relative_path_words_against_config_dir() {
let (_tmp, root) = tmp_dir("path-words");
let words_dir = root.join("words");
std::fs::create_dir_all(&words_dir).expect("create words dir");
let cfg_path = root.join("poexam.toml");
std::fs::write(&cfg_path, "").expect("write empty config");
let cfg = Config {
path: Some(cfg_path),
check: CheckConfig {
path_words: Some(PathBuf::from("words")),
..CheckConfig::default()
},
};
let cfg = cfg.with_args_check(&default_check_args());
let resolved = cfg.check.path_words.expect("path_words resolved");
let expected = words_dir.canonicalize().expect("canonicalize words dir");
assert_eq!(resolved, expected);
}
#[test]
fn test_find_config_path_priority_dot_poexam_dir_first() {
let (_tmp, root) = tmp_dir("cfg-priority");
let hidden_dir = root.join(".poexam");
std::fs::create_dir_all(&hidden_dir).expect("create .poexam dir");
let winner = hidden_dir.join("poexam.toml");
std::fs::write(&winner, "").expect("write winner");
std::fs::write(root.join("poexam.toml"), "").expect("write poexam.toml");
std::fs::write(root.join(".poexam.toml"), "").expect("write .poexam.toml");
let po = root.join("fr.po");
std::fs::write(&po, "").expect("write po file");
let found = find_config_path(&po).expect("config found");
assert_eq!(found, winner);
}
#[test]
fn test_find_config_path_finds_poexam_toml_when_only_one() {
let (_tmp, root) = tmp_dir("cfg-plain");
let cfg_path = root.join("poexam.toml");
std::fs::write(&cfg_path, "").expect("write config");
let po = root.join("fr.po");
std::fs::write(&po, "").expect("write po file");
let found = find_config_path(&po).expect("config found");
assert_eq!(found, cfg_path);
}
#[test]
fn test_find_config_path_finds_dot_poexam_toml_when_only_one() {
let (_tmp, root) = tmp_dir("cfg-dot");
let cfg_path = root.join(".poexam.toml");
std::fs::write(&cfg_path, "").expect("write config");
let po = root.join("fr.po");
std::fs::write(&po, "").expect("write po file");
let found = find_config_path(&po).expect("config found");
assert_eq!(found, cfg_path);
}
#[test]
fn test_find_config_path_walks_ancestors() {
let (_tmp, root) = tmp_dir("cfg-ancestors");
let cfg_path = root.join("poexam.toml");
std::fs::write(&cfg_path, "").expect("write config");
let sub = root.join("a/b");
std::fs::create_dir_all(&sub).expect("create nested dirs");
let po = sub.join("fr.po");
std::fs::write(&po, "").expect("write po file");
let found = find_config_path(&po).expect("config found by walking up");
assert_eq!(found, cfg_path);
}
#[test]
fn test_find_config_path_returns_none_for_nonexistent_input() {
let missing = PathBuf::from("/this/path/should/not/exist/file.po");
assert!(find_config_path(&missing).is_none());
}
}