Skip to main content

cargoless_core/
model.rs

1//! File-level green/red model + event bus, with #21 verdict provenance.
2//!
3//! The daemon's single source of truth for "what works". It folds the
4//! per-file diagnostics from [`crate::lsp`] into the `cargoless_proto` contract:
5//! level-triggered [`StateEvent::FileVerdict`] and edge-triggered
6//! [`StateEvent::BecameGreen`] / [`StateEvent::BecameRed`].
7//!
8//! ## #21 — cargo-check is the verdict AUTHORITY (load-bearing for v0)
9//!
10//! S1 proved RA-native diagnostics are BLIND to the type/trait/method/macro
11//! error class — only `cargo check` (RA's *flycheck*) produces it. A checker
12//! that called such code GREEN off RA-native would violate the product's one
13//! promise. So GREEN is gated strictly on a completed flycheck pass:
14//!
15//! * GREEN ⟺ at least one flycheck pass has COMPLETED and that pass left
16//!   NO `severity == Error` diagnostic from ANY source.
17//!   Pre-first-flycheck the tree is RED (never claim unproven green —
18//!   the project-long invariant).
19//!
20//! ## #8-redo — severity:Error from ANY source drives RED (FIELD FINDING #55)
21//!
22//! The original #21 rule restricted RED to `source == "rustc"` errors and
23//! treated RA-native errors as advisory-only. dogfood-lead's `let bad =`
24//! reproducer broke this: an RA-native severity:Error on a parse failure
25//! never reaches cargo-check (cargo errors before producing JSON
26//! diagnostics for the broken file in some cases), so the rustc-only rule
27//! reported GREEN on a tree that cargo check called RED. The fix tightens
28//! the per-file rule to **any severity:Error from any source** while
29//! leaving GREEN gating on flycheck-completion unchanged. The asymmetry
30//! is honest: RA's "saw an error" is strictly stronger evidence than
31//! "didn't see one" because RA's analysis is partial — so RA-native
32//! severity:Error can drive RED (the bug evidence is real), but only
33//! flycheck-completion + zero errors can drive GREEN (absence of
34//! evidence must be backed by cargo's fuller analysis).
35//!
36//! [`ModelSession::subscribe_advisory`] / [`Verdict::provenance`] still
37//! exist for the warning/info/hint advisory channel; only the verdict bit
38//! has been broadened.
39//!
40//! ## Frozen-seam discipline
41//!
42//! `StateEvent` and the four `cargoless-proto` seams are byte-frozen. `check_once`,
43//! `watch`, `ModelSession::{subscribe,tree_state,shutdown}` keep their exact
44//! signatures (cli-ux is wired to them). Provenance is ADDITIVE only:
45//! [`Verdict`], [`VerdictProvenance`], [`check_verdict`],
46//! [`ModelSession::last_verdict`], [`ModelSession::subscribe_advisory`].
47//! Publish path is untouched (AC#4 stays a build-CAS concern).
48//!
49//! Pure and std-only: the bus is `std::sync::mpsc`; the verdict rules are
50//! unit-tested by driving [`Model::apply_event`] + draining subscribers.
51
52use std::collections::BTreeMap;
53use std::fs;
54use std::io;
55use std::path::{Path, PathBuf};
56use std::sync::atomic::{AtomicBool, Ordering};
57use std::sync::mpsc::{Receiver, RecvTimeoutError, Sender, channel};
58use std::sync::{Arc, Mutex};
59use std::thread::{self, JoinHandle};
60use std::time::{Duration, Instant};
61
62use cargoless_proto::{
63    BuildIdentity, CheckResult, ContentHash, Diagnostic, FileState, Profile, StateEvent,
64    TargetTriple, TreeState,
65};
66
67use crate::lsp::LspEvent;
68
69/// Hard ceiling for [`check_once`]/[`check_verdict`] (override:
70/// `TF_CHECK_TIMEOUT_SECS`). A cold flycheck can take minutes; this only
71/// bounds pathological hangs.
72const CHECK_HARD_CAP: Duration = Duration::from_secs(180);
73/// Quiet window: once events have arrived and none have for this long without
74/// an authoritative pass, the one-shot check gives up (→ Red/Advisory).
75const CHECK_SETTLE: Duration = Duration::from_secs(2);
76/// Default debounce for the streaming [`watch`] pipeline. The save-burst
77/// quiet window before a [`crate::watcher`] batch is emitted to the model.
78/// Tuned to keep mid-edit reds quiet without making the post-save verdict
79/// feel laggy; user-overridable via `TF_DEBOUNCE_MS` (set by the
80/// `--debounce-ms` CLI flag — FIELD FINDING #5 / #49).
81const DEFAULT_WATCH_DEBOUNCE: Duration = Duration::from_millis(150);
82
83/// Resolve the live debounce duration: `TF_DEBOUNCE_MS` env override iff
84/// parseable as a positive `u64` milliseconds, else [`DEFAULT_WATCH_DEBOUNCE`].
85/// Zero is rejected (would cause the watcher to spin); too-large values are
86/// honored — dogfood tuning may legitimately want a long quiet window for
87/// flicker-free large-refactor runs. Pure (only reads env, no side effects).
88fn resolve_watch_debounce() -> Duration {
89    std::env::var("TF_DEBOUNCE_MS")
90        .ok()
91        .and_then(|s| s.parse::<u64>().ok())
92        .filter(|&ms| ms > 0)
93        .map(Duration::from_millis)
94        .unwrap_or(DEFAULT_WATCH_DEBOUNCE)
95}
96
97// ---------------------------------------------------------------------------
98// #21 additive provenance types (cargoless_core::model, serde-free — NOT cargoless-proto)
99// ---------------------------------------------------------------------------
100
101/// Where a verdict's authority comes from.
102#[derive(Debug, Clone, Copy, PartialEq, Eq)]
103pub enum VerdictProvenance {
104    /// Backed by a completed `cargo check` (flycheck) pass — trustworthy.
105    Authoritative,
106    /// RA-native only / no flycheck pass yet — a fast hint, NEVER a green.
107    Advisory,
108}
109
110/// A reported verdict: the tree state plus how authoritative it is.
111#[derive(Debug, Clone, Copy, PartialEq, Eq)]
112pub struct Verdict {
113    pub tree: TreeState,
114    pub provenance: VerdictProvenance,
115}
116
117/// FIELD FINDING #6-NEG-A (#51): supervisor-lifecycle events surfaced to
118/// the CLI so the watch stream is never silent during an AC#6 transparent
119/// restart. Separate channel from [`StateEvent`] (which is the byte-frozen
120/// cargoless-proto seam — must NOT grow) and [`Verdict`] (which is the #21
121/// authoritative-vs-advisory verdict, not lifecycle). Additive only.
122#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
123pub enum LifecycleEvent {
124    /// AC#6's supervisor just respawned the rust-analyzer child after a
125    /// crash/kill. The next-verdict latency is "however long re-indexing
126    /// takes" — typically tens of seconds on a real project. Emitted ONCE
127    /// per transparent restart; NOT emitted on the initial spawn (the
128    /// bring-up line covers that).
129    AnalyzerRestarting,
130}
131
132// ---------------------------------------------------------------------------
133// Identity seam (unchanged, frozen)
134// ---------------------------------------------------------------------------
135
136/// Supplies the current [`BuildIdentity`] at a green edge. Blanket-impl'd for
137/// any `Fn() -> BuildIdentity`, so callers pass a closure/fn; the real
138/// implementation lives behind the build-cas seam.
139pub trait IdentityProvider: Send {
140    fn current_identity(&self) -> BuildIdentity;
141}
142
143impl<F> IdentityProvider for F
144where
145    F: Fn() -> BuildIdentity + Send,
146{
147    fn current_identity(&self) -> BuildIdentity {
148        self()
149    }
150}
151
152// ---------------------------------------------------------------------------
153// Model
154// ---------------------------------------------------------------------------
155
156/// The green/red state machine + subscriber buses. The authoritative tree
157/// derives strictly from the cargo-check (rustc) tier gated on a completed
158/// flycheck pass; RA-native feeds the advisory channel only.
159pub struct Model {
160    /// Authoritative per-file state from `source:"rustc"` (cargo-check).
161    auth: BTreeMap<String, FileState>,
162    /// Advisory per-file state from RA-native diagnostics.
163    native: BTreeMap<String, FileState>,
164    /// At least one flycheck (`cargo check`) pass has completed.
165    flycheck_done: bool,
166    /// Last emitted authoritative tree state (edge tracking).
167    tree: TreeState,
168    subscribers: Vec<Sender<StateEvent>>,
169    advisory_subscribers: Vec<Sender<Verdict>>,
170    identity: Box<dyn IdentityProvider>,
171    /// FIELD FINDING #2 additive surface: the most recent diagnostic list
172    /// per file, indexed by path-from-URI. Replaced wholesale on every
173    /// `publishDiagnostics` (RA's semantics — a publish supersedes the prior
174    /// list for that file; an empty list clears it). The aggregated view is
175    /// what the CLI prints; this is NOT part of the authoritative verdict
176    /// rule (which still derives from `auth` + `flycheck_done`), just the
177    /// human-facing detail the boolean verdict was hiding.
178    diagnostics: BTreeMap<String, Vec<Diagnostic>>,
179    /// FIELD FINDING #6-NEG-A (#51) additive surface: lifecycle event
180    /// subscribers. Currently the only event is
181    /// [`LifecycleEvent::AnalyzerRestarting`] emitted by the watch
182    /// pipeline's `on_spawn` hook on every transparent RA restart (NOT on
183    /// the initial spawn). Bounded by retain-on-send like the verdict
184    /// channels so a dropped subscriber does not stall the producer.
185    lifecycle_subscribers: Vec<Sender<LifecycleEvent>>,
186    /// #126 Tier-3 bench/dogfood hook: count of `publishDiagnostics`
187    /// folds where RA-native `severity:Error` was *demoted* out of the
188    /// authoritative RED set (proc-macro-off downrank engaged). `0`
189    /// when `TF_RA_PROCMACRO_OFF` is unset (default-off ⇒ F8-redo
190    /// any-source rule, byte-identical). Lets bench-lead correlate the
191    /// RSS delta with the false-RED-suppression actually firing.
192    procmacro_downranked: u64,
193}
194
195/// #126 Tier-3 — the per-file authoritative-RED decision, factored
196/// pure (no env, no `self`) so both modes are unit-tested
197/// deterministically (the #88 discipline: isolate the env read, test
198/// the logic). Returns `(file_state, native_downranked)`.
199///
200/// When `downrank == false` (default-off) this is the F8-redo (#55)
201/// rule: ANY-source `severity:Error` ⇒ `Red` (RA-native parse errors
202/// are real "cannot compile" evidence that beat cargo-check to the
203/// punch). When `downrank == true` (`TF_RA_PROCMACRO_OFF=1`) RA
204/// proc-macro is forced off, so RA-native hallucinates "unresolved"
205/// for every macro-generated item — RED is driven from the
206/// `source:"rustc"` (cargo-check) tier ONLY (`has_authoritative_error`).
207/// cargo-check expands proc-macros itself, RA-independently, so it
208/// stays the complete authority; RA-native is demoted to advisory
209/// (still in `native`/diagnostics, just not verdict-driving).
210/// `native_downranked` is `true` iff there WERE any-source errors but
211/// NO authoritative one — i.e. this fold's RED was suppressed-as-false
212/// (the bench/dogfood signal that the −53 % RAM mode is firing safely).
213fn file_state_for(pd: &crate::lsp::PublishDiagnostics, downrank: bool) -> (FileState, bool) {
214    if downrank {
215        let st = if pd.has_authoritative_error() {
216            FileState::Red
217        } else {
218            FileState::Green
219        };
220        let suppressed = pd.has_any_severity_error() && !pd.has_authoritative_error();
221        (st, suppressed)
222    } else {
223        let st = if pd.has_any_severity_error() {
224            FileState::Red
225        } else {
226            FileState::Green
227        };
228        (st, false)
229    }
230}
231
232impl Model {
233    /// New model: nothing proven ⇒ tree RED, provenance Advisory.
234    pub fn new<I: IdentityProvider + 'static>(identity: I) -> Self {
235        Self {
236            auth: BTreeMap::new(),
237            native: BTreeMap::new(),
238            flycheck_done: false,
239            tree: TreeState::Red,
240            subscribers: Vec::new(),
241            advisory_subscribers: Vec::new(),
242            identity: Box::new(identity),
243            diagnostics: BTreeMap::new(),
244            lifecycle_subscribers: Vec::new(),
245            procmacro_downranked: 0,
246        }
247    }
248
249    /// Subscribe to the AUTHORITATIVE `StateEvent` stream (frozen seam).
250    pub fn subscribe(&mut self) -> Receiver<StateEvent> {
251        let (tx, rx) = channel();
252        self.subscribers.push(tx);
253        rx
254    }
255
256    /// Subscribe to the ADVISORY (provisional, visibly-distinct) verdict
257    /// stream — the RA-native fast hint. Additive (#21).
258    pub fn subscribe_advisory(&mut self) -> Receiver<Verdict> {
259        let (tx, rx) = channel();
260        self.advisory_subscribers.push(tx);
261        rx
262    }
263
264    /// Subscribe to the supervisor-lifecycle stream (FIELD FINDING #6-NEG-A
265    /// / #51). Currently fires [`LifecycleEvent::AnalyzerRestarting`] once
266    /// per transparent RA restart. Additive — distinct from `subscribe()`
267    /// (the frozen StateEvent seam) and `subscribe_advisory()` (the #21
268    /// verdict provenance channel).
269    pub fn subscribe_lifecycle(&mut self) -> Receiver<LifecycleEvent> {
270        let (tx, rx) = channel();
271        self.lifecycle_subscribers.push(tx);
272        rx
273    }
274
275    /// Fan-out a lifecycle event to every live subscriber; prune dropped
276    /// ones. Producer is the watch pipeline's on_spawn hook.
277    pub(crate) fn emit_lifecycle(&mut self, ev: LifecycleEvent) {
278        self.lifecycle_subscribers.retain(|s| s.send(ev).is_ok());
279    }
280
281    /// Current AUTHORITATIVE aggregate verdict (frozen signature).
282    pub fn tree_state(&self) -> TreeState {
283        self.tree
284    }
285
286    /// #122 Tier-4: has ≥1 authoritative `cargo check` (flycheck) pass
287    /// completed? The idle-evict trigger gates on this so the cold
288    /// first authoritative pass is never interrupted by an eviction.
289    pub fn flycheck_done(&self) -> bool {
290        self.flycheck_done
291    }
292
293    /// #126 Tier-3 bench/dogfood hook: how many `publishDiagnostics`
294    /// folds had RA-native `severity:Error` demoted out of the
295    /// authoritative RED set (proc-macro-off downrank suppressing a
296    /// would-be false-RED). `0` while `TF_RA_PROCMACRO_OFF` is unset.
297    pub fn procmacro_downranked(&self) -> u64 {
298        self.procmacro_downranked
299    }
300
301    /// The full reported verdict incl. provenance. Additive (#21).
302    pub fn last_verdict(&self) -> Verdict {
303        Verdict {
304            tree: self.tree,
305            provenance: if self.flycheck_done {
306                VerdictProvenance::Authoritative
307            } else {
308                VerdictProvenance::Advisory
309            },
310        }
311    }
312
313    /// Authoritative verdict for a specific document, if cargo-check has
314    /// reported on it.
315    pub fn file_state(&self, path: &str) -> Option<FileState> {
316        self.auth.get(path).copied()
317    }
318
319    /// FIELD FINDING #2: the diagnostics last reported for `path`, in
320    /// publish order. Empty iff RA has explicitly cleared this file (or
321    /// never reported on it). The aggregate stream is
322    /// [`Self::all_diagnostics`].
323    pub fn file_diagnostics(&self, path: &str) -> &[Diagnostic] {
324        self.diagnostics.get(path).map(Vec::as_slice).unwrap_or(&[])
325    }
326
327    /// FIELD FINDING #2: every known diagnostic, flattened across files in
328    /// deterministic path order. This is what the CLI prints — pairing
329    /// `tree_state` with `all_diagnostics` is the rich verdict the boolean
330    /// `TreeState` alone could not surface.
331    pub fn all_diagnostics(&self) -> Vec<Diagnostic> {
332        let total: usize = self.diagnostics.values().map(Vec::len).sum();
333        let mut out = Vec::with_capacity(total);
334        for v in self.diagnostics.values() {
335            out.extend(v.iter().cloned());
336        }
337        out
338    }
339
340    /// Fold one [`LspEvent`] into the model.
341    pub fn apply_event(&mut self, ev: &LspEvent) {
342        match ev {
343            LspEvent::Diagnostics(pd) => {
344                let Some(path) = crate::lsp::path_from_uri(&pd.uri) else {
345                    return;
346                };
347                // FIELD FINDING #8-redo: per-file RED on ANY severity:Error
348                // (rustc-tier OR rust-analyzer-native). The #21 design
349                // restricted authoritative-RED to rustc-source only, but
350                // dogfood-lead's `let bad =` reproducer showed that
351                // RA-native parse errors don't make it to cargo-check (so
352                // never publish as source:rustc) yet are unambiguous
353                // evidence the file cannot compile. The asymmetry is
354                // honest: RA's "saw an error" is strictly stronger
355                // evidence than "didn't see one" because RA's analysis
356                // is partial — so RA-native severity:Error can drive RED
357                // (the bug evidence is real), but only flycheck-completion
358                // + zero errors can drive GREEN (the absence of evidence
359                // must be backed by cargo's fuller analysis).
360                // #126 Tier-3: pure decision (env isolated). In
361                // proc-macro-off downrank mode RED is rustc-source only
362                // (cargo-check = complete RA-independent authority);
363                // otherwise the F8-redo any-source rule. RA-native is
364                // never lost — it still flows to `native`/advisory/
365                // diagnostics below, just not the authoritative verdict.
366                let (file_state, downranked) = file_state_for(pd, crate::procmacro::enabled());
367                if downranked {
368                    self.procmacro_downranked += 1;
369                }
370                let native_state = if pd.advisory_errors > 0 {
371                    FileState::Red
372                } else {
373                    FileState::Green
374                };
375                self.auth.insert(path.clone(), file_state);
376                self.native.insert(path.clone(), native_state);
377                // FIELD FINDING #2: stash the rich diagnostic list for this
378                // file. RA's `publishDiagnostics` replaces the list (an empty
379                // list clears it) — mirror that exactly so the CLI's
380                // aggregate view never shows a stale error for a fixed file.
381                if pd.diagnostics.is_empty() {
382                    self.diagnostics.remove(&path);
383                } else {
384                    self.diagnostics
385                        .insert(path.clone(), pd.diagnostics.clone());
386                }
387                // FileVerdict is the authoritative per-file settle —
388                // emits the broadened-per-#8-redo `file_state` (any
389                // severity:Error from any source → Red), matching the
390                // tree-derivation rule above.
391                self.emit(StateEvent::FileVerdict {
392                    path,
393                    state: file_state,
394                });
395                self.reconcile();
396                self.emit_advisory();
397            }
398            LspEvent::FlycheckEnded => {
399                self.flycheck_done = true;
400                self.reconcile();
401                self.emit_advisory();
402            }
403            LspEvent::IndexingEnded => {
404                // FIELD FINDING #3a: the watch-mode model's authoritative
405                // GREEN is already correctly gated on `flycheck_done` (the
406                // #21 rule), and a real RA only fires a flycheck pass AFTER
407                // indexing completes — so the model itself does not need to
408                // gate on indexing here. The signal exists in this enum to
409                // un-stick the one-shot `check_*` loops (which were
410                // settle-early-breaking before the first flycheck on cold
411                // RA); the watch loop sees it pass through and ignores it.
412                // A future "still warming up" UI signal could light up off
413                // this — out of #43 scope.
414            }
415        }
416    }
417
418    /// A document went away (deleted / gitignored).
419    pub fn forget_file(&mut self, path: &str) {
420        let a = self.auth.remove(path).is_some();
421        let n = self.native.remove(path).is_some();
422        // FIELD FINDING #2: keep the diagnostics map in sync — a deleted file
423        // must not haunt the CLI's aggregate view with stale errors.
424        let d = self.diagnostics.remove(path).is_some();
425        if a || n || d {
426            self.reconcile();
427            self.emit_advisory();
428        }
429    }
430
431    /// The #21 authoritative rule: RED until a flycheck pass has completed;
432    /// then GREEN iff that pass left no rustc-source error (an empty clean
433    /// pass is authoritatively green — `cargo check` succeeded with zero
434    /// errors).
435    fn authoritative_tree(&self) -> TreeState {
436        if !self.flycheck_done {
437            return TreeState::Red;
438        }
439        if self.auth.values().any(|s| *s == FileState::Red) {
440            TreeState::Red
441        } else {
442            TreeState::Green
443        }
444    }
445
446    fn reconcile(&mut self) {
447        let next = self.authoritative_tree();
448        if next == self.tree {
449            return;
450        }
451        self.tree = next;
452        match next {
453            TreeState::Green => {
454                let identity = self.identity.current_identity();
455                self.emit(StateEvent::BecameGreen { identity });
456            }
457            TreeState::Red => self.emit(StateEvent::BecameRed),
458        }
459    }
460
461    fn emit(&mut self, ev: StateEvent) {
462        self.subscribers.retain(|s| s.send(ev.clone()).is_ok());
463    }
464
465    fn emit_advisory(&mut self) {
466        let v = self.last_verdict();
467        self.advisory_subscribers.retain(|s| s.send(v).is_ok());
468    }
469}
470
471fn poisoned<T>(m: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
472    m.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
473}
474
475/// Recursively collect `*.rs` files under `root`, skipping ignored paths
476/// (`target/`, `.git/`, `.gitignore`d) via [`crate::watcher::IgnoreRules`].
477fn collect_rs_files(root: &Path, ignore: &crate::watcher::IgnoreRules) -> Vec<PathBuf> {
478    let mut out = Vec::new();
479    let mut stack = vec![root.to_path_buf()];
480    while let Some(dir) = stack.pop() {
481        let Ok(entries) = fs::read_dir(&dir) else {
482            continue;
483        };
484        for entry in entries.flatten() {
485            let path = entry.path();
486            let rel = path.strip_prefix(root).unwrap_or(&path);
487            if ignore.is_ignored(rel) {
488                continue;
489            }
490            match entry.file_type() {
491                Ok(ft) if ft.is_dir() => stack.push(path),
492                Ok(ft) if ft.is_file() => {
493                    if path.extension().is_some_and(|e| e == "rs") {
494                        out.push(path);
495                    }
496                }
497                _ => {}
498            }
499        }
500    }
501    out
502}
503
504// ---------------------------------------------------------------------------
505// cli-ux public surface
506// ---------------------------------------------------------------------------
507
508/// The display-only [`BuildIdentity`] for callers that consume the verdict
509/// stream but never trigger a build. Not a real build key — fixed sentinel
510/// hashes — so it can never alias a genuine artifact. Real identity is the
511/// build-cas owner's to compute.
512pub fn placeholder_identity() -> BuildIdentity {
513    let sentinel = ContentHash::new("placeholder-display-only-not-a-build-key");
514    BuildIdentity {
515        source_tree: sentinel.clone(),
516        cargo_lock: sentinel.clone(),
517        rust_toolchain: sentinel.clone(),
518        tf_config: sentinel,
519        target: TargetTriple::new("wasm32-unknown-unknown"),
520        profile: Profile::Dev,
521    }
522}
523
524/// One-shot AUTHORITATIVE verdict for `root`: spin up rust-analyzer with
525/// flycheck on, open every workspace `.rs`, wait for a completed `cargo
526/// check` pass, and report it with provenance. Additive (#21).
527///
528/// `Err` = setup/env failure (rust-analyzer missing, spawn/pipe error) — the
529/// CLI must surface this distinctly from "code is red". A run that never sees
530/// an authoritative flycheck pass yields `Verdict { Red, Advisory }` (never
531/// claim unproven green — AC#4).
532pub fn check_verdict(root: &Path) -> io::Result<Verdict> {
533    let root = fs::canonicalize(root)?;
534    let mut cmd = crate::analyzer::rust_analyzer_command()?;
535    cmd.current_dir(&root);
536    // FIELD FINDING #3b: wrap the child in a `ReapOnDrop` BEFORE any
537    // `?` early-return path so a failing LSP handshake (handshake EOF,
538    // pipe error, etc.) no longer leaks the child + its proc-macro-srv
539    // grandchildren on Unix.
540    let mut guard = crate::analyzer::ReapOnDrop::new(cmd.spawn()?);
541    let (stdin, stdout) = guard
542        .take_stdio()
543        .ok_or_else(|| io::Error::other("rust-analyzer stdio unavailable"))?;
544    let root_str = root.to_string_lossy().into_owned();
545    // FIELD FINDING #74: lean InitOpts derived from env vars
546    // (TF_PROC_MACRO / TF_FEATURES — set by the CLI's --proc-macro /
547    // --features flags) + Cargo.toml auto-detection at the project root.
548    // The lean initializationOptions cut RA's idle/check cost ~30-50%
549    // without breaking the F8-redo verdict gate (checkOnSave stays
550    // enabled per Option B+).
551    let init_opts = crate::lsp::InitOpts::from_env_and_project(&root);
552    let (client, events) = crate::lsp::LspClient::initialize(stdin, stdout, &root_str, &init_opts)?;
553
554    let ignore = crate::watcher::IgnoreRules::for_root(&root);
555    for f in collect_rs_files(&root, &ignore) {
556        if let Ok(text) = fs::read_to_string(&f) {
557            let _ = client.did_open(&f.to_string_lossy(), &text, 1);
558        }
559    }
560
561    let cap = std::env::var("TF_CHECK_TIMEOUT_SECS")
562        .ok()
563        .and_then(|s| s.parse::<u64>().ok())
564        .map(Duration::from_secs)
565        .unwrap_or(CHECK_HARD_CAP);
566    let deadline = Instant::now() + cap;
567    let mut auth: BTreeMap<String, FileState> = BTreeMap::new();
568    let mut flycheck_seen = false;
569    let mut got_any = false;
570    // FIELD FINDING #3a: RA fires advisory `publishDiagnostics` during
571    // indexing (got_any=true), then goes quiet for 5-10s while the rest of
572    // indexing/proc-macro-bringup completes; the old code's "if got_any
573    // and Timeout, break early" path fired during THAT quiet and reported
574    // the unproven red. Gate the early-break on `indexing_done` so cold
575    // RA gets the project-ready signal it deserves before we give up.
576    let mut indexing_done = false;
577    while !flycheck_seen {
578        let now = Instant::now();
579        if now >= deadline {
580            break;
581        }
582        let wait = CHECK_SETTLE.min(deadline - now);
583        match events.recv_timeout(wait) {
584            Ok(LspEvent::Diagnostics(pd)) => {
585                got_any = true;
586                if let Some(p) = crate::lsp::path_from_uri(&pd.uri) {
587                    // FIELD FINDING #8-redo: severity:Error from ANY
588                    // source flips the file Red — matches the model's
589                    // apply_event rule. RA-native parse errors (e.g.
590                    // `let bad =` at file scope, the dogfood reproducer)
591                    // never make it to cargo-check, so the original
592                    // rustc-source-only rule produced a silent-green
593                    // verdict on a broken tree. Treating any severity:
594                    // Error as authoritative-for-RED is the honest fix.
595                    let s = if pd.has_any_severity_error() {
596                        FileState::Red
597                    } else {
598                        FileState::Green
599                    };
600                    auth.insert(p, s);
601                }
602            }
603            Ok(LspEvent::FlycheckEnded) => {
604                flycheck_seen = true;
605            }
606            Ok(LspEvent::IndexingEnded) => {
607                indexing_done = true;
608            }
609            Err(RecvTimeoutError::Timeout) => {
610                // Only allow settle-early once RA has actually finished
611                // indexing — otherwise the quiet IS the indexing window
612                // and breaking here false-reds a green tree (#43).
613                if got_any && indexing_done {
614                    break;
615                }
616            }
617            Err(RecvTimeoutError::Disconnected) => break, // RA exited
618        }
619    }
620    // `guard` drops here — `ReapOnDrop` SIGKILLs RA's whole process group
621    // on Unix (RA + every proc-macro-srv grandchild) and waits to reap;
622    // on non-Unix it falls back to killing just the immediate child.
623    // The explicit `drop(guard)` makes the scope-end deterministic and
624    // documents the reap point.
625    drop(guard);
626
627    if flycheck_seen {
628        let tree = if auth.values().any(|s| *s == FileState::Red) {
629            TreeState::Red
630        } else {
631            TreeState::Green
632        };
633        Ok(Verdict {
634            tree,
635            provenance: VerdictProvenance::Authoritative,
636        })
637    } else {
638        // No authoritative pass observed → unproven, never green.
639        Ok(Verdict {
640            tree: TreeState::Red,
641            provenance: VerdictProvenance::Advisory,
642        })
643    }
644}
645
646/// One-shot verdict for `root` (frozen signature — cli-ux is wired to this).
647/// Thin wrapper over [`check_verdict`] discarding provenance.
648pub fn check_once(root: &Path) -> io::Result<TreeState> {
649    check_verdict(root).map(|v| v.tree)
650}
651
652/// FIELD FINDING #2: one-shot AUTHORITATIVE verdict for `root` PAIRED with
653/// the diagnostic list (file/line/col/severity/code/message/source) the CLI
654/// needs to print. Spins up rust-analyzer with flycheck on, opens every
655/// workspace `.rs`, waits for a completed `cargo check` pass, then returns
656/// the [`CheckResult`] (the [`TreeState`] every existing caller uses, plus
657/// the per-file diagnostics every red tree carries).
658///
659/// **Additive alongside** [`check_once`] / [`check_verdict`]: those keep
660/// their byte-frozen signatures (cli-ux's `watch`, bench-lead's harness, and
661/// the #21 advisory channel are all unchanged). This is the parallel rich
662/// API the CLI's `check` command binds to.
663///
664/// `Err` = setup/env failure (rust-analyzer missing, spawn/pipe error) — the
665/// CLI must surface this distinctly from "code is red". A run that never sees
666/// an authoritative flycheck pass yields `CheckResult { Red, diagnostics }`
667/// with whatever diagnostics RA emitted before the timeout (never claim
668/// unproven green — AC#4 stays inviolable here too).
669pub fn check_once_with_diagnostics(root: &Path) -> io::Result<CheckResult> {
670    let root = fs::canonicalize(root)?;
671    let mut cmd = crate::analyzer::rust_analyzer_command()?;
672    cmd.current_dir(&root);
673    // FIELD FINDING #3b: same guard as `check_verdict` — Drop-based reap
674    // covers every early-return path (handshake EOF, pipe error, etc.)
675    // and on Unix takes out the whole RA process group so
676    // `rust-analyzer-proc-macro-srv` grandchildren do not accumulate.
677    let mut guard = crate::analyzer::ReapOnDrop::new(cmd.spawn()?);
678    let (stdin, stdout) = guard
679        .take_stdio()
680        .ok_or_else(|| io::Error::other("rust-analyzer stdio unavailable"))?;
681    let root_str = root.to_string_lossy().into_owned();
682    // FIELD FINDING #74: lean InitOpts derived from env vars
683    // (TF_PROC_MACRO / TF_FEATURES — set by the CLI's --proc-macro /
684    // --features flags) + Cargo.toml auto-detection at the project root.
685    // The lean initializationOptions cut RA's idle/check cost ~30-50%
686    // without breaking the F8-redo verdict gate (checkOnSave stays
687    // enabled per Option B+).
688    let init_opts = crate::lsp::InitOpts::from_env_and_project(&root);
689    let (client, events) = crate::lsp::LspClient::initialize(stdin, stdout, &root_str, &init_opts)?;
690
691    let ignore = crate::watcher::IgnoreRules::for_root(&root);
692    for f in collect_rs_files(&root, &ignore) {
693        if let Ok(text) = fs::read_to_string(&f) {
694            let _ = client.did_open(&f.to_string_lossy(), &text, 1);
695        }
696    }
697
698    let cap = std::env::var("TF_CHECK_TIMEOUT_SECS")
699        .ok()
700        .and_then(|s| s.parse::<u64>().ok())
701        .map(Duration::from_secs)
702        .unwrap_or(CHECK_HARD_CAP);
703    let deadline = Instant::now() + cap;
704    let mut auth: BTreeMap<String, FileState> = BTreeMap::new();
705    // Reuse the model's per-file-replace semantics: a later publish for the
706    // same file SUPERSEDES the earlier one (empty publish clears). This is
707    // how `apply_event` does it; mirroring keeps the CLI's one-shot view
708    // consistent with what `watch` shows live.
709    let mut diagnostics: BTreeMap<String, Vec<Diagnostic>> = BTreeMap::new();
710    let mut flycheck_seen = false;
711    let mut got_any = false;
712    // FIELD FINDING #3a: identical gate to `check_verdict`. The false-red
713    // on a cold green tree happens because RA fires advisory diags during
714    // indexing, then goes silent — the old settle-early-on-got_any path
715    // mistook the silence for completion. `indexing_done` blocks the
716    // early-break until RA has actually announced project-ready.
717    let mut indexing_done = false;
718    while !flycheck_seen {
719        let now = Instant::now();
720        if now >= deadline {
721            break;
722        }
723        let wait = CHECK_SETTLE.min(deadline - now);
724        match events.recv_timeout(wait) {
725            Ok(LspEvent::Diagnostics(pd)) => {
726                got_any = true;
727                if let Some(p) = crate::lsp::path_from_uri(&pd.uri) {
728                    // FIELD FINDING #8-redo: severity:Error from ANY
729                    // source flips Red — same rule as check_verdict and
730                    // the model's apply_event (see those for the full
731                    // honest-asymmetry rationale: RA-native parse errors
732                    // are real evidence; cargo-check is the only thing
733                    // that can earn GREEN).
734                    let s = if pd.has_any_severity_error() {
735                        FileState::Red
736                    } else {
737                        FileState::Green
738                    };
739                    auth.insert(p.clone(), s);
740                    if pd.diagnostics.is_empty() {
741                        diagnostics.remove(&p);
742                    } else {
743                        diagnostics.insert(p, pd.diagnostics.clone());
744                    }
745                }
746            }
747            Ok(LspEvent::FlycheckEnded) => {
748                flycheck_seen = true;
749            }
750            Ok(LspEvent::IndexingEnded) => {
751                indexing_done = true;
752            }
753            Err(RecvTimeoutError::Timeout) => {
754                if got_any && indexing_done {
755                    break; // settled, project ready, no authoritative pass
756                }
757            }
758            Err(RecvTimeoutError::Disconnected) => break, // RA exited
759        }
760    }
761    // Same deterministic reap-point as `check_verdict` (see comment there).
762    drop(guard);
763
764    let tree = if flycheck_seen {
765        if auth.values().any(|s| *s == FileState::Red) {
766            TreeState::Red
767        } else {
768            TreeState::Green
769        }
770    } else {
771        // No authoritative pass observed → unproven, never green (AC#4).
772        TreeState::Red
773    };
774    let total: usize = diagnostics.values().map(Vec::len).sum();
775    let mut flat = Vec::with_capacity(total);
776    for v in diagnostics.values() {
777        flat.extend(v.iter().cloned());
778    }
779    Ok(CheckResult {
780        tree,
781        diagnostics: flat,
782    })
783}
784
785/// A running watch pipeline: rust-analyzer + LSP + watcher feeding the model.
786/// Drop = graceful shutdown (stop threads, stop watcher, kill RA).
787pub struct ModelSession {
788    model: Arc<Mutex<Model>>,
789    stop: Arc<AtomicBool>,
790    /// Manages rust-analyzer with AC#6 transparent restart.
791    supervisor: Option<crate::analyzer::Supervisor>,
792    watch: Option<crate::watcher::WatchHandle>,
793    threads: Vec<JoinHandle<()>>,
794    /// #112 structural-trigger spike — bench-lead measurement hook
795    /// (D-OPENCLOSED §4.2). Additive; not part of any frozen seam.
796    structural_counters: Arc<crate::structural::StructuralCounters>,
797    /// #122 Tier-4 idle-evict — bench-lead measurement hook
798    /// (composes with #116 stage-3). Additive; no frozen seam.
799    idle_evict_counters: Arc<crate::idle::IdleEvictCounters>,
800}
801
802impl ModelSession {
803    /// Add another AUTHORITATIVE [`StateEvent`] subscriber (frozen seam).
804    pub fn subscribe(&self) -> Receiver<StateEvent> {
805        poisoned(&self.model).subscribe()
806    }
807
808    /// Subscribe to the ADVISORY provisional verdict stream (additive #21).
809    pub fn subscribe_advisory(&self) -> Receiver<Verdict> {
810        poisoned(&self.model).subscribe_advisory()
811    }
812
813    /// Current AUTHORITATIVE aggregate verdict (frozen signature).
814    pub fn tree_state(&self) -> TreeState {
815        poisoned(&self.model).tree_state()
816    }
817
818    /// Full reported verdict incl. provenance (additive #21).
819    pub fn last_verdict(&self) -> Verdict {
820        poisoned(&self.model).last_verdict()
821    }
822
823    /// FIELD FINDING #2: every diagnostic the model has accumulated, across
824    /// every reporting file. Returned in deterministic path order so the
825    /// CLI's `watch` mode can re-print a stable view on each transition
826    /// without flicker. Snapshot — the lock is released before the caller
827    /// formats output, so this is safe to call on every event.
828    pub fn current_diagnostics(&self) -> Vec<Diagnostic> {
829        poisoned(&self.model).all_diagnostics()
830    }
831
832    /// FIELD FINDING #6-NEG-A (#51): subscribe to supervisor-lifecycle
833    /// events. The CLI watch loop drains this on every iteration and prints
834    /// a stream signal so the user is never staring at silence during an
835    /// AC#6 transparent restart's 30-60s re-index window.
836    pub fn subscribe_lifecycle(&self) -> Receiver<LifecycleEvent> {
837        poisoned(&self.model).subscribe_lifecycle()
838    }
839
840    /// #112 structural-trigger spike — bench-lead measurement hook
841    /// (D-OPENCLOSED §4.2). `(settled_batches, closed_batches)`:
842    /// `1 − closed/settled` is the fraction of authoritative
843    /// cargo-checks the structural gate eliminated per agent-edit-batch.
844    /// `(0, 0)` while `TF_STRUCTURAL_TRIGGER` is unset (default-off ⇒ no
845    /// counting, prior path byte-identical). Additive — touches no
846    /// frozen seam.
847    pub fn structural_counters(&self) -> (u64, u64) {
848        self.structural_counters.snapshot()
849    }
850
851    /// #122 Tier-4 idle-evict — bench-lead measurement hook
852    /// (composes with #116 stage-3 fleet-scale). `(evictions,
853    /// suspended_ms)`: `suspended_ms` ≈ the time-averaged ~2 GB RA RSS
854    /// reclaimed. `(0, 0)` while `TF_RA_IDLE_EVICT` is unset
855    /// (default-off ⇒ no eviction, prior path byte-identical).
856    /// Additive — touches no frozen seam.
857    pub fn idle_evict_counters(&self) -> (u64, u64) {
858        self.idle_evict_counters.snapshot()
859    }
860
861    /// #126 Tier-3 bench/dogfood hook: count of RA-native severity:Error
862    /// folds demoted out of the authoritative verdict by the
863    /// proc-macro-off downrank (the false-RED-suppression firing). `0`
864    /// while `TF_RA_PROCMACRO_OFF` is unset (default-off, byte-
865    /// identical). Additive — touches no frozen seam.
866    pub fn procmacro_downranked(&self) -> u64 {
867        poisoned(&self.model).procmacro_downranked()
868    }
869
870    /// Explicit graceful shutdown (also runs on drop).
871    pub fn shutdown(mut self) {
872        self.do_shutdown();
873    }
874
875    fn do_shutdown(&mut self) {
876        self.stop.store(true, Ordering::SeqCst);
877        if let Some(sup) = self.supervisor.take() {
878            sup.shutdown();
879        }
880        drop(self.watch.take());
881        for t in self.threads.drain(..) {
882            let _ = t.join();
883        }
884    }
885}
886
887impl Drop for ModelSession {
888    fn drop(&mut self) {
889        self.do_shutdown();
890    }
891}
892
893/// Start the streaming pipeline for `root` (frozen signature). `identity`
894/// supplies the [`BuildIdentity`] at authoritative green edges.
895pub fn watch<I: IdentityProvider + 'static>(
896    root: &Path,
897    identity: I,
898) -> io::Result<(ModelSession, Receiver<StateEvent>)> {
899    let root = fs::canonicalize(root)?;
900    let root_str = root.to_string_lossy().into_owned();
901
902    let model = Arc::new(Mutex::new(Model::new(identity)));
903    let events = poisoned(&model).subscribe();
904    let stop = Arc::new(AtomicBool::new(false));
905
906    // The LSP client for whichever rust-analyzer instance is currently alive;
907    // the on_spawn hook swaps it on every (re)start (AC#6 transparent).
908    let current: Arc<Mutex<Option<Arc<crate::lsp::LspClient>>>> = Arc::new(Mutex::new(None));
909
910    let spawn_root = root.clone();
911    let spawn = move || {
912        let mut cmd = crate::analyzer::rust_analyzer_command()?;
913        cmd.current_dir(&spawn_root);
914        cmd.spawn()
915    };
916
917    let hook_root = root_str.clone();
918    let hook_model = Arc::clone(&model);
919    let hook_current = Arc::clone(&current);
920    // FIELD FINDING #6-NEG-A (#51): per-watch counter of on_spawn calls.
921    // n == 0 ⇒ the initial spawn (covered by the CLI's bring-up line — no
922    // restart signal needed). n >= 1 ⇒ a transparent AC#6 restart after
923    // RA crashed/was killed; emit `LifecycleEvent::AnalyzerRestarting`
924    // BEFORE the LSP handshake starts (so the CLI sees the signal while
925    // the model is still cold).
926    let spawn_count = Arc::new(std::sync::atomic::AtomicUsize::new(0));
927    let hook_spawn_count = Arc::clone(&spawn_count);
928    let on_spawn = move |child: &mut std::process::Child| {
929        let n = hook_spawn_count.fetch_add(1, Ordering::SeqCst);
930        if n > 0 {
931            // Transparent restart — tell the CLI so the stream doesn't go
932            // silent for the 30-60s reindex window. Emit BEFORE the
933            // handshake so a slow-handshake restart is visible immediately.
934            poisoned(&hook_model).emit_lifecycle(LifecycleEvent::AnalyzerRestarting);
935        }
936        let (Some(stdin), Some(stdout)) = (child.stdin.take(), child.stdout.take()) else {
937            return;
938        };
939        // FIELD FINDING #74: same lean InitOpts as the one-shot check
940        // paths above — read env vars (TF_PROC_MACRO / TF_FEATURES) +
941        // auto-detect proc-macro from project Cargo.toml. On a
942        // transparent AC#6 restart the env reads re-execute, so a CLI
943        // change between restarts is respected by the new RA instance.
944        let init_opts = crate::lsp::InitOpts::from_env_and_project(Path::new(&hook_root));
945        let Ok((client, events)) =
946            crate::lsp::LspClient::initialize(stdin, stdout, &hook_root, &init_opts)
947        else {
948            return; // RA broke during handshake; supervisor retries
949        };
950        let client = Arc::new(client);
951        let ig = crate::watcher::IgnoreRules::for_root(Path::new(&hook_root));
952        for f in collect_rs_files(Path::new(&hook_root), &ig) {
953            if let Ok(text) = fs::read_to_string(&f) {
954                let _ = client.did_open(&f.to_string_lossy(), &text, 1);
955            }
956        }
957        *poisoned(&hook_current) = Some(Arc::clone(&client));
958        let m = Arc::clone(&hook_model);
959        // Detached: ends when this RA instance's stdout EOFs (it died); the
960        // next on_spawn invocation starts a fresh forwarder.
961        let _ = thread::Builder::new()
962            .name("tf-model-events".into())
963            .spawn(move || {
964                while let Ok(ev) = events.recv() {
965                    poisoned(&m).apply_event(&ev);
966                }
967            });
968    };
969
970    let supervisor = crate::analyzer::Supervisor::start_with_hook(spawn, on_spawn)?;
971    // #122 Tier-4 idle-evict: cheap clonable handle so the fs-batch
972    // loop can suspend/resume RA without owning the Supervisor (which
973    // ModelSession owns). Only ever exercised when `TF_RA_IDLE_EVICT=1`.
974    let suspend_handle = supervisor.suspend_handle();
975
976    let (watch_handle, batches) =
977        crate::watcher::watch(&root, resolve_watch_debounce()).map_err(io::Error::other)?;
978    // #112 structural-trigger spike: bench-lead measurement counters,
979    // shared into the fs thread and exposed via ModelSession. Only move
980    // while `TF_STRUCTURAL_TRIGGER=1` (the gated branch); dormant (0,0)
981    // when default-off so an instrumented run is opt-in.
982    let structural_counters = Arc::new(crate::structural::StructuralCounters::new());
983    // #122 Tier-4 bench hook (composes with #116 stage-3). Dormant
984    // (0,0) when default-off so an instrumented run is opt-in.
985    let idle_counters = Arc::new(crate::idle::IdleEvictCounters::new());
986    let mut threads = Vec::new();
987    {
988        let model = Arc::clone(&model);
989        let stop = Arc::clone(&stop);
990        let current = Arc::clone(&current);
991        let structural = Arc::clone(&structural_counters);
992        let idle_counters = Arc::clone(&idle_counters);
993        let suspend_handle = suspend_handle.clone();
994        threads.push(
995            thread::Builder::new()
996                .name("tf-model-fs".into())
997                .spawn(move || {
998                    let mut version: i64 = 2;
999                    // #122 Tier-4 idle-evict tracking. Only consulted
1000                    // when TF_RA_IDLE_EVICT=1; inert otherwise.
1001                    let mut last_activity = std::time::Instant::now();
1002                    let mut suspended_since: Option<std::time::Instant> = None;
1003                    loop {
1004                        match batches.recv_timeout(Duration::from_millis(250)) {
1005                            Ok(batch) => {
1006                                // #122 Tier-4: if RA was idle-evicted,
1007                                // bring it back BEFORE touching the
1008                                // client. resume() respawns via the
1009                                // unchanged AC#6 path (LSP re-init +
1010                                // re-did_open at CURRENT content); wait
1011                                // (bounded by the AC#1 bring-up budget)
1012                                // for the fresh child so this batch's
1013                                // verdict comes from the SAME
1014                                // post-restart path ac6_kill9 proves
1015                                // correct. Zero syscalls when
1016                                // default-off.
1017                                if crate::idle::enabled() && suspend_handle.is_suspended() {
1018                                    suspend_handle.resume();
1019                                    let deadline =
1020                                        std::time::Instant::now() + Duration::from_secs(35);
1021                                    while !suspend_handle.child_alive()
1022                                        && std::time::Instant::now() < deadline
1023                                    {
1024                                        thread::sleep(Duration::from_millis(50));
1025                                    }
1026                                    if let Some(since) = suspended_since.take() {
1027                                        idle_counters.add_suspended(since.elapsed());
1028                                    }
1029                                }
1030                                last_activity = std::time::Instant::now();
1031                                let client = poisoned(&current).as_ref().cloned();
1032                                if crate::structural::enabled() {
1033                                    // #112 (D-OPENCLOSED): gate the cargo-check
1034                                    // spend on the coalesced batch being
1035                                    // structurally CLOSED. ALWAYS didChange
1036                                    // (RA re-parses → RA-native severity:Error
1037                                    // still flips per-file RED via the
1038                                    // UNTOUCHED F8-redo path); only didSave
1039                                    // (the flycheck/cargo-check trigger) when
1040                                    // every .rs file in the batch is CLOSED.
1041                                    // Closedness gates SPEND + publish-
1042                                    // eligibility, NEVER the verdict colour.
1043                                    let mut files: Vec<(String, String)> = Vec::new();
1044                                    for path in batch {
1045                                        if path.extension().is_none_or(|e| e != "rs") {
1046                                            continue;
1047                                        }
1048                                        let p = path.to_string_lossy().into_owned();
1049                                        match fs::read_to_string(&path) {
1050                                            Ok(text) => files.push((p, text)),
1051                                            Err(_) => poisoned(&model).forget_file(&p),
1052                                        }
1053                                    }
1054                                    // A batch is worth a cargo-check iff NO
1055                                    // file in it is OPEN (D-OPENCLOSED §2.4).
1056                                    let all_closed =
1057                                        files.iter().all(|(_, t)| crate::structural::is_closed(t));
1058                                    structural.record(all_closed);
1059                                    for (p, text) in &files {
1060                                        version += 1;
1061                                        if let Some(c) = client.as_ref() {
1062                                            let _ = c.did_change(p, text, version);
1063                                            if all_closed {
1064                                                let _ = c.did_save(p);
1065                                            }
1066                                        }
1067                                    }
1068                                } else {
1069                                    // DEFAULT-OFF: prior path, byte-identical.
1070                                    for path in batch {
1071                                        if path.extension().is_none_or(|e| e != "rs") {
1072                                            continue;
1073                                        }
1074                                        let p = path.to_string_lossy().into_owned();
1075                                        match fs::read_to_string(&path) {
1076                                            Ok(text) => {
1077                                                version += 1;
1078                                                if let Some(c) = client.as_ref() {
1079                                                    let _ = c.did_change(&p, &text, version);
1080                                                    let _ = c.did_save(&p);
1081                                                }
1082                                            }
1083                                            Err(_) => poisoned(&model).forget_file(&p),
1084                                        }
1085                                    }
1086                                }
1087                            }
1088                            Err(RecvTimeoutError::Timeout) => {
1089                                if stop.load(Ordering::SeqCst) {
1090                                    break;
1091                                }
1092                                // #122 Tier-4: reclaim RA's ~2 GB during
1093                                // the long, provably check-free
1094                                // agent-idle gaps. Gated on
1095                                // `flycheck_done` so the cold first
1096                                // authoritative pass is NEVER
1097                                // interrupted; only once idle ≥ the
1098                                // window and not already suspended.
1099                                // Zero syscalls when default-off.
1100                                if crate::idle::enabled()
1101                                    && !suspend_handle.is_suspended()
1102                                    && poisoned(&model).flycheck_done()
1103                                    && last_activity.elapsed() >= crate::idle::idle_window()
1104                                {
1105                                    suspend_handle.suspend();
1106                                    idle_counters.record_eviction();
1107                                    suspended_since = Some(std::time::Instant::now());
1108                                }
1109                            }
1110                            Err(RecvTimeoutError::Disconnected) => break,
1111                        }
1112                    }
1113                })
1114                .expect("spawn tf-model-fs"),
1115        );
1116    }
1117
1118    let session = ModelSession {
1119        model,
1120        stop,
1121        supervisor: Some(supervisor),
1122        watch: Some(watch_handle),
1123        threads,
1124        structural_counters,
1125        idle_evict_counters: idle_counters,
1126    };
1127    Ok((session, events))
1128}
1129
1130#[cfg(test)]
1131mod tests {
1132    use super::*;
1133
1134    fn ident() -> BuildIdentity {
1135        BuildIdentity {
1136            source_tree: ContentHash::new("src"),
1137            cargo_lock: ContentHash::new("lock"),
1138            rust_toolchain: ContentHash::new("tc"),
1139            tf_config: ContentHash::new("cfg"),
1140            target: TargetTriple::new("wasm32-unknown-unknown"),
1141            profile: Profile::Dev,
1142        }
1143    }
1144
1145    fn model() -> Model {
1146        Model::new(ident)
1147    }
1148
1149    fn diag(uri: &str, auth_err: usize, adv_err: usize) -> LspEvent {
1150        LspEvent::Diagnostics(crate::lsp::PublishDiagnostics {
1151            uri: uri.into(),
1152            authoritative_errors: auth_err,
1153            advisory_errors: adv_err,
1154            total: auth_err + adv_err,
1155            // Counts-only helper: the per-file rich list isn't what these
1156            // #21 tests assert on; the dedicated FIELD FINDING #2 tests
1157            // below populate it via `diag_rich`.
1158            diagnostics: Vec::new(),
1159        })
1160    }
1161
1162    fn diag_rich(uri: &str, ds: Vec<Diagnostic>) -> LspEvent {
1163        let mut auth = 0usize;
1164        let mut adv = 0usize;
1165        for d in &ds {
1166            if d.severity == cargoless_proto::Severity::Error {
1167                if d.source.as_deref() == Some("rustc") {
1168                    auth += 1;
1169                } else {
1170                    adv += 1;
1171                }
1172            }
1173        }
1174        LspEvent::Diagnostics(crate::lsp::PublishDiagnostics {
1175            uri: uri.into(),
1176            authoritative_errors: auth,
1177            advisory_errors: adv,
1178            total: ds.len(),
1179            diagnostics: ds,
1180        })
1181    }
1182
1183    // -----------------------------------------------------------------
1184    // #126 Tier-3 — file_state_for: proc-macro-off RA-native-downrank.
1185    // Pure decision, env isolated (the #88 discipline); exhaustive.
1186    // -----------------------------------------------------------------
1187
1188    #[test]
1189    fn file_state_for_downrank_vs_f8redo_default() {
1190        fn pd(auth: usize, adv: usize) -> crate::lsp::PublishDiagnostics {
1191            crate::lsp::PublishDiagnostics {
1192                uri: "file:///x.rs".into(),
1193                authoritative_errors: auth,
1194                advisory_errors: adv,
1195                total: auth + adv,
1196                diagnostics: Vec::new(),
1197            }
1198        }
1199
1200        // DEFAULT (downrank=false) — F8-redo #55: ANY-source error ⇒ Red,
1201        // never "downranked". Byte-identical to pre-#126.
1202        assert_eq!(file_state_for(&pd(0, 0), false), (FileState::Green, false));
1203        assert_eq!(file_state_for(&pd(1, 0), false), (FileState::Red, false));
1204        assert_eq!(
1205            file_state_for(&pd(0, 1), false),
1206            (FileState::Red, false),
1207            "F8-redo: RA-native-only error still drives RED by default"
1208        );
1209
1210        // DOWNRANK (proc-macro-off): rustc-source ONLY drives RED.
1211        assert_eq!(file_state_for(&pd(0, 0), true), (FileState::Green, false));
1212        assert_eq!(
1213            file_state_for(&pd(1, 0), true),
1214            (FileState::Red, false),
1215            "no false-GREEN: a real cargo-check error still drives RED"
1216        );
1217        assert_eq!(
1218            file_state_for(&pd(0, 1), true),
1219            (FileState::Green, true),
1220            "the fix: RA-native-only (proc-macro hallucination) is \
1221             demoted — would-be false-RED suppressed + counted"
1222        );
1223        assert_eq!(
1224            file_state_for(&pd(1, 1), true),
1225            (FileState::Red, false),
1226            "authoritative present ⇒ RED, NOT a suppression (rustc \
1227             evidence is real; nothing was downranked away)"
1228        );
1229    }
1230
1231    fn mk_diag(
1232        path: &str,
1233        line: u32,
1234        col: u32,
1235        sev: cargoless_proto::Severity,
1236        code: Option<&str>,
1237        msg: &str,
1238        source: Option<&str>,
1239    ) -> Diagnostic {
1240        Diagnostic {
1241            file_path: std::path::PathBuf::from(path),
1242            line,
1243            col,
1244            severity: sev,
1245            code: code.map(str::to_owned),
1246            message: msg.to_owned(),
1247            source: source.map(str::to_owned),
1248        }
1249    }
1250
1251    #[test]
1252    fn lifecycle_subscribers_receive_analyzer_restarting() {
1253        // FIELD FINDING #6-NEG-A (#51) — the model's lifecycle bus
1254        // delivers AnalyzerRestarting to every live subscriber, and a
1255        // dropped subscriber does not panic the producer.
1256        let mut m = model();
1257        let r1 = m.subscribe_lifecycle();
1258        {
1259            let r2 = m.subscribe_lifecycle();
1260            m.emit_lifecycle(LifecycleEvent::AnalyzerRestarting);
1261            assert_eq!(
1262                drain(&r1),
1263                vec![LifecycleEvent::AnalyzerRestarting],
1264                "r1 receives"
1265            );
1266            assert_eq!(
1267                drain(&r2),
1268                vec![LifecycleEvent::AnalyzerRestarting],
1269                "r2 receives"
1270            );
1271        }
1272        // r2 dropped; emit must not panic and r1 must still receive.
1273        m.emit_lifecycle(LifecycleEvent::AnalyzerRestarting);
1274        assert_eq!(drain(&r1).len(), 1);
1275    }
1276
1277    #[test]
1278    fn lifecycle_no_emit_before_first_restart_is_silent() {
1279        // Sanity: just constructing a model does NOT emit
1280        // AnalyzerRestarting (that would falsely tell the CLI "restarting"
1281        // when the analyzer hasn't even started). The on_spawn hook is
1282        // responsible for the first-spawn-vs-restart distinction.
1283        let mut m = model();
1284        let rx = m.subscribe_lifecycle();
1285        assert!(drain(&rx).is_empty(), "no lifecycle events on quiet model");
1286        // The verdict bus also stays quiet — lifecycle and verdict are
1287        // strictly separate channels.
1288        m.apply_event(&LspEvent::FlycheckEnded);
1289        assert!(
1290            drain(&rx).is_empty(),
1291            "flycheck-end is a verdict event, not a lifecycle event"
1292        );
1293    }
1294
1295    fn drain<T>(rx: &Receiver<T>) -> Vec<T> {
1296        let mut v = Vec::new();
1297        while let Ok(e) = rx.try_recv() {
1298            v.push(e);
1299        }
1300        v
1301    }
1302
1303    #[test]
1304    fn starts_red_advisory() {
1305        let m = model();
1306        assert_eq!(m.tree_state(), TreeState::Red);
1307        assert_eq!(
1308            m.last_verdict(),
1309            Verdict {
1310                tree: TreeState::Red,
1311                provenance: VerdictProvenance::Advisory
1312            }
1313        );
1314        assert_eq!(m.file_state("x"), None);
1315    }
1316
1317    #[test]
1318    fn native_only_clean_never_green_without_flycheck() {
1319        let mut m = model();
1320        let rx = m.subscribe();
1321        // RA-native says "no errors" for a file — but flycheck has NOT run.
1322        m.apply_event(&diag("file:///p/src/lib.rs", 0, 0));
1323        assert_eq!(m.tree_state(), TreeState::Red, "no green without flycheck");
1324        assert_eq!(m.last_verdict().provenance, VerdictProvenance::Advisory);
1325        // Only an authoritative FileVerdict, NEVER a BecameGreen.
1326        let evs = drain(&rx);
1327        assert!(
1328            evs.iter()
1329                .all(|e| !matches!(e, StateEvent::BecameGreen { .. }))
1330        );
1331        assert!(evs.contains(&StateEvent::FileVerdict {
1332            path: "/p/src/lib.rs".into(),
1333            state: FileState::Green
1334        }));
1335    }
1336
1337    #[test]
1338    fn native_error_pre_flycheck_is_red_advisory_no_event_green() {
1339        let mut m = model();
1340        let rx = m.subscribe();
1341        let arx = m.subscribe_advisory();
1342        m.apply_event(&diag("file:///p/a.rs", 0, 3)); // 3 native errors
1343        assert_eq!(m.tree_state(), TreeState::Red);
1344        assert_eq!(
1345            m.last_verdict(),
1346            Verdict {
1347                tree: TreeState::Red,
1348                provenance: VerdictProvenance::Advisory
1349            }
1350        );
1351        assert!(
1352            drain(&rx)
1353                .iter()
1354                .all(|e| !matches!(e, StateEvent::BecameGreen { .. }))
1355        );
1356        // advisory channel got a provisional verdict
1357        assert!(!drain(&arx).is_empty());
1358    }
1359
1360    #[test]
1361    fn completed_clean_flycheck_is_authoritative_green() {
1362        let mut m = model();
1363        let rx = m.subscribe();
1364        m.apply_event(&diag("file:///p/src/lib.rs", 0, 0)); // still red (no pass yet)
1365        assert_eq!(m.tree_state(), TreeState::Red);
1366        m.apply_event(&LspEvent::FlycheckEnded);
1367        assert_eq!(m.tree_state(), TreeState::Green);
1368        assert_eq!(
1369            m.last_verdict(),
1370            Verdict {
1371                tree: TreeState::Green,
1372                provenance: VerdictProvenance::Authoritative
1373            }
1374        );
1375        let evs = drain(&rx);
1376        assert!(evs.contains(&StateEvent::BecameGreen { identity: ident() }));
1377    }
1378
1379    #[test]
1380    fn ra_native_severity_error_post_flycheck_is_red_too() {
1381        // FIELD FINDING #8-redo (#55): dogfood-lead's smoking-gun case.
1382        // After a clean flycheck (tree green), a NEW edit with an
1383        // RA-native severity:Error (e.g. `let bad =` at file scope —
1384        // RA's parser catches it but cargo-check may not produce a
1385        // source:rustc diagnostic for it) MUST flip the tree Red. Under
1386        // the original #21 rustc-only rule this case was silently
1387        // green-on-broken — the worst possible v0 failure mode.
1388        let mut m = model();
1389        let rx = m.subscribe();
1390        // Get to authoritative green.
1391        m.apply_event(&diag("file:///p/lib.rs", 0, 0));
1392        m.apply_event(&LspEvent::FlycheckEnded);
1393        assert_eq!(m.tree_state(), TreeState::Green);
1394        let _ = drain(&rx);
1395        // User saves `let bad =` — RA-native severity:Error fires; cargo
1396        // hasn't re-checked yet (or won't, if cargo-check itself errors
1397        // before producing per-file JSON output for this file).
1398        m.apply_event(&diag("file:///p/lib.rs", 0, 1)); // 1 ra-native err
1399        assert_eq!(
1400            m.tree_state(),
1401            TreeState::Red,
1402            "RA-native severity:Error post-flycheck must flip tree Red"
1403        );
1404        // The transition event fires on the verdict bus.
1405        assert!(drain(&rx).contains(&StateEvent::BecameRed));
1406        // Provenance stays Authoritative (a flycheck has completed —
1407        // that's what the provenance flag tracks, not which tier the
1408        // current red comes from).
1409        assert_eq!(
1410            m.last_verdict().provenance,
1411            VerdictProvenance::Authoritative
1412        );
1413    }
1414
1415    #[test]
1416    fn rustc_error_after_pass_is_authoritative_red() {
1417        let mut m = model();
1418        let rx = m.subscribe();
1419        // get to authoritative green
1420        m.apply_event(&diag("file:///p/a.rs", 0, 0));
1421        m.apply_event(&LspEvent::FlycheckEnded);
1422        assert_eq!(m.tree_state(), TreeState::Green);
1423        let _ = drain(&rx);
1424        // a later cargo-check error (E0599-class) flips authoritative red
1425        m.apply_event(&diag("file:///p/a.rs", 1, 0));
1426        assert_eq!(m.tree_state(), TreeState::Red);
1427        assert_eq!(
1428            m.last_verdict().provenance,
1429            VerdictProvenance::Authoritative
1430        );
1431        assert!(drain(&rx).contains(&StateEvent::BecameRed));
1432    }
1433
1434    #[test]
1435    fn empty_clean_pass_is_green() {
1436        // flycheck ended with zero diagnostics at all ⇒ cargo check passed.
1437        let mut m = model();
1438        m.apply_event(&LspEvent::FlycheckEnded);
1439        assert_eq!(m.tree_state(), TreeState::Green);
1440        assert_eq!(
1441            m.last_verdict().provenance,
1442            VerdictProvenance::Authoritative
1443        );
1444    }
1445
1446    #[test]
1447    fn forget_last_rustc_red_file_flips_green_post_pass() {
1448        let mut m = model();
1449        let rx = m.subscribe();
1450        m.apply_event(&diag("file:///p/keep.rs", 0, 0));
1451        m.apply_event(&diag("file:///p/scratch.rs", 1, 0)); // rustc error
1452        m.apply_event(&LspEvent::FlycheckEnded);
1453        assert_eq!(m.tree_state(), TreeState::Red);
1454        let _ = drain(&rx);
1455        m.forget_file("/p/scratch.rs");
1456        assert_eq!(m.tree_state(), TreeState::Green);
1457        assert!(drain(&rx).contains(&StateEvent::BecameGreen { identity: ident() }));
1458    }
1459
1460    #[test]
1461    fn advisory_channel_receives_and_prunes() {
1462        let mut m = model();
1463        let a1 = m.subscribe_advisory();
1464        {
1465            let a2 = m.subscribe_advisory();
1466            m.apply_event(&diag("file:///p/a.rs", 0, 1));
1467            assert!(!drain(&a1).is_empty());
1468            assert!(!drain(&a2).is_empty());
1469        }
1470        // a2 dropped — emit must not panic, a1 still live
1471        m.apply_event(&LspEvent::FlycheckEnded);
1472        let got = drain(&a1);
1473        assert!(
1474            got.iter()
1475                .any(|v| v.provenance == VerdictProvenance::Authoritative)
1476        );
1477    }
1478
1479    #[test]
1480    fn non_file_uri_ignored() {
1481        let mut m = model();
1482        m.apply_event(&diag("untitled:Untitled-1", 5, 5));
1483        assert_eq!(m.file_state("untitled:Untitled-1"), None);
1484        assert_eq!(m.tree_state(), TreeState::Red);
1485    }
1486
1487    // -----------------------------------------------------------------------
1488    // FIELD FINDING #5 (#49) — TF_DEBOUNCE_MS env override semantics
1489    //
1490    // Per-test env mutation is unavoidable here (the function reads the
1491    // process env on purpose) — `set_var` is `unsafe` on Edition 2024 due
1492    // to the multi-threaded-read hazard; serializing tests + the
1493    // remove_var-on-exit guard below keeps them deterministic.
1494    // -----------------------------------------------------------------------
1495
1496    /// RAII guard: set `TF_DEBOUNCE_MS` for the test's scope, restore the
1497    /// pre-test value (or unset) on drop.
1498    struct EnvGuard {
1499        prev: Option<String>,
1500    }
1501
1502    impl EnvGuard {
1503        fn set(value: &str) -> Self {
1504            let prev = std::env::var("TF_DEBOUNCE_MS").ok();
1505            // SAFETY: tests gated on `#[cfg(test)]`; this module's tests do
1506            // not spawn threads that read the env.
1507            unsafe { std::env::set_var("TF_DEBOUNCE_MS", value) };
1508            Self { prev }
1509        }
1510    }
1511
1512    impl Drop for EnvGuard {
1513        fn drop(&mut self) {
1514            unsafe {
1515                match &self.prev {
1516                    Some(v) => std::env::set_var("TF_DEBOUNCE_MS", v),
1517                    None => std::env::remove_var("TF_DEBOUNCE_MS"),
1518                }
1519            }
1520        }
1521    }
1522
1523    #[test]
1524    fn watch_debounce_defaults_when_env_unset() {
1525        // Belt-and-braces: clear first, then probe the default.
1526        let _g = EnvGuard::set("");
1527        unsafe { std::env::remove_var("TF_DEBOUNCE_MS") };
1528        let d = resolve_watch_debounce();
1529        assert_eq!(d, Duration::from_millis(150), "default = 150ms");
1530        // Restore via guard's Drop (sets to "", which the parser rejects →
1531        // also falls back to default — that's fine for the next test).
1532    }
1533
1534    #[test]
1535    fn watch_debounce_honors_valid_env_override() {
1536        let _g = EnvGuard::set("300");
1537        assert_eq!(resolve_watch_debounce(), Duration::from_millis(300));
1538    }
1539
1540    #[test]
1541    fn watch_debounce_rejects_zero_and_garbage() {
1542        // Zero ⇒ spinloop hazard, rejected → default.
1543        let _g0 = EnvGuard::set("0");
1544        assert_eq!(resolve_watch_debounce(), Duration::from_millis(150));
1545        drop(_g0);
1546        // Non-numeric ⇒ rejected → default. (No fail-loud; the CLI parser
1547        // rejects bad input upstream; here we fail-safe on accidental env
1548        // pollution.)
1549        let _g1 = EnvGuard::set("nope");
1550        assert_eq!(resolve_watch_debounce(), Duration::from_millis(150));
1551    }
1552
1553    #[test]
1554    fn watch_debounce_accepts_large_values_for_flicker_free_refactor() {
1555        // Sweet spot per F5 is 300-1000ms but dogfood may want longer for
1556        // a noisy multi-file refactor; no artificial upper bound.
1557        let _g = EnvGuard::set("2500");
1558        assert_eq!(resolve_watch_debounce(), Duration::from_millis(2500));
1559    }
1560
1561    #[test]
1562    fn placeholder_identity_is_sentinel_dev_wasm() {
1563        let id = placeholder_identity();
1564        assert_eq!(id.profile, Profile::Dev);
1565        assert_eq!(id.target.as_str(), "wasm32-unknown-unknown");
1566        assert_eq!(id.source_tree, id.cargo_lock); // all the same sentinel
1567    }
1568
1569    // -----------------------------------------------------------------------
1570    // FIELD FINDING #2 — diagnostic storage / aggregate view / forget sync
1571    // -----------------------------------------------------------------------
1572
1573    #[test]
1574    fn diagnostics_accumulate_per_file_and_aggregate() {
1575        let mut m = model();
1576        // Two files, each with one rustc error + a position.
1577        m.apply_event(&diag_rich(
1578            "file:///p/src/a.rs",
1579            vec![mk_diag(
1580                "/p/src/a.rs",
1581                10,
1582                3,
1583                cargoless_proto::Severity::Error,
1584                Some("E0277"),
1585                "the trait bound not satisfied",
1586                Some("rustc"),
1587            )],
1588        ));
1589        m.apply_event(&diag_rich(
1590            "file:///p/src/b.rs",
1591            vec![mk_diag(
1592                "/p/src/b.rs",
1593                1,
1594                1,
1595                cargoless_proto::Severity::Warning,
1596                Some("unused_imports"),
1597                "unused import",
1598                Some("rust-analyzer"),
1599            )],
1600        ));
1601        let all = m.all_diagnostics();
1602        assert_eq!(all.len(), 2, "two diagnostics, one per file");
1603        assert_eq!(m.file_diagnostics("/p/src/a.rs").len(), 1);
1604        assert_eq!(m.file_diagnostics("/p/src/b.rs").len(), 1);
1605        // Codes survived end-to-end (the FIELD FINDING #2 ask).
1606        let codes: Vec<&str> = all.iter().filter_map(|d| d.code.as_deref()).collect();
1607        assert!(codes.contains(&"E0277"));
1608        assert!(codes.contains(&"unused_imports"));
1609    }
1610
1611    #[test]
1612    fn later_publish_replaces_prior_per_file() {
1613        // RA's contract: publishDiagnostics REPLACES the prior list for that
1614        // file. Test that the model mirrors that (no stale errors after a
1615        // fix).
1616        let mut m = model();
1617        m.apply_event(&diag_rich(
1618            "file:///p/src/a.rs",
1619            vec![mk_diag(
1620                "/p/src/a.rs",
1621                1,
1622                1,
1623                cargoless_proto::Severity::Error,
1624                Some("E0277"),
1625                "first",
1626                Some("rustc"),
1627            )],
1628        ));
1629        assert_eq!(m.file_diagnostics("/p/src/a.rs").len(), 1);
1630        // A second publish with a different diagnostic supersedes the first.
1631        m.apply_event(&diag_rich(
1632            "file:///p/src/a.rs",
1633            vec![mk_diag(
1634                "/p/src/a.rs",
1635                2,
1636                2,
1637                cargoless_proto::Severity::Error,
1638                Some("E0308"),
1639                "second",
1640                Some("rustc"),
1641            )],
1642        ));
1643        let now = m.file_diagnostics("/p/src/a.rs");
1644        assert_eq!(now.len(), 1);
1645        assert_eq!(now[0].code.as_deref(), Some("E0308"));
1646        // An empty publish CLEARS the file's diagnostics — the user fixed it.
1647        m.apply_event(&diag_rich("file:///p/src/a.rs", vec![]));
1648        assert!(m.file_diagnostics("/p/src/a.rs").is_empty());
1649    }
1650
1651    #[test]
1652    fn forget_file_drops_diagnostics_too() {
1653        let mut m = model();
1654        m.apply_event(&diag_rich(
1655            "file:///p/src/a.rs",
1656            vec![mk_diag(
1657                "/p/src/a.rs",
1658                1,
1659                1,
1660                cargoless_proto::Severity::Error,
1661                Some("E0277"),
1662                "x",
1663                Some("rustc"),
1664            )],
1665        ));
1666        assert!(!m.all_diagnostics().is_empty());
1667        m.forget_file("/p/src/a.rs");
1668        assert!(
1669            m.all_diagnostics().is_empty(),
1670            "deleted file's diagnostics must be evicted from aggregate"
1671        );
1672    }
1673
1674    #[test]
1675    fn aggregate_diagnostics_are_path_ordered() {
1676        // BTreeMap iteration is sorted by key — verify the aggregate flatten
1677        // preserves a deterministic file order so the CLI's `watch` mode
1678        // doesn't flicker between renders.
1679        let mut m = model();
1680        m.apply_event(&diag_rich(
1681            "file:///p/z.rs",
1682            vec![mk_diag(
1683                "/p/z.rs",
1684                1,
1685                1,
1686                cargoless_proto::Severity::Error,
1687                None,
1688                "z",
1689                Some("rustc"),
1690            )],
1691        ));
1692        m.apply_event(&diag_rich(
1693            "file:///p/a.rs",
1694            vec![mk_diag(
1695                "/p/a.rs",
1696                1,
1697                1,
1698                cargoless_proto::Severity::Error,
1699                None,
1700                "a",
1701                Some("rustc"),
1702            )],
1703        ));
1704        let all = m.all_diagnostics();
1705        assert_eq!(all.len(), 2);
1706        // /p/a.rs sorts before /p/z.rs.
1707        assert_eq!(all[0].file_path, std::path::PathBuf::from("/p/a.rs"));
1708        assert_eq!(all[1].file_path, std::path::PathBuf::from("/p/z.rs"));
1709    }
1710
1711    #[test]
1712    fn collect_rs_files_skips_target_git_and_gitignored() {
1713        let base = std::env::temp_dir().join(format!("tf-model-walk-{}", std::process::id()));
1714        let _ = fs::remove_dir_all(&base);
1715        let mk = |rel: &str, body: &str| {
1716            let p = base.join(rel);
1717            fs::create_dir_all(p.parent().unwrap()).unwrap();
1718            fs::write(&p, body).unwrap();
1719        };
1720        mk(".gitignore", "ignored.rs\n");
1721        mk("src/lib.rs", "");
1722        mk("src/nested/m.rs", "");
1723        mk("ignored.rs", "");
1724        mk("target/debug/build.rs", "");
1725        mk(".git/hooks/pre.rs", "");
1726        mk("README.md", "");
1727
1728        let root = fs::canonicalize(&base).unwrap();
1729        let ignore = crate::watcher::IgnoreRules::for_root(&root);
1730        let mut got: Vec<String> = collect_rs_files(&root, &ignore)
1731            .into_iter()
1732            .map(|p| {
1733                p.strip_prefix(&root)
1734                    .unwrap()
1735                    .to_string_lossy()
1736                    .replace('\\', "/")
1737            })
1738            .collect();
1739        got.sort();
1740        assert_eq!(
1741            got,
1742            vec!["src/lib.rs".to_string(), "src/nested/m.rs".to_string()]
1743        );
1744        let _ = fs::remove_dir_all(&base);
1745    }
1746}