use std::collections::HashMap;
use std::fmt;
use std::path::{Path, PathBuf};
use tera::{Context, Tera};
use crate::error::CliError;
pub struct TemplateEngine {
tera: Tera,
template_dir: PathBuf,
template_types: Vec<String>,
}
impl fmt::Debug for TemplateEngine {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("TemplateEngine")
.field("template_dir", &self.template_dir)
.field("template_types", &self.template_types)
.finish()
}
}
impl TemplateEngine {
pub async fn init(template_dir: &Path) -> Result<Self, CliError> {
if !tokio::fs::try_exists(template_dir).await? {
return Err(CliError::TemplateMissing(vec![template_dir
.display()
.to_string()]));
}
let mut tera = Tera::default();
let mut template_types = Vec::new();
let mut all_template_files = Vec::new();
let mut root_entries = tokio::fs::read_dir(template_dir).await?;
while let Some(entry) = root_entries.next_entry().await? {
let path = entry.path();
if path.is_dir() {
let dir_name = entry.file_name().to_string_lossy().to_string();
template_types.push(dir_name.clone());
let mut sub_entries = tokio::fs::read_dir(&path).await?;
while let Some(sub_entry) = sub_entries.next_entry().await? {
let sub_path = sub_entry.path();
if sub_path.is_file()
&& sub_path
.extension()
.map(|ext| ext == "tera")
.unwrap_or(false)
{
all_template_files.push(sub_path);
}
}
}
}
template_types.sort();
let mut pending: Vec<(String, String)> = Vec::new();
for file_path in &all_template_files {
let relative = file_path.strip_prefix(template_dir).unwrap_or(file_path);
let template_name = relative.to_string_lossy().replace('\\', "/");
let content = tokio::fs::read_to_string(file_path).await?;
pending.push((template_name, content));
}
while !pending.is_empty() {
let mut deferred: Vec<(String, String)> = Vec::new();
let mut progress = 0usize;
for (name, content) in pending.drain(..) {
match tera.add_raw_template(&name, &content) {
Ok(()) => progress += 1,
Err(_) => deferred.push((name, content)),
}
}
if progress == 0 && !deferred.is_empty() {
let (name, content) = deferred.swap_remove(0);
tera.add_raw_template(&name, &content)
.map_err(|e| map_syntax_error(e, &name))?;
unreachable!("无进展轮次的重新注册必然返回 Err");
}
pending = deferred;
}
tera.register_filter(
"pascal_case",
|value: &tera::Value, _: &HashMap<String, tera::Value>| {
let s = value.as_str().unwrap_or("");
let pascal: String = s
.split('_')
.map(|word| {
let mut chars = word.chars();
match chars.next() {
Some(first) => {
first.to_uppercase().collect::<String>() + chars.as_str()
}
None => String::new(),
}
})
.collect();
Ok(tera::Value::String(pascal))
},
);
tera.register_filter(
"snake_case",
|value: &tera::Value, _: &HashMap<String, tera::Value>| {
let s = value.as_str().unwrap_or("");
let snake = s.replace('-', "_").to_lowercase();
Ok(tera::Value::String(snake))
},
);
Ok(Self {
tera,
template_dir: template_dir.to_path_buf(),
template_types,
})
}
pub fn render(&self, template_name: &str, context: &Context) -> Result<String, CliError> {
self.tera
.render(template_name, context)
.map_err(|e| map_render_error(e, template_name))
}
pub fn list_templates(&self) -> Vec<String> {
self.template_types.clone()
}
pub fn validate_template_type(&self, template_type: &str) -> Result<(), CliError> {
if self.template_types.iter().any(|t| t == template_type) {
Ok(())
} else {
Err(CliError::UnknownTemplate {
requested: template_type.to_string(),
available: self.template_types.clone(),
})
}
}
pub fn template_dir(&self) -> &Path {
&self.template_dir
}
}
fn map_syntax_error(e: tera::Error, file: &str) -> CliError {
let msg = e.to_string();
let (line, col) = parse_line_col(&msg);
CliError::TemplateSyntaxError {
file: file.to_string(),
line,
col,
msg,
}
}
fn map_render_error(e: tera::Error, template_name: &str) -> CliError {
let msg = e.to_string();
let mut full_msg = msg.clone();
let mut source = std::error::Error::source(&e);
while let Some(s) = source {
full_msg.push_str(&format!("\n caused by: {s}"));
source = std::error::Error::source(s);
}
if let Some(var) = extract_variable_name(&full_msg) {
let line = parse_line_col(&full_msg).0;
CliError::VarNotFound {
var,
file: template_name.to_string(),
line,
}
} else {
CliError::Generic(msg)
}
}
fn parse_line_col(msg: &str) -> (usize, usize) {
let mut line = 0;
let mut col = 0;
if let Some(pos) = msg.find("line ") {
let rest = &msg[pos + 5..];
if let Some(num) = take_number(rest) {
line = num;
}
}
if let Some(pos) = msg.find("column ") {
let rest = &msg[pos + 7..];
if let Some(num) = take_number(rest) {
col = num;
}
}
(line, col)
}
fn extract_variable_name(msg: &str) -> Option<String> {
let prefix = "Variable `";
if let Some(start) = msg.find(prefix) {
let rest = &msg[start + prefix.len()..];
if let Some(end) = rest.find('`') {
return Some(rest[..end].to_string());
}
}
None
}
fn take_number(s: &str) -> Option<usize> {
let num_str: String = s.chars().take_while(|c| c.is_ascii_digit()).collect();
if num_str.is_empty() {
None
} else {
num_str.parse().ok()
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
async fn setup_test_templates() -> (tempfile::TempDir, PathBuf) {
let temp = tempfile::tempdir().expect("tempdir failed");
let template_dir = temp.path().to_path_buf();
let crud_dir = template_dir.join("plugin-crud");
tokio::fs::create_dir_all(&crud_dir)
.await
.expect("mkdir failed");
tokio::fs::write(
&crud_dir.join("model.rs.tera"),
"// Model for {{ table_name }}\npub struct {{ class_name }} {\n {% for field in fields %}{{ field.name }}: {{ field.rust_type }},\n {% endfor %}}\n}\n",
)
.await
.expect("write failed");
tokio::fs::write(
&crud_dir.join("controller.rs.tera"),
"// Controller for {{ table_name }}\n",
)
.await
.expect("write failed");
let ms_dir = template_dir.join("plugin-master-slave");
tokio::fs::create_dir_all(&ms_dir)
.await
.expect("mkdir failed");
tokio::fs::write(
&ms_dir.join("master_model.rs.tera"),
"// Master: {{ master_table }}\n",
)
.await
.expect("write failed");
(temp, template_dir)
}
#[tokio::test]
async fn test_init_loads_templates() {
let (_temp, template_dir) = setup_test_templates().await;
let engine = TemplateEngine::init(&template_dir).await;
assert!(engine.is_ok(), "init should succeed");
let engine = engine.unwrap();
let templates = engine.list_templates();
assert!(templates.contains(&"plugin-crud".to_string()));
assert!(templates.contains(&"plugin-master-slave".to_string()));
}
#[tokio::test]
async fn test_init_dir_not_exists() {
let result = TemplateEngine::init(Path::new("/nonexistent/path/templates")).await;
assert!(result.is_err());
let err = result.unwrap_err();
assert!(matches!(err, CliError::TemplateMissing(_)));
}
#[tokio::test]
async fn test_init_syntax_error() {
let temp = tempfile::tempdir().expect("tempdir failed");
let template_dir = temp.path().to_path_buf();
let crud_dir = template_dir.join("bad-template");
tokio::fs::create_dir_all(&crud_dir)
.await
.expect("mkdir failed");
tokio::fs::write(
&crud_dir.join("bad.rs.tera"),
"{% for field in fields %}{{ field.name }}\n",
)
.await
.expect("write failed");
let result = TemplateEngine::init(&template_dir).await;
assert!(result.is_err());
let err = result.unwrap_err();
assert!(matches!(err, CliError::TemplateSyntaxError { .. }));
}
#[tokio::test]
async fn test_render_success() {
let (_temp, template_dir) = setup_test_templates().await;
let engine = TemplateEngine::init(&template_dir)
.await
.expect("init failed");
let mut ctx = Context::new();
ctx.insert("table_name", "users");
ctx.insert("class_name", "User");
ctx.insert(
"fields",
&vec![serde_json::json!({"name": "id", "rust_type": "i32"})],
);
let result = engine.render("plugin-crud/model.rs.tera", &ctx);
assert!(result.is_ok(), "render should succeed: {:?}", result);
let output = result.unwrap();
assert!(output.contains("users"));
assert!(output.contains("User"));
assert!(output.contains("id"));
}
#[tokio::test]
async fn test_render_var_not_found() {
let (_temp, template_dir) = setup_test_templates().await;
let engine = TemplateEngine::init(&template_dir)
.await
.expect("init failed");
let ctx = Context::new();
let result = engine.render("plugin-crud/controller.rs.tera", &ctx);
assert!(result.is_err());
let err = result.unwrap_err();
assert!(matches!(err, CliError::VarNotFound { .. }));
}
#[tokio::test]
async fn test_list_templates() {
let (_temp, template_dir) = setup_test_templates().await;
let engine = TemplateEngine::init(&template_dir)
.await
.expect("init failed");
let templates = engine.list_templates();
assert_eq!(templates.len(), 2);
assert!(templates.contains(&"plugin-crud".to_string()));
assert!(templates.contains(&"plugin-master-slave".to_string()));
}
#[tokio::test]
async fn test_validate_template_type_valid() {
let (_temp, template_dir) = setup_test_templates().await;
let engine = TemplateEngine::init(&template_dir)
.await
.expect("init failed");
assert!(engine.validate_template_type("plugin-crud").is_ok());
assert!(engine.validate_template_type("plugin-master-slave").is_ok());
}
#[tokio::test]
async fn test_validate_template_type_invalid() {
let (_temp, template_dir) = setup_test_templates().await;
let engine = TemplateEngine::init(&template_dir)
.await
.expect("init failed");
let result = engine.validate_template_type("nonexistent");
assert!(result.is_err());
let err = result.unwrap_err();
match err {
CliError::UnknownTemplate {
requested,
available,
} => {
assert_eq!(requested, "nonexistent");
assert!(available.contains(&"plugin-crud".to_string()));
}
_ => panic!("expected UnknownTemplate error"),
}
}
#[test]
fn test_parse_line_col() {
let (line, col) = parse_line_col("error at line 5, column 3");
assert_eq!(line, 5);
assert_eq!(col, 3);
}
#[test]
fn test_parse_line_col_no_match() {
let (line, col) = parse_line_col("some generic error");
assert_eq!(line, 0);
assert_eq!(col, 0);
}
#[test]
fn test_extract_variable_name() {
let name = extract_variable_name("Variable `plugin_name` not found in context");
assert_eq!(name, Some("plugin_name".to_string()));
}
#[test]
fn test_extract_variable_name_no_match() {
let name = extract_variable_name("some other error");
assert_eq!(name, None);
}
#[tokio::test]
async fn test_load_real_templates() {
let template_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("templates");
let engine = TemplateEngine::init(&template_dir).await;
assert!(
engine.is_ok(),
"Failed to load real templates: {:?}",
engine.err()
);
let engine = engine.unwrap();
let types = engine.list_templates();
assert!(
types.contains(&"plugin-crud".to_string()),
"Missing plugin-crud"
);
assert!(
types.contains(&"plugin-master-slave".to_string()),
"Missing plugin-master-slave"
);
}
#[tokio::test]
async fn test_render_crud_model() {
let template_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("templates");
let engine = TemplateEngine::init(&template_dir)
.await
.expect("init failed");
let mut ctx = Context::new();
ctx.insert("plugin_name", "user-management");
ctx.insert("table_name", "users");
ctx.insert("class_name", "User");
ctx.insert("template_type", "crud");
ctx.insert("template_version", "1.0.0");
ctx.insert("generated_at", "2026-08-11 10:00:00");
ctx.insert("primary_key_name", "id");
ctx.insert("primary_key_type", "i32");
ctx.insert(
"fields",
&vec![
serde_json::json!({"name": "id", "rust_type": "i32", "sql_type": "INT", "is_nullable": false, "is_primary_key": true, "is_indexed": false}),
serde_json::json!({"name": "name", "rust_type": "String", "sql_type": "VARCHAR(255)", "is_nullable": false, "is_primary_key": false, "is_indexed": false}),
serde_json::json!({"name": "age", "rust_type": "i32", "sql_type": "INT", "is_nullable": true, "is_primary_key": false, "is_indexed": false}),
],
);
let result = engine.render("plugin-crud/model.rs.tera", &ctx);
assert!(result.is_ok(), "Render failed: {:?}", result.err());
let output = result.unwrap();
assert!(output.contains("pub struct User"));
assert!(output.contains("pub id: i32"));
assert!(output.contains("pub name: String"));
assert!(output.contains("impl Model for User"));
assert!(output.contains("\"users\""));
}
#[tokio::test]
async fn test_render_crud_controller() {
let template_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("templates");
let engine = TemplateEngine::init(&template_dir)
.await
.expect("init failed");
let mut ctx = Context::new();
ctx.insert("plugin_name", "user-management");
ctx.insert("table_name", "users");
ctx.insert("class_name", "User");
ctx.insert("template_type", "crud");
ctx.insert("template_version", "1.0.0");
ctx.insert("generated_at", "2026-08-11 10:00:00");
ctx.insert("primary_key_name", "id");
ctx.insert("primary_key_type", "i32");
ctx.insert("fields", &Vec::<serde_json::Value>::new());
let result = engine.render("plugin-crud/controller.rs.tera", &ctx);
assert!(result.is_ok(), "Render failed: {:?}", result.err());
let output = result.unwrap();
assert!(output.contains("pub struct UserController"));
assert!(output.contains("async fn index"));
assert!(output.contains("async fn create"));
assert!(output.contains("async fn save"));
assert!(output.contains("async fn read"));
assert!(output.contains("async fn edit"));
assert!(output.contains("async fn update"));
assert!(output.contains("async fn delete"));
}
#[tokio::test]
async fn test_render_master_model() {
let template_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("templates");
let engine = TemplateEngine::init(&template_dir)
.await
.expect("init failed");
let mut ctx = Context::new();
ctx.insert("plugin_name", "order-plugin");
ctx.insert("template_type", "master-slave");
ctx.insert("template_version", "1.0.0");
ctx.insert("generated_at", "2026-08-11 10:00:00");
ctx.insert("primary_key_name", "id");
ctx.insert("primary_key_type", "i32");
ctx.insert("master_table", "users");
ctx.insert("slave_table", "orders");
ctx.insert("master_class_name", "User");
ctx.insert("slave_class_name", "Order");
ctx.insert("foreign_key", "user_id");
ctx.insert(
"master_fields",
&vec![
serde_json::json!({"name": "id", "rust_type": "i32", "sql_type": "INT", "is_nullable": false, "is_primary_key": true, "is_indexed": false}),
serde_json::json!({"name": "name", "rust_type": "String", "sql_type": "VARCHAR(255)", "is_nullable": false, "is_primary_key": false, "is_indexed": false}),
],
);
ctx.insert(
"slave_fields",
&vec![
serde_json::json!({"name": "id", "rust_type": "i32", "sql_type": "INT", "is_nullable": false, "is_primary_key": true, "is_indexed": false}),
serde_json::json!({"name": "user_id", "rust_type": "i32", "sql_type": "INT", "is_nullable": false, "is_primary_key": false, "is_indexed": false}),
serde_json::json!({"name": "total", "rust_type": "f64", "sql_type": "DOUBLE", "is_nullable": false, "is_primary_key": false, "is_indexed": false}),
],
);
ctx.insert("fields", &Vec::<serde_json::Value>::new());
let result = engine.render("plugin-master-slave/master_model.rs.tera", &ctx);
assert!(result.is_ok(), "Render failed: {:?}", result.err());
let output = result.unwrap();
assert!(output.contains("pub struct User"));
assert!(output.contains("\"users\""));
assert!(output.contains("pub id: i32"));
assert!(output.contains("pub name: String"));
}
#[tokio::test]
async fn test_render_slave_model() {
let template_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("templates");
let engine = TemplateEngine::init(&template_dir)
.await
.expect("init failed");
let mut ctx = Context::new();
ctx.insert("plugin_name", "order-plugin");
ctx.insert("template_type", "master-slave");
ctx.insert("template_version", "1.0.0");
ctx.insert("generated_at", "2026-08-11 10:00:00");
ctx.insert("primary_key_name", "id");
ctx.insert("primary_key_type", "i32");
ctx.insert("master_table", "users");
ctx.insert("slave_table", "orders");
ctx.insert("master_class_name", "User");
ctx.insert("slave_class_name", "Order");
ctx.insert("foreign_key", "user_id");
ctx.insert("master_fields", &Vec::<serde_json::Value>::new());
ctx.insert(
"slave_fields",
&vec![
serde_json::json!({"name": "id", "rust_type": "i32", "sql_type": "INT", "is_nullable": false, "is_primary_key": true, "is_indexed": false}),
serde_json::json!({"name": "user_id", "rust_type": "i32", "sql_type": "INT", "is_nullable": false, "is_primary_key": false, "is_indexed": false}),
],
);
ctx.insert("fields", &Vec::<serde_json::Value>::new());
let result = engine.render("plugin-master-slave/slave_model.rs.tera", &ctx);
assert!(result.is_ok(), "Render failed: {:?}", result.err());
let output = result.unwrap();
assert!(output.contains("pub struct Order"));
assert!(output.contains("\"orders\""));
assert!(output.contains("pub user_id: i32"));
}
#[tokio::test]
async fn test_render_master_slave_migration() {
let template_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("templates");
let engine = TemplateEngine::init(&template_dir)
.await
.expect("init failed");
let mut ctx = Context::new();
ctx.insert("plugin_name", "order-plugin");
ctx.insert("template_type", "master-slave");
ctx.insert("template_version", "1.0.0");
ctx.insert("generated_at", "2026-08-11 10:00:00");
ctx.insert("primary_key_name", "id");
ctx.insert("primary_key_type", "i32");
ctx.insert("master_table", "users");
ctx.insert("slave_table", "orders");
ctx.insert("master_class_name", "User");
ctx.insert("slave_class_name", "Order");
ctx.insert("foreign_key", "user_id");
ctx.insert(
"master_fields",
&vec![
serde_json::json!({"name": "id", "rust_type": "i32", "sql_type": "INT", "is_nullable": false, "is_primary_key": true, "is_indexed": false}),
serde_json::json!({"name": "name", "rust_type": "String", "sql_type": "VARCHAR(255)", "is_nullable": false, "is_primary_key": false, "is_indexed": false}),
],
);
ctx.insert(
"slave_fields",
&vec![
serde_json::json!({"name": "id", "rust_type": "i32", "sql_type": "INT", "is_nullable": false, "is_primary_key": true, "is_indexed": false}),
serde_json::json!({"name": "user_id", "rust_type": "i32", "sql_type": "INT", "is_nullable": false, "is_primary_key": false, "is_indexed": false}),
],
);
let result = engine.render("plugin-master-slave/migration.sql.tera", &ctx);
assert!(result.is_ok(), "Render failed: {:?}", result.err());
let output = result.unwrap();
assert!(output.contains("CREATE TABLE IF NOT EXISTS users"));
assert!(output.contains("CREATE TABLE IF NOT EXISTS orders"));
assert!(output.contains("FOREIGN KEY (user_id)"));
assert!(output.contains("REFERENCES users (id)"));
}
#[tokio::test]
async fn test_template_engine_debug() {
let (_temp, template_dir) = setup_test_templates().await;
let engine = TemplateEngine::init(&template_dir)
.await
.expect("init failed");
let debug_str = format!("{:?}", engine);
assert!(debug_str.contains("TemplateEngine"));
assert!(debug_str.contains("template_dir"));
assert!(debug_str.contains("template_types"));
}
#[tokio::test]
async fn test_template_dir_accessor() {
let (_temp, template_dir) = setup_test_templates().await;
let engine = TemplateEngine::init(&template_dir)
.await
.expect("init failed");
assert_eq!(engine.template_dir(), template_dir.as_path());
}
#[tokio::test]
async fn test_pascal_case_filter() {
let (_temp, template_dir) = setup_test_templates().await;
let crud_dir = template_dir.join("plugin-crud");
tokio::fs::write(
&crud_dir.join("pascal_test.rs.tera"),
"{{ table_name | pascal_case }}",
)
.await
.expect("write failed");
let engine = TemplateEngine::init(&template_dir)
.await
.expect("init failed");
let mut ctx = Context::new();
ctx.insert("table_name", "user_orders");
let result = engine.render("plugin-crud/pascal_test.rs.tera", &ctx);
assert!(result.is_ok(), "Render failed: {:?}", result.err());
assert_eq!(result.unwrap(), "UserOrders");
}
#[tokio::test]
async fn test_snake_case_filter() {
let (_temp, template_dir) = setup_test_templates().await;
let crud_dir = template_dir.join("plugin-crud");
tokio::fs::write(
&crud_dir.join("snake_test.rs.tera"),
"{{ class_name | snake_case }}",
)
.await
.expect("write failed");
let engine = TemplateEngine::init(&template_dir)
.await
.expect("init failed");
let mut ctx = Context::new();
ctx.insert("class_name", "UserOrders");
let result = engine.render("plugin-crud/snake_test.rs.tera", &ctx);
assert!(result.is_ok(), "Render failed: {:?}", result.err());
assert_eq!(result.unwrap(), "userorders");
}
#[tokio::test]
async fn test_render_generic_error() {
let (_temp, template_dir) = setup_test_templates().await;
let engine = TemplateEngine::init(&template_dir)
.await
.expect("init failed");
let ctx = Context::new();
let result = engine.render("plugin-crud/nonexistent_template.rs.tera", &ctx);
assert!(result.is_err());
let err = result.unwrap_err();
assert!(!matches!(err, CliError::VarNotFound { .. }));
}
#[test]
fn test_take_number_empty() {
assert_eq!(take_number("abc"), None);
}
#[test]
fn test_take_number_valid() {
assert_eq!(take_number("123abc"), Some(123));
}
#[test]
fn test_parse_line_col_only_line() {
let (line, col) = parse_line_col("error at line 42");
assert_eq!(line, 42);
assert_eq!(col, 0);
}
#[test]
fn test_extract_variable_name_prefix_no_close() {
let name = extract_variable_name("Variable `unclosed");
assert_eq!(name, None);
}
}