use std::collections::{HashMap, HashSet};
use std::fs;
use std::io;
use std::path::Path;
use std::time::{Instant, UNIX_EPOCH};
use rayon::prelude::*;
use crate::extract;
use crate::graph::{self, AnyFragment, Fragment, GraphFile};
use crate::manifest::{self, Value};
use crate::markup;
use crate::parse;
use crate::repo;
use crate::walk;
fn io_err<E: std::fmt::Display>(e: E) -> io::Error {
io::Error::other(e.to_string())
}
fn is_csharp(rel: &str) -> bool {
rel.ends_with(".cs")
}
fn is_graph_source(rel: &str) -> bool {
is_csharp(rel) || markup::is_markup(rel) || parse::ts_grammar_for(rel).is_some()
}
fn read_source_lossy(path: &Path) -> Option<String> {
fs::read(path)
.ok()
.map(|bytes| String::from_utf8_lossy(&bytes).into_owned())
}
mod hashkey {
use sha2::{Digest, Sha256};
pub fn cache_key(bytes: &[u8]) -> i64 {
let digest = Sha256::digest(bytes);
i64::from_be_bytes(digest[0..8].try_into().expect("sha256 digest is 32 bytes"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn same_bytes_produce_the_same_key() {
assert_eq!(cache_key(b"hello"), cache_key(b"hello"));
}
#[test]
fn different_bytes_produce_different_keys() {
assert_ne!(cache_key(b"hello"), cache_key(b"world"));
}
}
}
#[derive(Debug, Clone, Copy, Default)]
pub struct MapOptions {
pub hash_reuse: bool,
}
impl MapOptions {
pub fn from_env() -> Self {
MapOptions {
hash_reuse: std::env::var("SCOUT_MTIME_REUSE").as_deref() != Ok("1"),
}
}
}
#[derive(Debug, Clone)]
pub struct MapReport {
pub scope: Vec<String>,
pub scoped_file_count: usize,
pub total_manifest_entries: usize,
pub merged_out_of_scope: usize,
pub preserved: usize,
pub downgraded: usize,
pub added: usize,
pub removed: usize,
pub parsed_ast: usize,
pub graph_rebuilt: bool,
pub graph_seconds: f64,
pub graph_def_count: Option<usize>,
pub graph_edge_count: Option<usize>,
}
impl MapReport {
pub fn summary_line(&self) -> String {
let graph_note = match (
self.graph_rebuilt,
self.graph_def_count,
self.graph_edge_count,
) {
(true, Some(defs), Some(edges)) => {
format!(
"graph rebuilt in {:.2}s ({defs} defs, {edges} edges)",
self.graph_seconds
)
}
_ => "graph unchanged".to_string(),
};
format!(
"mapped {} files under {} (preserved {} agent purposes, downgraded {} changed, {} new, {} removed, {} ast signatures); {graph_note}",
self.scoped_file_count,
self.scope.join(", "),
self.preserved,
self.downgraded,
self.added,
self.removed,
self.parsed_ast,
)
}
}
fn carries_best_source(entry: &Value, rel: &str) -> bool {
let source = entry
.get("source")
.and_then(Value::as_str)
.unwrap_or("heuristic");
if source == "agent" {
return true;
}
if parse::is_ts_js(rel) {
return source == "ast" || source == "ast-none";
}
source == if is_csharp(rel) { "ast" } else { "heuristic" }
}
fn entry_cache_key(entry: &Value) -> Option<i64> {
match entry.get("mtime") {
Some(Value::Number(n)) => n.as_i64(),
_ => None,
}
}
fn with_ensured_source(entry: &Value) -> Value {
let fields = entry.as_object().unwrap_or(&[]);
let source = entry
.get("source")
.and_then(Value::as_str)
.unwrap_or("heuristic")
.to_string();
let mut out: Vec<(String, Value)> = Vec::with_capacity(fields.len() + 1);
let mut replaced = false;
for (k, v) in fields {
if k == "source" {
out.push((k.clone(), Value::string(source.clone())));
replaced = true;
} else {
out.push((k.clone(), v.clone()));
}
}
if !replaced {
out.push(("source".to_string(), Value::string(source)));
}
Value::Object(out)
}
fn is_within_scope(rel: &str, scope: &[String]) -> bool {
scope
.iter()
.any(|d| d == "." || rel == d || rel.starts_with(&format!("{d}/")))
}
fn cache_key_for(root: &Path, rel: &str, opts: &MapOptions) -> i64 {
let abs = root.join(rel);
if opts.hash_reuse {
match fs::read(&abs) {
Ok(bytes) => hashkey::cache_key(&bytes),
Err(_) => 0,
}
} else {
match fs::metadata(&abs).and_then(|m| m.modified()) {
Ok(t) => match t.duration_since(UNIX_EPOCH) {
Ok(d) => {
let ms = (d.as_secs() as f64) * 1000.0 + f64::from(d.subsec_nanos()) / 1e6;
ms.floor() as i64
}
Err(_) => 0,
},
Err(_) => 0,
}
}
}
struct PlanEntry {
rel: String,
cache_key: i64,
prior_entry: Option<Value>,
reusable: bool,
}
enum ParsedFile {
CSharp(String, extract::Extraction),
TsJs(String, Option<String>, extract::TsFragment),
Markup(String, Fragment),
}
pub fn map_repo(root: &Path, scope_dirs: &[String], opts: MapOptions) -> io::Result<MapReport> {
let scope = manifest::scope_for(
root,
if scope_dirs.is_empty() {
None
} else {
Some(scope_dirs)
},
)
.map_err(io_err)?;
let files = walk::list_source_files(root, &scope)?;
let prior_manifest = manifest::read_manifest(root).map_err(io_err)?;
let prior_entries: Vec<(String, Value)> = prior_manifest
.as_ref()
.and_then(|m| m.get("entries"))
.and_then(Value::as_object)
.map(|s| s.to_vec())
.unwrap_or_default();
let prior_index: HashMap<String, Value> = prior_entries.iter().cloned().collect();
let graph_index = graph::read_fragments_index(root);
let plan: Vec<PlanEntry> = files
.iter()
.map(|rel| {
let cache_key = cache_key_for(root, rel, &opts);
let fragment_ok =
!is_graph_source(rel) || graph_index.get(rel.as_str()) == Some(&cache_key);
let prior_entry = prior_index.get(rel.as_str()).cloned();
let reusable = prior_entry
.as_ref()
.map(|e| {
entry_cache_key(e) == Some(cache_key)
&& carries_best_source(e, rel)
&& fragment_ok
})
.unwrap_or(false);
PlanEntry {
rel: rel.clone(),
cache_key,
prior_entry,
reusable,
}
})
.collect();
let per_file: Vec<Option<ParsedFile>> = plan
.par_iter()
.map(|p| {
if p.reusable {
return None;
}
if is_csharp(&p.rel) {
return read_source_lossy(&root.join(&p.rel))
.map(|src| ParsedFile::CSharp(p.rel.clone(), extract::extract(&src)));
}
if markup::is_markup(&p.rel) {
return graph::markup_fragment(root, &p.rel)
.map(|f| ParsedFile::Markup(p.rel.clone(), f));
}
let grammar = parse::ts_grammar_for(&p.rel)?;
let src = read_source_lossy(&root.join(&p.rel))?;
let ts = extract::extract_ts_file(root, &p.rel, &src, grammar)?;
Some(ParsedFile::TsJs(p.rel.clone(), ts.purpose, ts.fragment))
})
.collect();
let mut purposes: HashMap<String, String> = HashMap::new();
let mut fresh_fragments: HashMap<String, AnyFragment> = HashMap::new();
for parsed in per_file.into_iter().flatten() {
match parsed {
ParsedFile::CSharp(rel, extraction) => {
if let Some(p) = &extraction.purpose {
purposes.insert(rel.clone(), p.clone());
}
fresh_fragments.insert(
rel,
AnyFragment::Cs(graph::fragment_from_extraction(&extraction)),
);
}
ParsedFile::Markup(rel, fragment) => {
fresh_fragments.insert(rel, AnyFragment::Cs(fragment));
}
ParsedFile::TsJs(rel, purpose, fragment) => {
if let Some(p) = purpose {
purposes.insert(rel.clone(), p);
}
fresh_fragments.insert(rel, AnyFragment::Ts(fragment));
}
}
}
let mut entries: Vec<(String, Value)> = Vec::with_capacity(plan.len());
let mut preserved = 0usize;
let mut downgraded = 0usize;
let mut added = 0usize;
for p in &plan {
if p.reusable {
let merged = with_ensured_source(
p.prior_entry
.as_ref()
.expect("reusable implies a prior entry exists"),
);
if merged.get("source").and_then(Value::as_str) == Some("agent") {
preserved += 1;
}
entries.push((p.rel.clone(), merged));
continue;
}
if let Some(prior) = p.prior_entry.as_ref() {
let source = prior
.get("source")
.and_then(Value::as_str)
.unwrap_or("heuristic");
if source == "agent" {
downgraded += 1;
}
} else {
added += 1;
}
let value = match purposes.get(&p.rel) {
Some(sig) => Value::object(vec![
("purpose", Value::string(sig.clone())),
("mtime", Value::number(p.cache_key)),
("source", Value::string("ast")),
]),
None => {
let source = if parse::is_ts_js(&p.rel) {
"ast-none"
} else {
"heuristic"
};
Value::object(vec![
(
"purpose",
Value::string(walk::default_purpose(root, &p.rel)),
),
("mtime", Value::number(p.cache_key)),
("source", Value::string(source)),
])
}
};
entries.push((p.rel.clone(), value));
}
let parsed_ast = entries
.iter()
.filter(|(_, v)| v.get("source").and_then(Value::as_str) == Some("ast"))
.count();
let removed = {
let entries_index: HashSet<String> = entries.iter().map(|(k, _)| k.clone()).collect();
prior_entries
.iter()
.filter(|entry| is_within_scope(&entry.0, &scope) && !entries_index.contains(&entry.0))
.count()
};
let scoped_file_count = entries.len();
let mut merged_out_of_scope = 0usize;
for entry in &prior_entries {
if !is_within_scope(&entry.0, &scope) {
entries.push(entry.clone());
merged_out_of_scope += 1;
}
}
let total_manifest_entries = entries.len();
let indexed_file_set: HashSet<String> = entries.iter().map(|(k, _)| k.clone()).collect();
let built_at_head = manifest::git_head(root);
let manifest_value = Value::object(vec![
(
"built_at_head",
built_at_head
.clone()
.map(Value::string)
.unwrap_or(Value::Null),
),
(
"scoped_dirs",
Value::array(scope.iter().cloned().map(Value::string).collect()),
),
("entries", Value::Object(entries)),
]);
manifest::write_manifest(root, &manifest_value)?;
let dirty = manifest::is_working_tree_dirty(root, &scope);
let dirty_indexed_files = manifest::dirty_indexed_files_at(root, &scope, &indexed_file_set);
manifest::write_index_state(
root,
built_at_head,
dirty,
&dirty_indexed_files,
total_manifest_entries as i64,
)?;
let flag = repo::scout_dir(root).join("refresh-needed");
if flag.exists() {
fs::remove_file(&flag)?;
}
let graph_files: Vec<GraphFile> = plan
.iter()
.filter(|p| is_graph_source(&p.rel))
.map(|p| GraphFile {
rel: p.rel.clone(),
mtime: p.cache_key,
})
.collect();
let changed = graph::index_is_stale(&graph_index, &graph_files);
let graph_start = Instant::now();
let outcome = graph::rebuild_graph(root, &graph_files, &fresh_fragments, changed)?;
let graph_seconds = graph_start.elapsed().as_secs_f64();
let (graph_rebuilt, graph_def_count, graph_edge_count) = match &outcome {
graph::RebuildOutcome::Rebuilt(g) => (true, Some(g.defs.len()), Some(g.edges.len())),
graph::RebuildOutcome::NotRebuilt => (false, None, None),
};
Ok(MapReport {
scope,
scoped_file_count,
total_manifest_entries,
merged_out_of_scope,
preserved,
downgraded,
added,
removed,
parsed_ast,
graph_rebuilt,
graph_seconds,
graph_def_count,
graph_edge_count,
})
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicU64, Ordering};
static COUNTER: AtomicU64 = AtomicU64::new(0);
fn temp_dir(label: &str) -> std::path::PathBuf {
let n = COUNTER.fetch_add(1, Ordering::SeqCst);
let dir = std::env::temp_dir().join(format!(
"scout-mapcmd-test-{label}-{}-{n}",
std::process::id()
));
fs::create_dir_all(&dir).expect("create temp dir");
dir
}
fn write_file(path: &Path, contents: &str) {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).expect("create parent dirs");
}
fs::write(path, contents).expect("write file");
}
#[test]
fn dot_scope_covers_everything() {
assert!(is_within_scope("src/a.ts", &[".".to_string()]));
assert!(is_within_scope("a.ts", &[".".to_string()]));
}
#[test]
fn dir_scope_covers_itself_and_nested_paths_only() {
let scope = vec!["src/Foo".to_string()];
assert!(is_within_scope("src/Foo/A.cs", &scope));
assert!(is_within_scope("src/Foo/Sub/B.cs", &scope));
assert!(!is_within_scope("src/Bar/C.cs", &scope));
assert!(!is_within_scope("src/FooBar/D.cs", &scope));
}
#[test]
fn multiple_scope_dirs_union() {
let scope = vec!["src".to_string(), "docs".to_string()];
assert!(is_within_scope("src/a.ts", &scope));
assert!(is_within_scope("docs/readme.md", &scope));
assert!(!is_within_scope("config/settings.json", &scope));
}
#[test]
fn agent_source_always_carries() {
let e = Value::object(vec![("source", Value::string("agent"))]);
assert!(carries_best_source(&e, "a.cs"));
assert!(carries_best_source(&e, "a.ts"));
}
#[test]
fn ast_source_carries_for_csharp_and_ts_js() {
let e = Value::object(vec![("source", Value::string("ast"))]);
assert!(carries_best_source(&e, "a.cs"));
assert!(carries_best_source(&e, "a.ts"));
assert!(!carries_best_source(&e, "a.json"));
}
#[test]
fn heuristic_source_carries_only_for_non_ast_capable_extensions() {
let e = Value::object(vec![("source", Value::string("heuristic"))]);
assert!(!carries_best_source(&e, "a.cs"));
assert!(!carries_best_source(&e, "a.ts"));
assert!(carries_best_source(&e, "a.json"));
}
#[test]
fn missing_source_defaults_to_heuristic() {
let e = Value::object(vec![("purpose", Value::string("x"))]);
assert!(carries_best_source(&e, "a.json"));
assert!(!carries_best_source(&e, "a.cs"));
assert!(!carries_best_source(&e, "a.ts"));
}
#[test]
fn ast_none_source_carries_for_ts_js_but_not_for_csharp_or_plain_files() {
let e = Value::object(vec![("source", Value::string("ast-none"))]);
assert!(carries_best_source(&e, "a.ts"));
assert!(carries_best_source(&e, "a.tsx"));
assert!(carries_best_source(&e, "a.js"));
assert!(
!carries_best_source(&e, "a.cs"),
"C# never produces ast-none; an entry claiming it must still be reparsed"
);
assert!(!carries_best_source(&e, "a.json"));
}
#[test]
fn ensured_source_is_appended_when_absent() {
let e = Value::object(vec![
("purpose", Value::string("p")),
("mtime", Value::number(1)),
]);
let out = with_ensured_source(&e);
let fields = out.as_object().unwrap();
assert_eq!(fields.last().unwrap().0, "source");
assert_eq!(out.get("source").and_then(Value::as_str), Some("heuristic"));
}
#[test]
fn ensured_source_overwrites_in_place_preserving_position() {
let e = Value::object(vec![
("source", Value::string("agent")),
("purpose", Value::string("p")),
("mtime", Value::number(1)),
]);
let out = with_ensured_source(&e);
let fields = out.as_object().unwrap();
assert_eq!(fields[0].0, "source");
assert_eq!(out.get("source").and_then(Value::as_str), Some("agent"));
}
#[test]
fn ensured_source_null_collapses_to_heuristic_in_place() {
let e = Value::object(vec![
("purpose", Value::string("p")),
("source", Value::Null),
]);
let out = with_ensured_source(&e);
let fields = out.as_object().unwrap();
assert_eq!(fields[1].0, "source"); assert_eq!(out.get("source").and_then(Value::as_str), Some("heuristic"));
}
#[test]
fn full_run_builds_entries_with_expected_sources() {
let root = temp_dir("full");
write_file(
&root.join("src/A.cs"),
"namespace Fixtures.MapCmd\n{\n public class A\n {\n }\n}\n",
);
write_file(
&root.join("src/note.ts"),
"// a helper\nexport const x = 1;\n",
);
write_file(
&root.join("src/internal.ts"),
"function helper() { return 1; }\n",
);
let report = map_repo(&root, &[], MapOptions::default()).unwrap();
assert_eq!(report.scoped_file_count, 3);
assert_eq!(report.total_manifest_entries, 3);
assert_eq!(report.merged_out_of_scope, 0);
assert_eq!(report.added, 3);
assert_eq!(
report.parsed_ast, 2,
"A.cs and note.ts both compose a purpose; internal.ts has no exported declaration"
);
assert!(report.graph_rebuilt);
assert_eq!(
report.graph_def_count,
Some(2),
"A.cs's class plus note.ts's exported const"
);
let manifest = manifest::read_manifest(&root).unwrap().unwrap();
let entries = manifest.get("entries").unwrap().as_object().unwrap();
let a = entries
.iter()
.find(|(k, _)| k == "src/A.cs")
.unwrap()
.1
.clone();
assert_eq!(a.get("source").and_then(Value::as_str), Some("ast"));
let note = entries
.iter()
.find(|(k, _)| k == "src/note.ts")
.unwrap()
.1
.clone();
assert_eq!(note.get("source").and_then(Value::as_str), Some("ast"));
assert_eq!(
note.get("purpose").and_then(Value::as_str),
Some("a helper — const x")
);
let internal = entries
.iter()
.find(|(k, _)| k == "src/internal.ts")
.unwrap()
.1
.clone();
assert_eq!(
internal.get("source").and_then(Value::as_str),
Some("ast-none")
);
assert_eq!(
internal.get("purpose").and_then(Value::as_str),
Some("function helper() { return 1; }")
);
fs::remove_dir_all(&root).ok();
}
#[test]
fn ts_purpose_upgrades_from_heuristic_and_then_reuses_unchanged() {
let root = temp_dir("ts-reuse");
write_file(&root.join("src/bar.ts"), "export function bar() {}\n");
let first = map_repo(&root, &[], MapOptions::default()).unwrap();
assert_eq!(first.added, 1);
assert_eq!(first.parsed_ast, 1);
let manifest = manifest::read_manifest(&root).unwrap().unwrap();
let entries = manifest.get("entries").unwrap().as_object().unwrap();
let bar = entries
.iter()
.find(|(k, _)| k == "src/bar.ts")
.unwrap()
.1
.clone();
assert_eq!(bar.get("source").and_then(Value::as_str), Some("ast"));
assert_eq!(
bar.get("purpose").and_then(Value::as_str),
Some("function bar")
);
let second = map_repo(&root, &[], MapOptions::default()).unwrap();
assert_eq!(second.added, 0);
assert_eq!(second.downgraded, 0);
assert_eq!(
second.parsed_ast, 1,
"reused ast-source TS entry still counts as ast on an unchanged rerun"
);
fs::remove_dir_all(&root).ok();
}
#[test]
fn unchanged_second_run_reuses_everything() {
let root = temp_dir("reuse");
write_file(
&root.join("src/A.cs"),
"namespace Fixtures.MapCmd\n{\n public class A\n {\n }\n}\n",
);
let first = map_repo(&root, &[], MapOptions::default()).unwrap();
assert_eq!(first.added, 1);
let second = map_repo(&root, &[], MapOptions::default()).unwrap();
assert_eq!(second.added, 0);
assert_eq!(second.downgraded, 0);
assert_eq!(second.removed, 0);
assert_eq!(second.parsed_ast, 1, "reused entry keeps its ast source");
assert!(
!second.graph_rebuilt,
"unchanged C# set must not rebuild the graph"
);
fs::remove_dir_all(&root).ok();
}
#[test]
fn deleted_file_is_pruned_and_counted_removed() {
let root = temp_dir("prune");
write_file(
&root.join("src/A.cs"),
"namespace Fixtures.MapCmd { public class A {} }\n",
);
write_file(
&root.join("src/B.cs"),
"namespace Fixtures.MapCmd { public class B {} }\n",
);
map_repo(&root, &[], MapOptions::default()).unwrap();
fs::remove_file(root.join("src/B.cs")).unwrap();
let report = map_repo(&root, &[], MapOptions::default()).unwrap();
assert_eq!(report.removed, 1);
assert_eq!(report.total_manifest_entries, 1);
fs::remove_dir_all(&root).ok();
}
#[test]
fn scoped_run_merges_out_of_scope_entries() {
let root = temp_dir("scoped-merge");
write_file(
&root.join("src/Foo/A.cs"),
"namespace Fixtures.MapCmd.Foo { public class A {} }\n",
);
write_file(
&root.join("src/Bar/C.ts"),
"// bar helper\nexport const bar = 1;\n",
);
write_file(&root.join("docs/readme.md"), "# Fixtures Map Demo\n");
let full = map_repo(&root, &[], MapOptions::default()).unwrap();
assert_eq!(full.total_manifest_entries, 3);
let scoped = map_repo(&root, &["src/Foo".to_string()], MapOptions::default()).unwrap();
assert_eq!(scoped.scope, vec!["src/Foo".to_string()]);
assert_eq!(
scoped.scoped_file_count, 1,
"only src/Foo/A.cs is walked this run"
);
assert_eq!(
scoped.merged_out_of_scope, 2,
"src/Bar/C.ts and docs/readme.md preserved, not destroyed"
);
assert_eq!(scoped.total_manifest_entries, 3);
assert_eq!(scoped.removed, 0, "nothing in scope was actually deleted");
let manifest = manifest::read_manifest(&root).unwrap().unwrap();
let entries = manifest.get("entries").unwrap().as_object().unwrap();
assert_eq!(entries.len(), 3);
assert!(
entries.iter().any(|(k, _)| k == "src/Bar/C.ts"),
"out-of-scope entry must survive a scoped map"
);
assert!(
entries.iter().any(|(k, _)| k == "docs/readme.md"),
"out-of-scope entry must survive a scoped map"
);
fs::remove_dir_all(&root).ok();
}
#[test]
fn scoped_run_still_prunes_in_scope_deletions() {
let root = temp_dir("scoped-prune");
write_file(
&root.join("src/Foo/A.cs"),
"namespace Fixtures.MapCmd.Foo { public class A {} }\n",
);
write_file(
&root.join("src/Foo/B.cs"),
"namespace Fixtures.MapCmd.Foo { public class B {} }\n",
);
write_file(
&root.join("src/Bar/C.ts"),
"// bar\nexport const bar = 1;\n",
);
map_repo(&root, &[], MapOptions::default()).unwrap();
fs::remove_file(root.join("src/Foo/B.cs")).unwrap();
let scoped = map_repo(&root, &["src/Foo".to_string()], MapOptions::default()).unwrap();
assert_eq!(scoped.removed, 1);
assert_eq!(
scoped.merged_out_of_scope, 1,
"src/Bar/C.ts still preserved"
);
assert_eq!(scoped.total_manifest_entries, 2);
fs::remove_dir_all(&root).ok();
}
#[test]
fn invalid_utf8_byte_is_read_lossily_not_dropped() {
let root = temp_dir("lossy-utf8");
let path = root.join("src/Weird.cs");
fs::create_dir_all(path.parent().unwrap()).unwrap();
let mut bytes = b"namespace Fixtures.MapCmd\n{\n // note \x97 em dash\n public class Weird\n {\n }\n}\n".to_vec();
assert!(
std::str::from_utf8(&bytes).is_err(),
"fixture must actually contain an invalid UTF-8 byte"
);
fs::write(&path, &bytes).unwrap();
bytes.clear();
let report = map_repo(&root, &[], MapOptions::default()).unwrap();
assert_eq!(report.added, 1);
assert_eq!(
report.parsed_ast, 1,
"the file must still be AST-extracted despite the bad byte"
);
assert!(report.graph_rebuilt);
assert_eq!(report.graph_def_count, Some(1));
let second = map_repo(&root, &[], MapOptions::default()).unwrap();
assert!(
!second.graph_rebuilt,
"an unchanged rerun must reuse, not be permanently stale"
);
fs::remove_dir_all(&root).ok();
}
#[test]
fn hash_reuse_survives_an_mtime_only_touch() {
let root = temp_dir("hash-reuse");
let opts = MapOptions { hash_reuse: true };
let content = "namespace Fixtures.MapCmd { public class A {} }\n";
write_file(&root.join("src/A.cs"), content);
let first = map_repo(&root, &[], opts).unwrap();
assert_eq!(first.added, 1);
assert!(first.graph_rebuilt);
std::thread::sleep(std::time::Duration::from_millis(10));
write_file(&root.join("src/A.cs"), content);
let second = map_repo(&root, &[], opts).unwrap();
assert_eq!(second.added, 0);
assert_eq!(second.downgraded, 0);
assert!(
!second.graph_rebuilt,
"same content hash must reuse, not reparse"
);
fs::remove_dir_all(&root).ok();
}
#[test]
fn hash_reuse_reparses_on_real_content_change() {
let root = temp_dir("hash-reparse");
let opts = MapOptions { hash_reuse: true };
write_file(
&root.join("src/A.cs"),
"namespace Fixtures.MapCmd { public class A {} }\n",
);
map_repo(&root, &[], opts).unwrap();
write_file(
&root.join("src/A.cs"),
"namespace Fixtures.MapCmd { public class A { public void M() {} } }\n",
);
let second = map_repo(&root, &[], opts).unwrap();
assert!(second.graph_rebuilt, "changed content hash must reparse");
fs::remove_dir_all(&root).ok();
}
#[test]
fn mtime_default_mode_ignores_content_and_keys_on_mtime_only() {
let root = temp_dir("mtime-default");
let content = "namespace Fixtures.MapCmd { public class A {} }\n";
write_file(&root.join("src/A.cs"), content);
map_repo(&root, &[], MapOptions::default()).unwrap();
std::thread::sleep(std::time::Duration::from_millis(10));
write_file(&root.join("src/A.cs"), content);
let second = map_repo(&root, &[], MapOptions::default()).unwrap();
assert!(
second.graph_rebuilt,
"default mode has no content-awareness -- an mtime bump alone reparses"
);
fs::remove_dir_all(&root).ok();
}
#[test]
fn zero_export_ts_file_is_tagged_ast_none_not_heuristic() {
let root = temp_dir("ast-none-tag");
write_file(
&root.join("src/internal.ts"),
"function helper() { return 1; }\n",
);
let report = map_repo(&root, &[], MapOptions::default()).unwrap();
assert_eq!(report.added, 1);
assert_eq!(
report.parsed_ast, 0,
"ast-none never counts toward parsed_ast"
);
let manifest = manifest::read_manifest(&root).unwrap().unwrap();
let entries = manifest.get("entries").unwrap().as_object().unwrap();
let entry = entries
.iter()
.find(|(k, _)| k == "src/internal.ts")
.unwrap()
.1
.clone();
assert_eq!(
entry.get("source").and_then(Value::as_str),
Some("ast-none")
);
fs::remove_dir_all(&root).ok();
}
#[test]
fn unchanged_rerun_does_not_recompute_ast_none_purposes() {
let root = temp_dir("ast-none-resend-trap");
write_file(
&root.join("src/internal.ts"),
"function helper() { return 1; }\n",
);
map_repo(&root, &[], MapOptions::default()).unwrap();
let manifest = manifest::read_manifest(&root).unwrap().unwrap();
let built_at_head = manifest
.get("built_at_head")
.cloned()
.unwrap_or(Value::Null);
let scoped_dirs = manifest
.get("scoped_dirs")
.cloned()
.unwrap_or_else(|| Value::array(vec![]));
let mut entries: Vec<(String, Value)> = manifest
.get("entries")
.and_then(Value::as_object)
.unwrap()
.to_vec();
for (k, v) in entries.iter_mut() {
if k == "src/internal.ts" {
assert_eq!(
v.get("source").and_then(Value::as_str),
Some("ast-none"),
"precondition"
);
let cache_key = v.get("mtime").cloned().expect("mtime present");
*v = Value::object(vec![
("purpose", Value::string("__SENTINEL_DO_NOT_RECOMPUTE__")),
("mtime", cache_key),
("source", Value::string("ast-none")),
]);
}
}
manifest::write_manifest(
&root,
&Value::object(vec![
("built_at_head", built_at_head),
("scoped_dirs", scoped_dirs),
("entries", Value::Object(entries)),
]),
)
.unwrap();
let second = map_repo(&root, &[], MapOptions::default()).unwrap();
assert_eq!(second.added, 0);
assert_eq!(second.downgraded, 0);
let after = manifest::read_manifest(&root).unwrap().unwrap();
let after_entries = after.get("entries").and_then(Value::as_object).unwrap();
let reused = after_entries
.iter()
.find(|(k, _)| k == "src/internal.ts")
.unwrap()
.1
.clone();
assert_eq!(
reused.get("purpose").and_then(Value::as_str),
Some("__SENTINEL_DO_NOT_RECOMPUTE__"),
"unchanged rerun must not resend this file for recompute"
);
assert_eq!(
reused.get("source").and_then(Value::as_str),
Some("ast-none")
);
fs::remove_dir_all(&root).ok();
}
#[test]
fn empty_dir_maps_to_zero_entries_without_erroring() {
let root = temp_dir("empty");
fs::create_dir_all(&root).unwrap();
let report = map_repo(&root, &[], MapOptions::default()).unwrap();
assert_eq!(report.scoped_file_count, 0);
assert_eq!(report.total_manifest_entries, 0);
assert!(report.graph_rebuilt);
assert_eq!(report.graph_def_count, Some(0));
let second = map_repo(&root, &[], MapOptions::default()).unwrap();
assert!(!second.graph_rebuilt);
fs::remove_dir_all(&root).ok();
}
}