Skip to main content

devscout_rs/graph/
paths.rs

1use std::fs;
2use std::io;
3use std::path::{Path, PathBuf};
4use std::sync::atomic::{AtomicU64, Ordering};
5
6use serde::Serialize;
7
8use crate::repo::{git_common_dir, scout_dir};
9
10// ---------------------------------------------------------------------------
11// Path resolution (private helpers for the graph directory and cache paths).
12// ---------------------------------------------------------------------------
13
14// The def/ref graph lives in the git COMMON dir (shared by every linked
15// worktree, alongside the manifest), one level down; a non-git root falls
16// back to `<root>/.scout/graph/`.
17pub(crate) fn graph_dir(root: &Path) -> PathBuf {
18    match git_common_dir(root) {
19        Some(common) => common.join("scout").join("graph"),
20        None => scout_dir(root).join("graph"),
21    }
22}
23
24/// Path to the graph artifact (`graph.json`) for `root`.
25pub fn graph_json_path(root: &Path) -> PathBuf {
26    graph_dir(root).join("graph.json")
27}
28
29/// Path to the project-model staleness sidecar for `root`.
30///
31/// Holds exactly the bytes `graph.json`'s `units` array would carry -- the
32/// serialized `Vec<GraphUnit>` and nothing else. `map` compares this file
33/// against a freshly discovered model to notice a `.csproj` edit, which no
34/// mtime in the fragments index can see: `.csproj` is not a `SOURCE_EXT`, so
35/// editing one moves no graph file and `index_is_stale` stays false. Written
36/// by `rebuild_graph` only when a model exists and DELETED when one does not,
37/// so a repo that never had a `.csproj` never grows the file and one whose
38/// last `.csproj` was removed still sees a difference on the next run.
39pub fn project_units_path(root: &Path) -> PathBuf {
40    graph_dir(root).join("project-units.json")
41}
42
43// The version in both cache filenames is the fragment SCHEMA version, bumped
44// whenever the extractor starts recording something old cached fragments
45// lack -- v10 added def `type_params`/`base_generic_args` and the new
46// `ctor-param` ref kind (with its `args` field), v11 added markup graph
47// facts (a `.xaml` file's fragment now carries the `x:Class` def and its
48// element/`x:Bind` refs, where every cached markup fragment before it carried
49// names only), v12 added the TS/JS reference fragment, v13 added def
50// `propertyTypes` and ref `receiverPropertyOwner` plus the ref
51// `receiverCallOwner`/`receiverCallMember` pair, plus the foreach
52// element-type fact -- no new field (it settles into the existing
53// `receiver_type`), but a cached fragment from before it can still disagree
54// with a fresh one for the same unchanged file, so it rides the same bump
55// rather than skipping it. v16 added the ref `receiverBase` flag, set only
56// for a `base.` qualifier -- a cached v15 fragment carries none, so every
57// `base.` receiver would silently resolve (or fail to resolve) as if it
58// were a plain `this.` receiver. v17 added no field: the extractor now
59// blanks the inactive arms of `#if`/`#else` groups before parsing, so a
60// cached v16 fragment of an unchanged file can carry twin defs and refs
61// from both arms that a fresh one no longer records -- the same
62// "disagrees for an unchanged file" case that moved v13, so it rides a
63// bump too. v18 added def `methodParams` and ref `receiverLambda`, the
64// untyped-lambda-parameter callee slot -- a cached v17 fragment carries
65// neither, so every per-overload parameter shape and every untyped-lambda
66// callee-slot lookup they back would silently see no candidates. v19
67// added def `overrideMethods` (the `override`-marked method names a type
68// declares) and the fragment-level `registrations` list (each two-type-argument
69// DI service registration a file's invocations record) -- a cached v18
70// fragment carries neither, so every override-chain lookup and every
71// registration-driven `implements` edge would silently see no facts. v20
72// added ref `receiverNullable` and `lambdaArgArity` -- a cached v19
73// fragment carries neither, so a `T?` receiver's own `Nullable<T>.Value`/
74// `HasValue`/`GetValueOrDefault` unwrap would silently keep resolving
75// against `T`'s own same-named member, and a delegate-shaped argument whose
76// own parameter count disagrees with a candidate's delegate parameter
77// shape would silently keep binding it. v21 fills ref `typeArgCount` on
78// every `uses-member` ref -- a cached v20 fragment carries `null` there, so
79// a member qualifier written with a type-argument list would silently keep
80// binding a same-named sibling of the WRONG arity, and a bare qualifier
81// would silently keep binding whichever same-named sibling the def index
82// happened to order first. v22 added def `baseTypeArgs` (a base's NESTED
83// generic-argument descriptors, alongside the already-flat
84// `baseGenericArgs`), the fragment-level `publishes` list (each
85// message-bus publish-site fact a file's invocations record), def
86// `propertyMessageArgs` (the single type arguments a type's own properties
87// wrap), publish `enclosingMethod` (set when a publish hands on its own
88// caller's message, which makes the enclosing method a forwarding
89// wrapper), the fragment-level `handlerRegistrations` list (each
90// one-type-argument handler registration a file's invocations record), and
91// publish sites whose message is named by the call itself under a verb
92// this engine does not ship -- a cached v21 fragment carries none of them,
93// so a nested base's inner type argument (`IConsumer<Batch<T>>`'s `T`),
94// every publish-site fact and every message vocabulary a repository
95// declares about itself would silently be missing from a reused cache.
96// v23 added def `arrayMessageBases` (the array-marked subset of a def's own
97// `bases`, for a base whose message-position argument is an array type) --
98// a cached v22 fragment carries none, so a single message and an array of
99// it would read as the same base-list argument. v22 never shipped in an
100// accepted or released build; every run that wrote one, including this
101// lane's own measurement runs, is superseded by the rename same as any
102// other generation.
103// The rename IS the invalidation mechanism:
104// pre-bump caches stop being found, every file reparses
105// once, no reader carries version-compat logic. Writers delete every
106// superseded generation (see `remove_superseded_caches`).
107pub(crate) fn fragments_cache_path(root: &Path) -> PathBuf {
108    graph_dir(root).join("fragments-v23.json")
109}
110
111pub(crate) fn fragments_index_path(root: &Path) -> PathBuf {
112    graph_dir(root).join("fragments-index-v23.json")
113}
114
115// Every generation below the current one, not just the immediately previous:
116// a repo mapped last under v1 and never since would otherwise keep its v1
117// pair forever.
118pub(crate) const SUPERSEDED_CACHE_FILES: &[&str] = &[
119    "fragments.json",
120    "fragments-index.json",
121    "fragments-v2.json",
122    "fragments-index-v2.json",
123    "fragments-v3.json",
124    "fragments-index-v3.json",
125    "fragments-v4.json",
126    "fragments-index-v4.json",
127    "fragments-v5.json",
128    "fragments-index-v5.json",
129    "fragments-v6.json",
130    "fragments-index-v6.json",
131    "fragments-v7.json",
132    "fragments-index-v7.json",
133    "fragments-v8.json",
134    "fragments-index-v8.json",
135    "fragments-v9.json",
136    "fragments-index-v9.json",
137    "fragments-v10.json",
138    "fragments-index-v10.json",
139    "fragments-v11.json",
140    "fragments-index-v11.json",
141    "fragments-v12.json",
142    "fragments-index-v12.json",
143    "fragments-v13.json",
144    "fragments-index-v13.json",
145    "fragments-v14.json",
146    "fragments-index-v14.json",
147    "fragments-v15.json",
148    "fragments-index-v15.json",
149    "fragments-v16.json",
150    "fragments-index-v16.json",
151    "fragments-v17.json",
152    "fragments-index-v17.json",
153    "fragments-v18.json",
154    "fragments-index-v18.json",
155    "fragments-v19.json",
156    "fragments-index-v19.json",
157    "fragments-v20.json",
158    "fragments-index-v20.json",
159    "fragments-v21.json",
160    "fragments-index-v21.json",
161    "fragments-v22.json",
162    "fragments-index-v22.json",
163];
164
165pub(crate) fn remove_superseded_caches(root: &Path) {
166    for name in SUPERSEDED_CACHE_FILES {
167        // Best-effort cleanup only -- a leftover superseded file is inert.
168        let _ = fs::remove_file(graph_dir(root).join(name));
169    }
170}
171
172// ---------------------------------------------------------------------------
173// Atomic writes -- tmp file in the SAME directory as the target (so
174// the final `rename` is same-filesystem and therefore atomic), unique name
175// so concurrent writers (this process's own sequential graph/fragments/index
176// writes, or a genuinely concurrent process) never collide. Writing to a
177// tmp file and renaming is a deliberate upgrade over an in-place write --
178// output BYTES are unaffected, only the write's crash-safety.
179// ---------------------------------------------------------------------------
180
181static TMP_COUNTER: AtomicU64 = AtomicU64::new(0);
182
183pub(crate) fn atomic_write_bytes(path: &Path, bytes: &[u8]) -> io::Result<()> {
184    let dir = path.parent().unwrap_or_else(|| Path::new("."));
185    fs::create_dir_all(dir)?;
186    let file_name = path
187        .file_name()
188        .and_then(|n| n.to_str())
189        .unwrap_or("artifact");
190    let counter = TMP_COUNTER.fetch_add(1, Ordering::Relaxed);
191    let tmp_path = dir.join(format!(".{file_name}.tmp.{}.{counter}", std::process::id()));
192    let write_result = fs::write(&tmp_path, bytes);
193    if write_result.is_err() {
194        let _ = fs::remove_file(&tmp_path);
195        write_result?;
196    }
197    fs::rename(&tmp_path, path)?;
198    Ok(())
199}
200
201pub(crate) fn atomic_write_json<T: Serialize>(path: &Path, value: &T) -> io::Result<()> {
202    let bytes = serde_json::to_vec(value).map_err(io::Error::other)?;
203    atomic_write_bytes(path, &bytes)
204}