//! Project scaffolding from templates using MiniJinja. //! //! This module replaces the cookiecutter dependency by providing native Rust //! templating for project generation. It uses MiniJinja which is Jinja2-compatible, //! so existing cookiecutter templates (like snowflake) work with minimal changes. use minijinja::Environment; use std::collections::HashMap; use std::path::{Path, PathBuf}; use std::fs; /// Template variables passed to MiniJinja. #[derive(Debug, Clone)] pub struct TemplateVariables { pub project_name: String, pub project_slug: String, pub author: String, pub description: String, } impl TemplateVariables { pub fn new(project_name: &str, author: &str, description: &str) -> Self { let project_slug = project_name .to_lowercase() .replace(' ', "_") .replace(|c: char| !c.is_ascii_alphanumeric() && c != '_' && c != '-', ""); Self { project_name: project_name.to_string(), project_slug, author: author.to_string(), description: description.to_string(), } } /// Convert to a HashMap for MiniJinja. pub fn to_context(&self) -> HashMap { let mut map = HashMap::new(); // Create a nested map for cookiecutter.variable access let mut cookiecutter_map = HashMap::new(); cookiecutter_map.insert("project_name".to_string(), minijinja::Value::from(self.project_name.clone())); cookiecutter_map.insert("project_slug".to_string(), minijinja::Value::from(self.project_slug.clone())); cookiecutter_map.insert("author".to_string(), minijinja::Value::from(self.author.clone())); cookiecutter_map.insert("description".to_string(), minijinja::Value::from(self.description.clone())); map.insert("cookiecutter".to_string(), minijinja::Value::from(cookiecutter_map)); // Also add top-level variables for compatibility map.insert("project_name".to_string(), minijinja::Value::from(self.project_name.clone())); map.insert("project_slug".to_string(), minijinja::Value::from(self.project_slug.clone())); map.insert("author".to_string(), minijinja::Value::from(self.author.clone())); map.insert("description".to_string(), minijinja::Value::from(self.description.clone())); map } } /// Configuration for template rendering. pub struct RenderConfig { /// Path to the template directory (contains cookiecutter.json and template folder) pub template_dir: PathBuf, /// Output directory where the project will be created pub output_dir: PathBuf, /// Template variables pub variables: TemplateVariables, /// Whether to run post-generation hooks (Gitea publishing is handled by caller) #[allow(dead_code)] pub run_hooks: bool, } /// Result of rendering a template. pub type RenderResult = Result; /// Render a project template using MiniJinja. /// /// This walks the template directory, renders each file and directory name /// through MiniJinja, and writes the results to the output directory. /// The template directory is expected to contain a subdirectory named with /// a template expression (e.g., `{{ cookiecutter.project_name }}`) which /// becomes the project root after rendering. pub fn render_template(config: &RenderConfig) -> RenderResult { // Verify the template directory has a cookiecutter.json let cookiecutter_json = config.template_dir.join("cookiecutter.json"); if !cookiecutter_json.is_file() { return Err(format!( "No cookiecutter.json in {} — that folder is not a template.", config.template_dir.display() )); } // Create the output directory if it doesn't exist fs::create_dir_all(&config.output_dir) .map_err(|e| format!("Cannot create {}: {}", config.output_dir.display(), e))?; // Build MiniJinja environment let mut env = Environment::new(); // Add built-in filters that cookiecutter templates might use // These are commonly used in cookiecutter: lower, replace, etc. env.add_filter("lower", |value: String| -> Result { Ok(value.to_lowercase()) }); env.add_filter("upper", |value: String| -> Result { Ok(value.to_uppercase()) }); env.add_filter("replace", |value: String, from: String, to: String| -> Result { Ok(value.replace(&from, &to)) }); env.add_filter("title", |value: String| -> Result { Ok(value.chars() .enumerate() .map(|(i, c)| if i == 0 { c.to_uppercase().collect::() } else { c.to_lowercase().collect::() }) .collect::()) // Simplified title case }); // Capitalize filter - capitalizes the first letter of each word env.add_filter("capitalize", |value: String| -> Result { Ok(value.chars() .enumerate() .map(|(i, c)| if i == 0 { c.to_uppercase().collect::() } else { c.to_lowercase().collect::() }) .collect::()) }); // capitalizes filter (plural) - same as capitalize but handles the common template usage env.add_filter("capitalizes", |value: String| -> Result { Ok(value.chars() .enumerate() .map(|(i, c)| if i == 0 { c.to_uppercase().collect::() } else { c.to_lowercase().collect::() }) .collect::()) }); // Get the context from variables let context = config.variables.to_context(); // Find the template project directory (the one with the templated name) // This is typically named something like "{{ cookiecutter.project_name }}" let template_entries = fs::read_dir(&config.template_dir) .map_err(|e| format!("Cannot read template directory {}: {}", config.template_dir.display(), e))?; let mut template_project_dir: Option = None; for entry in template_entries { let entry = entry.map_err(|e| format!("Error reading template directory entry: {}", e))?; let path = entry.path(); if path.is_dir() { let name = path.file_name().and_then(|n| n.to_str()).unwrap_or(""); // Check if this directory name looks like a template (contains {{ ) if name.contains("{{") { template_project_dir = Some(path); break; } } } let template_project_dir = template_project_dir.ok_or(format!( "No template project directory found in {}. Expected a directory with a templated name like '{{{{ cookiecutter.project_name }}}}'.", config.template_dir.display() ))?; // Render the project directory name let project_dir_name = render_filename(&env, &template_project_dir.file_name().unwrap_or_default().to_string_lossy(), &context) .map_err(|e| format!("Failed to render project directory name: {}", e))?; let project_dir = config.output_dir.join(&project_dir_name); // Recursively copy and render the template copy_and_render_directory(&env, &template_project_dir, &project_dir, &context)?; // Note: Gitea publishing is now handled by the caller (app/project.rs) // The run_hooks flag is checked there and the publishing is done natively in Rust Ok(project_dir) } /// Render a filename through MiniJinja. fn render_filename(env: &Environment, template: &str, context: &HashMap) -> Result { let tmpl = env.template_from_str(template)?; tmpl.render(context) } /// Recursively copy a directory, rendering all file and directory names and contents. fn copy_and_render_directory( env: &Environment, src: &Path, dest: &Path, context: &HashMap, ) -> Result<(), String> { fs::create_dir_all(dest) .map_err(|e| format!("Cannot create directory {}: {}", dest.display(), e))?; for entry in fs::read_dir(src) .map_err(|e| format!("Cannot read directory {}: {}", src.display(), e))? { let entry = entry.map_err(|e| format!("Error reading directory entry: {}", e))?; let src_path = entry.path(); let name = entry.file_name(); let name_str = name.to_string_lossy(); // Render the name let rendered_name = if name_str.contains("{{") { render_filename(env, &name_str, context) .map_err(|e| format!("Failed to render name '{}': {}", name_str, e))? } else { name_str.into_owned() }; let dest_path = dest.join(&rendered_name); if src_path.is_dir() { copy_and_render_directory(env, &src_path, &dest_path, context)?; } else { // Copy and potentially render the file contents if is_text_file(&src_path) { copy_and_render_file(env, &src_path, &dest_path, context)?; } else { // Binary file - just copy fs::copy(&src_path, &dest_path) .map_err(|e| format!("Cannot copy {} to {}: {}", src_path.display(), dest_path.display(), e))?; } } } Ok(()) } /// Check if a file is likely text (and should be templated) or binary. fn is_text_file(path: &Path) -> bool { // List of binary extensions that shouldn't be templated let binary_extensions = [ "png", "jpg", "jpeg", "gif", "bmp", "ico", "svg", "odt", "odm", "ods", "doc", "docx", "xls", "xlsx", "ppt", "pptx", "zip", "tar", "gz", "rar", "7z", "exe", "dll", "so", "dylib", "bin", "pdf", "epub", "mobi", ".gitignore", ]; let ext = path.extension() .and_then(|e| e.to_str()) .map(|e| e.to_lowercase()); if let Some(ext) = ext { if binary_extensions.contains(&ext.as_str()) { return false; } } true } /// Copy a file, rendering its contents through MiniJinja if it looks like a template. fn copy_and_render_file( env: &Environment, src: &Path, dest: &Path, context: &HashMap, ) -> Result<(), String> { let content = fs::read_to_string(src) .map_err(|e| format!("Cannot read {}: {}", src.display(), e))?; // Check if the content contains template expressions if content.contains("{{") { let tmpl = env.template_from_str(&content) .map_err(|e| format!("Failed to parse template in {}: {}", src.display(), e))?; let rendered = tmpl.render(context) .map_err(|e| format!("Failed to render {}: {}", src.display(), e))?; fs::write(dest, &rendered) .map_err(|e| format!("Cannot write {}: {}", dest.display(), e))?; } else { fs::write(dest, &content) .map_err(|e| format!("Cannot write {}: {}", dest.display(), e))?; } Ok(()) } /// Run post-generation hooks. #[allow(dead_code)] fn run_hooks(_config: &RenderConfig, _project_dir: &Path, _project_name: &str) -> Result<(), String> { // Note: Gitea publishing is now handled natively in Rust via the caller // The Python hooks are no longer executed Ok(()) } #[cfg(test)] mod tests { use super::*; use std::fs; #[test] fn test_template_variables() { let vars = TemplateVariables::new("My Project", "John Doe", "A test project"); assert_eq!(vars.project_name, "My Project"); assert_eq!(vars.project_slug, "my_project"); assert_eq!(vars.author, "John Doe"); assert_eq!(vars.description, "A test project"); } #[test] fn test_render_filename() { let mut env = Environment::new(); // Note: In MiniJinja, `lower` is not a built-in method like in Jinja2. // We use the `lower` filter which we've registered in the main render function. env.add_filter("lower", |value: String| -> Result { Ok(value.to_lowercase()) }); let mut context = HashMap::new(); context.insert("project_name".to_string(), minijinja::Value::from("Test Project")); // Use the filter syntax |lower instead of .lower() method let name = render_filename(&env, "{{ project_name | lower }}", &context).unwrap(); assert_eq!(name, "test project"); } /// Integration test: render the snowflake template using MiniJinja. /// This tests that the templating works end-to-end with a real template. #[test] fn test_render_snowflake_template() { let template_dir = crate::config::default_project_template(); if !template_dir.join("cookiecutter.json").is_file() { eprintln!("skipping: no snowflake template at {}", template_dir.display()); return; } let out = std::env::temp_dir().join("md_manuscript_minijinja_test"); let _ = std::fs::remove_dir_all(&out); std::fs::create_dir_all(&out).unwrap(); let config = RenderConfig { template_dir, output_dir: out.clone(), variables: TemplateVariables::new( "MiniJinja Test Project", "Test Author", "A project generated with MiniJinja", ), run_hooks: false, }; let project = render_template(&config).expect("generation should succeed"); // Check that the project directory was created with the correct name assert_eq!( project.file_name().and_then(|n| n.to_str()), Some("MiniJinja Test Project") ); assert!(project.is_dir()); // Check that the README.md was rendered with the correct variables let readme = project.join("README.md"); assert!(readme.is_file(), "README.md should exist"); let readme_content = fs::read_to_string(&readme).unwrap(); assert!(readme_content.contains("MiniJinja Test Project")); assert!(readme_content.contains("A project generated with MiniJinja")); // Check that the 05-First Draft subdirectory exists (default manuscript folder) let subdir = crate::config::default_project_open_subdir(); assert!( project.join(&subdir).is_dir(), "expected a {subdir} folder in {project:?}" ); // Verify old directories (05-Plot, 06-First Draft) are NOT created // (they were merged into 05-First Draft) assert!( !project.join("05-Plot").is_dir(), "05-Plot should not exist (merged into 05-First Draft)" ); assert!( !project.join("06-First Draft").is_dir(), "06-First Draft should not exist (merged into 05-First Draft)" ); // Clean up let _ = std::fs::remove_dir_all(&out); } }