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