termesh_filesystem/service.rs
1//! The `FileSystemService` boundary: the only door to the real filesystem.
2//!
3//! Per ADR-0005 §3 these methods are **blocking and synchronous**. They are called only
4//! from the filesystem worker thread, so the non-blocking guarantee comes from *where*
5//! they run, not from `async fn`. That keeps the trait object-safe, keeps the in-memory
6//! fake trivial, and avoids colouring the codebase `async` before Phase 03 needs it.
7//!
8//! This shape is the template `GitService`, `PtyService`, and `LanguageService` follow.
9//!
10//! The data types themselves live in `core` because [`termesh_core::AppMessage`] has to
11//! carry them across the worker/state boundary; they are re-exported here so callers
12//! only ever need one import.
13
14use std::path::{Path, PathBuf};
15
16pub use termesh_core::{DirEntryInfo, EntryKind, FsError, FsResult};
17
18/// Read and mutate the filesystem. Widgets and the agent reach the OS only through this
19/// (CONTRIBUTING.md invariants, ARCHITECTURE.md §7.4) — never `std::fs` directly.
20///
21/// Every write method is a permission-gate chokepoint for the agent's future
22/// `file.create` / `file.rename` tool calls, which is why they live behind one trait.
23pub trait FileSystemService: Send + Sync {
24 /// List one directory level. Does not recurse — the tree is lazy (ADR-0005 §2).
25 ///
26 /// **Contract:** entries come back sorted, directories first, then by name
27 /// case-insensitively. Ordering is part of the contract rather than left to the
28 /// caller so the real and fake implementations are interchangeable in tests.
29 fn read_dir(&self, path: &Path) -> FsResult<Vec<DirEntryInfo>>;
30
31 fn read_file(&self, path: &Path) -> FsResult<Vec<u8>>;
32
33 /// Create an empty file. Errors with [`FsError::AlreadyExists`] rather than truncating.
34 fn create_file(&self, path: &Path) -> FsResult<()>;
35
36 /// Replace a file's contents, creating it if absent.
37 ///
38 /// Distinct from [`Self::create_file`] on purpose: creating is a user gesture that
39 /// must not clobber, whereas writing is a deliberate overwrite. Phase 03's buffer
40 /// save lands here too.
41 fn write_file(&self, path: &Path, contents: &[u8]) -> FsResult<()>;
42
43 /// Create a directory, including missing parents.
44 fn create_dir(&self, path: &Path) -> FsResult<()>;
45
46 fn rename(&self, from: &Path, to: &Path) -> FsResult<()>;
47
48 fn remove_file(&self, path: &Path) -> FsResult<()>;
49
50 /// Recursively delete a directory. Named for what it does: callers must confirm
51 /// with the user (or hold an agent permission grant) before invoking it.
52 fn remove_dir_all(&self, path: &Path) -> FsResult<()>;
53
54 /// Resolve symlinks and `..` to an absolute path. Used as the loop guard when
55 /// deciding whether a symlinked directory has already been visited.
56 fn canonicalize(&self, path: &Path) -> FsResult<PathBuf>;
57}
58
59/// Apply the [`FileSystemService::read_dir`] ordering contract.
60pub fn sort_entries(entries: &mut [DirEntryInfo]) {
61 entries.sort_by(|a, b| {
62 a.kind
63 .sort_rank()
64 .cmp(&b.kind.sort_rank())
65 .then_with(|| {
66 a.name
67 .to_string_lossy()
68 .to_lowercase()
69 .cmp(&b.name.to_string_lossy().to_lowercase())
70 })
71 // Tie-break on the raw name so equal-ignoring-case names stay deterministic.
72 .then_with(|| a.name.cmp(&b.name))
73 });
74}
75
76#[cfg(test)]
77mod tests {
78 use super::*;
79
80 fn entry(name: &str, kind: EntryKind) -> DirEntryInfo {
81 DirEntryInfo { name: name.into(), path: PathBuf::from(name), kind }
82 }
83
84 #[test]
85 fn dirs_sort_before_files_then_case_insensitively() {
86 let mut v = vec![
87 entry("README.md", EntryKind::File),
88 entry("src", EntryKind::Dir),
89 entry("Cargo.toml", EntryKind::File),
90 entry("assets", EntryKind::Dir),
91 ];
92 sort_entries(&mut v);
93 let names: Vec<_> = v.iter().map(|e| e.name.to_string_lossy().into_owned()).collect();
94 assert_eq!(names, ["assets", "src", "Cargo.toml", "README.md"]);
95 }
96
97 #[test]
98 fn sort_is_deterministic_for_names_differing_only_by_case() {
99 let mut v = vec![entry("b", EntryKind::File), entry("B", EntryKind::File)];
100 sort_entries(&mut v);
101 let first = v[0].name.clone();
102 sort_entries(&mut v);
103 assert_eq!(v[0].name, first);
104 }
105}