termesh_core/fs.rs
1//! Filesystem data types shared across the service boundary.
2//!
3//! These live in `core` rather than in `filesystem` because [`crate::AppMessage`] has to
4//! carry them: the worker thread produces them and the single state owner consumes them
5//! (ARCHITECTURE.md §7.1, §7.2 — `core` is the shared-types crate). The
6//! `FileSystemService` trait and its implementations stay in `filesystem`, which
7//! re-exports everything here so call sites see one module.
8
9use std::ffi::OsString;
10use std::path::{Path, PathBuf};
11
12use crate::{BufferId, LocationRequestId, NodeId, PreviewRequestId};
13
14/// What a directory entry is, determined *without* following symlinks — a symlink
15/// reports as [`EntryKind::Symlink`] whatever it points at (ADR-0005 §6).
16#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
17pub enum EntryKind {
18 Dir,
19 File,
20 Symlink,
21}
22
23impl EntryKind {
24 /// Directories sort before everything else in the explorer.
25 pub fn sort_rank(self) -> u8 {
26 match self {
27 EntryKind::Dir => 0,
28 EntryKind::File | EntryKind::Symlink => 1,
29 }
30 }
31}
32
33/// One entry in a directory listing.
34#[derive(Debug, Clone, PartialEq, Eq)]
35pub struct DirEntryInfo {
36 /// The file name only. Kept as an `OsString` so non-UTF-8 names survive intact;
37 /// rendering lossy-converts, but we never lose the real name (ADR-0005 §6).
38 pub name: OsString,
39 pub path: PathBuf,
40 pub kind: EntryKind,
41}
42
43/// Filesystem failures, in the vocabulary the explorer actually needs.
44///
45/// Deliberately not `std::io::Error`: these get stored in tree nodes to render a failed
46/// expansion inline, and `io::Error` is neither `Clone` nor `PartialEq`.
47#[derive(Debug, Clone, PartialEq, Eq)]
48pub enum FsError {
49 NotFound(PathBuf),
50 PermissionDenied(PathBuf),
51 NotADirectory(PathBuf),
52 AlreadyExists(PathBuf),
53 Other { path: PathBuf, message: String },
54}
55
56impl FsError {
57 /// The path the failure is about, for attaching the error to a tree node.
58 pub fn path(&self) -> &Path {
59 match self {
60 FsError::NotFound(p)
61 | FsError::PermissionDenied(p)
62 | FsError::NotADirectory(p)
63 | FsError::AlreadyExists(p)
64 | FsError::Other { path: p, .. } => p,
65 }
66 }
67}
68
69impl std::fmt::Display for FsError {
70 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
71 match self {
72 FsError::NotFound(p) => write!(f, "not found: {}", p.display()),
73 FsError::PermissionDenied(p) => write!(f, "permission denied: {}", p.display()),
74 FsError::NotADirectory(p) => write!(f, "not a directory: {}", p.display()),
75 FsError::AlreadyExists(p) => write!(f, "already exists: {}", p.display()),
76 FsError::Other { path, message } => write!(f, "{}: {message}", path.display()),
77 }
78 }
79}
80
81impl std::error::Error for FsError {}
82
83pub type FsResult<T> = Result<T, FsError>;
84
85/// Work sent *to* the filesystem worker thread.
86///
87/// Intentionally *not* `#[non_exhaustive]`: this is internal vocabulary between our own
88/// crates, and we want the compiler to flag every unhandled variant rather than letting
89/// a wildcard arm swallow it.
90#[derive(Debug, Clone, PartialEq, Eq)]
91pub enum FsRequest {
92 /// List one directory level for the tree node that asked for it.
93 ReadDir {
94 id: NodeId,
95 path: PathBuf,
96 },
97 /// Read a whole file for a buffer that is being opened.
98 ///
99 /// Opening a file is blocking I/O like any other, so it goes through the worker
100 /// rather than the render loop — a cold file on a network mount must not freeze the
101 /// UI any more than a cold directory does (ADR-0005 §1).
102 ReadFile {
103 buffer: BufferId,
104 path: PathBuf,
105 },
106 /// Read a small line window for a search-result preview without opening a buffer.
107 ReadPreview {
108 request: PreviewRequestId,
109 path: PathBuf,
110 line: usize,
111 context: usize,
112 },
113 /// Canonicalize a diagnostic path before the model decides whether it is safe to
114 /// open inside the current workspace.
115 ResolvePath {
116 request: LocationRequestId,
117 path: PathBuf,
118 },
119 /// Write a buffer back to disk.
120 ///
121 /// `version` is the buffer revision these bytes were taken from; it comes back on
122 /// [`FsEvent::FileSaved`] so the buffer only clears its dirty flag if nothing was
123 /// typed while the write was in flight. Carried as a raw `u64` because `core` must
124 /// not depend on `editor`.
125 WriteFile {
126 buffer: BufferId,
127 path: PathBuf,
128 contents: Vec<u8>,
129 version: u64,
130 },
131 /// Begin watching a root for changes.
132 Watch(PathBuf),
133 CreateFile(PathBuf),
134 CreateDir(PathBuf),
135 Rename {
136 from: PathBuf,
137 to: PathBuf,
138 },
139 /// Delete a file, or a directory and everything under it. The caller must have
140 /// confirmed with the user first — the worker does not second-guess it.
141 Remove {
142 path: PathBuf,
143 recursive: bool,
144 },
145 /// Stop the worker. Sent on shutdown so the thread exits its loop cleanly.
146 Shutdown,
147}
148
149/// Results sent *back* from the filesystem worker into the state loop.
150/// Exhaustive for the same reason as [`FsRequest`].
151#[derive(Debug, Clone, PartialEq, Eq)]
152pub enum FsEvent {
153 DirLoaded {
154 id: NodeId,
155 entries: Vec<DirEntryInfo>,
156 },
157 DirFailed {
158 id: NodeId,
159 error: FsError,
160 },
161 /// A watched directory changed on disk and should be re-read. Paths, not ids,
162 /// because the watcher knows nothing about the tree's identity scheme.
163 ///
164 /// Successful mutations report themselves this way too, so there is exactly one
165 /// path that brings disk state back into the tree whether or not a watcher is running.
166 Changed(Vec<PathBuf>),
167 /// A create/rename/delete failed. Success needs no event — it arrives as `Changed`.
168 MutationFailed(FsError),
169
170 /// A file was read for a buffer being opened. Bytes, not text: decoding is the
171 /// editor's decision, and `core` stays out of it.
172 FileLoaded {
173 buffer: BufferId,
174 path: PathBuf,
175 contents: Vec<u8>,
176 },
177 /// A buffer was written to disk at revision `version`.
178 FileSaved {
179 buffer: BufferId,
180 version: u64,
181 },
182 /// A read or write for a specific buffer failed. Distinct from [`Self::MutationFailed`]
183 /// because it has a buffer to report against, not just a path.
184 FileFailed {
185 buffer: BufferId,
186 error: FsError,
187 },
188 PreviewLoaded {
189 request: PreviewRequestId,
190 path: PathBuf,
191 start_line: usize,
192 text: String,
193 },
194 PreviewFailed {
195 request: PreviewRequestId,
196 path: PathBuf,
197 error: FsError,
198 },
199 PathResolved {
200 request: LocationRequestId,
201 path: PathBuf,
202 },
203 PathResolveFailed {
204 request: LocationRequestId,
205 path: PathBuf,
206 error: FsError,
207 },
208}
209
210#[cfg(test)]
211mod tests {
212 use super::*;
213
214 #[test]
215 fn dirs_outrank_files_and_symlinks() {
216 assert!(EntryKind::Dir.sort_rank() < EntryKind::File.sort_rank());
217 assert_eq!(EntryKind::File.sort_rank(), EntryKind::Symlink.sort_rank());
218 }
219
220 #[test]
221 fn error_carries_the_offending_path() {
222 let e = FsError::PermissionDenied(PathBuf::from("/root/secret"));
223 assert_eq!(e.path(), Path::new("/root/secret"));
224 assert!(e.to_string().contains("permission denied"));
225 }
226}