feat: Weevil v1.0.0-beta1 - FTC Project Generator
Cross-platform tool for generating clean, testable FTC robot projects without editing the SDK installation. Features: - Standalone project generation with proper separation from SDK - Per-project SDK configuration via .weevil.toml - Local unit testing support (no robot required) - Cross-platform build/deploy scripts (Linux/macOS/Windows) - Project upgrade system preserving user code - Configuration management commands - Comprehensive test suite (11 passing tests) - Zero-warning builds Architecture: - Pure Rust implementation with embedded Gradle wrapper - Projects use deployToSDK task to copy code to FTC SDK TeamCode - Git-ready projects with automatic initialization - USB and WiFi deployment with auto-detection Commands: - weevil new <name> - Create new project - weevil upgrade <path> - Update project infrastructure - weevil config <path> - View/modify project configuration - weevil sdk status/install/update - Manage SDKs Addresses the core problem: FTC's SDK structure forces students to edit framework internals instead of separating concerns like industry standard practices. Weevil enables proper software engineering workflows for robotics education.
This commit is contained in:
455
src/project/mod.rs
Normal file
455
src/project/mod.rs
Normal file
@@ -0,0 +1,455 @@
|
||||
use std::path::Path;
|
||||
use anyhow::{Result, Context};
|
||||
use std::fs;
|
||||
use colored::*;
|
||||
use tera::Context as TeraContext;
|
||||
use git2::Repository;
|
||||
|
||||
use crate::sdk::SdkConfig;
|
||||
|
||||
pub mod deployer;
|
||||
pub mod config;
|
||||
|
||||
pub use config::ProjectConfig;
|
||||
|
||||
pub struct ProjectBuilder {
|
||||
name: String,
|
||||
}
|
||||
|
||||
impl ProjectBuilder {
|
||||
pub fn new(name: &str, _sdk_config: &SdkConfig) -> Result<Self> {
|
||||
Ok(Self {
|
||||
name: name.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn create(&self, project_path: &Path, sdk_config: &SdkConfig) -> Result<()> {
|
||||
// Create directory structure
|
||||
self.create_directories(project_path)?;
|
||||
|
||||
// Generate files from templates
|
||||
self.generate_files(project_path, sdk_config)?;
|
||||
|
||||
// Setup Gradle wrapper
|
||||
self.setup_gradle(project_path)?;
|
||||
|
||||
// Initialize git repository
|
||||
self.init_git(project_path)?;
|
||||
|
||||
// Make scripts executable
|
||||
self.make_executable(project_path)?;
|
||||
|
||||
println!("{} Project structure created", "✓".green());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn create_directories(&self, project_path: &Path) -> Result<()> {
|
||||
let dirs = vec![
|
||||
"src/main/java/robot",
|
||||
"src/main/java/robot/subsystems",
|
||||
"src/main/java/robot/hardware",
|
||||
"src/main/java/robot/opmodes",
|
||||
"src/test/java/robot",
|
||||
"src/test/java/robot/subsystems",
|
||||
"gradle/wrapper",
|
||||
];
|
||||
|
||||
for dir in dirs {
|
||||
let full_path = project_path.join(dir);
|
||||
fs::create_dir_all(&full_path)
|
||||
.context(format!("Failed to create directory: {}", dir))?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn generate_files(&self, project_path: &Path, sdk_config: &SdkConfig) -> Result<()> {
|
||||
let mut _context = TeraContext::new();
|
||||
_context.insert("project_name", &self.name);
|
||||
_context.insert("sdk_dir", &sdk_config.ftc_sdk_path.to_string_lossy());
|
||||
_context.insert("generator_version", "1.0.0");
|
||||
|
||||
self.create_project_files(project_path, sdk_config)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn create_project_files(&self, project_path: &Path, sdk_config: &SdkConfig) -> Result<()> {
|
||||
// Create .weevil.toml config
|
||||
let project_config = ProjectConfig::new(&self.name, sdk_config.ftc_sdk_path.clone())?;
|
||||
project_config.save(project_path)?;
|
||||
|
||||
// README.md
|
||||
let readme = format!(
|
||||
r#"# {}
|
||||
|
||||
FTC Robot Project generated by Weevil v1.0.0
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# Test your code (runs on PC, no robot needed)
|
||||
./gradlew test
|
||||
|
||||
# Build and deploy (Linux/Mac)
|
||||
./build.sh
|
||||
./deploy.sh
|
||||
|
||||
# Build and deploy (Windows)
|
||||
build.bat
|
||||
deploy.bat
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
|
||||
- `src/main/java/robot/` - Your robot code
|
||||
- `src/test/java/robot/` - Unit tests (run on PC)
|
||||
|
||||
## Development Workflow
|
||||
|
||||
1. Write code in `src/main/java/robot/`
|
||||
2. Test locally: `./gradlew test`
|
||||
3. Deploy: `./deploy.sh` (or `deploy.bat` on Windows)
|
||||
"#,
|
||||
self.name
|
||||
);
|
||||
fs::write(project_path.join("README.md"), readme)?;
|
||||
|
||||
// .gitignore
|
||||
let gitignore = "build/\n.gradle/\n*.iml\n.idea/\nlocal.properties\n*.apk\n*.aab\n";
|
||||
fs::write(project_path.join(".gitignore"), gitignore)?;
|
||||
|
||||
// Version marker
|
||||
fs::write(project_path.join(".weevil-version"), "1.0.0")?;
|
||||
|
||||
// build.gradle.kts - Pure Java with deployToSDK task
|
||||
let build_gradle = format!(r#"plugins {{
|
||||
java
|
||||
}}
|
||||
|
||||
repositories {{
|
||||
mavenCentral()
|
||||
google()
|
||||
}}
|
||||
|
||||
dependencies {{
|
||||
// Testing (runs on PC without SDK)
|
||||
testImplementation("org.junit.jupiter:junit-jupiter:5.10.0")
|
||||
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
|
||||
testImplementation("org.mockito:mockito-core:5.5.0")
|
||||
}}
|
||||
|
||||
java {{
|
||||
sourceCompatibility = JavaVersion.VERSION_11
|
||||
targetCompatibility = JavaVersion.VERSION_11
|
||||
}}
|
||||
|
||||
tasks.test {{
|
||||
useJUnitPlatform()
|
||||
testLogging {{
|
||||
events("passed", "skipped", "failed")
|
||||
showStandardStreams = false
|
||||
exceptionFormat = org.gradle.api.tasks.testing.logging.TestExceptionFormat.FULL
|
||||
}}
|
||||
}}
|
||||
|
||||
// Task to deploy code to FTC SDK
|
||||
tasks.register<Copy>("deployToSDK") {{
|
||||
group = "ftc"
|
||||
description = "Copy code to FTC SDK TeamCode for deployment"
|
||||
|
||||
val sdkDir = "{}"
|
||||
|
||||
from("src/main/java") {{
|
||||
include("robot/**/*.java")
|
||||
}}
|
||||
|
||||
into(layout.projectDirectory.dir("$sdkDir/TeamCode/src/main/java"))
|
||||
|
||||
doLast {{
|
||||
println("✓ Code deployed to TeamCode")
|
||||
}}
|
||||
}}
|
||||
|
||||
// Task to build APK
|
||||
tasks.register<Exec>("buildApk") {{
|
||||
group = "ftc"
|
||||
description = "Build APK using FTC SDK"
|
||||
|
||||
dependsOn("deployToSDK")
|
||||
|
||||
val sdkDir = "{}"
|
||||
workingDir = file(sdkDir)
|
||||
|
||||
commandLine = if (System.getProperty("os.name").lowercase().contains("windows")) {{
|
||||
listOf("cmd", "/c", "gradlew.bat", "assembleDebug")
|
||||
}} else {{
|
||||
listOf("./gradlew", "assembleDebug")
|
||||
}}
|
||||
|
||||
doLast {{
|
||||
println("✓ APK built successfully")
|
||||
}}
|
||||
}}
|
||||
"#, sdk_config.ftc_sdk_path.display(), sdk_config.ftc_sdk_path.display());
|
||||
fs::write(project_path.join("build.gradle.kts"), build_gradle)?;
|
||||
|
||||
// settings.gradle.kts
|
||||
let settings_gradle = format!("rootProject.name = \"{}\"\n", self.name);
|
||||
fs::write(project_path.join("settings.gradle.kts"), settings_gradle)?;
|
||||
|
||||
// build.sh (Linux/Mac)
|
||||
let build_sh = r#"#!/bin/bash
|
||||
set -e
|
||||
|
||||
# Read SDK path from config
|
||||
SDK_DIR=$(grep '^ftc_sdk_path' .weevil.toml | sed 's/.*= "\(.*\)"/\1/')
|
||||
|
||||
if [ -z "$SDK_DIR" ]; then
|
||||
echo "Error: Could not read FTC SDK path from .weevil.toml"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Building project..."
|
||||
echo "Using FTC SDK: $SDK_DIR"
|
||||
./gradlew buildApk
|
||||
echo ""
|
||||
echo "✓ Build complete!"
|
||||
echo ""
|
||||
APK=$(find "$SDK_DIR" -path "*/outputs/apk/debug/*.apk" 2>/dev/null | head -1)
|
||||
if [ -n "$APK" ]; then
|
||||
echo "APK: $APK"
|
||||
fi
|
||||
"#;
|
||||
let build_sh_path = project_path.join("build.sh");
|
||||
fs::write(&build_sh_path, build_sh)?;
|
||||
|
||||
// build.bat (Windows)
|
||||
let build_bat = r#"@echo off
|
||||
setlocal enabledelayedexpansion
|
||||
|
||||
REM Read SDK path from config
|
||||
for /f "tokens=2 delims==" %%a in ('findstr /c:"ftc_sdk_path" .weevil.toml') do (
|
||||
set SDK_DIR=%%a
|
||||
set SDK_DIR=!SDK_DIR:"=!
|
||||
set SDK_DIR=!SDK_DIR: =!
|
||||
)
|
||||
|
||||
if not defined SDK_DIR (
|
||||
echo Error: Could not read FTC SDK path from .weevil.toml
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo Building project...
|
||||
echo Using FTC SDK: %SDK_DIR%
|
||||
call gradlew.bat buildApk
|
||||
echo.
|
||||
echo Build complete!
|
||||
"#;
|
||||
fs::write(project_path.join("build.bat"), build_bat)?;
|
||||
|
||||
// deploy.sh with all the flags
|
||||
let deploy_sh = r#"#!/bin/bash
|
||||
set -e
|
||||
|
||||
# Read SDK path from config
|
||||
SDK_DIR=$(grep '^ftc_sdk_path' .weevil.toml | sed 's/.*= "\(.*\)"/\1/')
|
||||
|
||||
if [ -z "$SDK_DIR" ]; then
|
||||
echo "Error: Could not read FTC SDK path from .weevil.toml"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Parse arguments
|
||||
USE_USB=false
|
||||
USE_WIFI=false
|
||||
CUSTOM_IP=""
|
||||
WIFI_TIMEOUT=5
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case $1 in
|
||||
--usb) USE_USB=true; shift ;;
|
||||
--wifi) USE_WIFI=true; shift ;;
|
||||
-i|--ip) CUSTOM_IP="$2"; USE_WIFI=true; shift 2 ;;
|
||||
--timeout) WIFI_TIMEOUT="$2"; shift 2 ;;
|
||||
*) echo "Unknown option: $1"; echo "Usage: $0 [--usb|--wifi] [-i IP] [--timeout SECONDS]"; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
echo "Building APK..."
|
||||
./gradlew buildApk
|
||||
|
||||
echo ""
|
||||
echo "Deploying to Control Hub..."
|
||||
|
||||
# Check for adb
|
||||
if ! command -v adb &> /dev/null; then
|
||||
echo "Error: adb not found. Install Android SDK platform-tools."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Find the APK in FTC SDK
|
||||
APK=$(find "$SDK_DIR" -path "*/outputs/apk/debug/*.apk" | head -1)
|
||||
|
||||
if [ -z "$APK" ]; then
|
||||
echo "Error: APK not found in $SDK_DIR"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Connection logic
|
||||
if [ "$USE_USB" = true ]; then
|
||||
echo "Using USB..."
|
||||
adb devices
|
||||
elif [ "$USE_WIFI" = true ]; then
|
||||
TARGET_IP="${CUSTOM_IP:-192.168.43.1}"
|
||||
echo "Connecting to $TARGET_IP..."
|
||||
timeout ${WIFI_TIMEOUT}s adb connect "$TARGET_IP:5555" || {
|
||||
echo "Failed to connect"
|
||||
exit 1
|
||||
}
|
||||
else
|
||||
# Auto-detect
|
||||
if adb devices | grep -q "device$"; then
|
||||
echo "Using USB (auto-detected)..."
|
||||
else
|
||||
echo "Trying WiFi..."
|
||||
timeout ${WIFI_TIMEOUT}s adb connect "192.168.43.1:5555" || {
|
||||
echo "No devices found"
|
||||
exit 1
|
||||
}
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "Installing: $APK"
|
||||
adb install -r "$APK"
|
||||
echo ""
|
||||
echo "✓ Deployed!"
|
||||
"#;
|
||||
fs::write(project_path.join("deploy.sh"), deploy_sh)?;
|
||||
|
||||
// deploy.bat
|
||||
let deploy_bat = r#"@echo off
|
||||
setlocal enabledelayedexpansion
|
||||
|
||||
REM Read SDK path from config
|
||||
for /f "tokens=2 delims==" %%a in ('findstr /c:"ftc_sdk_path" .weevil.toml') do (
|
||||
set SDK_DIR=%%a
|
||||
set SDK_DIR=!SDK_DIR:"=!
|
||||
set SDK_DIR=!SDK_DIR: =!
|
||||
)
|
||||
|
||||
if not defined SDK_DIR (
|
||||
echo Error: Could not read FTC SDK path from .weevil.toml
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo Building APK...
|
||||
call gradlew.bat buildApk
|
||||
|
||||
echo.
|
||||
echo Deploying to Control Hub...
|
||||
|
||||
REM Find APK
|
||||
for /f "delims=" %%i in ('dir /s /b "%SDK_DIR%\*app-debug.apk" 2^>nul') do set APK=%%i
|
||||
|
||||
if not defined APK (
|
||||
echo Error: APK not found
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo Installing: %APK%
|
||||
adb install -r "%APK%"
|
||||
|
||||
echo.
|
||||
echo Deployed!
|
||||
"#;
|
||||
fs::write(project_path.join("deploy.bat"), deploy_bat)?;
|
||||
|
||||
// Simple test file
|
||||
let test_file = r#"package robot;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class BasicTest {
|
||||
@Test
|
||||
void testBasic() {
|
||||
assertTrue(true, "Basic test should pass");
|
||||
}
|
||||
}
|
||||
"#;
|
||||
fs::write(
|
||||
project_path.join("src/test/java/robot/BasicTest.java"),
|
||||
test_file
|
||||
)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn setup_gradle(&self, project_path: &Path) -> Result<()> {
|
||||
println!("Setting up Gradle wrapper...");
|
||||
crate::sdk::gradle::setup_wrapper(project_path)?;
|
||||
println!("{} Gradle wrapper configured", "✓".green());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn init_git(&self, project_path: &Path) -> Result<()> {
|
||||
println!("Initializing git repository...");
|
||||
|
||||
let repo = Repository::init(project_path)
|
||||
.context("Failed to initialize git repository")?;
|
||||
|
||||
// Configure git
|
||||
let mut config = repo.config()?;
|
||||
|
||||
// Only set if not already set globally
|
||||
if config.get_string("user.email").is_err() {
|
||||
config.set_str("user.email", "robot@example.com")?;
|
||||
}
|
||||
if config.get_string("user.name").is_err() {
|
||||
config.set_str("user.name", "FTC Robot")?;
|
||||
}
|
||||
|
||||
// Initial commit
|
||||
let mut index = repo.index()?;
|
||||
index.add_all(["*"].iter(), git2::IndexAddOption::DEFAULT, None)?;
|
||||
index.write()?;
|
||||
|
||||
let tree_id = index.write_tree()?;
|
||||
let tree = repo.find_tree(tree_id)?;
|
||||
let signature = repo.signature()?;
|
||||
|
||||
repo.commit(
|
||||
Some("HEAD"),
|
||||
&signature,
|
||||
&signature,
|
||||
"Initial commit from Weevil",
|
||||
&tree,
|
||||
&[],
|
||||
)?;
|
||||
|
||||
println!("{} Git repository initialized", "✓".green());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn make_executable(&self, project_path: &Path) -> Result<()> {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let scripts = vec!["gradlew", "build.sh", "deploy.sh"];
|
||||
for script in scripts {
|
||||
let path = project_path.join(script);
|
||||
if path.exists() {
|
||||
let mut perms = fs::metadata(&path)?.permissions();
|
||||
perms.set_mode(0o755);
|
||||
fs::set_permissions(&path, perms)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user