Skip to main content

snapper_fmt/
config.rs

1use std::path::{Path, PathBuf};
2
3use anyhow::Result;
4
5/// Per-project configuration loaded from `.snapperrc.toml`.
6#[derive(Debug, Default)]
7pub struct ProjectConfig {
8    /// Additional abbreviations that should not trigger sentence breaks.
9    pub extra_abbreviations: Vec<String>,
10    /// File patterns to ignore (glob syntax).
11    pub ignore_patterns: Vec<String>,
12    /// Default format override.
13    pub default_format: Option<String>,
14    /// Default max width.
15    pub max_width: Option<usize>,
16}
17
18impl ProjectConfig {
19    /// Search for `.snapperrc.toml` starting from `start_dir` and walking up
20    /// to the filesystem root. Returns the default config if none found.
21    pub fn find_and_load(start_dir: &Path) -> Result<Self> {
22        let mut dir = start_dir.to_path_buf();
23        loop {
24            let candidate = dir.join(".snapperrc.toml");
25            if candidate.is_file() {
26                return Self::load(&candidate);
27            }
28            if !dir.pop() {
29                break;
30            }
31        }
32        Ok(Self::default())
33    }
34
35    /// Load config from a specific path.
36    pub fn load(path: &Path) -> Result<Self> {
37        let contents = std::fs::read_to_string(path)?;
38        Self::parse(&contents)
39    }
40
41    fn parse(toml_str: &str) -> Result<Self> {
42        let mut config = Self::default();
43
44        // Simple TOML parsing without pulling in a full TOML crate.
45        // We only support flat key = value and key = ["array"] forms.
46        for line in toml_str.lines() {
47            let line = line.trim();
48            if line.is_empty() || line.starts_with('#') {
49                continue;
50            }
51
52            if let Some((key, value)) = line.split_once('=') {
53                let key = key.trim();
54                let value = value.trim();
55
56                match key {
57                    "extra_abbreviations" => {
58                        config.extra_abbreviations = parse_string_array(value);
59                    }
60                    "ignore_patterns" | "ignore" => {
61                        config.ignore_patterns = parse_string_array(value);
62                    }
63                    "default_format" | "format" => {
64                        config.default_format =
65                            Some(value.trim_matches('"').trim_matches('\'').to_string());
66                    }
67                    "max_width" => {
68                        if let Ok(w) = value.parse::<usize>() {
69                            config.max_width = Some(w);
70                        }
71                    }
72                    _ => {} // Ignore unknown keys
73                }
74            }
75        }
76
77        Ok(config)
78    }
79
80    /// Get the config file path if explicitly provided, otherwise search.
81    pub fn resolve(explicit_path: Option<&PathBuf>) -> Result<Self> {
82        if let Some(path) = explicit_path {
83            Self::load(path)
84        } else {
85            let cwd = std::env::current_dir()?;
86            Self::find_and_load(&cwd)
87        }
88    }
89}
90
91/// Parse a TOML-style string array: ["foo", "bar", "baz"]
92fn parse_string_array(s: &str) -> Vec<String> {
93    let s = s.trim();
94    if !s.starts_with('[') || !s.ends_with(']') {
95        return vec![];
96    }
97    let inner = &s[1..s.len() - 1];
98    inner
99        .split(',')
100        .map(|item| item.trim().trim_matches('"').trim_matches('\'').to_string())
101        .filter(|s| !s.is_empty())
102        .collect()
103}
104
105#[cfg(test)]
106mod tests {
107    use super::*;
108
109    #[test]
110    fn parse_empty_config() {
111        let config = ProjectConfig::parse("").unwrap();
112        assert!(config.extra_abbreviations.is_empty());
113        assert!(config.ignore_patterns.is_empty());
114        assert!(config.default_format.is_none());
115        assert!(config.max_width.is_none());
116    }
117
118    #[test]
119    fn parse_full_config() {
120        let toml = r#"
121# Project-specific snapper config
122extra_abbreviations = ["Dept", "Univ", "Corp"]
123ignore = ["*.bib", "*.cls"]
124format = "org"
125max_width = 80
126"#;
127        let config = ProjectConfig::parse(toml).unwrap();
128        assert_eq!(config.extra_abbreviations, vec!["Dept", "Univ", "Corp"]);
129        assert_eq!(config.ignore_patterns, vec!["*.bib", "*.cls"]);
130        assert_eq!(config.default_format, Some("org".to_string()));
131        assert_eq!(config.max_width, Some(80));
132    }
133
134    #[test]
135    fn parse_comments_and_blanks() {
136        let toml = "# comment\n\nextra_abbreviations = [\"Fig\"]\n";
137        let config = ProjectConfig::parse(toml).unwrap();
138        assert_eq!(config.extra_abbreviations, vec!["Fig"]);
139    }
140}