Skip to main content

devscout_rs/
mapcmd.rs

1// Assemble devscout's `map`: walk -> parallel extract -> resolve -> artifacts,
2// incremental. Glues walk.rs, extract.rs, graph.rs (which calls resolve.rs
3// internally) and manifest.rs into a read-plan -> parallel-reparse -> merge ->
4// write cycle, with rayon doing the parse fan-out (parsing is the only part of a
5// cold map slow enough to need it -- resolution itself is pure map lookups, see
6// graph.rs's own doc comment on `rebuild_graph`).
7//
8// ## Scoped-merge behavior
9//
10// A scoped `map <dir>` MERGES into the manifest instead of replacing it whole. A
11// naive build that only iterates the scoped walk when constructing `entries`
12// would silently drop anything outside `dirs` that was in a PRIOR manifest -- a
13// data-destroying footgun (a full-repo manifest built once, then narrowed by one
14// `map src/` call, would permanently lose every entry outside `src/` on the next
15// write). This module partitions the prior manifest by scope membership BEFORE
16// touching anything (`is_within_scope`): in-scope entries go through the normal
17// reuse/reparse/prune cycle; out-of-scope entries are carried through untouched,
18// never counted in preserved/downgraded/added/removed/parsed_ast (this run made
19// no decision about them), and appended to the written manifest after the
20// in-scope entries in their original prior-manifest order. See `map_repo`'s merge
21// step and `MapReport::merged_out_of_scope`.
22//
23// ## Content-hash reuse (`MapOptions::hash_reuse`, the binary's default)
24//
25// Reuse keyed on a file's content hash instead of its mtime, so a fresh
26// git-worktree checkout (new mtimes, byte-identical content) does not force a
27// full reparse of every C# file -- exactly the case the shared,
28// git-common-dir-keyed manifest/graph was built to serve: every linked worktree
29// of one repo already shares one manifest and one graph, but a worktree that
30// never ran `devscout map` before would otherwise repeat the full parse cost on
31// first run because its files carry the checkout's OWN mtimes, not the mtimes
32// recorded by whichever worktree wrote the shared manifest first.
33//
34// Confined entirely to this module: when enabled, the SAME i64 slot used for
35// `mtime` (and this crate's `graph::GraphFile.mtime` / `graph::OrderedMap<i64>`
36// fragments-index) instead carries the first 8 bytes of the file's SHA-256
37// digest, big-endian, as a cache key -- not a timestamp. This is a deliberate,
38// documented repurposing of an existing i64 slot rather than a schema change (no
39// new manifest or graph field): a manifest or fragments-index written in
40// hash-reuse mode has NUMERICALLY MEANINGLESS `mtime` values by wall-clock
41// standards, by design. Collision risk (two distinct files' 8-byte SHA-256
42// prefixes colliding) is astronomically below any concern for a reuse cache key,
43// which this is -- not a content-addressing guarantee. See `hashkey::cache_key`
44// and `cache_key_for`.
45//
46// The BINARY defaults to hash reuse (`MapOptions::from_env`), with
47// `SCOUT_MTIME_REUSE=1` as the escape hatch back to mtime keying.
48// `MapOptions::default()` STAYS `hash_reuse: false` (mtime keying). Two
49// consequences worth knowing at the call site: (1) the first `map` over a
50// manifest written in the other mode cannot match any stored key, so it repays
51// one full reparse and then converges; (2) alternating the two modes against the
52// same repo flips the keys back and forth, costing a full reparse each crossing.
53//
54// ## Parallel extraction determinism
55//
56// rayon fans the parse-and-extract step out with `plan.par_iter().map(..)` over
57// `Vec<PlanEntry>` (an `IndexedParallelIterator`), collected into a
58// `Vec<Option<ParsedFile>>` that is the SAME LENGTH as `plan` -- one slot per
59// plan entry, `None` for anything that isn't reparsed. `collect()` on an indexed
60// rayon iterator reassembles results at their ORIGINAL index regardless of which
61// worker thread finished which file first (this, not any property of thread
62// scheduling, is what makes the collected `Vec`'s order deterministic). But this
63// module goes one step further and does not even lean on that alone: every
64// extraction result is immediately folded into a `HashMap<String, _>`
65// (order-erasing) BEFORE any artifact-affecting step consumes it, and every later
66// step (the manifest `entries` build, `graph::GraphFile` list, `resolve_graph`'s
67// eventual input via `graph::rebuild_graph`) iterates `plan` -- i.e. `files`,
68// i.e. walk order -- not the parallel result `Vec`. So artifact byte-order is
69// driven exclusively by walk order; a hypothetical rayon version that scrambled
70// `collect()` order would still produce byte-identical artifacts here, because
71// nothing downstream reads the parallel `Vec`'s position at all.
72//
73// The `--refresh` flag is a no-op alias: the manifest merge with the prior run
74// always happens regardless of it, and callers strip `--refresh` out of
75// `scope_dirs` before calling `map_repo`. This module does not parse CLI flags at
76// all -- that is cli.rs's job.
77
78use std::collections::{HashMap, HashSet};
79use std::fs;
80use std::io;
81use std::path::Path;
82use std::time::{Instant, UNIX_EPOCH};
83
84use rayon::prelude::*;
85
86use crate::extract;
87use crate::graph::{self, AnyFragment, Fragment, GraphFile};
88use crate::manifest::{self, Value};
89use crate::markup;
90use crate::parse;
91use crate::project;
92use crate::repo;
93use crate::walk;
94
95fn io_err<E: std::fmt::Display>(e: E) -> io::Error {
96    io::Error::other(e.to_string())
97}
98
99// Whether `rel` is a C# source file (`.cs`).
100fn is_csharp(rel: &str) -> bool {
101    rel.ends_with(".cs")
102}
103
104// The files that contribute a graph fragment: C# (defs, refs, member names),
105// markup (`x:Class`/`x:Name`, `.resw` keys -- names only) and TS/JS (imports,
106// exported declarations, call/JSX/dispatch references). The TS/JS arm is
107// `parse::ts_grammar_for`'s own extension gate rather than a second list -- a
108// file this predicate calls a graph source but the reparse dispatch below cannot
109// hand a grammar would be a rel `graph_files` names and `rebuild_graph` can never
110// find a fragment for, which `index_is_stale`'s length check turns into a
111// permanent rebuild.
112fn is_graph_source(rel: &str) -> bool {
113    is_csharp(rel) || markup::is_markup(rel) || parse::ts_grammar_for(rel).is_some()
114}
115
116// LOSSY UTF-8 decoding (invalid byte sequences become U+FFFD), never failing on
117// bad encoding, only on a real I/O failure (missing file, permission). Both this
118// crate's extraction worker and `default_purpose` read this way;
119// `fs::read_to_string` is the wrong primitive here -- it is STRICT UTF-8 and
120// returns `Err` on the first invalid byte, silently dropping any file with even
121// one non-UTF-8 byte (e.g. a stray CP1252 em-dash inside a `//` comment: the
122// lossy read substitutes one U+FFFD and produces a normal signature, while
123// `fs::read_to_string` errors on it outright). Losing a file this way is not just
124// a missed purpose -- `graph::index_is_stale`'s `index.len() != graph_files.len()`
125// check makes the loss STICKY: a C# file the walk finds but the fragments cache
126// can never hold forces `changed == true` on every subsequent run forever,
127// defeating incremental reuse for the whole graph, not just this one file.
128fn read_source_lossy(path: &Path) -> Option<String> {
129    fs::read(path)
130        .ok()
131        .map(|bytes| String::from_utf8_lossy(&bytes).into_owned())
132}
133
134// ---------------------------------------------------------------------------
135// Content-hash cache key (see module header).
136// ---------------------------------------------------------------------------
137
138mod hashkey {
139    use sha2::{Digest, Sha256};
140
141    // First 8 bytes of the file's SHA-256 digest, big-endian, as an i64 -- used
142    // ONLY as an opaque reuse-cache key (see module header); never exposed as a
143    // content hash proper (no hex encoding kept, no full digest retained).
144    pub fn cache_key(bytes: &[u8]) -> i64 {
145        let digest = Sha256::digest(bytes);
146        i64::from_be_bytes(digest[0..8].try_into().expect("sha256 digest is 32 bytes"))
147    }
148
149    #[cfg(test)]
150    mod tests {
151        use super::*;
152
153        #[test]
154        fn same_bytes_produce_the_same_key() {
155            assert_eq!(cache_key(b"hello"), cache_key(b"hello"));
156        }
157
158        #[test]
159        fn different_bytes_produce_different_keys() {
160            assert_ne!(cache_key(b"hello"), cache_key(b"world"));
161        }
162    }
163}
164
165/// Options for `map_repo` -- see the module header.
166#[derive(Debug, Clone, Copy, Default)]
167pub struct MapOptions {
168    /// Content-hash reuse (module header). `MapOptions::default()` keeps `false`
169    /// (mtime keying); the binary's env default is `true` -- see `from_env`.
170    pub hash_reuse: bool,
171}
172
173impl MapOptions {
174    /// Binary default: hash reuse ON. Exactly `SCOUT_MTIME_REUSE=1` drops back to
175    /// mtime keying; anything else (including unset) keeps hash reuse. The retired
176    /// opt-in `SCOUT_HASH_REUSE` is deliberately not read any more -- it named what
177    /// is now the default. A free function rather than folded into `map_repo`
178    /// itself so tests (and any future caller) can construct `MapOptions` directly
179    /// -- deterministic, no process-env mutation shared across parallel `cargo
180    /// test` threads.
181    pub fn from_env() -> Self {
182        MapOptions {
183            hash_reuse: std::env::var("SCOUT_MTIME_REUSE").as_deref() != Ok("1"),
184        }
185    }
186}
187
188/// Counts + timing `devscout map`'s CLI line reports, plus the scoped-merge
189/// bookkeeping. Every field but `total_manifest_entries`/`merged_out_of_scope`
190/// corresponds to a term in the one-line summary; see `summary_line`.
191#[derive(Debug, Clone)]
192pub struct MapReport {
193    /// The scope value.
194    pub scope: Vec<String>,
195    /// Files this run actually walked and (re)decided a manifest entry for.
196    /// This is the IN-SCOPE count only; see `total_manifest_entries` for the
197    /// merged total actually written to disk.
198    pub scoped_file_count: usize,
199    /// `scoped_file_count` + `merged_out_of_scope` -- the manifest entry count
200    /// actually written this run. Equals `scoped_file_count` whenever the prior
201    /// manifest had no out-of-scope entries (always true for an unscoped/full-repo
202    /// run, so this only differs from `scoped_file_count` on a genuinely scoped
203    /// `map <dir>` against a manifest that already covered more).
204    pub total_manifest_entries: usize,
205    /// Prior entries outside this run's `scope`, carried through untouched. A
206    /// naive whole-manifest replacement would drop these; this count is exactly
207    /// what that approach would have destroyed.
208    pub merged_out_of_scope: usize,
209    /// The preserved value.
210    pub preserved: usize,
211    /// The downgraded value.
212    pub downgraded: usize,
213    /// The added value.
214    pub added: usize,
215    /// In-scope prior entries this run's walk no longer produced -- legitimate
216    /// pruning (deleted, renamed, or newly excluded by walk.rs's skip/extension
217    /// rules), unaffected by the scoped merge: an out-of-scope prior entry is
218    /// never a candidate for removal, merged or not.
219    pub removed: usize,
220    /// The parsed ast value.
221    pub parsed_ast: usize,
222    /// The graph rebuilt value.
223    pub graph_rebuilt: bool,
224    /// The graph seconds value.
225    pub graph_seconds: f64,
226    /// The graph def count value.
227    pub graph_def_count: Option<usize>,
228    /// The graph edge count value.
229    pub graph_edge_count: Option<usize>,
230}
231
232impl MapReport {
233    /// The one-line `out` string `devscout map` reports. On a genuinely scoped
234    /// run this describes the SCOPED work (`scoped_file_count`/`scope`), which is
235    /// what this run actually decided; the out-of-scope merge is a silent,
236    /// additional safety property the summary line does not mention.
237    pub fn summary_line(&self) -> String {
238        let graph_note = match (
239            self.graph_rebuilt,
240            self.graph_def_count,
241            self.graph_edge_count,
242        ) {
243            (true, Some(defs), Some(edges)) => {
244                format!(
245                    "graph rebuilt in {:.2}s ({defs} defs, {edges} edges)",
246                    self.graph_seconds
247                )
248            }
249            _ => "graph unchanged".to_string(),
250        };
251        format!(
252            "mapped {} files under {} (preserved {} agent purposes, downgraded {} changed, {} new, {} removed, {} ast signatures); {graph_note}",
253            self.scoped_file_count,
254            self.scope.join(", "),
255            self.preserved,
256            self.downgraded,
257            self.added,
258            self.removed,
259            self.parsed_ast,
260        )
261    }
262}
263
264// ---------------------------------------------------------------------------
265// `carries_best_source` -- an unchanged cache key alone doesn't justify reuse: a
266// C# or TS/JS entry written before its language had an AST path still carries a
267// heuristic purpose, and reusing it would freeze the manifest on the weaker
268// source forever. Reuse holds only when the stored source is already the best
269// this run could produce. The ast-capable predicate is `is_csharp(rel) ||
270// parse::is_ts_js(rel)`: both languages have an AST path, so both expect "ast" as
271// their best source; every other extension (docs, config, ...) expects
272// "heuristic".
273//
274// For TS/JS specifically, "ast-none" ALSO counts as best -- a
275// zero-export/parse-failure file already got the AST worker's final answer
276// (None), and re-tagging it "heuristic" would make this function treat it as
277// not-best forever, resending it to the AST worker on every map run. C# is
278// completely untouched: it never produces "ast-none" (this module's entries-build
279// below only ever writes that source for a TS/JS rel), so its own branch is
280// unchanged.
281// ---------------------------------------------------------------------------
282
283fn carries_best_source(entry: &Value, rel: &str) -> bool {
284    let source = entry
285        .get("source")
286        .and_then(Value::as_str)
287        .unwrap_or("heuristic");
288    if source == "agent" {
289        return true;
290    }
291    if parse::is_ts_js(rel) {
292        return source == "ast" || source == "ast-none";
293    }
294    source == if is_csharp(rel) { "ast" } else { "heuristic" }
295}
296
297// The `mtime` field read for the reuse comparison. Under content-hash reuse this
298// same JSON field instead carries a content-hash cache key (module header) -- the
299// comparison is identical either way, just against a differently-sourced i64.
300fn entry_cache_key(entry: &Value) -> Option<i64> {
301    match entry.get("mtime") {
302        Some(Value::Number(n)) => n.as_i64(),
303        _ => None,
304    }
305}
306
307// A reused entry with `source` defaulted to `"heuristic"`; every OTHER field
308// (including any the manifest schema doesn't know about -- manifest.rs's own
309// header documents the manifest as fully schema-agnostic) passes through
310// untouched. If `entry` already has a `source` key, its VALUE is overwritten in
311// place (position preserved); if `entry` has no `source` key, a new one is
312// appended at the end.
313fn with_ensured_source(entry: &Value) -> Value {
314    let fields = entry.as_object().unwrap_or(&[]);
315    let source = entry
316        .get("source")
317        .and_then(Value::as_str)
318        .unwrap_or("heuristic")
319        .to_string();
320    let mut out: Vec<(String, Value)> = Vec::with_capacity(fields.len() + 1);
321    let mut replaced = false;
322    for (k, v) in fields {
323        if k == "source" {
324            out.push((k.clone(), Value::string(source.clone())));
325            replaced = true;
326        } else {
327            out.push((k.clone(), v.clone()));
328        }
329    }
330    if !replaced {
331        out.push(("source".to_string(), Value::string(source)));
332    }
333    Value::Object(out)
334}
335
336// Does `rel` (a manifest key, `/`-joined repo-relative path) fall under one of
337// `scope`'s directories? Mirrors the SAME containment `list_source_files` uses to
338// decide what to walk (`"."` covers everything; otherwise `dir` itself or
339// anything under `dir/`) -- so a prior entry is "in scope" exactly when THIS
340// run's walk could have produced it, independent of whether it actually did. That
341// independence is the whole point: it is what lets `map_repo` tell "genuinely
342// deleted (in scope, not in this run's files)" apart from "never looked at this
343// run (out of scope entirely)" -- a distinction a plain "is this rel in
344// `entries`" test cannot make, because that is true for neither case.
345fn is_within_scope(rel: &str, scope: &[String]) -> bool {
346    scope
347        .iter()
348        .any(|d| d == "." || rel == d || rel.starts_with(&format!("{d}/")))
349}
350
351// mtime keying (`hash_reuse == false`): the file's modification time in
352// milliseconds, `0` on any stat failure (caught, not propagated -- a file that
353// vanished between the walk and the stat is not fatal to the whole run). The
354// value is computed as a double (`sec * 1000 + nsec / 1e6`) and then floored,
355// deliberately: near-millisecond-boundary nanosecond values can round UP in that
356// float math, and reproducing that exact pipeline keeps the cache key stable
357// against manifests keyed the same way.
358//
359// content-hash reuse (`hash_reuse == true`): the file's content-hash cache key
360// (module header) instead, `0` on any read failure -- same fail-open shape.
361fn cache_key_for(root: &Path, rel: &str, opts: &MapOptions) -> i64 {
362    let abs = root.join(rel);
363    if opts.hash_reuse {
364        match fs::read(&abs) {
365            Ok(bytes) => hashkey::cache_key(&bytes),
366            Err(_) => 0,
367        }
368    } else {
369        match fs::metadata(&abs).and_then(|m| m.modified()) {
370            Ok(t) => match t.duration_since(UNIX_EPOCH) {
371                Ok(d) => {
372                    let ms = (d.as_secs() as f64) * 1000.0 + f64::from(d.subsec_nanos()) / 1e6;
373                    ms.floor() as i64
374                }
375                Err(_) => 0,
376            },
377            Err(_) => 0,
378        }
379    }
380}
381
382struct PlanEntry {
383    rel: String,
384    cache_key: i64,
385    prior_entry: Option<Value>,
386    reusable: bool,
387}
388
389// One reparsed file's result, tagged by which grammar produced it -- the C#
390// branch (purpose + graph fragment, via `extract::extract`) or the TS/JS branch
391// (purpose + TS reference fragment, via `extract::extract_ts_file`), from the
392// same parse. Kept as an enum rather than one widened struct so a TS/JS result
393// can never accidentally reach `graph::fragment_from_extraction` below, and vice
394// versa.
395enum ParsedFile {
396    CSharp(String, extract::Extraction),
397    // The purpose AND the reference fragment, off ONE parse. The purpose is
398    // `None` for a zero-export file (the heuristic text stands); the fragment is
399    // written either way, for the same reason the C# branch writes an empty
400    // fragment -- a rel absent from the cache makes `index_is_stale` see a
401    // permanent mismatch and rebuild on every run.
402    TsJs(String, Option<String>, extract::TsFragment),
403    // A markup or resource file: a fragment carrying names and nothing else, and
404    // no purpose (the heuristic one stands).
405    Markup(String, Fragment),
406}
407
408/// Runs `devscout map`, assembled from walk.rs (walk), extract.rs (extract),
409/// graph.rs (resolve + graph) and manifest.rs. `scope_dirs` is the CLI's
410/// positional dir arguments with `--refresh` already stripped (module header) --
411/// an empty slice means "no explicit scope".
412#[allow(
413    clippy::too_many_lines,
414    reason = "one ordered pipeline stage by stage (walk, extract, resolve, manifest); the stages share state that would only be split apart by an artificial seam"
415)]
416pub fn map_repo(root: &Path, scope_dirs: &[String], opts: MapOptions) -> io::Result<MapReport> {
417    let scope = manifest::scope_for(
418        root,
419        if scope_dirs.is_empty() {
420            None
421        } else {
422            Some(scope_dirs)
423        },
424    )
425    .map_err(io_err)?;
426    let files = walk::list_source_files(root, &scope)?;
427
428    // A missing manifest, or one with no `entries` object, both collapse to "no
429    // prior entries" (no error either way; `find_in_manifest`'s stricter
430    // behavior on a missing `entries` key does NOT apply here -- `map` never
431    // errors on this, only `find` does).
432    let prior_manifest = manifest::read_manifest(root).map_err(io_err)?;
433    let prior_entries: Vec<(String, Value)> = prior_manifest
434        .as_ref()
435        .and_then(|m| m.get("entries"))
436        .and_then(Value::as_object)
437        .map(|s| s.to_vec())
438        .unwrap_or_default();
439    let prior_index: HashMap<String, Value> = prior_entries.iter().cloned().collect();
440
441    // The cheap mtime-only index (`graph::read_fragments_index`) -- read once,
442    // reused for every file's fragment-freshness check below AND for the
443    // `changed` decision further down, so even a fully unchanged run reads it
444    // only once.
445    let graph_index = graph::read_fragments_index(root);
446
447    let plan: Vec<PlanEntry> = files
448        .iter()
449        .map(|rel| {
450            let cache_key = cache_key_for(root, rel, &opts);
451            let fragment_ok =
452                !is_graph_source(rel) || graph_index.get(rel.as_str()) == Some(&cache_key);
453            let prior_entry = prior_index.get(rel.as_str()).cloned();
454            let reusable = prior_entry
455                .as_ref()
456                .map(|e| {
457                    entry_cache_key(e) == Some(cache_key)
458                        && carries_best_source(e, rel)
459                        && fragment_ok
460                })
461                .unwrap_or(false);
462            PlanEntry {
463                rel: rel.clone(),
464                cache_key,
465                prior_entry,
466                reusable,
467            }
468        })
469        .collect();
470
471    // ---- parallel reparse (rayon) --------------------------------------
472    // See the module header's "Parallel extraction determinism" section:
473    // `map` (not `filter`) over `plan.par_iter()` keeps this an
474    // IndexedParallelIterator end to end, and the result is folded into
475    // order-erasing HashMaps immediately below, before anything
476    // artifact-affecting reads it.
477    //
478    // A reparse candidate is C#, markup or TS/JS -- each file is dispatched to
479    // the C# branch (purpose + graph fragment) or the TS/JS branch (purpose + TS
480    // reference fragment) on the SAME parse. Both branches produce a fragment;
481    // which SHAPE it is decides which resolver sees it, and that routing is the
482    // `AnyFragment` tag, never this dispatch.
483    let per_file: Vec<Option<ParsedFile>> = plan
484        .par_iter()
485        .map(|p| {
486            if p.reusable {
487                return None;
488            }
489            if is_csharp(&p.rel) {
490                return read_source_lossy(&root.join(&p.rel))
491                    .map(|src| ParsedFile::CSharp(p.rel.clone(), extract::extract(&src)));
492            }
493            // Markup never reaches a grammar -- the scan is a line walk -- but it
494            // obeys the same reuse decision every other graph source obeys, so it
495            // is dispatched from the same place.
496            if markup::is_markup(&p.rel) {
497                return graph::markup_fragment(root, &p.rel)
498                    .map(|f| ParsedFile::Markup(p.rel.clone(), f));
499            }
500            let grammar = parse::ts_grammar_for(&p.rel)?;
501            // The leading-comment prefix rides inside `extract_ts_file` (not the
502            // pure `extract_ts_purpose`): for every TS/JS file that yields an AST
503            // purpose it re-checks `default_purpose_detailed` and prefixes the
504            // leading comment text when that heuristic match was comment-derived.
505            // The same call returns the reference fragment off that one parse, and
506            // a parse failure drops the file out of BOTH outputs.
507            let src = read_source_lossy(&root.join(&p.rel))?;
508            let ts = extract::extract_ts_file(root, &p.rel, &src, grammar)?;
509            Some(ParsedFile::TsJs(p.rel.clone(), ts.purpose, ts.fragment))
510        })
511        .collect();
512
513    let mut purposes: HashMap<String, String> = HashMap::new();
514    let mut fresh_fragments: HashMap<String, AnyFragment> = HashMap::new();
515    for parsed in per_file.into_iter().flatten() {
516        match parsed {
517            ParsedFile::CSharp(rel, extraction) => {
518                if let Some(p) = &extraction.purpose {
519                    purposes.insert(rel.clone(), p.clone());
520                }
521                // Cached even when the purpose is None (no namespace-level types):
522                // skipping empty fragments here would make `index_is_stale`'s "did
523                // the C# set change" check see a permanent mismatch for genuinely
524                // type-free files, forcing a rebuild on every run even when nothing
525                // changed.
526                fresh_fragments.insert(
527                    rel,
528                    AnyFragment::Cs(graph::fragment_from_extraction(&extraction)),
529                );
530            }
531            ParsedFile::Markup(rel, fragment) => {
532                fresh_fragments.insert(rel, AnyFragment::Cs(fragment));
533            }
534            ParsedFile::TsJs(rel, purpose, fragment) => {
535                if let Some(p) = purpose {
536                    purposes.insert(rel.clone(), p);
537                }
538                // Cached even when the file exports nothing, for the same reason
539                // the C# branch caches an empty fragment: a rel absent from
540                // `graph` makes the fragments-index reuse check see a permanent
541                // mismatch and rebuild on every run.
542                fresh_fragments.insert(rel, AnyFragment::Ts(fragment));
543            }
544        }
545    }
546    // A plan entry with no `per_file` result (unreadable file, reusable, or not a
547    // reparse candidate at all -- e.g. a `.md`/`.json` file) simply has no
548    // `purposes`/`fresh_fragments` entry -- the loop below falls back to
549    // `default_purpose`, and `graph::rebuild_graph` drops it from the graph (a
550    // file with no fragment contributes nothing).
551
552    let mut entries: Vec<(String, Value)> = Vec::with_capacity(plan.len());
553    let mut preserved = 0usize;
554    let mut downgraded = 0usize;
555    let mut added = 0usize;
556
557    for p in &plan {
558        if p.reusable {
559            let merged = with_ensured_source(
560                p.prior_entry
561                    .as_ref()
562                    .expect("reusable implies a prior entry exists"),
563            );
564            if merged.get("source").and_then(Value::as_str) == Some("agent") {
565                preserved += 1;
566            }
567            entries.push((p.rel.clone(), merged));
568            continue;
569        }
570        if let Some(prior) = p.prior_entry.as_ref() {
571            let source = prior
572                .get("source")
573                .and_then(Value::as_str)
574                .unwrap_or("heuristic");
575            if source == "agent" {
576                downgraded += 1;
577            }
578        } else {
579            added += 1;
580        }
581        let value = match purposes.get(&p.rel) {
582            Some(sig) => Value::object(vec![
583                ("purpose", Value::string(sig.clone())),
584                ("mtime", Value::number(p.cache_key)),
585                ("source", Value::string("ast")),
586            ]),
587            None => {
588                // A TS/JS file whose AST pass yielded no purpose (zero-export or
589                // parse failure) is already the best this worker can produce this
590                // run -- "ast-none" carries the same heuristic purpose text as a
591                // plain heuristic result, but (unlike "heuristic")
592                // `carries_best_source` above recognises it as best-obtainable for
593                // TS/JS, so an unchanged mtime is reused on the next map instead of
594                // being resent for reparse. C# is untouched -- it still gets
595                // "heuristic".
596                let source = if parse::is_ts_js(&p.rel) {
597                    "ast-none"
598                } else {
599                    "heuristic"
600                };
601                Value::object(vec![
602                    (
603                        "purpose",
604                        Value::string(walk::default_purpose(root, &p.rel)),
605                    ),
606                    ("mtime", Value::number(p.cache_key)),
607                    ("source", Value::string(source)),
608                ])
609            }
610        };
611        entries.push((p.rel.clone(), value));
612    }
613
614    // Counts BOTH freshly-reparsed AND reused entries whose source is already
615    // "ast", over the (pre-merge, in-scope) `entries`.
616    let parsed_ast = entries
617        .iter()
618        .filter(|(_, v)| v.get("source").and_then(Value::as_str) == Some("ast"))
619        .count();
620
621    // In-scope prior entries this run's walk no longer produced -- see
622    // `removed`'s doc comment on `MapReport`. Scoped to a block so the
623    // borrow of `entries` ends before the out-of-scope merge loop mutates it.
624    let removed = {
625        let entries_index: HashSet<String> = entries.iter().map(|(k, _)| k.clone()).collect();
626        prior_entries
627            .iter()
628            .filter(|entry| is_within_scope(&entry.0, &scope) && !entries_index.contains(&entry.0))
629            .count()
630    };
631
632    let scoped_file_count = entries.len();
633
634    // Scoped merge (module header): every out-of-scope prior entry is carried
635    // through untouched, in its original prior-manifest order, appended after
636    // this run's in-scope entries -- never counted in
637    // preserved/downgraded/added/removed/parsed_ast above.
638    let mut merged_out_of_scope = 0usize;
639    for entry in &prior_entries {
640        if !is_within_scope(&entry.0, &scope) {
641            entries.push(entry.clone());
642            merged_out_of_scope += 1;
643        }
644    }
645    let total_manifest_entries = entries.len();
646    let indexed_file_set: HashSet<String> = entries.iter().map(|(k, _)| k.clone()).collect();
647
648    let built_at_head = manifest::git_head(root);
649    let manifest_value = Value::object(vec![
650        (
651            "built_at_head",
652            built_at_head
653                .clone()
654                .map(Value::string)
655                .unwrap_or(Value::Null),
656        ),
657        (
658            "scoped_dirs",
659            Value::array(scope.iter().cloned().map(Value::string).collect()),
660        ),
661        ("entries", Value::Object(entries)),
662    ]);
663    manifest::write_manifest(root, &manifest_value)?;
664
665    // Index-freshness sidecar, written every successful map run, the same cadence
666    // manifest.json's own built_at_head refreshes on.
667    let dirty = manifest::is_working_tree_dirty(root, &scope);
668    let dirty_indexed_files = manifest::dirty_indexed_files_at(root, &scope, &indexed_file_set);
669    manifest::write_index_state(
670        root,
671        built_at_head,
672        dirty,
673        &dirty_indexed_files,
674        total_manifest_entries as i64,
675    )?;
676
677    // Clear the `refresh-needed` flag if present.
678    let flag = repo::scout_dir(root).join("refresh-needed");
679    if flag.exists() {
680        fs::remove_file(&flag)?;
681    }
682
683    let graph_files: Vec<GraphFile> = plan
684        .iter()
685        .filter(|p| is_graph_source(&p.rel))
686        .map(|p| GraphFile {
687            rel: p.rel.clone(),
688            mtime: p.cache_key,
689        })
690        .collect();
691    // The repo's `.csproj` projects, rediscovered on every run: the scan is
692    // cheap (a handful of files, hand-scanned) next to the extraction above,
693    // and it is the only way to notice a csproj edit at all -- `.csproj` is
694    // not a `SOURCE_EXT`, so no walked file's mtime moves when one changes.
695    // `None` when the repo declares no project, which keeps graph.json's
696    // bytes exactly as they were.
697    let project_model = project::discover(root, &scope)?;
698
699    // `graph::index_is_stale` decides whether the graph must be rebuilt (any
700    // graph file's cache key changed, or the set of graph files changed size),
701    // reused here rather than re-implemented. The project model is the second
702    // half of that decision, for the reason above: `project::sidecar_differs`
703    // compares this run's model against the one the last rebuild persisted, so
704    // a `ProjectReference` added or dropped rebuilds the graph even though not
705    // one indexed file moved. Deliberately OR-ed, not short-circuited the
706    // other way: a repo with no csproj at all always answers false here and
707    // the whole check costs nothing.
708    let changed = graph::index_is_stale(&graph_index, &graph_files)
709        || project::sidecar_differs(root, project_model.as_ref());
710
711    let graph_start = Instant::now();
712    let outcome = graph::rebuild_graph(
713        root,
714        &graph_files,
715        &fresh_fragments,
716        changed,
717        project_model.as_ref(),
718    )?;
719    let graph_seconds = graph_start.elapsed().as_secs_f64();
720
721    let (graph_rebuilt, graph_def_count, graph_edge_count) = match &outcome {
722        graph::RebuildOutcome::Rebuilt(g) => (true, Some(g.defs.len()), Some(g.edges.len())),
723        graph::RebuildOutcome::NotRebuilt => (false, None, None),
724    };
725
726    Ok(MapReport {
727        scope,
728        scoped_file_count,
729        total_manifest_entries,
730        merged_out_of_scope,
731        preserved,
732        downgraded,
733        added,
734        removed,
735        parsed_ast,
736        graph_rebuilt,
737        graph_seconds,
738        graph_def_count,
739        graph_edge_count,
740    })
741}
742
743#[cfg(test)]
744mod tests {
745    use super::*;
746    use std::sync::atomic::{AtomicU64, Ordering};
747
748    static COUNTER: AtomicU64 = AtomicU64::new(0);
749
750    fn temp_dir(label: &str) -> std::path::PathBuf {
751        let n = COUNTER.fetch_add(1, Ordering::SeqCst);
752        let dir = std::env::temp_dir().join(format!(
753            "scout-mapcmd-test-{label}-{}-{n}",
754            std::process::id()
755        ));
756        fs::create_dir_all(&dir).expect("create temp dir");
757        dir
758    }
759
760    fn write_file(path: &Path, contents: &str) {
761        if let Some(parent) = path.parent() {
762            fs::create_dir_all(parent).expect("create parent dirs");
763        }
764        fs::write(path, contents).expect("write file");
765    }
766
767    // -- is_within_scope --------------------------------------------------
768
769    #[test]
770    fn dot_scope_covers_everything() {
771        assert!(is_within_scope("src/a.ts", &[".".to_string()]));
772        assert!(is_within_scope("a.ts", &[".".to_string()]));
773    }
774
775    #[test]
776    fn dir_scope_covers_itself_and_nested_paths_only() {
777        let scope = vec!["src/Foo".to_string()];
778        assert!(is_within_scope("src/Foo/A.cs", &scope));
779        assert!(is_within_scope("src/Foo/Sub/B.cs", &scope));
780        assert!(!is_within_scope("src/Bar/C.cs", &scope));
781        // A same-prefixed SIBLING directory ("src/FooBar") must not match --
782        // this is why the check requires the "/" boundary, not a bare
783        // `starts_with(d)`.
784        assert!(!is_within_scope("src/FooBar/D.cs", &scope));
785    }
786
787    #[test]
788    fn multiple_scope_dirs_union() {
789        let scope = vec!["src".to_string(), "docs".to_string()];
790        assert!(is_within_scope("src/a.ts", &scope));
791        assert!(is_within_scope("docs/readme.md", &scope));
792        assert!(!is_within_scope("config/settings.json", &scope));
793    }
794
795    // -- carries_best_source ------------------------------------------------
796
797    #[test]
798    fn agent_source_always_carries() {
799        let e = Value::object(vec![("source", Value::string("agent"))]);
800        assert!(carries_best_source(&e, "a.cs"));
801        assert!(carries_best_source(&e, "a.ts"));
802    }
803
804    // `carries_best_source`'s "ast-capable" predicate is `is_csharp(rel) ||
805    // parse::is_ts_js(rel)` -- both languages have an AST path, so both expect
806    // "ast" as their best source. `a.json`/`a.md`/etc. (neither predicate) expect
807    // "heuristic".
808    #[test]
809    fn ast_source_carries_for_csharp_and_ts_js() {
810        let e = Value::object(vec![("source", Value::string("ast"))]);
811        assert!(carries_best_source(&e, "a.cs"));
812        assert!(carries_best_source(&e, "a.ts"));
813        assert!(!carries_best_source(&e, "a.json"));
814    }
815
816    #[test]
817    fn heuristic_source_carries_only_for_non_ast_capable_extensions() {
818        let e = Value::object(vec![("source", Value::string("heuristic"))]);
819        assert!(!carries_best_source(&e, "a.cs"));
820        assert!(!carries_best_source(&e, "a.ts"));
821        assert!(carries_best_source(&e, "a.json"));
822    }
823
824    #[test]
825    fn missing_source_defaults_to_heuristic() {
826        let e = Value::object(vec![("purpose", Value::string("x"))]);
827        assert!(carries_best_source(&e, "a.json"));
828        assert!(!carries_best_source(&e, "a.cs"));
829        assert!(!carries_best_source(&e, "a.ts"));
830    }
831
832    // "ast-none" carries the best source for TS/JS (no more forced resend on
833    // every rerun), but is NOT a valid best source for C# -- that path never
834    // produces it.
835    #[test]
836    fn ast_none_source_carries_for_ts_js_but_not_for_csharp_or_plain_files() {
837        let e = Value::object(vec![("source", Value::string("ast-none"))]);
838        assert!(carries_best_source(&e, "a.ts"));
839        assert!(carries_best_source(&e, "a.tsx"));
840        assert!(carries_best_source(&e, "a.js"));
841        assert!(
842            !carries_best_source(&e, "a.cs"),
843            "C# never produces ast-none; an entry claiming it must still be reparsed"
844        );
845        assert!(!carries_best_source(&e, "a.json"));
846    }
847
848    // -- with_ensured_source: source-field position semantics --------------
849
850    #[test]
851    fn ensured_source_is_appended_when_absent() {
852        let e = Value::object(vec![
853            ("purpose", Value::string("p")),
854            ("mtime", Value::number(1)),
855        ]);
856        let out = with_ensured_source(&e);
857        let fields = out.as_object().unwrap();
858        assert_eq!(fields.last().unwrap().0, "source");
859        assert_eq!(out.get("source").and_then(Value::as_str), Some("heuristic"));
860    }
861
862    #[test]
863    fn ensured_source_overwrites_in_place_preserving_position() {
864        let e = Value::object(vec![
865            ("source", Value::string("agent")),
866            ("purpose", Value::string("p")),
867            ("mtime", Value::number(1)),
868        ]);
869        let out = with_ensured_source(&e);
870        let fields = out.as_object().unwrap();
871        // Position preserved (still first), value untouched (already "agent").
872        assert_eq!(fields[0].0, "source");
873        assert_eq!(out.get("source").and_then(Value::as_str), Some("agent"));
874    }
875
876    #[test]
877    fn ensured_source_null_collapses_to_heuristic_in_place() {
878        let e = Value::object(vec![
879            ("purpose", Value::string("p")),
880            ("source", Value::Null),
881        ]);
882        let out = with_ensured_source(&e);
883        let fields = out.as_object().unwrap();
884        assert_eq!(fields[1].0, "source"); // position preserved (was 2nd key)
885        assert_eq!(out.get("source").and_then(Value::as_str), Some("heuristic"));
886    }
887
888    // -- map_repo: full run, reuse, scoped merge -----------------------------
889
890    #[test]
891    fn full_run_builds_entries_with_expected_sources() {
892        let root = temp_dir("full");
893        write_file(
894            &root.join("src/A.cs"),
895            "namespace Fixtures.MapCmd\n{\n    public class A\n    {\n    }\n}\n",
896        );
897        // This composes a purpose (`const x`) via the TS AST path instead of
898        // falling back to the heuristic -- source is "ast".
899        write_file(
900            &root.join("src/note.ts"),
901            "// a helper\nexport const x = 1;\n",
902        );
903        // A file with no exported top-level declaration still degrades to
904        // the heuristic purpose, same as a C# file with no namespace-level
905        // type -- proves the AST-vs-heuristic branch is still live, not
906        // just "every TS/JS file is always ast now".
907        write_file(
908            &root.join("src/internal.ts"),
909            "function helper() { return 1; }\n",
910        );
911
912        let report = map_repo(&root, &[], MapOptions::default()).unwrap();
913        assert_eq!(report.scoped_file_count, 3);
914        assert_eq!(report.total_manifest_entries, 3);
915        assert_eq!(report.merged_out_of_scope, 0);
916        assert_eq!(report.added, 3);
917        assert_eq!(
918            report.parsed_ast, 2,
919            "A.cs and note.ts both compose a purpose; internal.ts has no exported declaration"
920        );
921        assert!(report.graph_rebuilt);
922        // `A.cs`'s class AND `note.ts`'s exported `const x` -- TS/JS files
923        // contribute graph defs. `internal.ts` exports nothing, so it still
924        // contributes a cached fragment and no def.
925        assert_eq!(
926            report.graph_def_count,
927            Some(2),
928            "A.cs's class plus note.ts's exported const"
929        );
930
931        let manifest = manifest::read_manifest(&root).unwrap().unwrap();
932        let entries = manifest.get("entries").unwrap().as_object().unwrap();
933        let a = entries
934            .iter()
935            .find(|(k, _)| k == "src/A.cs")
936            .unwrap()
937            .1
938            .clone();
939        assert_eq!(a.get("source").and_then(Value::as_str), Some("ast"));
940        let note = entries
941            .iter()
942            .find(|(k, _)| k == "src/note.ts")
943            .unwrap()
944            .1
945            .clone();
946        assert_eq!(note.get("source").and_then(Value::as_str), Some("ast"));
947        // note.ts's first line ("// a helper") IS comment-derived under
948        // `default_purpose_detailed`, so its AST purpose carries the
949        // leading-comment hybrid prefix.
950        assert_eq!(
951            note.get("purpose").and_then(Value::as_str),
952            Some("a helper — const x")
953        );
954        let internal = entries
955            .iter()
956            .find(|(k, _)| k == "src/internal.ts")
957            .unwrap()
958            .1
959            .clone();
960        // A zero-export TS/JS file is tagged "ast-none", not "heuristic" --
961        // carries the same heuristic purpose text, but is recognised as
962        // best-obtainable by `carries_best_source` so an unchanged rerun reuses
963        // it instead of resending it.
964        assert_eq!(
965            internal.get("source").and_then(Value::as_str),
966            Some("ast-none")
967        );
968        assert_eq!(
969            internal.get("purpose").and_then(Value::as_str),
970            Some("function helper() { return 1; }")
971        );
972
973        fs::remove_dir_all(&root).ok();
974    }
975
976    #[test]
977    fn ts_purpose_upgrades_from_heuristic_and_then_reuses_unchanged() {
978        // TS reuse semantics: a TS entry with source "heuristic" upgrades to
979        // "ast" on the first map with the AST path; an immediate unchanged rerun
980        // then reuses it (no reparse, counted in `parsed_ast` since it already
981        // carries "ast").
982        let root = temp_dir("ts-reuse");
983        write_file(&root.join("src/bar.ts"), "export function bar() {}\n");
984
985        let first = map_repo(&root, &[], MapOptions::default()).unwrap();
986        assert_eq!(first.added, 1);
987        assert_eq!(first.parsed_ast, 1);
988        let manifest = manifest::read_manifest(&root).unwrap().unwrap();
989        let entries = manifest.get("entries").unwrap().as_object().unwrap();
990        let bar = entries
991            .iter()
992            .find(|(k, _)| k == "src/bar.ts")
993            .unwrap()
994            .1
995            .clone();
996        assert_eq!(bar.get("source").and_then(Value::as_str), Some("ast"));
997        assert_eq!(
998            bar.get("purpose").and_then(Value::as_str),
999            Some("function bar")
1000        );
1001
1002        let second = map_repo(&root, &[], MapOptions::default()).unwrap();
1003        assert_eq!(second.added, 0);
1004        assert_eq!(second.downgraded, 0);
1005        assert_eq!(
1006            second.parsed_ast, 1,
1007            "reused ast-source TS entry still counts as ast on an unchanged rerun"
1008        );
1009
1010        fs::remove_dir_all(&root).ok();
1011    }
1012
1013    #[test]
1014    fn unchanged_second_run_reuses_everything() {
1015        let root = temp_dir("reuse");
1016        write_file(
1017            &root.join("src/A.cs"),
1018            "namespace Fixtures.MapCmd\n{\n    public class A\n    {\n    }\n}\n",
1019        );
1020
1021        let first = map_repo(&root, &[], MapOptions::default()).unwrap();
1022        assert_eq!(first.added, 1);
1023
1024        let second = map_repo(&root, &[], MapOptions::default()).unwrap();
1025        assert_eq!(second.added, 0);
1026        assert_eq!(second.downgraded, 0);
1027        assert_eq!(second.removed, 0);
1028        assert_eq!(second.parsed_ast, 1, "reused entry keeps its ast source");
1029        assert!(
1030            !second.graph_rebuilt,
1031            "unchanged C# set must not rebuild the graph"
1032        );
1033
1034        fs::remove_dir_all(&root).ok();
1035    }
1036
1037    #[test]
1038    fn deleted_file_is_pruned_and_counted_removed() {
1039        let root = temp_dir("prune");
1040        write_file(
1041            &root.join("src/A.cs"),
1042            "namespace Fixtures.MapCmd { public class A {} }\n",
1043        );
1044        write_file(
1045            &root.join("src/B.cs"),
1046            "namespace Fixtures.MapCmd { public class B {} }\n",
1047        );
1048        map_repo(&root, &[], MapOptions::default()).unwrap();
1049
1050        fs::remove_file(root.join("src/B.cs")).unwrap();
1051        let report = map_repo(&root, &[], MapOptions::default()).unwrap();
1052        assert_eq!(report.removed, 1);
1053        assert_eq!(report.total_manifest_entries, 1);
1054
1055        fs::remove_dir_all(&root).ok();
1056    }
1057
1058    // A `.csproj` is not a `SOURCE_EXT`: adding or editing one moves no
1059    // walked file's mtime, so `index_is_stale` alone can never see it and the
1060    // graph would keep a stale project model forever. The sidecar comparison
1061    // is what closes that hole, and this walks the whole cycle -- no project
1062    // at all, a project added, nothing touched, a reference dropped.
1063    #[test]
1064    fn a_csproj_edit_rebuilds_the_graph_and_lands_its_units_while_no_csproj_changes_nothing() {
1065        let root = temp_dir("csproj-units");
1066        write_file(
1067            &root.join("A/A.cs"),
1068            "namespace Fixtures.MapCmd.A { public class A {} }\n",
1069        );
1070        write_file(
1071            &root.join("B/B.cs"),
1072            "namespace Fixtures.MapCmd.B { public class B {} }\n",
1073        );
1074
1075        let graph_json = graph::graph_json_path(&root);
1076        let sidecar = graph::project_units_path(&root);
1077
1078        // 1. No `.csproj` anywhere: the graph must not grow a `units` key and
1079        //    the sidecar must not be written at all -- an empty `[]` on disk
1080        //    would be indistinguishable from "a model that has no projects".
1081        let first = map_repo(&root, &[], MapOptions::default()).unwrap();
1082        assert!(first.graph_rebuilt);
1083        let json = fs::read_to_string(&graph_json).unwrap();
1084        assert!(
1085            !json.contains(r#""units""#),
1086            "a repo with no csproj must serialize exactly as it did before the project model: {json}"
1087        );
1088        assert!(!sidecar.exists(), "no model, no sidecar");
1089
1090        // 2. A project added, referencing a second one. Not one indexed file
1091        //    moved, so `index_is_stale` is false here and the rebuild is the
1092        //    sidecar's doing alone.
1093        write_file(
1094            &root.join("A/A.csproj"),
1095            "<Project Sdk=\"Microsoft.NET.Sdk\">\n  <ItemGroup>\n    <ProjectReference Include=\"..\\B\\B.csproj\" />\n  </ItemGroup>\n</Project>\n",
1096        );
1097        write_file(
1098            &root.join("B/B.csproj"),
1099            "<Project Sdk=\"Microsoft.NET.Sdk\">\n</Project>\n",
1100        );
1101
1102        let second = map_repo(&root, &[], MapOptions::default()).unwrap();
1103        assert!(
1104            second.graph_rebuilt,
1105            "a csproj appearing must rebuild the graph even though no source file changed"
1106        );
1107        let json = fs::read_to_string(&graph_json).unwrap();
1108        assert!(
1109            json.ends_with(
1110                r#","units":[{"id":"A/A.csproj","name":"A","refs":["B/B.csproj"]},{"id":"B/B.csproj","name":"B"}]}"#
1111            ),
1112            "units are appended LAST, sorted by id, with the ref list normalized to repo-relative ids: {json}"
1113        );
1114        assert!(
1115            sidecar.exists(),
1116            "the model was written, so was the sidecar"
1117        );
1118
1119        // The persisted rows really do rebuild a usable model, closure and
1120        // all -- that is the whole point of storing them.
1121        let graph_value = graph::read_graph(&root).unwrap();
1122        let model =
1123            project::ProjectModel::from_units(project::units_from_graph(&graph_value.units));
1124        assert!(model.reachable(0, 1), "A references B");
1125        assert!(!model.reachable(1, 0), "B does not reference A");
1126        assert_eq!(model.unit_of_file("A/A.cs"), Some(0));
1127        assert_eq!(model.unit_of_file("B/B.cs"), Some(1));
1128
1129        // 3. Nothing touched: the sidecar matches, so no rebuild.
1130        let third = map_repo(&root, &[], MapOptions::default()).unwrap();
1131        assert!(
1132            !third.graph_rebuilt,
1133            "an unchanged csproj must not force a rebuild on every run"
1134        );
1135
1136        // 4. The reference dropped. Same file count, same mtimes on every
1137        //    indexed file -- only the csproj's CONTENT changed.
1138        write_file(
1139            &root.join("A/A.csproj"),
1140            "<Project Sdk=\"Microsoft.NET.Sdk\">\n</Project>\n",
1141        );
1142        let fourth = map_repo(&root, &[], MapOptions::default()).unwrap();
1143        assert!(
1144            fourth.graph_rebuilt,
1145            "editing a csproj's references must rebuild the graph"
1146        );
1147        let json = fs::read_to_string(&graph_json).unwrap();
1148        assert!(
1149            json.ends_with(
1150                r#","units":[{"id":"A/A.csproj","name":"A"},{"id":"B/B.csproj","name":"B"}]}"#
1151            ),
1152            "the dropped reference is gone and `refs` is omitted rather than emitted empty: {json}"
1153        );
1154
1155        fs::remove_dir_all(&root).ok();
1156    }
1157
1158    #[test]
1159    fn scoped_run_merges_out_of_scope_entries() {
1160        let root = temp_dir("scoped-merge");
1161        write_file(
1162            &root.join("src/Foo/A.cs"),
1163            "namespace Fixtures.MapCmd.Foo { public class A {} }\n",
1164        );
1165        write_file(
1166            &root.join("src/Bar/C.ts"),
1167            "// bar helper\nexport const bar = 1;\n",
1168        );
1169        write_file(&root.join("docs/readme.md"), "# Fixtures Map Demo\n");
1170
1171        let full = map_repo(&root, &[], MapOptions::default()).unwrap();
1172        assert_eq!(full.total_manifest_entries, 3);
1173
1174        let scoped = map_repo(&root, &["src/Foo".to_string()], MapOptions::default()).unwrap();
1175        assert_eq!(scoped.scope, vec!["src/Foo".to_string()]);
1176        assert_eq!(
1177            scoped.scoped_file_count, 1,
1178            "only src/Foo/A.cs is walked this run"
1179        );
1180        assert_eq!(
1181            scoped.merged_out_of_scope, 2,
1182            "src/Bar/C.ts and docs/readme.md preserved, not destroyed"
1183        );
1184        assert_eq!(scoped.total_manifest_entries, 3);
1185        assert_eq!(scoped.removed, 0, "nothing in scope was actually deleted");
1186
1187        let manifest = manifest::read_manifest(&root).unwrap().unwrap();
1188        let entries = manifest.get("entries").unwrap().as_object().unwrap();
1189        assert_eq!(entries.len(), 3);
1190        assert!(
1191            entries.iter().any(|(k, _)| k == "src/Bar/C.ts"),
1192            "out-of-scope entry must survive a scoped map"
1193        );
1194        assert!(
1195            entries.iter().any(|(k, _)| k == "docs/readme.md"),
1196            "out-of-scope entry must survive a scoped map"
1197        );
1198
1199        fs::remove_dir_all(&root).ok();
1200    }
1201
1202    #[test]
1203    fn scoped_run_still_prunes_in_scope_deletions() {
1204        let root = temp_dir("scoped-prune");
1205        write_file(
1206            &root.join("src/Foo/A.cs"),
1207            "namespace Fixtures.MapCmd.Foo { public class A {} }\n",
1208        );
1209        write_file(
1210            &root.join("src/Foo/B.cs"),
1211            "namespace Fixtures.MapCmd.Foo { public class B {} }\n",
1212        );
1213        write_file(
1214            &root.join("src/Bar/C.ts"),
1215            "// bar\nexport const bar = 1;\n",
1216        );
1217        map_repo(&root, &[], MapOptions::default()).unwrap();
1218
1219        fs::remove_file(root.join("src/Foo/B.cs")).unwrap();
1220        let scoped = map_repo(&root, &["src/Foo".to_string()], MapOptions::default()).unwrap();
1221        // In-scope deletion IS pruned (not merge-preserved) -- the scoped merge
1222        // only protects entries this run never looked at, not entries it looked
1223        // at and found gone.
1224        assert_eq!(scoped.removed, 1);
1225        assert_eq!(
1226            scoped.merged_out_of_scope, 1,
1227            "src/Bar/C.ts still preserved"
1228        );
1229        assert_eq!(scoped.total_manifest_entries, 2);
1230
1231        fs::remove_dir_all(&root).ok();
1232    }
1233
1234    #[test]
1235    fn invalid_utf8_byte_is_read_lossily_not_dropped() {
1236        // Regression: a stray CP1252 byte (0x97, em-dash) inside a `//` comment
1237        // must not cause the whole file to be silently excluded from extraction
1238        // (see `read_source_lossy`'s doc comment).
1239        let root = temp_dir("lossy-utf8");
1240        let path = root.join("src/Weird.cs");
1241        fs::create_dir_all(path.parent().unwrap()).unwrap();
1242        let mut bytes = b"namespace Fixtures.MapCmd\n{\n    // note \x97 em dash\n    public class Weird\n    {\n    }\n}\n".to_vec();
1243        assert!(
1244            std::str::from_utf8(&bytes).is_err(),
1245            "fixture must actually contain an invalid UTF-8 byte"
1246        );
1247        fs::write(&path, &bytes).unwrap();
1248        bytes.clear(); // silence "unused" if the assert above is ever removed
1249
1250        let report = map_repo(&root, &[], MapOptions::default()).unwrap();
1251        assert_eq!(report.added, 1);
1252        assert_eq!(
1253            report.parsed_ast, 1,
1254            "the file must still be AST-extracted despite the bad byte"
1255        );
1256        assert!(report.graph_rebuilt);
1257        assert_eq!(report.graph_def_count, Some(1));
1258
1259        // The regression's OTHER symptom: index_is_stale's length check
1260        // must not be permanently tripped by a dropped file -- an immediate
1261        // unchanged rerun must skip the graph rebuild.
1262        let second = map_repo(&root, &[], MapOptions::default()).unwrap();
1263        assert!(
1264            !second.graph_rebuilt,
1265            "an unchanged rerun must reuse, not be permanently stale"
1266        );
1267
1268        fs::remove_dir_all(&root).ok();
1269    }
1270
1271    // -- content-hash reuse --------------------------------------------
1272
1273    #[test]
1274    fn hash_reuse_survives_an_mtime_only_touch() {
1275        let root = temp_dir("hash-reuse");
1276        let opts = MapOptions { hash_reuse: true };
1277        let content = "namespace Fixtures.MapCmd { public class A {} }\n";
1278        write_file(&root.join("src/A.cs"), content);
1279        let first = map_repo(&root, &[], opts).unwrap();
1280        assert_eq!(first.added, 1);
1281        assert!(first.graph_rebuilt);
1282
1283        // Rewrite with IDENTICAL content -- a real mtime bump (a fresh
1284        // worktree checkout's own failure mode), but the content hash is
1285        // unchanged.
1286        std::thread::sleep(std::time::Duration::from_millis(10));
1287        write_file(&root.join("src/A.cs"), content);
1288
1289        let second = map_repo(&root, &[], opts).unwrap();
1290        assert_eq!(second.added, 0);
1291        assert_eq!(second.downgraded, 0);
1292        assert!(
1293            !second.graph_rebuilt,
1294            "same content hash must reuse, not reparse"
1295        );
1296
1297        fs::remove_dir_all(&root).ok();
1298    }
1299
1300    #[test]
1301    fn hash_reuse_reparses_on_real_content_change() {
1302        let root = temp_dir("hash-reparse");
1303        let opts = MapOptions { hash_reuse: true };
1304        write_file(
1305            &root.join("src/A.cs"),
1306            "namespace Fixtures.MapCmd { public class A {} }\n",
1307        );
1308        map_repo(&root, &[], opts).unwrap();
1309
1310        write_file(
1311            &root.join("src/A.cs"),
1312            "namespace Fixtures.MapCmd { public class A { public void M() {} } }\n",
1313        );
1314        let second = map_repo(&root, &[], opts).unwrap();
1315        assert!(second.graph_rebuilt, "changed content hash must reparse");
1316
1317        fs::remove_dir_all(&root).ok();
1318    }
1319
1320    #[test]
1321    fn mtime_default_mode_ignores_content_and_keys_on_mtime_only() {
1322        // Sanity check that the two modes are actually distinct: rewriting
1323        // identical content under DEFAULT (mtime) mode still forces a
1324        // reparse if the mtime moved, unlike hash_reuse above.
1325        let root = temp_dir("mtime-default");
1326        let content = "namespace Fixtures.MapCmd { public class A {} }\n";
1327        write_file(&root.join("src/A.cs"), content);
1328        map_repo(&root, &[], MapOptions::default()).unwrap();
1329
1330        std::thread::sleep(std::time::Duration::from_millis(10));
1331        write_file(&root.join("src/A.cs"), content);
1332        let second = map_repo(&root, &[], MapOptions::default()).unwrap();
1333        // mtime moved -> not reusable -> reparsed -> graph still has the
1334        // same single def, but the fragments-index mtime must have moved,
1335        // which rebuild_graph treats as "changed".
1336        assert!(
1337            second.graph_rebuilt,
1338            "default mode has no content-awareness -- an mtime bump alone reparses"
1339        );
1340
1341        fs::remove_dir_all(&root).ok();
1342    }
1343
1344    // -- ast-none manifest + reuse ------------------------------------------
1345
1346    #[test]
1347    fn zero_export_ts_file_is_tagged_ast_none_not_heuristic() {
1348        let root = temp_dir("ast-none-tag");
1349        write_file(
1350            &root.join("src/internal.ts"),
1351            "function helper() { return 1; }\n",
1352        );
1353
1354        let report = map_repo(&root, &[], MapOptions::default()).unwrap();
1355        assert_eq!(report.added, 1);
1356        assert_eq!(
1357            report.parsed_ast, 0,
1358            "ast-none never counts toward parsed_ast"
1359        );
1360
1361        let manifest = manifest::read_manifest(&root).unwrap().unwrap();
1362        let entries = manifest.get("entries").unwrap().as_object().unwrap();
1363        let entry = entries
1364            .iter()
1365            .find(|(k, _)| k == "src/internal.ts")
1366            .unwrap()
1367            .1
1368            .clone();
1369        assert_eq!(
1370            entry.get("source").and_then(Value::as_str),
1371            Some("ast-none")
1372        );
1373
1374        fs::remove_dir_all(&root).ok();
1375    }
1376
1377    #[test]
1378    fn unchanged_rerun_does_not_recompute_ast_none_purposes() {
1379        // The resend trap: plant a sentinel purpose a fresh recompute could never
1380        // produce, at the SAME cache key/source ("ast-none"). If the file is
1381        // resent for recompute on the next map (ast-none miscategorised as
1382        // not-best), the real heuristic text overwrites the sentinel; correct
1383        // reuse leaves it intact. A byte-identical manifest.json alone would NOT
1384        // prove this -- a resent file recomputes to the same deterministic text --
1385        // so this is the direct evidence for "rerun resends 0 TS files".
1386        let root = temp_dir("ast-none-resend-trap");
1387        write_file(
1388            &root.join("src/internal.ts"),
1389            "function helper() { return 1; }\n",
1390        );
1391        map_repo(&root, &[], MapOptions::default()).unwrap();
1392
1393        let manifest = manifest::read_manifest(&root).unwrap().unwrap();
1394        let built_at_head = manifest
1395            .get("built_at_head")
1396            .cloned()
1397            .unwrap_or(Value::Null);
1398        let scoped_dirs = manifest
1399            .get("scoped_dirs")
1400            .cloned()
1401            .unwrap_or_else(|| Value::array(vec![]));
1402        let mut entries: Vec<(String, Value)> = manifest
1403            .get("entries")
1404            .and_then(Value::as_object)
1405            .unwrap()
1406            .to_vec();
1407        for (k, v) in entries.iter_mut() {
1408            if k == "src/internal.ts" {
1409                assert_eq!(
1410                    v.get("source").and_then(Value::as_str),
1411                    Some("ast-none"),
1412                    "precondition"
1413                );
1414                let cache_key = v.get("mtime").cloned().expect("mtime present");
1415                *v = Value::object(vec![
1416                    ("purpose", Value::string("__SENTINEL_DO_NOT_RECOMPUTE__")),
1417                    ("mtime", cache_key),
1418                    ("source", Value::string("ast-none")),
1419                ]);
1420            }
1421        }
1422        manifest::write_manifest(
1423            &root,
1424            &Value::object(vec![
1425                ("built_at_head", built_at_head),
1426                ("scoped_dirs", scoped_dirs),
1427                ("entries", Value::Object(entries)),
1428            ]),
1429        )
1430        .unwrap();
1431
1432        let second = map_repo(&root, &[], MapOptions::default()).unwrap();
1433        assert_eq!(second.added, 0);
1434        assert_eq!(second.downgraded, 0);
1435
1436        let after = manifest::read_manifest(&root).unwrap().unwrap();
1437        let after_entries = after.get("entries").and_then(Value::as_object).unwrap();
1438        let reused = after_entries
1439            .iter()
1440            .find(|(k, _)| k == "src/internal.ts")
1441            .unwrap()
1442            .1
1443            .clone();
1444        assert_eq!(
1445            reused.get("purpose").and_then(Value::as_str),
1446            Some("__SENTINEL_DO_NOT_RECOMPUTE__"),
1447            "unchanged rerun must not resend this file for recompute"
1448        );
1449        assert_eq!(
1450            reused.get("source").and_then(Value::as_str),
1451            Some("ast-none")
1452        );
1453
1454        fs::remove_dir_all(&root).ok();
1455    }
1456
1457    // -- non-git root (no manifest yet) ------------------------------------
1458
1459    #[test]
1460    fn empty_dir_maps_to_zero_entries_without_erroring() {
1461        let root = temp_dir("empty");
1462        fs::create_dir_all(&root).unwrap();
1463        let report = map_repo(&root, &[], MapOptions::default()).unwrap();
1464        assert_eq!(report.scoped_file_count, 0);
1465        assert_eq!(report.total_manifest_entries, 0);
1466        // First run always writes an initial (empty) graph.json -- the graph
1467        // rebuild only skips when `!changed` AND a graph.json already exists; on a
1468        // fresh repo neither is true yet.
1469        assert!(report.graph_rebuilt);
1470        assert_eq!(report.graph_def_count, Some(0));
1471
1472        // A SECOND run against the same (still empty) root has an existing,
1473        // unchanged (0 == 0 C# files) graph.json -- this is the case that
1474        // actually exercises the "skip" path.
1475        let second = map_repo(&root, &[], MapOptions::default()).unwrap();
1476        assert!(!second.graph_rebuilt);
1477
1478        fs::remove_dir_all(&root).ok();
1479    }
1480}