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}