use anyhow::Result;
use std::collections::{HashMap, HashSet};
use std::io::Write;
use std::path::PathBuf;
use super::{EdgeType, Graph};
pub struct OntologyStore {
path: PathBuf,
}
impl OntologyStore {
pub fn new(path: impl AsRef<std::path::Path>) -> Self {
Self {
path: path.as_ref().to_path_buf(),
}
}
pub fn save(&self, graph: &Graph) -> Result<()> {
let yaml = serde_yaml::to_string(graph)?;
let parent = self
.path
.parent()
.filter(|path| !path.as_os_str().is_empty())
.unwrap_or_else(|| std::path::Path::new("."));
std::fs::create_dir_all(parent)?;
if std::fs::symlink_metadata(&self.path)
.is_ok_and(|metadata| metadata.file_type().is_symlink())
{
anyhow::bail!("refusing to replace symlinked ontology store");
}
let mut temporary = tempfile::NamedTempFile::new_in(parent)?;
temporary.write_all(yaml.as_bytes())?;
temporary.flush()?;
temporary.as_file().sync_all()?;
temporary.persist(&self.path).map_err(|error| {
anyhow::anyhow!(
"failed to atomically persist {}: {}",
self.path.display(),
error.error
)
})?;
Ok(())
}
pub fn load(&self) -> Result<Graph> {
let yaml = std::fs::read_to_string(&self.path)?;
Ok(serde_yaml::from_str(&yaml)?)
}
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct DanglingEdge {
pub from: String,
pub to: String,
}
#[derive(Debug, Default, serde::Serialize)]
pub struct ValidationReport {
pub valid: bool,
pub duplicate_ids: Vec<String>,
pub cycles: Vec<Vec<String>>,
pub dangling_edges: Vec<DanglingEdge>,
pub isolated_nodes: Vec<String>,
}
pub fn validate_graph(graph: &Graph) -> ValidationReport {
let mut node_counts: HashMap<&str, usize> = HashMap::new();
for node in &graph.nodes {
*node_counts.entry(node.id.as_str()).or_default() += 1;
}
let node_ids: HashSet<&str> = node_counts.keys().copied().collect();
let mut report = ValidationReport {
duplicate_ids: node_counts
.into_iter()
.filter(|&(_id, count)| count > 1)
.map(|(id, _count)| id.to_string())
.collect(),
..Default::default()
};
report.duplicate_ids.sort();
for edge in &graph.edges {
if !node_ids.contains(edge.from.as_str()) || !node_ids.contains(edge.to.as_str()) {
report.dangling_edges.push(DanglingEdge {
from: edge.from.clone(),
to: edge.to.clone(),
});
}
}
report
.dangling_edges
.sort_by(|a, b| a.from.cmp(&b.from).then_with(|| a.to.cmp(&b.to)));
let mut connected: HashSet<&str> = HashSet::new();
for edge in &graph.edges {
if node_ids.contains(edge.from.as_str()) && node_ids.contains(edge.to.as_str()) {
connected.insert(edge.from.as_str());
connected.insert(edge.to.as_str());
}
}
report.isolated_nodes = node_ids
.iter()
.filter(|id| !connected.contains(**id))
.map(|id| (*id).to_string())
.collect();
report.isolated_nodes.sort();
let mut adjacency: HashMap<&str, Vec<&str>> = HashMap::new();
for edge in &graph.edges {
if edge.edge_type != EdgeType::DependsOn
&& node_ids.contains(edge.from.as_str())
&& node_ids.contains(edge.to.as_str())
{
adjacency
.entry(edge.from.as_str())
.or_default()
.push(edge.to.as_str());
}
}
for children in adjacency.values_mut() {
children.sort_unstable();
children.dedup();
}
const WHITE: u8 = 0;
const GRAY: u8 = 1;
const BLACK: u8 = 2;
let mut color: HashMap<&str, u8> = node_ids.iter().map(|id| (*id, WHITE)).collect();
let mut starts: Vec<&str> = node_ids.iter().copied().collect();
starts.sort_unstable();
for start in starts {
if color[start] != WHITE {
continue;
}
let mut stack: Vec<(&str, usize)> = vec![(start, 0)];
let mut path: Vec<&str> = vec![start];
color.insert(start, GRAY);
while let Some(frame) = stack.last_mut() {
let node = frame.0;
let children: &[&str] = adjacency.get(node).map_or(&[], Vec::as_slice);
if frame.1 >= children.len() {
color.insert(node, BLACK);
stack.pop();
path.pop();
continue;
}
let next = children[frame.1];
frame.1 += 1;
match color[next] {
WHITE => {
color.insert(next, GRAY);
stack.push((next, 0));
path.push(next);
}
GRAY => {
if let Some(position) = path.iter().position(|node| *node == next) {
let mut cycle: Vec<String> = path[position..]
.iter()
.map(|node| node.to_string())
.collect();
cycle.push(next.to_string());
report.cycles.push(cycle);
}
}
_ => {}
}
}
}
report.cycles.sort();
report.cycles.dedup();
report.valid = report.duplicate_ids.is_empty()
&& report.cycles.is_empty()
&& report.dangling_edges.is_empty()
&& report.isolated_nodes.is_empty();
report
}