1use clap::Args;
23use ggen_utils::error::Result;
24use std::fs;
25use std::path::{Component, Path};
26
27#[derive(Args, Debug)]
28pub struct ExportArgs {
29 pub output: String,
31
32 #[arg(long, default_value = "turtle")]
34 pub format: String,
35
36 #[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
52fn validate_output_path(output: &str) -> Result<()> {
54 if output.trim().is_empty() {
56 return Err(ggen_utils::error::Error::new(
57 "Output file path cannot be empty",
58 ));
59 }
60
61 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 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 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
89fn validate_rdf_format(format: &str) -> Result<()> {
91 if format.trim().is_empty() {
93 return Err(ggen_utils::error::Error::new("RDF format cannot be empty"));
94 }
95
96 if format.len() > 20 {
98 return Err(ggen_utils::error::Error::new(
99 "RDF format too long (max 20 characters)",
100 ));
101 }
102
103 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_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
131fn export_graph(output: String, format: String, pretty: bool) -> Result<ExportStats> {
133 let graph = ggen_core::Graph::new()
135 .map_err(|e| ggen_utils::error::Error::new(&format!("Failed to create graph: {}", e)))?;
136
137 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 fs::write(&output, content).map_err(|e| {
149 ggen_utils::error::Error::new(&format!("Failed to write output file: {}", e))
150 })?;
151
152 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
163fn 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
184fn 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
195fn 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
220fn 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
251fn 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_output_path(&args.output)?;
275 validate_rdf_format(&args.format)?;
276
277 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}