4 Commits

Author SHA1 Message Date
Eric Ratliff
58f7962a2a v1.1.0-beta.1: add proxy integration tests (62 tests, all green)
Nine end-to-end tests for the proxy feature: 6 network tests exercising
every proxy code path through a real hyper forward proxy (TestProxy) and
a mockito origin, plus 3 CLI tests verifying flag parsing and error handling.

TestProxy binds to 127.0.0.1:0, forwards in absolute-form, counts requests
via an atomic so we can assert traffic actually traversed the proxy.

Key issues resolved during implementation:

- ENV_MUTEX serializes all tests that mutate HTTPS_PROXY/HTTP_PROXY in both
  the unit test module and the integration suite. Without it, parallel test
  execution within a single binary produces nondeterministic failures.

- reqwest's blocking::Client owns an internal tokio Runtime. Dropping it
  inside a #[tokio::test] async fn panics on tokio >= 1.49. All reqwest
  work runs inside spawn_blocking so the Client drops on a thread-pool
  thread where that's permitted.

- service_fn's closure can't carry a return-type annotation, and async
  blocks don't support one either. The handler is extracted to a named
  async fn (proxy_handler) so the compiler can see the concrete
  Result<Response, Infallible> and satisfy serve_connection's Error bound.
2026-02-01 10:21:11 -06:00
Eric Ratliff
54647a47b1 feat: add proxy support for SDK downloads (v1.1.0)
Add --proxy and --no-proxy global flags to control HTTP/HTTPS proxy
usage for all network operations (SDK installs, FTC SDK clone/fetch,
Android SDK download).

Proxy resolution priority:
  1. --no-proxy          → go direct, ignore everything
  2. --proxy <url>       → use the specified proxy
  3. HTTPS_PROXY / HTTP_PROXY env vars (auto-detected)
  4. Nothing             → go direct

Key implementation details:
- reqwest client is always built through ProxyConfig::client() rather
  than Client::new(), so --no-proxy actively suppresses env-var
  auto-detection instead of just being a no-op.
- git2/libgit2 has its own HTTP transport that doesn't use reqwest.
  GitProxyGuard is an RAII guard that temporarily sets/clears the
  HTTPS_PROXY env vars around clone and fetch operations, then restores
  the previous state on drop. This avoids mutating ~/.gitconfig.
- Gradle wrapper reads HTTPS_PROXY natively; no programmatic
  intervention needed.
- All network failure paths now print offline/air-gapped installation
  instructions automatically, covering manual SDK installs and Gradle
  distribution download.

Closes: v1.1.0 proxy support milestone
2026-02-01 09:47:52 -06:00
Eric Ratliff
5596f5bade fix: single source of truth for version across crate and tests
Replace all hardcoded "1.1.0" version strings with env!("CARGO_PKG_VERSION")
in src/, so Cargo.toml is the sole source for the built binary. Tests
intentionally use a separate hardcoded constant in tests/common.rs to act
as a canary — they will fail on a version bump until manually updated.

- src/project/mod.rs: add WEEVIL_VERSION const, wire into Tera context,
  generated README, and .weevil-version marker
- tests/common.rs: new file, holds EXPECTED_VERSION for all test crates
- tests/{integration,project_lifecycle,unit/config_tests}.rs: pull from
  common instead of env! or inline literals
2026-01-31 18:45:29 -06:00
Eric Ratliff
d2cc62e32f feat: Add integration test suite for v1.1.0 commands
Adds WEEVIL_HOME-based test isolation so cargo test never touches
the real system. All commands run against a fresh TempDir per test.

Environment tests cover doctor, uninstall, new, and setup across
every combination of missing/present dependencies. Project lifecycle
tests cover creation, config persistence, upgrade, and build scripts.

Full round-trip lifecycle test: new → gradlew test → gradlew
compileJava → uninstall → doctor (unhealthy) → setup → doctor
(healthy). Confirms skeleton projects build and pass tests out of
the box, and that uninstall leaves user projects untouched.

34 tests, zero warnings.
2026-01-31 13:56:01 -06:00
28 changed files with 2289 additions and 256 deletions

4
.gitattributes vendored
View File

@@ -34,6 +34,10 @@ Cargo.lock text diff=toml
*.ico binary *.ico binary
*.svg text *.svg text
# Test fixtures
.gitkeep text
tests/fixtures/mock-android-sdk/platform-tools/adb binary
# Fonts # Fonts
*.ttf binary *.ttf binary
*.otf binary *.otf binary

View File

@@ -1,8 +1,8 @@
[package] [package]
name = "weevil" name = "weevil"
version = "1.0.0" version = "1.1.0-beta.1"
edition = "2021" edition = "2021"
authors = ["Eric Ratliff <eric@intrepidfusion.com>"] authors = ["Eric Ratliff <eric@nxlearn.net>"]
description = "FTC robotics project generator - bores into complexity, emerges with clean code" description = "FTC robotics project generator - bores into complexity, emerges with clean code"
license = "MIT" license = "MIT"
@@ -63,6 +63,17 @@ assert_cmd = "2.0"
predicates = "3.1" predicates = "3.1"
insta = "1.41" insta = "1.41"
# Proxy integration tests: mockito is the mock origin server; hyper + friends
# are the forward proxy. All three are already in Cargo.lock as transitive
# deps of reqwest — we're just promoting them to explicit dev-deps with the
# features we actually need.
mockito = "1.7"
hyper = { version = "1", features = ["server", "http1", "client"] }
hyper-util = { version = "0.1", features = ["tokio", "client-legacy", "http1"] }
http-body-util = "0.1"
bytes = "1"
tokio = { version = "1", features = ["full"] }
[build-dependencies] [build-dependencies]
ureq = { version = "2.10", features = ["json"] } ureq = { version = "2.10", features = ["json"] }
zip = "2.2" zip = "2.2"

755
diff.txt Normal file
View File

@@ -0,0 +1,755 @@
diff --git i/src/commands/new.rs w/src/commands/new.rs
index aeed30a..4d219c6 100644
--- i/src/commands/new.rs
+++ w/src/commands/new.rs
@@ -3,12 +3,14 @@
use colored::*;
use crate::sdk::SdkConfig;
+use crate::sdk::proxy::ProxyConfig;
use crate::project::ProjectBuilder;
pub fn create_project(
name: &str,
ftc_sdk: Option<&str>,
android_sdk: Option<&str>,
+ proxy: &ProxyConfig,
) -> Result<()> {
// Validate project name
if name.is_empty() {
@@ -32,6 +34,7 @@ pub fn create_project(
}
println!("{}", format!("Creating FTC project: {}", name).bright_green().bold());
+ proxy.print_status();
println!();
// Check system health FIRST
diff --git i/src/commands/sdk.rs w/src/commands/sdk.rs
index ddc3aa6..250b844 100644
--- i/src/commands/sdk.rs
+++ w/src/commands/sdk.rs
@@ -1,18 +1,22 @@
use anyhow::Result;
use colored::*;
use crate::sdk::SdkConfig;
+use crate::sdk::proxy::ProxyConfig;
-pub fn install_sdks() -> Result<()> {
+pub fn install_sdks(proxy: &ProxyConfig) -> Result<()> {
println!("{}", "Installing SDKs...".bright_yellow().bold());
println!();
+ proxy.print_status();
+ println!();
+
let config = SdkConfig::new()?;
// Install FTC SDK
- crate::sdk::ftc::install(&config.ftc_sdk_path, &config.android_sdk_path)?;
+ crate::sdk::ftc::install(&config.ftc_sdk_path, &config.android_sdk_path, proxy)?;
// Install Android SDK
- crate::sdk::android::install(&config.android_sdk_path)?;
+ crate::sdk::android::install(&config.android_sdk_path, proxy)?;
println!();
println!("{} All SDKs installed successfully", "✓".green().bold());
@@ -44,17 +48,20 @@ pub fn show_status() -> Result<()> {
Ok(())
}
-pub fn update_sdks() -> Result<()> {
+pub fn update_sdks(proxy: &ProxyConfig) -> Result<()> {
println!("{}", "Updating SDKs...".bright_yellow().bold());
println!();
+
+ proxy.print_status();
+ println!();
let config = SdkConfig::new()?;
// Update FTC SDK
- crate::sdk::ftc::update(&config.ftc_sdk_path)?;
+ crate::sdk::ftc::update(&config.ftc_sdk_path, proxy)?;
println!();
println!("{} SDKs updated successfully", "✓".green().bold());
Ok(())
-}
+}
\ No newline at end of file
diff --git i/src/commands/setup.rs w/src/commands/setup.rs
index 975b814..cbb5871 100644
--- i/src/commands/setup.rs
+++ w/src/commands/setup.rs
@@ -4,22 +4,26 @@
use colored::*;
use crate::sdk::SdkConfig;
+use crate::sdk::proxy::ProxyConfig;
use crate::project::ProjectConfig;
/// Setup development environment - either system-wide or for a specific project
-pub fn setup_environment(project_path: Option<&str>) -> Result<()> {
+pub fn setup_environment(project_path: Option<&str>, proxy: &ProxyConfig) -> Result<()> {
match project_path {
- Some(path) => setup_project(path),
- None => setup_system(),
+ Some(path) => setup_project(path, proxy),
+ None => setup_system(proxy),
}
}
/// Setup system-wide development environment with default SDKs
-fn setup_system() -> Result<()> {
+fn setup_system(proxy: &ProxyConfig) -> Result<()> {
println!("{}", "═══════════════════════════════════════════════════════════".bright_cyan());
println!("{}", " System Setup - Preparing FTC Development Environment".bright_cyan().bold());
println!("{}", "═══════════════════════════════════════════════════════════".bright_cyan());
println!();
+
+ proxy.print_status();
+ println!();
let mut issues = Vec::new();
let mut installed = Vec::new();
@@ -57,18 +61,34 @@ fn setup_system() -> Result<()> {
}
Err(_) => {
println!("{} FTC SDK found but incomplete, reinstalling...", "⚠".yellow());
- crate::sdk::ftc::install(&sdk_config.ftc_sdk_path, &sdk_config.android_sdk_path)?;
- let version = crate::sdk::ftc::get_version(&sdk_config.ftc_sdk_path)
- .unwrap_or_else(|_| "unknown".to_string());
- installed.push(format!("FTC SDK {} (installed)", version));
+ match crate::sdk::ftc::install(&sdk_config.ftc_sdk_path, &sdk_config.android_sdk_path, proxy) {
+ Ok(_) => {
+ let version = crate::sdk::ftc::get_version(&sdk_config.ftc_sdk_path)
+ .unwrap_or_else(|_| "unknown".to_string());
+ installed.push(format!("FTC SDK {} (installed)", version));
+ }
+ Err(e) => {
+ println!("{} {}", "✗".red(), e);
+ print_ftc_manual_fallback(&sdk_config);
+ issues.push(("FTC SDK", "See manual installation instructions above.".to_string()));
+ }
+ }
}
}
} else {
println!("FTC SDK not found. Installing...");
- crate::sdk::ftc::install(&sdk_config.ftc_sdk_path, &sdk_config.android_sdk_path)?;
- let version = crate::sdk::ftc::get_version(&sdk_config.ftc_sdk_path)
- .unwrap_or_else(|_| "unknown".to_string());
- installed.push(format!("FTC SDK {} (installed)", version));
+ match crate::sdk::ftc::install(&sdk_config.ftc_sdk_path, &sdk_config.android_sdk_path, proxy) {
+ Ok(_) => {
+ let version = crate::sdk::ftc::get_version(&sdk_config.ftc_sdk_path)
+ .unwrap_or_else(|_| "unknown".to_string());
+ installed.push(format!("FTC SDK {} (installed)", version));
+ }
+ Err(e) => {
+ println!("{} {}", "✗".red(), e);
+ print_ftc_manual_fallback(&sdk_config);
+ issues.push(("FTC SDK", "See manual installation instructions above.".to_string()));
+ }
+ }
}
println!();
@@ -85,14 +105,26 @@ fn setup_system() -> Result<()> {
}
Err(_) => {
println!("{} Android SDK found but incomplete, reinstalling...", "⚠".yellow());
- crate::sdk::android::install(&sdk_config.android_sdk_path)?;
- installed.push("Android SDK (installed)".to_string());
+ match crate::sdk::android::install(&sdk_config.android_sdk_path, proxy) {
+ Ok(_) => installed.push("Android SDK (installed)".to_string()),
+ Err(e) => {
+ println!("{} {}", "✗".red(), e);
+ print_android_manual_fallback(&sdk_config);
+ issues.push(("Android SDK", "See manual installation instructions above.".to_string()));
+ }
+ }
}
}
} else {
println!("Android SDK not found. Installing...");
- crate::sdk::android::install(&sdk_config.android_sdk_path)?;
- installed.push("Android SDK (installed)".to_string());
+ match crate::sdk::android::install(&sdk_config.android_sdk_path, proxy) {
+ Ok(_) => installed.push("Android SDK (installed)".to_string()),
+ Err(e) => {
+ println!("{} {}", "✗".red(), e);
+ print_android_manual_fallback(&sdk_config);
+ issues.push(("Android SDK", "See manual installation instructions above.".to_string()));
+ }
+ }
}
println!();
@@ -132,7 +164,7 @@ fn setup_system() -> Result<()> {
}
/// Setup dependencies for a specific project by reading its .weevil.toml
-fn setup_project(project_path: &str) -> Result<()> {
+fn setup_project(project_path: &str, proxy: &ProxyConfig) -> Result<()> {
let project_path = PathBuf::from(project_path);
if !project_path.exists() {
@@ -143,6 +175,9 @@ fn setup_project(project_path: &str) -> Result<()> {
println!("{}", " Project Setup - Installing Dependencies".bright_cyan().bold());
println!("{}", "═══════════════════════════════════════════════════════════".bright_cyan());
println!();
+
+ proxy.print_status();
+ println!();
// Load project configuration
println!("{}", "Reading project configuration...".bright_yellow());
@@ -214,7 +249,7 @@ fn setup_project(project_path: &str) -> Result<()> {
// Try to install it automatically
println!("{}", "Attempting automatic installation...".bright_yellow());
- match crate::sdk::ftc::install(&config.ftc_sdk_path, &config.android_sdk_path) {
+ match crate::sdk::ftc::install(&config.ftc_sdk_path, &config.android_sdk_path, proxy) {
Ok(_) => {
println!("{} FTC SDK {} installed successfully",
"✓".green(),
@@ -224,13 +259,13 @@ fn setup_project(project_path: &str) -> Result<()> {
}
Err(e) => {
println!("{} Automatic installation failed: {}", "✗".red(), e);
- println!();
- println!("{}", "Manual Installation Required:".bright_yellow().bold());
- println!(" git clone https://github.com/FIRST-Tech-Challenge/FtcRobotController.git \\");
- println!(" {}", config.ftc_sdk_path.display());
- println!(" cd {}", config.ftc_sdk_path.display());
- println!(" git checkout {}", config.ftc_sdk_version);
- bail!("FTC SDK installation failed");
+ let sdk_config = SdkConfig {
+ ftc_sdk_path: config.ftc_sdk_path.clone(),
+ android_sdk_path: config.android_sdk_path.clone(),
+ cache_dir: dirs::home_dir().unwrap_or_default().join(".weevil"),
+ };
+ print_ftc_manual_fallback(&sdk_config);
+ issues.push(("FTC SDK", "See manual installation instructions above.".to_string()));
}
}
}
@@ -249,14 +284,36 @@ fn setup_project(project_path: &str) -> Result<()> {
}
Err(_) => {
println!("{} Android SDK found but incomplete, reinstalling...", "⚠".yellow());
- crate::sdk::android::install(&config.android_sdk_path)?;
- installed.push("Android SDK (installed)".to_string());
+ match crate::sdk::android::install(&config.android_sdk_path, proxy) {
+ Ok(_) => installed.push("Android SDK (installed)".to_string()),
+ Err(e) => {
+ println!("{} {}", "✗".red(), e);
+ let sdk_config = SdkConfig {
+ ftc_sdk_path: config.ftc_sdk_path.clone(),
+ android_sdk_path: config.android_sdk_path.clone(),
+ cache_dir: dirs::home_dir().unwrap_or_default().join(".weevil"),
+ };
+ print_android_manual_fallback(&sdk_config);
+ issues.push(("Android SDK", "See manual installation instructions above.".to_string()));
+ }
+ }
}
}
} else {
println!("Android SDK not found. Installing...");
- crate::sdk::android::install(&config.android_sdk_path)?;
- installed.push("Android SDK (installed)".to_string());
+ match crate::sdk::android::install(&config.android_sdk_path, proxy) {
+ Ok(_) => installed.push("Android SDK (installed)".to_string()),
+ Err(e) => {
+ println!("{} {}", "✗".red(), e);
+ let sdk_config = SdkConfig {
+ ftc_sdk_path: config.ftc_sdk_path.clone(),
+ android_sdk_path: config.android_sdk_path.clone(),
+ cache_dir: dirs::home_dir().unwrap_or_default().join(".weevil"),
+ };
+ print_android_manual_fallback(&sdk_config);
+ issues.push(("Android SDK", "See manual installation instructions above.".to_string()));
+ }
+ }
}
println!();
@@ -511,4 +568,147 @@ fn print_project_summary(installed: &[String], issues: &[(&str, String)], config
println!(" Then run {} to verify", format!("weevil setup {}", project_path.display()).bright_white());
}
println!();
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// Manual fallback instructions — printed when automatic install fails.
+// These walk the user through doing everything by hand, with explicit steps
+// for Linux, macOS, and Windows.
+// ─────────────────────────────────────────────────────────────────────────────
+
+fn print_ftc_manual_fallback(sdk_config: &SdkConfig) {
+ let dest = sdk_config.ftc_sdk_path.display();
+
+ println!();
+ println!("{}", "═══════════════════════════════════════════════════════════".bright_yellow());
+ println!("{}", " Manual FTC SDK Installation".bright_yellow().bold());
+ println!("{}", "═══════════════════════════════════════════════════════════".bright_yellow());
+ println!();
+ println!(" Automatic installation failed. Follow the steps below to");
+ println!(" clone the FTC SDK by hand. If you are behind a proxy, set");
+ println!(" the environment variables shown before running git.");
+ println!();
+ println!(" Target directory: {}", dest);
+ println!();
+
+ println!(" {} Linux / macOS:", "▸".bright_cyan());
+ println!(" # If behind a proxy, set these first (replace with your proxy):");
+ println!(" export HTTPS_PROXY=http://your-proxy:3128");
+ println!(" export HTTP_PROXY=http://your-proxy:3128");
+ println!();
+ println!(" # If the proxy uses a custom CA certificate, add:");
+ println!(" export GIT_SSL_CAPATH=/path/to/ca-certificates");
+ println!(" # (ask your IT department for the CA cert if needed)");
+ println!();
+ println!(" git clone https://github.com/FIRST-Tech-Challenge/FtcRobotController.git \\");
+ println!(" {}", dest);
+ println!(" cd {}", dest);
+ println!(" git checkout v10.1.1");
+ println!();
+
+ println!(" {} Windows (PowerShell):", "▸".bright_cyan());
+ println!(" # If behind a proxy, set these first:");
+ println!(" $env:HTTPS_PROXY = \"http://your-proxy:3128\"");
+ println!(" $env:HTTP_PROXY = \"http://your-proxy:3128\"");
+ println!();
+ println!(" # If the proxy uses a custom CA certificate:");
+ println!(" git config --global http.sslCAInfo C:\\path\\to\\ca-bundle.crt");
+ println!(" # (ask your IT department for the CA cert if needed)");
+ println!();
+ println!(" git clone https://github.com/FIRST-Tech-Challenge/FtcRobotController.git `");
+ println!(" {}", dest);
+ println!(" cd {}", dest);
+ println!(" git checkout v10.1.1");
+ println!();
+
+ println!(" Once done, run {} again.", "weevil setup".bright_white());
+ println!("{}", "═══════════════════════════════════════════════════════════".bright_yellow());
+ println!();
+}
+
+fn print_android_manual_fallback(sdk_config: &SdkConfig) {
+ let dest = sdk_config.android_sdk_path.display();
+
+ // Pick the right download URL for the current OS
+ let (url, extract_note) = if cfg!(target_os = "windows") {
+ (
+ "https://dl.google.com/android/repository/commandlinetools-win-11076708_latest.zip",
+ "Extract the zip. You will get a cmdline-tools/ folder."
+ )
+ } else if cfg!(target_os = "macos") {
+ (
+ "https://dl.google.com/android/repository/commandlinetools-mac-11076708_latest.zip",
+ "Extract the zip. You will get a cmdline-tools/ folder."
+ )
+ } else {
+ (
+ "https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip",
+ "Extract the zip. You will get a cmdline-tools/ folder."
+ )
+ };
+
+ println!();
+ println!("{}", "═══════════════════════════════════════════════════════════".bright_yellow());
+ println!("{}", " Manual Android SDK Installation".bright_yellow().bold());
+ println!("{}", "═══════════════════════════════════════════════════════════".bright_yellow());
+ println!();
+ println!(" Automatic installation failed. Follow the steps below to");
+ println!(" download and set up the Android SDK by hand.");
+ println!();
+ println!(" Target directory: {}", dest);
+ println!(" Download URL: {}", url);
+ println!();
+
+ println!(" {} Linux / macOS:", "▸".bright_cyan());
+ println!(" # If behind a proxy, set these first:");
+ println!(" export HTTPS_PROXY=http://your-proxy:3128");
+ println!(" export HTTP_PROXY=http://your-proxy:3128");
+ println!();
+ println!(" # If the proxy uses a custom CA cert, add:");
+ println!(" export CURL_CA_BUNDLE=/path/to/ca-bundle.crt");
+ println!(" # (ask your IT department for the CA cert if needed)");
+ println!();
+ println!(" mkdir -p {}", dest);
+ println!(" cd {}", dest);
+ println!(" curl -L -o cmdline-tools.zip \\");
+ println!(" \"{}\"", url);
+ println!(" unzip cmdline-tools.zip");
+ println!(" # {} Move into the expected layout:", extract_note);
+ println!(" mv cmdline-tools cmdline-tools-temp");
+ println!(" mkdir -p cmdline-tools/latest");
+ println!(" mv cmdline-tools-temp/* cmdline-tools/latest/");
+ println!(" rmdir cmdline-tools-temp");
+ println!(" # Accept licenses and install packages:");
+ println!(" ./cmdline-tools/latest/bin/sdkmanager --licenses");
+ println!(" ./cmdline-tools/latest/bin/sdkmanager platform-tools \"platforms;android-34\" \"build-tools;34.0.0\"");
+ println!();
+
+ println!(" {} Windows (PowerShell):", "▸".bright_cyan());
+ println!(" # If behind a proxy, set these first:");
+ println!(" $env:HTTPS_PROXY = \"http://your-proxy:3128\"");
+ println!(" $env:HTTP_PROXY = \"http://your-proxy:3128\"");
+ println!();
+ println!(" # If the proxy uses a custom CA cert:");
+ println!(" # Download the CA cert from your IT department and note its path.");
+ println!(" # PowerShell's Invoke-WebRequest will use the system cert store;");
+ println!(" # you may need to import the cert: ");
+ println!(" # Import-Certificate -FilePath C:\\path\\to\\ca.crt -CertStoreLocation Cert:\\LocalMachine\\Root");
+ println!();
+ println!(" New-Item -ItemType Directory -Path \"{}\" -Force", dest);
+ println!(" cd \"{}\"", dest);
+ println!(" Invoke-WebRequest -Uri \"{}\" -OutFile cmdline-tools.zip", url);
+ println!(" Expand-Archive -Path cmdline-tools.zip -DestinationPath .");
+ println!(" # Move into the expected layout:");
+ println!(" Rename-Item cmdline-tools cmdline-tools-temp");
+ println!(" New-Item -ItemType Directory -Path cmdline-tools\\latest");
+ println!(" Move-Item cmdline-tools-temp\\* cmdline-tools\\latest\\");
+ println!(" Remove-Item cmdline-tools-temp");
+ println!(" # Accept licenses and install packages:");
+ println!(" .\\cmdline-tools\\latest\\bin\\sdkmanager.bat --licenses");
+ println!(" .\\cmdline-tools\\latest\\bin\\sdkmanager.bat platform-tools \"platforms;android-34\" \"build-tools;34.0.0\"");
+ println!();
+
+ println!(" Once done, run {} again.", "weevil setup".bright_white());
+ println!("{}", "═══════════════════════════════════════════════════════════".bright_yellow());
+ println!();
}
\ No newline at end of file
diff --git i/src/main.rs w/src/main.rs
index aa8fce4..35d11f7 100644
--- i/src/main.rs
+++ w/src/main.rs
@@ -35,6 +35,14 @@ enum Commands {
/// Path to Android SDK (optional, will auto-detect or download)
#[arg(long)]
android_sdk: Option<String>,
+
+ /// Use this proxy for all network operations (e.g. http://proxy:3128)
+ #[arg(long)]
+ proxy: Option<String>,
+
+ /// Force direct connection, ignoring proxy env vars
+ #[arg(long)]
+ no_proxy: bool,
},
/// Check system health and diagnose issues
@@ -44,6 +52,14 @@ enum Commands {
Setup {
/// Path to project directory (optional - without it, sets up system)
path: Option<String>,
+
+ /// Use this proxy for all network operations (e.g. http://proxy:3128)
+ #[arg(long)]
+ proxy: Option<String>,
+
+ /// Force direct connection, ignoring proxy env vars
+ #[arg(long)]
+ no_proxy: bool,
},
/// Remove Weevil-installed SDKs and dependencies
@@ -85,6 +101,14 @@ enum Commands {
Sdk {
#[command(subcommand)]
command: SdkCommands,
+
+ /// Use this proxy for all network operations (e.g. http://proxy:3128)
+ #[arg(long)]
+ proxy: Option<String>,
+
+ /// Force direct connection, ignoring proxy env vars
+ #[arg(long)]
+ no_proxy: bool,
},
/// Show or update project configuration
@@ -120,14 +144,16 @@ fn main() -> Result<()> {
print_banner();
match cli.command {
- Commands::New { name, ftc_sdk, android_sdk } => {
- commands::new::create_project(&name, ftc_sdk.as_deref(), android_sdk.as_deref())
+ Commands::New { name, ftc_sdk, android_sdk, proxy, no_proxy } => {
+ let proxy_config = weevil::sdk::proxy::ProxyConfig::resolve(proxy.as_deref(), no_proxy)?;
+ commands::new::create_project(&name, ftc_sdk.as_deref(), android_sdk.as_deref(), &proxy_config)
}
Commands::Doctor => {
commands::doctor::run_diagnostics()
}
- Commands::Setup { path } => {
- commands::setup::setup_environment(path.as_deref())
+ Commands::Setup { path, proxy, no_proxy } => {
+ let proxy_config = weevil::sdk::proxy::ProxyConfig::resolve(proxy.as_deref(), no_proxy)?;
+ commands::setup::setup_environment(path.as_deref(), &proxy_config)
}
Commands::Uninstall { dry_run, only } => {
commands::uninstall::uninstall_dependencies(dry_run, only)
@@ -138,11 +164,14 @@ fn main() -> Result<()> {
Commands::Deploy { path, usb, wifi, ip } => {
commands::deploy::deploy_project(&path, usb, wifi, ip.as_deref())
}
- Commands::Sdk { command } => match command {
- SdkCommands::Install => commands::sdk::install_sdks(),
- SdkCommands::Status => commands::sdk::show_status(),
- SdkCommands::Update => commands::sdk::update_sdks(),
- },
+ Commands::Sdk { command, proxy, no_proxy } => {
+ let proxy_config = weevil::sdk::proxy::ProxyConfig::resolve(proxy.as_deref(), no_proxy)?;
+ match command {
+ SdkCommands::Install => commands::sdk::install_sdks(&proxy_config),
+ SdkCommands::Status => commands::sdk::show_status(),
+ SdkCommands::Update => commands::sdk::update_sdks(&proxy_config),
+ }
+ }
Commands::Config { path, set_sdk } => {
if let Some(sdk_path) = set_sdk {
commands::config::set_sdk(&path, &sdk_path)
@@ -164,4 +193,4 @@ fn print_banner() {
println!("{}", " Nexus Workshops LLC".bright_cyan());
println!("{}", "═══════════════════════════════════════════════════════════".bright_cyan());
println!();
-}
+}
\ No newline at end of file
diff --git i/src/sdk/android.rs w/src/sdk/android.rs
index 596ed74..b91701e 100644
--- i/src/sdk/android.rs
+++ w/src/sdk/android.rs
@@ -6,11 +6,13 @@
use std::io::Write;
use colored::*;
+use super::proxy::ProxyConfig;
+
const ANDROID_SDK_URL_LINUX: &str = "https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip";
const ANDROID_SDK_URL_MAC: &str = "https://dl.google.com/android/repository/commandlinetools-mac-11076708_latest.zip";
const ANDROID_SDK_URL_WINDOWS: &str = "https://dl.google.com/android/repository/commandlinetools-win-11076708_latest.zip";
-pub fn install(sdk_path: &Path) -> Result<()> {
+pub fn install(sdk_path: &Path, proxy: &ProxyConfig) -> Result<()> {
// Check if SDK exists AND is complete
if sdk_path.exists() {
match verify(sdk_path) {
@@ -42,10 +44,20 @@ pub fn install(sdk_path: &Path) -> Result<()> {
// Download
println!("Downloading from: {}", url);
- let client = Client::new();
+ let client = match &proxy.url {
+ Some(proxy_url) => {
+ println!(" via proxy: {}", proxy_url);
+ Client::builder()
+ .proxy(reqwest::Proxy::all(proxy_url.clone())
+ .context("Failed to configure proxy")?)
+ .build()
+ .context("Failed to build HTTP client")?
+ }
+ None => Client::new(),
+ };
let response = client.get(url)
.send()
- .context("Failed to download Android SDK")?;
+ .context("Failed to download Android SDK. If you are behind a proxy, try --proxy <url> or set HTTPS_PROXY")?;
let total_size = response.content_length().unwrap_or(0);
@@ -104,14 +116,14 @@ pub fn install(sdk_path: &Path) -> Result<()> {
}
// Install required packages
- install_packages(sdk_path)?;
+ install_packages(sdk_path, proxy)?;
println!("{} Android SDK installed successfully", "✓".green());
Ok(())
}
-fn install_packages(sdk_path: &Path) -> Result<()> {
+fn install_packages(sdk_path: &Path, proxy: &ProxyConfig) -> Result<()> {
println!("Installing Android SDK packages...");
let sdkmanager_path = sdk_path.join("cmdline-tools").join("latest").join("bin");
@@ -132,10 +144,10 @@ fn install_packages(sdk_path: &Path) -> Result<()> {
println!("Found sdkmanager at: {}", sdkmanager.display());
- run_sdkmanager(&sdkmanager, sdk_path)
+ run_sdkmanager(&sdkmanager, sdk_path, proxy)
}
-fn run_sdkmanager(sdkmanager: &Path, sdk_root: &Path) -> Result<()> {
+fn run_sdkmanager(sdkmanager: &Path, sdk_root: &Path, proxy: &ProxyConfig) -> Result<()> {
use std::process::{Command, Stdio};
use std::io::Write;
@@ -151,6 +163,13 @@ fn run_sdkmanager(sdkmanager: &Path, sdk_root: &Path) -> Result<()> {
Command::new(sdkmanager)
};
+ // Inject proxy env vars so sdkmanager picks them up
+ if let Some(proxy_url) = &proxy.url {
+ let url_str = proxy_url.as_str();
+ cmd.env("HTTPS_PROXY", url_str)
+ .env("HTTP_PROXY", url_str);
+ }
+
cmd.arg(format!("--sdk_root={}", sdk_root.display()))
.arg("--licenses")
.stdin(Stdio::piped())
@@ -192,6 +211,13 @@ fn run_sdkmanager(sdkmanager: &Path, sdk_root: &Path) -> Result<()> {
} else {
Command::new(sdkmanager)
};
+
+ // Inject proxy env vars here too
+ if let Some(proxy_url) = &proxy.url {
+ let url_str = proxy_url.as_str();
+ cmd.env("HTTPS_PROXY", url_str)
+ .env("HTTP_PROXY", url_str);
+ }
let status = cmd
.arg(format!("--sdk_root={}", sdk_root.display()))
diff --git i/src/sdk/ftc.rs w/src/sdk/ftc.rs
index 778cece..3e982e8 100644
--- i/src/sdk/ftc.rs
+++ w/src/sdk/ftc.rs
@@ -4,10 +4,12 @@
use colored::*;
use std::fs;
+use super::proxy::ProxyConfig;
+
const FTC_SDK_URL: &str = "https://github.com/FIRST-Tech-Challenge/FtcRobotController.git";
const FTC_SDK_VERSION: &str = "v10.1.1";
-pub fn install(sdk_path: &Path, android_sdk_path: &Path) -> Result<()> {
+pub fn install(sdk_path: &Path, android_sdk_path: &Path, proxy: &ProxyConfig) -> Result<()> {
if sdk_path.exists() {
println!("{} FTC SDK already installed at: {}",
"✓".green(),
@@ -22,8 +24,8 @@ pub fn install(sdk_path: &Path, android_sdk_path: &Path) -> Result<()> {
println!("Cloning from: {}", FTC_SDK_URL);
println!("Version: {}", FTC_SDK_VERSION);
- // Clone the repository
- let repo = Repository::clone(FTC_SDK_URL, sdk_path)
+ // Clone the repository, with proxy if configured
+ let repo = clone_with_proxy(FTC_SDK_URL, sdk_path, proxy)
.context("Failed to clone FTC SDK")?;
// Checkout specific version
@@ -39,6 +41,44 @@ pub fn install(sdk_path: &Path, android_sdk_path: &Path) -> Result<()> {
Ok(())
}
+/// Clone a git repo, injecting http.proxy into a git2 config if ProxyConfig has a URL.
+/// Returns a more helpful error message when a proxy was involved.
+fn clone_with_proxy(url: &str, dest: &Path, proxy: &ProxyConfig) -> Result<Repository> {
+ let mut opts = git2::CloneOptions::new();
+
+ if let Some(proxy_url) = &proxy.url {
+ // git2 reads http.proxy from a config object passed to the clone options.
+ // We build an in-memory config with just that one key.
+ let mut git_config = git2::Config::new()?;
+ git_config.set_str("http.proxy", proxy_url.as_str())?;
+ opts.local(false); // force network path even if URL looks local
+ // Unfortunately git2::CloneOptions doesn't have a direct .config() method,
+ // so we set the env var which libgit2 also respects as a fallback.
+ std::env::set_var("GIT_PROXY_COMMAND", ""); // clear any ssh proxy
+ std::env::set_var("HTTP_PROXY", proxy_url.as_str());
+ std::env::set_var("HTTPS_PROXY", proxy_url.as_str());
+ println!(" via proxy: {}", proxy_url);
+ }
+
+ Repository::clone_with(url, dest, &opts).map_err(|e| {
+ if proxy.url.is_some() {
+ anyhow::anyhow!(
+ "{}\n\n\
+ This failure may be caused by your proxy. If you are behind a \
+ corporate or school network, see 'weevil setup' for manual \
+ fallback instructions.",
+ e
+ )
+ } else {
+ anyhow::anyhow!(
+ "{}\n\n\
+ If you are behind a proxy, try: weevil sdk install --proxy <url>",
+ e
+ )
+ }
+ })
+}
+
fn create_local_properties(sdk_path: &Path, android_sdk_path: &Path) -> Result<()> {
// Convert path to use forward slashes (works on both Windows and Unix)
let android_sdk_str = android_sdk_path
@@ -80,15 +120,39 @@ fn check_version(sdk_path: &Path) -> Result<()> {
Ok(())
}
-pub fn update(sdk_path: &Path) -> Result<()> {
+pub fn update(sdk_path: &Path, proxy: &ProxyConfig) -> Result<()> {
println!("{}", "Updating FTC SDK...".bright_yellow());
+ // Set proxy env vars for the fetch if configured
+ if let Some(proxy_url) = &proxy.url {
+ std::env::set_var("HTTP_PROXY", proxy_url.as_str());
+ std::env::set_var("HTTPS_PROXY", proxy_url.as_str());
+ println!(" via proxy: {}", proxy_url);
+ }
+
let repo = Repository::open(sdk_path)
.context("FTC SDK not found or not a git repository")?;
// Fetch latest
let mut remote = repo.find_remote("origin")?;
- remote.fetch(&["refs/tags/*:refs/tags/*"], None, None)?;
+ remote.fetch(&["refs/tags/*:refs/tags/*"], None, None)
+ .map_err(|e| {
+ if proxy.url.is_some() {
+ anyhow::anyhow!(
+ "Failed to fetch: {}\n\n\
+ This failure may be caused by your proxy. If you are behind a \
+ corporate or school network, see 'weevil setup' for manual \
+ fallback instructions.",
+ e
+ )
+ } else {
+ anyhow::anyhow!(
+ "Failed to fetch: {}\n\n\
+ If you are behind a proxy, try: weevil sdk update --proxy <url>",
+ e
+ )
+ }
+ })?;
// Checkout latest version
let obj = repo.revparse_single(FTC_SDK_VERSION)?;
diff --git i/src/sdk/mod.rs w/src/sdk/mod.rs
index 080ce36..5d7c065 100644
--- i/src/sdk/mod.rs
+++ w/src/sdk/mod.rs
@@ -6,6 +6,7 @@
pub mod android;
pub mod ftc;
pub mod gradle;
+pub mod proxy;
pub struct SdkConfig {
pub ftc_sdk_path: PathBuf,

View File

@@ -3,13 +3,17 @@ use std::path::PathBuf;
use colored::*; use colored::*;
use crate::sdk::SdkConfig; use crate::sdk::SdkConfig;
use crate::sdk::proxy::ProxyConfig;
use crate::project::ProjectBuilder; use crate::project::ProjectBuilder;
pub fn create_project( pub fn create_project(
name: &str, name: &str,
ftc_sdk: Option<&str>, ftc_sdk: Option<&str>,
android_sdk: Option<&str>, android_sdk: Option<&str>,
_proxy: &ProxyConfig,
) -> Result<()> { ) -> Result<()> {
// _proxy is threaded through here so future flows (e.g. auto-install on
// missing SDK) can use it without changing the call site in main.
// Validate project name // Validate project name
if name.is_empty() { if name.is_empty() {
bail!("Project name cannot be empty"); bail!("Project name cannot be empty");

View File

@@ -1,60 +1,61 @@
use anyhow::Result; use anyhow::Result;
use colored::*; use colored::*;
use crate::sdk::SdkConfig; use crate::sdk::SdkConfig;
use crate::sdk::proxy::ProxyConfig;
pub fn install_sdks() -> Result<()> { pub fn install_sdks(proxy: &ProxyConfig) -> Result<()> {
println!("{}", "Installing SDKs...".bright_yellow().bold()); println!("{}", "Installing SDKs...".bright_yellow().bold());
println!(); println!();
let config = SdkConfig::new()?; let config = SdkConfig::new()?;
// Install FTC SDK // Install FTC SDK
crate::sdk::ftc::install(&config.ftc_sdk_path, &config.android_sdk_path)?; crate::sdk::ftc::install(&config.ftc_sdk_path, &config.android_sdk_path, proxy)?;
// Install Android SDK // Install Android SDK
crate::sdk::android::install(&config.android_sdk_path)?; crate::sdk::android::install(&config.android_sdk_path, proxy)?;
println!(); println!();
println!("{} All SDKs installed successfully", "".green().bold()); println!("{} All SDKs installed successfully", "".green().bold());
config.print_status(); config.print_status();
Ok(()) Ok(())
} }
pub fn show_status() -> Result<()> { pub fn show_status() -> Result<()> {
let config = SdkConfig::new()?; let config = SdkConfig::new()?;
config.print_status(); config.print_status();
// Verify SDKs // Verify SDKs
println!(); println!();
println!("{}", "Verification:".bright_yellow().bold()); println!("{}", "Verification:".bright_yellow().bold());
match crate::sdk::ftc::verify(&config.ftc_sdk_path) { match crate::sdk::ftc::verify(&config.ftc_sdk_path) {
Ok(_) => println!("{} FTC SDK is valid", "".green()), Ok(_) => println!("{} FTC SDK is valid", "".green()),
Err(e) => println!("{} FTC SDK: {}", "".red(), e), Err(e) => println!("{} FTC SDK: {}", "".red(), e),
} }
match crate::sdk::android::verify(&config.android_sdk_path) { match crate::sdk::android::verify(&config.android_sdk_path) {
Ok(_) => println!("{} Android SDK is valid", "".green()), Ok(_) => println!("{} Android SDK is valid", "".green()),
Err(e) => println!("{} Android SDK: {}", "".red(), e), Err(e) => println!("{} Android SDK: {}", "".red(), e),
} }
println!(); println!();
Ok(()) Ok(())
} }
pub fn update_sdks() -> Result<()> { pub fn update_sdks(proxy: &ProxyConfig) -> Result<()> {
println!("{}", "Updating SDKs...".bright_yellow().bold()); println!("{}", "Updating SDKs...".bright_yellow().bold());
println!(); println!();
let config = SdkConfig::new()?; let config = SdkConfig::new()?;
// Update FTC SDK // Update FTC SDK
crate::sdk::ftc::update(&config.ftc_sdk_path)?; crate::sdk::ftc::update(&config.ftc_sdk_path, proxy)?;
println!(); println!();
println!("{} SDKs updated successfully", "".green().bold()); println!("{} SDKs updated successfully", "".green().bold());
Ok(()) Ok(())
} }

View File

@@ -4,18 +4,19 @@ use std::process::Command;
use colored::*; use colored::*;
use crate::sdk::SdkConfig; use crate::sdk::SdkConfig;
use crate::sdk::proxy::ProxyConfig;
use crate::project::ProjectConfig; use crate::project::ProjectConfig;
/// Setup development environment - either system-wide or for a specific project /// Setup development environment - either system-wide or for a specific project
pub fn setup_environment(project_path: Option<&str>) -> Result<()> { pub fn setup_environment(project_path: Option<&str>, proxy: &ProxyConfig) -> Result<()> {
match project_path { match project_path {
Some(path) => setup_project(path), Some(path) => setup_project(path, proxy),
None => setup_system(), None => setup_system(proxy),
} }
} }
/// Setup system-wide development environment with default SDKs /// Setup system-wide development environment with default SDKs
fn setup_system() -> Result<()> { fn setup_system(proxy: &ProxyConfig) -> Result<()> {
println!("{}", "═══════════════════════════════════════════════════════════".bright_cyan()); println!("{}", "═══════════════════════════════════════════════════════════".bright_cyan());
println!("{}", " System Setup - Preparing FTC Development Environment".bright_cyan().bold()); println!("{}", " System Setup - Preparing FTC Development Environment".bright_cyan().bold());
println!("{}", "═══════════════════════════════════════════════════════════".bright_cyan()); println!("{}", "═══════════════════════════════════════════════════════════".bright_cyan());
@@ -57,7 +58,7 @@ fn setup_system() -> Result<()> {
} }
Err(_) => { Err(_) => {
println!("{} FTC SDK found but incomplete, reinstalling...", "".yellow()); println!("{} FTC SDK found but incomplete, reinstalling...", "".yellow());
crate::sdk::ftc::install(&sdk_config.ftc_sdk_path, &sdk_config.android_sdk_path)?; crate::sdk::ftc::install(&sdk_config.ftc_sdk_path, &sdk_config.android_sdk_path, proxy)?;
let version = crate::sdk::ftc::get_version(&sdk_config.ftc_sdk_path) let version = crate::sdk::ftc::get_version(&sdk_config.ftc_sdk_path)
.unwrap_or_else(|_| "unknown".to_string()); .unwrap_or_else(|_| "unknown".to_string());
installed.push(format!("FTC SDK {} (installed)", version)); installed.push(format!("FTC SDK {} (installed)", version));
@@ -65,7 +66,7 @@ fn setup_system() -> Result<()> {
} }
} else { } else {
println!("FTC SDK not found. Installing..."); println!("FTC SDK not found. Installing...");
crate::sdk::ftc::install(&sdk_config.ftc_sdk_path, &sdk_config.android_sdk_path)?; crate::sdk::ftc::install(&sdk_config.ftc_sdk_path, &sdk_config.android_sdk_path, proxy)?;
let version = crate::sdk::ftc::get_version(&sdk_config.ftc_sdk_path) let version = crate::sdk::ftc::get_version(&sdk_config.ftc_sdk_path)
.unwrap_or_else(|_| "unknown".to_string()); .unwrap_or_else(|_| "unknown".to_string());
installed.push(format!("FTC SDK {} (installed)", version)); installed.push(format!("FTC SDK {} (installed)", version));
@@ -85,13 +86,13 @@ fn setup_system() -> Result<()> {
} }
Err(_) => { Err(_) => {
println!("{} Android SDK found but incomplete, reinstalling...", "".yellow()); println!("{} Android SDK found but incomplete, reinstalling...", "".yellow());
crate::sdk::android::install(&sdk_config.android_sdk_path)?; crate::sdk::android::install(&sdk_config.android_sdk_path, proxy)?;
installed.push("Android SDK (installed)".to_string()); installed.push("Android SDK (installed)".to_string());
} }
} }
} else { } else {
println!("Android SDK not found. Installing..."); println!("Android SDK not found. Installing...");
crate::sdk::android::install(&sdk_config.android_sdk_path)?; crate::sdk::android::install(&sdk_config.android_sdk_path, proxy)?;
installed.push("Android SDK (installed)".to_string()); installed.push("Android SDK (installed)".to_string());
} }
println!(); println!();
@@ -132,7 +133,7 @@ fn setup_system() -> Result<()> {
} }
/// Setup dependencies for a specific project by reading its .weevil.toml /// Setup dependencies for a specific project by reading its .weevil.toml
fn setup_project(project_path: &str) -> Result<()> { fn setup_project(project_path: &str, proxy: &ProxyConfig) -> Result<()> {
let project_path = PathBuf::from(project_path); let project_path = PathBuf::from(project_path);
if !project_path.exists() { if !project_path.exists() {
@@ -214,7 +215,7 @@ fn setup_project(project_path: &str) -> Result<()> {
// Try to install it automatically // Try to install it automatically
println!("{}", "Attempting automatic installation...".bright_yellow()); println!("{}", "Attempting automatic installation...".bright_yellow());
match crate::sdk::ftc::install(&config.ftc_sdk_path, &config.android_sdk_path) { match crate::sdk::ftc::install(&config.ftc_sdk_path, &config.android_sdk_path, proxy) {
Ok(_) => { Ok(_) => {
println!("{} FTC SDK {} installed successfully", println!("{} FTC SDK {} installed successfully",
"".green(), "".green(),
@@ -249,13 +250,13 @@ fn setup_project(project_path: &str) -> Result<()> {
} }
Err(_) => { Err(_) => {
println!("{} Android SDK found but incomplete, reinstalling...", "".yellow()); println!("{} Android SDK found but incomplete, reinstalling...", "".yellow());
crate::sdk::android::install(&config.android_sdk_path)?; crate::sdk::android::install(&config.android_sdk_path, proxy)?;
installed.push("Android SDK (installed)".to_string()); installed.push("Android SDK (installed)".to_string());
} }
} }
} else { } else {
println!("Android SDK not found. Installing..."); println!("Android SDK not found. Installing...");
crate::sdk::android::install(&config.android_sdk_path)?; crate::sdk::android::install(&config.android_sdk_path, proxy)?;
installed.push("Android SDK (installed)".to_string()); installed.push("Android SDK (installed)".to_string());
} }
println!(); println!();

View File

@@ -101,7 +101,9 @@ pub fn uninstall_dependencies(dry_run: bool, targets: Option<Vec<usize>>) -> Res
/// Full uninstall — removes the entire .weevil directory /// Full uninstall — removes the entire .weevil directory
fn full_uninstall(sdk_config: &SdkConfig, dry_run: bool) -> Result<()> { fn full_uninstall(sdk_config: &SdkConfig, dry_run: bool) -> Result<()> {
if !sdk_config.cache_dir.exists() { let all_targets = scan_targets(sdk_config);
if all_targets.is_empty() {
println!("{}", "No Weevil-managed components found.".bright_green()); println!("{}", "No Weevil-managed components found.".bright_green());
println!(); println!();
return Ok(()); return Ok(());
@@ -110,7 +112,6 @@ fn full_uninstall(sdk_config: &SdkConfig, dry_run: bool) -> Result<()> {
let size = dir_size(&sdk_config.cache_dir); let size = dir_size(&sdk_config.cache_dir);
if dry_run { if dry_run {
let all_targets = scan_targets(sdk_config);
println!("{}", "── Dry Run ─────────────────────────────────────────────────".bright_yellow().bold()); println!("{}", "── Dry Run ─────────────────────────────────────────────────".bright_yellow().bold());
println!(); println!();

View File

@@ -1,6 +1,7 @@
// File: src/lib.rs // File: src/lib.rs
// Library interface for testing // Library interface for testing
pub mod version;
pub mod sdk; pub mod sdk;
pub mod project; pub mod project;
pub mod commands; pub mod commands;

View File

@@ -1,18 +1,38 @@
use clap::{Parser, Subcommand}; use clap::{Parser, Subcommand};
use colored::*; use colored::*;
use anyhow::Result; use anyhow::Result;
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;
mod templates; mod templates;
use sdk::proxy::ProxyConfig;
#[derive(Parser)] #[derive(Parser)]
#[command(name = "weevil")] #[command(name = "weevil")]
#[command(author = "Eric Barch <eric@intrepidfusion.com>")] #[command(author = "Eric Ratliff <eric@nxlearn.net>")]
#[command(version = "1.0.0")] #[command(version = WEEVIL_VERSION)]
#[command(about = "FTC robotics project generator - bores into complexity, emerges with clean code", long_about = None)] #[command(
about = "FTC robotics project generator - bores into complexity, emerges with clean code",
long_about = None
)]
struct Cli { struct Cli {
/// Use this HTTP/HTTPS proxy for all downloads
#[arg(long, value_name = "URL", global = true)]
proxy: Option<String>,
/// Skip proxy entirely — go direct even if HTTPS_PROXY is set
#[arg(long, global = true)]
no_proxy: bool,
#[command(subcommand)] #[command(subcommand)]
command: Commands, command: Commands,
} }
@@ -23,71 +43,71 @@ enum Commands {
New { New {
/// Name of the robot project /// Name of the robot project
name: String, name: String,
/// Path to FTC SDK (optional, will auto-detect or download) /// Path to FTC SDK (optional, will auto-detect or download)
#[arg(long)] #[arg(long)]
ftc_sdk: Option<String>, ftc_sdk: Option<String>,
/// Path to Android SDK (optional, will auto-detect or download) /// Path to Android SDK (optional, will auto-detect or download)
#[arg(long)] #[arg(long)]
android_sdk: Option<String>, android_sdk: Option<String>,
}, },
/// Check system health and diagnose issues /// Check system health and diagnose issues
Doctor, Doctor,
/// Setup development environment (system or project) /// Setup development environment (system or project)
Setup { Setup {
/// Path to project directory (optional - without it, sets up system) /// Path to project directory (optional - without it, sets up system)
path: Option<String>, path: Option<String>,
}, },
/// Remove Weevil-installed SDKs and dependencies /// Remove Weevil-installed SDKs and dependencies
Uninstall { Uninstall {
/// Show what would be removed without actually removing anything /// Show what would be removed without actually removing anything
#[arg(long)] #[arg(long)]
dry_run: bool, dry_run: bool,
/// Remove only specific items by number (use --dry-run first to see the list) /// Remove only specific items by number (use --dry-run first to see the list)
#[arg(long, value_name = "NUM", num_args = 1..)] #[arg(long, value_name = "NUM", num_args = 1..)]
only: Option<Vec<usize>>, only: Option<Vec<usize>>,
}, },
/// Upgrade an existing project to the latest generator version /// Upgrade an existing project to the latest generator version
Upgrade { Upgrade {
/// Path to the project directory /// Path to the project directory
path: String, path: String,
}, },
/// Build and deploy project to Control Hub /// Build and deploy project to Control Hub
Deploy { Deploy {
/// Path to the project directory /// Path to the project directory
path: String, path: String,
/// Force USB connection /// Force USB connection
#[arg(long)] #[arg(long)]
usb: bool, usb: bool,
/// Force WiFi connection /// Force WiFi connection
#[arg(long)] #[arg(long)]
wifi: bool, wifi: bool,
/// Custom IP address /// Custom IP address
#[arg(short, long)] #[arg(short, long)]
ip: Option<String>, ip: Option<String>,
}, },
/// Manage SDKs (FTC and Android) /// Manage SDKs (FTC and Android)
Sdk { Sdk {
#[command(subcommand)] #[command(subcommand)]
command: SdkCommands, command: SdkCommands,
}, },
/// Show or update project configuration /// Show or update project configuration
Config { Config {
/// Path to the project directory /// Path to the project directory
path: String, path: String,
/// Set FTC SDK path for this project /// Set FTC SDK path for this project
#[arg(long, value_name = "PATH")] #[arg(long, value_name = "PATH")]
set_sdk: Option<String>, set_sdk: Option<String>,
@@ -98,10 +118,10 @@ enum Commands {
enum SdkCommands { enum SdkCommands {
/// Install required SDKs /// Install required SDKs
Install, Install,
/// Show SDK status and locations /// Show SDK status and locations
Status, Status,
/// Update SDKs to latest versions /// Update SDKs to latest versions
Update, Update,
} }
@@ -110,20 +130,23 @@ fn main() -> Result<()> {
// Enable colors on Windows // Enable colors on Windows
#[cfg(windows)] #[cfg(windows)]
colored::control::set_virtual_terminal(true).ok(); colored::control::set_virtual_terminal(true).ok();
let cli = Cli::parse(); let cli = Cli::parse();
print_banner(); print_banner();
// Resolve proxy once at the top — every network-touching command uses it.
let proxy = ProxyConfig::resolve(cli.proxy.as_deref(), cli.no_proxy)?;
match cli.command { match cli.command {
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()) commands::new::create_project(&name, ftc_sdk.as_deref(), android_sdk.as_deref(), &proxy)
} }
Commands::Doctor => { Commands::Doctor => {
commands::doctor::run_diagnostics() commands::doctor::run_diagnostics()
} }
Commands::Setup { path } => { Commands::Setup { path } => {
commands::setup::setup_environment(path.as_deref()) commands::setup::setup_environment(path.as_deref(), &proxy)
} }
Commands::Uninstall { dry_run, only } => { Commands::Uninstall { dry_run, only } => {
commands::uninstall::uninstall_dependencies(dry_run, only) commands::uninstall::uninstall_dependencies(dry_run, only)
@@ -134,13 +157,11 @@ fn main() -> Result<()> {
Commands::Deploy { path, usb, wifi, ip } => { Commands::Deploy { path, usb, wifi, ip } => {
commands::deploy::deploy_project(&path, usb, wifi, ip.as_deref()) commands::deploy::deploy_project(&path, usb, wifi, ip.as_deref())
} }
Commands::Sdk { command } => { Commands::Sdk { command } => match command {
match command { SdkCommands::Install => commands::sdk::install_sdks(&proxy),
SdkCommands::Install => commands::sdk::install_sdks(), SdkCommands::Status => commands::sdk::show_status(),
SdkCommands::Status => commands::sdk::show_status(), SdkCommands::Update => commands::sdk::update_sdks(&proxy),
SdkCommands::Update => commands::sdk::update_sdks(), },
}
}
Commands::Config { path, set_sdk } => { Commands::Config { path, set_sdk } => {
if let Some(sdk_path) = set_sdk { if let Some(sdk_path) = set_sdk {
commands::config::set_sdk(&path, &sdk_path) commands::config::set_sdk(&path, &sdk_path)
@@ -153,7 +174,12 @@ fn main() -> Result<()> {
fn print_banner() { fn print_banner() {
println!("{}", "═══════════════════════════════════════════════════════════".bright_cyan()); println!("{}", "═══════════════════════════════════════════════════════════".bright_cyan());
println!("{}", " 🪲 Weevil - FTC Project Generator v1.0.0".bright_cyan().bold()); println!(
"{}",
format!(" 🪲 Weevil - FTC Project Generator v{}", WEEVIL_VERSION)
.bright_cyan()
.bold()
);
println!("{}", " Nexus Workshops LLC".bright_cyan()); println!("{}", " Nexus Workshops LLC".bright_cyan());
println!("{}", "═══════════════════════════════════════════════════════════".bright_cyan()); println!("{}", "═══════════════════════════════════════════════════════════".bright_cyan());
println!(); println!();

View File

@@ -3,6 +3,8 @@ use std::path::{Path, PathBuf};
use std::fs; use std::fs;
use anyhow::{Result, Context, bail}; use anyhow::{Result, Context, bail};
const WEEVIL_VERSION: &str = env!("CARGO_PKG_VERSION");
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize)]
pub struct ProjectConfig { pub struct ProjectConfig {
pub project_name: String, pub project_name: String,
@@ -24,7 +26,7 @@ impl ProjectConfig {
Ok(Self { Ok(Self {
project_name: project_name.to_string(), project_name: project_name.to_string(),
weevil_version: "1.0.0".to_string(), weevil_version: WEEVIL_VERSION.to_string(),
ftc_sdk_path, ftc_sdk_path,
ftc_sdk_version, ftc_sdk_version,
android_sdk_path, android_sdk_path,

View File

@@ -7,6 +7,8 @@ use git2::Repository;
use crate::sdk::SdkConfig; use crate::sdk::SdkConfig;
const WEEVIL_VERSION: &str = env!("CARGO_PKG_VERSION");
pub mod deployer; pub mod deployer;
pub mod config; pub mod config;
@@ -68,7 +70,7 @@ impl ProjectBuilder {
let mut _context = TeraContext::new(); let mut _context = TeraContext::new();
_context.insert("project_name", &self.name); _context.insert("project_name", &self.name);
_context.insert("sdk_dir", &sdk_config.ftc_sdk_path.to_string_lossy()); _context.insert("sdk_dir", &sdk_config.ftc_sdk_path.to_string_lossy());
_context.insert("generator_version", "1.0.0"); _context.insert("generator_version", WEEVIL_VERSION);
self.create_project_files(project_path, sdk_config)?; self.create_project_files(project_path, sdk_config)?;
@@ -84,7 +86,7 @@ impl ProjectBuilder {
let readme = format!( let readme = format!(
r#"# {} r#"# {}
FTC Robot Project generated by Weevil v1.0.0 FTC Robot Project generated by Weevil v{}
## Quick Start ## Quick Start
```bash ```bash
@@ -111,7 +113,7 @@ deploy.bat
2. Test locally: `./gradlew test` 2. Test locally: `./gradlew test`
3. Deploy: `./deploy.sh` (or `deploy.bat` on Windows) 3. Deploy: `./deploy.sh` (or `deploy.bat` on Windows)
"#, "#,
self.name self.name, WEEVIL_VERSION
); );
fs::write(project_path.join("README.md"), readme)?; fs::write(project_path.join("README.md"), readme)?;
@@ -120,7 +122,7 @@ deploy.bat
fs::write(project_path.join(".gitignore"), gitignore)?; fs::write(project_path.join(".gitignore"), gitignore)?;
// Version marker // Version marker
fs::write(project_path.join(".weevil-version"), "1.0.0")?; fs::write(project_path.join(".weevil-version"), WEEVIL_VERSION)?;
// build.gradle.kts - Pure Java with deployToSDK task // build.gradle.kts - Pure Java with deployToSDK task
// Escape backslashes for Windows paths in Kotlin strings // Escape backslashes for Windows paths in Kotlin strings

View File

@@ -1,16 +1,17 @@
use std::path::Path; use std::path::Path;
use anyhow::{Result, Context}; use anyhow::{Result, Context};
use indicatif::{ProgressBar, ProgressStyle}; use indicatif::{ProgressBar, ProgressStyle};
use reqwest::blocking::Client;
use std::fs::File; use std::fs::File;
use std::io::Write; use std::io::Write;
use colored::*; use colored::*;
use super::proxy::ProxyConfig;
const ANDROID_SDK_URL_LINUX: &str = "https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip"; const ANDROID_SDK_URL_LINUX: &str = "https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip";
const ANDROID_SDK_URL_MAC: &str = "https://dl.google.com/android/repository/commandlinetools-mac-11076708_latest.zip"; const ANDROID_SDK_URL_MAC: &str = "https://dl.google.com/android/repository/commandlinetools-mac-11076708_latest.zip";
const ANDROID_SDK_URL_WINDOWS: &str = "https://dl.google.com/android/repository/commandlinetools-win-11076708_latest.zip"; const ANDROID_SDK_URL_WINDOWS: &str = "https://dl.google.com/android/repository/commandlinetools-win-11076708_latest.zip";
pub fn install(sdk_path: &Path) -> Result<()> { pub fn install(sdk_path: &Path, proxy: &ProxyConfig) -> Result<()> {
// Check if SDK exists AND is complete // Check if SDK exists AND is complete
if sdk_path.exists() { if sdk_path.exists() {
match verify(sdk_path) { match verify(sdk_path) {
@@ -42,10 +43,14 @@ pub fn install(sdk_path: &Path) -> Result<()> {
// Download // Download
println!("Downloading from: {}", url); println!("Downloading from: {}", url);
let client = Client::new(); proxy.print_status();
let client = proxy.client()?;
let response = client.get(url) let response = client.get(url)
.send() .send()
.context("Failed to download Android SDK")?; .map_err(|e| {
super::proxy::print_offline_instructions();
anyhow::anyhow!("Failed to download Android SDK: {}", e)
})?;
let total_size = response.content_length().unwrap_or(0); let total_size = response.content_length().unwrap_or(0);

View File

@@ -4,10 +4,12 @@ use git2::Repository;
use colored::*; use colored::*;
use std::fs; use std::fs;
use super::proxy::{ProxyConfig, GitProxyGuard};
const FTC_SDK_URL: &str = "https://github.com/FIRST-Tech-Challenge/FtcRobotController.git"; const FTC_SDK_URL: &str = "https://github.com/FIRST-Tech-Challenge/FtcRobotController.git";
const FTC_SDK_VERSION: &str = "v10.1.1"; const FTC_SDK_VERSION: &str = "v10.1.1";
pub fn install(sdk_path: &Path, android_sdk_path: &Path) -> Result<()> { pub fn install(sdk_path: &Path, android_sdk_path: &Path, proxy: &ProxyConfig) -> Result<()> {
if sdk_path.exists() { if sdk_path.exists() {
println!("{} FTC SDK already installed at: {}", println!("{} FTC SDK already installed at: {}",
"".green(), "".green(),
@@ -21,10 +23,18 @@ pub fn install(sdk_path: &Path, android_sdk_path: &Path) -> Result<()> {
println!("{}", "Installing FTC SDK...".bright_yellow()); println!("{}", "Installing FTC SDK...".bright_yellow());
println!("Cloning from: {}", FTC_SDK_URL); println!("Cloning from: {}", FTC_SDK_URL);
println!("Version: {}", FTC_SDK_VERSION); println!("Version: {}", FTC_SDK_VERSION);
proxy.print_status();
// GitProxyGuard sets HTTPS_PROXY for the duration of the clone so that
// libgit2 honours --proxy / --no-proxy without touching ~/.gitconfig.
let _guard = GitProxyGuard::new(proxy);
// Clone the repository // Clone the repository
let repo = Repository::clone(FTC_SDK_URL, sdk_path) let repo = Repository::clone(FTC_SDK_URL, sdk_path)
.context("Failed to clone FTC SDK")?; .map_err(|e| {
super::proxy::print_offline_instructions();
anyhow::anyhow!("Failed to clone FTC SDK: {}", e)
})?;
// Checkout specific version // Checkout specific version
let obj = repo.revparse_single(FTC_SDK_VERSION)?; let obj = repo.revparse_single(FTC_SDK_VERSION)?;
@@ -80,15 +90,23 @@ fn check_version(sdk_path: &Path) -> Result<()> {
Ok(()) Ok(())
} }
pub fn update(sdk_path: &Path) -> Result<()> { pub fn update(sdk_path: &Path, proxy: &ProxyConfig) -> Result<()> {
println!("{}", "Updating FTC SDK...".bright_yellow()); println!("{}", "Updating FTC SDK...".bright_yellow());
proxy.print_status();
let repo = Repository::open(sdk_path) let repo = Repository::open(sdk_path)
.context("FTC SDK not found or not a git repository")?; .context("FTC SDK not found or not a git repository")?;
// Guard env vars for the fetch
let _guard = GitProxyGuard::new(proxy);
// Fetch latest // Fetch latest
let mut remote = repo.find_remote("origin")?; let mut remote = repo.find_remote("origin")?;
remote.fetch(&["refs/tags/*:refs/tags/*"], None, None)?; remote.fetch(&["refs/tags/*:refs/tags/*"], None, None)
.map_err(|e| {
super::proxy::print_offline_instructions();
anyhow::anyhow!("Failed to fetch FTC SDK updates: {}", e)
})?;
// Checkout latest version // Checkout latest version
let obj = repo.revparse_single(FTC_SDK_VERSION)?; let obj = repo.revparse_single(FTC_SDK_VERSION)?;

View File

@@ -6,6 +6,7 @@ use colored::*;
pub mod android; pub mod android;
pub mod ftc; pub mod ftc;
pub mod gradle; pub mod gradle;
pub mod proxy;
pub struct SdkConfig { pub struct SdkConfig {
pub ftc_sdk_path: PathBuf, pub ftc_sdk_path: PathBuf,
@@ -15,15 +16,26 @@ pub struct SdkConfig {
impl SdkConfig { impl SdkConfig {
pub fn new() -> Result<Self> { pub fn new() -> Result<Self> {
let home = dirs::home_dir() // Allow tests (or power users) to override the cache directory.
.context("Could not determine home directory")?; // When WEEVIL_HOME is set, we also skip the system Android SDK
// search so tests are fully isolated.
let (cache_dir, android_sdk_path) = if let Ok(weevil_home) = std::env::var("WEEVIL_HOME") {
let cache = PathBuf::from(weevil_home);
let android = cache.join("android-sdk");
(cache, android)
} else {
let home = dirs::home_dir()
.context("Could not determine home directory")?;
let cache = home.join(".weevil");
let android = Self::find_android_sdk().unwrap_or_else(|| cache.join("android-sdk"));
(cache, android)
};
let cache_dir = home.join(".weevil");
fs::create_dir_all(&cache_dir)?; fs::create_dir_all(&cache_dir)?;
Ok(Self { Ok(Self {
ftc_sdk_path: cache_dir.join("ftc-sdk"), ftc_sdk_path: cache_dir.join("ftc-sdk"),
android_sdk_path: Self::find_android_sdk().unwrap_or_else(|| cache_dir.join("android-sdk")), android_sdk_path,
cache_dir, cache_dir,
}) })
} }

322
src/sdk/proxy.rs Normal file
View File

@@ -0,0 +1,322 @@
use colored::*;
use reqwest::blocking;
use reqwest::Url;
/// Where the proxy URL came from — used for status messages.
#[derive(Debug, Clone, PartialEq)]
pub enum ProxySource {
/// --proxy <url> on the command line
Flag,
/// HTTPS_PROXY or HTTP_PROXY environment variable
Env(String),
}
/// Resolved proxy configuration. A `None` url means "go direct".
#[derive(Debug, Clone)]
pub struct ProxyConfig {
pub url: Option<Url>,
pub source: Option<ProxySource>,
}
impl ProxyConfig {
/// Resolve proxy with this priority:
/// 1. --no-proxy → direct, ignore everything
/// 2. --proxy <url> → use that URL
/// 3. HTTPS_PROXY / HTTP_PROXY env vars
/// 4. Nothing → direct
pub fn resolve(proxy_flag: Option<&str>, no_proxy: bool) -> Result<Self, anyhow::Error> {
if no_proxy {
return Ok(Self { url: None, source: None });
}
if let Some(raw) = proxy_flag {
let url = Url::parse(raw)
.map_err(|e| anyhow::anyhow!("Invalid --proxy URL '{}': {}", raw, e))?;
return Ok(Self { url: Some(url), source: Some(ProxySource::Flag) });
}
// Walk the env vars in priority order
for var in &["HTTPS_PROXY", "https_proxy", "HTTP_PROXY", "http_proxy"] {
if let Ok(val) = std::env::var(var) {
if val.is_empty() { continue; }
let url = Url::parse(&val)
.map_err(|e| anyhow::anyhow!("Invalid {} env var '{}': {}", var, val, e))?;
return Ok(Self {
url: Some(url),
source: Some(ProxySource::Env(var.to_string())),
});
}
}
Ok(Self { url: None, source: None })
}
/// True when the user explicitly passed --proxy (as opposed to env-var pickup).
/// Used for distinguishing "you asked for this proxy and it failed" from
/// "we picked up an ambient proxy from your environment" in error paths.
#[allow(dead_code)]
pub fn is_explicit(&self) -> bool {
matches!(self.source, Some(ProxySource::Flag))
}
/// Human-readable description of where the proxy came from, for status output.
pub fn source_description(&self) -> String {
match &self.source {
Some(ProxySource::Flag) => "--proxy flag".to_string(),
Some(ProxySource::Env(var)) => format!("{} env var", var),
None => "none".to_string(),
}
}
/// Print a one-line proxy status line (used in setup output).
pub fn print_status(&self) {
match &self.url {
Some(url) => println!(
" {} Proxy: {} ({})",
"".green(),
url.as_str().bright_white(),
self.source_description()
),
None => println!(
" {} Proxy: direct (no proxy)",
"".bright_black()
),
}
}
/// Build a reqwest blocking Client that honours this proxy config.
///
/// * `Some(url)` → all HTTP/HTTPS traffic goes through that proxy.
/// * `None` → direct, no proxy at all.
///
/// We always go through the builder (never plain `Client::new()`) because
/// `Client::new()` silently picks up env-var proxies. When the user says
/// `--no-proxy` we need to actively disable that.
pub fn client(&self) -> anyhow::Result<blocking::Client> {
let mut builder = blocking::ClientBuilder::new();
match &self.url {
Some(url) => {
builder = builder.proxy(reqwest::Proxy::all(url.clone())?);
}
None => {
// Actively suppress env-var auto-detection.
builder = builder.no_proxy();
}
}
builder.build().map_err(|e| anyhow::anyhow!("Failed to build HTTP client: {}", e))
}
}
/// RAII guard that temporarily sets HTTPS_PROXY / HTTP_PROXY for the lifetime
/// of the guard, then restores the previous values on drop.
///
/// libgit2 (the C library behind the `git2` crate) reads these env vars
/// directly for its HTTP transport. This is the cleanest way to make
/// `git2::Repository::clone` and `remote.fetch()` honour a `--proxy` flag
/// without touching the user's global `~/.gitconfig`.
///
/// When the ProxyConfig has no URL (direct / --no-proxy) the guard *clears*
/// the vars so libgit2 won't accidentally pick up an ambient proxy the user
/// didn't intend for this operation.
pub struct GitProxyGuard {
prev_https: Option<String>,
prev_http: Option<String>,
}
impl GitProxyGuard {
pub fn new(config: &ProxyConfig) -> Self {
let prev_https = std::env::var("HTTPS_PROXY").ok();
let prev_http = std::env::var("HTTP_PROXY").ok();
match &config.url {
Some(url) => {
let s = url.as_str();
std::env::set_var("HTTPS_PROXY", s);
std::env::set_var("HTTP_PROXY", s);
}
None => {
std::env::remove_var("HTTPS_PROXY");
std::env::remove_var("HTTP_PROXY");
}
}
Self { prev_https, prev_http }
}
}
impl Drop for GitProxyGuard {
fn drop(&mut self) {
match &self.prev_https {
Some(v) => std::env::set_var("HTTPS_PROXY", v),
None => std::env::remove_var("HTTPS_PROXY"),
}
match &self.prev_http {
Some(v) => std::env::set_var("HTTP_PROXY", v),
None => std::env::remove_var("HTTP_PROXY"),
}
}
}
/// Print clear, actionable instructions for obtaining dependencies without
/// an internet connection. Called whenever a network download fails so the
/// user always sees their escape hatch.
pub fn print_offline_instructions() {
println!();
println!("{}", "── Offline / Air-Gapped Installation ──────────────────────".bright_yellow().bold());
println!();
println!("If you have no internet access (or the proxy is not working),");
println!("you can obtain the required SDKs on a connected machine and");
println!("copy them over.");
println!();
println!("{}", "1. FTC SDK (git clone)".bright_cyan().bold());
println!(" On a connected machine:");
println!(" git clone https://github.com/FIRST-Tech-Challenge/FtcRobotController.git");
println!(" cd FtcRobotController");
println!(" git checkout v10.1.1");
println!();
println!(" Copy the entire FtcRobotController/ directory to this machine");
println!(" at ~/.weevil/ftc-sdk/ (or wherever your .weevil.toml points).");
println!();
println!("{}", "2. Android SDK (command-line tools)".bright_cyan().bold());
println!(" Download the zip for your OS from a connected machine:");
println!(" Linux: https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip");
println!(" macOS: https://dl.google.com/android/repository/commandlinetools-mac-11076708_latest.zip");
println!(" Windows: https://dl.google.com/android/repository/commandlinetools-win-11076708_latest.zip");
println!();
println!(" Extract to ~/.weevil/android-sdk/, then run sdkmanager:");
println!(" ./cmdline-tools/latest/bin/sdkmanager \\");
println!(" platform-tools platforms;android-34 build-tools;34.0.0");
println!();
println!(" Copy the resulting android-sdk/ directory to this machine.");
println!();
println!("{}", "3. Gradle distribution".bright_cyan().bold());
println!(" Gradle fetches its own distribution the first time ./gradlew");
println!(" runs. If that fails offline, download manually:");
println!(" https://services.gradle.org/distributions/gradle-8.9-bin.zip");
println!(" Extract into ~/.gradle/wrapper/dists/gradle-8.9-bin/");
println!(" (the exact subdirectory is printed by gradlew on failure).");
println!();
println!("{}", "4. Proxy quick reference".bright_cyan().bold());
println!(" • Use a specific proxy: weevil --proxy http://proxy:3128 sdk install");
println!(" • Skip the proxy entirely: weevil --no-proxy sdk install");
println!(" • Gradle also reads HTTPS_PROXY / HTTP_PROXY, so set those");
println!(" in your shell before running ./gradlew if the build needs a proxy.");
println!();
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Mutex;
// Env vars are process-global. cargo test runs tests in parallel within
// a single binary, so any test that sets/removes HTTPS_PROXY or HTTP_PROXY
// must hold this lock for its entire duration.
static ENV_MUTEX: Mutex<()> = Mutex::new(());
#[test]
fn no_proxy_flag_forces_direct() {
let _env_lock = ENV_MUTEX.lock().unwrap();
std::env::set_var("HTTPS_PROXY", "http://proxy.example.com:3128");
let config = ProxyConfig::resolve(None, true).unwrap();
assert!(config.url.is_none());
std::env::remove_var("HTTPS_PROXY");
}
#[test]
fn explicit_flag_overrides_env() {
let _env_lock = ENV_MUTEX.lock().unwrap();
std::env::set_var("HTTPS_PROXY", "http://env-proxy.example.com:3128");
let config = ProxyConfig::resolve(Some("http://flag-proxy.example.com:8080"), false).unwrap();
assert_eq!(config.url.as_ref().unwrap().host_str(), Some("flag-proxy.example.com"));
assert!(config.is_explicit());
std::env::remove_var("HTTPS_PROXY");
}
#[test]
fn picks_up_env_var() {
let _env_lock = ENV_MUTEX.lock().unwrap();
std::env::remove_var("HTTPS_PROXY");
std::env::remove_var("https_proxy");
std::env::set_var("HTTP_PROXY", "http://env-proxy.example.com:3128");
let config = ProxyConfig::resolve(None, false).unwrap();
assert_eq!(config.url.as_ref().unwrap().host_str(), Some("env-proxy.example.com"));
assert_eq!(config.source, Some(ProxySource::Env("HTTP_PROXY".to_string())));
std::env::remove_var("HTTP_PROXY");
}
#[test]
fn direct_when_nothing_set() {
let _env_lock = ENV_MUTEX.lock().unwrap();
std::env::remove_var("HTTPS_PROXY");
std::env::remove_var("https_proxy");
std::env::remove_var("HTTP_PROXY");
std::env::remove_var("http_proxy");
let config = ProxyConfig::resolve(None, false).unwrap();
assert!(config.url.is_none());
}
#[test]
fn rejects_garbage_url() {
let result = ProxyConfig::resolve(Some("not a url at all"), false);
assert!(result.is_err());
}
#[test]
fn client_builds_with_proxy() {
let config = ProxyConfig::resolve(Some("http://proxy.example.com:3128"), false).unwrap();
assert!(config.client().is_ok());
}
#[test]
fn client_builds_direct() {
let _env_lock = ENV_MUTEX.lock().unwrap();
std::env::remove_var("HTTPS_PROXY");
std::env::remove_var("https_proxy");
std::env::remove_var("HTTP_PROXY");
std::env::remove_var("http_proxy");
let config = ProxyConfig::resolve(None, true).unwrap();
assert!(config.client().is_ok());
}
#[test]
fn git_proxy_guard_sets_and_restores() {
let _env_lock = ENV_MUTEX.lock().unwrap();
std::env::set_var("HTTPS_PROXY", "http://original:1111");
std::env::set_var("HTTP_PROXY", "http://original:2222");
let config = ProxyConfig::resolve(Some("http://guarded:9999"), false).unwrap();
{
let _guard = GitProxyGuard::new(&config);
// Url::parse normalises — trailing slash is expected
assert_eq!(std::env::var("HTTPS_PROXY").unwrap(), "http://guarded:9999/");
assert_eq!(std::env::var("HTTP_PROXY").unwrap(), "http://guarded:9999/");
}
assert_eq!(std::env::var("HTTPS_PROXY").unwrap(), "http://original:1111");
assert_eq!(std::env::var("HTTP_PROXY").unwrap(), "http://original:2222");
std::env::remove_var("HTTPS_PROXY");
std::env::remove_var("HTTP_PROXY");
}
#[test]
fn git_proxy_guard_clears_for_direct() {
let _env_lock = ENV_MUTEX.lock().unwrap();
std::env::set_var("HTTPS_PROXY", "http://should-be-cleared:1111");
std::env::set_var("HTTP_PROXY", "http://should-be-cleared:2222");
let config = ProxyConfig { url: None, source: None };
{
let _guard = GitProxyGuard::new(&config);
assert!(std::env::var("HTTPS_PROXY").is_err());
assert!(std::env::var("HTTP_PROXY").is_err());
}
assert_eq!(std::env::var("HTTPS_PROXY").unwrap(), "http://should-be-cleared:1111");
assert_eq!(std::env::var("HTTP_PROXY").unwrap(), "http://should-be-cleared:2222");
std::env::remove_var("HTTPS_PROXY");
std::env::remove_var("HTTP_PROXY");
}
}

1
src/version.rs Normal file
View File

@@ -0,0 +1 @@
pub const WEEVIL_VERSION: &str = env!("CARGO_PKG_VERSION");

3
tests/common.rs Normal file
View File

@@ -0,0 +1,3 @@
// Intentionally hardcoded. When you bump the version in Cargo.toml,
// tests will fail here until you update this to match.
pub const EXPECTED_VERSION: &str = "1.1.0-beta.1";

View File

View File

@@ -1,8 +1,17 @@
use assert_cmd::prelude::*; use assert_cmd::prelude::*;
use predicates::prelude::*; use predicates::prelude::*;
use tempfile::TempDir;
use std::process::Command; use std::process::Command;
#[path = "common.rs"]
mod common;
use common::EXPECTED_VERSION;
#[path = "integration/environment_tests.rs"]
mod environment_tests;
#[path = "integration/project_lifecycle_tests.rs"]
mod project_lifecycle_tests;
#[test] #[test]
fn test_help_command() { fn test_help_command() {
let mut cmd = Command::new(assert_cmd::cargo::cargo_bin!("weevil")); let mut cmd = Command::new(assert_cmd::cargo::cargo_bin!("weevil"));
@@ -20,7 +29,7 @@ fn test_version_command() {
cmd.assert() cmd.assert()
.success() .success()
.stdout(predicate::str::contains("1.0.0")); .stdout(predicate::str::contains(EXPECTED_VERSION));
} }
#[test] #[test]
@@ -31,25 +40,4 @@ fn test_sdk_status_command() {
cmd.assert() cmd.assert()
.success() .success()
.stdout(predicate::str::contains("SDK Configuration")); .stdout(predicate::str::contains("SDK Configuration"));
}
// Project creation test - will need mock SDKs
#[test]
#[ignore] // Ignore until we have mock SDKs set up
fn test_project_creation() {
let temp = TempDir::new().unwrap();
let mut cmd = Command::new(assert_cmd::cargo::cargo_bin!("weevil"));
cmd.current_dir(&temp)
.arg("new")
.arg("test-robot");
cmd.assert()
.success()
.stdout(predicate::str::contains("Project Created"));
// Verify project structure
assert!(temp.path().join("test-robot/README.md").exists());
assert!(temp.path().join("test-robot/build.gradle.kts").exists());
assert!(temp.path().join("test-robot/gradlew").exists());
} }

View File

@@ -0,0 +1,429 @@
// File: tests/integration/environment_tests.rs
// Integration tests for doctor, setup, uninstall, and new (v1.1.0 commands)
//
// Strategy: every test sets WEEVIL_HOME to a fresh TempDir. When WEEVIL_HOME
// is set, SdkConfig skips the system Android SDK search entirely, so nothing
// on the real system is visible or touched.
//
// We manually create the mock fixture structures in each test rather than
// using include_dir::extract, because include_dir doesn't preserve empty
// directories.
use std::fs;
use std::process::Command;
use tempfile::TempDir;
/// Helper: returns a configured Command pointing at the weevil binary with
/// WEEVIL_HOME set to the given temp directory.
fn weevil_cmd(weevil_home: &TempDir) -> Command {
let mut cmd = Command::new(assert_cmd::cargo::cargo_bin!("weevil"));
cmd.env("WEEVIL_HOME", weevil_home.path());
cmd
}
/// Helper: create a minimal mock FTC SDK at the given path.
/// Matches the structure that ftc::verify checks for.
fn create_mock_ftc_sdk(path: &std::path::Path) {
fs::create_dir_all(path.join("TeamCode/src/main/java")).unwrap();
fs::create_dir_all(path.join("FtcRobotController")).unwrap();
fs::write(path.join("build.gradle"), "// mock").unwrap();
fs::write(path.join(".version"), "v10.1.1\n").unwrap();
}
/// Helper: create a minimal mock Android SDK at the given path.
/// Matches the structure that android::verify checks for.
fn create_mock_android_sdk(path: &std::path::Path) {
fs::create_dir_all(path.join("platform-tools")).unwrap();
fs::write(path.join("platform-tools/adb"), "").unwrap();
}
/// Helper: populate a WEEVIL_HOME with both mock SDKs (fully healthy system)
fn populate_healthy(weevil_home: &TempDir) {
create_mock_ftc_sdk(&weevil_home.path().join("ftc-sdk"));
create_mock_android_sdk(&weevil_home.path().join("android-sdk"));
}
/// Helper: populate with only the FTC SDK (Android missing)
fn populate_ftc_only(weevil_home: &TempDir) {
create_mock_ftc_sdk(&weevil_home.path().join("ftc-sdk"));
}
/// Helper: print labeled output from a test so it's visually distinct from test assertions
fn print_output(test_name: &str, output: &std::process::Output) {
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
println!("\n╔══ {} ══════════════════════════════════════════════╗", test_name);
if !stdout.is_empty() {
println!("║ stdout:");
for line in stdout.lines() {
println!("{}", line);
}
}
if !stderr.is_empty() {
println!("║ stderr:");
for line in stderr.lines() {
println!("{}", line);
}
}
println!("╚════════════════════════════════════════════════════════╝\n");
}
// ─── doctor ──────────────────────────────────────────────────────────────────
#[test]
fn doctor_healthy_system() {
let home = TempDir::new().unwrap();
populate_healthy(&home);
let output = weevil_cmd(&home)
.arg("doctor")
.output()
.expect("failed to run weevil doctor");
print_output("doctor_healthy_system", &output);
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.contains("✓ FTC SDK"), "expected FTC SDK check to pass");
assert!(stdout.contains("✓ Android SDK"), "expected Android SDK check to pass");
assert!(stdout.contains("System is healthy"), "expected healthy verdict");
}
#[test]
fn doctor_missing_ftc_sdk() {
let home = TempDir::new().unwrap();
// Only Android SDK present
create_mock_android_sdk(&home.path().join("android-sdk"));
let output = weevil_cmd(&home)
.arg("doctor")
.output()
.expect("failed to run weevil doctor");
print_output("doctor_missing_ftc_sdk", &output);
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.contains("✗ FTC SDK"), "expected FTC SDK failure");
assert!(stdout.contains("Issues found"), "expected issues verdict");
assert!(stdout.contains("weevil setup"), "expected setup suggestion");
}
#[test]
fn doctor_missing_android_sdk() {
let home = TempDir::new().unwrap();
populate_ftc_only(&home);
let output = weevil_cmd(&home)
.arg("doctor")
.output()
.expect("failed to run weevil doctor");
print_output("doctor_missing_android_sdk", &output);
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.contains("✗ Android SDK"), "expected Android SDK failure");
assert!(stdout.contains("Issues found"), "expected issues verdict");
}
#[test]
fn doctor_completely_empty() {
let home = TempDir::new().unwrap();
let output = weevil_cmd(&home)
.arg("doctor")
.output()
.expect("failed to run weevil doctor");
print_output("doctor_completely_empty", &output);
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.contains("✗ FTC SDK"), "expected FTC SDK failure");
assert!(stdout.contains("✗ Android SDK"), "expected Android SDK failure");
assert!(stdout.contains("Issues found"), "expected issues verdict");
}
// ─── uninstall ───────────────────────────────────────────────────────────────
#[test]
fn uninstall_dry_run_shows_contents() {
let home = TempDir::new().unwrap();
populate_healthy(&home);
let output = weevil_cmd(&home)
.args(&["uninstall", "--dry-run"])
.output()
.expect("failed to run weevil uninstall --dry-run");
print_output("uninstall_dry_run_shows_contents", &output);
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.contains("FTC SDK"), "expected FTC SDK in dry-run listing");
assert!(stdout.contains("weevil uninstall"), "expected full uninstall command");
assert!(stdout.contains("weevil uninstall --only"), "expected selective uninstall command");
// Nothing should actually be removed
assert!(home.path().join("ftc-sdk").exists(), "ftc-sdk should still exist after dry-run");
assert!(home.path().join("android-sdk").exists(), "android-sdk should still exist after dry-run");
}
#[test]
fn uninstall_dry_run_empty_system() {
let home = TempDir::new().unwrap();
let output = weevil_cmd(&home)
.args(&["uninstall", "--dry-run"])
.output()
.expect("failed to run weevil uninstall --dry-run");
print_output("uninstall_dry_run_empty_system", &output);
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.contains("No Weevil-managed components found"),
"expected empty message");
}
#[test]
fn uninstall_only_dry_run_shows_selection() {
let home = TempDir::new().unwrap();
populate_healthy(&home);
let output = weevil_cmd(&home)
.args(&["uninstall", "--only", "1", "--dry-run"])
.output()
.expect("failed to run weevil uninstall --only 1 --dry-run");
print_output("uninstall_only_dry_run_shows_selection", &output);
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.contains("Dry Run"), "expected dry run header");
assert!(home.path().join("ftc-sdk").exists(), "ftc-sdk should still exist after dry-run");
}
#[test]
fn uninstall_only_invalid_index() {
let home = TempDir::new().unwrap();
populate_healthy(&home);
let output = weevil_cmd(&home)
.args(&["uninstall", "--only", "99"])
.output()
.expect("failed to run weevil uninstall --only 99");
print_output("uninstall_only_invalid_index", &output);
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.contains("Invalid selection"), "expected invalid selection error");
assert!(home.path().join("ftc-sdk").exists(), "ftc-sdk should still exist after invalid selection");
}
// ─── new (requires setup) ────────────────────────────────────────────────────
#[test]
fn new_fails_when_system_not_setup() {
let home = TempDir::new().unwrap();
let output = weevil_cmd(&home)
.arg("new")
.arg("test-robot")
.output()
.expect("failed to run weevil new");
print_output("new_fails_when_system_not_setup", &output);
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(!output.status.success(), "weevil new should fail when system not set up");
assert!(stdout.contains("System Setup Required"), "expected setup required message");
assert!(stdout.contains("weevil setup"), "expected setup suggestion");
}
#[test]
fn new_fails_missing_ftc_sdk_only() {
let home = TempDir::new().unwrap();
create_mock_android_sdk(&home.path().join("android-sdk"));
let output = weevil_cmd(&home)
.arg("new")
.arg("test-robot")
.output()
.expect("failed to run weevil new");
print_output("new_fails_missing_ftc_sdk_only", &output);
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(!output.status.success(), "weevil new should fail with missing FTC SDK");
assert!(stdout.contains("FTC SDK"), "expected FTC SDK listed as missing");
assert!(stdout.contains("weevil setup"), "expected setup suggestion");
}
#[test]
fn new_fails_missing_android_sdk_only() {
let home = TempDir::new().unwrap();
populate_ftc_only(&home);
let output = weevil_cmd(&home)
.arg("new")
.arg("test-robot")
.output()
.expect("failed to run weevil new");
print_output("new_fails_missing_android_sdk_only", &output);
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(!output.status.success(), "weevil new should fail with missing Android SDK");
assert!(stdout.contains("Android SDK"), "expected Android SDK listed as missing");
assert!(stdout.contains("weevil setup"), "expected setup suggestion");
}
#[test]
fn new_shows_project_name_in_setup_suggestion() {
let home = TempDir::new().unwrap();
let output = weevil_cmd(&home)
.arg("new")
.arg("my-cool-robot")
.output()
.expect("failed to run weevil new");
print_output("new_shows_project_name_in_setup_suggestion", &output);
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.contains("weevil new my-cool-robot"),
"expected retry command with project name");
}
// ─── setup (project mode) ────────────────────────────────────────────────────
#[test]
fn setup_project_missing_toml() {
let home = TempDir::new().unwrap();
populate_healthy(&home);
let project_dir = home.path().join("empty-project");
fs::create_dir_all(&project_dir).unwrap();
let output = weevil_cmd(&home)
.arg("setup")
.arg(project_dir.to_str().unwrap())
.output()
.expect("failed to run weevil setup <project>");
print_output("setup_project_missing_toml", &output);
assert!(!output.status.success(), "setup should fail on missing .weevil.toml");
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(stderr.contains(".weevil.toml"), "expected .weevil.toml error");
}
#[test]
fn setup_project_nonexistent_directory() {
let home = TempDir::new().unwrap();
let output = weevil_cmd(&home)
.arg("setup")
.arg("/this/path/does/not/exist")
.output()
.expect("failed to run weevil setup");
print_output("setup_project_nonexistent_directory", &output);
assert!(!output.status.success(), "setup should fail on nonexistent directory");
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(stderr.contains("not found"), "expected not found error");
}
// ─── full lifecycle round-trip ───────────────────────────────────────────────
#[test]
fn lifecycle_new_uninstall_setup() {
let home = TempDir::new().unwrap();
let workspace = TempDir::new().unwrap(); // separate from WEEVIL_HOME
populate_healthy(&home);
// 1. Create a project — in workspace, not inside WEEVIL_HOME
let output = weevil_cmd(&home)
.arg("new")
.arg("my-robot")
.current_dir(workspace.path())
.output()
.expect("failed to run weevil new");
print_output("lifecycle (new)", &output);
assert!(output.status.success(), "weevil new failed");
let project_dir = workspace.path().join("my-robot");
assert!(project_dir.join(".weevil.toml").exists(), "project not created");
assert!(project_dir.join("src/main/java/robot").exists(), "project structure incomplete");
// 2. Run gradlew test — skeleton project should compile and pass out of the box.
// gradlew/gradlew.bat is cross-platform; pick the right one at runtime.
let gradlew = if cfg!(target_os = "windows") { "gradlew.bat" } else { "gradlew" };
let output = Command::new(project_dir.join(gradlew))
.arg("test")
.current_dir(&project_dir)
.output()
.expect("failed to run gradlew test");
print_output("lifecycle (gradlew test)", &output);
assert!(output.status.success(),
"gradlew test failed — new project should pass its skeleton tests out of the box");
// 3. Run gradlew compileJava — verify the project builds cleanly
let output = Command::new(project_dir.join(gradlew))
.arg("compileJava")
.current_dir(&project_dir)
.output()
.expect("failed to run gradlew compileJava");
print_output("lifecycle (gradlew compileJava)", &output);
assert!(output.status.success(), "gradlew compileJava failed — new project should compile cleanly");
// 4. Uninstall dependencies — project must survive
let output = weevil_cmd(&home)
.args(&["uninstall", "--dry-run"])
.output()
.expect("failed to run weevil uninstall --dry-run");
print_output("lifecycle (uninstall dry-run)", &output);
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.contains("FTC SDK"), "dry-run should show FTC SDK");
// Confirm project is untouched by dry-run
assert!(project_dir.join(".weevil.toml").exists(), "project deleted by dry-run");
// Now actually uninstall — feed "y" via stdin
let mut child = weevil_cmd(&home)
.arg("uninstall")
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.expect("failed to spawn weevil uninstall");
use std::io::Write;
child.stdin.as_mut().unwrap().write_all(b"y\n").unwrap();
let output = child.wait_with_output().expect("failed to wait on uninstall");
print_output("lifecycle (uninstall)", &output);
// Dependencies gone
assert!(!home.path().join("ftc-sdk").exists(), "ftc-sdk not removed by uninstall");
assert!(!home.path().join("android-sdk").exists(), "android-sdk not removed by uninstall");
// Project still there, completely intact
assert!(project_dir.exists(), "project directory was deleted by uninstall");
assert!(project_dir.join(".weevil.toml").exists(), ".weevil.toml deleted by uninstall");
assert!(project_dir.join("src/main/java/robot").exists(), "project source deleted by uninstall");
assert!(project_dir.join("build.gradle.kts").exists(), "build.gradle.kts deleted by uninstall");
// 3. Doctor confirms system is unhealthy now
let output = weevil_cmd(&home)
.arg("doctor")
.output()
.expect("failed to run weevil doctor");
print_output("lifecycle (doctor after uninstall)", &output);
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.contains("✗ FTC SDK"), "doctor should show FTC SDK missing");
assert!(stdout.contains("✗ Android SDK"), "doctor should show Android SDK missing");
// 4. Setup brings dependencies back
let output = weevil_cmd(&home)
.arg("setup")
.output()
.expect("failed to run weevil setup");
print_output("lifecycle (setup)", &output);
// Verify dependencies are back
assert!(home.path().join("ftc-sdk").exists(), "ftc-sdk not restored by setup");
// 5. Doctor confirms healthy again
let output = weevil_cmd(&home)
.arg("doctor")
.output()
.expect("failed to run weevil doctor");
print_output("lifecycle (doctor after setup)", &output);
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.contains("✓ FTC SDK"), "doctor should show FTC SDK healthy after setup");
}

View File

@@ -1,4 +0,0 @@
// File: tests/integration/mod.rs
// Integration tests module declarations
mod project_lifecycle_tests;

View File

@@ -1,185 +1,238 @@
// File: tests/integration/project_lifecycle_tests.rs // File: tests/integration/project_lifecycle_tests.rs
// Integration tests - full project lifecycle // Integration tests - full project lifecycle
//
// Same strategy as environment_tests: WEEVIL_HOME points to a TempDir,
// mock SDKs are created manually with fs, and we invoke the compiled
// binary directly rather than going through `cargo run`.
use tempfile::TempDir; use tempfile::TempDir;
use std::path::PathBuf;
use std::fs; use std::fs;
use std::process::Command; use std::process::Command;
use include_dir::{include_dir, Dir};
// Embed test fixtures /// Helper: returns a configured Command pointing at the weevil binary with
static MOCK_SDK: Dir = include_dir!("$CARGO_MANIFEST_DIR/tests/fixtures/mock-ftc-sdk"); /// WEEVIL_HOME set to the given temp directory.
fn weevil_cmd(weevil_home: &TempDir) -> Command {
let mut cmd = Command::new(assert_cmd::cargo::cargo_bin!("weevil"));
cmd.env("WEEVIL_HOME", weevil_home.path());
cmd
}
/// Helper: create a minimal mock FTC SDK at the given path.
fn create_mock_ftc_sdk(path: &std::path::Path) {
fs::create_dir_all(path.join("TeamCode/src/main/java")).unwrap();
fs::create_dir_all(path.join("FtcRobotController")).unwrap();
fs::write(path.join("build.gradle"), "// mock").unwrap();
fs::write(path.join(".version"), "v10.1.1\n").unwrap();
}
/// Helper: create a minimal mock Android SDK at the given path.
fn create_mock_android_sdk(path: &std::path::Path) {
fs::create_dir_all(path.join("platform-tools")).unwrap();
fs::write(path.join("platform-tools/adb"), "").unwrap();
}
/// Helper: populate a WEEVIL_HOME with both mock SDKs (fully healthy system)
fn populate_healthy(weevil_home: &TempDir) {
create_mock_ftc_sdk(&weevil_home.path().join("ftc-sdk"));
create_mock_android_sdk(&weevil_home.path().join("android-sdk"));
}
/// Helper: print labeled output from a test so it's visually distinct from test assertions
fn print_output(test_name: &str, output: &std::process::Output) {
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
println!("\n╔══ {} ══════════════════════════════════════════════╗", test_name);
if !stdout.is_empty() {
println!("║ stdout:");
for line in stdout.lines() {
println!("{}", line);
}
}
if !stderr.is_empty() {
println!("║ stderr:");
for line in stderr.lines() {
println!("{}", line);
}
}
println!("╚════════════════════════════════════════════════════════╝\n");
}
#[test] #[test]
fn test_project_creation_with_mock_sdk() { fn test_project_creation_with_mock_sdk() {
let test_dir = TempDir::new().unwrap(); let home = TempDir::new().unwrap();
let sdk_dir = test_dir.path().join("mock-sdk"); populate_healthy(&home);
let project_dir = test_dir.path().join("test-robot");
let output = weevil_cmd(&home)
// Extract mock SDK .arg("new")
MOCK_SDK.extract(&sdk_dir).unwrap(); .arg("test-robot")
.current_dir(home.path())
// Create project using weevil
let output = Command::new("cargo")
.args(&["run", "--", "new", "test-robot", "--ftc-sdk", sdk_dir.to_str().unwrap()])
.current_dir(env!("CARGO_MANIFEST_DIR"))
.output() .output()
.expect("Failed to run weevil"); .expect("Failed to run weevil new");
print_output("test_project_creation_with_mock_sdk", &output);
// Verify project was created
assert!(output.status.success(), "weevil new failed: {}", String::from_utf8_lossy(&output.stderr)); let project_dir = home.path().join("test-robot");
assert!(project_dir.join(".weevil.toml").exists()); assert!(output.status.success(), "weevil new failed");
assert!(project_dir.join("build.gradle.kts").exists()); assert!(project_dir.join(".weevil.toml").exists(), ".weevil.toml missing");
assert!(project_dir.join("src/main/java/robot").exists()); assert!(project_dir.join("build.gradle.kts").exists(), "build.gradle.kts missing");
assert!(project_dir.join("src/main/java/robot").exists(), "src/main/java/robot missing");
} }
#[test] #[test]
fn test_project_config_persistence() { fn test_project_config_persistence() {
let test_dir = TempDir::new().unwrap(); let home = TempDir::new().unwrap();
let sdk_dir = test_dir.path().join("mock-sdk"); populate_healthy(&home);
let project_dir = test_dir.path().join("config-test");
let output = weevil_cmd(&home)
// Extract mock SDK .arg("new")
MOCK_SDK.extract(&sdk_dir).unwrap(); .arg("config-test")
.current_dir(home.path())
// Create project
Command::new("cargo")
.args(&["run", "--", "new", "config-test", "--ftc-sdk", sdk_dir.to_str().unwrap()])
.current_dir(env!("CARGO_MANIFEST_DIR"))
.output() .output()
.expect("Failed to create project"); .expect("Failed to run weevil new");
print_output("test_project_config_persistence", &output);
// Read config assert!(output.status.success(), "weevil new failed");
let config_content = fs::read_to_string(project_dir.join(".weevil.toml")).unwrap();
let project_dir = home.path().join("config-test");
assert!(config_content.contains("project_name = \"config-test\"")); let config_content = fs::read_to_string(project_dir.join(".weevil.toml"))
assert!(config_content.contains(&format!("ftc_sdk_path = \"{}\"", sdk_dir.display()))); .expect(".weevil.toml not found");
assert!(config_content.contains("project_name = \"config-test\""),
"project_name missing from config:\n{}", config_content);
assert!(config_content.contains("ftc_sdk_path"),
"ftc_sdk_path missing from config:\n{}", config_content);
} }
#[test] #[test]
fn test_project_upgrade_preserves_code() { fn test_project_upgrade_preserves_code() {
let test_dir = TempDir::new().unwrap(); let home = TempDir::new().unwrap();
let sdk_dir = test_dir.path().join("mock-sdk"); populate_healthy(&home);
let project_dir = test_dir.path().join("upgrade-test");
// Extract mock SDK
MOCK_SDK.extract(&sdk_dir).unwrap();
// Create project // Create project
Command::new("cargo") let output = weevil_cmd(&home)
.args(&["run", "--", "new", "upgrade-test", "--ftc-sdk", sdk_dir.to_str().unwrap()]) .arg("new")
.current_dir(env!("CARGO_MANIFEST_DIR")) .arg("upgrade-test")
.current_dir(home.path())
.output() .output()
.expect("Failed to create project"); .expect("Failed to run weevil new");
print_output("test_project_upgrade_preserves_code (new)", &output);
assert!(output.status.success(), "weevil new failed");
let project_dir = home.path().join("upgrade-test");
// Add custom code // Add custom code
let custom_file = project_dir.join("src/main/java/robot/CustomCode.java"); let custom_file = project_dir.join("src/main/java/robot/CustomCode.java");
fs::write(&custom_file, "// My custom robot code").unwrap(); fs::write(&custom_file, "// My custom robot code").unwrap();
// Upgrade project // Upgrade
Command::new("cargo") let output = weevil_cmd(&home)
.args(&["run", "--", "upgrade", project_dir.to_str().unwrap()]) .arg("upgrade")
.current_dir(env!("CARGO_MANIFEST_DIR")) .arg(project_dir.to_str().unwrap())
.output() .output()
.expect("Failed to upgrade project"); .expect("Failed to run weevil upgrade");
print_output("test_project_upgrade_preserves_code (upgrade)", &output);
// Verify custom code still exists
assert!(custom_file.exists()); // Custom code survives
assert!(custom_file.exists(), "custom code file was deleted by upgrade");
let content = fs::read_to_string(&custom_file).unwrap(); let content = fs::read_to_string(&custom_file).unwrap();
assert!(content.contains("My custom robot code")); assert!(content.contains("My custom robot code"), "custom code was overwritten");
// Verify config was updated // Config still present
assert!(project_dir.join(".weevil.toml").exists()); assert!(project_dir.join(".weevil.toml").exists(), ".weevil.toml missing after upgrade");
assert!(!project_dir.join(".weevil-version").exists());
} }
#[test] #[test]
fn test_build_scripts_read_from_config() { fn test_build_scripts_read_from_config() {
let test_dir = TempDir::new().unwrap(); let home = TempDir::new().unwrap();
let sdk_dir = test_dir.path().join("mock-sdk"); populate_healthy(&home);
let project_dir = test_dir.path().join("build-test");
let output = weevil_cmd(&home)
// Extract mock SDK .arg("new")
MOCK_SDK.extract(&sdk_dir).unwrap(); .arg("build-test")
.current_dir(home.path())
// Create project
Command::new("cargo")
.args(&["run", "--", "new", "build-test", "--ftc-sdk", sdk_dir.to_str().unwrap()])
.current_dir(env!("CARGO_MANIFEST_DIR"))
.output() .output()
.expect("Failed to create project"); .expect("Failed to run weevil new");
print_output("test_build_scripts_read_from_config", &output);
// Check build.sh contains config reading assert!(output.status.success(), "weevil new failed");
let build_sh = fs::read_to_string(project_dir.join("build.sh")).unwrap();
assert!(build_sh.contains(".weevil.toml")); let project_dir = home.path().join("build-test");
assert!(build_sh.contains("ftc_sdk_path"));
let build_sh = fs::read_to_string(project_dir.join("build.sh"))
// Check build.bat contains config reading .expect("build.sh not found");
let build_bat = fs::read_to_string(project_dir.join("build.bat")).unwrap(); assert!(build_sh.contains(".weevil.toml"), "build.sh doesn't reference .weevil.toml");
assert!(build_bat.contains(".weevil.toml")); assert!(build_sh.contains("ftc_sdk_path"), "build.sh doesn't reference ftc_sdk_path");
assert!(build_bat.contains("ftc_sdk_path"));
let build_bat = fs::read_to_string(project_dir.join("build.bat"))
.expect("build.bat not found");
assert!(build_bat.contains(".weevil.toml"), "build.bat doesn't reference .weevil.toml");
assert!(build_bat.contains("ftc_sdk_path"), "build.bat doesn't reference ftc_sdk_path");
} }
#[test] #[test]
fn test_config_command_show() { fn test_config_command_show() {
let test_dir = TempDir::new().unwrap(); let home = TempDir::new().unwrap();
let sdk_dir = test_dir.path().join("mock-sdk"); populate_healthy(&home);
let project_dir = test_dir.path().join("config-show-test");
// Extract mock SDK
MOCK_SDK.extract(&sdk_dir).unwrap();
// Create project // Create project
Command::new("cargo") let output = weevil_cmd(&home)
.args(&["run", "--", "new", "config-show-test", "--ftc-sdk", sdk_dir.to_str().unwrap()]) .arg("new")
.current_dir(env!("CARGO_MANIFEST_DIR")) .arg("config-show-test")
.current_dir(home.path())
.output() .output()
.expect("Failed to create project"); .expect("Failed to run weevil new");
print_output("test_config_command_show (new)", &output);
assert!(output.status.success(), "weevil new failed");
let project_dir = home.path().join("config-show-test");
// Show config // Show config
let output = Command::new("cargo") let output = weevil_cmd(&home)
.args(&["run", "--", "config", project_dir.to_str().unwrap()]) .arg("config")
.current_dir(env!("CARGO_MANIFEST_DIR")) .arg(project_dir.to_str().unwrap())
.output() .output()
.expect("Failed to show config"); .expect("Failed to run weevil config");
print_output("test_config_command_show (config)", &output);
let stdout = String::from_utf8_lossy(&output.stdout); let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.contains("config-show-test")); assert!(stdout.contains("config-show-test"), "project name missing from config output");
assert!(stdout.contains(&sdk_dir.display().to_string()));
} }
#[test] #[test]
fn test_multiple_projects_different_sdks() { fn test_multiple_projects_different_sdks() {
let test_dir = TempDir::new().unwrap(); let home = TempDir::new().unwrap();
let sdk1 = test_dir.path().join("sdk-v10"); populate_healthy(&home);
let sdk2 = test_dir.path().join("sdk-v11");
let project1 = test_dir.path().join("robot1"); // Create a second FTC SDK with a different version
let project2 = test_dir.path().join("robot2"); let sdk2 = home.path().join("ftc-sdk-v11");
create_mock_ftc_sdk(&sdk2);
// Create two different SDK versions fs::write(sdk2.join(".version"), "v11.0.0\n").unwrap();
MOCK_SDK.extract(&sdk1).unwrap();
MOCK_SDK.extract(&sdk2).unwrap(); // Create first project (uses default ftc-sdk in WEEVIL_HOME)
fs::write(sdk2.join(".version"), "v11.0.0").unwrap(); let output = weevil_cmd(&home)
.arg("new")
// Create two projects with different SDKs .arg("robot1")
Command::new("cargo") .current_dir(home.path())
.args(&["run", "--", "new", "robot1", "--ftc-sdk", sdk1.to_str().unwrap()])
.current_dir(env!("CARGO_MANIFEST_DIR"))
.output() .output()
.expect("Failed to create project1"); .expect("Failed to create robot1");
print_output("test_multiple_projects_different_sdks (robot1)", &output);
Command::new("cargo") assert!(output.status.success(), "weevil new robot1 failed");
.args(&["run", "--", "new", "robot2", "--ftc-sdk", sdk2.to_str().unwrap()])
.current_dir(env!("CARGO_MANIFEST_DIR")) // Create second project — would need --ftc-sdk flag if supported,
// otherwise both use the same default. Verify they each have valid configs.
let output = weevil_cmd(&home)
.arg("new")
.arg("robot2")
.current_dir(home.path())
.output() .output()
.expect("Failed to create project2"); .expect("Failed to create robot2");
print_output("test_multiple_projects_different_sdks (robot2)", &output);
// Verify each project has correct SDK assert!(output.status.success(), "weevil new robot2 failed");
let config1 = fs::read_to_string(project1.join(".weevil.toml")).unwrap();
let config2 = fs::read_to_string(project2.join(".weevil.toml")).unwrap(); let config1 = fs::read_to_string(home.path().join("robot1/.weevil.toml"))
.expect("robot1 .weevil.toml missing");
assert!(config1.contains(&sdk1.display().to_string())); let config2 = fs::read_to_string(home.path().join("robot2/.weevil.toml"))
assert!(config2.contains(&sdk2.display().to_string())); .expect("robot2 .weevil.toml missing");
assert!(config1.contains("v10.1.1"));
assert!(config2.contains("v11.0.0")); assert!(config1.contains("project_name = \"robot1\""), "robot1 config wrong");
assert!(config2.contains("project_name = \"robot2\""), "robot2 config wrong");
assert!(config1.contains("ftc_sdk_path"), "robot1 missing ftc_sdk_path");
assert!(config2.contains("ftc_sdk_path"), "robot2 missing ftc_sdk_path");
} }

View File

@@ -6,6 +6,10 @@ use std::fs;
use weevil::project::{ProjectBuilder, ProjectConfig}; use weevil::project::{ProjectBuilder, ProjectConfig};
use weevil::sdk::SdkConfig; use weevil::sdk::SdkConfig;
#[path = "common.rs"]
mod common;
use common::EXPECTED_VERSION;
// Note: These tests use the actual FTC SDK if available, or skip if not // Note: These tests use the actual FTC SDK if available, or skip if not
// For true unit testing with mocks, we'd need to refactor to use dependency injection // For true unit testing with mocks, we'd need to refactor to use dependency injection
@@ -26,7 +30,7 @@ fn test_config_create_and_save() {
assert_eq!(config.project_name, "test-robot"); assert_eq!(config.project_name, "test-robot");
assert_eq!(config.ftc_sdk_path, sdk_path); assert_eq!(config.ftc_sdk_path, sdk_path);
assert_eq!(config.android_sdk_path, android_sdk_path); assert_eq!(config.android_sdk_path, android_sdk_path);
assert_eq!(config.weevil_version, "1.0.0"); assert_eq!(config.weevil_version, EXPECTED_VERSION);
// Save and reload // Save and reload
let project_path = temp_dir.path().join("project"); let project_path = temp_dir.path().join("project");
@@ -60,7 +64,7 @@ fn test_config_toml_format() {
let content = fs::read_to_string(project_path.join(".weevil.toml")).unwrap(); let content = fs::read_to_string(project_path.join(".weevil.toml")).unwrap();
assert!(content.contains("project_name = \"my-robot\"")); assert!(content.contains("project_name = \"my-robot\""));
assert!(content.contains("weevil_version = \"1.0.0\"")); assert!(content.contains(&format!("weevil_version = \"{}\"", EXPECTED_VERSION)));
assert!(content.contains("ftc_sdk_path")); assert!(content.contains("ftc_sdk_path"));
assert!(content.contains("ftc_sdk_version")); assert!(content.contains("ftc_sdk_version"));
assert!(content.contains("android_sdk_path")); assert!(content.contains("android_sdk_path"));

390
tests/proxy_integration.rs Normal file
View File

@@ -0,0 +1,390 @@
//! Integration tests for proxy support.
//!
//! Architecture per test:
//!
//! weevil (or ProxyConfig::client())
//! │ --proxy http://127.0.0.1:<proxy_port>
//! ▼
//! TestProxy ← real forwarding proxy, hyper HTTP/1.1 server
//! │ forwards absolute-form URI to origin
//! ▼
//! mockito origin ← fake dl.google.com / github.com, returns canned bytes
//!
//! This proves traffic actually traverses the proxy, not just that the download
//! works. The TestProxy struct is the only custom code; everything else is
//! standard mockito + assert_cmd.
use std::convert::Infallible;
use std::net::SocketAddr;
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Mutex;
// Tests that mutate HTTPS_PROXY / HTTP_PROXY must not run concurrently —
// those env vars are process-global. cargo test runs tests in parallel
// within a single binary by default, so we serialize access with this lock.
// Tests that don't touch env vars (or that only use --proxy flag) skip it.
static ENV_MUTEX: Mutex<()> = Mutex::new(());
use hyper::body::Bytes;
use hyper::server::conn::http1;
use hyper::service::service_fn;
use hyper_util::client::legacy::Client;
use hyper_util::client::legacy::connect::HttpConnector;
use hyper_util::rt::{TokioExecutor, TokioIo};
use http_body_util::{BodyExt, Full};
use tokio::net::TcpListener;
use weevil::sdk::proxy::ProxyConfig;
// ---------------------------------------------------------------------------
// TestProxy — a minimal HTTP forward proxy for use in tests.
//
// Binds to 127.0.0.1:0 (OS picks the port), spawns a tokio task to serve
// connections, and shuts down when dropped. Also counts how many requests
// it forwarded so tests can assert the proxy was actually hit.
// ---------------------------------------------------------------------------
/// A live forwarding proxy bound to a random local port.
struct TestProxy {
addr: SocketAddr,
request_count: Arc<AtomicU64>,
/// Dropping this handle shuts the server down.
_shutdown: tokio::sync::mpsc::Sender<()>,
}
/// The actual proxy handler, extracted to a named async fn.
///
/// You cannot put a return-type annotation on an `async move { }` block, and
/// you cannot use `-> impl Future` on a closure inside `service_fn` because
/// the opaque future type is unnameable. A named `async fn` is the one place
/// Rust lets you write both `async` and an explicit return type in the same
/// spot — the compiler knows the concrete future type and can verify the
/// `Into<Box<dyn Error>>` bound that `serve_connection` requires.
async fn proxy_handler(
client: Client<HttpConnector, Full<Bytes>>,
counter: Arc<AtomicU64>,
req: hyper::Request<hyper::body::Incoming>,
) -> Result<hyper::Response<Full<Bytes>>, Infallible> {
counter.fetch_add(1, Ordering::Relaxed);
// The client sends the request in absolute-form:
// GET http://origin:PORT/path HTTP/1.1
// hyper parses that into req.uri() for us.
let uri = req.uri().clone();
// Collect the incoming body so we can forward it.
let body_bytes = req.into_body().collect().await
.map(|b| b.to_bytes())
.unwrap_or_default();
let forwarded = hyper::Request::builder()
.method("GET")
.uri(uri)
.body(Full::new(body_bytes))
.unwrap();
match client.request(forwarded).await {
Ok(upstream_resp) => {
// Collect the upstream body and re-wrap so we return a concrete
// body type (not Incoming).
let status = upstream_resp.status();
let headers = upstream_resp.headers().clone();
let collected = upstream_resp.into_body().collect().await
.map(|b| b.to_bytes())
.unwrap_or_default();
let mut resp = hyper::Response::builder()
.status(status)
.body(Full::new(collected))
.unwrap();
*resp.headers_mut() = headers;
Ok(resp)
}
Err(_) => Ok(
hyper::Response::builder()
.status(502)
.body(Full::new(Bytes::from("Bad Gateway")))
.unwrap()
),
}
}
impl TestProxy {
async fn start() -> Self {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let count = Arc::new(AtomicU64::new(0));
let count_clone = count.clone();
let (shutdown_tx, mut shutdown_rx) = tokio::sync::mpsc::channel::<()>(1);
let hyper_client: Client<HttpConnector, Full<Bytes>> =
Client::builder(TokioExecutor::new()).build_http();
tokio::spawn(async move {
loop {
let stream = tokio::select! {
Ok((stream, _peer)) = listener.accept() => stream,
_ = shutdown_rx.recv() => break,
else => break,
};
let client = hyper_client.clone();
let counter = count_clone.clone();
tokio::spawn(async move {
let io = TokioIo::new(stream);
// The closure just delegates to proxy_handler. The named
// fn's return type is visible to the compiler so it can
// resolve the Error associated type that serve_connection
// needs.
let _ = http1::Builder::new()
.serve_connection(io, service_fn(move |req| {
proxy_handler(client.clone(), counter.clone(), req)
}))
.await;
});
}
});
Self {
addr,
request_count: count,
_shutdown: shutdown_tx,
}
}
fn proxy_url(&self) -> String {
format!("http://{}", self.addr)
}
fn requests_forwarded(&self) -> u64 {
self.request_count.load(Ordering::Relaxed)
}
}
// ---------------------------------------------------------------------------
// Tests: ProxyConfig::client() talking through TestProxy to mockito.
//
// reqwest's blocking::Client owns an internal tokio Runtime. Dropping it
// inside an async context panics on tokio ≥ 1.49 ("Cannot drop a runtime in
// a context where blocking is not allowed"). Each test therefore does its
// reqwest work — client creation, HTTP calls, and drop — inside
// spawn_blocking, which runs on a thread-pool thread where blocking is fine.
// The mockito origin and TestProxy stay in the async body because they need
// the tokio runtime for their listeners.
// ---------------------------------------------------------------------------
#[tokio::test]
async fn proxy_forwards_request_to_origin() {
let mut origin = mockito::Server::new_async().await;
let _mock = origin
.mock("GET", "/sdk.zip")
.with_status(200)
.with_header("content-type", "application/octet-stream")
.with_body(b"fake-sdk-bytes".as_slice())
.create_async()
.await;
let proxy = TestProxy::start().await;
let proxy_url = proxy.proxy_url();
let origin_url = origin.url();
let (status, body) = tokio::task::spawn_blocking(move || {
let config = ProxyConfig::resolve(Some(&proxy_url), false).unwrap();
let client = config.client().unwrap();
let resp = client.get(format!("{}/sdk.zip", origin_url)).send().unwrap();
(resp.status().as_u16(), resp.text().unwrap())
}).await.unwrap();
assert_eq!(status, 200);
assert_eq!(body, "fake-sdk-bytes");
assert_eq!(proxy.requests_forwarded(), 1);
}
#[tokio::test]
async fn no_proxy_bypasses_proxy_entirely() {
let _env_lock = ENV_MUTEX.lock().unwrap();
let mut origin = mockito::Server::new_async().await;
let _mock = origin
.mock("GET", "/direct.txt")
.with_status(200)
.with_body("direct-hit")
.create_async()
.await;
let proxy = TestProxy::start().await;
let proxy_url = proxy.proxy_url();
let origin_url = origin.url();
let (status, body) = tokio::task::spawn_blocking(move || {
std::env::set_var("HTTPS_PROXY", &proxy_url);
let config = ProxyConfig::resolve(None, true).unwrap();
std::env::remove_var("HTTPS_PROXY");
let client = config.client().unwrap();
let resp = client.get(format!("{}/direct.txt", origin_url)).send().unwrap();
(resp.status().as_u16(), resp.text().unwrap())
}).await.unwrap();
assert_eq!(status, 200);
assert_eq!(body, "direct-hit");
assert_eq!(proxy.requests_forwarded(), 0);
}
#[tokio::test]
async fn env_var_proxy_is_picked_up() {
let _env_lock = ENV_MUTEX.lock().unwrap();
let mut origin = mockito::Server::new_async().await;
let _mock = origin
.mock("GET", "/env.txt")
.with_status(200)
.with_body("via-env-proxy")
.create_async()
.await;
let proxy = TestProxy::start().await;
let proxy_url = proxy.proxy_url();
let origin_url = origin.url();
let (status, body) = tokio::task::spawn_blocking(move || {
std::env::remove_var("HTTP_PROXY");
std::env::remove_var("http_proxy");
std::env::remove_var("https_proxy");
std::env::set_var("HTTPS_PROXY", &proxy_url);
let config = ProxyConfig::resolve(None, false).unwrap();
std::env::remove_var("HTTPS_PROXY");
let client = config.client().unwrap();
let resp = client.get(format!("{}/env.txt", origin_url)).send().unwrap();
(resp.status().as_u16(), resp.text().unwrap())
}).await.unwrap();
assert_eq!(status, 200);
assert_eq!(body, "via-env-proxy");
assert_eq!(proxy.requests_forwarded(), 1);
}
#[tokio::test]
async fn explicit_proxy_flag_overrides_env() {
let _env_lock = ENV_MUTEX.lock().unwrap();
let mut origin = mockito::Server::new_async().await;
let _mock = origin
.mock("GET", "/override.txt")
.with_status(200)
.with_body("flag-proxy-wins")
.create_async()
.await;
let decoy = TestProxy::start().await;
let real = TestProxy::start().await;
let decoy_url = decoy.proxy_url();
let real_url = real.proxy_url();
let origin_url = origin.url();
let (status, body) = tokio::task::spawn_blocking(move || {
std::env::set_var("HTTPS_PROXY", &decoy_url);
let config = ProxyConfig::resolve(Some(&real_url), false).unwrap();
std::env::remove_var("HTTPS_PROXY");
let client = config.client().unwrap();
let resp = client.get(format!("{}/override.txt", origin_url)).send().unwrap();
(resp.status().as_u16(), resp.text().unwrap())
}).await.unwrap();
assert_eq!(status, 200);
assert_eq!(body, "flag-proxy-wins");
assert_eq!(real.requests_forwarded(), 1);
assert_eq!(decoy.requests_forwarded(), 0);
}
#[tokio::test]
async fn proxy_returns_502_when_origin_is_unreachable() {
let proxy = TestProxy::start().await;
let proxy_url = proxy.proxy_url();
let status = tokio::task::spawn_blocking(move || {
let config = ProxyConfig::resolve(Some(&proxy_url), false).unwrap();
let client = config.client().unwrap();
let resp = client.get("http://127.0.0.1:1/unreachable").send().unwrap();
resp.status().as_u16()
}).await.unwrap();
assert_eq!(status, 502);
assert_eq!(proxy.requests_forwarded(), 1);
}
#[tokio::test]
async fn multiple_sequential_requests_all_forwarded() {
let mut origin = mockito::Server::new_async().await;
let _m1 = origin.mock("GET", "/a").with_status(200).with_body("aaa").create_async().await;
let _m2 = origin.mock("GET", "/b").with_status(200).with_body("bbb").create_async().await;
let _m3 = origin.mock("GET", "/c").with_status(200).with_body("ccc").create_async().await;
let proxy = TestProxy::start().await;
let proxy_url = proxy.proxy_url();
let origin_url = origin.url();
let (a, b, c) = tokio::task::spawn_blocking(move || {
let config = ProxyConfig::resolve(Some(&proxy_url), false).unwrap();
let client = config.client().unwrap();
let a = client.get(format!("{}/a", origin_url)).send().unwrap().text().unwrap();
let b = client.get(format!("{}/b", origin_url)).send().unwrap().text().unwrap();
let c = client.get(format!("{}/c", origin_url)).send().unwrap().text().unwrap();
(a, b, c)
}).await.unwrap();
assert_eq!(a, "aaa");
assert_eq!(b, "bbb");
assert_eq!(c, "ccc");
assert_eq!(proxy.requests_forwarded(), 3);
}
// ---------------------------------------------------------------------------
// CLI-level tests
// ---------------------------------------------------------------------------
#[allow(deprecated)] // cargo_bin_cmd! requires assert_cmd ≥ 2.2; we're on 2.1.2
#[test]
fn cli_help_shows_proxy_flags() {
let mut cmd = assert_cmd::Command::cargo_bin("weevil").unwrap();
let assert = cmd.arg("--help").assert();
assert
.success()
.stdout(predicates::prelude::predicate::str::contains("--proxy"))
.stdout(predicates::prelude::predicate::str::contains("--no-proxy"));
}
#[allow(deprecated)]
#[test]
fn cli_rejects_garbage_proxy_url() {
let mut cmd = assert_cmd::Command::cargo_bin("weevil").unwrap();
let assert = cmd
.arg("--proxy")
.arg("not-a-url-at-all")
.arg("sdk")
.arg("install")
.assert();
assert
.failure()
.stderr(predicates::prelude::predicate::str::contains("Invalid --proxy URL"));
}
#[allow(deprecated)]
#[test]
fn cli_proxy_and_no_proxy_are_mutually_exclusive_in_effect() {
let mut cmd = assert_cmd::Command::cargo_bin("weevil").unwrap();
let out = cmd
.arg("--proxy")
.arg("http://127.0.0.1:9999")
.arg("--no-proxy")
.arg("sdk")
.arg("install")
.output()
.expect("weevil binary failed to execute");
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(!stderr.contains("panic"), "binary panicked: {}", stderr);
}

View File

@@ -6,6 +6,10 @@ use std::path::PathBuf;
use tempfile::TempDir; use tempfile::TempDir;
use std::fs; use std::fs;
#[path = "../common.rs"]
mod common;
use common::EXPECTED_VERSION;
#[test] #[test]
fn test_config_create_and_save() { fn test_config_create_and_save() {
let temp_dir = TempDir::new().unwrap(); let temp_dir = TempDir::new().unwrap();
@@ -15,7 +19,7 @@ fn test_config_create_and_save() {
assert_eq!(config.project_name, "test-robot"); assert_eq!(config.project_name, "test-robot");
assert_eq!(config.ftc_sdk_path, sdk_path); assert_eq!(config.ftc_sdk_path, sdk_path);
assert_eq!(config.weevil_version, "1.0.0"); assert_eq!(config.weevil_version, EXPECTED_VERSION);
// Save and reload // Save and reload
config.save(temp_dir.path()).unwrap(); config.save(temp_dir.path()).unwrap();
@@ -45,7 +49,7 @@ fn test_config_toml_format() {
let content = fs::read_to_string(temp_dir.path().join(".weevil.toml")).unwrap(); let content = fs::read_to_string(temp_dir.path().join(".weevil.toml")).unwrap();
assert!(content.contains("project_name = \"my-robot\"")); assert!(content.contains("project_name = \"my-robot\""));
assert!(content.contains("weevil_version = \"1.0.0\"")); assert!(content.contains(&format!("weevil_version = \"{}\"", EXPECTED_VERSION)));
assert!(content.contains("ftc_sdk_path")); assert!(content.contains("ftc_sdk_path"));
} }