use ggen_utils::error::{Context, Result};
use ggen_core::graph::{Graph, GraphExport};
use oxigraph::io::{RdfFormat, RdfSerializer};
use oxigraph::model::GraphName;
use std::fs;
use std::io::Write;
use std::path::PathBuf;
use std::str::FromStr;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ExportFormat {
Turtle,
NTriples,
RdfXml,
JsonLd,
N3,
}
impl FromStr for ExportFormat {
type Err = ggen_utils::error::Error;
fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"turtle" | "ttl" => Ok(ExportFormat::Turtle),
"ntriples" | "nt" => Ok(ExportFormat::NTriples),
"rdfxml" | "rdf" | "xml" => Ok(ExportFormat::RdfXml),
"jsonld" | "json" => Ok(ExportFormat::JsonLd),
"n3" => Ok(ExportFormat::N3),
_ => Err(ggen_utils::error::Error::new(&format!(
"Unsupported export format: {}",
s
))),
}
}
}
impl ExportFormat {
pub fn as_str(&self) -> &'static str {
match self {
ExportFormat::Turtle => "Turtle",
ExportFormat::NTriples => "N-Triples",
ExportFormat::RdfXml => "RDF/XML",
ExportFormat::JsonLd => "JSON-LD",
ExportFormat::N3 => "N3",
}
}
}
#[derive(Clone)]
pub struct ExportOptions {
pub output_path: String,
pub format: ExportFormat,
pub pretty: bool,
pub graph: Option<Graph>,
}
impl std::fmt::Debug for ExportOptions {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ExportOptions")
.field("output_path", &self.output_path)
.field("format", &self.format)
.field("pretty", &self.pretty)
.field("graph", &"<Graph>")
.finish()
}
}
#[derive(Debug, Clone)]
pub struct ExportStats {
pub triples_exported: usize,
pub file_size_bytes: usize,
pub output_path: String,
}
#[derive(Debug, Clone, Default, serde::Deserialize)]
pub struct ExportInput {
pub input: PathBuf,
pub output: PathBuf,
pub format: String,
#[serde(default)]
pub pretty: bool,
}
pub fn export_graph(options: ExportOptions) -> Result<String> {
let graph = match options.graph {
Some(g) => g,
None => Graph::new().map_err(|e| {
ggen_utils::error::Error::new(&format!("Failed to create empty graph: {}", e))
})?,
};
let rdf_format = match options.format {
ExportFormat::Turtle => RdfFormat::Turtle,
ExportFormat::NTriples => RdfFormat::NTriples,
ExportFormat::RdfXml => RdfFormat::RdfXml,
ExportFormat::JsonLd => {
return Err(ggen_utils::error::Error::new(
"JSON-LD format not yet supported via Oxigraph RdfSerializer",
));
}
ExportFormat::N3 => {
return Err(ggen_utils::error::Error::new(
"N3 format not yet supported via Oxigraph RdfSerializer",
));
}
};
let supports_datasets = matches!(rdf_format, RdfFormat::TriG | RdfFormat::NQuads);
if supports_datasets {
let export = GraphExport::new(&graph);
export
.write_to_file(&options.output_path, rdf_format)
.context(format!(
"Failed to write export file: {}",
options.output_path
))?;
} else {
let file = fs::File::create(&options.output_path).map_err(|e| {
ggen_utils::error::Error::new(&format!(
"Failed to create export file {}: {}",
options.output_path, e
))
})?;
let mut writer = std::io::BufWriter::new(file);
let mut serializer = RdfSerializer::from_format(rdf_format).for_writer(&mut writer);
let default_graph_quads = graph
.quads_for_pattern(
None, None, None, Some(&GraphName::DefaultGraph), )
.context("Failed to query default graph quads")?;
for quad in default_graph_quads {
serializer.serialize_quad(&quad).map_err(|e| {
ggen_utils::error::Error::new(&format!("Failed to serialize quad: {}", e))
})?;
}
serializer.finish().map_err(|e| {
ggen_utils::error::Error::new(&format!("Failed to finish RDF serialization: {}", e))
})?;
writer.flush().map_err(|e| {
ggen_utils::error::Error::new(&format!("Failed to flush export file: {}", e))
})?;
}
let content = fs::read_to_string(&options.output_path)
.map_err(|e| {
ggen_utils::error::Error::new(&format!(
"Failed to read exported file {}: {}",
options.output_path, e
))
})
.context(format!(
"Failed to read exported file: {}",
options.output_path
))?;
Ok(content)
}
#[allow(dead_code)]
fn generate_turtle(_graph: &Graph, pretty: bool) -> Result<String> {
let content = if pretty {
r#"@prefix rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#> .
@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .
@prefix ex: <http://example.org/> .
ex:subject a ex:Type ;
rdfs:label "Example Subject" ;
ex:property "value" .
"#
} else {
r#"@prefix rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#> . @prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> . @prefix ex: <http://example.org/> . ex:subject a ex:Type ; rdfs:label "Example Subject" ; ex:property "value" ."#
};
Ok(content.to_string())
}
#[allow(dead_code)]
fn generate_ntriples(_graph: &Graph) -> Result<String> {
let content = r#"<http://example.org/subject> <http://www.w3.org/1999/02/22-rdf-syntax-ns#type> <http://example.org/Type> .
<http://example.org/subject> <http://www.w3.org/2000/01/rdf-schema#label> "Example Subject" .
<http://example.org/subject> <http://example.org/property> "value" .
"#;
Ok(content.to_string())
}
#[allow(dead_code)]
fn generate_rdfxml(_graph: &Graph, pretty: bool) -> Result<String> {
let content = if pretty {
r#"<?xml version="1.0" encoding="UTF-8"?>
<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:rdfs="http://www.w3.org/2000/01/rdf-schema#"
xmlns:ex="http://example.org/">
<rdf:Description rdf:about="http://example.org/subject">
<rdf:type rdf:resource="http://example.org/Type"/>
<rdfs:label>Example Subject</rdfs:label>
<ex:property>value</ex:property>
</rdf:Description>
</rdf:RDF>
"#
} else {
r#"<?xml version="1.0" encoding="UTF-8"?><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:rdfs="http://www.w3.org/2000/01/rdf-schema#" xmlns:ex="http://example.org/"><rdf:Description rdf:about="http://example.org/subject"><rdf:type rdf:resource="http://example.org/Type"/><rdfs:label>Example Subject</rdfs:label><ex:property>value</ex:property></rdf:Description></rdf:RDF>"#
};
Ok(content.to_string())
}
#[allow(dead_code)]
fn generate_jsonld(_graph: &Graph, pretty: bool) -> Result<String> {
let content = if pretty {
r#"{
"@context": {
"rdf": "http://www.w3.org/1999/02/22-rdf-syntax-ns#",
"rdfs": "http://www.w3.org/2000/01/rdf-schema#",
"ex": "http://example.org/"
},
"@id": "ex:subject",
"@type": "ex:Type",
"rdfs:label": "Example Subject",
"ex:property": "value"
}
"#
} else {
r#"{"@context":{"rdf":"http://www.w3.org/1999/02/22-rdf-syntax-ns#","rdfs":"http://www.w3.org/2000/01/rdf-schema#","ex":"http://example.org/"},"@id":"ex:subject","@type":"ex:Type","rdfs:label":"Example Subject","ex:property":"value"}"#
};
Ok(content.to_string())
}
#[allow(dead_code)]
fn generate_n3(graph: &Graph, pretty: bool) -> Result<String> {
generate_turtle(graph, pretty)
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[allow(clippy::expect_used)]
#[test]
fn test_export_turtle_to_file() -> Result<()> {
let temp_dir = tempdir()?;
let output_path = temp_dir.path().join("output.ttl");
let graph = Graph::new().expect("Failed to create graph");
graph.insert_turtle(
r#"
@prefix ex: <http://example.org/> .
ex:subject a ex:Test ;
ex:name "Test Subject" .
"#,
)?;
let options = ExportOptions {
output_path: output_path.to_string_lossy().to_string(),
format: ExportFormat::Turtle,
pretty: true,
graph: Some(graph),
};
let content = export_graph(options)?;
assert!(output_path.exists());
let file_content = fs::read_to_string(&output_path)?;
assert_eq!(file_content, content);
assert!(
file_content.contains("ex:subject")
|| file_content.contains("http://example.org/subject")
);
Ok(())
}
#[allow(clippy::expect_used)]
#[test]
fn test_export_all_formats() -> Result<()> {
let temp_dir = tempdir()?;
let graph = Graph::new().expect("Failed to create graph");
graph.insert_turtle(
r#"
@prefix ex: <http://example.org/> .
ex:test a ex:Test ;
ex:name "Test" .
"#,
)?;
let formats = vec![
(ExportFormat::Turtle, "output.ttl"),
(ExportFormat::NTriples, "output.nt"),
(ExportFormat::RdfXml, "output.rdf"),
];
for (format, filename) in formats {
let output_path = temp_dir.path().join(filename);
let options = ExportOptions {
output_path: output_path.to_string_lossy().to_string(),
format,
pretty: false,
graph: Some(graph.clone()),
};
export_graph(options)?;
assert!(output_path.exists());
let content = fs::read_to_string(&output_path)?;
assert!(!content.is_empty());
}
Ok(())
}
#[allow(clippy::expect_used)]
#[test]
fn test_export_pretty_vs_compact() -> Result<()> {
let temp_dir = tempdir()?;
let graph = Graph::new().expect("Failed to create graph");
graph.insert_turtle(
r#"
@prefix ex: <http://example.org/> .
ex:test a ex:Test ;
ex:name "Test" .
"#,
)?;
let pretty_path = temp_dir.path().join("pretty.ttl");
let pretty_options = ExportOptions {
output_path: pretty_path.to_string_lossy().to_string(),
format: ExportFormat::Turtle,
pretty: true,
graph: Some(graph.clone()),
};
let pretty_content = export_graph(pretty_options)?;
let compact_path = temp_dir.path().join("compact.ttl");
let compact_options = ExportOptions {
output_path: compact_path.to_string_lossy().to_string(),
format: ExportFormat::Turtle,
pretty: false,
graph: Some(graph.clone()),
};
let compact_content = export_graph(compact_options)?;
assert!(!pretty_content.is_empty());
assert!(!compact_content.is_empty());
Ok(())
}
#[test]
fn test_export_format_parsing() -> Result<()> {
assert_eq!(ExportFormat::from_str("turtle")?, ExportFormat::Turtle);
assert_eq!(ExportFormat::from_str("TTL")?, ExportFormat::Turtle);
assert_eq!(ExportFormat::from_str("ntriples")?, ExportFormat::NTriples);
assert_eq!(ExportFormat::from_str("rdfxml")?, ExportFormat::RdfXml);
assert_eq!(ExportFormat::from_str("jsonld")?, ExportFormat::JsonLd);
assert_eq!(ExportFormat::from_str("n3")?, ExportFormat::N3);
assert!(ExportFormat::from_str("invalid").is_err());
Ok(())
}
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct ExportOutput {
pub output_path: String,
pub format: String,
pub triples_exported: usize,
pub file_size_bytes: usize,
}
pub async fn execute_export(input: ExportInput) -> Result<ExportOutput> {
let graph = Graph::load_from_file(&input.input).context(format!(
"Failed to load graph from {}",
input.input.display()
))?;
let triples_exported = graph.len();
let format = ExportFormat::from_str(&input.format)?;
let options = ExportOptions {
output_path: input.output.to_string_lossy().to_string(),
format,
pretty: input.pretty,
graph: Some(graph),
};
let content = export_graph(options)?;
let file_size_bytes = content.len();
Ok(ExportOutput {
output_path: input.output.to_string_lossy().to_string(),
format: format.as_str().to_string(),
triples_exported,
file_size_bytes,
})
}
pub fn run(args: &ExportInput) -> Result<()> {
let rt = tokio::runtime::Runtime::new()
.map_err(|e| {
ggen_utils::error::Error::new(&format!("Failed to create tokio runtime: {}", e))
})
.context("Failed to create tokio runtime")?;
let output = rt.block_on(execute_export(args.clone()))?;
ggen_utils::alert_success!(
"Exported {} triples to {} ({})",
output.triples_exported,
output.output_path,
output.format
);
ggen_utils::alert_info!(" File size: {} bytes", output.file_size_bytes);
Ok(())
}