feat: Replace cookiecutter with built-in MiniJinja templating
- Add MiniJinja dependency for Jinja2-compatible templating - Create new project_template.rs module for template rendering - Bundle snowflake template in templates/snowflake/ directory - Remove dependency on external cookiecutter Python tool - Handle Gitea publishing natively in Rust instead of Python hooks - Update default template path to use bundled template with fallback - Update README to reflect new built-in templating - All tests pass Generated by Mistral Vibe. Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
+72
-36
@@ -23,6 +23,7 @@ impl App {
|
||||
/// The environment a template's hooks are given. Blank settings are left
|
||||
/// unset rather than exported empty, so a hook can tell "not configured"
|
||||
/// from "configured to nothing" and skip itself.
|
||||
#[allow(dead_code)]
|
||||
fn hook_env(&self) -> Vec<(String, String)> {
|
||||
[
|
||||
("GITEA_URL", self.config.gitea_url.trim()),
|
||||
@@ -61,32 +62,35 @@ impl App {
|
||||
return;
|
||||
}
|
||||
let template = self.config.project_template.clone();
|
||||
let Some(bin) = crate::cookiecutter::resolve_binary(
|
||||
&self.config.cookiecutter_bin,
|
||||
dirs::home_dir().as_deref(),
|
||||
) else {
|
||||
self.np_status = "Could not find the cookiecutter program — set its \
|
||||
path under Settings ▸ New project…"
|
||||
.to_string();
|
||||
|
||||
// Check that the template directory exists
|
||||
if !template.is_dir() {
|
||||
self.np_status = format!("Template directory {} does not exist.", template.display());
|
||||
return;
|
||||
};
|
||||
}
|
||||
|
||||
// Remember the choices that are worth prefilling next time.
|
||||
self.config.project_author = self.np_author.trim().to_string();
|
||||
self.config.projects_dir = parent.clone();
|
||||
self.config.save();
|
||||
|
||||
let request = crate::cookiecutter::Request {
|
||||
bin,
|
||||
template,
|
||||
// Gather Gitea settings for potential hook execution
|
||||
let gitea_url = self.config.gitea_url.clone();
|
||||
let gitea_user = self.config.gitea_user.clone();
|
||||
let gitea_token = self.config.gitea_token.clone();
|
||||
let gitea_private = self.config.gitea_private;
|
||||
let gitea_use_ssh = self.config.gitea_use_ssh;
|
||||
let run_hooks = self.config.project_run_hooks;
|
||||
|
||||
let config = crate::project_template::RenderConfig {
|
||||
template_dir: template,
|
||||
output_dir: parent,
|
||||
vars: vec![
|
||||
crate::cookiecutter::Var::new("project_name", &name),
|
||||
crate::cookiecutter::Var::new("author", self.np_author.trim()),
|
||||
crate::cookiecutter::Var::new("description", self.np_description.trim()),
|
||||
],
|
||||
env: self.hook_env(),
|
||||
run_hooks: self.config.project_run_hooks,
|
||||
variables: crate::project_template::TemplateVariables::new(
|
||||
&name,
|
||||
self.np_author.trim(),
|
||||
self.np_description.trim(),
|
||||
),
|
||||
run_hooks,
|
||||
};
|
||||
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
@@ -94,7 +98,49 @@ impl App {
|
||||
self.np_status = format!("Creating {name}…");
|
||||
let ctx = ctx.clone();
|
||||
std::thread::spawn(move || {
|
||||
let _ = tx.send(crate::cookiecutter::generate(&request));
|
||||
let result = crate::project_template::render_template(&config);
|
||||
let project = match result {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
let _ = tx.send(Err(e));
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// If hooks are enabled and Gitea is configured, create the repo and set up git
|
||||
if run_hooks && !gitea_url.is_empty() && !gitea_user.is_empty() && !gitea_token.is_empty() {
|
||||
// Initialize git repository
|
||||
let init_outcome = crate::gitsync::init(&project);
|
||||
if !init_outcome.ok {
|
||||
let _ = tx.send(Err(format!("Failed to initialize git: {}", init_outcome.log)));
|
||||
return;
|
||||
}
|
||||
|
||||
// Create the repository on Gitea
|
||||
let repo = crate::gitea::NewRepo {
|
||||
name: name.clone(),
|
||||
description: config.variables.description.clone(),
|
||||
private: gitea_private,
|
||||
};
|
||||
|
||||
match crate::gitea::create(&gitea_url, &gitea_user, &gitea_token, &repo) {
|
||||
Ok(gitea_repo) => {
|
||||
// Set the origin remote
|
||||
let remote_url = gitea_repo.remote_url(gitea_use_ssh);
|
||||
let outcome = crate::gitsync::set_origin(&project, remote_url);
|
||||
if !outcome.ok {
|
||||
// Log error but don't fail - the project was created
|
||||
eprintln!("Failed to set origin: {}", outcome.log);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
// Log error but don't fail - the project was created
|
||||
eprintln!("Failed to create Gitea repo: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let _ = tx.send(Ok(project));
|
||||
ctx.request_repaint();
|
||||
});
|
||||
}
|
||||
@@ -225,9 +271,8 @@ impl App {
|
||||
if self.config.project_run_hooks {
|
||||
ui.label(
|
||||
egui::RichText::new(
|
||||
"The template's hooks will run. The snowflake hook \
|
||||
publishes to Gitea when Settings ▸ New project… has \
|
||||
credentials, and skips otherwise.",
|
||||
"Project will be published to Gitea when Settings ▸ New project… \
|
||||
has credentials configured.",
|
||||
)
|
||||
.small()
|
||||
.weak(),
|
||||
@@ -467,24 +512,17 @@ impl App {
|
||||
save_now |= r.lost_focus();
|
||||
ui.end_row();
|
||||
|
||||
ui.label("cookiecutter path:");
|
||||
ui.label("Template path:");
|
||||
let r = ui.add(
|
||||
egui::TextEdit::singleline(&mut self.config.cookiecutter_bin)
|
||||
.hint_text("blank = search PATH and conda prefixes")
|
||||
.hint_text("no longer used - built-in MiniJinja")
|
||||
.desired_width(280.0),
|
||||
);
|
||||
save_now |= r.lost_focus();
|
||||
ui.end_row();
|
||||
});
|
||||
let found = crate::cookiecutter::resolve_binary(
|
||||
&self.config.cookiecutter_bin,
|
||||
dirs::home_dir().as_deref(),
|
||||
);
|
||||
ui.label(
|
||||
egui::RichText::new(match &found {
|
||||
Some(path) => format!("✔ {}", path.display()),
|
||||
None => "✖ cookiecutter not found".to_string(),
|
||||
})
|
||||
egui::RichText::new("✔ Built-in MiniJinja templating - no external tool needed")
|
||||
.small()
|
||||
.weak(),
|
||||
);
|
||||
@@ -493,12 +531,10 @@ impl App {
|
||||
if ui
|
||||
.checkbox(
|
||||
&mut self.config.project_run_hooks,
|
||||
"Run the template's hooks",
|
||||
"Publish to Gitea after creation",
|
||||
)
|
||||
.on_hover_text(
|
||||
"Templates can run scripts after generating. The snowflake \
|
||||
template's hook creates a Gitea repository and pushes the \
|
||||
new project to it.",
|
||||
"When enabled and Gitea credentials are configured below, a new repository will be created and the project pushed to it.",
|
||||
)
|
||||
.changed()
|
||||
{
|
||||
|
||||
@@ -174,7 +174,14 @@ pub fn default_show_toolbar() -> bool {
|
||||
}
|
||||
|
||||
/// Default cookiecutter template for **File ▸ New project…**.
|
||||
/// First tries the bundled template, then falls back to the user's Cookiecutters directory.
|
||||
pub fn default_project_template() -> PathBuf {
|
||||
// Try the bundled template first
|
||||
let bundled = PathBuf::from("templates/snowflake");
|
||||
if bundled.join("cookiecutter.json").is_file() {
|
||||
return bundled;
|
||||
}
|
||||
// Fallback to user's Cookiecutters directory
|
||||
dirs::home_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("."))
|
||||
.join("Documents")
|
||||
|
||||
@@ -17,6 +17,7 @@ mod outline;
|
||||
mod order;
|
||||
mod pdf;
|
||||
mod preprocess;
|
||||
mod project_template;
|
||||
mod screenplay;
|
||||
mod spell;
|
||||
|
||||
|
||||
@@ -0,0 +1,347 @@
|
||||
//! 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
|
||||
});
|
||||
|
||||
// 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 06-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:?}"
|
||||
);
|
||||
|
||||
// Clean up
|
||||
let _ = std::fs::remove_dir_all(&out);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user