ggen_cli_lib/cmds/template/
lint.rs1use clap::Args;
2use ggen_utils::error::Result;
3use std::fs;
4use std::path::Path;
5
6#[derive(Args, Debug)]
7pub struct LintArgs {
8 pub template_ref: String,
10
11 #[arg(long)]
13 pub sparql: bool,
14
15 #[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
59fn validate_template_ref(template_ref: &str) -> Result<()> {
61 if template_ref.trim().is_empty() {
63 return Err(ggen_utils::error::Error::new(
64 "Template reference cannot be empty",
65 ));
66 }
67
68 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 if template_ref.contains("..") {
77 return Err(ggen_utils::error::Error::new(
78 "Path traversal detected: template reference cannot contain '..'",
79 ));
80 }
81
82 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_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
141fn 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 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 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 let content = fs::read_to_string(path)
171 .map_err(|e| ggen_utils::error::Error::new(&format!("Failed to read template: {}", e)))?;
172
173 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 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 validate_template_variables(&content, &mut report);
189
190 if options.check_sparql {
192 validate_sparql_queries(&content, &mut report);
193 }
194
195 if options.check_schema {
197 validate_schema(&content, &mut report);
198 }
199
200 Ok(report)
201}
202
203fn validate_frontmatter(frontmatter: &str, report: &mut LintReport) {
205 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
224fn 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 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 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
254fn 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 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
274fn validate_schema(content: &str, report: &mut LintReport) {
276 if content.contains("rdf:") || content.contains("@prefix") {
278 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_template_ref(&args.template_ref)?;
291
292 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}