lattice_listing/oil/
mod.rs1pub mod modes;
36
37pub use modes::{OilDir, OilMode, register_oil_modes};
38
39use std::path::Path;
40
41#[derive(Debug, Clone, PartialEq, Eq)]
42pub struct OilEntry {
43 pub name: String,
44 pub is_dir: bool,
45}
46
47#[derive(Debug, Clone, Default)]
66pub struct OilSnapshot {
67 entries: Vec<OilEntry>,
68}
69
70impl OilSnapshot {
71 pub fn open(dir: &Path) -> std::io::Result<Self> {
73 Ok(Self {
74 entries: read_dir_entries(dir)?,
75 })
76 }
77
78 pub fn reload(&mut self, dir: &Path) -> std::io::Result<()> {
80 self.entries = read_dir_entries(dir)?;
81 Ok(())
82 }
83
84 pub fn is_dirty(&self, current_text: &str) -> bool {
87 current_text != render_to_text(&self.entries)
88 }
89
90 pub fn snapshot_names(&self) -> Vec<&str> {
92 self.entries.iter().map(|e| e.name.as_str()).collect()
93 }
94
95 pub fn snapshot_entries(&self) -> &[OilEntry] {
97 &self.entries
98 }
99
100 pub fn entry_at_line(&self, line: u32) -> Option<&OilEntry> {
102 self.entries.get(line as usize)
103 }
104
105 pub fn apply(&mut self, dir: &Path, current_text: &str) -> std::io::Result<()> {
116 let current_names: Vec<String> = current_text
117 .lines()
118 .map(|l| l.trim().to_owned())
119 .filter(|l| !l.is_empty())
120 .collect();
121
122 let snap_names: std::collections::HashSet<&str> =
123 self.entries.iter().map(|e| e.name.as_str()).collect();
124 let curr_set: std::collections::HashSet<&str> =
125 current_names.iter().map(|s| s.as_str()).collect();
126
127 let deleted: Vec<&OilEntry> = self
128 .entries
129 .iter()
130 .filter(|e| !curr_set.contains(e.name.as_str()))
131 .collect();
132
133 let created: Vec<&str> = current_names
134 .iter()
135 .map(|s| s.as_str())
136 .filter(|name| !snap_names.contains(*name))
137 .collect();
138
139 if deleted.len() == 1 && created.len() == 1 {
141 let from = dir.join(&deleted[0].name);
142 let to = dir.join(created[0]);
143 std::fs::rename(&from, &to)?;
144 } else {
145 for entry in &deleted {
146 let path = dir.join(&entry.name);
147 if entry.is_dir {
148 std::fs::remove_dir_all(&path)?;
149 } else {
150 std::fs::remove_file(&path)?;
151 }
152 }
153 for name in &created {
154 let clean = name.trim_end_matches('/');
155 let path = dir.join(clean);
156 if name.ends_with('/') {
157 std::fs::create_dir_all(&path)?;
158 } else {
159 std::fs::File::create(&path)?;
160 }
161 }
162 }
163
164 self.entries = read_dir_entries(dir)?;
167 Ok(())
168 }
169}
170
171fn read_dir_entries(dir: &Path) -> std::io::Result<Vec<OilEntry>> {
173 Ok(crate::dir::read_dir_sorted(dir)?
177 .into_iter()
178 .map(|e| OilEntry {
179 name: e.name,
180 is_dir: e.is_dir,
181 })
182 .collect())
183}
184
185pub fn render_to_text(entries: &[OilEntry]) -> String {
192 entries
193 .iter()
194 .map(|e| e.name.as_str())
195 .collect::<Vec<_>>()
196 .join("\n")
197}
198
199pub fn listing_entries(
202 dir: &std::path::Path,
203 entries: &[OilEntry],
204) -> Vec<crate::listing_mode::ListingEntry> {
205 entries
206 .iter()
207 .map(|e| crate::listing_mode::ListingEntry {
208 path: dir.join(&e.name),
209 is_dir: e.is_dir,
210 icon_byte: 0,
211 name_byte_len: e.name.len() as u32,
214 })
215 .collect()
216}
217
218#[cfg(test)]
219mod tests {
220 #![allow(clippy::unwrap_used)]
221 use super::*;
222 use std::path::PathBuf;
223
224 fn temp_dir() -> PathBuf {
225 use std::sync::atomic::{AtomicU64, Ordering};
226 static N: AtomicU64 = AtomicU64::new(0);
227 let dir = std::env::temp_dir().join(format!(
228 "lattice-oil-{}-{}",
229 std::process::id(),
230 N.fetch_add(1, Ordering::Relaxed)
231 ));
232 std::fs::create_dir_all(&dir).unwrap();
233 dir
234 }
235
236 #[test]
237 fn open_lists_dirs_first_then_files_alpha() {
238 let dir = temp_dir();
239 std::fs::write(dir.join("z.txt"), "").unwrap();
240 std::fs::write(dir.join("a.txt"), "").unwrap();
241 std::fs::create_dir(dir.join("sub")).unwrap();
242 let oil = OilSnapshot::open(&dir).unwrap();
243 assert_eq!(oil.snapshot_names(), vec!["sub", "a.txt", "z.txt"]);
244 std::fs::remove_dir_all(dir).ok();
245 }
246
247 #[test]
248 fn rope_contains_bare_names_only() {
249 let dir = temp_dir();
250 std::fs::write(dir.join("main.rs"), "").unwrap();
251 let oil = OilSnapshot::open(&dir).unwrap();
252 let text = render_to_text(oil.snapshot_entries());
253 assert_eq!(text.trim(), "main.rs");
254 assert!(!text.contains(""), "icon should not be in rope");
255 std::fs::remove_dir_all(dir).ok();
256 }
257
258 #[test]
259 fn reload_replaces_listing() {
260 let dir = temp_dir();
261 let sub = dir.join("sub");
262 std::fs::create_dir(&sub).unwrap();
263 std::fs::write(sub.join("inner.rs"), "").unwrap();
264 let mut oil = OilSnapshot::open(&dir).unwrap();
265 oil.reload(&sub).unwrap();
266 assert_eq!(oil.snapshot_names(), vec!["inner.rs"]);
267 std::fs::remove_dir_all(dir).ok();
268 }
269
270 #[test]
271 fn navigate_up_via_caller_computed_parent() {
272 let dir = temp_dir();
278 let sub = dir.join("sub");
279 std::fs::create_dir(&sub).unwrap();
280 std::fs::write(dir.join("a.txt"), "").unwrap();
281 let mut oil = OilSnapshot::open(&sub).unwrap();
282 let parent = sub.parent().expect("sub has parent").to_path_buf();
284 oil.reload(&parent).unwrap();
285 assert!(oil.snapshot_names().contains(&"a.txt"));
286 std::fs::remove_dir_all(dir).ok();
287 }
288
289 #[test]
290 fn is_dirty_false_when_unchanged() {
291 let dir = temp_dir();
292 std::fs::write(dir.join("a.txt"), "").unwrap();
293 let oil = OilSnapshot::open(&dir).unwrap();
294 assert!(!oil.is_dirty(&render_to_text(oil.snapshot_entries())));
295 std::fs::remove_dir_all(dir).ok();
296 }
297
298 #[test]
299 fn apply_renames_single_file() {
300 let dir = temp_dir();
301 std::fs::write(dir.join("old.txt"), "content").unwrap();
302 let mut oil = OilSnapshot::open(&dir).unwrap();
303 let text = "new.txt".to_string();
305 oil.apply(&dir, &text).unwrap();
306 assert!(
307 dir.join("new.txt").exists(),
308 "new.txt should exist after rename"
309 );
310 assert!(
311 !dir.join("old.txt").exists(),
312 "old.txt should not exist after rename"
313 );
314 std::fs::remove_dir_all(dir).ok();
315 }
316
317 #[test]
318 fn apply_deletes_removed_lines() {
319 let dir = temp_dir();
320 std::fs::write(dir.join("keep.txt"), "").unwrap();
321 std::fs::write(dir.join("gone.txt"), "").unwrap();
322 let mut oil = OilSnapshot::open(&dir).unwrap();
323 let text = render_to_text(oil.snapshot_entries());
324 let new_text = text
325 .lines()
326 .filter(|l| *l != "gone.txt")
327 .collect::<Vec<_>>()
328 .join("\n");
329 oil.apply(&dir, &new_text).unwrap();
330 assert!(!dir.join("gone.txt").exists());
331 assert!(dir.join("keep.txt").exists());
332 std::fs::remove_dir_all(dir).ok();
333 }
334
335 #[test]
336 fn apply_creates_new_file_lines() {
337 let dir = temp_dir();
338 std::fs::write(dir.join("existing.txt"), "").unwrap();
339 let mut oil = OilSnapshot::open(&dir).unwrap();
340 let mut text = render_to_text(oil.snapshot_entries());
341 text.push_str("\nnewfile.txt");
342 let text = text;
343 oil.apply(&dir, &text).unwrap();
344 assert!(dir.join("newfile.txt").exists());
345 std::fs::remove_dir_all(dir).ok();
346 }
347
348 #[test]
349 fn apply_handles_multiple_deletes_and_creates() {
350 let dir = temp_dir();
351 std::fs::write(dir.join("a.txt"), "").unwrap();
352 std::fs::write(dir.join("b.txt"), "").unwrap();
353 let mut oil = OilSnapshot::open(&dir).unwrap();
354 let text = "c.txt\nd.txt".to_string();
355 oil.apply(&dir, &text).unwrap();
356 assert!(!dir.join("a.txt").exists());
357 assert!(!dir.join("b.txt").exists());
358 assert!(dir.join("c.txt").exists());
359 assert!(dir.join("d.txt").exists());
360 std::fs::remove_dir_all(dir).ok();
361 }
362
363 #[test]
364 fn apply_refreshes_snapshot_on_success() {
365 let dir = temp_dir();
366 std::fs::write(dir.join("a.txt"), "").unwrap();
367 let mut oil = OilSnapshot::open(&dir).unwrap();
368 let text = "b.txt".to_string();
369 oil.apply(&dir, &text).unwrap();
370 assert_eq!(oil.snapshot_names(), vec!["b.txt"]);
371 assert!(!oil.is_dirty(&render_to_text(oil.snapshot_entries())));
372 std::fs::remove_dir_all(dir).ok();
373 }
374}