pub mod gexf;
pub mod graphml;
use std::collections::BTreeMap;
use std::io;
use grafeo_common::PropertyKey;
use grafeo_common::types::Value;
use grafeo_core::graph::lpg::{Edge, Node};
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum ExportError {
#[error("I/O error: {0}")]
Io(#[from] io::Error),
}
#[must_use]
pub fn escape_xml(s: &str) -> String {
let mut result = String::with_capacity(s.len());
for ch in s.chars() {
match ch {
'&' => result.push_str("&"),
'<' => result.push_str("<"),
'>' => result.push_str(">"),
'"' => result.push_str("""),
'\'' => result.push_str("'"),
_ => result.push(ch),
}
}
result
}
#[must_use]
pub fn value_to_gexf_type(value: &Value) -> &'static str {
match value {
Value::Int64(_) => "integer",
Value::Float64(_) => "float",
Value::Bool(_) => "boolean",
Value::String(_) => "string",
Value::Date(_) => "date",
_ => "string",
}
}
#[must_use]
pub fn value_to_graphml_type(value: &Value) -> &'static str {
match value {
Value::Int64(_) => "long",
Value::Float64(_) => "double",
Value::Bool(_) => "boolean",
Value::String(_) => "string",
_ => "string",
}
}
#[must_use]
pub fn value_to_xml_string(value: &Value) -> Option<String> {
match value {
Value::Null => None,
Value::Bool(b) => Some(b.to_string()),
Value::Int64(i) => Some(i.to_string()),
Value::Float64(f) => Some(f.to_string()),
Value::String(s) => Some(escape_xml(s.as_str())),
Value::Date(d) => Some(d.to_string()),
Value::Time(t) => Some(t.to_string()),
Value::Timestamp(ts) => Some(ts.to_string()),
Value::Duration(d) => Some(d.to_string()),
Value::ZonedDatetime(zdt) => Some(zdt.to_string()),
Value::Bytes(b) => {
use std::fmt::Write;
let hex = b.iter().fold(String::new(), |mut acc, byte| {
let _ = write!(acc, "{byte:02x}");
acc
});
Some(hex)
}
Value::Vector(v) => {
let parts: Vec<String> = v.iter().map(|f| f.to_string()).collect();
Some(parts.join(","))
}
Value::List(items) => {
let parts: Vec<String> = items.iter().filter_map(value_to_xml_string).collect();
Some(parts.join(","))
}
Value::Map(m) => {
let parts: Vec<String> = m
.iter()
.map(|(k, v)| {
let val_str = value_to_xml_string(v).unwrap_or_default();
format!("{}={}", escape_xml(k.as_str()), val_str)
})
.collect();
Some(parts.join(";"))
}
Value::Path { .. } | Value::GCounter(_) | Value::OnCounter { .. } => {
Some(escape_xml(&value.to_string()))
}
_ => Some(escape_xml(&value.to_string())),
}
}
pub(crate) type PropertySchema = BTreeMap<PropertyKey, (usize, &'static str)>;
pub(crate) fn discover_node_schema<F>(nodes: &[Node], type_fn: F) -> PropertySchema
where
F: Fn(&Value) -> &'static str,
{
let mut schema: BTreeMap<PropertyKey, Option<&'static str>> = BTreeMap::new();
for node in nodes {
for (key, value) in node.properties.iter() {
schema
.entry(key.clone())
.and_modify(|existing| {
if existing.is_none() && !value.is_null() {
*existing = Some(type_fn(value));
}
})
.or_insert_with(|| {
if value.is_null() {
None
} else {
Some(type_fn(value))
}
});
}
}
schema
.into_iter()
.enumerate()
.map(|(idx, (key, type_str))| (key, (idx, type_str.unwrap_or("string"))))
.collect()
}
pub(crate) fn discover_edge_schema<F>(edges: &[Edge], type_fn: F) -> PropertySchema
where
F: Fn(&Value) -> &'static str,
{
let mut schema: BTreeMap<PropertyKey, Option<&'static str>> = BTreeMap::new();
for edge in edges {
for (key, value) in edge.properties.iter() {
schema
.entry(key.clone())
.and_modify(|existing| {
if existing.is_none() && !value.is_null() {
*existing = Some(type_fn(value));
}
})
.or_insert_with(|| {
if value.is_null() {
None
} else {
Some(type_fn(value))
}
});
}
}
schema
.into_iter()
.enumerate()
.map(|(idx, (key, type_str))| (key, (idx, type_str.unwrap_or("string"))))
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_escape_xml_basic() {
assert_eq!(escape_xml("hello"), "hello");
assert_eq!(escape_xml("a & b"), "a & b");
assert_eq!(escape_xml("<tag>"), "<tag>");
assert_eq!(escape_xml("she said \"hi\""), "she said "hi"");
assert_eq!(escape_xml("it's"), "it's");
}
#[test]
fn test_escape_xml_combined() {
assert_eq!(
escape_xml("<a href=\"x&y\">"),
"<a href="x&y">"
);
}
#[test]
fn test_value_to_xml_string_null() {
assert!(value_to_xml_string(&Value::Null).is_none());
}
#[test]
fn test_value_to_xml_string_primitives() {
assert_eq!(value_to_xml_string(&Value::Bool(true)).unwrap(), "true");
assert_eq!(value_to_xml_string(&Value::Int64(42)).unwrap(), "42");
assert_eq!(
value_to_xml_string(&Value::Float64(3.125)).unwrap(),
"3.125"
);
assert_eq!(
value_to_xml_string(&Value::String("Alix & Gus".into())).unwrap(),
"Alix & Gus"
);
}
#[test]
fn test_value_to_xml_string_vector() {
let v = Value::Vector(std::sync::Arc::from(vec![1.0f32, 2.0, 3.0].as_slice()));
assert_eq!(value_to_xml_string(&v).unwrap(), "1,2,3");
}
#[test]
fn test_gexf_type_mapping() {
assert_eq!(value_to_gexf_type(&Value::Int64(0)), "integer");
assert_eq!(value_to_gexf_type(&Value::Float64(0.0)), "float");
assert_eq!(value_to_gexf_type(&Value::Bool(true)), "boolean");
assert_eq!(value_to_gexf_type(&Value::String("".into())), "string");
}
#[test]
fn test_graphml_type_mapping() {
assert_eq!(value_to_graphml_type(&Value::Int64(0)), "long");
assert_eq!(value_to_graphml_type(&Value::Float64(0.0)), "double");
assert_eq!(value_to_graphml_type(&Value::Bool(true)), "boolean");
assert_eq!(value_to_graphml_type(&Value::String("".into())), "string");
}
#[test]
fn test_value_to_xml_string_bytes() {
let v = Value::Bytes(std::sync::Arc::from(
vec![0xDE, 0xAD, 0xBE, 0xEF].as_slice(),
));
assert_eq!(value_to_xml_string(&v).unwrap(), "deadbeef");
}
#[test]
fn test_value_to_xml_string_bytes_empty() {
let v = Value::Bytes(std::sync::Arc::from(Vec::<u8>::new().as_slice()));
assert_eq!(value_to_xml_string(&v).unwrap(), "");
}
#[test]
fn test_value_to_xml_string_date() {
use grafeo_common::types::Date;
let date = Date::from_ymd(2025, 6, 15).unwrap();
let v = Value::Date(date);
let result = value_to_xml_string(&v).unwrap();
assert!(
result.contains("2025"),
"date should contain the year: {result}"
);
}
#[test]
fn test_value_to_xml_string_time() {
use grafeo_common::types::Time;
let time = Time::from_hms(14, 30, 0).unwrap();
let v = Value::Time(time);
let result = value_to_xml_string(&v).unwrap();
assert!(
result.contains("14"),
"time should contain the hour: {result}"
);
}
#[test]
fn test_value_to_xml_string_timestamp() {
use grafeo_common::types::Timestamp;
let ts = Timestamp::from_micros(1_000_000);
let v = Value::Timestamp(ts);
let result = value_to_xml_string(&v);
assert!(result.is_some());
}
#[test]
fn test_value_to_xml_string_duration() {
use grafeo_common::types::Duration;
let dur = Duration::new(2, 5, 0);
let v = Value::Duration(dur);
let result = value_to_xml_string(&v).unwrap();
assert!(!result.is_empty());
}
#[test]
fn test_value_to_xml_string_zoned_datetime() {
use grafeo_common::types::{Timestamp, ZonedDatetime};
let zdt = ZonedDatetime::from_timestamp_offset(Timestamp::from_micros(0), 3600);
let v = Value::ZonedDatetime(zdt);
let result = value_to_xml_string(&v).unwrap();
assert!(!result.is_empty());
}
#[test]
fn test_value_to_xml_string_list() {
let items = vec![
Value::Int64(1),
Value::Int64(2),
Value::Null,
Value::Int64(3),
];
let v = Value::List(std::sync::Arc::from(items.as_slice()));
assert_eq!(value_to_xml_string(&v).unwrap(), "1,2,3");
}
#[test]
fn test_value_to_xml_string_list_empty() {
let v = Value::List(std::sync::Arc::from(Vec::<Value>::new().as_slice()));
assert_eq!(value_to_xml_string(&v).unwrap(), "");
}
#[test]
fn test_value_to_xml_string_map() {
let mut map = BTreeMap::new();
map.insert(PropertyKey::from("city"), Value::String("Amsterdam".into()));
map.insert(PropertyKey::from("pop"), Value::Int64(900_000));
let v = Value::Map(std::sync::Arc::new(map));
let result = value_to_xml_string(&v).unwrap();
assert!(result.contains("city=Amsterdam"));
assert!(result.contains("pop=900000"));
assert!(result.contains(';'));
}
#[test]
fn test_value_to_xml_string_map_with_null_value() {
let mut map = BTreeMap::new();
map.insert(PropertyKey::from("key"), Value::Null);
let v = Value::Map(std::sync::Arc::new(map));
let result = value_to_xml_string(&v).unwrap();
assert_eq!(result, "key=");
}
#[test]
fn test_value_to_xml_string_map_with_special_chars() {
let mut map = BTreeMap::new();
map.insert(PropertyKey::from("k&ey"), Value::String("<val>".into()));
let v = Value::Map(std::sync::Arc::new(map));
let result = value_to_xml_string(&v).unwrap();
assert!(result.contains("k&ey=<val>"));
}
#[test]
fn test_gexf_type_date_variant() {
use grafeo_common::types::Date;
let date = Date::from_ymd(2025, 1, 1).unwrap();
assert_eq!(value_to_gexf_type(&Value::Date(date)), "date");
}
#[test]
fn test_gexf_type_fallback_to_string() {
let v = Value::Bytes(std::sync::Arc::from(vec![1u8].as_slice()));
assert_eq!(value_to_gexf_type(&v), "string");
}
#[test]
fn test_graphml_type_fallback_to_string() {
use grafeo_common::types::Duration;
let dur = Duration::new(0, 0, 0);
assert_eq!(value_to_graphml_type(&Value::Duration(dur)), "string");
}
#[test]
fn test_discover_node_schema_multiple_nodes() {
use grafeo_common::types::NodeId;
use grafeo_core::graph::lpg::Node;
let mut n1 = Node::new(NodeId(1));
n1.set_property("name", Value::String("Alix".into()));
n1.set_property("age", Value::Int64(30));
let mut n2 = Node::new(NodeId(2));
n2.set_property("name", Value::String("Gus".into()));
n2.set_property("score", Value::Float64(9.5));
let schema = discover_node_schema(&[n1, n2], value_to_gexf_type);
assert_eq!(schema.len(), 3); assert_eq!(schema[&PropertyKey::from("name")].1, "string");
assert_eq!(schema[&PropertyKey::from("age")].1, "integer");
assert_eq!(schema[&PropertyKey::from("score")].1, "float");
}
#[test]
fn test_discover_node_schema_null_then_typed() {
use grafeo_common::types::NodeId;
use grafeo_core::graph::lpg::Node;
let mut n1 = Node::new(NodeId(1));
n1.set_property("age", Value::Null);
let mut n2 = Node::new(NodeId(2));
n2.set_property("age", Value::Int64(25));
let schema = discover_node_schema(&[n1, n2], value_to_gexf_type);
assert_eq!(schema[&PropertyKey::from("age")].1, "integer");
}
#[test]
fn test_discover_node_schema_all_null_falls_back_to_string() {
use grafeo_common::types::NodeId;
use grafeo_core::graph::lpg::Node;
let mut n1 = Node::new(NodeId(1));
n1.set_property("unknown", Value::Null);
let schema = discover_node_schema(&[n1], value_to_gexf_type);
assert_eq!(schema[&PropertyKey::from("unknown")].1, "string");
}
#[test]
fn test_discover_node_schema_empty() {
let schema = discover_node_schema(&[], value_to_gexf_type);
assert!(schema.is_empty());
}
#[test]
fn test_discover_edge_schema_multiple_edges() {
use grafeo_common::types::{EdgeId, NodeId};
use grafeo_core::graph::lpg::Edge;
let mut e1 = Edge::new(EdgeId(1), NodeId(1), NodeId(2), "KNOWS");
e1.set_property("since", Value::Int64(2020));
let mut e2 = Edge::new(EdgeId(2), NodeId(2), NodeId(3), "FOLLOWS");
e2.set_property("weight", Value::Float64(0.8));
let schema = discover_edge_schema(&[e1, e2], value_to_graphml_type);
assert_eq!(schema.len(), 2);
assert_eq!(schema[&PropertyKey::from("since")].1, "long");
assert_eq!(schema[&PropertyKey::from("weight")].1, "double");
}
#[test]
fn test_discover_edge_schema_null_then_typed() {
use grafeo_common::types::{EdgeId, NodeId};
use grafeo_core::graph::lpg::Edge;
let mut e1 = Edge::new(EdgeId(1), NodeId(1), NodeId(2), "KNOWS");
e1.set_property("weight", Value::Null);
let mut e2 = Edge::new(EdgeId(2), NodeId(2), NodeId(3), "KNOWS");
e2.set_property("weight", Value::Float64(1.5));
let schema = discover_edge_schema(&[e1, e2], value_to_graphml_type);
assert_eq!(schema[&PropertyKey::from("weight")].1, "double");
}
#[test]
fn test_discover_edge_schema_empty() {
let schema = discover_edge_schema(&[], value_to_graphml_type);
assert!(schema.is_empty());
}
#[test]
fn test_discover_node_schema_ids_are_sequential() {
use grafeo_common::types::NodeId;
use grafeo_core::graph::lpg::Node;
let mut n1 = Node::new(NodeId(1));
n1.set_property("a", Value::Int64(1));
n1.set_property("b", Value::Bool(true));
n1.set_property("c", Value::Float64(1.0));
let schema = discover_node_schema(&[n1], value_to_gexf_type);
let ids: Vec<usize> = schema.values().map(|(id, _)| *id).collect();
assert_eq!(ids, vec![0, 1, 2]);
}
}