1use std::collections::{BTreeMap, HashSet};
10
11use rto_graph::{Edge, EdgeKind, FactSet, Node, NodeKind, Provenance};
12use serde::Deserialize;
13
14pub const GRAPHIFY_REF: &str = "import:graphify";
17
18#[derive(Debug, thiserror::Error)]
20pub enum ImportError {
21 #[error("invalid graphify json: {0}")]
23 Json(#[from] serde_json::Error),
24}
25
26#[derive(Debug, Clone)]
28pub struct GraphifyImport {
29 pub facts: FactSet,
31 pub report: ImportReport,
33}
34
35#[derive(Debug, Clone, Default, serde::Serialize)]
37pub struct ImportReport {
38 pub nodes_total: usize,
40 pub nodes_imported: usize,
42 pub nodes_dropped_code: usize,
44 pub nodes_by_type: BTreeMap<String, usize>,
46 pub links_total: usize,
48 pub edges_imported: usize,
50 pub edges_dropped_ast: usize,
52 pub edges_skipped_dangling: usize,
54 pub hyperedges_total: usize,
56 pub hyperedges_imported: usize,
58}
59
60#[derive(Deserialize)]
63struct GraphifyGraph {
64 #[serde(default)]
65 nodes: Vec<GNode>,
66 #[serde(default)]
67 links: Vec<GLink>,
68 #[serde(default)]
69 hyperedges: Vec<GHyper>,
70}
71
72#[derive(Deserialize)]
73struct GNode {
74 id: String,
75 #[serde(default)]
76 label: String,
77 #[serde(default)]
78 file_type: String,
79 #[serde(default)]
80 source_file: Option<String>,
81 #[serde(rename = "_origin", default)]
82 origin: String,
83 #[serde(default)]
84 community_name: Option<String>,
85}
86
87#[derive(Deserialize)]
88struct GLink {
89 source: String,
90 target: String,
91 #[serde(default)]
92 relation: String,
93 #[serde(default)]
94 confidence: String,
95 #[serde(default)]
96 confidence_score: Option<f64>,
97 #[serde(rename = "_origin", default)]
98 origin: String,
99}
100
101#[derive(Deserialize)]
102struct GHyper {
103 id: String,
104 #[serde(default)]
105 label: String,
106 #[serde(default)]
107 nodes: Vec<String>,
108 #[serde(default)]
109 confidence_score: Option<f64>,
110}
111
112fn is_code_node(n: &GNode) -> bool {
115 n.file_type == "code"
116}
117
118fn is_semantic_link(l: &GLink) -> bool {
123 l.origin != "ast" || l.confidence.eq_ignore_ascii_case("inferred")
124}
125
126fn node_kind(file_type: &str) -> NodeKind {
130 match file_type {
131 "document" | "" => NodeKind::Doc,
132 other => NodeKind::Other(other.to_owned()),
133 }
134}
135
136fn edge_kind(relation: &str) -> EdgeKind {
138 match relation {
139 "conceptually_related_to" | "semantically_similar_to" | "" => EdgeKind::Related,
140 "references" | "rationale_for" => EdgeKind::References,
141 other => EdgeKind::Other(other.to_owned()),
142 }
143}
144
145fn key(id: &str) -> String {
147 format!("graphify:{id}")
148}
149
150fn group_key(id: &str) -> String {
154 format!("graphify:group:{id}")
155}
156
157fn confidence(score: Option<f64>) -> f64 {
159 score.unwrap_or(0.5).clamp(0.0, 1.0)
160}
161
162pub fn import_graphify(json: &str) -> Result<GraphifyImport, ImportError> {
172 let graph: GraphifyGraph = serde_json::from_str(json)?;
173 let mut report = ImportReport {
174 nodes_total: graph.nodes.len(),
175 links_total: graph.links.len(),
176 hyperedges_total: graph.hyperedges.len(),
177 ..ImportReport::default()
178 };
179 let mut facts = FactSet::new();
180 let mut imported: HashSet<String> = HashSet::new();
181
182 for n in &graph.nodes {
184 if is_code_node(n) {
185 report.nodes_dropped_code += 1;
186 continue;
187 }
188 let node_key = key(&n.id);
189 let name = if n.label.is_empty() {
190 n.id.clone()
191 } else {
192 n.label.clone()
193 };
194 let mut node = Node::new(node_key.clone(), node_kind(&n.file_type), name)
195 .with_provenance(Provenance::Inferred);
196 node.path.clone_from(&n.source_file);
197 node.meta = serde_json::json!({
198 "graphify_id": n.id,
199 "file_type": n.file_type,
200 "origin": n.origin,
201 "community": n.community_name,
202 });
203 facts.nodes.push(node);
204 imported.insert(node_key);
205 *report
206 .nodes_by_type
207 .entry(if n.file_type.is_empty() {
208 "unknown".to_owned()
209 } else {
210 n.file_type.clone()
211 })
212 .or_default() += 1;
213 report.nodes_imported += 1;
214 }
215
216 for l in &graph.links {
218 if !is_semantic_link(l) {
219 report.edges_dropped_ast += 1;
220 continue;
221 }
222 let (src, dst) = (key(&l.source), key(&l.target));
223 if !imported.contains(&src) || !imported.contains(&dst) {
224 report.edges_skipped_dangling += 1;
226 continue;
227 }
228 let mut edge = Edge::inferred(
229 src,
230 dst,
231 edge_kind(&l.relation),
232 confidence(l.confidence_score),
233 );
234 edge.src_ref = Some(GRAPHIFY_REF.to_owned());
235 facts.edges.push(edge);
236 report.edges_imported += 1;
237 }
238
239 for h in &graph.hyperedges {
241 let members: Vec<String> = h
242 .nodes
243 .iter()
244 .map(|m| key(m))
245 .filter(|m| imported.contains(m))
246 .collect();
247 if members.is_empty() {
248 continue;
249 }
250 let gkey = group_key(&h.id);
251 let name = if h.label.is_empty() {
252 h.id.clone()
253 } else {
254 h.label.clone()
255 };
256 let mut group = Node::new(gkey.clone(), NodeKind::Other("group".to_owned()), name)
257 .with_provenance(Provenance::Inferred);
258 group.meta = serde_json::json!({ "graphify_id": h.id, "kind": "hyperedge" });
259 facts.nodes.push(group);
260 for member in members {
261 let mut edge = Edge::inferred(
262 gkey.clone(),
263 member,
264 EdgeKind::Related,
265 confidence(h.confidence_score),
266 );
267 edge.src_ref = Some(GRAPHIFY_REF.to_owned());
268 facts.edges.push(edge);
269 }
270 report.hyperedges_imported += 1;
271 }
272
273 Ok(GraphifyImport { facts, report })
274}
275
276#[cfg(test)]
277mod tests {
278 use super::{GRAPHIFY_REF, import_graphify};
279 use rto_graph::{EdgeKind, NodeKind, Provenance};
280
281 const SAMPLE: &str = r#"{
283 "directed": false, "multigraph": false,
284 "nodes": [
285 {"id": "adr59", "label": "ADR-0059", "file_type": "concept", "source_file": "docs/adr/0059.md", "_origin": "semantic", "community_name": "adrs"},
286 {"id": "doc1", "label": "Design note", "file_type": "document", "source_file": "docs/design.md", "_origin": "semantic"},
287 {"id": "codeA", "label": "fn a", "file_type": "code", "source_file": "src/a.rs", "_origin": "ast"}
288 ],
289 "links": [
290 {"source": "adr59", "target": "doc1", "relation": "conceptually_related_to", "confidence": "INFERRED", "confidence_score": 0.82, "_origin": "semantic"},
291 {"source": "codeA", "target": "doc1", "relation": "references", "confidence": "EXTRACTED", "confidence_score": 1.0, "_origin": "ast"},
292 {"source": "adr59", "target": "codeA", "relation": "references", "confidence": "EXTRACTED", "confidence_score": 1.0, "_origin": "semantic"}
293 ],
294 "hyperedges": [
295 {"id": "grp1", "label": "ADR cluster", "nodes": ["adr59", "doc1", "codeA"], "confidence_score": 0.9}
296 ]
297 }"#;
298
299 #[test]
300 fn imports_docs_and_semantic_edges_drops_code() {
301 let out = import_graphify(SAMPLE).expect("import");
302 let r = &out.report;
303
304 assert_eq!(r.nodes_total, 3);
306 assert_eq!(r.nodes_imported, 2);
307 assert_eq!(r.nodes_dropped_code, 1);
308 assert_eq!(r.nodes_by_type.get("concept"), Some(&1));
309 assert_eq!(r.nodes_by_type.get("document"), Some(&1));
310
311 assert_eq!(r.edges_imported, 1);
314 assert_eq!(r.edges_dropped_ast, 1);
315 assert_eq!(r.edges_skipped_dangling, 1);
316
317 assert_eq!(r.hyperedges_imported, 1);
319
320 let e = out
323 .facts
324 .edges
325 .iter()
326 .find(|e| e.src == "graphify:adr59" && e.dst == "graphify:doc1")
327 .expect("semantic edge");
328 assert_eq!(e.provenance, Provenance::Inferred);
329 assert_eq!(e.kind, EdgeKind::Related);
330 assert_eq!(e.confidence, Some(0.82));
331 assert_eq!(e.src_ref.as_deref(), Some(GRAPHIFY_REF));
332
333 let n = out
335 .facts
336 .nodes
337 .iter()
338 .find(|n| n.key == "graphify:adr59")
339 .expect("concept node");
340 assert_eq!(n.kind, NodeKind::Other("concept".to_owned()));
341 assert_eq!(n.path.as_deref(), Some("docs/adr/0059.md"));
342 assert_eq!(n.meta["graphify_id"], "adr59");
343 assert_eq!(n.provenance, Provenance::Inferred);
345
346 let d = out
348 .facts
349 .nodes
350 .iter()
351 .find(|n| n.key == "graphify:doc1")
352 .expect("doc node");
353 assert_eq!(d.kind, NodeKind::Doc);
354
355 for edge in &out.facts.edges {
357 assert!(edge.is_valid());
358 }
359 }
360
361 #[test]
362 fn hyperedge_group_links_only_imported_members() {
363 let out = import_graphify(SAMPLE).expect("import");
364 let group = out
366 .facts
367 .nodes
368 .iter()
369 .find(|n| n.key == "graphify:group:grp1")
370 .expect("group node");
371 assert_eq!(group.kind, NodeKind::Other("group".to_owned()));
372 let group_edges: Vec<_> = out
374 .facts
375 .edges
376 .iter()
377 .filter(|e| e.src == "graphify:group:grp1")
378 .map(|e| e.dst.as_str())
379 .collect();
380 assert_eq!(group_edges.len(), 2);
381 assert!(group_edges.contains(&"graphify:adr59"));
382 assert!(group_edges.contains(&"graphify:doc1"));
383 assert!(!group_edges.contains(&"graphify:codeA"));
384 }
385
386 #[test]
387 fn group_id_colliding_with_a_node_id_does_not_clobber() {
388 let json = r#"{
391 "nodes": [
392 {"id": "x", "label": "real node", "file_type": "document", "_origin": "semantic"},
393 {"id": "y", "label": "other", "file_type": "concept", "_origin": "semantic"}
394 ],
395 "links": [],
396 "hyperedges": [
397 {"id": "x", "label": "group named x", "nodes": ["y"], "confidence_score": 0.9}
398 ]
399 }"#;
400 let out = import_graphify(json).expect("import");
401 let real = out
402 .facts
403 .nodes
404 .iter()
405 .find(|n| n.key == "graphify:x")
406 .expect("real node survives");
407 assert_eq!(real.name, "real node");
408 let group = out
409 .facts
410 .nodes
411 .iter()
412 .find(|n| n.key == "graphify:group:x")
413 .expect("group in its own namespace");
414 assert_eq!(group.name, "group named x");
415 }
416
417 #[test]
418 fn invalid_json_errors() {
419 assert!(import_graphify("not json").is_err());
420 }
421}