1use std::fs;
2use std::path::Path;
3
4use anyhow::{Context, Result};
5
6fn detect_formats(dir: &Path) -> Vec<&'static str> {
8 let mut formats = Vec::new();
9 let check = |ext: &str| -> bool {
10 walkdir(dir)
11 .into_iter()
12 .any(|e| e.path().extension().and_then(|e| e.to_str()) == Some(ext))
13 };
14 fn walkdir(dir: &Path) -> Vec<fs::DirEntry> {
16 let mut entries = Vec::new();
17 if let Ok(rd) = fs::read_dir(dir) {
18 for entry in rd.flatten() {
19 let path = entry.path();
20 if path.is_dir()
21 && !path
22 .file_name()
23 .is_some_and(|n| n.to_string_lossy().starts_with('.'))
24 {
25 entries.extend(walkdir(&path));
26 } else {
27 entries.push(entry);
28 }
29 }
30 }
31 entries
32 }
33
34 if check("org") {
35 formats.push("org");
36 }
37 if check("tex") || check("latex") {
38 formats.push("latex");
39 }
40 if check("md") || check("markdown") {
41 formats.push("markdown");
42 }
43 formats
44}
45
46fn generate_config(formats: &[&str]) -> String {
48 let default_format = formats.first().copied().unwrap_or("plaintext");
49 format!(
50 r##"# snapper project configuration
51# https://snapper.turtletech.us/docs/reference/config/
52
53# Extra abbreviations (merged with built-in list)
54# extra_abbreviations = ["GROMACS", "LAMMPS", "DFT"]
55
56# File patterns to ignore
57# ignore = ["*.bib", "*.cls", "*.sty"]
58
59# Default format (auto-detected from extension if omitted)
60format = "{default_format}"
61
62# Maximum line width (0 = unlimited)
63max_width = 0
64
65# Per-language code-block reflow and formatter delegation.
66# Each language entry may set any combination of:
67# line_comment -- marker for single-line comments
68# block_comment -- ["open", "close"] markers for multi-line comments
69# formatter -- argv passed to std::process::Command for --format-code
70# Missing fields are no-ops for that language.
71
72[code.rust]
73line_comment = "//"
74block_comment = ["/*", "*/"]
75formatter = ["rustfmt", "--edition", "2024"]
76
77[code.python]
78line_comment = "#"
79block_comment = ["\"\"\"", "\"\"\""]
80formatter = ["ruff", "format", "-"]
81
82[code.toml]
83line_comment = "#"
84formatter = ["taplo", "format", "-"]
85
86[code.lua]
87line_comment = "--"
88block_comment = ["--[[", "]]"]
89
90[code.lisp]
91line_comment = ";"
92
93[code.html]
94block_comment = ["<!--", "-->"]
95
96[code.javascript]
97line_comment = "//"
98block_comment = ["/*", "*/"]
99formatter = ["prettier", "--stdin-filepath", "src.js"]
100"##
101 )
102}
103
104fn generate_gitattributes(formats: &[&str]) -> String {
106 let mut lines = String::from("# snapper semantic line break filter\n");
107 for fmt in formats {
108 let ext = match *fmt {
109 "org" => "*.org",
110 "latex" => "*.tex",
111 "markdown" => "*.md",
112 _ => continue,
113 };
114 lines.push_str(&format!("{ext} filter=snapper\n"));
115 }
116 lines
117}
118
119fn generate_precommit() -> String {
121 format!(
122 r#"# Add to .pre-commit-config.yaml:
123- repo: https://github.com/TurtleTech-ehf/snapper
124 rev: v{}
125 hooks:
126 - id: snapper
127"#,
128 env!("CARGO_PKG_VERSION")
129 )
130}
131
132fn generate_apheleia(formats: &[&str]) -> String {
134 let mut s = String::from(";; Add to your Emacs config:\n(with-eval-after-load 'apheleia\n");
135 s.push_str(" (push '(snapper . (\"snapper\")) apheleia-formatters)\n");
136 for fmt in formats {
137 let mode = match *fmt {
138 "org" => "org-mode",
139 "latex" => "latex-mode",
140 "markdown" => "markdown-mode",
141 _ => continue,
142 };
143 s.push_str(&format!(
144 " (push '({mode} . snapper) apheleia-mode-alist)\n"
145 ));
146 }
147 s.push_str(")\n");
148 s
149}
150
151pub fn run_init(dry_run: bool) -> Result<()> {
153 let cwd = std::env::current_dir()?;
154 let formats = detect_formats(&cwd);
155
156 eprintln!(
157 "Detected formats: {}",
158 if formats.is_empty() {
159 "none (will use plaintext defaults)".to_string()
160 } else {
161 formats.join(", ")
162 }
163 );
164
165 let config_content = generate_config(&formats);
167 let config_path = cwd.join(".snapperrc.toml");
168 if config_path.exists() {
169 eprintln!(" .snapperrc.toml already exists, skipping");
170 } else if dry_run {
171 eprintln!("\n--- .snapperrc.toml ---");
172 eprint!("{config_content}");
173 } else {
174 fs::write(&config_path, &config_content).context("failed to write .snapperrc.toml")?;
175 eprintln!(" Created .snapperrc.toml");
176 }
177
178 if !formats.is_empty() {
180 let ga_content = generate_gitattributes(&formats);
181 let ga_path = cwd.join(".gitattributes");
182 if dry_run {
183 eprintln!("\n--- .gitattributes (append) ---");
184 eprint!("{ga_content}");
185 } else if ga_path.exists() {
186 let existing = fs::read_to_string(&ga_path)?;
187 if !existing.contains("filter=snapper") {
188 fs::write(&ga_path, format!("{existing}\n{ga_content}"))
189 .context("failed to append .gitattributes")?;
190 eprintln!(" Appended to .gitattributes");
191 } else {
192 eprintln!(" .gitattributes already has snapper filter, skipping");
193 }
194 } else {
195 fs::write(&ga_path, &ga_content).context("failed to write .gitattributes")?;
196 eprintln!(" Created .gitattributes");
197 }
198 }
199
200 eprintln!("\n{}", generate_precommit());
202 eprintln!("{}", generate_apheleia(&formats));
203
204 eprintln!("To enable the git smudge/clean filter, run:");
206 eprintln!(" git config filter.snapper.clean \"snapper\"");
207 eprintln!(" git config filter.snapper.smudge cat");
208
209 Ok(())
210}
211
212#[cfg(test)]
213mod tests {
214 use super::*;
215
216 #[test]
217 fn generate_config_with_org() {
218 let config = generate_config(&["org"]);
219 assert!(config.contains("format = \"org\""));
220 assert!(config.contains("max_width = 0"));
221 }
222
223 #[test]
224 fn generate_config_empty_defaults_to_plaintext() {
225 let config = generate_config(&[]);
226 assert!(config.contains("format = \"plaintext\""));
227 }
228
229 #[test]
230 fn generate_config_includes_seven_code_languages() {
231 let config = generate_config(&["markdown"]);
232 for lang in ["rust", "python", "toml", "lua", "lisp", "html", "javascript"] {
234 assert!(
235 config.contains(&format!("[code.{lang}]")),
236 "missing [code.{lang}] entry in init template",
237 );
238 }
239 assert!(config.contains(r#"line_comment = "//""#));
241 assert!(config.contains(r#"formatter = ["rustfmt", "--edition", "2024"]"#));
242 }
243
244 #[test]
245 fn generate_gitattributes_multiple_formats() {
246 let ga = generate_gitattributes(&["org", "latex", "markdown"]);
247 assert!(ga.contains("*.org filter=snapper"));
248 assert!(ga.contains("*.tex filter=snapper"));
249 assert!(ga.contains("*.md filter=snapper"));
250 }
251
252 #[test]
253 fn generate_gitattributes_empty() {
254 let ga = generate_gitattributes(&[]);
255 assert!(ga.contains("# snapper"));
256 assert!(!ga.contains("filter=snapper"));
257 }
258}