Skip to main content

snapper_fmt/
config.rs

1use std::path::{Path, PathBuf};
2
3use anyhow::Result;
4use serde::Deserialize;
5
6/// Per-format overrides in .snapperrc.toml.
7#[derive(Debug, Default, Deserialize)]
8#[serde(default)]
9pub struct FormatOverrides {
10    pub extra_abbreviations: Vec<String>,
11    pub max_width: Option<usize>,
12}
13
14/// Per-project configuration loaded from `.snapperrc.toml`.
15#[derive(Debug, Default, Deserialize)]
16#[serde(default)]
17pub struct ProjectConfig {
18    /// Additional abbreviations that should not trigger sentence breaks.
19    pub extra_abbreviations: Vec<String>,
20    /// File patterns to ignore (glob syntax).
21    #[serde(alias = "ignore")]
22    pub ignore_patterns: Vec<String>,
23    /// Default format override.
24    #[serde(alias = "format")]
25    pub default_format: Option<String>,
26    /// Default max width.
27    pub max_width: Option<usize>,
28    /// Default language for abbreviation sets.
29    pub lang: Option<String>,
30
31    /// Per-format overrides.
32    pub org: Option<FormatOverrides>,
33    pub latex: Option<FormatOverrides>,
34    pub markdown: Option<FormatOverrides>,
35    pub plaintext: Option<FormatOverrides>,
36}
37
38impl ProjectConfig {
39    /// Search for `.snapperrc.toml` starting from `start_dir` and walking up
40    /// to the filesystem root. Returns the default config if none found.
41    pub fn find_and_load(start_dir: &Path) -> Result<Self> {
42        let mut dir = start_dir.to_path_buf();
43        loop {
44            let candidate = dir.join(".snapperrc.toml");
45            if candidate.is_file() {
46                return Self::load(&candidate);
47            }
48            if !dir.pop() {
49                break;
50            }
51        }
52        Ok(Self::default())
53    }
54
55    /// Load config from a specific path.
56    pub fn load(path: &Path) -> Result<Self> {
57        let contents = std::fs::read_to_string(path)?;
58        Self::parse(&contents)
59    }
60
61    fn parse(toml_str: &str) -> Result<Self> {
62        let config: ProjectConfig = toml::from_str(toml_str)?;
63        Ok(config)
64    }
65
66    /// Get the config file path if explicitly provided, otherwise search.
67    pub fn resolve(explicit_path: Option<&PathBuf>) -> Result<Self> {
68        if let Some(path) = explicit_path {
69            Self::load(path)
70        } else {
71            let cwd = std::env::current_dir()?;
72            Self::find_and_load(&cwd)
73        }
74    }
75
76    /// Get merged extra_abbreviations for a specific format, combining
77    /// top-level abbreviations with per-format overrides.
78    pub fn abbreviations_for_format(&self, format: &str) -> Vec<String> {
79        let mut abbrevs = self.extra_abbreviations.clone();
80        let overrides = match format {
81            "org" => self.org.as_ref(),
82            "latex" => self.latex.as_ref(),
83            "markdown" => self.markdown.as_ref(),
84            "plaintext" => self.plaintext.as_ref(),
85            _ => None,
86        };
87        if let Some(ov) = overrides {
88            abbrevs.extend(ov.extra_abbreviations.iter().cloned());
89        }
90        abbrevs
91    }
92
93    /// Get max_width for a specific format (per-format overrides top-level).
94    pub fn max_width_for_format(&self, format: &str) -> Option<usize> {
95        let overrides = match format {
96            "org" => self.org.as_ref(),
97            "latex" => self.latex.as_ref(),
98            "markdown" => self.markdown.as_ref(),
99            "plaintext" => self.plaintext.as_ref(),
100            _ => None,
101        };
102        overrides.and_then(|ov| ov.max_width).or(self.max_width)
103    }
104}
105
106#[cfg(test)]
107mod tests {
108    use super::*;
109
110    #[test]
111    fn parse_empty_config() {
112        let config = ProjectConfig::parse("").unwrap();
113        assert!(config.extra_abbreviations.is_empty());
114        assert!(config.ignore_patterns.is_empty());
115        assert!(config.default_format.is_none());
116        assert!(config.max_width.is_none());
117    }
118
119    #[test]
120    fn parse_full_config() {
121        let toml = r#"
122# Project-specific snapper config
123extra_abbreviations = ["Dept", "Univ", "Corp"]
124ignore = ["*.bib", "*.cls"]
125format = "org"
126max_width = 80
127lang = "de"
128"#;
129        let config = ProjectConfig::parse(toml).unwrap();
130        assert_eq!(config.extra_abbreviations, vec!["Dept", "Univ", "Corp"]);
131        assert_eq!(config.ignore_patterns, vec!["*.bib", "*.cls"]);
132        assert_eq!(config.default_format, Some("org".to_string()));
133        assert_eq!(config.max_width, Some(80));
134        assert_eq!(config.lang, Some("de".to_string()));
135    }
136
137    #[test]
138    fn parse_comments_and_blanks() {
139        let toml = "# comment\n\nextra_abbreviations = [\"Fig\"]\n";
140        let config = ProjectConfig::parse(toml).unwrap();
141        assert_eq!(config.extra_abbreviations, vec!["Fig"]);
142    }
143
144    #[test]
145    fn parse_per_format_overrides() {
146        let toml = r#"
147extra_abbreviations = ["Global"]
148max_width = 80
149
150[org]
151extra_abbreviations = ["PROPERTIES", "DEADLINE"]
152
153[latex]
154extra_abbreviations = ["Thm", "Lem"]
155max_width = 100
156"#;
157        let config = ProjectConfig::parse(toml).unwrap();
158        let org_abbrevs = config.abbreviations_for_format("org");
159        assert!(org_abbrevs.contains(&"Global".to_string()));
160        assert!(org_abbrevs.contains(&"PROPERTIES".to_string()));
161        assert_eq!(config.max_width_for_format("org"), Some(80));
162        assert_eq!(config.max_width_for_format("latex"), Some(100));
163        assert_eq!(config.max_width_for_format("plaintext"), Some(80));
164    }
165}