use std::collections::{HashMap, HashSet}; use std::error::Error; use std::fmt::{Display, Formatter}; use std::fs::File; use std::io::{BufReader, Read}; use std::path::Path; use oxrdf::vocab::rdfs; use oxrdf::{NamedOrBlankNode, Term}; use oxttl::{TurtleParseError, TurtleParser}; use crate::model::{Edge, Graph, Node}; #[derive(Debug)] pub enum TtlImportError { Io(std::io::Error), Parse(TurtleParseError), NoSubclassTriples, } impl Display for TtlImportError { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { match self { TtlImportError::Io(error) => write!(f, "failed to read Turtle input: {error}"), TtlImportError::Parse(error) => write!(f, "failed to parse Turtle input: {error}"), TtlImportError::NoSubclassTriples => { write!( f, "no usable rdfs:subClassOf triples were found in the Turtle input" ) } } } } impl Error for TtlImportError { fn source(&self) -> Option<&(dyn Error + 'static)> { match self { TtlImportError::Io(error) => Some(error), TtlImportError::Parse(error) => Some(error), TtlImportError::NoSubclassTriples => None, } } } impl From for TtlImportError { fn from(error: std::io::Error) -> Self { Self::Io(error) } } impl From for TtlImportError { fn from(error: TurtleParseError) -> Self { Self::Parse(error) } } pub fn graph_from_ttl_reader(reader: R) -> Result { let mut nodes = Vec::new(); let mut node_indices = HashMap::new(); let mut edges = Vec::new(); let mut seen_edges = HashSet::new(); for triple in TurtleParser::new().for_reader(reader) { let triple = triple?; if triple.predicate.as_ref() != rdfs::SUB_CLASS_OF { continue; } let NamedOrBlankNode::NamedNode(subject) = triple.subject else { continue; }; let Term::NamedNode(object) = triple.object else { continue; }; let subclass = get_or_insert_node(&mut nodes, &mut node_indices, subject.as_str()); let superclass = get_or_insert_node(&mut nodes, &mut node_indices, object.as_str()); if seen_edges.insert((superclass, subclass)) { edges.push(Edge::new(superclass, subclass)); } } if edges.is_empty() { return Err(TtlImportError::NoSubclassTriples); } Ok(Graph::new(nodes, edges)) } pub fn graph_from_ttl_path(path: impl AsRef) -> Result { let file = File::open(path)?; graph_from_ttl_reader(BufReader::new(file)) } fn get_or_insert_node( nodes: &mut Vec, node_indices: &mut HashMap, iri: &str, ) -> usize { if let Some(&index) = node_indices.get(iri) { return index; } let index = nodes.len(); nodes.push(Node { label: Some(iri.to_owned()), ..Node::default() }); node_indices.insert(iri.to_owned(), index); index } #[cfg(test)] mod tests { use super::*; use crate::{layout_radial_hierarchy, LayoutConfig}; const TTL_PREFIXES: &str = "@prefix ex: .\n@prefix rdfs: .\n@prefix schema: .\n"; #[test] fn imports_only_subclass_triples() { let ttl = format!( "{TTL_PREFIXES}ex:A rdfs:subClassOf ex:B .\nex:A schema:name \"Alpha\" .\nex:B schema:name \"Beta\" .\n" ); let graph = graph_from_ttl_reader(ttl.as_bytes()).unwrap(); assert_eq!(graph.nodes.len(), 2); assert_eq!(graph.edges, vec![Edge::new(1, 0)]); assert_eq!( graph.nodes[0].label.as_deref(), Some("http://example.com/A") ); assert_eq!( graph.nodes[1].label.as_deref(), Some("http://example.com/B") ); } #[test] fn deduplicates_repeated_subclass_triples() { let ttl = format!("{TTL_PREFIXES}ex:A rdfs:subClassOf ex:B .\nex:A rdfs:subClassOf ex:B .\n"); let graph = graph_from_ttl_reader(ttl.as_bytes()).unwrap(); assert_eq!(graph.edges, vec![Edge::new(1, 0)]); } #[test] fn ignores_blank_node_and_literal_targets() { let ttl = format!( "{TTL_PREFIXES}ex:A rdfs:subClassOf [ a ex:Anonymous ] .\nex:B rdfs:subClassOf \"Literal\" .\nex:C rdfs:subClassOf ex:D .\n" ); let graph = graph_from_ttl_reader(ttl.as_bytes()).unwrap(); assert_eq!(graph.nodes.len(), 2); assert_eq!(graph.edges, vec![Edge::new(1, 0)]); assert_eq!( graph.nodes[0].label.as_deref(), Some("http://example.com/C") ); assert_eq!( graph.nodes[1].label.as_deref(), Some("http://example.com/D") ); } #[test] fn imports_can_flow_into_layout() { let ttl = format!("{TTL_PREFIXES}ex:A rdfs:subClassOf ex:B .\nex:B rdfs:subClassOf ex:C .\n"); let mut graph = graph_from_ttl_reader(ttl.as_bytes()).unwrap(); layout_radial_hierarchy(&mut graph, LayoutConfig::default()).unwrap(); assert!(graph .nodes .iter() .all(|node| node.x.is_finite() && node.y.is_finite())); } #[test] fn returns_clear_error_for_invalid_turtle() { let ttl = "@prefix ex: .\nex:A rdfs:subClassOf .\n"; let error = graph_from_ttl_reader(ttl.as_bytes()).unwrap_err(); assert!(matches!(error, TtlImportError::Parse(_))); } #[test] fn returns_clear_error_when_no_subclass_triples_exist() { let ttl = format!("{TTL_PREFIXES}ex:A schema:name \"Alpha\" .\n"); let error = graph_from_ttl_reader(ttl.as_bytes()).unwrap_err(); assert!(matches!(error, TtlImportError::NoSubclassTriples)); } }