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.
245 lines
7.9 KiB
Rust
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);
|
|
}
|
|
} |