2 Commits

Author SHA1 Message Date
Eric Ratliff
409469350e Fixed warnings 2026-02-02 19:17:48 -06:00
Eric Ratliff
8cb799d378 Working on a create command to add features 2026-02-02 19:14:50 -06:00
7 changed files with 497 additions and 70 deletions

View File

@@ -56,6 +56,7 @@ which = "7.0"
# Colors # Colors
colored = "2.1" colored = "2.1"
chrono = "0.4.43"
[dev-dependencies] [dev-dependencies]
tempfile = "3.13" tempfile = "3.13"
@@ -86,4 +87,4 @@ strip = true
[features] [features]
default = [] default = []
embedded-gradle = [] # Embed gradle-wrapper.jar in binary (run download-gradle-wrapper.sh first) embedded-gradle = [] # Embed gradle-wrapper.jar in binary (run download-gradle-wrapper.sh first)

241
src/commands/create.rs Normal file
View File

@@ -0,0 +1,241 @@
use anyhow::{Result, Context, bail};
use colored::*;
use std::path::{Path, PathBuf};
use std::fs;
use chrono::Utc;
use crate::templates::TemplateManager;
// Use Cargo's version macro instead of importing
const WEEVIL_VERSION: &str = env!("CARGO_PKG_VERSION");
pub fn create_project(
name: &str,
template: Option<&str>,
path: Option<&str>,
force: bool,
no_init: bool,
dry_run: bool,
) -> Result<()> {
// Validate project name
validate_project_name(name)?;
// Determine output directory
let output_dir = if let Some(p) = path {
PathBuf::from(p).join(name)
} else {
PathBuf::from(name)
};
// Check if directory exists
if output_dir.exists() && !force {
bail!(
"Directory '{}' already exists\nUse --force to overwrite",
output_dir.display()
);
}
let template_name = template.unwrap_or("basic");
println!("{}", format!("Creating FTC project '{}' with template '{}'...", name, template_name).bright_cyan().bold());
println!();
if dry_run {
println!("{}", "DRY RUN - No files will be created".yellow().bold());
println!();
}
// Load template manager
let template_mgr = TemplateManager::new()?;
// Verify template exists
if !template_mgr.template_exists(template_name) {
bail!(
"Template '{}' not found\n\nAvailable templates:\n{}",
template_name,
template_mgr.list_templates().join("\n ")
);
}
// Prepare template context
let context = TemplateContext {
project_name: name.to_string(),
package_name: name.to_lowercase().replace("-", "").replace("_", ""),
creation_date: Utc::now().to_rfc3339(),
weevil_version: WEEVIL_VERSION.to_string(),
template_name: template_name.to_string(),
};
if !dry_run {
// Create output directory
if force && output_dir.exists() {
println!("{}", "⚠ Removing existing directory...".yellow());
fs::remove_dir_all(&output_dir)?;
}
fs::create_dir_all(&output_dir)?;
// Extract template
template_mgr.extract_template(template_name, &output_dir, &context)?;
println!("{}", "✓ Created directory structure".green());
// Initialize git repository
if !no_init {
init_git_repository(&output_dir, template_name)?;
}
println!();
print_success_message(name, template_name);
} else {
println!("{}", format!("Would create project '{}' using template '{}'", name, template_name).bright_white());
println!("{}", format!("Output directory: {}", output_dir.display()).bright_white());
template_mgr.show_template_info(template_name)?;
}
Ok(())
}
pub fn list_templates() -> Result<()> {
let template_mgr = TemplateManager::new()?;
println!("{}", "Available templates:".bright_cyan().bold());
println!();
for info in template_mgr.get_template_info_all()? {
println!("{}", format!(" {} {}", info.name, if info.is_default { "(default)" } else { "" }).bright_white().bold());
println!(" {}", info.description);
println!(" Files: {} | Lines: ~{}", info.file_count, info.line_count);
if info.test_count > 0 {
println!(" Tests: {} tests", info.test_count);
}
println!();
}
println!("Use: weevil create <n> --template <template>");
Ok(())
}
pub fn show_template(template_name: &str) -> Result<()> {
let template_mgr = TemplateManager::new()?;
if !template_mgr.template_exists(template_name) {
bail!("Template '{}' not found", template_name);
}
template_mgr.show_template_info(template_name)?;
Ok(())
}
fn validate_project_name(name: &str) -> Result<()> {
if name.is_empty() {
bail!("Project name cannot be empty");
}
if name.len() > 50 {
bail!("Project name must be 50 characters or less");
}
// Check for valid characters
let valid = name.chars().all(|c| {
c.is_alphanumeric() || c == '-' || c == '_'
});
if !valid {
bail!(
"Invalid project name '{}'\nProject names must:\n - Contain only letters, numbers, hyphens, underscores\n - Start with a letter\n - Be 1-50 characters long\n\nValid examples:\n my-robot\n team1234_robot\n competitionBot2024",
name
);
}
// Must start with letter
if !name.chars().next().unwrap().is_alphabetic() {
bail!("Project name must start with a letter");
}
Ok(())
}
fn init_git_repository(project_dir: &Path, template_name: &str) -> Result<()> {
use std::process::Command;
// Check if git is available
let git_check = Command::new("git")
.arg("--version")
.output();
if git_check.is_err() {
println!("{}", "⚠ Git not found, skipping repository initialization".yellow());
return Ok(());
}
// Initialize repository
let status = Command::new("git")
.arg("init")
.current_dir(project_dir)
.output()
.context("Failed to initialize git repository")?;
if !status.status.success() {
println!("{}", "⚠ Failed to initialize git repository".yellow());
return Ok(());
}
// Create initial commit
Command::new("git")
.args(&["add", "."])
.current_dir(project_dir)
.output()
.ok();
let commit_message = format!("Initial commit from weevil create --template {}", template_name);
Command::new("git")
.args(&["commit", "-m", &commit_message])
.current_dir(project_dir)
.output()
.ok();
println!("{}", "✓ Initialized Git repository".green());
Ok(())
}
fn print_success_message(project_name: &str, template_name: &str) {
println!("{}", "Project created successfully!".bright_green().bold());
println!();
println!("{}", "Next steps:".bright_cyan().bold());
println!(" cd {}", project_name);
match template_name {
"testing" => {
println!(" weevil test # Run 45 tests (< 2 seconds)");
println!(" weevil build # Build APK for robot");
println!(" weevil deploy --auto # Deploy to robot");
println!();
println!("{}", "Documentation:".bright_cyan().bold());
println!(" README.md - Project overview");
println!(" DESIGN_AND_TEST_PLAN.md - System architecture");
println!(" TESTING_GUIDE.md - How to write tests");
}
"basic" => {
println!(" weevil setup # Setup development environment");
println!(" weevil build # Build APK for robot");
println!(" weevil deploy # Deploy to robot");
}
_ => {
println!(" weevil setup # Setup development environment");
println!(" weevil build # Build your robot code");
}
}
println!();
println!("{}", format!("Learn more: https://docs.weevil.dev/templates/{}", template_name).bright_black());
}
pub struct TemplateContext {
pub project_name: String,
pub package_name: String,
pub creation_date: String,
pub weevil_version: String,
pub template_name: String,
}

View File

@@ -5,4 +5,5 @@ pub mod sdk;
pub mod config; pub mod config;
pub mod setup; pub mod setup;
pub mod doctor; pub mod doctor;
pub mod uninstall; pub mod uninstall;
pub mod create;

View File

@@ -3,12 +3,6 @@ use colored::*;
use anyhow::Result; use anyhow::Result;
use weevil::version::WEEVIL_VERSION; use weevil::version::WEEVIL_VERSION;
// Import ProxyConfig through our own `mod sdk`, not through the `weevil`
// library crate. Both re-export the same source, but Rust treats
// `weevil::sdk::proxy::ProxyConfig` and `sdk::proxy::ProxyConfig` as
// distinct types when a binary and its lib are compiled together.
// The command modules already see the local-mod version, so main must match.
mod commands; mod commands;
mod sdk; mod sdk;
mod project; mod project;
@@ -39,7 +33,41 @@ struct Cli {
#[derive(Subcommand)] #[derive(Subcommand)]
enum Commands { enum Commands {
/// Create a new FTC robot project /// Create a new FTC robot project from a template
Create {
/// Name of the project to create
name: String,
/// Template to use (basic, testing)
#[arg(long, short = 't', value_name = "TEMPLATE")]
template: Option<String>,
/// Parent directory for the project (project will be created in <path>/<name>)
#[arg(long, value_name = "PATH")]
path: Option<String>,
/// Overwrite existing directory
#[arg(long)]
force: bool,
/// Don't initialize git repository
#[arg(long)]
no_init: bool,
/// Show what would be created without creating
#[arg(long)]
dry_run: bool,
/// List available templates
#[arg(long, exclusive = true)]
list_templates: bool,
/// Show detailed information about a template
#[arg(long, value_name = "TEMPLATE", exclusive = true)]
show_template: Option<String>,
},
/// Create a new FTC robot project (legacy - use 'create' instead)
New { New {
/// Name of the robot project /// Name of the robot project
name: String, name: String,
@@ -139,6 +167,31 @@ fn main() -> Result<()> {
let proxy = ProxyConfig::resolve(cli.proxy.as_deref(), cli.no_proxy)?; let proxy = ProxyConfig::resolve(cli.proxy.as_deref(), cli.no_proxy)?;
match cli.command { match cli.command {
Commands::Create {
name,
template,
path,
force,
no_init,
dry_run,
list_templates,
show_template
} => {
if list_templates {
commands::create::list_templates()
} else if let Some(tmpl) = show_template {
commands::create::show_template(&tmpl)
} else {
commands::create::create_project(
&name,
template.as_deref(),
path.as_deref(),
force,
no_init,
dry_run,
)
}
}
Commands::New { name, ftc_sdk, android_sdk } => { Commands::New { name, ftc_sdk, android_sdk } => {
commands::new::create_project(&name, ftc_sdk.as_deref(), android_sdk.as_deref(), &proxy) commands::new::create_project(&name, ftc_sdk.as_deref(), android_sdk.as_deref(), &proxy)
} }

View File

@@ -1,101 +1,230 @@
use include_dir::{include_dir, Dir}; use include_dir::{include_dir, Dir};
use std::path::Path; use std::path::Path;
use anyhow::{Result, Context}; use anyhow::{Result, Context, bail};
use tera::{Tera, Context as TeraContext}; use tera::Tera;
use std::fs; use std::fs;
use colored::*;
// Embed all template files at compile time // Embed template directories at compile time
static TEMPLATES_DIR: Dir = include_dir!("$CARGO_MANIFEST_DIR/templates"); 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)] #[allow(dead_code)]
tera: Tera, tera: Tera,
} }
impl TemplateEngine { pub struct TemplateInfo {
#[allow(dead_code)] pub name: String,
pub description: String,
pub file_count: usize,
pub line_count: usize,
pub test_count: usize,
pub is_default: bool,
}
impl TemplateManager {
pub fn new() -> Result<Self> { pub fn new() -> Result<Self> {
let mut tera = Tera::default(); let 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)?;
}
Ok(Self { tera }) Ok(Self { tera })
} }
#[allow(dead_code)] pub fn template_exists(&self, name: &str) -> bool {
pub fn render_to_file( 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(),
]
}
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,
},
])
}
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 create my-robot --template {}", info.name);
Ok(())
}
pub fn extract_template(
&self, &self,
template_name: &str, template_name: &str,
output_path: &Path, output_dir: &Path,
context: &TeraContext, context: &crate::commands::create::TemplateContext,
) -> Result<()> { ) -> Result<()> {
let rendered = self.tera.render(template_name, context)?; let template_dir = match template_name {
"basic" => &BASIC_TEMPLATE,
"testing" => &TESTING_TEMPLATE,
_ => bail!("Unknown template: {}", template_name),
};
if let Some(parent) = output_path.parent() { // Extract all files from template
fs::create_dir_all(parent)?; self.extract_dir_recursively(template_dir, output_dir, "", context)?;
}
fs::write(output_path, rendered)?;
Ok(()) Ok(())
} }
#[allow(dead_code)] fn extract_dir_recursively(
pub fn extract_static_file(&self, template_path: &str, output_path: &Path) -> Result<()> { &self,
let file = TEMPLATES_DIR source: &Dir,
.get_file(template_path) output_base: &Path,
.context(format!("Template not found: {}", template_path))?; relative_path: &str,
context: &crate::commands::create::TemplateContext,
if let Some(parent) = output_path.parent() { ) -> Result<()> {
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)?;
} else {
// Regular file - copy as-is
fs::write(&output_path, file.contents())?;
}
}
// 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)
};
self.extract_dir_recursively(dir, output_base, &new_relative, context)?;
} }
fs::write(output_path, file.contents())?;
Ok(()) Ok(())
} }
#[allow(dead_code)] fn process_template_string(
pub fn list_templates(&self) -> Vec<String> { &self,
TEMPLATES_DIR template: &str,
.files() context: &crate::commands::create::TemplateContext,
.map(|f| f.path().to_string_lossy().to_string()) ) -> Result<String> {
.collect() 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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use tempfile::TempDir;
#[test] #[test]
fn test_template_engine_creation() { fn test_template_manager_creation() {
let engine = TemplateEngine::new(); let mgr = TemplateManager::new();
assert!(engine.is_ok()); 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] #[test]
fn test_list_templates() { fn test_list_templates() {
let engine = TemplateEngine::new().unwrap(); let mgr = TemplateManager::new().unwrap();
let templates = engine.list_templates(); let templates = mgr.list_templates();
assert!(!templates.is_empty()); assert_eq!(templates.len(), 2);
}
#[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();
} }
} }

1
templates/basic/.gitkeep Normal file
View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1 @@