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(¤t);
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(¤t);
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(¤t).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}