Skip to main content

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}