use std::collections::HashMap;
use std::path::Path;
use anyhow::Result;
#[cfg(any(feature = "cli", feature = "watch"))]
use glob::Pattern;
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, Clone, Deserialize, PartialEq, Eq)]
#[serde(default)]
pub struct CodeLang {
pub line_comment: Option<String>,
pub block_comment: Option<[String; 2]>,
pub formatter: Option<Vec<String>>,
}
#[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 rst: Option<FormatOverrides>,
pub plaintext: Option<FormatOverrides>,
pub code: HashMap<String, CodeLang>,
}
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<&Path>) -> 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(),
"rst" => self.rst.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(),
"rst" => self.rst.as_ref(),
"plaintext" => self.plaintext.as_ref(),
_ => None,
};
overrides.and_then(|ov| ov.max_width).or(self.max_width)
}
#[cfg(any(feature = "cli", feature = "watch"))]
pub fn is_ignored(&self, path: &Path) -> bool {
self.ignore_patterns.iter().any(|pattern| {
Pattern::new(pattern).ok().is_some_and(|compiled| {
compiled.matches_path(path)
|| std::env::current_dir()
.ok()
.and_then(|cwd| path.strip_prefix(&cwd).ok())
.is_some_and(|relative| compiled.matches_path(relative))
|| path
.file_name()
.is_some_and(|name| compiled.matches_path(Path::new(name)))
})
})
}
}
#[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));
}
#[test]
fn parse_code_table_seven_seed_languages() {
let toml = r##"
[code.rust]
line_comment = "//"
block_comment = ["/*", "*/"]
formatter = ["rustfmt", "--edition", "2024"]
[code.python]
line_comment = "#"
block_comment = ["\"\"\"", "\"\"\""]
formatter = ["ruff", "format", "-"]
[code.toml]
line_comment = "#"
formatter = ["taplo", "format", "-"]
[code.lua]
line_comment = "--"
block_comment = ["--[[", "]]"]
[code.lisp]
line_comment = ";"
[code.html]
block_comment = ["<!--", "-->"]
[code.javascript]
line_comment = "//"
block_comment = ["/*", "*/"]
formatter = ["prettier", "--stdin-filepath", "src.js"]
"##;
let config = ProjectConfig::parse(toml).unwrap();
assert_eq!(config.code.len(), 7);
let rust = config.code.get("rust").expect("rust entry present");
assert_eq!(rust.line_comment.as_deref(), Some("//"));
assert_eq!(
rust.block_comment.as_ref(),
Some(&["/*".to_string(), "*/".to_string()])
);
assert_eq!(
rust.formatter.as_deref(),
Some(&["rustfmt".to_string(), "--edition".to_string(), "2024".to_string()][..])
);
let lisp = config.code.get("lisp").expect("lisp entry present");
assert_eq!(lisp.line_comment.as_deref(), Some(";"));
assert!(lisp.block_comment.is_none());
assert!(lisp.formatter.is_none());
let html = config.code.get("html").expect("html entry present");
assert!(html.line_comment.is_none());
assert_eq!(
html.block_comment.as_ref(),
Some(&["<!--".to_string(), "-->".to_string()])
);
assert!(html.formatter.is_none());
}
#[test]
fn parse_rst_overrides() {
let toml = r#"
[rst]
extra_abbreviations = ["Fig"]
max_width = 72
"#;
let config = ProjectConfig::parse(toml).unwrap();
assert_eq!(config.max_width_for_format("rst"), Some(72));
assert_eq!(config.abbreviations_for_format("rst"), vec!["Fig"]);
}
}