Skip to main content

devscout_rs/graph/
rebuild.rs

1use std::collections::HashMap;
2use std::fs;
3use std::io;
4use std::io::Read;
5use std::path::Path;
6
7use crate::extract;
8
9use super::artifact::{write_graph, Graph, GRAPH_SCHEMA_VERSION};
10use super::cache::{
11    read_fragments_cache, write_fragments_cache, write_fragments_index, FragmentCacheEntry,
12};
13use super::fragment_types::{AnyFragment, Fragment};
14use super::ordered::OrderedMap;
15use super::paths::{
16    atomic_write_json, graph_json_path, project_units_path, remove_superseded_caches,
17};
18
19// ---------------------------------------------------------------------------
20// `devscout map`'s cache-then-resolve-then-write cycle.
21// ---------------------------------------------------------------------------
22
23/// One file that contributes a graph fragment: C# (defs, refs, member names),
24/// markup (`x:Class`/`x:Name`, `.resw` keys -- names only) or TS/JS
25/// (imports, exported declarations, call/JSX/dispatch references).
26#[derive(Debug, Clone)]
27pub struct GraphFile {
28    /// The rel value.
29    pub rel: String,
30    /// The mtime value.
31    pub mtime: i64,
32}
33
34#[derive(Debug)]
35/// Represents `RebuildOutcome`.
36pub enum RebuildOutcome {
37    /// Represents `NotRebuilt`.
38    NotRebuilt,
39    /// Represents `Rebuilt`.
40    Rebuilt(Graph),
41}
42
43/// Whether the graph.json already on disk was written at the CURRENT schema
44/// version. Reads the first 64 bytes and compares the literal
45/// `{"schema_version":N,` prefix rather than deserializing: `schema_version`
46/// is the first key `Graph` serializes, the artifact can be tens of
47/// megabytes, and this runs on the fast path whose whole point is not opening
48/// it. Anything else -- an older version, an unreadable or truncated file --
49/// answers false and costs a rebuild, which is the safe direction.
50pub(crate) fn graph_schema_is_current(root: &Path) -> bool {
51    let Ok(mut file) = fs::File::open(graph_json_path(root)) else {
52        return false;
53    };
54    let mut head = [0u8; 64];
55    let mut filled = 0;
56    while filled < head.len() {
57        match file.read(&mut head[filled..]) {
58            Ok(0) => break,
59            Ok(n) => filled += n,
60            Err(_) => return false,
61        }
62    }
63    head[..filled].starts_with(format!("{{\"schema_version\":{GRAPH_SCHEMA_VERSION},").as_bytes())
64}
65
66// Mirrors the model's units into the staleness sidecar. No model means the
67// repo declares no `.csproj`, and then the file must NOT exist: an empty
68// `[]` left behind would be indistinguishable from "no model" on the read
69// side, and the sidecar's whole job is telling those two apart.
70fn write_project_units(
71    root: &Path,
72    model: Option<&crate::project::ProjectModel>,
73) -> io::Result<()> {
74    match model {
75        Some(m) => atomic_write_json(&project_units_path(root), &crate::project::graph_units(m)),
76        None => {
77            let path = project_units_path(root);
78            match fs::remove_file(&path) {
79                Ok(()) => Ok(()),
80                Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()),
81                Err(e) => Err(e),
82            }
83        }
84    }
85}
86
87/// `fresh_fragments`: fragments this run's extractor produced for files that
88/// needed reparsing (the same set that got a fresh purpose signature in the
89/// real `devscout map` flow -- `mapcmd::map_repo` assembles it). `changed`: from the
90/// caller's own `indexIsStale`-equivalent check against `csFiles`, passed in
91/// rather than recomputed so the unchanged path never opens any graph file.
92/// `model`: the repo's discovered `.csproj` projects, or `None` when it
93/// declares none -- serialized into `graph.units` and mirrored into the
94/// `project_units_path` staleness sidecar. The caller is expected to have
95/// already folded `project::sidecar_differs` into `changed`; this function
96/// only writes the sidecar, it never decides on it.
97pub fn rebuild_graph(
98    root: &Path,
99    graph_files: &[GraphFile],
100    fresh_fragments: &HashMap<String, AnyFragment>,
101    changed: bool,
102    model: Option<&crate::project::ProjectModel>,
103) -> io::Result<RebuildOutcome> {
104    if !changed && graph_json_path(root).exists() && graph_schema_is_current(root) {
105        return Ok(RebuildOutcome::NotRebuilt);
106    }
107
108    let cache = read_fragments_cache(root);
109    // Split at the door: the two shapes share no field beyond
110    // `defs`, and letting one resolver see the other's names would resolve a
111    // C# type reference onto a same-named TypeScript const. Built as two vecs
112    // here rather than one mixed vec split later so no fragment is cloned a
113    // third time on the rebuild path.
114    let mut merged_cs: Vec<(String, Fragment)> = Vec::new();
115    let mut merged_ts: Vec<(String, extract::TsFragment)> = Vec::new();
116    let mut new_cache: OrderedMap<FragmentCacheEntry> = OrderedMap::new();
117    for f in graph_files {
118        let fragment = match cache.get(&f.rel) {
119            Some(entry) if entry.mtime == f.mtime => Some(entry.fragment.clone()),
120            _ => fresh_fragments.get(&f.rel).cloned(),
121        };
122        let Some(fragment) = fragment else { continue };
123        match &fragment {
124            AnyFragment::Cs(c) => merged_cs.push((f.rel.clone(), c.clone())),
125            AnyFragment::Ts(t) => merged_ts.push((f.rel.clone(), t.clone())),
126        }
127        new_cache.insert(
128            f.rel.clone(),
129            FragmentCacheEntry {
130                mtime: f.mtime,
131                fragment,
132            },
133        );
134    }
135
136    let graph = crate::resolve::resolve_graph_with_model(root, &merged_cs, &merged_ts, model);
137    write_graph(root, &graph)?;
138    write_project_units(root, model)?;
139    write_fragments_cache(root, &new_cache)?;
140    write_fragments_index(root, &new_cache)?;
141    remove_superseded_caches(root);
142    Ok(RebuildOutcome::Rebuilt(graph))
143}