ggen_cli_lib/cmds/graph/
validate.rs

1use clap::Args;
2use ggen_utils::error::Result;
3use std::path::Path;
4
5#[derive(Args, Debug)]
6pub struct ValidateArgs {
7    /// SHACL shapes file
8    pub shapes: String,
9
10    /// RDF graph to validate
11    #[arg(long)]
12    pub graph: Option<String>,
13}
14
15#[cfg_attr(test, mockall::automock)]
16pub trait ShaclValidator {
17    fn validate(&self, shapes: String, graph: Option<String>) -> Result<ValidationReport>;
18}
19
20#[derive(Debug, Clone)]
21pub struct ValidationReport {
22    pub conforms: bool,
23    pub violations: Vec<Violation>,
24}
25
26#[derive(Debug, Clone)]
27pub struct Violation {
28    pub focus_node: String,
29    pub property: Option<String>,
30    pub message: String,
31    pub severity: Severity,
32}
33
34#[derive(Debug, Clone, PartialEq)]
35pub enum Severity {
36    Violation,
37    Warning,
38    Info,
39}
40
41/// Validate and sanitize shapes file path input
42fn validate_shapes_path(shapes: &str) -> Result<()> {
43    // Validate shapes path is not empty
44    if shapes.trim().is_empty() {
45        return Err(ggen_utils::error::Error::new(
46            "Shapes file path cannot be empty",
47        ));
48    }
49
50    // Validate shapes path length
51    if shapes.len() > 1000 {
52        return Err(ggen_utils::error::Error::new(
53            "Shapes file path too long (max 1000 characters)",
54        ));
55    }
56
57    // Basic path traversal protection
58    if shapes.contains("..") {
59        return Err(ggen_utils::error::Error::new(
60            "Path traversal detected: shapes file path cannot contain '..'",
61        ));
62    }
63
64    // Validate shapes path format (basic pattern check)
65    if !shapes
66        .chars()
67        .all(|c| c.is_alphanumeric() || c == '.' || c == '/' || c == '-' || c == '_' || c == '\\')
68    {
69        return Err(ggen_utils::error::Error::new(
70            "Invalid shapes file path format: only alphanumeric characters, dots, slashes, dashes, underscores, and backslashes allowed",
71        ));
72    }
73
74    Ok(())
75}
76
77/// Validate and sanitize graph file path input (if provided)
78fn validate_graph_path(graph: &Option<String>) -> Result<()> {
79    if let Some(graph) = graph {
80        // Validate graph path is not empty
81        if graph.trim().is_empty() {
82            return Err(ggen_utils::error::Error::new(
83                "Graph file path cannot be empty",
84            ));
85        }
86
87        // Validate graph path length
88        if graph.len() > 1000 {
89            return Err(ggen_utils::error::Error::new(
90                "Graph file path too long (max 1000 characters)",
91            ));
92        }
93
94        // Basic path traversal protection
95        if graph.contains("..") {
96            return Err(ggen_utils::error::Error::new(
97                "Path traversal detected: graph file path cannot contain '..'",
98            ));
99        }
100
101        // Validate graph path format (basic pattern check)
102        if !graph.chars().all(|c| {
103            c.is_alphanumeric() || c == '.' || c == '/' || c == '-' || c == '_' || c == '\\'
104        }) {
105            return Err(ggen_utils::error::Error::new(
106                "Invalid graph file path format: only alphanumeric characters, dots, slashes, dashes, underscores, and backslashes allowed",
107            ));
108        }
109    }
110
111    Ok(())
112}
113
114pub async fn run(args: &ValidateArgs) -> Result<()> {
115    // Validate inputs
116    validate_shapes_path(&args.shapes)?;
117    validate_graph_path(&args.graph)?;
118
119    println!("šŸ” Validating graph against SHACL shapes...");
120
121    let report = validate_graph(args.shapes.clone(), args.graph.clone())?;
122
123    if report.conforms {
124        println!("āœ… Graph conforms to SHACL shapes");
125        return Ok(());
126    }
127
128    println!("āŒ Graph does not conform to SHACL shapes");
129    println!("\nšŸ“‹ Violations:");
130
131    for violation in &report.violations {
132        let severity_symbol = match violation.severity {
133            Severity::Violation => "āŒ",
134            Severity::Warning => "āš ļø",
135            Severity::Info => "ā„¹ļø",
136        };
137
138        println!("{} {}", severity_symbol, violation.focus_node);
139        if let Some(property) = &violation.property {
140            println!("   Property: {}", property);
141        }
142        println!("   {}", violation.message);
143        println!();
144    }
145
146    Err(ggen_utils::error::Error::new_fmt(format_args!(
147        "Validation failed with {} violations",
148        report.violations.len()
149    )))
150}
151
152/// Validate graph against SHACL shapes
153fn validate_graph(shapes: String, graph: Option<String>) -> Result<ValidationReport> {
154    // Check if shapes file exists
155    if !Path::new(&shapes).exists() {
156        return Err(ggen_utils::error::Error::new(&format!(
157            "Shapes file not found: {}",
158            shapes
159        )));
160    }
161
162    // Load graph if provided
163    let graph_data = if let Some(graph_path) = graph {
164        if !Path::new(&graph_path).exists() {
165            return Err(ggen_utils::error::Error::new(&format!(
166                "Graph file not found: {}",
167                graph_path
168            )));
169        }
170        ggen_core::Graph::load_from_file(&graph_path)
171            .map_err(|e| ggen_utils::error::Error::new(&format!("Failed to load graph: {}", e)))?
172    } else {
173        // Use empty graph for demonstration
174        ggen_core::Graph::new()
175            .map_err(|e| ggen_utils::error::Error::new(&format!("Failed to create graph: {}", e)))?
176    };
177
178    // Basic validation - in production this would use proper SHACL validation
179    let mut violations = Vec::new();
180
181    // Simulate some validation checks
182    if graph_data.is_empty() {
183        violations.push(Violation {
184            focus_node: "http://example.org/".to_string(),
185            property: None,
186            message: "Graph is empty".to_string(),
187            severity: Severity::Warning,
188        });
189    }
190
191    // Check for basic RDF structure
192    if !graph_data.is_empty() {
193        // Simulate a validation that always passes for non-empty graphs
194        violations.clear();
195    }
196
197    Ok(ValidationReport {
198        conforms: violations.is_empty(),
199        violations,
200    })
201}
202
203pub async fn run_with_deps(args: &ValidateArgs, validator: &dyn ShaclValidator) -> Result<()> {
204    // Validate inputs
205    validate_shapes_path(&args.shapes)?;
206    validate_graph_path(&args.graph)?;
207
208    // Show progress for validation operation
209    println!("šŸ” Validating graph against SHACL shapes...");
210
211    let report = validator.validate(args.shapes.clone(), args.graph.clone())?;
212
213    if report.conforms {
214        println!("āœ… Graph conforms to SHACL shapes");
215        return Ok(());
216    }
217
218    println!("āŒ Graph does not conform to SHACL shapes");
219    println!("\nšŸ“‹ Violations:");
220
221    // Show progress for large violation sets
222    if report.violations.len() > 20 {
223        println!("šŸ“Š Processing {} violations...", report.violations.len());
224    }
225
226    for violation in &report.violations {
227        let severity_symbol = match violation.severity {
228            Severity::Violation => "āŒ",
229            Severity::Warning => "āš ļø",
230            Severity::Info => "ā„¹ļø",
231        };
232
233        println!("{} {}", severity_symbol, violation.focus_node);
234        if let Some(property) = &violation.property {
235            println!("   Property: {}", property);
236        }
237        println!("   {}", violation.message);
238        println!();
239    }
240
241    Err(ggen_utils::error::Error::new_fmt(format_args!(
242        "Validation failed with {} violations",
243        report.violations.len()
244    )))
245}
246
247#[cfg(test)]
248mod tests {
249    use super::*;
250    use mockall::predicate::*;
251
252    #[tokio::test]
253    async fn test_validate_conforming_graph() {
254        let mut mock_validator = MockShaclValidator::new();
255        mock_validator
256            .expect_validate()
257            .with(
258                eq(String::from("shapes.ttl")),
259                eq(Some(String::from("data.ttl"))),
260            )
261            .times(1)
262            .returning(|_, _| {
263                Ok(ValidationReport {
264                    conforms: true,
265                    violations: vec![],
266                })
267            });
268
269        let args = ValidateArgs {
270            shapes: "shapes.ttl".to_string(),
271            graph: Some("data.ttl".to_string()),
272        };
273
274        let result = run_with_deps(&args, &mock_validator).await;
275        assert!(result.is_ok());
276    }
277
278    #[tokio::test]
279    async fn test_validate_non_conforming_graph() {
280        let mut mock_validator = MockShaclValidator::new();
281        mock_validator.expect_validate().times(1).returning(|_, _| {
282            Ok(ValidationReport {
283                conforms: false,
284                violations: vec![Violation {
285                    focus_node: "ex:Person1".to_string(),
286                    property: Some("ex:age".to_string()),
287                    message: "Value must be greater than 0".to_string(),
288                    severity: Severity::Violation,
289                }],
290            })
291        });
292
293        let args = ValidateArgs {
294            shapes: "shapes.ttl".to_string(),
295            graph: None,
296        };
297
298        let result = run_with_deps(&args, &mock_validator).await;
299        assert!(result.is_err());
300    }
301
302    #[tokio::test]
303    async fn test_validate_multiple_violations() {
304        let mut mock_validator = MockShaclValidator::new();
305        mock_validator.expect_validate().times(1).returning(|_, _| {
306            Ok(ValidationReport {
307                conforms: false,
308                violations: vec![
309                    Violation {
310                        focus_node: "ex:Person1".to_string(),
311                        property: Some("ex:name".to_string()),
312                        message: "Missing required property".to_string(),
313                        severity: Severity::Violation,
314                    },
315                    Violation {
316                        focus_node: "ex:Person2".to_string(),
317                        property: Some("ex:email".to_string()),
318                        message: "Invalid email format".to_string(),
319                        severity: Severity::Warning,
320                    },
321                ],
322            })
323        });
324
325        let args = ValidateArgs {
326            shapes: "shapes.ttl".to_string(),
327            graph: Some("data.ttl".to_string()),
328        };
329
330        let result = run_with_deps(&args, &mock_validator).await;
331        assert!(result.is_err());
332    }
333}