1use std::collections::HashMap;
2use std::path::Path;
3
4use anyhow::Result;
5#[cfg(any(feature = "cli", feature = "watch"))]
6use glob::Pattern;
7use serde::Deserialize;
8
9#[derive(Debug, Default, Deserialize)]
11#[serde(default)]
12pub struct FormatOverrides {
13 pub extra_abbreviations: Vec<String>,
14 pub max_width: Option<usize>,
15}
16
17#[derive(Debug, Default, Clone, Deserialize, PartialEq, Eq)]
27#[serde(default)]
28pub struct CodeLang {
29 pub line_comment: Option<String>,
30 pub block_comment: Option<[String; 2]>,
31 pub formatter: Option<Vec<String>>,
32}
33
34#[derive(Debug, Default, Deserialize)]
36#[serde(default)]
37pub struct ProjectConfig {
38 pub extra_abbreviations: Vec<String>,
40 #[serde(alias = "ignore")]
42 pub ignore_patterns: Vec<String>,
43 #[serde(alias = "format")]
45 pub default_format: Option<String>,
46 pub max_width: Option<usize>,
48 pub lang: Option<String>,
50
51 pub org: Option<FormatOverrides>,
53 pub latex: Option<FormatOverrides>,
54 pub markdown: Option<FormatOverrides>,
55 pub rst: Option<FormatOverrides>,
56 pub plaintext: Option<FormatOverrides>,
57
58 pub code: HashMap<String, CodeLang>,
63}
64
65impl ProjectConfig {
66 pub fn find_and_load(start_dir: &Path) -> Result<Self> {
69 let mut dir = start_dir.to_path_buf();
70 loop {
71 let candidate = dir.join(".snapperrc.toml");
72 if candidate.is_file() {
73 return Self::load(&candidate);
74 }
75 if !dir.pop() {
76 break;
77 }
78 }
79 Ok(Self::default())
80 }
81
82 pub fn load(path: &Path) -> Result<Self> {
84 let contents = std::fs::read_to_string(path)?;
85 Self::parse(&contents)
86 }
87
88 fn parse(toml_str: &str) -> Result<Self> {
89 let config: ProjectConfig = toml::from_str(toml_str)?;
90 Ok(config)
91 }
92
93 pub fn resolve(explicit_path: Option<&Path>) -> Result<Self> {
95 if let Some(path) = explicit_path {
96 Self::load(path)
97 } else {
98 let cwd = std::env::current_dir()?;
99 Self::find_and_load(&cwd)
100 }
101 }
102
103 pub fn abbreviations_for_format(&self, format: &str) -> Vec<String> {
106 let mut abbrevs = self.extra_abbreviations.clone();
107 let overrides = match format {
108 "org" => self.org.as_ref(),
109 "latex" => self.latex.as_ref(),
110 "markdown" => self.markdown.as_ref(),
111 "rst" => self.rst.as_ref(),
112 "plaintext" => self.plaintext.as_ref(),
113 _ => None,
114 };
115 if let Some(ov) = overrides {
116 abbrevs.extend(ov.extra_abbreviations.iter().cloned());
117 }
118 abbrevs
119 }
120
121 pub fn max_width_for_format(&self, format: &str) -> Option<usize> {
123 let overrides = match format {
124 "org" => self.org.as_ref(),
125 "latex" => self.latex.as_ref(),
126 "markdown" => self.markdown.as_ref(),
127 "rst" => self.rst.as_ref(),
128 "plaintext" => self.plaintext.as_ref(),
129 _ => None,
130 };
131 overrides.and_then(|ov| ov.max_width).or(self.max_width)
132 }
133
134 #[cfg(any(feature = "cli", feature = "watch"))]
135 pub fn is_ignored(&self, path: &Path) -> bool {
136 self.ignore_patterns.iter().any(|pattern| {
137 Pattern::new(pattern).ok().is_some_and(|compiled| {
138 compiled.matches_path(path)
139 || std::env::current_dir()
140 .ok()
141 .and_then(|cwd| path.strip_prefix(&cwd).ok())
142 .is_some_and(|relative| compiled.matches_path(relative))
143 || path
144 .file_name()
145 .is_some_and(|name| compiled.matches_path(Path::new(name)))
146 })
147 })
148 }
149}
150
151#[cfg(test)]
152mod tests {
153 use super::*;
154
155 #[test]
156 fn parse_empty_config() {
157 let config = ProjectConfig::parse("").unwrap();
158 assert!(config.extra_abbreviations.is_empty());
159 assert!(config.ignore_patterns.is_empty());
160 assert!(config.default_format.is_none());
161 assert!(config.max_width.is_none());
162 }
163
164 #[test]
165 fn parse_full_config() {
166 let toml = r#"
167# Project-specific snapper config
168extra_abbreviations = ["Dept", "Univ", "Corp"]
169ignore = ["*.bib", "*.cls"]
170format = "org"
171max_width = 80
172lang = "de"
173"#;
174 let config = ProjectConfig::parse(toml).unwrap();
175 assert_eq!(config.extra_abbreviations, vec!["Dept", "Univ", "Corp"]);
176 assert_eq!(config.ignore_patterns, vec!["*.bib", "*.cls"]);
177 assert_eq!(config.default_format, Some("org".to_string()));
178 assert_eq!(config.max_width, Some(80));
179 assert_eq!(config.lang, Some("de".to_string()));
180 }
181
182 #[test]
183 fn parse_comments_and_blanks() {
184 let toml = "# comment\n\nextra_abbreviations = [\"Fig\"]\n";
185 let config = ProjectConfig::parse(toml).unwrap();
186 assert_eq!(config.extra_abbreviations, vec!["Fig"]);
187 }
188
189 #[test]
190 fn parse_per_format_overrides() {
191 let toml = r#"
192extra_abbreviations = ["Global"]
193max_width = 80
194
195[org]
196extra_abbreviations = ["PROPERTIES", "DEADLINE"]
197
198[latex]
199extra_abbreviations = ["Thm", "Lem"]
200max_width = 100
201"#;
202 let config = ProjectConfig::parse(toml).unwrap();
203 let org_abbrevs = config.abbreviations_for_format("org");
204 assert!(org_abbrevs.contains(&"Global".to_string()));
205 assert!(org_abbrevs.contains(&"PROPERTIES".to_string()));
206 assert_eq!(config.max_width_for_format("org"), Some(80));
207 assert_eq!(config.max_width_for_format("latex"), Some(100));
208 assert_eq!(config.max_width_for_format("plaintext"), Some(80));
209 }
210
211 #[test]
212 fn parse_code_table_seven_seed_languages() {
213 let toml = r##"
218[code.rust]
219line_comment = "//"
220block_comment = ["/*", "*/"]
221formatter = ["rustfmt", "--edition", "2024"]
222
223[code.python]
224line_comment = "#"
225block_comment = ["\"\"\"", "\"\"\""]
226formatter = ["ruff", "format", "-"]
227
228[code.toml]
229line_comment = "#"
230formatter = ["taplo", "format", "-"]
231
232[code.lua]
233line_comment = "--"
234block_comment = ["--[[", "]]"]
235
236[code.lisp]
237line_comment = ";"
238
239[code.html]
240block_comment = ["<!--", "-->"]
241
242[code.javascript]
243line_comment = "//"
244block_comment = ["/*", "*/"]
245formatter = ["prettier", "--stdin-filepath", "src.js"]
246"##;
247 let config = ProjectConfig::parse(toml).unwrap();
248 assert_eq!(config.code.len(), 7);
249 let rust = config.code.get("rust").expect("rust entry present");
250 assert_eq!(rust.line_comment.as_deref(), Some("//"));
251 assert_eq!(
252 rust.block_comment.as_ref(),
253 Some(&["/*".to_string(), "*/".to_string()])
254 );
255 assert_eq!(
256 rust.formatter.as_deref(),
257 Some(&["rustfmt".to_string(), "--edition".to_string(), "2024".to_string()][..])
258 );
259 let lisp = config.code.get("lisp").expect("lisp entry present");
261 assert_eq!(lisp.line_comment.as_deref(), Some(";"));
262 assert!(lisp.block_comment.is_none());
263 assert!(lisp.formatter.is_none());
264 let html = config.code.get("html").expect("html entry present");
266 assert!(html.line_comment.is_none());
267 assert_eq!(
268 html.block_comment.as_ref(),
269 Some(&["<!--".to_string(), "-->".to_string()])
270 );
271 assert!(html.formatter.is_none());
272 }
273
274 #[test]
275 fn parse_rst_overrides() {
276 let toml = r#"
277[rst]
278extra_abbreviations = ["Fig"]
279max_width = 72
280"#;
281 let config = ProjectConfig::parse(toml).unwrap();
282 assert_eq!(config.max_width_for_format("rst"), Some(72));
283 assert_eq!(config.abbreviations_for_format("rst"), vec!["Fig"]);
284 }
285}