use std::fs::File;
use std::io::{self, BufRead, BufReader, Write};
#[cfg(feature = "hgraph")]
use crate::hgraph::h_graph::HeterogeneousGraph;
#[cfg(feature = "hgraph")]
#[derive(Debug, thiserror::Error)]
pub enum CsvIoError {
#[error("I/O error: {0}")]
IoError(#[from] io::Error),
#[error("Invalid CSV format: {0}")]
InvalidFormat(String),
#[error("Failed to parse {field} '{value}': {details}")]
ParseError {
field: String,
value: String,
details: String,
},
#[error("Edge refers to node ID {0}, which the nodes file does not define")]
UnknownNode(usize),
#[error("Failed to build the graph: {0}")]
GraphError(#[from] crate::hgraph::h_graph::HGraphError),
}
#[cfg(feature = "hgraph")]
pub type Result<T> = std::result::Result<T, CsvIoError>;
#[cfg(feature = "hgraph")]
pub trait CsvIO<W, N, E> {
fn save_to_csv(&self, nodes_file: &str, edges_file: &str) -> Result<()>
where
W: Copy + PartialEq + std::fmt::Display,
N: Clone + std::fmt::Debug + std::fmt::Display,
E: Clone + std::fmt::Debug + std::fmt::Display;
fn load_from_csv(nodes_file: &str, edges_file: &str, directed: bool) -> Result<Self>
where
Self: Sized,
W: Copy + PartialEq + Default + std::str::FromStr,
N: Clone + std::fmt::Debug + std::str::FromStr,
E: Clone + std::fmt::Debug + Default + std::str::FromStr,
<W as std::str::FromStr>::Err: std::fmt::Debug,
<N as std::str::FromStr>::Err: std::fmt::Debug,
<E as std::str::FromStr>::Err: std::fmt::Debug;
}
#[cfg(feature = "hgraph")]
impl<W, N, E> CsvIO<W, N, E> for HeterogeneousGraph<W, N, E>
where
W: Copy + PartialEq + std::fmt::Debug,
N: crate::hgraph::h_node::NodeType,
E: crate::hgraph::h_edge::EdgeType + Default,
{
fn save_to_csv(&self, nodes_file: &str, edges_file: &str) -> Result<()>
where
W: std::fmt::Display,
N: std::fmt::Display,
E: std::fmt::Display,
{
let mut nodes_writer = File::create(nodes_file).map_err(CsvIoError::IoError)?;
let mut node_attrs: Vec<String> = self
.nodes
.iter()
.flat_map(|(_, node)| node.attributes.keys())
.collect::<std::collections::HashSet<_>>()
.into_iter()
.cloned()
.collect();
node_attrs.sort();
writeln!(nodes_writer, "node_id,data,{}", node_attrs.join(","))
.map_err(CsvIoError::IoError)?;
for (id, node) in self.nodes.iter() {
let attrs: Vec<String> = node_attrs
.iter()
.map(|key| {
node.attributes
.get(key)
.map_or("".to_string(), |v| v.to_string())
})
.collect();
writeln!(
nodes_writer,
"{},{},{}",
id,
node.data.as_string(),
attrs.join(",")
)
.map_err(CsvIoError::IoError)?;
}
let mut edges_writer = File::create(edges_file).map_err(CsvIoError::IoError)?;
let mut edge_attrs: Vec<String> = self
.edges
.iter()
.flat_map(|(_, edge)| edge.attributes.keys())
.collect::<std::collections::HashSet<_>>()
.into_iter()
.cloned()
.collect();
edge_attrs.sort();
writeln!(
edges_writer,
"edge_id,from,to,weight,data,{}",
edge_attrs.join(",")
)
.map_err(CsvIoError::IoError)?;
for (id, edge) in self.edges.iter() {
let attrs: Vec<String> = edge_attrs
.iter()
.map(|key| {
edge.attributes
.get(key)
.map_or("".to_string(), |v| v.to_string())
})
.collect();
writeln!(
edges_writer,
"{},{},{},{},{},{}",
id,
edge.from,
edge.to,
edge.weight,
edge.data.as_string(),
attrs.join(",")
)
.map_err(CsvIoError::IoError)?;
}
Ok(())
}
fn load_from_csv(nodes_file: &str, edges_file: &str, directed: bool) -> Result<Self>
where
Self: Sized,
W: Default + std::str::FromStr,
N: std::str::FromStr,
E: std::str::FromStr,
<W as std::str::FromStr>::Err: std::fmt::Debug,
<N as std::str::FromStr>::Err: std::fmt::Debug,
<E as std::str::FromStr>::Err: std::fmt::Debug,
{
let mut graph = HeterogeneousGraph::new(directed);
let mut node_ids: std::collections::HashMap<usize, crate::core::view::NodeId> =
std::collections::HashMap::new();
let nodes_reader = BufReader::new(File::open(nodes_file).map_err(CsvIoError::IoError)?);
let mut lines = nodes_reader.lines();
let header = lines
.next()
.ok_or(CsvIoError::InvalidFormat("Empty nodes file".to_string()))?
.map_err(CsvIoError::IoError)?;
let attr_keys: Vec<&str> = header.split(',').skip(2).collect();
if header.is_empty() || !header.starts_with("node_id,data") {
return Err(CsvIoError::InvalidFormat(
"Nodes CSV must start with 'node_id,data'".to_string(),
));
}
for (line_num, line) in lines.enumerate() {
let line = line.map_err(CsvIoError::IoError)?;
let parts: Vec<&str> = line.split(',').collect();
if parts.len() < 2 {
return Err(CsvIoError::InvalidFormat(format!(
"Line {} in nodes CSV has too few fields",
line_num + 2
)));
}
let file_id: usize = parts[0].parse().map_err(|e| CsvIoError::ParseError {
field: "node_id".to_string(),
value: parts[0].to_string(),
details: format!("{:?}", e),
})?;
let data = parts[1].parse().map_err(|e| CsvIoError::ParseError {
field: "node data".to_string(),
value: parts[1].to_string(),
details: format!("{:?}", e),
})?;
let node = graph.add_node(data);
node_ids.insert(file_id, node);
for (key, value) in attr_keys.iter().zip(parts.iter().skip(2)) {
if !value.is_empty() {
let _ = graph.set_node_attribute(node, key.to_string(), value.to_string());
}
}
}
let edges_reader = BufReader::new(File::open(edges_file).map_err(CsvIoError::IoError)?);
let mut lines = edges_reader.lines();
let header = lines
.next()
.ok_or(CsvIoError::InvalidFormat("Empty edges file".to_string()))?
.map_err(CsvIoError::IoError)?;
let attr_keys: Vec<&str> = header.split(',').skip(5).collect();
if header.is_empty() || !header.starts_with("edge_id,from,to,weight,data") {
return Err(CsvIoError::InvalidFormat(
"Edges CSV must start with 'edge_id,from,to,weight,data'".to_string(),
));
}
for (line_num, line) in lines.enumerate() {
let line = line.map_err(CsvIoError::IoError)?;
let parts: Vec<&str> = line.split(',').collect();
if parts.len() < 5 {
return Err(CsvIoError::InvalidFormat(format!(
"Line {} in edges CSV has too few fields",
line_num + 2
)));
}
let parse_endpoint =
|field: &str,
raw: &str|
-> std::result::Result<crate::core::view::NodeId, CsvIoError> {
let file_id: usize = raw.parse().map_err(|e| CsvIoError::ParseError {
field: field.to_string(),
value: raw.to_string(),
details: format!("{:?}", e),
})?;
node_ids
.get(&file_id)
.copied()
.ok_or(CsvIoError::UnknownNode(file_id))
};
let from = parse_endpoint("from", parts[1])?;
let to = parse_endpoint("to", parts[2])?;
let weight = parts[3].parse().map_err(|e| CsvIoError::ParseError {
field: "weight".to_string(),
value: parts[3].to_string(),
details: format!("{:?}", e),
})?;
let data = parts[4].parse().map_err(|e| CsvIoError::ParseError {
field: "edge data".to_string(),
value: parts[4].to_string(),
details: format!("{:?}", e),
})?;
let edge_id = graph
.add_edge(from, to, weight, data)
.map_err(CsvIoError::GraphError)?;
for (key, value) in attr_keys.iter().zip(parts.iter().skip(5)) {
if !value.is_empty() {
let _ = graph.set_edge_attribute(edge_id, key.to_string(), value.to_string());
}
}
}
Ok(graph)
}
}
#[cfg(test)]
#[cfg(feature = "hgraph")]
mod tests {
use super::*;
use crate::core::view::{EdgeId, NodeId};
use crate::hgraph::h_edge::EdgeType;
use crate::hgraph::h_node::NodeType;
use std::fmt;
use std::str::FromStr;
#[derive(Clone, Eq, PartialEq, Hash, Debug)]
struct TestNode(String);
impl NodeType for TestNode {
fn as_string(&self) -> String {
self.0.clone()
}
}
impl fmt::Display for TestNode {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0)
}
}
impl FromStr for TestNode {
type Err = String;
fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
Ok(TestNode(s.to_string()))
}
}
#[derive(Clone, Debug, Default)]
struct TestEdge(String);
impl EdgeType for TestEdge {
fn as_string(&self) -> String {
self.0.clone()
}
}
impl fmt::Display for TestEdge {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0)
}
}
impl FromStr for TestEdge {
type Err = String;
fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
Ok(TestEdge(s.to_string()))
}
}
#[test]
fn test_csv_io_basic() {
let mut graph = HeterogeneousGraph::<u32, TestNode, TestEdge>::new(false);
let n1 = graph.add_node(TestNode("A".to_string()));
let n2 = graph.add_node(TestNode("B".to_string()));
let edge_id = graph
.add_edge(n1, n2, 1, TestEdge("edge".to_string()))
.unwrap();
graph
.set_node_attribute(n1, "color".to_string(), "red".to_string())
.unwrap();
graph
.set_edge_attribute(edge_id, "type".to_string(), "road".to_string())
.unwrap();
assert!(graph
.save_to_csv("test_nodes_basic.csv", "test_edges_basic.csv")
.is_ok());
let loaded_graph = HeterogeneousGraph::<u32, TestNode, TestEdge>::load_from_csv(
"test_nodes_basic.csv",
"test_edges_basic.csv",
false,
)
.unwrap();
assert_eq!(graph.nodes.len(), loaded_graph.nodes.len());
assert_eq!(graph.edges.len(), loaded_graph.edges.len());
assert_eq!(
graph.get_node_data(n1).unwrap().as_string(),
loaded_graph
.get_node_data(NodeId::from_index(0))
.unwrap()
.as_string()
);
assert_eq!(
graph.get_edge_data_by_id(edge_id).unwrap().as_string(),
loaded_graph
.get_edge_data_by_id(EdgeId::from_index(0))
.unwrap()
.as_string()
);
}
#[test]
fn test_csv_io_multigraph() {
let mut graph = HeterogeneousGraph::<f64, TestNode, TestEdge>::new(true);
let n1 = graph.add_node(TestNode("X".to_string()));
let n2 = graph.add_node(TestNode("Y".to_string()));
let edge1 = graph
.add_edge(n1, n2, 1.5, TestEdge("friend".to_string()))
.unwrap();
let edge2 = graph
.add_edge(n1, n2, 2.0, TestEdge("colleague".to_string()))
.unwrap();
graph
.set_node_attribute(n1, "type".to_string(), "person".to_string())
.unwrap();
graph
.set_edge_attribute(edge1, "strength".to_string(), "strong".to_string())
.unwrap();
graph
.set_edge_attribute(edge2, "strength".to_string(), "weak".to_string())
.unwrap();
assert!(graph
.save_to_csv("test_nodes_multi.csv", "test_edges_multi.csv")
.is_ok());
let loaded_graph = HeterogeneousGraph::<f64, TestNode, TestEdge>::load_from_csv(
"test_nodes_multi.csv",
"test_edges_multi.csv",
true,
)
.unwrap();
assert_eq!(graph.nodes.len(), loaded_graph.nodes.len());
assert_eq!(graph.edges.len(), loaded_graph.edges.len());
assert_eq!(
loaded_graph
.get_edges_between(NodeId::from_index(0), NodeId::from_index(1))
.len(),
2
);
}
#[test]
fn test_csv_io_invalid_file() {
let result = HeterogeneousGraph::<u32, TestNode, TestEdge>::load_from_csv(
"nonexistent_nodes.csv",
"nonexistent_edges.csv",
false,
);
assert!(matches!(result, Err(CsvIoError::IoError(_))));
std::fs::write("empty_nodes.csv", "").unwrap();
let result = HeterogeneousGraph::<u32, TestNode, TestEdge>::load_from_csv(
"empty_nodes.csv",
"nonexistent_edges.csv",
false,
);
assert!(matches!(result, Err(CsvIoError::InvalidFormat(_))));
}
}