rgen_cli_lib/cmds/
lint.rs

1use clap::Args;
2use std::collections::BTreeMap;
3use std::path::PathBuf;
4use tera::Context;
5use rgen_utils::error::Result;
6
7use rgen_core::graph::Graph;
8use rgen_core::template::Template;
9
10#[derive(Args, Debug)]
11pub struct LintArgs {
12    /// Path to the template file to lint
13    #[arg(value_name = "TEMPLATE")]
14    pub template: PathBuf,
15
16    /// Variables (key=value pairs) for frontmatter rendering
17    #[arg(short = 'v', long = "var", value_parser = parse_key_val::<String, String>)]
18    pub vars: Vec<(String, String)>,
19
20    /// Show detailed linting output
21    #[arg(long)]
22    pub verbose: bool,
23
24    /// Perform SHACL validation (requires RDF data)
25    #[arg(long)]
26    pub shacl: bool,
27}
28
29fn parse_key_val<K, V>(s: &str) -> std::result::Result<(K, V), String>
30where
31    K: std::str::FromStr,
32    K::Err: ToString,
33    V: std::str::FromStr,
34    V::Err: ToString,
35{
36    let pos = s
37        .find('=')
38        .ok_or_else(|| format!("invalid KEY=value: no `=` found in `{s}`"))?;
39    let key = s[..pos].parse().map_err(|e: K::Err| e.to_string())?;
40    let val = s[pos + 1..].parse().map_err(|e: V::Err| e.to_string())?;
41    Ok((key, val))
42}
43
44pub fn run(args: &LintArgs) -> Result<()> {
45    let mut issues = Vec::new();
46
47    // Read template file
48    let template_content = std::fs::read_to_string(&args.template)?;
49
50    // Parse template
51    let mut template = Template::parse(&template_content)?;
52
53    // Create Tera context from vars
54    let mut vars = BTreeMap::new();
55    for (k, v) in &args.vars {
56        vars.insert(k.clone(), v.clone());
57    }
58    let mut ctx = Context::from_serialize(&vars)?;
59
60    // Insert environment variables
61    insert_env(&mut ctx);
62
63    // Create Tera instance
64    let mut tera = tera::Tera::default();
65    tera.autoescape_on(vec![]);
66
67    // Register text transformation filters
68    rgen_core::register::register_all(&mut tera);
69
70    // Render frontmatter
71    template.render_frontmatter(&mut tera, &ctx)?;
72
73    // Schema validation
74    validate_frontmatter_schema(&template.front, &mut issues);
75
76    // SPARQL syntax validation
77    validate_sparql_queries(&template.front, &mut issues);
78
79    // RDF syntax validation
80    validate_rdf_content(&template.front, &mut issues);
81
82    // SHACL validation if requested
83    if args.shacl {
84        validate_shacl(&template.front, &mut issues);
85    }
86
87    // Report issues
88    if issues.is_empty() {
89        println!("✓ No linting issues found");
90    } else {
91        println!("Found {} linting issue(s):", issues.len());
92        for (i, issue) in issues.iter().enumerate() {
93            println!("{}. {}", i + 1, issue);
94        }
95        return Err(rgen_utils::error::Error::new("Linting failed"));
96    }
97
98    Ok(())
99}
100
101fn validate_frontmatter_schema(
102    frontmatter: &rgen_core::template::Frontmatter, issues: &mut Vec<String>,
103) {
104    // Check for unknown or deprecated keys
105    // This is a basic validation - in a real implementation, you'd load the JSON schema
106
107    // Validate injection configuration
108    if frontmatter.inject {
109        if frontmatter.to.is_none() {
110            issues.push("Injection mode requires 'to:' field to specify target file".to_string());
111        }
112
113        // Check for conflicting injection modes
114        let _injection_modes = [
115            frontmatter.before.is_some(),
116            frontmatter.after.is_some(),
117            frontmatter.prepend,
118            frontmatter.append,
119            frontmatter.at_line.is_some(),
120        ];
121
122        let active_modes: Vec<&str> = [
123            (frontmatter.before.is_some(), "before"),
124            (frontmatter.after.is_some(), "after"),
125            (frontmatter.prepend, "prepend"),
126            (frontmatter.append, "append"),
127            (frontmatter.at_line.is_some(), "at_line"),
128        ]
129        .iter()
130        .filter_map(|(active, name)| if *active { Some(*name) } else { None })
131        .collect();
132
133        if active_modes.len() > 1 {
134            issues.push(format!(
135                "Multiple injection modes specified: {}. Only one should be used",
136                active_modes.join(", ")
137            ));
138        }
139
140        if active_modes.is_empty() {
141            issues.push("Injection mode enabled but no injection method specified (before, after, prepend, append, at_line)".to_string());
142        }
143    }
144
145    // Validate shell hooks
146    if frontmatter.sh_before.is_some() && !frontmatter.inject {
147        issues.push("sh_before specified but injection mode is not enabled".to_string());
148    }
149
150    if frontmatter.sh_after.is_some() && !frontmatter.inject {
151        issues.push("sh_after specified but injection mode is not enabled".to_string());
152    }
153
154    // Validate RDF configuration
155    if !frontmatter.rdf.is_empty() && !frontmatter.rdf_inline.is_empty() {
156        // This is actually fine, but we could warn about it
157    }
158
159    // Validate SPARQL configuration
160    for (name, query) in &frontmatter.sparql {
161        if name.trim().is_empty() {
162            issues.push("SPARQL query name cannot be empty".to_string());
163        }
164        if query.trim().is_empty() {
165            issues.push(format!("SPARQL query '{}' is empty", name));
166        }
167    }
168}
169
170fn validate_sparql_queries(frontmatter: &rgen_core::template::Frontmatter, issues: &mut Vec<String>) {
171    for (name, query) in &frontmatter.sparql {
172        // Basic SPARQL syntax validation
173        if !query.to_uppercase().contains("SELECT")
174            && !query.to_uppercase().contains("ASK")
175            && !query.to_uppercase().contains("CONSTRUCT")
176            && !query.to_uppercase().contains("DESCRIBE")
177        {
178            issues.push(format!("SPARQL query '{}' does not appear to be a valid query type (SELECT, ASK, CONSTRUCT, DESCRIBE)", name));
179        }
180
181        // Check for common syntax issues
182        if query.contains("{{") && !query.contains("}}") {
183            issues.push(format!(
184                "SPARQL query '{}' has unclosed template variable",
185                name
186            ));
187        }
188
189        if query.contains("}}") && !query.contains("{{") {
190            issues.push(format!(
191                "SPARQL query '{}' has template closing without opening",
192                name
193            ));
194        }
195    }
196}
197
198fn validate_rdf_content(frontmatter: &rgen_core::template::Frontmatter, issues: &mut Vec<String>) {
199    // Validate inline RDF content
200    for (i, rdf_content) in frontmatter.rdf_inline.iter().enumerate() {
201        if rdf_content.trim().is_empty() {
202            issues.push(format!("Inline RDF block {} is empty", i + 1));
203        }
204
205        // Basic Turtle syntax validation
206        if rdf_content.contains("@prefix") && !rdf_content.contains(" .") {
207            issues.push(format!(
208                "Inline RDF block {} has @prefix without proper termination",
209                i + 1
210            ));
211        }
212    }
213
214    // Validate RDF file references
215    for (i, rdf_file) in frontmatter.rdf.iter().enumerate() {
216        if rdf_file.trim().is_empty() {
217            issues.push(format!("RDF file reference {} is empty", i + 1));
218        }
219    }
220}
221
222fn validate_shacl(frontmatter: &rgen_core::template::Frontmatter, issues: &mut Vec<String>) {
223    // Early validation checks
224    if frontmatter.rdf.is_empty() && frontmatter.rdf_inline.is_empty() {
225        issues.push("SHACL validation requested but no RDF data is available".to_string());
226        return;
227    }
228
229    if frontmatter.shape.is_empty() {
230        issues.push("SHACL validation requested but no shape files specified".to_string());
231        return;
232    }
233
234    // Use a single graph for both data and shapes to avoid duplication
235    let mut combined_graph = match Graph::new() {
236        Ok(g) => g,
237        Err(e) => {
238            issues.push(format!(
239                "Failed to initialize graph for SHACL validation: {}",
240                e
241            ));
242            return;
243        }
244    };
245
246    // Load RDF data and shapes into the same graph
247    if let Err(e) = load_rdf_data_into_graph(frontmatter, &mut combined_graph) {
248        issues.push(format!(
249            "Failed to load RDF data for SHACL validation: {}",
250            e
251        ));
252        return;
253    }
254
255    if let Err(e) = load_shacl_shapes_into_graph(frontmatter, &mut combined_graph) {
256        issues.push(format!("Failed to load SHACL shapes: {}", e));
257        return;
258    }
259
260    // Perform optimized SHACL validation
261    match perform_optimized_shacl_validation(&combined_graph) {
262        Ok(validation_results) => {
263            if !validation_results.is_empty() {
264                for result in validation_results {
265                    issues.push(format!("SHACL validation error: {}", result));
266                }
267            }
268        }
269        Err(e) => {
270            issues.push(format!("SHACL validation failed: {}", e));
271        }
272    }
273}
274
275fn load_rdf_data_into_graph(
276    frontmatter: &rgen_core::template::Frontmatter, graph: &mut Graph,
277) -> Result<()> {
278    // Load inline RDF data first (usually smaller and faster)
279    for rdf_content in &frontmatter.rdf_inline {
280        if !rdf_content.trim().is_empty() {
281            graph.insert_turtle(rdf_content)?;
282        }
283    }
284
285    // Load RDF files
286    for rdf_file in &frontmatter.rdf {
287        if !rdf_file.trim().is_empty() {
288            graph.load_path(rdf_file)?;
289        }
290    }
291
292    Ok(())
293}
294
295fn load_shacl_shapes_into_graph(
296    frontmatter: &rgen_core::template::Frontmatter, graph: &mut Graph,
297) -> Result<()> {
298    // Load shape files
299    for shape_file in &frontmatter.shape {
300        if !shape_file.trim().is_empty() {
301            graph.load_path(shape_file)?;
302        }
303    }
304
305    Ok(())
306}
307
308fn perform_optimized_shacl_validation(combined_graph: &Graph) -> Result<Vec<String>> {
309    let mut validation_errors = Vec::new();
310
311    // Early exit if graph is empty
312    if combined_graph.is_empty() {
313        validation_errors
314            .push("Combined graph is empty - no data or shapes to validate".to_string());
315        return Ok(validation_errors);
316    }
317
318    // Use cached queries for better performance
319    let shapes_query = "SELECT ?shape WHERE { ?shape <http://www.w3.org/1999/02/22-rdf-syntax-ns#type> <http://www.w3.org/ns/shacl#NodeShape> }";
320    let shapes_result = combined_graph.query_cached(shapes_query)?;
321
322    match shapes_result {
323        rgen_core::graph::CachedResult::Solutions(solutions) => {
324            if solutions.is_empty() {
325                validation_errors.push("No SHACL NodeShapes found in shapes graph".to_string());
326            } else {
327                // Validate each shape against the data
328                for shape_solution in solutions {
329                    if let Some(shape_iri) = shape_solution.get("shape") {
330                        if let Err(e) = validate_single_shape(combined_graph, shape_iri) {
331                            validation_errors
332                                .push(format!("Shape validation error for {}: {}", shape_iri, e));
333                        }
334                    }
335                }
336            }
337        }
338        _ => {
339            validation_errors.push("Failed to query for SHACL shapes".to_string());
340        }
341    }
342
343    Ok(validation_errors)
344}
345
346fn validate_single_shape(graph: &Graph, shape_iri: &str) -> Result<()> {
347    // Basic shape validation - check if shape has required properties
348    let properties_query = format!(
349        "SELECT ?property WHERE {{ <{}> <http://www.w3.org/ns/shacl#property> ?property }}",
350        shape_iri
351    );
352
353    let properties_result = graph.query_cached(&properties_query)?;
354    match properties_result {
355        rgen_core::graph::CachedResult::Solutions(properties) => {
356            // Validate each property constraint
357            for property_solution in properties {
358                if let Some(property_iri) = property_solution.get("property") {
359                    validate_property_constraint(graph, property_iri)?;
360                }
361            }
362        }
363        _ => {
364            // No properties defined for this shape - this might be valid
365        }
366    }
367
368    Ok(())
369}
370
371fn validate_property_constraint(graph: &Graph, property_iri: &str) -> Result<()> {
372    // Check for common SHACL property constraints
373    let min_count_query = format!(
374        "ASK WHERE {{ <{}> <http://www.w3.org/ns/shacl#minCount> ?minCount }}",
375        property_iri
376    );
377
378    let min_count_result = graph.query_cached(&min_count_query)?;
379    match min_count_result {
380        rgen_core::graph::CachedResult::Boolean(true) => {
381            // Property has minCount constraint - would need to validate against data
382            // For now, just note that the constraint exists
383        }
384        _ => {
385            // No minCount constraint
386        }
387    }
388
389    Ok(())
390}
391
392fn insert_env(ctx: &mut Context) {
393    let mut env_map: BTreeMap<String, String> = BTreeMap::new();
394    for (k, v) in std::env::vars() {
395        env_map.insert(k, v);
396    }
397    ctx.insert("env", &env_map);
398
399    if let Ok(cwd) = std::env::current_dir() {
400        ctx.insert("cwd", &cwd.display().to_string());
401    }
402}