Files
weevil/src/templates/mod.rs
Eric Ratliff 60679e097f 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.
2026-02-02 22:31:08 -06:00

245 lines
7.9 KiB
Rust

use include_dir::{include_dir, Dir};
use std::path::Path;
use anyhow::{Result, Context, bail};
use tera::Tera;
use std::fs;
use colored::*;
// 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 TemplateManager {
#[allow(dead_code)]
tera: Tera,
}
#[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 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 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_dir: &Path,
context: &TemplateContext,
) -> Result<usize> {
let template_dir = match template_name {
"basic" => &BASIC_TEMPLATE,
"testing" => &TESTING_TEMPLATE,
_ => bail!("Unknown template: {}", template_name),
};
// Extract all files from template
let file_count = self.extract_dir_recursively(template_dir, output_dir, "", context)?;
Ok(file_count)
}
fn extract_dir_recursively(
&self,
source: &Dir,
output_base: &Path,
relative_path: &str,
context: &TemplateContext,
) -> Result<usize> {
let mut file_count = 0;
// 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;
}
}
// 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)
}
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::*;
#[test]
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 mgr = TemplateManager::new().unwrap();
let templates = mgr.list_templates();
assert_eq!(templates.len(), 2);
}
}