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