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 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); 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}