use std::collections::{BTreeMap, HashSet};
use rto_graph::{Edge, EdgeKind, FactSet, Node, NodeKind, Provenance};
use serde::Deserialize;
pub const GRAPHIFY_REF: &str = "import:graphify";
#[derive(Debug, thiserror::Error)]
pub enum ImportError {
#[error("invalid graphify json: {0}")]
Json(#[from] serde_json::Error),
}
#[derive(Debug, Clone)]
pub struct GraphifyImport {
pub facts: FactSet,
pub report: ImportReport,
}
#[derive(Debug, Clone, Default, serde::Serialize)]
pub struct ImportReport {
pub nodes_total: usize,
pub nodes_imported: usize,
pub nodes_dropped_code: usize,
pub nodes_by_type: BTreeMap<String, usize>,
pub links_total: usize,
pub edges_imported: usize,
pub edges_dropped_ast: usize,
pub edges_skipped_dangling: usize,
pub hyperedges_total: usize,
pub hyperedges_imported: usize,
}
#[derive(Deserialize)]
struct GraphifyGraph {
#[serde(default)]
nodes: Vec<GNode>,
#[serde(default)]
links: Vec<GLink>,
#[serde(default)]
hyperedges: Vec<GHyper>,
}
#[derive(Deserialize)]
struct GNode {
id: String,
#[serde(default)]
label: String,
#[serde(default)]
file_type: String,
#[serde(default)]
source_file: Option<String>,
#[serde(rename = "_origin", default)]
origin: String,
#[serde(default)]
community_name: Option<String>,
}
#[derive(Deserialize)]
struct GLink {
source: String,
target: String,
#[serde(default)]
relation: String,
#[serde(default)]
confidence: String,
#[serde(default)]
confidence_score: Option<f64>,
#[serde(rename = "_origin", default)]
origin: String,
}
#[derive(Deserialize)]
struct GHyper {
id: String,
#[serde(default)]
label: String,
#[serde(default)]
nodes: Vec<String>,
#[serde(default)]
confidence_score: Option<f64>,
}
fn is_code_node(n: &GNode) -> bool {
n.file_type == "code"
}
fn is_semantic_link(l: &GLink) -> bool {
l.origin != "ast" || l.confidence.eq_ignore_ascii_case("inferred")
}
fn node_kind(file_type: &str) -> NodeKind {
match file_type {
"document" | "" => NodeKind::Doc,
other => NodeKind::Other(other.to_owned()),
}
}
fn edge_kind(relation: &str) -> EdgeKind {
match relation {
"conceptually_related_to" | "semantically_similar_to" | "" => EdgeKind::Related,
"references" | "rationale_for" => EdgeKind::References,
other => EdgeKind::Other(other.to_owned()),
}
}
fn key(id: &str) -> String {
format!("graphify:{id}")
}
fn group_key(id: &str) -> String {
format!("graphify:group:{id}")
}
fn confidence(score: Option<f64>) -> f64 {
score.unwrap_or(0.5).clamp(0.0, 1.0)
}
pub fn import_graphify(json: &str) -> Result<GraphifyImport, ImportError> {
let graph: GraphifyGraph = serde_json::from_str(json)?;
let mut report = ImportReport {
nodes_total: graph.nodes.len(),
links_total: graph.links.len(),
hyperedges_total: graph.hyperedges.len(),
..ImportReport::default()
};
let mut facts = FactSet::new();
let mut imported: HashSet<String> = HashSet::new();
for n in &graph.nodes {
if is_code_node(n) {
report.nodes_dropped_code += 1;
continue;
}
let node_key = key(&n.id);
let name = if n.label.is_empty() {
n.id.clone()
} else {
n.label.clone()
};
let mut node = Node::new(node_key.clone(), node_kind(&n.file_type), name)
.with_provenance(Provenance::Inferred);
node.path.clone_from(&n.source_file);
node.meta = serde_json::json!({
"graphify_id": n.id,
"file_type": n.file_type,
"origin": n.origin,
"community": n.community_name,
});
facts.nodes.push(node);
imported.insert(node_key);
*report
.nodes_by_type
.entry(if n.file_type.is_empty() {
"unknown".to_owned()
} else {
n.file_type.clone()
})
.or_default() += 1;
report.nodes_imported += 1;
}
for l in &graph.links {
if !is_semantic_link(l) {
report.edges_dropped_ast += 1;
continue;
}
let (src, dst) = (key(&l.source), key(&l.target));
if !imported.contains(&src) || !imported.contains(&dst) {
report.edges_skipped_dangling += 1;
continue;
}
let mut edge = Edge::inferred(
src,
dst,
edge_kind(&l.relation),
confidence(l.confidence_score),
);
edge.src_ref = Some(GRAPHIFY_REF.to_owned());
facts.edges.push(edge);
report.edges_imported += 1;
}
for h in &graph.hyperedges {
let members: Vec<String> = h
.nodes
.iter()
.map(|m| key(m))
.filter(|m| imported.contains(m))
.collect();
if members.is_empty() {
continue;
}
let gkey = group_key(&h.id);
let name = if h.label.is_empty() {
h.id.clone()
} else {
h.label.clone()
};
let mut group = Node::new(gkey.clone(), NodeKind::Other("group".to_owned()), name)
.with_provenance(Provenance::Inferred);
group.meta = serde_json::json!({ "graphify_id": h.id, "kind": "hyperedge" });
facts.nodes.push(group);
for member in members {
let mut edge = Edge::inferred(
gkey.clone(),
member,
EdgeKind::Related,
confidence(h.confidence_score),
);
edge.src_ref = Some(GRAPHIFY_REF.to_owned());
facts.edges.push(edge);
}
report.hyperedges_imported += 1;
}
Ok(GraphifyImport { facts, report })
}
#[cfg(test)]
mod tests {
use super::{GRAPHIFY_REF, import_graphify};
use rto_graph::{EdgeKind, NodeKind, Provenance};
const SAMPLE: &str = r#"{
"directed": false, "multigraph": false,
"nodes": [
{"id": "adr59", "label": "ADR-0059", "file_type": "concept", "source_file": "docs/adr/0059.md", "_origin": "semantic", "community_name": "adrs"},
{"id": "doc1", "label": "Design note", "file_type": "document", "source_file": "docs/design.md", "_origin": "semantic"},
{"id": "codeA", "label": "fn a", "file_type": "code", "source_file": "src/a.rs", "_origin": "ast"}
],
"links": [
{"source": "adr59", "target": "doc1", "relation": "conceptually_related_to", "confidence": "INFERRED", "confidence_score": 0.82, "_origin": "semantic"},
{"source": "codeA", "target": "doc1", "relation": "references", "confidence": "EXTRACTED", "confidence_score": 1.0, "_origin": "ast"},
{"source": "adr59", "target": "codeA", "relation": "references", "confidence": "EXTRACTED", "confidence_score": 1.0, "_origin": "semantic"}
],
"hyperedges": [
{"id": "grp1", "label": "ADR cluster", "nodes": ["adr59", "doc1", "codeA"], "confidence_score": 0.9}
]
}"#;
#[test]
fn imports_docs_and_semantic_edges_drops_code() {
let out = import_graphify(SAMPLE).expect("import");
let r = &out.report;
assert_eq!(r.nodes_total, 3);
assert_eq!(r.nodes_imported, 2);
assert_eq!(r.nodes_dropped_code, 1);
assert_eq!(r.nodes_by_type.get("concept"), Some(&1));
assert_eq!(r.nodes_by_type.get("document"), Some(&1));
assert_eq!(r.edges_imported, 1);
assert_eq!(r.edges_dropped_ast, 1);
assert_eq!(r.edges_skipped_dangling, 1);
assert_eq!(r.hyperedges_imported, 1);
let e = out
.facts
.edges
.iter()
.find(|e| e.src == "graphify:adr59" && e.dst == "graphify:doc1")
.expect("semantic edge");
assert_eq!(e.provenance, Provenance::Inferred);
assert_eq!(e.kind, EdgeKind::Related);
assert_eq!(e.confidence, Some(0.82));
assert_eq!(e.src_ref.as_deref(), Some(GRAPHIFY_REF));
let n = out
.facts
.nodes
.iter()
.find(|n| n.key == "graphify:adr59")
.expect("concept node");
assert_eq!(n.kind, NodeKind::Other("concept".to_owned()));
assert_eq!(n.path.as_deref(), Some("docs/adr/0059.md"));
assert_eq!(n.meta["graphify_id"], "adr59");
assert_eq!(n.provenance, Provenance::Inferred);
let d = out
.facts
.nodes
.iter()
.find(|n| n.key == "graphify:doc1")
.expect("doc node");
assert_eq!(d.kind, NodeKind::Doc);
for edge in &out.facts.edges {
assert!(edge.is_valid());
}
}
#[test]
fn hyperedge_group_links_only_imported_members() {
let out = import_graphify(SAMPLE).expect("import");
let group = out
.facts
.nodes
.iter()
.find(|n| n.key == "graphify:group:grp1")
.expect("group node");
assert_eq!(group.kind, NodeKind::Other("group".to_owned()));
let group_edges: Vec<_> = out
.facts
.edges
.iter()
.filter(|e| e.src == "graphify:group:grp1")
.map(|e| e.dst.as_str())
.collect();
assert_eq!(group_edges.len(), 2);
assert!(group_edges.contains(&"graphify:adr59"));
assert!(group_edges.contains(&"graphify:doc1"));
assert!(!group_edges.contains(&"graphify:codeA"));
}
#[test]
fn group_id_colliding_with_a_node_id_does_not_clobber() {
let json = r#"{
"nodes": [
{"id": "x", "label": "real node", "file_type": "document", "_origin": "semantic"},
{"id": "y", "label": "other", "file_type": "concept", "_origin": "semantic"}
],
"links": [],
"hyperedges": [
{"id": "x", "label": "group named x", "nodes": ["y"], "confidence_score": 0.9}
]
}"#;
let out = import_graphify(json).expect("import");
let real = out
.facts
.nodes
.iter()
.find(|n| n.key == "graphify:x")
.expect("real node survives");
assert_eq!(real.name, "real node");
let group = out
.facts
.nodes
.iter()
.find(|n| n.key == "graphify:group:x")
.expect("group in its own namespace");
assert_eq!(group.name, "group named x");
}
#[test]
fn invalid_json_errors() {
assert!(import_graphify("not json").is_err());
}
}