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}