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