feat: Add template system to weevil new command
Implements template-based project creation allowing teams to start with
professional example code instead of empty projects.
Features:
- Two templates: 'basic' (minimal) and 'testing' (45-test showcase)
- Template variable substitution ({{PROJECT_NAME}}, etc.)
- Template validation with helpful error messages
- `weevil new --list-templates` command
- Template files embedded in binary at compile time
Technical details:
- Templates stored in templates/basic/ and templates/testing/
- Files ending in .template have variables replaced
- Uses include_dir! macro to embed templates in binary
- Returns file count for user feedback
Testing template includes:
- 3 complete subsystems (MotorCycler, WallApproach, TurnController)
- Hardware abstraction layer with mock implementations
- 45 comprehensive tests (unit, integration, system)
- Professional documentation (DESIGN_AND_TEST_PLAN.md, etc.)
Usage:
weevil new my-robot # basic template
weevil new my-robot --template testing # testing showcase
weevil new --list-templates # show available templates
This enables FTC teams to learn from working code and best practices
rather than starting from scratch.
This commit is contained in:
@@ -1,101 +1,245 @@
|
||||
use include_dir::{include_dir, Dir};
|
||||
use std::path::Path;
|
||||
use anyhow::{Result, Context};
|
||||
use tera::{Tera, Context as TeraContext};
|
||||
use anyhow::{Result, Context, bail};
|
||||
use tera::Tera;
|
||||
use std::fs;
|
||||
use colored::*;
|
||||
|
||||
// Embed all template files at compile time
|
||||
static TEMPLATES_DIR: Dir = include_dir!("$CARGO_MANIFEST_DIR/templates");
|
||||
// Embed template directories at compile time
|
||||
static BASIC_TEMPLATE: Dir = include_dir!("$CARGO_MANIFEST_DIR/templates/basic");
|
||||
static TESTING_TEMPLATE: Dir = include_dir!("$CARGO_MANIFEST_DIR/templates/testing");
|
||||
|
||||
pub struct TemplateEngine {
|
||||
pub struct TemplateManager {
|
||||
#[allow(dead_code)]
|
||||
tera: Tera,
|
||||
}
|
||||
|
||||
impl TemplateEngine {
|
||||
#[allow(dead_code)]
|
||||
#[allow(dead_code)]
|
||||
pub struct TemplateInfo {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub file_count: usize,
|
||||
pub line_count: usize,
|
||||
pub test_count: usize,
|
||||
pub is_default: bool,
|
||||
}
|
||||
|
||||
pub struct TemplateContext {
|
||||
pub project_name: String,
|
||||
pub package_name: String,
|
||||
pub creation_date: String,
|
||||
pub weevil_version: String,
|
||||
pub template_name: String,
|
||||
}
|
||||
|
||||
impl TemplateManager {
|
||||
pub fn new() -> Result<Self> {
|
||||
let mut tera = Tera::default();
|
||||
|
||||
// Load all templates from embedded directory
|
||||
for file in TEMPLATES_DIR.files() {
|
||||
let path = file.path().to_string_lossy();
|
||||
let contents = file.contents_utf8()
|
||||
.context("Template must be valid UTF-8")?;
|
||||
tera.add_raw_template(&path, contents)?;
|
||||
}
|
||||
|
||||
let tera = Tera::default();
|
||||
Ok(Self { tera })
|
||||
}
|
||||
|
||||
pub fn template_exists(&self, name: &str) -> bool {
|
||||
matches!(name, "basic" | "testing")
|
||||
}
|
||||
|
||||
pub fn list_templates(&self) -> Vec<String> {
|
||||
vec![
|
||||
" basic - Minimal FTC project (default)".to_string(),
|
||||
" testing - Testing showcase with examples".to_string(),
|
||||
]
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn render_to_file(
|
||||
pub fn get_template_info_all(&self) -> Result<Vec<TemplateInfo>> {
|
||||
Ok(vec![
|
||||
TemplateInfo {
|
||||
name: "basic".to_string(),
|
||||
description: "Minimal FTC project structure".to_string(),
|
||||
file_count: 10,
|
||||
line_count: 50,
|
||||
test_count: 0,
|
||||
is_default: true,
|
||||
},
|
||||
TemplateInfo {
|
||||
name: "testing".to_string(),
|
||||
description: "Professional testing showcase with examples".to_string(),
|
||||
file_count: 30,
|
||||
line_count: 2500,
|
||||
test_count: 45,
|
||||
is_default: false,
|
||||
},
|
||||
])
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn show_template_info(&self, name: &str) -> Result<()> {
|
||||
let info = match name {
|
||||
"basic" => TemplateInfo {
|
||||
name: "basic".to_string(),
|
||||
description: "Minimal FTC project with clean structure".to_string(),
|
||||
file_count: 10,
|
||||
line_count: 50,
|
||||
test_count: 0,
|
||||
is_default: true,
|
||||
},
|
||||
"testing" => TemplateInfo {
|
||||
name: "testing".to_string(),
|
||||
description: "Comprehensive testing showcase demonstrating professional robotics software engineering practices.".to_string(),
|
||||
file_count: 30,
|
||||
line_count: 2500,
|
||||
test_count: 45,
|
||||
is_default: false,
|
||||
},
|
||||
_ => bail!("Unknown template: {}", name),
|
||||
};
|
||||
|
||||
println!("{}", format!("Template: {}", info.name).bright_cyan().bold());
|
||||
println!();
|
||||
println!("{}", "Description:".bright_white().bold());
|
||||
println!(" {}", info.description);
|
||||
println!();
|
||||
|
||||
if info.name == "testing" {
|
||||
println!("{}", "Features:".bright_white().bold());
|
||||
println!(" • Three complete subsystems with full test coverage");
|
||||
println!(" • Hardware abstraction layer with mocks");
|
||||
println!(" • 45 passing tests (unit, integration, system)");
|
||||
println!(" • Comprehensive documentation (6 files)");
|
||||
println!(" • Ready to use as learning material");
|
||||
println!();
|
||||
}
|
||||
|
||||
println!("{}", "Files included:".bright_white().bold());
|
||||
println!(" {} files", info.file_count);
|
||||
println!(" ~{} lines of code", info.line_count);
|
||||
if info.test_count > 0 {
|
||||
println!(" {} tests", info.test_count);
|
||||
}
|
||||
println!();
|
||||
|
||||
println!("{}", "Example usage:".bright_white().bold());
|
||||
println!(" weevil new my-robot --template {}", info.name);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn extract_template(
|
||||
&self,
|
||||
template_name: &str,
|
||||
output_path: &Path,
|
||||
context: &TeraContext,
|
||||
) -> Result<()> {
|
||||
let rendered = self.tera.render(template_name, context)?;
|
||||
output_dir: &Path,
|
||||
context: &TemplateContext,
|
||||
) -> Result<usize> {
|
||||
let template_dir = match template_name {
|
||||
"basic" => &BASIC_TEMPLATE,
|
||||
"testing" => &TESTING_TEMPLATE,
|
||||
_ => bail!("Unknown template: {}", template_name),
|
||||
};
|
||||
|
||||
if let Some(parent) = output_path.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
// Extract all files from template
|
||||
let file_count = self.extract_dir_recursively(template_dir, output_dir, "", context)?;
|
||||
|
||||
fs::write(output_path, rendered)?;
|
||||
Ok(())
|
||||
Ok(file_count)
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn extract_static_file(&self, template_path: &str, output_path: &Path) -> Result<()> {
|
||||
let file = TEMPLATES_DIR
|
||||
.get_file(template_path)
|
||||
.context(format!("Template not found: {}", template_path))?;
|
||||
fn extract_dir_recursively(
|
||||
&self,
|
||||
source: &Dir,
|
||||
output_base: &Path,
|
||||
relative_path: &str,
|
||||
context: &TemplateContext,
|
||||
) -> Result<usize> {
|
||||
let mut file_count = 0;
|
||||
|
||||
if let Some(parent) = output_path.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
// Process files in current directory
|
||||
for file in source.files() {
|
||||
let file_path = file.path();
|
||||
let file_name = file_path.file_name().unwrap().to_string_lossy();
|
||||
|
||||
let output_path = if relative_path.is_empty() {
|
||||
output_base.join(&*file_name)
|
||||
} else {
|
||||
output_base.join(relative_path).join(&*file_name)
|
||||
};
|
||||
|
||||
// Create parent directory if needed
|
||||
if let Some(parent) = output_path.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
|
||||
// Process file based on extension
|
||||
if file_name.ends_with(".template") {
|
||||
// Template file - process variables
|
||||
let contents = file.contents_utf8()
|
||||
.context("Template file must be UTF-8")?;
|
||||
|
||||
let processed = self.process_template_string(contents, context)?;
|
||||
|
||||
// Remove .template extension from output
|
||||
let output_name = file_name.trim_end_matches(".template");
|
||||
let final_path = output_path.with_file_name(output_name);
|
||||
|
||||
fs::write(final_path, processed)?;
|
||||
file_count += 1;
|
||||
} else {
|
||||
// Regular file - copy as-is
|
||||
fs::write(&output_path, file.contents())?;
|
||||
file_count += 1;
|
||||
}
|
||||
}
|
||||
|
||||
fs::write(output_path, file.contents())?;
|
||||
Ok(())
|
||||
// Process subdirectories
|
||||
for dir in source.dirs() {
|
||||
let dir_name = dir.path().file_name().unwrap().to_string_lossy();
|
||||
let new_relative = if relative_path.is_empty() {
|
||||
dir_name.to_string()
|
||||
} else {
|
||||
format!("{}/{}", relative_path, dir_name)
|
||||
};
|
||||
|
||||
file_count += self.extract_dir_recursively(dir, output_base, &new_relative, context)?;
|
||||
}
|
||||
|
||||
Ok(file_count)
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn list_templates(&self) -> Vec<String> {
|
||||
TEMPLATES_DIR
|
||||
.files()
|
||||
.map(|f| f.path().to_string_lossy().to_string())
|
||||
.collect()
|
||||
fn process_template_string(
|
||||
&self,
|
||||
template: &str,
|
||||
context: &TemplateContext,
|
||||
) -> Result<String> {
|
||||
let processed = template
|
||||
.replace("{{PROJECT_NAME}}", &context.project_name)
|
||||
.replace("{{PACKAGE_NAME}}", &context.package_name)
|
||||
.replace("{{CREATION_DATE}}", &context.creation_date)
|
||||
.replace("{{WEEVIL_VERSION}}", &context.weevil_version)
|
||||
.replace("{{TEMPLATE_NAME}}", &context.template_name);
|
||||
|
||||
Ok(processed)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[test]
|
||||
fn test_template_engine_creation() {
|
||||
let engine = TemplateEngine::new();
|
||||
assert!(engine.is_ok());
|
||||
fn test_template_manager_creation() {
|
||||
let mgr = TemplateManager::new();
|
||||
assert!(mgr.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_template_exists() {
|
||||
let mgr = TemplateManager::new().unwrap();
|
||||
assert!(mgr.template_exists("basic"));
|
||||
assert!(mgr.template_exists("testing"));
|
||||
assert!(!mgr.template_exists("nonexistent"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_list_templates() {
|
||||
let engine = TemplateEngine::new().unwrap();
|
||||
let templates = engine.list_templates();
|
||||
assert!(!templates.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_render_template() {
|
||||
let _engine = TemplateEngine::new().unwrap();
|
||||
let temp = TempDir::new().unwrap();
|
||||
let _output = temp.path().join("test.txt");
|
||||
|
||||
let mut context = TeraContext::new();
|
||||
context.insert("project_name", "TestRobot");
|
||||
|
||||
// This will fail until we add templates, but shows the pattern
|
||||
// engine.render_to_file("README.md", &output, &context).unwrap();
|
||||
let mgr = TemplateManager::new().unwrap();
|
||||
let templates = mgr.list_templates();
|
||||
assert_eq!(templates.len(), 2);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user