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