use std::collections::{HashMap, HashSet};
use std::io::Write;
use crate::cli::error::CliResult as Result;
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct DiagramTriple {
pub subject: String,
pub predicate: String,
pub object: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LayoutStyle {
Tree,
Graph,
Compact,
List,
}
#[derive(Debug, Clone)]
pub struct DiagramConfig {
pub style: LayoutStyle,
pub max_width: usize,
pub max_nodes: usize,
pub max_edges: usize,
pub abbreviate_uris: bool,
pub use_unicode: bool,
}
impl Default for DiagramConfig {
fn default() -> Self {
Self {
style: LayoutStyle::Tree,
max_width: 120,
max_nodes: 50,
max_edges: 100,
abbreviate_uris: true,
use_unicode: true,
}
}
}
pub struct AsciiDiagramGenerator {
config: DiagramConfig,
}
impl AsciiDiagramGenerator {
pub fn new(config: DiagramConfig) -> Self {
Self { config }
}
pub fn generate(&self, triples: &[DiagramTriple], writer: &mut dyn Write) -> Result<()> {
if triples.is_empty() {
writeln!(writer, "(no triples to display)")?;
return Ok(());
}
match self.config.style {
LayoutStyle::Tree => self.generate_tree(triples, writer),
LayoutStyle::Graph => self.generate_graph(triples, writer),
LayoutStyle::Compact => self.generate_compact(triples, writer),
LayoutStyle::List => self.generate_list(triples, writer),
}
}
fn generate_tree(&self, triples: &[DiagramTriple], writer: &mut dyn Write) -> Result<()> {
let mut subjects: HashSet<String> = HashSet::new();
let mut objects: HashSet<String> = HashSet::new();
let mut edges: HashMap<String, Vec<(String, String)>> = HashMap::new();
for triple in triples.iter().take(self.config.max_edges) {
subjects.insert(triple.subject.clone());
objects.insert(triple.object.clone());
edges
.entry(triple.subject.clone())
.or_default()
.push((triple.predicate.clone(), triple.object.clone()));
}
let roots: Vec<String> = subjects
.iter()
.filter(|s| !objects.contains(*s))
.cloned()
.collect();
if roots.is_empty() {
if let Some(triple) = triples.first() {
self.render_tree_node(
&triple.subject,
&edges,
writer,
"",
true,
&mut HashSet::new(),
)?;
}
} else {
for (idx, root) in roots.iter().enumerate() {
let is_last = idx == roots.len() - 1;
self.render_tree_node(root, &edges, writer, "", is_last, &mut HashSet::new())?;
}
}
Ok(())
}
fn render_tree_node(
&self,
node: &str,
edges: &HashMap<String, Vec<(String, String)>>,
writer: &mut dyn Write,
prefix: &str,
is_last: bool,
visited: &mut HashSet<String>,
) -> Result<()> {
if visited.contains(node) {
writeln!(
writer,
"{}{}",
prefix,
self.format_node(&format!("{} (cyclic reference)", node))
)?;
return Ok(());
}
visited.insert(node.to_string());
let (branch, continuation) = if self.config.use_unicode {
if is_last {
("└── ", " ")
} else {
("├── ", "│ ")
}
} else if is_last {
("`-- ", " ")
} else {
("|-- ", "| ")
};
writeln!(writer, "{}{}{}", prefix, branch, self.format_node(node))?;
if let Some(children) = edges.get(node) {
for (idx, (predicate, object)) in children.iter().enumerate() {
let is_last_child = idx == children.len() - 1;
let child_prefix = format!("{}{}", prefix, continuation);
let pred_branch = if self.config.use_unicode {
if is_last_child {
"└─["
} else {
"├─["
}
} else if is_last_child {
"`-["
} else {
"|-["
};
writeln!(
writer,
"{}{}{}]",
child_prefix,
pred_branch,
self.format_predicate(predicate)
)?;
let obj_prefix = format!(
"{}{}",
child_prefix,
if is_last_child {
" "
} else if self.config.use_unicode {
"│ "
} else {
"| "
}
);
self.render_tree_node(object, edges, writer, &obj_prefix, true, visited)?;
}
}
visited.remove(node);
Ok(())
}
fn generate_graph(&self, triples: &[DiagramTriple], writer: &mut dyn Write) -> Result<()> {
writeln!(writer, "RDF Graph (ASCII representation):")?;
writeln!(writer)?;
let connector = if self.config.use_unicode { "─" } else { "-" };
let arrow = if self.config.use_unicode { "→" } else { "->" };
for (idx, triple) in triples.iter().enumerate() {
if self.config.max_edges > 0 && idx >= self.config.max_edges {
writeln!(writer, "... ({} more triples)", triples.len() - idx)?;
break;
}
let subject = self.format_node(&triple.subject);
let predicate = self.format_predicate(&triple.predicate);
let object = self.format_node(&triple.object);
let total_len = subject.len() + predicate.len() + object.len() + 10;
let connector_count = if total_len < self.config.max_width {
(self.config.max_width - total_len) / 2
} else {
2
};
writeln!(
writer,
"{} {}{}{} {} {}",
subject,
connector.repeat(connector_count),
predicate,
connector.repeat(connector_count),
arrow,
object
)?;
}
Ok(())
}
fn generate_compact(&self, triples: &[DiagramTriple], writer: &mut dyn Write) -> Result<()> {
writeln!(writer, "RDF Triples (Compact):")?;
writeln!(writer)?;
let mut grouped: HashMap<String, Vec<(String, String)>> = HashMap::new();
for triple in triples {
grouped
.entry(triple.subject.clone())
.or_default()
.push((triple.predicate.clone(), triple.object.clone()));
}
for (idx, (subject, predicates)) in grouped.iter().enumerate() {
if self.config.max_nodes > 0 && idx >= self.config.max_nodes {
writeln!(writer, "... ({} more subjects)", grouped.len() - idx)?;
break;
}
writeln!(writer, "{}", self.format_node(subject))?;
for (pred_idx, (predicate, object)) in predicates.iter().enumerate() {
let prefix = if pred_idx == predicates.len() - 1 {
if self.config.use_unicode {
" └─"
} else {
" `-"
}
} else if self.config.use_unicode {
" ├─"
} else {
" |-"
};
writeln!(
writer,
"{} {} {}",
prefix,
self.format_predicate(predicate),
self.format_node(object)
)?;
}
writeln!(writer)?;
}
Ok(())
}
fn generate_list(&self, triples: &[DiagramTriple], writer: &mut dyn Write) -> Result<()> {
writeln!(writer, "RDF Triples:")?;
writeln!(writer)?;
for (idx, triple) in triples.iter().enumerate() {
if self.config.max_edges > 0 && idx >= self.config.max_edges {
writeln!(writer, "... ({} more triples)", triples.len() - idx)?;
break;
}
writeln!(
writer,
"{:4}. {} {} {}",
idx + 1,
self.format_node(&triple.subject),
self.format_predicate(&triple.predicate),
self.format_node(&triple.object)
)?;
}
Ok(())
}
fn format_node(&self, node: &str) -> String {
if self.config.abbreviate_uris {
self.abbreviate_uri(node)
} else {
node.to_string()
}
}
fn format_predicate(&self, predicate: &str) -> String {
if self.config.abbreviate_uris {
self.abbreviate_uri(predicate)
} else {
predicate.to_string()
}
}
fn abbreviate_uri(&self, uri: &str) -> String {
let uri = uri.trim_start_matches('<').trim_end_matches('>');
let common_prefixes = [
("http://www.w3.org/1999/02/22-rdf-syntax-ns#", "rdf:"),
("http://www.w3.org/2000/01/rdf-schema#", "rdfs:"),
("http://www.w3.org/2002/07/owl#", "owl:"),
("http://www.w3.org/ns/shacl#", "sh:"),
("http://xmlns.com/foaf/0.1/", "foaf:"),
("http://purl.org/dc/elements/1.1/", "dc:"),
("http://purl.org/dc/terms/", "dct:"),
("http://schema.org/", "schema:"),
];
for (prefix, abbrev) in &common_prefixes {
if let Some(stripped) = uri.strip_prefix(prefix) {
return format!("{}{}", abbrev, stripped);
}
}
if let Some(pos) = uri.rfind(['/', '#']) {
uri[pos + 1..].to_string()
} else {
uri.to_string()
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_abbreviate_uri() {
let config = DiagramConfig::default();
let generator = AsciiDiagramGenerator::new(config);
assert_eq!(
generator.abbreviate_uri("http://www.w3.org/1999/02/22-rdf-syntax-ns#type"),
"rdf:type"
);
assert_eq!(
generator.abbreviate_uri("http://xmlns.com/foaf/0.1/name"),
"foaf:name"
);
assert_eq!(
generator.abbreviate_uri("http://example.org/person/John"),
"John"
);
}
#[test]
fn test_tree_layout_simple() {
let triples = vec![
DiagramTriple {
subject: "ex:John".to_string(),
predicate: "rdf:type".to_string(),
object: "foaf:Person".to_string(),
},
DiagramTriple {
subject: "ex:John".to_string(),
predicate: "foaf:name".to_string(),
object: "\"John Doe\"".to_string(),
},
];
let config = DiagramConfig::default();
let generator = AsciiDiagramGenerator::new(config);
let mut output = Vec::new();
generator.generate(&triples, &mut output).unwrap();
let result = String::from_utf8(output).unwrap();
assert!(result.contains("ex:John"));
assert!(result.contains("type"));
assert!(result.contains("name"));
}
#[test]
fn test_graph_layout() {
let triples = vec![
DiagramTriple {
subject: "ex:John".to_string(),
predicate: "foaf:knows".to_string(),
object: "ex:Jane".to_string(),
},
DiagramTriple {
subject: "ex:Jane".to_string(),
predicate: "foaf:knows".to_string(),
object: "ex:Bob".to_string(),
},
];
let config = DiagramConfig {
style: LayoutStyle::Graph,
..Default::default()
};
let generator = AsciiDiagramGenerator::new(config);
let mut output = Vec::new();
generator.generate(&triples, &mut output).unwrap();
let result = String::from_utf8(output).unwrap();
assert!(result.contains("RDF Graph"));
assert!(result.contains("ex:John"));
assert!(result.contains("ex:Jane"));
}
#[test]
fn test_compact_layout() {
let triples = vec![
DiagramTriple {
subject: "ex:John".to_string(),
predicate: "rdf:type".to_string(),
object: "foaf:Person".to_string(),
},
DiagramTriple {
subject: "ex:John".to_string(),
predicate: "foaf:name".to_string(),
object: "\"John Doe\"".to_string(),
},
DiagramTriple {
subject: "ex:Jane".to_string(),
predicate: "rdf:type".to_string(),
object: "foaf:Person".to_string(),
},
];
let config = DiagramConfig {
style: LayoutStyle::Compact,
..Default::default()
};
let generator = AsciiDiagramGenerator::new(config);
let mut output = Vec::new();
generator.generate(&triples, &mut output).unwrap();
let result = String::from_utf8(output).unwrap();
assert!(result.contains("Compact"));
assert!(result.contains("ex:John"));
assert!(result.contains("ex:Jane"));
}
#[test]
fn test_list_layout() {
let triples = vec![
DiagramTriple {
subject: "ex:John".to_string(),
predicate: "rdf:type".to_string(),
object: "foaf:Person".to_string(),
},
DiagramTriple {
subject: "ex:John".to_string(),
predicate: "foaf:name".to_string(),
object: "\"John Doe\"".to_string(),
},
];
let config = DiagramConfig {
style: LayoutStyle::List,
..Default::default()
};
let generator = AsciiDiagramGenerator::new(config);
let mut output = Vec::new();
generator.generate(&triples, &mut output).unwrap();
let result = String::from_utf8(output).unwrap();
assert!(result.contains("RDF Triples:"));
assert!(result.contains("1."));
assert!(result.contains("2."));
}
#[test]
fn test_empty_triples() {
let triples = vec![];
let config = DiagramConfig::default();
let generator = AsciiDiagramGenerator::new(config);
let mut output = Vec::new();
generator.generate(&triples, &mut output).unwrap();
let result = String::from_utf8(output).unwrap();
assert!(result.contains("no triples"));
}
#[test]
fn test_max_edges_limit() {
let triples = vec![
DiagramTriple {
subject: "ex:S1".to_string(),
predicate: "ex:p".to_string(),
object: "ex:O1".to_string(),
},
DiagramTriple {
subject: "ex:S2".to_string(),
predicate: "ex:p".to_string(),
object: "ex:O2".to_string(),
},
DiagramTriple {
subject: "ex:S3".to_string(),
predicate: "ex:p".to_string(),
object: "ex:O3".to_string(),
},
];
let config = DiagramConfig {
style: LayoutStyle::List,
max_edges: 2,
..Default::default()
};
let generator = AsciiDiagramGenerator::new(config);
let mut output = Vec::new();
generator.generate(&triples, &mut output).unwrap();
let result = String::from_utf8(output).unwrap();
assert!(result.contains("more triples"));
}
}