7854a961a9
links, a sprint timer, placeholder scanning, corkboard, and a quick-capture inbox - Compile templates/snowflake into the binary via include_dir, so New project… never depends on files installed beside the executable; Settings ▸ Template becomes an optional external override, and a broken saved override now falls back to the bundled template instead of failing project generation. - Outline beats now link to a matching chapter for a one-click jump. - Sprint window: a daily whole-manuscript word goal plus a Pomodoro-style writing sprint timer, with a compact countdown in the status bar while in Distraction-free mode. - Placeholders window: find [TODO]/[TK]/[FIXME]/[XXX]/[NOTE]/[???] markers left in the draft and jump straight to any of them. - Snowflake progress strip in the file panel: a nudge-only readout of which snowflake stage folders actually have content. - Corkboard window: drag-reorderable synopsis cards, one per chapter. - Quick-capture popup (Ctrl+Shift+N): jot a stray idea straight into the project's Scratch Pad without losing your place in the draft. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
485 lines
19 KiB
Rust
485 lines
19 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.
|
|
//!
|
|
//! The default (bundled) template's files are compiled into the binary via
|
|
//! [`BUNDLED_SNOWFLAKE`], so **File ▸ New project…** works out of the box —
|
|
//! no `templates/` folder has to be installed beside the executable. A writer
|
|
//! can still point at a folder of their own (`Settings ▸ New project… ▸
|
|
//! Template`) to use a customized copy instead; see [`TemplateSource`].
|
|
|
|
use include_dir::{include_dir, Dir};
|
|
use minijinja::Environment;
|
|
use std::collections::HashMap;
|
|
use std::path::{Path, PathBuf};
|
|
use std::fs;
|
|
|
|
/// The bundled "snowflake" project template, compiled into the binary so
|
|
/// **File ▸ New project…** never depends on a `templates/` folder installed
|
|
/// beside the executable.
|
|
static BUNDLED_SNOWFLAKE: Dir<'static> = include_dir!("$CARGO_MANIFEST_DIR/templates/snowflake");
|
|
|
|
/// Where a project template comes from: the one compiled into the binary, or
|
|
/// a folder on disk the writer has pointed **Settings ▸ New project… ▸
|
|
/// Template** at (to use a customized copy of their own).
|
|
#[derive(Debug, Clone)]
|
|
pub enum TemplateSource {
|
|
Bundled,
|
|
External(PathBuf),
|
|
}
|
|
|
|
impl TemplateSource {
|
|
/// Whether this source is actually usable. `Bundled` always is — it's
|
|
/// compiled in; `External` needs a real `cookiecutter.json` at the path.
|
|
pub fn is_available(&self) -> bool {
|
|
match self {
|
|
TemplateSource::Bundled => true,
|
|
TemplateSource::External(path) => path.join("cookiecutter.json").is_file(),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Removes its directory on drop, so the bundled template's temp extraction
|
|
/// is cleaned up whether `render_template` returns `Ok` or `Err`.
|
|
struct TempExtraction {
|
|
dir: PathBuf,
|
|
}
|
|
|
|
impl Drop for TempExtraction {
|
|
fn drop(&mut self) {
|
|
let _ = fs::remove_dir_all(&self.dir);
|
|
}
|
|
}
|
|
|
|
/// Write the bundled template out to a fresh temp directory, so the rest of
|
|
/// the render pipeline can treat it exactly like an on-disk template.
|
|
fn extract_bundled() -> Result<(PathBuf, TempExtraction), String> {
|
|
let unique = std::time::SystemTime::now()
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
.map(|d| d.as_nanos())
|
|
.unwrap_or(0);
|
|
let dir = std::env::temp_dir().join(format!(
|
|
"md-manuscript-template-{}-{unique}",
|
|
std::process::id()
|
|
));
|
|
// `Dir::extract` fails if any file already exists, so clear any stale
|
|
// leftovers (e.g. from a previous crash) first.
|
|
let _ = fs::remove_dir_all(&dir);
|
|
// `extract` creates *sub*directories as it walks in, but never the root
|
|
// path itself — with top-level files in the template (cookiecutter.json,
|
|
// README.md), it would otherwise try to write into a directory that
|
|
// doesn't exist yet.
|
|
fs::create_dir_all(&dir).map_err(|e| format!("Cannot create {}: {e}", dir.display()))?;
|
|
BUNDLED_SNOWFLAKE
|
|
.extract(&dir)
|
|
.map_err(|e| format!("Failed to extract the bundled template: {e}"))?;
|
|
Ok((dir.clone(), TempExtraction { dir }))
|
|
}
|
|
|
|
/// 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 {
|
|
/// Where the template (with its cookiecutter.json and template folder)
|
|
/// comes from.
|
|
pub template: TemplateSource,
|
|
/// 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 {
|
|
// Resolve the template to a real directory on disk: an external override
|
|
// is used as-is, while the bundled template is extracted to a fresh temp
|
|
// directory first. `_guard` cleans that temp directory up when this
|
|
// function returns, however it returns.
|
|
let (template_dir, _guard): (PathBuf, Option<TempExtraction>) = match &config.template {
|
|
TemplateSource::External(path) => (path.clone(), None),
|
|
TemplateSource::Bundled => {
|
|
let (dir, guard) = extract_bundled()?;
|
|
(dir, Some(guard))
|
|
}
|
|
};
|
|
|
|
// Verify the template directory has a cookiecutter.json
|
|
let cookiecutter_json = template_dir.join("cookiecutter.json");
|
|
if !cookiecutter_json.is_file() {
|
|
return Err(format!(
|
|
"No cookiecutter.json in {} — that folder is not a template.",
|
|
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(&template_dir)
|
|
.map_err(|e| format!("Cannot read template directory {}: {}", 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 }}}}'.",
|
|
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 bundled snowflake template using
|
|
/// MiniJinja. This tests that the templating works end-to-end, and that
|
|
/// the compiled-in template is actually extracted and used correctly —
|
|
/// there's no "skip if the template isn't installed" escape hatch
|
|
/// anymore, since it's always available now.
|
|
#[test]
|
|
fn test_render_snowflake_template() {
|
|
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: TemplateSource::Bundled,
|
|
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);
|
|
}
|
|
|
|
/// The embed itself has to actually hold the template — a hermetic check
|
|
/// (no filesystem, no temp directory) that what's compiled into the
|
|
/// binary is a real, usable snowflake template.
|
|
#[test]
|
|
fn bundled_template_contains_the_expected_files() {
|
|
assert!(
|
|
BUNDLED_SNOWFLAKE.get_file("cookiecutter.json").is_some(),
|
|
"bundled template is missing cookiecutter.json"
|
|
);
|
|
let project_dir = BUNDLED_SNOWFLAKE
|
|
.dirs()
|
|
.find(|d| {
|
|
d.path()
|
|
.file_name()
|
|
.and_then(|n| n.to_str())
|
|
.is_some_and(|n| n.contains("{{"))
|
|
})
|
|
.expect("bundled template is missing its `{{ cookiecutter.project_name }}` folder");
|
|
let subdir = crate::config::default_project_open_subdir();
|
|
assert!(
|
|
project_dir.get_dir(project_dir.path().join(&subdir)).is_some(),
|
|
"bundled template is missing its {subdir} folder"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn extracting_the_bundled_template_cleans_up_after_itself() {
|
|
let (dir, guard) = extract_bundled().expect("extraction should succeed");
|
|
assert!(dir.join("cookiecutter.json").is_file());
|
|
drop(guard);
|
|
assert!(!dir.exists(), "temp extraction should be removed on drop");
|
|
}
|
|
}
|