Skip to main content

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}