1use std::path::{Path, PathBuf};
2
3use anyhow::Result;
4
5#[derive(Debug, Default)]
7pub struct ProjectConfig {
8 pub extra_abbreviations: Vec<String>,
10 pub ignore_patterns: Vec<String>,
12 pub default_format: Option<String>,
14 pub max_width: Option<usize>,
16}
17
18impl ProjectConfig {
19 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 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 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 _ => {} }
74 }
75 }
76
77 Ok(config)
78 }
79
80 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
91fn 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}