termesh-filesystem 0.1.3

Internal component of a terminal-native, agent-first IDE.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
//! The filesystem worker thread (ADR-0005 §1).
//!
//! Owns a [`FileSystemService`] and does every blocking call on its own thread, emitting
//! [`FsEvent`]s back into the state loop. This is the half of the concurrency model that
//! makes the synchronous trait safe: the methods block, but never on the render loop.
//!
//! The worker is deliberately dumb — it reads what it is told to read and reports what it
//! found. All the decisions (what to expand, how to reconcile a re-read) live in
//! [`crate::tree::FileTree`], where they are pure and unit-testable.

use std::sync::mpsc::{self, Sender};
use std::sync::Arc;
use std::thread::JoinHandle;
use std::time::Duration;

use termesh_core::{FsEvent, FsRequest};

use crate::ignore_rules::{IgnoreOptions, IgnoreRules};
use crate::reader::DirReader;
use crate::service::FileSystemService;
use crate::watch::{is_relevant, RelevanceFilter, RootWatcher, DEFAULT_WINDOW};

/// Turn a mutation outcome into the event the state loop expects.
fn mutation_result(outcome: crate::service::FsResult<()>, path: &std::path::Path) -> FsEvent {
    match outcome {
        Ok(()) => FsEvent::Changed(vec![path.to_path_buf()]),
        Err(e) => FsEvent::MutationFailed(e),
    }
}

/// Handle to a running worker thread. Dropping it shuts the thread down and joins it.
pub struct FsWorker {
    tx: Sender<FsRequest>,
    handle: Option<JoinHandle<()>>,
}

impl FsWorker {
    /// Start a worker over `fs`, delivering results through `sink`.
    ///
    /// `sink` is a callback rather than a channel so the caller decides how events are
    /// wrapped — `app` turns them into `AppMessage::Fs`, tests collect them directly.
    pub fn spawn<S, F>(fs: S, options: IgnoreOptions, sink: F) -> Self
    where
        S: FileSystemService + 'static,
        F: Fn(FsEvent) + Send + Sync + 'static,
    {
        Self::spawn_with_window(fs, options, DEFAULT_WINDOW, sink)
    }

    /// As [`Self::spawn`], with an explicit debounce window. Tests use a short one.
    pub fn spawn_with_window<S, F>(
        fs: S,
        options: IgnoreOptions,
        watch_window: Duration,
        sink: F,
    ) -> Self
    where
        S: FileSystemService + 'static,
        F: Fn(FsEvent) + Send + Sync + 'static,
    {
        let sink = Arc::new(sink);
        let (tx, rx) = mpsc::channel::<FsRequest>();
        let handle = std::thread::Builder::new()
            .name("termesh-fs".into())
            .spawn(move || {
                // Both are built on the first Watch, which is also when we learn the
                // root the ignore chain has to be anchored to.
                let mut reader: Option<DirReader> = None;
                let mut watcher: Option<RootWatcher> = None;

                while let Ok(req) = rx.recv() {
                    match req {
                        FsRequest::ReadDir { id, path } => {
                            let result = match reader.as_mut() {
                                Some(r) => r.read(&path),
                                // Unreachable in practice: `Model::open_workspace`
                                // queues `Watch` before any `ReadDir`, and that ordering
                                // is enforced by a test. Degrade to an unfiltered read
                                // rather than stalling if that ever stops holding.
                                None => DirReader::unfiltered(&fs).read(&path),
                            };
                            sink(match result {
                                Ok(entries) => FsEvent::DirLoaded { id, entries },
                                Err(error) => FsEvent::DirFailed { id, error },
                            });
                        }
                        // Opening and saving are blocking I/O like any other, so they
                        // run here rather than on the render loop. Both report against a
                        // BufferId, since by the time the answer arrives the user may
                        // have opened something else.
                        FsRequest::ReadFile { buffer, path } => {
                            sink(match fs.read_file(&path) {
                                Ok(contents) => FsEvent::FileLoaded { buffer, path, contents },
                                Err(error) => FsEvent::FileFailed { buffer, error },
                            });
                        }
                        FsRequest::ReadPreview { request, path, line, context } => {
                            sink(match preview(&fs, &path, line, context.min(10)) {
                                Ok((start_line, text)) => {
                                    FsEvent::PreviewLoaded { request, path, start_line, text }
                                }
                                Err(error) => FsEvent::PreviewFailed { request, path, error },
                            });
                        }
                        FsRequest::ResolvePath { request, path } => {
                            sink(match fs.canonicalize(&path) {
                                Ok(path) => FsEvent::PathResolved { request, path },
                                Err(error) => FsEvent::PathResolveFailed { request, path, error },
                            });
                        }
                        FsRequest::WriteFile { buffer, path, contents, version } => {
                            sink(match fs.write_file(&path, &contents) {
                                Ok(()) => FsEvent::FileSaved { buffer, version },
                                Err(error) => FsEvent::FileFailed { buffer, error },
                            });
                        }
                        FsRequest::Watch(root) => {
                            let r = DirReader::new(&fs, &root, options);

                            // The watch thread needs an owned, Send predicate, and
                            // `IgnoreRules` is neither — so snapshot the decision as a
                            // closure over a fresh rules set anchored at the same root.
                            let rules = IgnoreRules::for_root(&fs, &root, options);
                            let filter = RelevanceFilter::new(move |p| is_relevant(p, &rules));

                            // Stop any previous watch before starting the new one: two
                            // recursive watchers over overlapping roots would report the
                            // same change twice. Dropping joins the old debounce thread.
                            drop(watcher.take());

                            let sink_for_watch = sink.clone();
                            // A root we cannot watch is degraded, not broken: the tree
                            // still works, it just will not update by itself.
                            watcher =
                                RootWatcher::start(&root, watch_window, filter, move |paths| {
                                    sink_for_watch(FsEvent::Changed(paths))
                                });
                            reader = Some(r);
                        }
                        // Mutations report success as `Changed`, not as a bespoke "done"
                        // event. That way exactly one code path brings disk state back
                        // into the tree, whether the change came from us or from an
                        // external editor — and it works with no watcher running.
                        FsRequest::CreateFile(path) => {
                            sink(mutation_result(fs.create_file(&path), &path));
                        }
                        FsRequest::CreateDir(path) => {
                            sink(mutation_result(fs.create_dir(&path), &path));
                        }
                        FsRequest::Rename { from, to } => {
                            // Report both ends: the source directory loses an entry and
                            // the destination gains one, and they may differ.
                            sink(match fs.rename(&from, &to) {
                                Ok(()) => FsEvent::Changed(vec![from, to]),
                                Err(e) => FsEvent::MutationFailed(e),
                            });
                        }
                        FsRequest::Remove { path, recursive } => {
                            let outcome = if recursive {
                                fs.remove_dir_all(&path)
                            } else {
                                fs.remove_file(&path)
                            };
                            sink(mutation_result(outcome, &path));
                        }
                        FsRequest::Shutdown => break,
                    }
                }
            })
            .expect("spawning the filesystem worker thread");

        Self { tx, handle: Some(handle) }
    }

    /// Queue work. Returns `false` if the worker has already stopped — callers treat a
    /// dead worker as "no result will arrive", never as a reason to block or panic.
    pub fn request(&self, req: FsRequest) -> bool {
        self.tx.send(req).is_ok()
    }
}

fn preview(
    fs: &dyn FileSystemService,
    path: &std::path::Path,
    line: usize,
    context: usize,
) -> crate::service::FsResult<(usize, String)> {
    let bytes = fs.read_file(path)?;
    let contents = String::from_utf8(bytes).map_err(|_| crate::service::FsError::Other {
        path: path.to_path_buf(),
        message: "not a UTF-8 text file".into(),
    })?;
    let lines: Vec<&str> = contents.split_inclusive('\n').collect();
    let target = line.saturating_sub(1).min(lines.len().saturating_sub(1));
    let start = target.saturating_sub(context);
    let end = (target + context + 1).min(lines.len());
    Ok((start + 1, lines[start..end].concat()))
}

impl Drop for FsWorker {
    fn drop(&mut self) {
        // Ask politely, then wait: the thread may be mid-`read_dir` on a slow disk and
        // we would rather join it than leave it writing into a dropped sink.
        let _ = self.tx.send(FsRequest::Shutdown);
        if let Some(h) = self.handle.take() {
            let _ = h.join();
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::path::{Path, PathBuf};
    use std::sync::mpsc::Receiver;
    use std::time::Duration;

    use termesh_core::{
        DirEntryInfo, EntryKind, FsError, FsResult, LocationRequestId, NodeId, PreviewRequestId,
    };

    /// A minimal in-crate fake. `test-support`'s richer one cannot be used here: it
    /// depends on this crate, so using it would be a dependency cycle.
    struct StubFs(Vec<DirEntryInfo>, Vec<u8>);

    impl FileSystemService for StubFs {
        fn read_dir(&self, path: &Path) -> FsResult<Vec<DirEntryInfo>> {
            if path == Path::new("/denied") {
                return Err(FsError::PermissionDenied(path.to_path_buf()));
            }
            Ok(self.0.clone())
        }
        fn read_file(&self, _: &Path) -> FsResult<Vec<u8>> {
            Ok(self.1.clone())
        }
        fn create_file(&self, _: &Path) -> FsResult<()> {
            Ok(())
        }
        fn write_file(&self, _: &Path, _: &[u8]) -> FsResult<()> {
            Ok(())
        }
        fn create_dir(&self, _: &Path) -> FsResult<()> {
            Ok(())
        }
        fn rename(&self, _: &Path, _: &Path) -> FsResult<()> {
            Ok(())
        }
        fn remove_file(&self, _: &Path) -> FsResult<()> {
            Ok(())
        }
        fn remove_dir_all(&self, _: &Path) -> FsResult<()> {
            Ok(())
        }
        fn canonicalize(&self, p: &Path) -> FsResult<PathBuf> {
            if p == Path::new("/missing") {
                Err(FsError::NotFound(p.to_path_buf()))
            } else {
                Ok(p.to_path_buf())
            }
        }
    }

    fn stub() -> StubFs {
        StubFs(
            vec![DirEntryInfo {
                name: "main.rs".into(),
                path: PathBuf::from("/src/main.rs"),
                kind: EntryKind::File,
            }],
            Vec::new(),
        )
    }

    fn worker_with_channel() -> (FsWorker, Receiver<FsEvent>) {
        let (tx, rx) = mpsc::channel();
        let worker = FsWorker::spawn(stub(), IgnoreOptions::show_all(), move |e| {
            let _ = tx.send(e);
        });
        (worker, rx)
    }

    fn recv(rx: &Receiver<FsEvent>) -> FsEvent {
        rx.recv_timeout(Duration::from_secs(5)).expect("worker should answer")
    }

    #[test]
    fn a_read_request_comes_back_as_dir_loaded() {
        let (worker, rx) = worker_with_channel();
        assert!(
            worker.request(FsRequest::ReadDir { id: NodeId::new(7), path: PathBuf::from("/src") })
        );

        match recv(&rx) {
            FsEvent::DirLoaded { id, entries } => {
                assert_eq!(id, NodeId::new(7), "the answer names the node that asked");
                assert_eq!(entries.len(), 1);
            }
            other => panic!("unexpected event: {other:?}"),
        }
    }

    #[test]
    fn preview_returns_only_the_requested_window() {
        let (tx, rx) = mpsc::channel();
        let worker = FsWorker::spawn(
            StubFs(Vec::new(), b"one\ntwo\nneedle\nfour\nfive\n".to_vec()),
            IgnoreOptions::show_all(),
            move |event| {
                let _ = tx.send(event);
            },
        );
        worker.request(FsRequest::ReadPreview {
            request: PreviewRequestId::new(3),
            path: PathBuf::from("/p/src/lib.rs"),
            line: 3,
            context: 1,
        });
        assert!(matches!(
            recv(&rx),
            FsEvent::PreviewLoaded {
                request,
                start_line: 2,
                text,
                ..
            } if request == PreviewRequestId::new(3) && text == "two\nneedle\nfour\n"
        ));
    }

    #[test]
    fn path_resolution_preserves_the_request_id_on_success_and_failure() {
        let (worker, rx) = worker_with_channel();
        worker.request(FsRequest::ResolvePath {
            request: LocationRequestId::new(4),
            path: PathBuf::from("/src/main.rs"),
        });
        assert!(matches!(
            recv(&rx),
            FsEvent::PathResolved { request, path }
                if request == LocationRequestId::new(4) && path == Path::new("/src/main.rs")
        ));
        worker.request(FsRequest::ResolvePath {
            request: LocationRequestId::new(5),
            path: PathBuf::from("/missing"),
        });
        assert!(matches!(
            recv(&rx),
            FsEvent::PathResolveFailed { request, .. }
                if request == LocationRequestId::new(5)
        ));
    }

    #[test]
    fn a_failed_read_comes_back_as_dir_failed_and_the_worker_survives() {
        let (worker, rx) = worker_with_channel();
        worker.request(FsRequest::ReadDir { id: NodeId::new(1), path: PathBuf::from("/denied") });
        assert!(matches!(recv(&rx), FsEvent::DirFailed { .. }));

        // The worker must keep serving after an error rather than tearing down.
        worker.request(FsRequest::ReadDir { id: NodeId::new(2), path: PathBuf::from("/src") });
        assert!(matches!(recv(&rx), FsEvent::DirLoaded { .. }));
    }

    #[test]
    fn requests_are_answered_in_order() {
        let (worker, rx) = worker_with_channel();
        for i in 0..5 {
            worker.request(FsRequest::ReadDir { id: NodeId::new(i), path: PathBuf::from("/src") });
        }
        for i in 0..5 {
            match recv(&rx) {
                FsEvent::DirLoaded { id, .. } => assert_eq!(id, NodeId::new(i)),
                other => panic!("unexpected event: {other:?}"),
            }
        }
    }

    #[test]
    fn dropping_the_worker_stops_the_thread() {
        let (worker, rx) = worker_with_channel();
        worker.request(FsRequest::ReadDir { id: NodeId::new(0), path: PathBuf::from("/src") });
        let _ = recv(&rx);

        drop(worker); // joins the thread; the sink is dropped with it
        assert!(rx.recv().is_err(), "no further events once the worker is gone");
    }

    /// The interactive path in `app::run` sends `Watch` and then `ReadDir`, and the very
    /// first listing is the one the user sees on launch. This proves ignore rules are
    /// already live for it — the arrangement the app's ordering test guards from the
    /// other side.
    #[test]
    fn the_first_listing_after_watch_is_already_ignore_filtered() {
        use crate::real::RealFileSystem;

        let stamp =
            std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
        let root = std::env::temp_dir().join(format!("termesh-firstread-{stamp}"));
        std::fs::create_dir_all(root.join("target")).unwrap();
        std::fs::create_dir_all(root.join("src")).unwrap();
        std::fs::write(root.join(".gitignore"), b"target\n").unwrap();

        let (tx, rx) = mpsc::channel();
        let worker = FsWorker::spawn(RealFileSystem::new(), IgnoreOptions::default(), move |e| {
            let _ = tx.send(e);
        });
        worker.request(FsRequest::Watch(root.clone()));
        worker.request(FsRequest::ReadDir { id: NodeId::new(0), path: root.clone() });

        let names: Vec<String> = loop {
            match rx.recv_timeout(Duration::from_secs(5)).expect("worker should answer") {
                FsEvent::DirLoaded { entries, .. } => {
                    break entries.iter().map(|e| e.name.to_string_lossy().into_owned()).collect()
                }
                _ => continue,
            }
        };

        drop(worker);
        let _ = std::fs::remove_dir_all(&root);

        assert_eq!(names, ["src"], "target/ and .gitignore must be filtered from the first read");
    }

    /// End-to-end over the real filesystem: watching a directory and creating a file in
    /// it must wake the loop with a coalesced `Changed` batch. This is the one test that
    /// proves the OS half works; everything about *policy* is unit-tested in `watch`.
    #[test]
    fn creating_a_file_under_a_watched_root_emits_a_changed_batch() {
        use crate::real::RealFileSystem;

        let stamp =
            std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
        let root = std::env::temp_dir().join(format!("termesh-watch-{stamp}"));
        std::fs::create_dir_all(&root).unwrap();

        let (tx, rx) = mpsc::channel();
        let worker = FsWorker::spawn_with_window(
            RealFileSystem::new(),
            IgnoreOptions::show_all(),
            Duration::from_millis(50),
            move |e| {
                let _ = tx.send(e);
            },
        );
        worker.request(FsRequest::Watch(root.clone()));

        // Sleeping a fixed span here was flaky: under parallel test load the worker
        // thread had not always reached `RootWatcher::start` before we wrote the file,
        // so the event was never generated and the deadline below waited on nothing.
        // Requests are answered in order, so a `ReadDir` reply proves `Watch` is done.
        worker.request(FsRequest::ReadDir { id: NodeId::new(0), path: root.clone() });
        let handshake = std::time::Instant::now() + Duration::from_secs(10);
        while std::time::Instant::now() < handshake {
            if let Ok(FsEvent::DirLoaded { .. }) = rx.recv_timeout(Duration::from_millis(500)) {
                break;
            }
        }

        // Registering the watch and the OS actually delivering for it are still two
        // different moments (FSEvents in particular arms asynchronously), so keep
        // re-touching the file instead of betting the whole test on the first write.
        let created = root.join("created.rs");
        let deadline = std::time::Instant::now() + Duration::from_secs(10);
        let mut saw_change = false;
        while std::time::Instant::now() < deadline && !saw_change {
            std::fs::write(&created, b"fn main() {}").unwrap();
            while let Ok(event) = rx.recv_timeout(Duration::from_millis(250)) {
                if let FsEvent::Changed(paths) = event {
                    if paths.iter().any(|p| p.ends_with("created.rs")) {
                        saw_change = true;
                        break;
                    }
                }
            }
        }

        drop(worker);
        let _ = std::fs::remove_dir_all(&root);
        assert!(saw_change, "a new file under the watched root should reach the loop");
    }

    #[test]
    fn requesting_after_shutdown_reports_failure_instead_of_panicking() {
        let (worker, _rx) = worker_with_channel();
        worker.request(FsRequest::Shutdown);
        // The thread has stopped; the channel may still accept one buffered send, so we
        // only assert the call returns rather than unwinding.
        let _ =
            worker.request(FsRequest::ReadDir { id: NodeId::new(0), path: PathBuf::from("/src") });
    }
}