ggen_cli_lib/cmds/graph/
export.rs

1//! RDF graph export and serialization functionality.
2//!
3//! This module provides comprehensive RDF graph export capabilities, supporting
4//! multiple output formats (Turtle, N-Triples, RDF/XML, JSON-LD) with pretty
5//! printing options. It validates output paths and formats to ensure secure
6//! and reliable data export.
7//!
8//! # Examples
9//!
10//! ```bash
11//! ggen graph export output.ttl --format turtle --pretty
12//! ggen graph export data.jsonld --format jsonld
13//! ggen graph export triples.nt --format ntriples
14//! ```
15//!
16//! # Errors
17//!
18//! Returns errors if the output path is invalid or contains traversal attempts,
19//! the RDF format is unsupported, the file cannot be written, or if RDF
20//! serialization fails due to graph structure issues.
21
22use clap::Args;
23use ggen_utils::error::Result;
24use std::fs;
25use std::path::{Component, Path};
26
27#[derive(Args, Debug)]
28pub struct ExportArgs {
29    /// Output file path
30    pub output: String,
31
32    /// RDF format (turtle, ntriples, rdfxml, jsonld)
33    #[arg(long, default_value = "turtle")]
34    pub format: String,
35
36    /// Pretty print output
37    #[arg(long)]
38    pub pretty: bool,
39}
40
41#[cfg_attr(test, mockall::automock)]
42pub trait GraphExporter {
43    fn export(&self, output: String, format: String, pretty: bool) -> Result<ExportStats>;
44}
45
46#[derive(Debug, Clone)]
47pub struct ExportStats {
48    pub triples_exported: usize,
49    pub file_size_bytes: usize,
50}
51
52/// Validate and sanitize output file path input
53fn validate_output_path(output: &str) -> Result<()> {
54    // Validate output path is not empty
55    if output.trim().is_empty() {
56        return Err(ggen_utils::error::Error::new(
57            "Output file path cannot be empty",
58        ));
59    }
60
61    // Validate output path length
62    if output.len() > 1000 {
63        return Err(ggen_utils::error::Error::new(
64            "Output file path too long (max 1000 characters)",
65        ));
66    }
67
68    // Use Path components for proper traversal protection
69    let path = Path::new(output);
70    if path.components().any(|c| matches!(c, Component::ParentDir)) {
71        return Err(ggen_utils::error::Error::new(
72            "Path traversal detected: paths containing '..' are not allowed",
73        ));
74    }
75
76    // Validate output path format (basic pattern check)
77    if !output
78        .chars()
79        .all(|c| c.is_alphanumeric() || c == '.' || c == '/' || c == '-' || c == '_' || c == '\\')
80    {
81        return Err(ggen_utils::error::Error::new(
82            "Invalid output file path format: only alphanumeric characters, dots, slashes, dashes, underscores, and backslashes allowed",
83        ));
84    }
85
86    Ok(())
87}
88
89/// Validate and sanitize RDF format input
90fn validate_rdf_format(format: &str) -> Result<()> {
91    // Validate format is not empty
92    if format.trim().is_empty() {
93        return Err(ggen_utils::error::Error::new("RDF format cannot be empty"));
94    }
95
96    // Validate format length
97    if format.len() > 20 {
98        return Err(ggen_utils::error::Error::new(
99            "RDF format too long (max 20 characters)",
100        ));
101    }
102
103    // Validate against known formats
104    let valid_formats = ["turtle", "ntriples", "rdfxml", "jsonld", "n3"];
105    if !valid_formats.contains(&format.to_lowercase().as_str()) {
106        return Err(ggen_utils::error::Error::new(
107            "Unsupported RDF format: supported formats are turtle, ntriples, rdfxml, jsonld, n3",
108        ));
109    }
110
111    Ok(())
112}
113
114pub async fn run(args: &ExportArgs) -> Result<()> {
115    // Validate inputs
116    validate_output_path(&args.output)?;
117    validate_rdf_format(&args.format)?;
118
119    println!("🔍 Exporting graph...");
120
121    let stats = export_graph(args.output.clone(), args.format.clone(), args.pretty)?;
122
123    println!(
124        "✅ Exported {} triples to {} ({} bytes)",
125        stats.triples_exported, args.output, stats.file_size_bytes
126    );
127
128    Ok(())
129}
130
131/// Export graph to specified format
132fn export_graph(output: String, format: String, pretty: bool) -> Result<ExportStats> {
133    // Create a basic graph for demonstration
134    let graph = ggen_core::Graph::new()
135        .map_err(|e| ggen_utils::error::Error::new(&format!("Failed to create graph: {}", e)))?;
136
137    // Generate sample RDF content based on format
138    let content = match format.to_lowercase().as_str() {
139        "turtle" => generate_turtle_content(pretty),
140        "ntriples" => generate_ntriples_content(),
141        "rdfxml" => generate_rdfxml_content(pretty),
142        "jsonld" => generate_jsonld_content(pretty),
143        "n3" => generate_n3_content(pretty),
144        _ => return Err(ggen_utils::error::Error::new("Unsupported format")),
145    };
146
147    // Write to file
148    fs::write(&output, content).map_err(|e| {
149        ggen_utils::error::Error::new(&format!("Failed to write output file: {}", e))
150    })?;
151
152    // Get file size
153    let file_size = fs::metadata(&output)
154        .map_err(|e| ggen_utils::error::Error::new(&format!("Failed to get file metadata: {}", e)))?
155        .len() as usize;
156
157    Ok(ExportStats {
158        triples_exported: graph.len(),
159        file_size_bytes: file_size,
160    })
161}
162
163/// Generate sample Turtle content
164fn generate_turtle_content(pretty: bool) -> String {
165    if pretty {
166        r#"@prefix rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#> .
167@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .
168@prefix ex: <http://example.org/> .
169
170ex:Person1 a ex:Person ;
171    rdfs:label "Sample Person" ;
172    ex:hasAge 30 .
173
174ex:Person2 a ex:Person ;
175    rdfs:label "Another Person" ;
176    ex:hasAge 25 .
177"#
178        .to_string()
179    } else {
180        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:Person1 a ex:Person ; rdfs:label "Sample Person" ; ex:hasAge 30 . ex:Person2 a ex:Person ; rdfs:label "Another Person" ; ex:hasAge 25 ."#.to_string()
181    }
182}
183
184/// Generate sample N-Triples content
185fn generate_ntriples_content() -> String {
186    r#"<http://example.org/Person1> <http://www.w3.org/1999/02/22-rdf-syntax-ns#type> <http://example.org/Person> .
187<http://example.org/Person1> <http://www.w3.org/2000/01/rdf-schema#label> "Sample Person" .
188<http://example.org/Person1> <http://example.org/hasAge> "30"^^<http://www.w3.org/2001/XMLSchema#integer> .
189<http://example.org/Person2> <http://www.w3.org/1999/02/22-rdf-syntax-ns#type> <http://example.org/Person> .
190<http://example.org/Person2> <http://www.w3.org/2000/01/rdf-schema#label> "Another Person" .
191<http://example.org/Person2> <http://example.org/hasAge> "25"^^<http://www.w3.org/2001/XMLSchema#integer> .
192"#.to_string()
193}
194
195/// Generate sample RDF/XML content
196fn generate_rdfxml_content(pretty: bool) -> String {
197    if pretty {
198        r#"<?xml version="1.0" encoding="UTF-8"?>
199<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
200         xmlns:rdfs="http://www.w3.org/2000/01/rdf-schema#"
201         xmlns:ex="http://example.org/">
202  <rdf:Description rdf:about="http://example.org/Person1">
203    <rdf:type rdf:resource="http://example.org/Person"/>
204    <rdfs:label>Sample Person</rdfs:label>
205    <ex:hasAge rdf:datatype="http://www.w3.org/2001/XMLSchema#integer">30</ex:hasAge>
206  </rdf:Description>
207  <rdf:Description rdf:about="http://example.org/Person2">
208    <rdf:type rdf:resource="http://example.org/Person"/>
209    <rdfs:label>Another Person</rdfs:label>
210    <ex:hasAge rdf:datatype="http://www.w3.org/2001/XMLSchema#integer">25</ex:hasAge>
211  </rdf:Description>
212</rdf:RDF>
213"#
214        .to_string()
215    } else {
216        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/Person1"><rdf:type rdf:resource="http://example.org/Person"/><rdfs:label>Sample Person</rdfs:label><ex:hasAge rdf:datatype="http://www.w3.org/2001/XMLSchema#integer">30</ex:hasAge></rdf:Description><rdf:Description rdf:about="http://example.org/Person2"><rdf:type rdf:resource="http://example.org/Person"/><rdfs:label>Another Person</rdfs:label><ex:hasAge rdf:datatype="http://www.w3.org/2001/XMLSchema#integer">25</ex:hasAge></rdf:Description></rdf:RDF>"#.to_string()
217    }
218}
219
220/// Generate sample JSON-LD content
221fn generate_jsonld_content(pretty: bool) -> String {
222    if pretty {
223        r#"{
224  "@context": {
225    "rdf": "http://www.w3.org/1999/02/22-rdf-syntax-ns#",
226    "rdfs": "http://www.w3.org/2000/01/rdf-schema#",
227    "ex": "http://example.org/"
228  },
229  "@graph": [
230    {
231      "@id": "ex:Person1",
232      "@type": "ex:Person",
233      "rdfs:label": "Sample Person",
234      "ex:hasAge": 30
235    },
236    {
237      "@id": "ex:Person2",
238      "@type": "ex:Person",
239      "rdfs:label": "Another Person",
240      "ex:hasAge": 25
241    }
242  ]
243}
244"#
245        .to_string()
246    } else {
247        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/"},"@graph":[{"@id":"ex:Person1","@type":"ex:Person","rdfs:label":"Sample Person","ex:hasAge":30},{"@id":"ex:Person2","@type":"ex:Person","rdfs:label":"Another Person","ex:hasAge":25}]}"#.to_string()
248    }
249}
250
251/// Generate sample N3 content
252fn generate_n3_content(pretty: bool) -> String {
253    if pretty {
254        r#"@prefix rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#> .
255@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .
256@prefix ex: <http://example.org/> .
257
258ex:Person1 a ex:Person ;
259    rdfs:label "Sample Person" ;
260    ex:hasAge 30 .
261
262ex:Person2 a ex:Person ;
263    rdfs:label "Another Person" ;
264    ex:hasAge 25 .
265"#
266        .to_string()
267    } else {
268        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:Person1 a ex:Person ; rdfs:label "Sample Person" ; ex:hasAge 30 . ex:Person2 a ex:Person ; rdfs:label "Another Person" ; ex:hasAge 25 ."#.to_string()
269    }
270}
271
272pub async fn run_with_deps(args: &ExportArgs, exporter: &dyn GraphExporter) -> Result<()> {
273    // Validate inputs
274    validate_output_path(&args.output)?;
275    validate_rdf_format(&args.format)?;
276
277    // Show progress for export operation
278    println!("🔍 Exporting graph...");
279
280    let stats = exporter.export(args.output.clone(), args.format.clone(), args.pretty)?;
281
282    println!(
283        "✅ Exported {} triples to {} ({} bytes)",
284        stats.triples_exported, args.output, stats.file_size_bytes
285    );
286
287    Ok(())
288}
289
290#[cfg(test)]
291mod tests {
292    use super::*;
293    use mockall::predicate::*;
294
295    #[tokio::test]
296    async fn test_export_graph() {
297        let mut mock_exporter = MockGraphExporter::new();
298        mock_exporter
299            .expect_export()
300            .with(
301                eq(String::from("output.ttl")),
302                eq(String::from("turtle")),
303                eq(true),
304            )
305            .times(1)
306            .returning(|_, _, _| {
307                Ok(ExportStats {
308                    triples_exported: 150,
309                    file_size_bytes: 4096,
310                })
311            });
312
313        let args = ExportArgs {
314            output: "output.ttl".to_string(),
315            format: "turtle".to_string(),
316            pretty: true,
317        };
318
319        let result = run_with_deps(&args, &mock_exporter).await;
320        assert!(result.is_ok());
321    }
322
323    #[tokio::test]
324    async fn test_export_different_formats() {
325        let formats = vec!["turtle", "ntriples", "rdfxml", "jsonld"];
326
327        for format in formats {
328            let mut mock_exporter = MockGraphExporter::new();
329            mock_exporter
330                .expect_export()
331                .with(always(), eq(String::from(format)), eq(false))
332                .times(1)
333                .returning(|_, _, _| {
334                    Ok(ExportStats {
335                        triples_exported: 100,
336                        file_size_bytes: 2048,
337                    })
338                });
339
340            let args = ExportArgs {
341                output: format!("output.{}", format),
342                format: format.to_string(),
343                pretty: false,
344            };
345
346            let result = run_with_deps(&args, &mock_exporter).await;
347            assert!(result.is_ok());
348        }
349    }
350}