Skip to main content

snapper_fmt/
files.rs

1use anyhow::{Context, Result};
2use std::path::Path;
3
4use crate::FormatConfig;
5use crate::diff;
6use crate::format::Format;
7
8/// Format a file on disk, returning the formatted text.
9/// Auto-detects format from the file extension.
10pub fn format_file(path: &str, max_width: usize) -> Result<String> {
11    let content =
12        std::fs::read_to_string(path).with_context(|| format!("failed to read {}", path))?;
13    let fmt = Format::recognized_from_path(Path::new(path)).ok_or_else(|| {
14        anyhow::anyhow!("{path}: not a prose format; pass --format or use .org/.tex/.md/.rst/.txt")
15    })?;
16    let config = FormatConfig {
17        format: fmt,
18        max_width,
19        ..Default::default()
20    };
21    crate::format_text(&content, &config)
22}
23
24/// Format a file with extra abbreviations from a project config.
25pub fn format_file_with(
26    path: &str,
27    max_width: usize,
28    extra_abbreviations: Vec<String>,
29) -> Result<String> {
30    let content =
31        std::fs::read_to_string(path).with_context(|| format!("failed to read {}", path))?;
32    let fmt = Format::recognized_from_path(Path::new(path)).ok_or_else(|| {
33        anyhow::anyhow!("{path}: not a prose format; pass --format or use .org/.tex/.md/.rst/.txt")
34    })?;
35    let config = FormatConfig {
36        format: fmt,
37        max_width,
38        extra_abbreviations,
39        ..Default::default()
40    };
41    crate::format_text(&content, &config)
42}
43
44/// Check if a file needs formatting. Returns `None` if already formatted,
45/// or `Some(unified_diff)` if changes are needed.
46pub fn check_file(path: &str, max_width: usize) -> Result<Option<String>> {
47    let original =
48        std::fs::read_to_string(path).with_context(|| format!("failed to read {}", path))?;
49    let formatted = format_file(path, max_width)?;
50    if original == formatted {
51        return Ok(None);
52    }
53    Ok(Some(diff::unified_diff(path, &original, &formatted)))
54}
55
56/// Format a file in-place. Returns `true` if the file was changed.
57pub fn format_in_place(path: &str, max_width: usize) -> Result<bool> {
58    let original =
59        std::fs::read_to_string(path).with_context(|| format!("failed to read {}", path))?;
60    let formatted = format_file(path, max_width)?;
61    if original == formatted {
62        return Ok(false);
63    }
64    std::fs::write(path, &formatted).with_context(|| format!("failed to write {}", path))?;
65    Ok(true)
66}
67
68#[cfg(test)]
69mod tests {
70    use super::*;
71
72    #[test]
73    fn already_formatted() {
74        let tmp = std::env::temp_dir().join("snap_files_ok.md");
75        std::fs::write(&tmp, "Hello world.\nThis is a test.\n").unwrap();
76        let result = check_file(tmp.to_str().unwrap(), 0).unwrap();
77        assert!(result.is_none());
78        std::fs::remove_file(&tmp).ok();
79    }
80
81    #[test]
82    fn needs_formatting() {
83        let tmp = std::env::temp_dir().join("snap_files_needs.md");
84        std::fs::write(&tmp, "Hello world. This is a test. Another sentence.\n").unwrap();
85        let result = check_file(tmp.to_str().unwrap(), 0).unwrap();
86        assert!(result.is_some());
87        let diff = result.unwrap();
88        assert!(diff.contains("--- a/"));
89        assert!(diff.contains("+Hello world."));
90        std::fs::remove_file(&tmp).ok();
91    }
92
93    #[test]
94    fn format_in_place_changes_file() {
95        let tmp = std::env::temp_dir().join("snap_files_inplace.md");
96        std::fs::write(&tmp, "Hello world. This is a test.\n").unwrap();
97        let changed = format_in_place(tmp.to_str().unwrap(), 0).unwrap();
98        assert!(changed);
99        let content = std::fs::read_to_string(&tmp).unwrap();
100        assert!(content.contains("Hello world.\n"));
101        assert!(content.contains("This is a test.\n"));
102        std::fs::remove_file(&tmp).ok();
103    }
104
105    #[test]
106    fn format_in_place_noop_when_formatted() {
107        let tmp = std::env::temp_dir().join("snap_files_noop.md");
108        std::fs::write(&tmp, "Hello world.\nThis is a test.\n").unwrap();
109        let changed = format_in_place(tmp.to_str().unwrap(), 0).unwrap();
110        assert!(!changed);
111        std::fs::remove_file(&tmp).ok();
112    }
113
114    #[test]
115    fn format_file_refuses_source_extension() {
116        let tmp = std::env::temp_dir().join("snap_files_refuse.rs");
117        std::fs::write(&tmp, "fn main() { let x = 1.0; }\n").unwrap();
118        let err = format_file(tmp.to_str().unwrap(), 0).unwrap_err();
119        assert!(err.to_string().contains("not a prose format"), "got: {err}");
120        std::fs::remove_file(&tmp).ok();
121    }
122}