devscout_rs/graph/imports.rs
1// The imported-edges artifact: a validated, versioned cross-repo edge export
2// loaded by the `import-edges` verb and read back by `impact`. Lives beside
3// `graph.json` under the same graph directory, but is its own file with its
4// own schema -- importing one never touches `graph.json` or bumps
5// `GRAPH_SCHEMA_VERSION`.
6//
7// The wire shape this module parses is the one the producer actually writes:
8// an envelope `{schemaVersion, format, tool, provenance, edges}` where each
9// edge end is `{repo, ref, file, line}` (the message end of a `publishes`/
10// `consumes`/`enqueues` triple carries `repo: "message"` with no file or
11// line) and `provenance` is a block -- `id`, `producer`, `formatVersion` --
12// carried once in the envelope, not per record. Only `id` is ever compared;
13// nothing here recomputes or verifies the digest it names.
14//
15// Validation is loud and total: a malformed file, a format or schema-version
16// mismatch, a record missing `kind`/`from`/`to`, or a kind outside the fixed
17// set below is rejected with a message naming the offending value, and
18// rejection never touches whatever artifact already exists on disk -- the
19// caller only calls `write_imported_edges` once `parse_imported_edges`
20// returns `Ok`.
21
22use std::fs;
23use std::io;
24use std::path::{Path, PathBuf};
25
26use serde::{Deserialize, Serialize};
27use serde_json::Value;
28
29use super::paths::{atomic_write_json, graph_dir};
30
31/// The export format `import-edges` accepts.
32pub const IMPORTED_EDGES_FORMAT: &str = "flowtrace-edges";
33
34/// The export schema version `import-edges` accepts.
35pub const IMPORTED_EDGES_SCHEMA_VERSION: u64 = 1;
36
37/// The closed set of edge kinds a record may carry. Fixed by the producer's
38/// own contract -- adding a kind here is out of scope for the importer.
39pub const IMPORTED_EDGE_KINDS: &[&str] = &["calls", "consumes", "enqueues", "publishes", "tests"];
40
41/// One end of an imported edge.
42///
43/// Every field but `repo` is optional: the message end of a
44/// `publishes`/`consumes`/`enqueues` triple carries no file or line, and a
45/// producer that does not track a reference name leaves `ref` absent. Joined
46/// to this graph on `file` (repo-relative) plus `repo`, never on `ref` --
47/// `ref` is carried through for display only.
48#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
49pub struct EdgeEnd {
50 /// The repo id this end belongs to, or `"message"` for a fileless
51 /// message node. Absent (rather than a hard parse failure) when a
52 /// producer omits it -- an end with no repo simply never matches the
53 /// mapped repo id.
54 #[serde(default)]
55 pub repo: Option<String>,
56 /// The producer's own symbol reference, display-only.
57 #[serde(rename = "ref", default)]
58 pub reference: Option<String>,
59 /// The repo-relative file this end sits in, absent on a message end.
60 #[serde(default)]
61 pub file: Option<String>,
62 /// The line this end sits at, absent on a message end.
63 #[serde(default)]
64 pub line: Option<u64>,
65}
66
67/// One joined edge from the export.
68///
69/// A `kind` in [`IMPORTED_EDGE_KINDS`], its two ends, and the join key a
70/// `publishes` record shares with the `consumes`/`enqueues` record on the
71/// other side of the same message node.
72#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
73pub struct ImportRecord {
74 /// The kind value.
75 pub kind: String,
76 /// The from value.
77 pub from: EdgeEnd,
78 /// The to value.
79 pub to: EdgeEnd,
80 /// The message join key, present on `publishes`/`consumes`/`enqueues`
81 /// records and absent (rather than compared) on every other kind.
82 #[serde(default)]
83 pub key: Option<String>,
84}
85
86/// The export's provenance: who produced it, in which format version, and
87/// the digest an importer keys equality on. Carried once in the envelope,
88/// never per record.
89#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
90pub struct Provenance {
91 /// The digest identifying this export's generation. Compared for
92 /// equality only -- never recomputed or verified here.
93 pub id: String,
94 /// The producer name and version, display-only.
95 #[serde(default)]
96 pub producer: String,
97 /// The producer's own format version, display-only (already checked
98 /// against [`IMPORTED_EDGES_SCHEMA_VERSION`] at the envelope level).
99 #[serde(rename = "formatVersion", default)]
100 pub format_version: u64,
101}
102
103/// The validated artifact `import-edges` writes and `impact` reads.
104///
105/// Carries the repo id the operator declared as "this repo" at import time,
106/// the export's provenance, and every accepted record. A re-import always
107/// overwrites this file wholesale -- there is no merge, so an edge the
108/// newest export no longer states is simply gone from the next `impact`,
109/// whether or not the provenance id changed.
110#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
111pub struct ImportedEdges {
112 /// This artifact's own schema version (distinct from the export's own
113 /// `schemaVersion`, already checked and not re-stored).
114 pub schema_version: u64,
115 /// The repo id from `--repo <id>` that names "this repo" inside the
116 /// export -- never inferred.
117 pub mapped_repo: String,
118 /// The export's provenance block.
119 pub provenance: Provenance,
120 /// Every accepted record, in file order.
121 pub edges: Vec<ImportRecord>,
122}
123
124/// This artifact's own schema version.
125pub const IMPORTED_EDGES_ARTIFACT_SCHEMA_VERSION: u64 = 1;
126
127fn field_str<'a>(obj: &'a serde_json::Map<String, Value>, key: &str) -> Option<&'a str> {
128 obj.get(key).and_then(Value::as_str)
129}
130
131/// Parses and validates a cross-repo edge export.
132///
133/// Names `mapped_repo` (the operator's `--repo <id>`) in the returned
134/// artifact. `Err` carries a message naming the offending value --
135/// malformed JSON, a `format` or `schemaVersion` mismatch, a record missing
136/// `kind`/`from`/`to`, or a kind outside [`IMPORTED_EDGE_KINDS`] -- and is
137/// the ONLY failure mode: nothing here writes to disk, so a caller that
138/// never reaches `Ok` never touches whatever artifact already exists.
139pub fn parse_imported_edges(bytes: &[u8], mapped_repo: &str) -> Result<ImportedEdges, String> {
140 let value: Value = serde_json::from_slice(bytes).map_err(|e| format!("malformed JSON: {e}"))?;
141 let obj = value
142 .as_object()
143 .ok_or_else(|| "malformed JSON: expected an object at the top level".to_string())?;
144
145 let format = field_str(obj, "format").unwrap_or("<missing>");
146 if format != IMPORTED_EDGES_FORMAT {
147 return Err(format!(
148 "unsupported format \"{format}\", expected \"{IMPORTED_EDGES_FORMAT}\""
149 ));
150 }
151 let schema_version = obj.get("schemaVersion").and_then(Value::as_u64);
152 if schema_version != Some(IMPORTED_EDGES_SCHEMA_VERSION) {
153 let got = obj
154 .get("schemaVersion")
155 .map(ToString::to_string)
156 .unwrap_or_else(|| "<missing>".to_string());
157 return Err(format!(
158 "unsupported schemaVersion {got}, expected {IMPORTED_EDGES_SCHEMA_VERSION}"
159 ));
160 }
161
162 let provenance_value = obj
163 .get("provenance")
164 .ok_or_else(|| "missing \"provenance\" block".to_string())?;
165 let provenance: Provenance = serde_json::from_value(provenance_value.clone())
166 .map_err(|e| format!("malformed \"provenance\" block: {e}"))?;
167
168 let edges_value = obj
169 .get("edges")
170 .and_then(Value::as_array)
171 .ok_or_else(|| "missing \"edges\" array".to_string())?;
172
173 let mut edges = Vec::with_capacity(edges_value.len());
174 for (i, raw) in edges_value.iter().enumerate() {
175 let rec_obj = raw
176 .as_object()
177 .ok_or_else(|| format!("edges[{i}] is not an object"))?;
178 let kind =
179 field_str(rec_obj, "kind").ok_or_else(|| format!("edges[{i}] missing \"kind\""))?;
180 if !IMPORTED_EDGE_KINDS.contains(&kind) {
181 let allowed = IMPORTED_EDGE_KINDS.join("|");
182 return Err(format!(
183 "edges[{i}] has kind \"{kind}\", not one of {allowed}"
184 ));
185 }
186 let from_value = rec_obj
187 .get("from")
188 .ok_or_else(|| format!("edges[{i}] missing \"from\""))?;
189 let to_value = rec_obj
190 .get("to")
191 .ok_or_else(|| format!("edges[{i}] missing \"to\""))?;
192 let from: EdgeEnd = serde_json::from_value(from_value.clone())
193 .map_err(|e| format!("edges[{i}].from is malformed: {e}"))?;
194 let to: EdgeEnd = serde_json::from_value(to_value.clone())
195 .map_err(|e| format!("edges[{i}].to is malformed: {e}"))?;
196 let key = field_str(rec_obj, "key").map(str::to_string);
197 edges.push(ImportRecord {
198 kind: kind.to_string(),
199 from,
200 to,
201 key,
202 });
203 }
204
205 Ok(ImportedEdges {
206 schema_version: IMPORTED_EDGES_ARTIFACT_SCHEMA_VERSION,
207 mapped_repo: mapped_repo.to_string(),
208 provenance,
209 edges,
210 })
211}
212
213/// Path to the imported-edges artifact for `root`, beside `graph.json` under
214/// the same graph directory.
215pub fn imported_edges_json_path(root: &Path) -> PathBuf {
216 graph_dir(root).join("imported-edges.json")
217}
218
219/// Reads the imported-edges artifact for `root`, or `None` when absent or
220/// unreadable.
221///
222/// The same fail-open convention [`super::read_graph`] follows, since this
223/// artifact is auxiliary and its absence is the ordinary "no import
224/// configured" state, not an error.
225pub fn read_imported_edges(root: &Path) -> Option<ImportedEdges> {
226 let text = fs::read_to_string(imported_edges_json_path(root)).ok()?;
227 serde_json::from_str(&text).ok()
228}
229
230/// Writes the imported-edges artifact for `root`, atomically.
231///
232/// The one and only writer: `import-edges` calls this after
233/// [`parse_imported_edges`] returns `Ok`, always as a wholesale replace --
234/// there is no merge with whatever this file already held.
235pub fn write_imported_edges(root: &Path, data: &ImportedEdges) -> io::Result<()> {
236 atomic_write_json(&imported_edges_json_path(root), data)
237}