ggen_cli_lib/cmds/template/
lint.rs

1use clap::Args;
2use ggen_utils::error::Result;
3use std::fs;
4use std::path::Path;
5
6#[derive(Args, Debug)]
7pub struct LintArgs {
8    /// Template reference (local path or gpack:template)
9    pub template_ref: String,
10
11    /// Check SPARQL query syntax
12    #[arg(long)]
13    pub sparql: bool,
14
15    /// Validate against RDF schema
16    #[arg(long)]
17    pub schema: bool,
18}
19
20#[cfg_attr(test, mockall::automock)]
21pub trait TemplateLinter {
22    fn lint(&self, template_ref: &str, options: &LintOptions) -> Result<LintReport>;
23}
24
25#[derive(Debug, Clone)]
26pub struct LintOptions {
27    pub check_sparql: bool,
28    pub check_schema: bool,
29}
30
31#[derive(Debug, Clone)]
32pub struct LintReport {
33    pub errors: Vec<LintError>,
34    pub warnings: Vec<LintWarning>,
35}
36
37#[derive(Debug, Clone)]
38pub struct LintError {
39    pub line: Option<usize>,
40    pub message: String,
41}
42
43#[derive(Debug, Clone)]
44pub struct LintWarning {
45    pub line: Option<usize>,
46    pub message: String,
47}
48
49impl LintReport {
50    pub fn has_errors(&self) -> bool {
51        !self.errors.is_empty()
52    }
53
54    pub fn has_warnings(&self) -> bool {
55        !self.warnings.is_empty()
56    }
57}
58
59/// Validate and sanitize template reference input
60fn validate_template_ref(template_ref: &str) -> Result<()> {
61    // Validate template reference is not empty
62    if template_ref.trim().is_empty() {
63        return Err(ggen_utils::error::Error::new(
64            "Template reference cannot be empty",
65        ));
66    }
67
68    // Validate template reference length
69    if template_ref.len() > 500 {
70        return Err(ggen_utils::error::Error::new(
71            "Template reference too long (max 500 characters)",
72        ));
73    }
74
75    // Basic path traversal protection
76    if template_ref.contains("..") {
77        return Err(ggen_utils::error::Error::new(
78            "Path traversal detected: template reference cannot contain '..'",
79        ));
80    }
81
82    // Validate template reference format (basic pattern check)
83    if !template_ref
84        .chars()
85        .all(|c| c.is_alphanumeric() || c == '.' || c == '/' || c == ':' || c == '-' || c == '_')
86    {
87        return Err(ggen_utils::error::Error::new(
88            "Invalid template reference format: only alphanumeric characters, dots, slashes, colons, dashes, and underscores allowed",
89        ));
90    }
91
92    Ok(())
93}
94
95pub async fn run(args: &LintArgs) -> Result<()> {
96    // Validate input
97    validate_template_ref(&args.template_ref)?;
98
99    println!("🔍 Linting template...");
100
101    let options = LintOptions {
102        check_sparql: args.sparql,
103        check_schema: args.schema,
104    };
105
106    let report = lint_template(&args.template_ref, &options)?;
107
108    if !report.errors.is_empty() {
109        println!("❌ Errors:");
110        for error in &report.errors {
111            if let Some(line) = error.line {
112                println!("  Line {}: {}", line, error.message);
113            } else {
114                println!("  {}", error.message);
115            }
116        }
117    }
118
119    if !report.warnings.is_empty() {
120        println!("⚠️  Warnings:");
121        for warning in &report.warnings {
122            if let Some(line) = warning.line {
123                println!("  Line {}: {}", line, warning.message);
124            } else {
125                println!("  {}", warning.message);
126            }
127        }
128    }
129
130    if !report.has_errors() && !report.has_warnings() {
131        println!("✅ No issues found");
132    }
133
134    if report.has_errors() {
135        Err(ggen_utils::error::Error::new("Template has errors"))
136    } else {
137        Ok(())
138    }
139}
140
141/// Lint a template file for common issues
142fn lint_template(template_ref: &str, options: &LintOptions) -> Result<LintReport> {
143    let mut report = LintReport {
144        errors: Vec::new(),
145        warnings: Vec::new(),
146    };
147
148    // Determine template path
149    let template_path = if template_ref.starts_with("gpack:") {
150        return Err(ggen_utils::error::Error::new(
151            "gpack templates not yet supported",
152        ));
153    } else if template_ref.contains('/') {
154        template_ref.to_string()
155    } else {
156        format!("templates/{}", template_ref)
157    };
158
159    // Check if template exists
160    let path = Path::new(&template_path);
161    if !path.exists() {
162        report.errors.push(LintError {
163            line: None,
164            message: format!("Template file not found: {}", template_path),
165        });
166        return Ok(report);
167    }
168
169    // Read template content
170    let content = fs::read_to_string(path)
171        .map_err(|e| ggen_utils::error::Error::new(&format!("Failed to read template: {}", e)))?;
172
173    // Check for YAML frontmatter
174    if !content.starts_with("---\n") {
175        report.warnings.push(LintWarning {
176            line: Some(1),
177            message: "Template should start with YAML frontmatter (---)".to_string(),
178        });
179    } else {
180        // Validate YAML frontmatter
181        if let Some(end_pos) = content.find("\n---\n") {
182            let frontmatter = &content[4..end_pos];
183            validate_frontmatter(frontmatter, &mut report);
184        }
185    }
186
187    // Check for template variables
188    validate_template_variables(&content, &mut report);
189
190    // Check SPARQL queries if requested
191    if options.check_sparql {
192        validate_sparql_queries(&content, &mut report);
193    }
194
195    // Check schema if requested
196    if options.check_schema {
197        validate_schema(&content, &mut report);
198    }
199
200    Ok(report)
201}
202
203/// Validate YAML frontmatter
204fn validate_frontmatter(frontmatter: &str, report: &mut LintReport) {
205    // Check for required fields
206    let has_to = frontmatter.contains("to:");
207    let has_vars = frontmatter.contains("vars:");
208
209    if !has_to {
210        report.warnings.push(LintWarning {
211            line: None,
212            message: "Frontmatter should include 'to:' field for output path".to_string(),
213        });
214    }
215
216    if !has_vars {
217        report.warnings.push(LintWarning {
218            line: None,
219            message: "Frontmatter should include 'vars:' field for template variables".to_string(),
220        });
221    }
222}
223
224/// Validate template variables
225fn validate_template_variables(content: &str, report: &mut LintReport) {
226    let lines: Vec<&str> = content.lines().collect();
227
228    for (line_num, line) in lines.iter().enumerate() {
229        // Check for unclosed template variables
230        if line.contains("{{") && !line.contains("}}") {
231            report.errors.push(LintError {
232                line: Some(line_num + 1),
233                message: "Unclosed template variable".to_string(),
234            });
235        }
236
237        if line.contains("}}") && !line.contains("{{") {
238            report.errors.push(LintError {
239                line: Some(line_num + 1),
240                message: "Closing template variable without opening".to_string(),
241            });
242        }
243
244        // Check for empty template variables
245        if line.contains("{{ }}") || line.contains("{{}}") {
246            report.warnings.push(LintWarning {
247                line: Some(line_num + 1),
248                message: "Empty template variable".to_string(),
249            });
250        }
251    }
252}
253
254/// Validate SPARQL queries
255fn validate_sparql_queries(content: &str, report: &mut LintReport) {
256    let lines: Vec<&str> = content.lines().collect();
257
258    for (line_num, line) in lines.iter().enumerate() {
259        if line.trim().starts_with("sparql:")
260            || line.contains("SELECT")
261            || line.contains("CONSTRUCT")
262        {
263            // Basic SPARQL validation
264            if line.contains("SELECT") && !line.contains("WHERE") {
265                report.warnings.push(LintWarning {
266                    line: Some(line_num + 1),
267                    message: "SPARQL SELECT query should include WHERE clause".to_string(),
268                });
269            }
270        }
271    }
272}
273
274/// Validate schema compliance
275fn validate_schema(content: &str, report: &mut LintReport) {
276    // Basic schema validation - check for common patterns
277    if content.contains("rdf:") || content.contains("@prefix") {
278        // Check for proper RDF structure
279        if content.contains("@prefix") && !content.contains(".") {
280            report.warnings.push(LintWarning {
281                line: None,
282                message: "RDF prefixes should end with '.'".to_string(),
283            });
284        }
285    }
286}
287
288pub async fn run_with_deps(args: &LintArgs, linter: &dyn TemplateLinter) -> Result<()> {
289    // Validate input
290    validate_template_ref(&args.template_ref)?;
291
292    // Show progress for linting operation
293    println!("🔍 Linting template...");
294
295    let options = LintOptions {
296        check_sparql: args.sparql,
297        check_schema: args.schema,
298    };
299
300    let report = linter.lint(&args.template_ref, &options)?;
301
302    if !report.errors.is_empty() {
303        println!("❌ Errors:");
304        for error in &report.errors {
305            if let Some(line) = error.line {
306                println!("  Line {}: {}", line, error.message);
307            } else {
308                println!("  {}", error.message);
309            }
310        }
311    }
312
313    if !report.warnings.is_empty() {
314        println!("⚠️  Warnings:");
315        for warning in &report.warnings {
316            if let Some(line) = warning.line {
317                println!("  Line {}: {}", line, warning.message);
318            } else {
319                println!("  {}", warning.message);
320            }
321        }
322    }
323
324    if !report.has_errors() && !report.has_warnings() {
325        println!("✅ No issues found");
326    }
327
328    if report.has_errors() {
329        Err(ggen_utils::error::Error::new("Template has errors"))
330    } else {
331        Ok(())
332    }
333}
334
335#[cfg(test)]
336mod tests {
337    use super::*;
338    use mockall::predicate::*;
339
340    #[tokio::test]
341    async fn test_lint_no_issues() {
342        let mut mock_linter = MockTemplateLinter::new();
343        mock_linter
344            .expect_lint()
345            .with(eq(String::from("hello.tmpl")), always())
346            .times(1)
347            .returning(|_, _| {
348                Ok(LintReport {
349                    errors: vec![],
350                    warnings: vec![],
351                })
352            });
353
354        let args = LintArgs {
355            template_ref: "hello.tmpl".to_string(),
356            sparql: false,
357            schema: false,
358        };
359
360        let result = run_with_deps(&args, &mock_linter).await;
361        assert!(result.is_ok());
362    }
363
364    #[tokio::test]
365    async fn test_lint_with_errors() {
366        let mut mock_linter = MockTemplateLinter::new();
367        mock_linter.expect_lint().times(1).returning(|_, _| {
368            Ok(LintReport {
369                errors: vec![LintError {
370                    line: Some(10),
371                    message: "Invalid SPARQL syntax".to_string(),
372                }],
373                warnings: vec![],
374            })
375        });
376
377        let args = LintArgs {
378            template_ref: "bad.tmpl".to_string(),
379            sparql: true,
380            schema: false,
381        };
382
383        let result = run_with_deps(&args, &mock_linter).await;
384        assert!(result.is_err());
385    }
386
387    #[tokio::test]
388    async fn test_lint_with_warnings() {
389        let mut mock_linter = MockTemplateLinter::new();
390        mock_linter.expect_lint().times(1).returning(|_, _| {
391            Ok(LintReport {
392                errors: vec![],
393                warnings: vec![LintWarning {
394                    line: None,
395                    message: "Variable 'name' is unused".to_string(),
396                }],
397            })
398        });
399
400        let args = LintArgs {
401            template_ref: "warning.tmpl".to_string(),
402            sparql: false,
403            schema: false,
404        };
405
406        let result = run_with_deps(&args, &mock_linter).await;
407        assert!(result.is_ok());
408    }
409
410    #[tokio::test]
411    async fn test_lint_options_passed_correctly() {
412        let mut mock_linter = MockTemplateLinter::new();
413        mock_linter
414            .expect_lint()
415            .withf(|_, options| options.check_sparql && options.check_schema)
416            .times(1)
417            .returning(|_, _| {
418                Ok(LintReport {
419                    errors: vec![],
420                    warnings: vec![],
421                })
422            });
423
424        let args = LintArgs {
425            template_ref: "test.tmpl".to_string(),
426            sparql: true,
427            schema: true,
428        };
429
430        let result = run_with_deps(&args, &mock_linter).await;
431        assert!(result.is_ok());
432    }
433}