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. The
72// rename IS the invalidation mechanism:
73// pre-bump caches stop being found, every file reparses
74// once, no reader carries version-compat logic. Writers delete every
75// superseded generation (see `remove_superseded_caches`).
76pub(crate) fn fragments_cache_path(root: &Path) -> PathBuf {
77 graph_dir(root).join("fragments-v19.json")
78}
79
80pub(crate) fn fragments_index_path(root: &Path) -> PathBuf {
81 graph_dir(root).join("fragments-index-v19.json")
82}
83
84// Every generation below the current one, not just the immediately previous:
85// a repo mapped last under v1 and never since would otherwise keep its v1
86// pair forever.
87pub(crate) const SUPERSEDED_CACHE_FILES: &[&str] = &[
88 "fragments.json",
89 "fragments-index.json",
90 "fragments-v2.json",
91 "fragments-index-v2.json",
92 "fragments-v3.json",
93 "fragments-index-v3.json",
94 "fragments-v4.json",
95 "fragments-index-v4.json",
96 "fragments-v5.json",
97 "fragments-index-v5.json",
98 "fragments-v6.json",
99 "fragments-index-v6.json",
100 "fragments-v7.json",
101 "fragments-index-v7.json",
102 "fragments-v8.json",
103 "fragments-index-v8.json",
104 "fragments-v9.json",
105 "fragments-index-v9.json",
106 "fragments-v10.json",
107 "fragments-index-v10.json",
108 "fragments-v11.json",
109 "fragments-index-v11.json",
110 "fragments-v12.json",
111 "fragments-index-v12.json",
112 "fragments-v13.json",
113 "fragments-index-v13.json",
114 "fragments-v14.json",
115 "fragments-index-v14.json",
116 "fragments-v15.json",
117 "fragments-index-v15.json",
118 "fragments-v16.json",
119 "fragments-index-v16.json",
120 "fragments-v17.json",
121 "fragments-index-v17.json",
122 "fragments-v18.json",
123 "fragments-index-v18.json",
124];
125
126pub(crate) fn remove_superseded_caches(root: &Path) {
127 for name in SUPERSEDED_CACHE_FILES {
128 // Best-effort cleanup only -- a leftover superseded file is inert.
129 let _ = fs::remove_file(graph_dir(root).join(name));
130 }
131}
132
133// ---------------------------------------------------------------------------
134// Atomic writes -- tmp file in the SAME directory as the target (so
135// the final `rename` is same-filesystem and therefore atomic), unique name
136// so concurrent writers (this process's own sequential graph/fragments/index
137// writes, or a genuinely concurrent process) never collide. Writing to a
138// tmp file and renaming is a deliberate upgrade over an in-place write --
139// output BYTES are unaffected, only the write's crash-safety.
140// ---------------------------------------------------------------------------
141
142static TMP_COUNTER: AtomicU64 = AtomicU64::new(0);
143
144pub(crate) fn atomic_write_bytes(path: &Path, bytes: &[u8]) -> io::Result<()> {
145 let dir = path.parent().unwrap_or_else(|| Path::new("."));
146 fs::create_dir_all(dir)?;
147 let file_name = path
148 .file_name()
149 .and_then(|n| n.to_str())
150 .unwrap_or("artifact");
151 let counter = TMP_COUNTER.fetch_add(1, Ordering::Relaxed);
152 let tmp_path = dir.join(format!(".{file_name}.tmp.{}.{counter}", std::process::id()));
153 let write_result = fs::write(&tmp_path, bytes);
154 if write_result.is_err() {
155 let _ = fs::remove_file(&tmp_path);
156 write_result?;
157 }
158 fs::rename(&tmp_path, path)?;
159 Ok(())
160}
161
162pub(crate) fn atomic_write_json<T: Serialize>(path: &Path, value: &T) -> io::Result<()> {
163 let bytes = serde_json::to_vec(value).map_err(io::Error::other)?;
164 atomic_write_bytes(path, &bytes)
165}