Files
md-manuscript/src/project_template.rs
T
landon 114f4ff697 docs: Update all references from old 05-Plot/06-First Draft to merged 05-First Draft
- Update project_template test to verify old directories are NOT created
- Update hover text and comments in project.rs
- Update comments and code in file_list.rs
- Update comment in spelling.rs
- Add test assertions to verify 05-Plot and 06-First Draft don't exist in new projects

This ensures all code references match the merged directory structure where
planning and draft content are combined in 05-First Draft with YAML frontmatter.

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
2026-09-16 15:34:36 -05:00

373 lines
15 KiB
Rust

//! 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<String, minijinja::Value> {
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<PathBuf, String>;
/// 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<String, minijinja::Error> {
Ok(value.to_lowercase())
});
env.add_filter("upper", |value: String| -> Result<String, minijinja::Error> {
Ok(value.to_uppercase())
});
env.add_filter("replace", |value: String, from: String, to: String| -> Result<String, minijinja::Error> {
Ok(value.replace(&from, &to))
});
env.add_filter("title", |value: String| -> Result<String, minijinja::Error> {
Ok(value.chars()
.enumerate()
.map(|(i, c)| if i == 0 { c.to_uppercase().collect::<String>() } else { c.to_lowercase().collect::<String>() })
.collect::<String>()) // Simplified title case
});
// Capitalize filter - capitalizes the first letter of each word
env.add_filter("capitalize", |value: String| -> Result<String, minijinja::Error> {
Ok(value.chars()
.enumerate()
.map(|(i, c)| if i == 0 { c.to_uppercase().collect::<String>() } else { c.to_lowercase().collect::<String>() })
.collect::<String>())
});
// capitalizes filter (plural) - same as capitalize but handles the common template usage
env.add_filter("capitalizes", |value: String| -> Result<String, minijinja::Error> {
Ok(value.chars()
.enumerate()
.map(|(i, c)| if i == 0 { c.to_uppercase().collect::<String>() } else { c.to_lowercase().collect::<String>() })
.collect::<String>())
});
// 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<PathBuf> = 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<String, minijinja::Value>) -> Result<String, minijinja::Error> {
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<String, minijinja::Value>,
) -> 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<String, minijinja::Value>,
) -> 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<String, minijinja::Error> {
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);
}
}