Skip to main content

sz_rust_cli/
template_engine.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright (c) 2024-2026 SZ-Rust Team
3//
4//! Tera 模板引擎封装
5//!
6//! 对应 design.md 第 2.2.2.2 节,封装 Tera 引擎提供模板加载、渲染、校验能力。
7//!
8//! ## 模板命名约定
9//!
10//! 模板以相对路径注册到 Tera 实例,如 `plugin-crud/model.rs.tera`。
11//! 跨目录继承通过 `{% extends "plugin-crud/model.rs.tera" %}` 引用。
12
13use std::collections::HashMap;
14use std::fmt;
15use std::path::{Path, PathBuf};
16
17use tera::{Context, Tera};
18
19use crate::error::CliError;
20
21/// Tera 模板引擎封装
22///
23/// 扫描指定目录下的 `.tera` 文件并注册到内部 Tera 实例,
24/// 提供模板渲染、类型校验等能力。
25pub struct TemplateEngine {
26    /// Tera 引擎实例
27    tera: Tera,
28    /// 模板根目录
29    template_dir: PathBuf,
30    /// 可用模板类型列表(模板根目录下的子目录名)
31    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    /// 初始化模板引擎
45    ///
46    /// 扫描 `template_dir` 下全部 `.tera` 文件并注册到 Tera 实例。
47    /// 每个子目录代表一种模板类型(如 `plugin-crud`、`plugin-master-slave`)。
48    ///
49    /// # 错误
50    ///
51    /// - `CliError::TemplateMissing`:目录不存在
52    /// - `CliError::TemplateSyntaxError`:模板语法错误(含文件名/行号/列号)
53    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        // 依赖拓扑注册:模板可 {% extends %} 其他模板(如 plugin-master-slave
89        // 继承 plugin-crud),Tera 在 add_raw_template 时即解析继承,要求父模板
90        // 已注册。read_dir 的文件系统顺序跨平台不确定(Windows 与 Linux 实测
91        // 不同序),固定单序注册会在父模板后行的平台挂掉。固定点迭代:每轮
92        // 注册当轮可解析的模板,缺父模板的留待下一轮,直至全部注册;无进展
93        // 说明存在真实语法错误或循环继承,按错误返回。
94        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                // 注册状态未变化,重试必然复现同一错误——取首个真实报错返回
113                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    /// 渲染模板
158    ///
159    /// # 错误
160    ///
161    /// - `CliError::VarNotFound`:模板变量缺失(含变量名/引用文件/行号)
162    /// - `CliError::Generic`:其他渲染错误
163    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    /// 返回可用模板类型列表(模板根目录下的子目录名)
170    pub fn list_templates(&self) -> Vec<String> {
171        self.template_types.clone()
172    }
173
174    /// 校验模板类型是否存在
175    ///
176    /// # 错误
177    ///
178    /// - `CliError::UnknownTemplate`:模板类型不存在,附带可用模板列表
179    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    /// 返回模板根目录
191    pub fn template_dir(&self) -> &Path {
192        &self.template_dir
193    }
194}
195
196/// 将 Tera 语法错误映射为 `CliError::TemplateSyntaxError`
197fn 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
208/// 将 Tera 渲染错误映射为 `CliError::VarNotFound` 或 `CliError::Generic`
209fn 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
231/// 从错误消息中解析行号和列号
232///
233/// Tera/pest 错误消息通常含 `line: N` 或 `at line N` 格式
234fn 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
254/// 从错误消息中提取变量名
255///
256/// Tera 渲染错误通常含 `Variable \`xxx\` not found` 格式
257fn 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
268/// 从字符串开头提取数字
269fn 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    /// 创建临时模板目录用于测试
284    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    /// Batch B 验收:加载真实模板目录(含跨目录继承)
469    #[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    /// Batch B 验收:渲染 CRUD model 模板
492    #[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    /// Batch B 验收:渲染 CRUD controller 模板
528    #[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    /// Batch B 验收:渲染主从 master_model 模板(跨目录继承)
560    #[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    /// Batch B 验收:渲染主从 slave_model 模板
606    #[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    /// Batch B 验收:渲染主从 migration 模板(含外键约束)
644    #[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    /// 测试 TemplateEngine 的 Debug 实现
688    #[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    /// 测试 template_dir 访问器
701    #[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    /// 测试 pascal_case filter
711    #[tokio::test]
712    async fn test_pascal_case_filter() {
713        let (_temp, template_dir) = setup_test_templates().await;
714        // 写入使用 pascal_case filter 的模板
715        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    /// 测试 snake_case filter
734    #[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    /// 测试 map_render_error 的 Generic 分支(非变量未找到错误)
756    #[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        // 渲染不存在的模板应触发错误(模板未找到,非变量错误)
763        let ctx = Context::new();
764        let result = engine.render("plugin-crud/nonexistent_template.rs.tera", &ctx);
765        assert!(result.is_err());
766        // 错误应为 Generic 或其他非 VarNotFound 类型
767        let err = result.unwrap_err();
768        // 确保不是 VarNotFound(模板未找到不是变量错误)
769        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        // 有前缀但没有反引号闭合
792        let name = extract_variable_name("Variable `unclosed");
793        assert_eq!(name, None);
794    }
795}