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; const WEEVIL_VERSION: &str = env!("CARGO_PKG_VERSION"); 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 { 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", ".idea/runConfigurations", ]; 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", WEEVIL_VERSION); 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(), sdk_config.android_sdk_path.clone())?; project_config.save(project_path)?; // README.md let readme = format!( r#"# {} FTC Robot Project generated by Weevil v{} ## 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, WEEVIL_VERSION ); 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"), WEEVIL_VERSION)?; // build.gradle.kts - Pure Java with deployToSDK task // Escape backslashes for Windows paths in Kotlin strings let sdk_path = sdk_config.ftc_sdk_path.display().to_string().replace("\\", "\\\\"); let build_gradle = format!(r#"plugins {{ java }} 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("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("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_path, sdk_path); fs::write(project_path.join("build.gradle.kts"), build_gradle)?; // settings.gradle.kts - Repositories go here in Gradle 8+ let settings_gradle = format!(r#"rootProject.name = "{}" // Repository configuration (Gradle 8+ prefers repositories in settings) dependencyResolutionManagement {{ repositoriesMode.set(RepositoriesMode.PREFER_SETTINGS) repositories {{ mavenCentral() google() }} }} "#, 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 paths from config for /f "tokens=2 delims==" %%a in ('findstr /c:"ftc_sdk_path" .weevil.toml') do set SDK_DIR=%%a for /f "tokens=2 delims==" %%a in ('findstr /c:"android_sdk_path" .weevil.toml') do set ANDROID_SDK=%%a REM Strip all quotes (both single and double) set SDK_DIR=%SDK_DIR:"=% set SDK_DIR=%SDK_DIR:'=% set SDK_DIR=%SDK_DIR: =% set ANDROID_SDK=%ANDROID_SDK:"=% set ANDROID_SDK=%ANDROID_SDK:'=% set ANDROID_SDK=%ANDROID_SDK: =% if not defined SDK_DIR ( echo Error: Could not read FTC SDK path from .weevil.toml exit /b 1 ) if not defined ANDROID_SDK ( echo Error: Could not read Android SDK path from .weevil.toml exit /b 1 ) REM Set ADB path set ADB_PATH=%ANDROID_SDK%\platform-tools\adb.exe echo Building APK... call gradlew.bat buildApk echo. echo Deploying to Control Hub... REM Find APK - look for TeamCode-debug.apk for /f "delims=" %%i in ('dir /s /b "%SDK_DIR%\TeamCode-debug.apk" 2^>nul') do set APK=%%i if not defined APK ( echo Error: APK not found exit /b 1 ) echo Found APK: %APK% REM Check for adb if not exist "%ADB_PATH%" ( echo Error: adb not found at %ADB_PATH% echo Run: weevil sdk install exit /b 1 ) echo Installing: %APK% "%ADB_PATH%" install -r "%APK%" if errorlevel 1 ( echo. echo Deployment failed! exit /b 1 ) 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 )?; // Android Studio integration: .idea/ files self.generate_idea_files(project_path)?; Ok(()) } /// Generate .idea/ files for Android Studio integration. /// /// The goal is for students to open the project in Android Studio and see /// a clean file tree (just src/ and the scripts) with Run configurations /// that invoke Weevil's shell scripts directly. All the internal plumbing /// (sdk/, .gradle/, build/) is hidden from the IDE view. /// /// Android Studio uses IntelliJ's run configuration XML format. The /// ShellScript type invokes a script relative to the project root — exactly /// what we want since deploy.sh and build.sh already live there. fn generate_idea_files(&self, project_path: &Path) -> Result<()> { // workspace.xml — controls the file-tree view and hides internals. // We use a ProjectViewPane exclude pattern list rather than touching // the module's source roots, so this works regardless of whether the // student has opened the project before. let workspace_xml = r#" "#; fs::write(project_path.join(".idea/workspace.xml"), workspace_xml)?; // Run configurations. Each is a ShellScript type that invokes one of // Weevil's scripts. Android Studio shows these in the Run dropdown // at the top of the IDE — no configuration needed by the student. // // We generate both Unix (.sh, ./gradlew) and Windows (.bat, gradlew.bat) // variants. Android Studio automatically hides configs whose script files // don't exist, so only the platform-appropriate ones appear in the dropdown. // Build (Unix) — just builds the APK without deploying let build_unix_xml = r#" "#; fs::write( project_path.join(".idea/runConfigurations/Build.xml"), build_unix_xml, )?; // Build (Windows) — same, but calls build.bat let build_windows_xml = r#" "#; fs::write( project_path.join(".idea/runConfigurations/Build (Windows).xml"), build_windows_xml, )?; // Deploy (auto) — no flags, deploy.sh auto-detects USB vs WiFi let deploy_auto_xml = r#" "#; fs::write( project_path.join(".idea/runConfigurations/Deploy (auto).xml"), deploy_auto_xml, )?; // Deploy (auto) (Windows) let deploy_auto_windows_xml = r#" "#; fs::write( project_path.join(".idea/runConfigurations/Deploy (auto) (Windows).xml"), deploy_auto_windows_xml, )?; // Deploy (USB) — forces USB connection let deploy_usb_xml = r#" "#; fs::write( project_path.join(".idea/runConfigurations/Deploy (USB).xml"), deploy_usb_xml, )?; // Deploy (USB) (Windows) let deploy_usb_windows_xml = r#" "#; fs::write( project_path.join(".idea/runConfigurations/Deploy (USB) (Windows).xml"), deploy_usb_windows_xml, )?; // Deploy (WiFi) — forces WiFi connection to default 192.168.43.1 let deploy_wifi_xml = r#" "#; fs::write( project_path.join(".idea/runConfigurations/Deploy (WiFi).xml"), deploy_wifi_xml, )?; // Deploy (WiFi) (Windows) let deploy_wifi_windows_xml = r#" "#; fs::write( project_path.join(".idea/runConfigurations/Deploy (WiFi) (Windows).xml"), deploy_wifi_windows_xml, )?; // Test — runs the unit test suite via Gradle let test_xml = r#" "#; fs::write( project_path.join(".idea/runConfigurations/Test.xml"), test_xml, )?; // Test (Windows) let test_windows_xml = r#" "#; fs::write( project_path.join(".idea/runConfigurations/Test (Windows).xml"), test_windows_xml, )?; 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(()) } }