1use clap::Args;
2use ggen_utils::error::Result;
3use std::path::Path;
4
5#[derive(Args, Debug)]
6pub struct ValidateArgs {
7 pub shapes: String,
9
10 #[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
41fn validate_shapes_path(shapes: &str) -> Result<()> {
43 if shapes.trim().is_empty() {
45 return Err(ggen_utils::error::Error::new(
46 "Shapes file path cannot be empty",
47 ));
48 }
49
50 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 if shapes.contains("..") {
59 return Err(ggen_utils::error::Error::new(
60 "Path traversal detected: shapes file path cannot contain '..'",
61 ));
62 }
63
64 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
77fn validate_graph_path(graph: &Option<String>) -> Result<()> {
79 if let Some(graph) = graph {
80 if graph.trim().is_empty() {
82 return Err(ggen_utils::error::Error::new(
83 "Graph file path cannot be empty",
84 ));
85 }
86
87 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 if graph.contains("..") {
96 return Err(ggen_utils::error::Error::new(
97 "Path traversal detected: graph file path cannot contain '..'",
98 ));
99 }
100
101 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_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
152fn validate_graph(shapes: String, graph: Option<String>) -> Result<ValidationReport> {
154 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 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 ggen_core::Graph::new()
175 .map_err(|e| ggen_utils::error::Error::new(&format!("Failed to create graph: {}", e)))?
176 };
177
178 let mut violations = Vec::new();
180
181 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 if !graph_data.is_empty() {
193 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_shapes_path(&args.shapes)?;
206 validate_graph_path(&args.graph)?;
207
208 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 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}