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