konoma 0.28.5

Terminal file browser built for AI pair-programming — full-screen previews (Markdown, images, PDF, CSV), a git suite (jj/Jujutsu in preview), and an agent-watch mode that follows your AI's edits (macOS and Linux)
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
//! Shared test-only utilities used across `src/**` test modules (`#[cfg(test)]` only — never
//! compiled into the shipped binary).
//!
//! This module exists to de-duplicate a helper that had drifted into six byte-identical (or
//! near-identical) copies across `git.rs`, `fileops.rs`, `app/tests.rs`, `mem_tests.rs`,
//! `main.rs` (as `watch_test_unique_tmp`), and `e2e_tests.rs` (inlined into `sandbox()`).

use std::cell::{Cell, RefCell};
use std::path::PathBuf;

thread_local! {
    /// How many stat syscalls the instrumented directory-walk helpers (`app::stat_follow`) issued
    /// on **this thread**.
    ///
    /// Thread-local on purpose. A process-wide `AtomicUsize` would also count the calls made by
    /// every other test running in parallel, so any assertion on an exact count would pass alone
    /// and fail in a full run — the exact flake `git::STATUS_CALLS` produced before it was
    /// switched to a per-run measurement. Each test runs on its own thread, so a thread-local
    /// counter measures exactly the walk under test. Const-init so touching it never allocates.
    static STAT_CALLS: Cell<usize> = const { Cell::new(0) };
}

/// Called by the walk helpers right before they issue a stat. Cheap enough to be unconditional in
/// test builds; compiled out entirely otherwise (the call sites are `#[cfg(test)]`).
pub(crate) fn note_stat_call() {
    // `try_with` tolerates thread teardown, matching `mem_tests`' allocator counter.
    let _ = STAT_CALLS.try_with(|c| c.set(c.get() + 1));
}

/// Runs `f` and returns `(its value, how many stat syscalls the walk helpers made while it ran)`.
pub(crate) fn count_stat_calls<T>(f: impl FnOnce() -> T) -> (T, usize) {
    let before = STAT_CALLS.with(|c| c.get());
    let out = f();
    (out, STAT_CALLS.with(|c| c.get()).saturating_sub(before))
}

/// A unique temp directory *path* per call (pid + a process-global counter). Tests that build a
/// fixture under `std::env::temp_dir()` must never use a fixed name: two `cargo test` binaries
/// (git-feature and no-git-feature builds, or two concurrent CI/dev runs) sharing one machine's
/// temp directory will otherwise race on `create_dir_all` / `remove_dir_all` / file writes on the
/// identical path — one process's fixture reset can delete or overwrite another process's
/// still-running test. The pid disambiguates across processes; the counter disambiguates
/// multiple calls with the same prefix within one process (parallel test threads, or a helper
/// called more than once in the same test).
///
/// Returns a path only — callers are responsible for `create_dir_all` / `File::create` / etc. as
/// appropriate for their fixture.
pub(crate) fn unique_tmp(prefix: &str) -> PathBuf {
    use std::sync::atomic::{AtomicU64, Ordering};
    static N: AtomicU64 = AtomicU64::new(0);
    let n = N.fetch_add(1, Ordering::Relaxed);
    std::env::temp_dir().join(format!("{prefix}_{}_{n}", std::process::id()))
}

thread_local! {
    /// Test-only stand-in for the OS clipboard (see `app::set_clipboard` / `paste_jump::read_clipboard`).
    ///
    /// Every copy key (`y c`, `Y`, table copy, git-log copy, ...) used to call `arboard` for real in
    /// `cargo test`, which overwrote whatever the *developer* had on their real system clipboard —
    /// a reported, reproducible bug (running the suite could clobber an in-progress paste). Routing
    /// both the write side and the read side (`P`/paste-jump) through this sink instead of arboard
    /// makes `cargo test` clipboard-independent by construction: the arboard-calling code isn't even
    /// compiled into a `#[cfg(test)]` build (see the `#[cfg(not(test))]` bodies of those functions).
    ///
    /// Thread-local, not a process-wide static, for the same reason as `STAT_CALLS` above: `cargo
    /// test`'s default runner reuses a fixed pool of OS threads across many tests, but never runs two
    /// tests *concurrently* on the same thread, so a thread-local sink can never let one test's copy
    /// leak into another test running at the same time. It can still leak into a *later* test that
    /// happens to land on the same worker thread and forgets to set its own value first — every test
    /// that asserts an "empty clipboard" state must call `clear_test_clipboard()` before reading.
    static CLIPBOARD: RefCell<Option<String>> = const { RefCell::new(None) };
}

/// Test-only stand-in for the clipboard's write side. `set_clipboard` writes here instead of arboard
/// when built for tests.
pub(crate) fn set_test_clipboard(text: &str) {
    CLIPBOARD.with(|c| *c.borrow_mut() = Some(text.to_string()));
}

/// Test-only stand-in for the clipboard's read side (`P` / paste-jump). `read_clipboard` reads from
/// here instead of arboard when built for tests.
pub(crate) fn get_test_clipboard() -> Option<String> {
    CLIPBOARD.with(|c| c.borrow().clone())
}

/// Reset the test clipboard sink to "empty" (mirrors an environment where the real clipboard is
/// unavailable). Tests that assert the "no clipboard" flash must call this first — see the
/// thread-reuse caveat on `CLIPBOARD` above.
pub(crate) fn clear_test_clipboard() {
    CLIPBOARD.with(|c| *c.borrow_mut() = None);
}

thread_local! {
    /// Test-only override for `app::cache_root()` (see that function's `#[cfg(test)]` body).
    ///
    /// Without this, `cargo test` computed the remote-image download cache root exactly the way
    /// the shipped binary does — `$XDG_CACHE_HOME`/`$HOME/.cache`, no test-only branch at all — so
    /// any test that reached `app::ensure_remote_md_fetch`'s background-download path (a Markdown
    /// document with an unresolved `http(s)://` image, previewed with the remote loader attached)
    /// created real directories under the developer's actual `~/.cache/konoma/remote-images/` on
    /// every run — confirmed by inspecting that directory's mtime around such a test. Same shape of
    /// bug as the clipboard one above, same fix: give production code a seam it reads from instead
    /// of the environment, and make test builds not compile the environment-reading path in at the
    /// one place that actually writes to disk.
    ///
    /// `cache_root()` itself keeps reading the real environment when no override is set (unlike
    /// `CLIPBOARD`, which always redirects in test builds) — `cache_root()` and
    /// `md_remote_cache_path()` are pure path arithmetic and never touch the filesystem on their
    /// own, so the many tests that call them read-only (to get a stable cache key, or to check the
    /// real-environment fallback formula itself) stay exercising the real formula. The actual
    /// filesystem write only happens inside `ensure_remote_md_fetch`'s spawned download thread, and
    /// that function asserts an override is set before ever spawning one — see its `#[cfg(test)]`
    /// guard — so a future test that reaches that spawn without opting in fails loudly instead of
    /// silently writing to the real cache.
    ///
    /// Thread-local, not a process-wide static, for the same reason as `CLIPBOARD`: `cargo test`'s
    /// default runner never runs two tests concurrently on the same thread, but does reuse threads
    /// across tests, so a test that sets this must not assume a clean slate — only the guard in
    /// `ensure_remote_md_fetch` cares whether *some* override is set, not which test set it, so a
    /// stale value from an earlier test on the same thread is harmless (it still points at a
    /// sandboxed directory, never the real cache root).
    static CACHE_ROOT: RefCell<Option<PathBuf>> = const { RefCell::new(None) };
}

/// Test-only override for `app::cache_root()`. Call with a sandboxed directory (e.g.
/// `unique_tmp(...)`) before triggering any code path that downloads a remote Markdown image.
pub(crate) fn set_test_cache_root(path: PathBuf) {
    CACHE_ROOT.with(|c| *c.borrow_mut() = Some(path));
}

/// Read side of the `cache_root()` override; `None` means no test on this thread has set one yet.
pub(crate) fn get_test_cache_root() -> Option<PathBuf> {
    CACHE_ROOT.with(|c| c.borrow().clone())
}

thread_local! {
    /// Test-only record of every path `fileops::move_to_trash`'s test-build body has "trashed" (see
    /// that function's `#[cfg(test)]` twin, which never calls the real `trash` crate).
    ///
    /// Before that seam existed, `cargo test` on macOS sent real files to the developer's actual
    /// `~/.Trash` on every run — the same class of bug the `CLIPBOARD` seam above fixed for the
    /// system clipboard, caught by inspecting `~/.Trash`'s entry count before/after a test run. The
    /// test double removes each target from disk directly (so the "gone from the original location"
    /// invariant the existing tests already assert still holds) and records what it removed here,
    /// so a test can additionally assert *that the seam engaged* rather than only that files
    /// happened to disappear (which a bug in the double itself, or an accidental real-trash call,
    /// could equally produce).
    ///
    /// Thread-local, not process-wide, for the same reason as `CLIPBOARD`/`CACHE_ROOT`: never
    /// cleared automatically between tests reusing the same worker thread, so a test that cares
    /// about a clean slate should call `clear_test_trashed()` first, or simply check its own paths
    /// are present in the record rather than asserting its exact contents.
    static TRASHED: RefCell<Vec<PathBuf>> = const { RefCell::new(Vec::new()) };
}

/// Record one path as "trashed" by the test double. Called only from `fileops::move_to_trash`'s
/// `#[cfg(test)]` body.
pub(crate) fn record_trashed(path: PathBuf) {
    TRASHED.with(|t| t.borrow_mut().push(path));
}

/// Every path recorded via `record_trashed` on this thread so far (oldest first).
pub(crate) fn get_trashed() -> Vec<PathBuf> {
    TRASHED.with(|t| t.borrow().clone())
}

/// Reset the trashed-paths record to empty. See the thread-reuse caveat on `TRASHED` above.
pub(crate) fn clear_test_trashed() {
    TRASHED.with(|t| t.borrow_mut().clear());
}

// ---------------------------------------------------------------------------------------------
// Regression guard: nothing outside this module should call `std::env::temp_dir()` to build a
// *fixed*-name path ever again (that's exactly the bug this module was written to fix — see the
// module doc). Scans `src/**/*.rs`'s own text, so it catches a reintroduced fixed name wherever it
// lands, not just in the files that had one at the time this guard was written.
#[cfg(test)]
mod guard {
    use std::path::{Path, PathBuf};

    /// Files this guard does not scan at all:
    ///   - `test_support.rs` itself: the one legitimate `std::env::temp_dir()` call is
    ///     `unique_tmp`'s own implementation above.
    ///   - Three **production** (non-test) one-shot temp-file paths that are already unique on
    ///     their own terms (pid + an atomic/monotonic counter baked into the filename itself —
    ///     `konoma-pdf-…` / `konoma-cmd-…` / `konoma-vthumb-…`), just not routed through this
    ///     `#[cfg(test)]`-only helper (they run in the shipped binary, where `test_support` does
    ///     not exist). Editing these to use `unique_tmp` is explicitly out of scope for this
    ///     guard — it must never ask for it.
    const EXEMPT_FILES: &[&str] = &[
        "test_support.rs",
        "preview/pdf.rs",
        "preview/command.rs",
        "preview/video.rs",
    ];

    /// Recursively collect every `.rs` file under `dir` (plain `std::fs`, no extra dependency —
    /// mirrors the project's other self-scanning meta tests, e.g.
    /// `e2e_tests::extract_ui_config_field_names`, which read their own source text directly
    /// rather than parse with a crate).
    fn collect_rs_files(dir: &Path, out: &mut Vec<PathBuf>) {
        let Ok(rd) = std::fs::read_dir(dir) else {
            return;
        };
        for entry in rd.flatten() {
            let path = entry.path();
            if path.is_dir() {
                collect_rs_files(&path, out);
            } else if path.extension().and_then(|e| e.to_str()) == Some("rs") {
                out.push(path);
            }
        }
    }

    /// Turning a bare *reference* to a temp path into a *specific, collidable* one: chaining
    /// `.join(`/`.push(` onto it (appends a fixed sub-path — the original form this guard checked),
    /// or handing it straight to a call that actually touches the filesystem at that exact name (no
    /// `.join` needed for a literal fixed name like `"/tmp/konoma_fixed"` to collide).
    const PATH_BUILDERS: [&str; 2] = [".join(", ".push("];
    const FS_MUTATORS: [&str; 8] = [
        "create_dir_all(",
        "create_dir(",
        "remove_dir_all(",
        "remove_file(",
        "File::create(",
        "fs::write(",
        "OpenOptions::new(",
        "set_current_dir(",
    ];

    fn contains_any(line: &str, needles: &[&str]) -> bool {
        needles.iter().any(|n| line.contains(n))
    }

    fn is_ident_byte(b: u8) -> bool {
        b.is_ascii_alphanumeric() || b == b'_'
    }

    /// `word` occurs in `line` as a whole identifier — not merely as a substring of a longer one
    /// (so an identifier `d` doesn't match inside `id` or `mkdir`).
    fn contains_word(line: &str, word: &str) -> bool {
        if word.is_empty() {
            return false;
        }
        let bytes = line.as_bytes();
        let mut start = 0;
        while let Some(rel) = line[start..].find(word) {
            let at = start + rel;
            let before_ok = at == 0 || !is_ident_byte(bytes[at - 1]);
            let after = at + word.len();
            let after_ok = after >= bytes.len() || !is_ident_byte(bytes[after]);
            if before_ok && after_ok {
                return true;
            }
            start = at + 1;
        }
        false
    }

    /// A line "targets a specific path" (as opposed to a bare reference — e.g. a `Command`'s
    /// `current_dir` when the actual target path is built and uniqued elsewhere, as
    /// `git.rs`/`e2e_tests.rs` do for a `git clone --bare`'s cwd) if it chains a path-builder or a
    /// filesystem mutator onto whatever risky base is on it.
    fn line_targets_a_path(line: &str) -> bool {
        contains_any(line, &PATH_BUILDERS) || contains_any(line, &FS_MUTATORS)
    }

    /// Given an identifier bound to a risky base a few lines up, does `line` use it to build or
    /// touch a specific path — either chaining `.join(`/`.push(` straight onto it, or passing it (as
    /// a whole word) to a filesystem-mutating call? Covers the case split across two statements:
    /// `let base = std::env::temp_dir(); ... base.join("fixed")` / `... base.push("fixed")` / `...
    /// std::fs::create_dir_all(&base)`.
    fn line_uses_ident_as_path(line: &str, ident: &str) -> bool {
        PATH_BUILDERS
            .iter()
            .any(|m| line.contains(&format!("{ident}{m}")))
            || (contains_any(line, &FS_MUTATORS) && contains_word(line, ident))
    }

    /// If `line` is a `let [mut] IDENT = ...` binding, return `IDENT`. Best-effort: an unrecognised
    /// binding form just means the walk below doesn't try to trace that identifier forward, which
    /// degrades to "unproven bare reference" (a miss), never to a false accusation.
    fn let_binding_ident(line: &str) -> Option<&str> {
        let after_let = line.find("let ")?;
        let mut rest = line[after_let + 4..].trim_start();
        rest = rest.strip_prefix("mut ").unwrap_or(rest).trim_start();
        let end = rest
            .find(|c: char| !(c.is_alphanumeric() || c == '_'))
            .unwrap_or(rest.len());
        if end == 0 {
            None
        } else {
            Some(&rest[..end])
        }
    }

    /// Best-effort function-boundary heuristic: bounds how far a bound identifier is traced forward,
    /// so a same-named variable in the *next*, unrelated test isn't blamed for this one's binding.
    fn is_fn_start(line: &str) -> bool {
        let mut t = line.trim_start();
        for prefix in ["pub(crate) ", "pub ", "async ", "unsafe "] {
            if let Some(s) = t.strip_prefix(prefix) {
                t = s;
            }
        }
        t.starts_with("fn ")
    }

    /// How far past a `let` binding of a risky base the scan traces the bound identifier, capped
    /// (whichever comes first) by hitting the next function signature.
    const IDENT_TRACE_WINDOW: usize = 200;

    /// Proof of per-call uniqueness for a production temp-file path: `std::process::id()`
    /// somewhere in the call (the same signal `unique_tmp` itself relies on, plus a counter). The
    /// scan allows this a few-line window after the risky-base line, not just the same line,
    /// since `format!(...)` args are commonly wrapped onto their own lines.
    const UNIQUENESS_PROOF: &str = "process::id()";
    const PROOF_WINDOW: usize = 6;

    fn has_nearby_proof(lines: &[&str], at: usize) -> bool {
        let end = (at + PROOF_WINDOW).min(lines.len());
        lines[at..end].iter().any(|l| l.contains(UNIQUENESS_PROOF))
    }

    /// Everything this guard treats as naming a filesystem temp directory without per-call
    /// uniqueness baked in at the call site: `temp_dir()` / `env::temp_dir()`, the `TMPDIR`
    /// environment variable, or a literal `"/tmp/...` path.
    fn line_has_risky_base(line: &str) -> bool {
        line.contains("temp_dir()") || line.contains("TMPDIR") || line.contains("\"/tmp/")
    }

    /// Scan result: every risky-base occurrence outside `EXEMPT_FILES` that is used to build or
    /// touch a specific path — either right there on the same line, or a few lines later via a
    /// `let`-bound identifier — without a nearby uniqueness proof.
    ///
    /// Deliberately not a full parser (a plain text scan, like the project's other self-scanning
    /// meta tests): it can miss a disguised violation, but it must never *falsely* accuse existing
    /// source, which is why every heuristic here (word-boundary identifier matching, an explicit
    /// path-builder/mutator vocabulary, a function-boundary trace cutoff) errs toward under- rather
    /// than over-detection.
    fn find_offenders(src_dir: &Path) -> (usize, Vec<String>) {
        let mut rs_files = Vec::new();
        collect_rs_files(src_dir, &mut rs_files);
        let mut files_scanned = 0usize;
        let mut offenders = Vec::new();
        for path in &rs_files {
            let rel = path
                .strip_prefix(src_dir)
                .unwrap()
                .to_string_lossy()
                .replace('\\', "/");
            if EXEMPT_FILES.iter().any(|f| rel == *f) {
                continue;
            }
            files_scanned += 1;
            let Ok(text) = std::fs::read_to_string(path) else {
                continue;
            };
            let lines: Vec<&str> = text.lines().collect();
            for (i, line) in lines.iter().enumerate() {
                if !line_has_risky_base(line) {
                    continue;
                }
                // Form 1: used to build/touch a path right here on this line (the original check,
                // now also covering a literal "/tmp/..." or TMPDIR combined with a mutator).
                if line_targets_a_path(line) {
                    if !has_nearby_proof(&lines, i) {
                        offenders.push(format!("{rel}:{}: {}", i + 1, line.trim()));
                    }
                    continue; // already flagged this line; no need to also trace it as a binding
                }
                // Form 2: bound to a variable, used a few lines later — the risky base and its use
                // split across statements (`let base = temp_dir(); ... base.join(...)`, or the same
                // shape for a literal `"/tmp/..."` path).
                let Some(ident) = let_binding_ident(line) else {
                    continue;
                };
                let mut trace_end = (i + 1 + IDENT_TRACE_WINDOW).min(lines.len());
                for (j, l) in lines.iter().enumerate().take(trace_end).skip(i + 1) {
                    if is_fn_start(l) {
                        trace_end = j;
                        break;
                    }
                }
                for (j, l) in lines.iter().enumerate().take(trace_end).skip(i + 1) {
                    if line_uses_ident_as_path(l, ident) && !has_nearby_proof(&lines, j) {
                        offenders.push(format!(
                            "{rel}:{}: {} (bound at {}: {})",
                            j + 1,
                            l.trim(),
                            i + 1,
                            line.trim()
                        ));
                        break; // one report per binding is enough
                    }
                }
            }
        }
        (files_scanned, offenders)
    }

    /// Safety valve for the scan itself: if the directory walk broke (wrong path, `src/` moved),
    /// it must fail LOUD by finding too few files — not silently scan zero and vacuously pass.
    /// `src/` currently has 60+ `.rs` files (minus the 4 exempt ones); 20 is a conservative floor.
    #[test]
    fn scan_finds_at_least_20_source_files() {
        let src_dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
        let (files_scanned, _) = find_offenders(&src_dir);
        assert!(
            files_scanned >= 20,
            "スキャン対象が少なすぎる(安全弁): {files_scanned} 件 — src/ の探索が壊れている可能性"
        );
    }

    /// The guard itself: every `temp_dir()` call that targets a specific path, outside the exempt
    /// files, must prove its own uniqueness (or — the normal case — go through `unique_tmp`
    /// instead, which doesn't call `temp_dir()` by name at the call site at all).
    #[test]
    fn no_fixed_name_temp_dirs_outside_unique_tmp() {
        let src_dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
        let (files_scanned, offenders) = find_offenders(&src_dir);
        assert!(
            files_scanned >= 20,
            "スキャン対象が少なすぎる(安全弁): {files_scanned} 件"
        );
        assert!(
            offenders.is_empty(),
            "共有ヘルパー unique_tmp を経由しない固定名 temp_dir() 呼び出しを検出\n\
             (固定名は並行実行中の2プロセスが衝突する — このモジュールの docコメント参照):\n{}",
            offenders.join("\n")
        );
    }
}