1use std::collections::HashMap;
14use std::fmt;
15use std::path::{Path, PathBuf};
16
17use tera::{Context, Tera};
18
19use crate::error::CliError;
20
21pub struct TemplateEngine {
26 tera: Tera,
28 template_dir: PathBuf,
30 template_types: Vec<String>,
32}
33
34impl fmt::Debug for TemplateEngine {
35 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
36 f.debug_struct("TemplateEngine")
37 .field("template_dir", &self.template_dir)
38 .field("template_types", &self.template_types)
39 .finish()
40 }
41}
42
43impl TemplateEngine {
44 pub async fn init(template_dir: &Path) -> Result<Self, CliError> {
54 if !tokio::fs::try_exists(template_dir).await? {
55 return Err(CliError::TemplateMissing(vec![template_dir
56 .display()
57 .to_string()]));
58 }
59
60 let mut tera = Tera::default();
61 let mut template_types = Vec::new();
62 let mut all_template_files = Vec::new();
63
64 let mut root_entries = tokio::fs::read_dir(template_dir).await?;
65 while let Some(entry) = root_entries.next_entry().await? {
66 let path = entry.path();
67 if path.is_dir() {
68 let dir_name = entry.file_name().to_string_lossy().to_string();
69 template_types.push(dir_name.clone());
70
71 let mut sub_entries = tokio::fs::read_dir(&path).await?;
72 while let Some(sub_entry) = sub_entries.next_entry().await? {
73 let sub_path = sub_entry.path();
74 if sub_path.is_file()
75 && sub_path
76 .extension()
77 .map(|ext| ext == "tera")
78 .unwrap_or(false)
79 {
80 all_template_files.push(sub_path);
81 }
82 }
83 }
84 }
85
86 template_types.sort();
87
88 let mut pending: Vec<(String, String)> = Vec::new();
95 for file_path in &all_template_files {
96 let relative = file_path.strip_prefix(template_dir).unwrap_or(file_path);
97 let template_name = relative.to_string_lossy().replace('\\', "/");
98 let content = tokio::fs::read_to_string(file_path).await?;
99 pending.push((template_name, content));
100 }
101
102 while !pending.is_empty() {
103 let mut deferred: Vec<(String, String)> = Vec::new();
104 let mut progress = 0usize;
105 for (name, content) in pending.drain(..) {
106 match tera.add_raw_template(&name, &content) {
107 Ok(()) => progress += 1,
108 Err(_) => deferred.push((name, content)),
109 }
110 }
111 if progress == 0 && !deferred.is_empty() {
112 let (name, content) = deferred.swap_remove(0);
114 tera.add_raw_template(&name, &content)
115 .map_err(|e| map_syntax_error(e, &name))?;
116 unreachable!("无进展轮次的重新注册必然返回 Err");
117 }
118 pending = deferred;
119 }
120
121 tera.register_filter(
122 "pascal_case",
123 |value: &tera::Value, _: &HashMap<String, tera::Value>| {
124 let s = value.as_str().unwrap_or("");
125 let pascal: String = s
126 .split('_')
127 .map(|word| {
128 let mut chars = word.chars();
129 match chars.next() {
130 Some(first) => {
131 first.to_uppercase().collect::<String>() + chars.as_str()
132 }
133 None => String::new(),
134 }
135 })
136 .collect();
137 Ok(tera::Value::String(pascal))
138 },
139 );
140
141 tera.register_filter(
142 "snake_case",
143 |value: &tera::Value, _: &HashMap<String, tera::Value>| {
144 let s = value.as_str().unwrap_or("");
145 let snake = s.replace('-', "_").to_lowercase();
146 Ok(tera::Value::String(snake))
147 },
148 );
149
150 Ok(Self {
151 tera,
152 template_dir: template_dir.to_path_buf(),
153 template_types,
154 })
155 }
156
157 pub fn render(&self, template_name: &str, context: &Context) -> Result<String, CliError> {
164 self.tera
165 .render(template_name, context)
166 .map_err(|e| map_render_error(e, template_name))
167 }
168
169 pub fn list_templates(&self) -> Vec<String> {
171 self.template_types.clone()
172 }
173
174 pub fn validate_template_type(&self, template_type: &str) -> Result<(), CliError> {
180 if self.template_types.iter().any(|t| t == template_type) {
181 Ok(())
182 } else {
183 Err(CliError::UnknownTemplate {
184 requested: template_type.to_string(),
185 available: self.template_types.clone(),
186 })
187 }
188 }
189
190 pub fn template_dir(&self) -> &Path {
192 &self.template_dir
193 }
194}
195
196fn map_syntax_error(e: tera::Error, file: &str) -> CliError {
198 let msg = e.to_string();
199 let (line, col) = parse_line_col(&msg);
200 CliError::TemplateSyntaxError {
201 file: file.to_string(),
202 line,
203 col,
204 msg,
205 }
206}
207
208fn map_render_error(e: tera::Error, template_name: &str) -> CliError {
210 let msg = e.to_string();
211
212 let mut full_msg = msg.clone();
213 let mut source = std::error::Error::source(&e);
214 while let Some(s) = source {
215 full_msg.push_str(&format!("\n caused by: {s}"));
216 source = std::error::Error::source(s);
217 }
218
219 if let Some(var) = extract_variable_name(&full_msg) {
220 let line = parse_line_col(&full_msg).0;
221 CliError::VarNotFound {
222 var,
223 file: template_name.to_string(),
224 line,
225 }
226 } else {
227 CliError::Generic(msg)
228 }
229}
230
231fn parse_line_col(msg: &str) -> (usize, usize) {
235 let mut line = 0;
236 let mut col = 0;
237
238 if let Some(pos) = msg.find("line ") {
239 let rest = &msg[pos + 5..];
240 if let Some(num) = take_number(rest) {
241 line = num;
242 }
243 }
244 if let Some(pos) = msg.find("column ") {
245 let rest = &msg[pos + 7..];
246 if let Some(num) = take_number(rest) {
247 col = num;
248 }
249 }
250
251 (line, col)
252}
253
254fn extract_variable_name(msg: &str) -> Option<String> {
258 let prefix = "Variable `";
259 if let Some(start) = msg.find(prefix) {
260 let rest = &msg[start + prefix.len()..];
261 if let Some(end) = rest.find('`') {
262 return Some(rest[..end].to_string());
263 }
264 }
265 None
266}
267
268fn take_number(s: &str) -> Option<usize> {
270 let num_str: String = s.chars().take_while(|c| c.is_ascii_digit()).collect();
271 if num_str.is_empty() {
272 None
273 } else {
274 num_str.parse().ok()
275 }
276}
277
278#[cfg(test)]
279mod tests {
280 use super::*;
281 use std::path::PathBuf;
282
283 async fn setup_test_templates() -> (tempfile::TempDir, PathBuf) {
285 let temp = tempfile::tempdir().expect("tempdir failed");
286 let template_dir = temp.path().to_path_buf();
287
288 let crud_dir = template_dir.join("plugin-crud");
289 tokio::fs::create_dir_all(&crud_dir)
290 .await
291 .expect("mkdir failed");
292
293 tokio::fs::write(
294 &crud_dir.join("model.rs.tera"),
295 "// Model for {{ table_name }}\npub struct {{ class_name }} {\n {% for field in fields %}{{ field.name }}: {{ field.rust_type }},\n {% endfor %}}\n}\n",
296 )
297 .await
298 .expect("write failed");
299
300 tokio::fs::write(
301 &crud_dir.join("controller.rs.tera"),
302 "// Controller for {{ table_name }}\n",
303 )
304 .await
305 .expect("write failed");
306
307 let ms_dir = template_dir.join("plugin-master-slave");
308 tokio::fs::create_dir_all(&ms_dir)
309 .await
310 .expect("mkdir failed");
311 tokio::fs::write(
312 &ms_dir.join("master_model.rs.tera"),
313 "// Master: {{ master_table }}\n",
314 )
315 .await
316 .expect("write failed");
317
318 (temp, template_dir)
319 }
320
321 #[tokio::test]
322 async fn test_init_loads_templates() {
323 let (_temp, template_dir) = setup_test_templates().await;
324 let engine = TemplateEngine::init(&template_dir).await;
325 assert!(engine.is_ok(), "init should succeed");
326 let engine = engine.unwrap();
327 let templates = engine.list_templates();
328 assert!(templates.contains(&"plugin-crud".to_string()));
329 assert!(templates.contains(&"plugin-master-slave".to_string()));
330 }
331
332 #[tokio::test]
333 async fn test_init_dir_not_exists() {
334 let result = TemplateEngine::init(Path::new("/nonexistent/path/templates")).await;
335 assert!(result.is_err());
336 let err = result.unwrap_err();
337 assert!(matches!(err, CliError::TemplateMissing(_)));
338 }
339
340 #[tokio::test]
341 async fn test_init_syntax_error() {
342 let temp = tempfile::tempdir().expect("tempdir failed");
343 let template_dir = temp.path().to_path_buf();
344 let crud_dir = template_dir.join("bad-template");
345 tokio::fs::create_dir_all(&crud_dir)
346 .await
347 .expect("mkdir failed");
348
349 tokio::fs::write(
350 &crud_dir.join("bad.rs.tera"),
351 "{% for field in fields %}{{ field.name }}\n",
352 )
353 .await
354 .expect("write failed");
355
356 let result = TemplateEngine::init(&template_dir).await;
357 assert!(result.is_err());
358 let err = result.unwrap_err();
359 assert!(matches!(err, CliError::TemplateSyntaxError { .. }));
360 }
361
362 #[tokio::test]
363 async fn test_render_success() {
364 let (_temp, template_dir) = setup_test_templates().await;
365 let engine = TemplateEngine::init(&template_dir)
366 .await
367 .expect("init failed");
368
369 let mut ctx = Context::new();
370 ctx.insert("table_name", "users");
371 ctx.insert("class_name", "User");
372 ctx.insert(
373 "fields",
374 &vec![serde_json::json!({"name": "id", "rust_type": "i32"})],
375 );
376
377 let result = engine.render("plugin-crud/model.rs.tera", &ctx);
378 assert!(result.is_ok(), "render should succeed: {:?}", result);
379 let output = result.unwrap();
380 assert!(output.contains("users"));
381 assert!(output.contains("User"));
382 assert!(output.contains("id"));
383 }
384
385 #[tokio::test]
386 async fn test_render_var_not_found() {
387 let (_temp, template_dir) = setup_test_templates().await;
388 let engine = TemplateEngine::init(&template_dir)
389 .await
390 .expect("init failed");
391
392 let ctx = Context::new();
393 let result = engine.render("plugin-crud/controller.rs.tera", &ctx);
394 assert!(result.is_err());
395 let err = result.unwrap_err();
396 assert!(matches!(err, CliError::VarNotFound { .. }));
397 }
398
399 #[tokio::test]
400 async fn test_list_templates() {
401 let (_temp, template_dir) = setup_test_templates().await;
402 let engine = TemplateEngine::init(&template_dir)
403 .await
404 .expect("init failed");
405 let templates = engine.list_templates();
406 assert_eq!(templates.len(), 2);
407 assert!(templates.contains(&"plugin-crud".to_string()));
408 assert!(templates.contains(&"plugin-master-slave".to_string()));
409 }
410
411 #[tokio::test]
412 async fn test_validate_template_type_valid() {
413 let (_temp, template_dir) = setup_test_templates().await;
414 let engine = TemplateEngine::init(&template_dir)
415 .await
416 .expect("init failed");
417 assert!(engine.validate_template_type("plugin-crud").is_ok());
418 assert!(engine.validate_template_type("plugin-master-slave").is_ok());
419 }
420
421 #[tokio::test]
422 async fn test_validate_template_type_invalid() {
423 let (_temp, template_dir) = setup_test_templates().await;
424 let engine = TemplateEngine::init(&template_dir)
425 .await
426 .expect("init failed");
427 let result = engine.validate_template_type("nonexistent");
428 assert!(result.is_err());
429 let err = result.unwrap_err();
430 match err {
431 CliError::UnknownTemplate {
432 requested,
433 available,
434 } => {
435 assert_eq!(requested, "nonexistent");
436 assert!(available.contains(&"plugin-crud".to_string()));
437 }
438 _ => panic!("expected UnknownTemplate error"),
439 }
440 }
441
442 #[test]
443 fn test_parse_line_col() {
444 let (line, col) = parse_line_col("error at line 5, column 3");
445 assert_eq!(line, 5);
446 assert_eq!(col, 3);
447 }
448
449 #[test]
450 fn test_parse_line_col_no_match() {
451 let (line, col) = parse_line_col("some generic error");
452 assert_eq!(line, 0);
453 assert_eq!(col, 0);
454 }
455
456 #[test]
457 fn test_extract_variable_name() {
458 let name = extract_variable_name("Variable `plugin_name` not found in context");
459 assert_eq!(name, Some("plugin_name".to_string()));
460 }
461
462 #[test]
463 fn test_extract_variable_name_no_match() {
464 let name = extract_variable_name("some other error");
465 assert_eq!(name, None);
466 }
467
468 #[tokio::test]
470 async fn test_load_real_templates() {
471 let template_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("templates");
472 let engine = TemplateEngine::init(&template_dir).await;
473 assert!(
474 engine.is_ok(),
475 "Failed to load real templates: {:?}",
476 engine.err()
477 );
478 let engine = engine.unwrap();
479
480 let types = engine.list_templates();
481 assert!(
482 types.contains(&"plugin-crud".to_string()),
483 "Missing plugin-crud"
484 );
485 assert!(
486 types.contains(&"plugin-master-slave".to_string()),
487 "Missing plugin-master-slave"
488 );
489 }
490
491 #[tokio::test]
493 async fn test_render_crud_model() {
494 let template_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("templates");
495 let engine = TemplateEngine::init(&template_dir)
496 .await
497 .expect("init failed");
498
499 let mut ctx = Context::new();
500 ctx.insert("plugin_name", "user-management");
501 ctx.insert("table_name", "users");
502 ctx.insert("class_name", "User");
503 ctx.insert("template_type", "crud");
504 ctx.insert("template_version", "1.0.0");
505 ctx.insert("generated_at", "2026-08-11 10:00:00");
506 ctx.insert("primary_key_name", "id");
507 ctx.insert("primary_key_type", "i32");
508 ctx.insert(
509 "fields",
510 &vec![
511 serde_json::json!({"name": "id", "rust_type": "i32", "sql_type": "INT", "is_nullable": false, "is_primary_key": true, "is_indexed": false}),
512 serde_json::json!({"name": "name", "rust_type": "String", "sql_type": "VARCHAR(255)", "is_nullable": false, "is_primary_key": false, "is_indexed": false}),
513 serde_json::json!({"name": "age", "rust_type": "i32", "sql_type": "INT", "is_nullable": true, "is_primary_key": false, "is_indexed": false}),
514 ],
515 );
516
517 let result = engine.render("plugin-crud/model.rs.tera", &ctx);
518 assert!(result.is_ok(), "Render failed: {:?}", result.err());
519 let output = result.unwrap();
520 assert!(output.contains("pub struct User"));
521 assert!(output.contains("pub id: i32"));
522 assert!(output.contains("pub name: String"));
523 assert!(output.contains("impl Model for User"));
524 assert!(output.contains("\"users\""));
525 }
526
527 #[tokio::test]
529 async fn test_render_crud_controller() {
530 let template_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("templates");
531 let engine = TemplateEngine::init(&template_dir)
532 .await
533 .expect("init failed");
534
535 let mut ctx = Context::new();
536 ctx.insert("plugin_name", "user-management");
537 ctx.insert("table_name", "users");
538 ctx.insert("class_name", "User");
539 ctx.insert("template_type", "crud");
540 ctx.insert("template_version", "1.0.0");
541 ctx.insert("generated_at", "2026-08-11 10:00:00");
542 ctx.insert("primary_key_name", "id");
543 ctx.insert("primary_key_type", "i32");
544 ctx.insert("fields", &Vec::<serde_json::Value>::new());
545
546 let result = engine.render("plugin-crud/controller.rs.tera", &ctx);
547 assert!(result.is_ok(), "Render failed: {:?}", result.err());
548 let output = result.unwrap();
549 assert!(output.contains("pub struct UserController"));
550 assert!(output.contains("async fn index"));
551 assert!(output.contains("async fn create"));
552 assert!(output.contains("async fn save"));
553 assert!(output.contains("async fn read"));
554 assert!(output.contains("async fn edit"));
555 assert!(output.contains("async fn update"));
556 assert!(output.contains("async fn delete"));
557 }
558
559 #[tokio::test]
561 async fn test_render_master_model() {
562 let template_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("templates");
563 let engine = TemplateEngine::init(&template_dir)
564 .await
565 .expect("init failed");
566
567 let mut ctx = Context::new();
568 ctx.insert("plugin_name", "order-plugin");
569 ctx.insert("template_type", "master-slave");
570 ctx.insert("template_version", "1.0.0");
571 ctx.insert("generated_at", "2026-08-11 10:00:00");
572 ctx.insert("primary_key_name", "id");
573 ctx.insert("primary_key_type", "i32");
574 ctx.insert("master_table", "users");
575 ctx.insert("slave_table", "orders");
576 ctx.insert("master_class_name", "User");
577 ctx.insert("slave_class_name", "Order");
578 ctx.insert("foreign_key", "user_id");
579 ctx.insert(
580 "master_fields",
581 &vec![
582 serde_json::json!({"name": "id", "rust_type": "i32", "sql_type": "INT", "is_nullable": false, "is_primary_key": true, "is_indexed": false}),
583 serde_json::json!({"name": "name", "rust_type": "String", "sql_type": "VARCHAR(255)", "is_nullable": false, "is_primary_key": false, "is_indexed": false}),
584 ],
585 );
586 ctx.insert(
587 "slave_fields",
588 &vec![
589 serde_json::json!({"name": "id", "rust_type": "i32", "sql_type": "INT", "is_nullable": false, "is_primary_key": true, "is_indexed": false}),
590 serde_json::json!({"name": "user_id", "rust_type": "i32", "sql_type": "INT", "is_nullable": false, "is_primary_key": false, "is_indexed": false}),
591 serde_json::json!({"name": "total", "rust_type": "f64", "sql_type": "DOUBLE", "is_nullable": false, "is_primary_key": false, "is_indexed": false}),
592 ],
593 );
594 ctx.insert("fields", &Vec::<serde_json::Value>::new());
595
596 let result = engine.render("plugin-master-slave/master_model.rs.tera", &ctx);
597 assert!(result.is_ok(), "Render failed: {:?}", result.err());
598 let output = result.unwrap();
599 assert!(output.contains("pub struct User"));
600 assert!(output.contains("\"users\""));
601 assert!(output.contains("pub id: i32"));
602 assert!(output.contains("pub name: String"));
603 }
604
605 #[tokio::test]
607 async fn test_render_slave_model() {
608 let template_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("templates");
609 let engine = TemplateEngine::init(&template_dir)
610 .await
611 .expect("init failed");
612
613 let mut ctx = Context::new();
614 ctx.insert("plugin_name", "order-plugin");
615 ctx.insert("template_type", "master-slave");
616 ctx.insert("template_version", "1.0.0");
617 ctx.insert("generated_at", "2026-08-11 10:00:00");
618 ctx.insert("primary_key_name", "id");
619 ctx.insert("primary_key_type", "i32");
620 ctx.insert("master_table", "users");
621 ctx.insert("slave_table", "orders");
622 ctx.insert("master_class_name", "User");
623 ctx.insert("slave_class_name", "Order");
624 ctx.insert("foreign_key", "user_id");
625 ctx.insert("master_fields", &Vec::<serde_json::Value>::new());
626 ctx.insert(
627 "slave_fields",
628 &vec![
629 serde_json::json!({"name": "id", "rust_type": "i32", "sql_type": "INT", "is_nullable": false, "is_primary_key": true, "is_indexed": false}),
630 serde_json::json!({"name": "user_id", "rust_type": "i32", "sql_type": "INT", "is_nullable": false, "is_primary_key": false, "is_indexed": false}),
631 ],
632 );
633 ctx.insert("fields", &Vec::<serde_json::Value>::new());
634
635 let result = engine.render("plugin-master-slave/slave_model.rs.tera", &ctx);
636 assert!(result.is_ok(), "Render failed: {:?}", result.err());
637 let output = result.unwrap();
638 assert!(output.contains("pub struct Order"));
639 assert!(output.contains("\"orders\""));
640 assert!(output.contains("pub user_id: i32"));
641 }
642
643 #[tokio::test]
645 async fn test_render_master_slave_migration() {
646 let template_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("templates");
647 let engine = TemplateEngine::init(&template_dir)
648 .await
649 .expect("init failed");
650
651 let mut ctx = Context::new();
652 ctx.insert("plugin_name", "order-plugin");
653 ctx.insert("template_type", "master-slave");
654 ctx.insert("template_version", "1.0.0");
655 ctx.insert("generated_at", "2026-08-11 10:00:00");
656 ctx.insert("primary_key_name", "id");
657 ctx.insert("primary_key_type", "i32");
658 ctx.insert("master_table", "users");
659 ctx.insert("slave_table", "orders");
660 ctx.insert("master_class_name", "User");
661 ctx.insert("slave_class_name", "Order");
662 ctx.insert("foreign_key", "user_id");
663 ctx.insert(
664 "master_fields",
665 &vec![
666 serde_json::json!({"name": "id", "rust_type": "i32", "sql_type": "INT", "is_nullable": false, "is_primary_key": true, "is_indexed": false}),
667 serde_json::json!({"name": "name", "rust_type": "String", "sql_type": "VARCHAR(255)", "is_nullable": false, "is_primary_key": false, "is_indexed": false}),
668 ],
669 );
670 ctx.insert(
671 "slave_fields",
672 &vec![
673 serde_json::json!({"name": "id", "rust_type": "i32", "sql_type": "INT", "is_nullable": false, "is_primary_key": true, "is_indexed": false}),
674 serde_json::json!({"name": "user_id", "rust_type": "i32", "sql_type": "INT", "is_nullable": false, "is_primary_key": false, "is_indexed": false}),
675 ],
676 );
677
678 let result = engine.render("plugin-master-slave/migration.sql.tera", &ctx);
679 assert!(result.is_ok(), "Render failed: {:?}", result.err());
680 let output = result.unwrap();
681 assert!(output.contains("CREATE TABLE IF NOT EXISTS users"));
682 assert!(output.contains("CREATE TABLE IF NOT EXISTS orders"));
683 assert!(output.contains("FOREIGN KEY (user_id)"));
684 assert!(output.contains("REFERENCES users (id)"));
685 }
686
687 #[tokio::test]
689 async fn test_template_engine_debug() {
690 let (_temp, template_dir) = setup_test_templates().await;
691 let engine = TemplateEngine::init(&template_dir)
692 .await
693 .expect("init failed");
694 let debug_str = format!("{:?}", engine);
695 assert!(debug_str.contains("TemplateEngine"));
696 assert!(debug_str.contains("template_dir"));
697 assert!(debug_str.contains("template_types"));
698 }
699
700 #[tokio::test]
702 async fn test_template_dir_accessor() {
703 let (_temp, template_dir) = setup_test_templates().await;
704 let engine = TemplateEngine::init(&template_dir)
705 .await
706 .expect("init failed");
707 assert_eq!(engine.template_dir(), template_dir.as_path());
708 }
709
710 #[tokio::test]
712 async fn test_pascal_case_filter() {
713 let (_temp, template_dir) = setup_test_templates().await;
714 let crud_dir = template_dir.join("plugin-crud");
716 tokio::fs::write(
717 &crud_dir.join("pascal_test.rs.tera"),
718 "{{ table_name | pascal_case }}",
719 )
720 .await
721 .expect("write failed");
722
723 let engine = TemplateEngine::init(&template_dir)
724 .await
725 .expect("init failed");
726 let mut ctx = Context::new();
727 ctx.insert("table_name", "user_orders");
728 let result = engine.render("plugin-crud/pascal_test.rs.tera", &ctx);
729 assert!(result.is_ok(), "Render failed: {:?}", result.err());
730 assert_eq!(result.unwrap(), "UserOrders");
731 }
732
733 #[tokio::test]
735 async fn test_snake_case_filter() {
736 let (_temp, template_dir) = setup_test_templates().await;
737 let crud_dir = template_dir.join("plugin-crud");
738 tokio::fs::write(
739 &crud_dir.join("snake_test.rs.tera"),
740 "{{ class_name | snake_case }}",
741 )
742 .await
743 .expect("write failed");
744
745 let engine = TemplateEngine::init(&template_dir)
746 .await
747 .expect("init failed");
748 let mut ctx = Context::new();
749 ctx.insert("class_name", "UserOrders");
750 let result = engine.render("plugin-crud/snake_test.rs.tera", &ctx);
751 assert!(result.is_ok(), "Render failed: {:?}", result.err());
752 assert_eq!(result.unwrap(), "userorders");
753 }
754
755 #[tokio::test]
757 async fn test_render_generic_error() {
758 let (_temp, template_dir) = setup_test_templates().await;
759 let engine = TemplateEngine::init(&template_dir)
760 .await
761 .expect("init failed");
762 let ctx = Context::new();
764 let result = engine.render("plugin-crud/nonexistent_template.rs.tera", &ctx);
765 assert!(result.is_err());
766 let err = result.unwrap_err();
768 assert!(!matches!(err, CliError::VarNotFound { .. }));
770 }
771
772 #[test]
773 fn test_take_number_empty() {
774 assert_eq!(take_number("abc"), None);
775 }
776
777 #[test]
778 fn test_take_number_valid() {
779 assert_eq!(take_number("123abc"), Some(123));
780 }
781
782 #[test]
783 fn test_parse_line_col_only_line() {
784 let (line, col) = parse_line_col("error at line 42");
785 assert_eq!(line, 42);
786 assert_eq!(col, 0);
787 }
788
789 #[test]
790 fn test_extract_variable_name_prefix_no_close() {
791 let name = extract_variable_name("Variable `unclosed");
793 assert_eq!(name, None);
794 }
795}