Skip to main content

ane/data/
buffer.rs

1use std::path::{Path, PathBuf};
2
3use anyhow::{Context, Result};
4
5#[derive(Debug, Clone)]
6pub struct Buffer {
7    pub path: PathBuf,
8    pub lines: Vec<String>,
9    pub dirty: bool,
10    /// Whether the source file ended with `\n`. Tracked so `content()` and
11    /// `write()` can reproduce the file byte-for-byte and avoid spurious
12    /// "no newline at end of file" diff noise.
13    pub trailing_newline: bool,
14}
15
16impl Buffer {
17    pub fn from_file(path: &Path) -> Result<Self> {
18        let bytes = std::fs::read(path).with_context(|| format!("reading {}", path.display()))?;
19        let content = match std::str::from_utf8(&bytes) {
20            Ok(s) => s.to_string(),
21            Err(_) => anyhow::bail!("file is not valid UTF-8: {}", path.display()),
22        };
23        let trailing_newline = content.ends_with('\n');
24        let lines: Vec<String> = content.lines().map(String::from).collect();
25        Ok(Self {
26            path: path.to_path_buf(),
27            lines,
28            dirty: false,
29            trailing_newline,
30        })
31    }
32
33    pub fn empty(path: &Path) -> Self {
34        Self {
35            path: path.to_path_buf(),
36            lines: vec![String::new()],
37            dirty: false,
38            trailing_newline: true,
39        }
40    }
41
42    pub fn line_count(&self) -> usize {
43        self.lines.len()
44    }
45
46    pub fn content(&self) -> String {
47        let mut s = self.lines.join("\n");
48        if self.trailing_newline {
49            s.push('\n');
50        }
51        s
52    }
53
54    pub fn set_line(&mut self, index: usize, text: String) {
55        if index < self.lines.len() {
56            self.lines[index] = text;
57            self.dirty = true;
58        }
59    }
60
61    pub fn insert_line(&mut self, index: usize, text: String) {
62        let idx = index.min(self.lines.len());
63        self.lines.insert(idx, text);
64        self.dirty = true;
65    }
66
67    pub fn remove_line(&mut self, index: usize) -> Option<String> {
68        if index < self.lines.len() && self.lines.len() > 1 {
69            self.dirty = true;
70            Some(self.lines.remove(index))
71        } else {
72            None
73        }
74    }
75
76    pub fn replace_range(&mut self, start: usize, end: usize, replacement: Vec<String>) {
77        let start = start.min(self.lines.len());
78        let end = end.min(self.lines.len());
79        if start <= end {
80            self.lines.splice(start..end, replacement);
81            self.dirty = true;
82        }
83    }
84
85    pub fn write(&mut self) -> Result<()> {
86        // std::fs::write performs a single open(O_WRONLY|O_CREAT|O_TRUNC)+write
87        // so a mid-write failure can leave the file partially overwritten.
88        // Atomic replace would require a tempfile in the same directory.
89        let content = self.content();
90        std::fs::write(&self.path, content)
91            .with_context(|| format!("writing {}", self.path.display()))?;
92        self.dirty = false;
93        Ok(())
94    }
95}
96
97#[cfg(test)]
98mod tests {
99    use super::*;
100    use std::io::Write;
101    use tempfile::NamedTempFile;
102
103    fn make_temp(content: &str) -> NamedTempFile {
104        let mut f = NamedTempFile::new().unwrap();
105        f.write_all(content.as_bytes()).unwrap();
106        f
107    }
108
109    #[test]
110    fn round_trip_read_write() {
111        let f = make_temp("hello\nworld");
112        let mut buf = Buffer::from_file(f.path()).unwrap();
113        assert_eq!(buf.line_count(), 2);
114        assert_eq!(buf.lines[0], "hello");
115
116        buf.set_line(0, "goodbye".into());
117        assert!(buf.dirty);
118
119        buf.write().unwrap();
120        let reloaded = Buffer::from_file(f.path()).unwrap();
121        assert_eq!(reloaded.lines[0], "goodbye");
122    }
123
124    #[test]
125    fn insert_and_remove_lines() {
126        let mut buf = Buffer::empty(Path::new("/tmp/test"));
127        buf.insert_line(0, "first".into());
128        buf.insert_line(1, "second".into());
129        assert_eq!(buf.line_count(), 3); // empty initial line + 2 inserted
130        buf.remove_line(0);
131        assert_eq!(buf.lines[0], "second");
132    }
133
134    #[test]
135    fn replace_range() {
136        let f = make_temp("a\nb\nc\nd");
137        let mut buf = Buffer::from_file(f.path()).unwrap();
138        buf.replace_range(1, 3, vec!["x".into(), "y".into(), "z".into()]);
139        assert_eq!(buf.lines, vec!["a", "x", "y", "z", "d"]);
140    }
141
142    #[test]
143    fn from_file_preserves_trailing_newline() {
144        let f = make_temp("hello\nworld\n");
145        let buf = Buffer::from_file(f.path()).unwrap();
146        assert!(buf.trailing_newline);
147        assert_eq!(buf.content(), "hello\nworld\n");
148    }
149
150    #[test]
151    fn from_file_preserves_absent_trailing_newline() {
152        let f = make_temp("hello\nworld");
153        let buf = Buffer::from_file(f.path()).unwrap();
154        assert!(!buf.trailing_newline);
155        assert_eq!(buf.content(), "hello\nworld");
156    }
157
158    #[test]
159    fn write_round_trip_preserves_trailing_newline() {
160        let f = make_temp("hello\nworld\n");
161        let mut buf = Buffer::from_file(f.path()).unwrap();
162        buf.set_line(0, "goodbye".into());
163        buf.write().unwrap();
164        let bytes = std::fs::read(f.path()).unwrap();
165        assert_eq!(bytes, b"goodbye\nworld\n");
166    }
167
168    #[test]
169    fn write_round_trip_preserves_no_trailing_newline() {
170        let f = make_temp("hello\nworld");
171        let mut buf = Buffer::from_file(f.path()).unwrap();
172        buf.set_line(0, "goodbye".into());
173        buf.write().unwrap();
174        let bytes = std::fs::read(f.path()).unwrap();
175        assert_eq!(bytes, b"goodbye\nworld");
176    }
177
178    #[test]
179    fn from_file_rejects_non_utf8_with_specific_message() {
180        let mut f = NamedTempFile::new().unwrap();
181        f.write_all(&[0xff, 0xfe, 0x00, 0x80]).unwrap();
182        f.flush().unwrap();
183        let err = Buffer::from_file(f.path()).unwrap_err();
184        let msg = err.to_string();
185        assert!(msg.starts_with("file is not valid UTF-8: "), "got: {msg}");
186        assert!(msg.contains(&f.path().display().to_string()), "got: {msg}");
187    }
188}