use std::path::{Path, PathBuf};
use anyhow::Result;
use serde::Deserialize;
#[derive(Debug, Default, Deserialize)]
#[serde(default)]
pub struct FormatOverrides {
pub extra_abbreviations: Vec<String>,
pub max_width: Option<usize>,
}
#[derive(Debug, Default, Deserialize)]
#[serde(default)]
pub struct ProjectConfig {
pub extra_abbreviations: Vec<String>,
#[serde(alias = "ignore")]
pub ignore_patterns: Vec<String>,
#[serde(alias = "format")]
pub default_format: Option<String>,
pub max_width: Option<usize>,
pub lang: Option<String>,
pub org: Option<FormatOverrides>,
pub latex: Option<FormatOverrides>,
pub markdown: Option<FormatOverrides>,
pub plaintext: Option<FormatOverrides>,
}
impl ProjectConfig {
pub fn find_and_load(start_dir: &Path) -> Result<Self> {
let mut dir = start_dir.to_path_buf();
loop {
let candidate = dir.join(".snapperrc.toml");
if candidate.is_file() {
return Self::load(&candidate);
}
if !dir.pop() {
break;
}
}
Ok(Self::default())
}
pub fn load(path: &Path) -> Result<Self> {
let contents = std::fs::read_to_string(path)?;
Self::parse(&contents)
}
fn parse(toml_str: &str) -> Result<Self> {
let config: ProjectConfig = toml::from_str(toml_str)?;
Ok(config)
}
pub fn resolve(explicit_path: Option<&PathBuf>) -> Result<Self> {
if let Some(path) = explicit_path {
Self::load(path)
} else {
let cwd = std::env::current_dir()?;
Self::find_and_load(&cwd)
}
}
pub fn abbreviations_for_format(&self, format: &str) -> Vec<String> {
let mut abbrevs = self.extra_abbreviations.clone();
let overrides = match format {
"org" => self.org.as_ref(),
"latex" => self.latex.as_ref(),
"markdown" => self.markdown.as_ref(),
"plaintext" => self.plaintext.as_ref(),
_ => None,
};
if let Some(ov) = overrides {
abbrevs.extend(ov.extra_abbreviations.iter().cloned());
}
abbrevs
}
pub fn max_width_for_format(&self, format: &str) -> Option<usize> {
let overrides = match format {
"org" => self.org.as_ref(),
"latex" => self.latex.as_ref(),
"markdown" => self.markdown.as_ref(),
"plaintext" => self.plaintext.as_ref(),
_ => None,
};
overrides.and_then(|ov| ov.max_width).or(self.max_width)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_empty_config() {
let config = ProjectConfig::parse("").unwrap();
assert!(config.extra_abbreviations.is_empty());
assert!(config.ignore_patterns.is_empty());
assert!(config.default_format.is_none());
assert!(config.max_width.is_none());
}
#[test]
fn parse_full_config() {
let toml = r#"
# Project-specific snapper config
extra_abbreviations = ["Dept", "Univ", "Corp"]
ignore = ["*.bib", "*.cls"]
format = "org"
max_width = 80
lang = "de"
"#;
let config = ProjectConfig::parse(toml).unwrap();
assert_eq!(config.extra_abbreviations, vec!["Dept", "Univ", "Corp"]);
assert_eq!(config.ignore_patterns, vec!["*.bib", "*.cls"]);
assert_eq!(config.default_format, Some("org".to_string()));
assert_eq!(config.max_width, Some(80));
assert_eq!(config.lang, Some("de".to_string()));
}
#[test]
fn parse_comments_and_blanks() {
let toml = "# comment\n\nextra_abbreviations = [\"Fig\"]\n";
let config = ProjectConfig::parse(toml).unwrap();
assert_eq!(config.extra_abbreviations, vec!["Fig"]);
}
#[test]
fn parse_per_format_overrides() {
let toml = r#"
extra_abbreviations = ["Global"]
max_width = 80
[org]
extra_abbreviations = ["PROPERTIES", "DEADLINE"]
[latex]
extra_abbreviations = ["Thm", "Lem"]
max_width = 100
"#;
let config = ProjectConfig::parse(toml).unwrap();
let org_abbrevs = config.abbreviations_for_format("org");
assert!(org_abbrevs.contains(&"Global".to_string()));
assert!(org_abbrevs.contains(&"PROPERTIES".to_string()));
assert_eq!(config.max_width_for_format("org"), Some(80));
assert_eq!(config.max_width_for_format("latex"), Some(100));
assert_eq!(config.max_width_for_format("plaintext"), Some(80));
}
}