Skip to main content

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}