Skip to main content

ane/data/
buffer.rs

1use std::path::{Path, PathBuf};
2use std::time::SystemTime;
3
4use anyhow::{Context, Result};
5
6#[derive(Debug, Clone)]
7pub struct Buffer {
8    pub path: PathBuf,
9    pub lines: Vec<String>,
10    pub dirty: bool,
11    /// Whether the source file ended with `\n`. Tracked so `content()` and
12    /// `write()` can reproduce the file byte-for-byte and avoid spurious
13    /// "no newline at end of file" diff noise.
14    pub trailing_newline: bool,
15    pub last_disk_mtime: Option<SystemTime>,
16    pub disk_changed: bool,
17    pub disk_deleted: bool,
18}
19
20impl Buffer {
21    pub fn from_file(path: &Path) -> Result<Self> {
22        let bytes = std::fs::read(path).with_context(|| format!("reading {}", path.display()))?;
23        let content = match std::str::from_utf8(&bytes) {
24            Ok(s) => s.to_string(),
25            Err(_) => anyhow::bail!("file is not valid UTF-8: {}", path.display()),
26        };
27        let trailing_newline = content.ends_with('\n');
28        let mut lines: Vec<String> = content.lines().map(String::from).collect();
29        if lines.is_empty() {
30            lines.push(String::new());
31        }
32        let canonical = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
33        let last_disk_mtime = std::fs::metadata(path).and_then(|m| m.modified()).ok();
34        Ok(Self {
35            path: canonical,
36            lines,
37            dirty: false,
38            trailing_newline,
39            last_disk_mtime,
40            disk_changed: false,
41            disk_deleted: false,
42        })
43    }
44
45    pub fn empty(path: &Path) -> Self {
46        Self {
47            path: path.to_path_buf(),
48            lines: vec![String::new()],
49            dirty: false,
50            trailing_newline: true,
51            last_disk_mtime: None,
52            disk_changed: false,
53            disk_deleted: false,
54        }
55    }
56
57    pub fn line_count(&self) -> usize {
58        self.lines.len()
59    }
60
61    pub fn content(&self) -> String {
62        let mut s = self.lines.join("\n");
63        if self.trailing_newline {
64            s.push('\n');
65        }
66        s
67    }
68
69    pub fn set_line(&mut self, index: usize, text: String) {
70        if index < self.lines.len() {
71            self.lines[index] = text;
72            self.dirty = true;
73        }
74    }
75
76    pub fn insert_line(&mut self, index: usize, text: String) {
77        let idx = index.min(self.lines.len());
78        self.lines.insert(idx, text);
79        self.dirty = true;
80    }
81
82    pub fn remove_line(&mut self, index: usize) -> Option<String> {
83        if index < self.lines.len() && self.lines.len() > 1 {
84            self.dirty = true;
85            Some(self.lines.remove(index))
86        } else {
87            None
88        }
89    }
90
91    pub fn replace_range(&mut self, start: usize, end: usize, replacement: Vec<String>) {
92        let start = start.min(self.lines.len());
93        let end = end.min(self.lines.len());
94        if start <= end {
95            self.lines.splice(start..end, replacement);
96            self.dirty = true;
97        }
98    }
99
100    pub fn record_disk_mtime(&mut self) {
101        self.last_disk_mtime = std::fs::metadata(&self.path)
102            .and_then(|m| m.modified())
103            .ok();
104    }
105
106    pub fn write(&mut self) -> Result<()> {
107        let content = self.content();
108        std::fs::write(&self.path, content)
109            .with_context(|| format!("writing {}", self.path.display()))?;
110        self.dirty = false;
111        self.disk_changed = false;
112        self.disk_deleted = false;
113        self.record_disk_mtime();
114        Ok(())
115    }
116}
117
118#[cfg(test)]
119mod tests {
120    use super::*;
121    use std::io::Write;
122    use tempfile::NamedTempFile;
123
124    fn make_temp(content: &str) -> NamedTempFile {
125        let mut f = NamedTempFile::new().unwrap();
126        f.write_all(content.as_bytes()).unwrap();
127        f
128    }
129
130    #[test]
131    fn round_trip_read_write() {
132        let f = make_temp("hello\nworld");
133        let mut buf = Buffer::from_file(f.path()).unwrap();
134        assert_eq!(buf.line_count(), 2);
135        assert_eq!(buf.lines[0], "hello");
136
137        buf.set_line(0, "goodbye".into());
138        assert!(buf.dirty);
139
140        buf.write().unwrap();
141        let reloaded = Buffer::from_file(f.path()).unwrap();
142        assert_eq!(reloaded.lines[0], "goodbye");
143    }
144
145    #[test]
146    fn insert_and_remove_lines() {
147        let mut buf = Buffer::empty(Path::new("/tmp/test"));
148        buf.insert_line(0, "first".into());
149        buf.insert_line(1, "second".into());
150        assert_eq!(buf.line_count(), 3); // empty initial line + 2 inserted
151        buf.remove_line(0);
152        assert_eq!(buf.lines[0], "second");
153    }
154
155    #[test]
156    fn replace_range() {
157        let f = make_temp("a\nb\nc\nd");
158        let mut buf = Buffer::from_file(f.path()).unwrap();
159        buf.replace_range(1, 3, vec!["x".into(), "y".into(), "z".into()]);
160        assert_eq!(buf.lines, vec!["a", "x", "y", "z", "d"]);
161    }
162
163    #[test]
164    fn from_file_preserves_trailing_newline() {
165        let f = make_temp("hello\nworld\n");
166        let buf = Buffer::from_file(f.path()).unwrap();
167        assert!(buf.trailing_newline);
168        assert_eq!(buf.content(), "hello\nworld\n");
169    }
170
171    #[test]
172    fn from_file_preserves_absent_trailing_newline() {
173        let f = make_temp("hello\nworld");
174        let buf = Buffer::from_file(f.path()).unwrap();
175        assert!(!buf.trailing_newline);
176        assert_eq!(buf.content(), "hello\nworld");
177    }
178
179    #[test]
180    fn write_round_trip_preserves_trailing_newline() {
181        let f = make_temp("hello\nworld\n");
182        let mut buf = Buffer::from_file(f.path()).unwrap();
183        buf.set_line(0, "goodbye".into());
184        buf.write().unwrap();
185        let bytes = std::fs::read(f.path()).unwrap();
186        assert_eq!(bytes, b"goodbye\nworld\n");
187    }
188
189    #[test]
190    fn write_round_trip_preserves_no_trailing_newline() {
191        let f = make_temp("hello\nworld");
192        let mut buf = Buffer::from_file(f.path()).unwrap();
193        buf.set_line(0, "goodbye".into());
194        buf.write().unwrap();
195        let bytes = std::fs::read(f.path()).unwrap();
196        assert_eq!(bytes, b"goodbye\nworld");
197    }
198
199    #[test]
200    fn from_file_rejects_non_utf8_with_specific_message() {
201        let mut f = NamedTempFile::new().unwrap();
202        f.write_all(&[0xff, 0xfe, 0x00, 0x80]).unwrap();
203        f.flush().unwrap();
204        let err = Buffer::from_file(f.path()).unwrap_err();
205        let msg = err.to_string();
206        assert!(msg.starts_with("file is not valid UTF-8: "), "got: {msg}");
207        assert!(msg.contains(&f.path().display().to_string()), "got: {msg}");
208    }
209
210    #[test]
211    fn record_disk_mtime_is_some_for_existing_file() {
212        let f = make_temp("content\n");
213        let mut buf = Buffer::empty(f.path());
214        buf.last_disk_mtime = None;
215        buf.record_disk_mtime();
216        assert!(buf.last_disk_mtime.is_some());
217    }
218
219    #[test]
220    fn record_disk_mtime_updates_to_newer_value_after_modification() {
221        let f = make_temp("initial\n");
222        let mut buf = Buffer::from_file(f.path()).unwrap();
223        assert!(buf.last_disk_mtime.is_some());
224        buf.last_disk_mtime = Some(std::time::SystemTime::UNIX_EPOCH);
225        std::fs::write(f.path(), b"modified\n").unwrap();
226        buf.record_disk_mtime();
227        assert!(
228            buf.last_disk_mtime > Some(std::time::SystemTime::UNIX_EPOCH),
229            "mtime should be newer than UNIX_EPOCH after modification"
230        );
231    }
232
233    #[test]
234    fn write_clears_disk_changed_flag() {
235        let f = make_temp("hello\n");
236        let mut buf = Buffer::from_file(f.path()).unwrap();
237        buf.disk_changed = true;
238        buf.dirty = true;
239        buf.set_line(0, "world".into());
240        buf.write().unwrap();
241        assert!(!buf.disk_changed, "write() must clear disk_changed");
242        assert!(!buf.dirty, "write() must clear dirty");
243    }
244}