diff --git a/.agents/skills/vx-best-practices/SKILL.md b/.agents/skills/vx-best-practices/SKILL.md new file mode 100644 index 000000000..b757c5b0e --- /dev/null +++ b/.agents/skills/vx-best-practices/SKILL.md @@ -0,0 +1,557 @@ +--- +name: vx-best-practices +description: "Best practices for using vx effectively. Use when following recommended patterns for tool management, project setup, and team workflows with vx." +--- + +# VX Best Practices + +> **Golden rule**: Always prefix tool commands with `vx` in vx-managed projects. Use `vx.toml` for project-level tool versions, commit `vx.lock` for reproducibility, and prefer templates over custom code when creating providers. + +## General Principles + +### 1. Always Use `vx` Prefix + +In vx-managed projects, always prefix tool commands with `vx`: + +```bash +# ✅ Correct +vx npm install +vx cargo build +vx just test + +# ❌ Wrong (might use system tools) +npm install +cargo build +just test +``` + +### 2. Prefer Project-Level Configuration + +Use `vx.toml` to ensure consistency across team members: + +```bash +# ✅ Correct - defined in vx.toml +vx sync + +# ❌ Wrong - manual installation +vx install node@22 +``` + +### 3. Commit Lock Files + +Always commit `vx.lock` to ensure reproducible builds: + +```bash +git add vx.lock +git commit -m "chore: update dependencies" +``` + +### 4. Keep Agent Work Small and Observable + +When an AI agent uses vx, optimize for correctness, speed, judgment, and token +efficiency: + +- Read enough surrounding code to understand the local pattern, then stop exploring. +- Prefer targeted searches, focused file sections, scoped diffs, selected JSON fields, and capped logs. +- Make the smallest maintainable change that solves the request. +- Reuse existing project helpers before creating new abstractions. +- Avoid single-use wrappers, speculative architecture, and unrelated cleanup. +- Validate according to risk: focused tests for narrow changes, broader checks for shared behavior. +- Preserve evidence from the actual command, CI job, or runtime surface when debugging. + +For large or unknown output, scope first and filter through vx-managed tools: + +```bash +vx rg -n -m 20 "SearchTerm" src +vx git diff --stat +vx git diff --name-only origin/main...HEAD +vx gh pr view 123 --json title,state,files +vx gh run view 456 --log | vx rg -n -m 50 "error|failed|panic" +``` + +## Project Setup + +### Initial Setup + +```bash +# 1. Initialize project +vx init + +# 2. Add required tools +vx add node@22 +vx add go +vx add just + +# 3. Generate lock file +vx lock + +# 4. Commit configuration +git add vx.toml vx.lock +``` + +### Team Onboarding + +New team members only need to run: + +```bash +# Clone repository +git clone +cd + +# One command setup +vx setup +``` + +### CI/CD Configuration + +Use the vx GitHub Action: + +```yaml +# .github/workflows/ci.yml +- uses: loonghao/vx@main + with: + setup: 'true' + cache: 'true' +``` + +## Version Management + +### Version Selection Strategy + +| Scenario | Constraint | Example | +|----------|------------|---------| +| Development | Major version | `node = "22"` | +| CI/CD | Exact version | `node = "22.0.0"` | +| Library | Range | `node = ">=18 <23"` | +| Latest features | `"latest"` | `uv = "latest"` | + +### Avoid Over-Specification + +```toml +# ✅ Good - flexible for patch updates +[tools] +node = "22" +go = "1.22" + +# ❌ Bad - too rigid for development +[tools] +node = "22.0.0" # Blocks security patches +go = "1.22.0" # Requires update for each patch +``` + +### LTS for Production + +```toml +# Use LTS versions for stability +[tools] +node = "lts" # Auto-updates to latest LTS +``` + +## Scripts Organization + +### Naming Conventions + +```toml +[scripts] +# Standard scripts (run with: vx run ) +dev = "npm run dev" +test = "npm run test" +build = "npm run build" +lint = "npm run lint" + +# Colon-separated variants (run with: vx run test:watch) +test:watch = "npm run test -- --watch" +test:coverage = "npm run test -- --coverage" +build:prod = "npm run build -- --mode production" + +# CI-specific scripts +ci = "just ci" +ci:test = "cargo test --all-features" +``` + +### Script Dependencies + +```toml +# Scripts can depend on specific tools +[scripts] +lint = "eslint . && cargo clippy" # Uses node and rust +build = "go build ./..." # Uses go +``` + +## Environment Variables + +### Project-Level Defaults + +```toml +[env] +NODE_ENV = "development" +DEBUG = "app:*" + +# Required variables (vx will warn if missing) +API_KEY = { env = "API_KEY", required = true } + +# Default values +PORT = { default = "3000" } +``` + +### Secrets Management + +Never commit secrets. Use environment variables: + +```bash +# .env (add to .gitignore) +DATABASE_URL=postgresql://... +API_KEY=secret123 + +# Reference in vx.toml +[env] +DATABASE_URL = { env = "DATABASE_URL" } +``` + +## Performance Optimization + +### Cache Configuration + +```toml +[cache] +# Enable aggressive caching +enabled = true +ttl = 86400 # 24 hours + +# Cache location +dir = "~/.vx/cache" +``` + +### Pre-install Common Tools + +```bash +# In project setup, pre-install tools +vx sync --parallel +``` + +### Use Offline Mode + +```bash +# When network is unreliable +vx sync --offline +``` + +## Cross-Platform Considerations + +### Platform-Specific Tools + +```toml +[tools] +# Cross-platform tools first +node = "22" +uv = "latest" + +# Platform-specific +[tools.msvc] +version = "14.42" +os = ["windows"] + +[tools.brew] +version = "latest" +os = ["macos", "linux"] +``` + +### Cross-Platform Scripts + +```bash +# Use tools that work everywhere +[scripts] +build = "just build" # just is cross-platform +test = "cargo test" # cargo is cross-platform + +# Avoid platform-specific commands +# ❌ build = "make build" # Unix only +``` + +## Security Best Practices + +### Verify Checksums + +vx automatically verifies checksums. For additional security: + +```bash +# Verify installation +vx install node@22 --verify +``` + +### Minimal Permissions + +```bash +# Install as regular user, not root +# ❌ sudo vx install node + +# vx manages user-level installations +vx install node +``` + +### Audit Dependencies + +```bash +# Audit installed tools +vx audit + +# Check for vulnerabilities +vx npm audit +``` + +## Team Workflows + +### Adding New Tools + +```bash +# 1. Add to vx.toml +vx add python@3.12 + +# 2. Update lock file +vx lock + +# 3. Commit changes +git add vx.toml vx.lock +git commit -m "feat: add python 3.12" +``` + +### Updating Tools + +```bash +# 1. Check for updates +vx outdated + +# 2. Update specific tool +vx update node + +# 3. Update all tools +vx update --all + +# 4. Commit lock file changes +git add vx.lock +``` + +### Removing Tools + +```bash +# 1. Remove from vx.toml +vx remove tool-name + +# 2. Clean up installation +vx sync --clean + +# 3. Commit changes +git add vx.toml vx.lock +``` + +## Monitoring & Maintenance + +### Regular Checks + +```bash +# Weekly: Check for updates +vx outdated + +# Monthly: Clean cache +vx cache clean + +# After issues: Run diagnostics +vx doctor +``` + +### Health Check Script + +```toml +[scripts] +doctor = "vx doctor" +check = "vx check" +audit = "vx npm audit && cargo audit" +``` + +## Anti-Patterns to Avoid + +### 1. Manual Tool Installation + +```bash +# ❌ Don't manually install tools in vx projects +npm install -g node # Conflicts with vx + +# ✅ Let vx manage tools +vx npm install +``` + +### 2. Ignoring Lock Files + +```bash +# ❌ Don't ignore vx.lock +git rm --cached vx.lock + +# ✅ Always commit lock files +git add vx.lock +``` + +### 3. Global vx.toml + +```bash +# ❌ Don't rely on global configuration +~/.vx/vx.toml + +# ✅ Use project-level configuration +./vx.toml +``` + +### 4. Hardcoded Paths + +```toml +# ❌ Don't hardcode absolute paths +[env] +TOOL_PATH = "/Users/alice/.vx/tools/node" + +# ✅ Use vx variables +[env] +TOOL_PATH = "${VX_ROOT}/tools/node" +``` + +## Migration Guide + +### From nvm/fnm → vx + +```bash +# 1. Export current versions +node --version > .nvmrc + +# 2. Create vx.toml +vx init + +# 3. Add node version +vx add node@$(cat .nvmrc | tr -d 'v') + +# 4. Remove nvm +rm -rf ~/.nvm +``` + +### From pyenv → vx + +```bash +# 1. Check current Python version +python --version + +# 2. Create vx.toml +vx init + +# 3. Add uv (recommended Python manager) +vx add uv + +# 4. Remove pyenv +rm -rf ~/.pyenv +``` + +## Provider Development Best Practices + +### Choose the Right Template + +**Decision tree for new providers**: +- Tool releases on GitHub with Rust target triple? → `github_rust_provider` (most common) +- Tool releases on GitHub with Go goreleaser? → `github_go_provider` +- Single binary download (no archive)? → `github_binary_provider` +- System package manager only? → `system_provider` +- Custom download source? → Hand-write `download_url` function + +For most tools, use a template instead of writing custom functions: + +```starlark +# ✅ Good — Use template (10 lines) +_p = github_rust_provider("owner", "tool", + asset = "tool-{vversion}-{triple}.{ext}") + +# ❌ Avoid — Custom download_url when template works +def download_url(ctx, version): + # 30+ lines of custom code... +``` + +### Understanding the ctx Object + +All provider.star functions receive a `ctx` object: + +| Field | Example value | Description | +|-------|--------------|-------------| +| `ctx.platform.os` | `"windows"`, `"macos"`, `"linux"` | Current OS | +| `ctx.platform.arch` | `"x64"`, `"arm64"` | CPU architecture | +| `ctx.install_dir` | `~/.vx/store/node/22.0.0` | Install path | +| `ctx.store_dir` | `~/.vx/store` | Global store root | +| `ctx.cache_dir` | `~/.vx/cache` | Cache directory | + +### Provider Naming + +```starlark +# ✅ Correct terminology +name = "ripgrep" # Provider name +runtimes = [runtime_def("rg")] # Runtime name (what user types) + +# Common pattern: provider name = project name, runtime name = binary name +# ripgrep provider → rg runtime +# rust provider → cargo, rustc, rustup runtimes +``` + +### Platform Constraints + +Return `None` from `download_url` for unsupported platforms: + +```starlark +def download_url(ctx, version): + platform = platform_map(ctx, _PLATFORMS) + if not platform: + return None # Not supported on this platform + return "https://example.com/v{}/tool-{}.tar.gz".format(version, platform) +``` + +### Bundled Runtimes + +Use `bundled_runtime_def` for tools shipped inside another: + +```starlark +runtimes = [ + runtime_def("node"), # Primary runtime + bundled_runtime_def("npm", "node"), # npm comes with node + bundled_runtime_def("npx", "node"), # npx comes with node +] +``` + +## vx Development Best Practices + +### Quick Development Cycle + +```bash +vx just quick # format → lint → test → build +vx cargo check -p vx-cli # Fast type-checking for one crate +vx cargo test -p vx-starlark # Test one crate +``` + +### Code Organization Rules + +1. **Layer dependencies go downward only** — Never import from higher layers +2. **Tests in `tests/` directories** — Never inline `#[cfg(test)]` +3. **Use `rstest`** for parameterized tests +4. **Use `tracing`** for logging, never `println!` or `eprintln!` +5. **Use correct terminology** — Runtime, Provider, provider.star + +## AI Agent Documentation Ecosystem + +vx maintains a comprehensive set of AI agent configuration files for 15+ agents: + +| File | Purpose | Audience | +|------|---------|----------| +| `AGENTS.md` | Primary AI agent entry point — rules, architecture, quick reference | All AI coding agents (official standard) | +| `CLAUDE.md` | Claude Code specific instructions with `@`-import support | Claude Code | +| `llms.txt` | Concise LLM-friendly project index (llmstxt.org protocol) | LLMs discovering the project | +| `llms-full.txt` | Detailed LLM documentation with full examples | LLMs needing deep context | +| `.github/copilot-instructions.md` | GitHub Copilot-specific instructions | GitHub Copilot | +| `.cursor/rules/*.mdc` | Modern Cursor IDE rules with YAML frontmatter (4 files) | Cursor AI (new format) | +| `.cursorrules` | Cursor IDE agent rules (legacy format, still supported) | Cursor AI (legacy) | +| `.clinerules` | Cline/Roo agent rules | Cline | +| `.windsurfrules` | Windsurf AI IDE rules | Windsurf | +| `.kiro/steering/*.md` | Kiro AI IDE steering documents | Kiro | +| `.trae/rules/*.md` | Trae AI IDE project rules | Trae | +| `skills/` | Distributable skill files for 15+ AI agents | ClawHub, vx ai setup | + +**Best practice**: When making changes that affect AI agent behavior (terminology, architecture, commands), update `AGENTS.md` first — it is the single source of truth. Other files derive from it. diff --git a/.agents/skills/vx-commands/SKILL.md b/.agents/skills/vx-commands/SKILL.md new file mode 100644 index 000000000..e7377a848 --- /dev/null +++ b/.agents/skills/vx-commands/SKILL.md @@ -0,0 +1,209 @@ +--- +name: vx-commands +description: "Complete vx CLI command reference. Use when looking up specific vx command syntax, flags, or output formats. All commands support --json for structured output and --output-format toon for token-optimized output." +--- + +# VX Command Reference + +> **Quick rule**: All vx commands support `--json` for structured output and `--output-format toon` for token-optimized output (saves 40-60% tokens). Set `VX_OUTPUT=json` to default all commands to JSON. + +## Structured Output Commands (AI-Optimized) + +All commands support `--json` for structured output and `--output-format toon` for token-optimized output (saves 40-60% tokens). + +### Project Analysis + +```bash +vx analyze --json # Analyze project structure +vx check --json # Verify tool constraints +vx ai context --json # Generate AI-friendly project context +``` + +**Output fields (analyze)**: +- `ecosystems[]` - Detected ecosystems (nodejs, python, rust, go) +- `dependencies[]` - Project dependencies +- `scripts[]` - Available scripts +- `required_tools[]` - Tools needed + +**Output fields (check)**: +- `requirements[]` - Tool requirement status +- `all_satisfied` - Whether all constraints are met +- `missing_tools[]` - Tools that need installation + +**Output fields (ai context)**: +- `project` - Project info (name, languages, frameworks) +- `tools[]` - Installed tools with versions +- `scripts[]` - Available scripts +- `constraints[]` - Tool constraints + +### Tool Management + +```bash +vx list --json # List installed tools +vx versions node --json # List available versions +vx which node --json # Find tool location +vx search --json # Search for tools +``` + +**Output fields (list)**: +- `runtimes[]` - Available runtimes +- `total` - Total count +- `installed_count` - Installed count + +**Output fields (versions)**: +- `versions[]` - Available versions with metadata +- `latest` - Latest version +- `lts` - LTS version (if applicable) + +**Output fields (which)**: +- `path` - Executable path +- `version` - Resolved version +- `source` - Source (vx, system, global_package) + +### Installation + +```bash +vx install node@22 --json # Install tool +vx sync --json # Sync from vx.toml +``` + +**Output fields (install)**: +- `runtime` - Tool name +- `version` - Installed version +- `path` - Installation path +- `duration_ms` - Installation duration +- `dependencies_installed[]` - Dependencies also installed + +**Output fields (sync)**: +- `installed[]` - Successfully installed +- `skipped[]` - Skipped (already installed) +- `failed[]` - Failed installations +- `duration_ms` - Total duration + +### AI Integration + +```bash +vx ai context # Generate AI-friendly context (Markdown) +vx ai context --json # JSON format +vx ai context --minimal # Minimal output +vx ai session init # Initialize session state +vx ai session status # Show session status +vx ai session cleanup # Clean up session +``` + +### Environment + +```bash +vx env --json # Show environment variables +vx dev --export # Export shell environment +``` + +## Output Formats + +### JSON Format +```bash +vx list --json +# Output: {"runtimes": [...], "total": 50, "installed_count": 5} +``` + +### TOON Format (Token-Optimized) +```bash +vx list --output-format toon +# Output: +# runtimes[50]{name,installed,description}: +# node,true,Node.js runtime +# python,false,Python runtime +# ... +``` + +TOON format is recommended for AI agents - it saves 40-60% tokens compared to JSON. + +### Environment Variable +```bash +export VX_OUTPUT=json # Default to JSON output +export VX_OUTPUT=toon # Default to TOON output +``` + +## Command Groups + +### Tool Execution +```bash +vx [args...] # Run any tool +vx npm install # Run npm +vx cargo build # Run cargo +``` + +### Advanced Execution Syntax +```bash +# Runtime with version +vx node@22 app.js # Use specific Node.js version + +# Runtime executable override +vx msvc@14.42::cl main.cpp # Run cl from MSVC runtime + +# Package execution (ecosystem:package pattern) +vx npm:vite # Run vite via npm +vx uv:ruff check . # Run ruff via uv +vx npm:typescript@5.0::tsc # Run tsc from specific typescript version + +# Package aliases (shortcuts) +vx vite # Same as: vx npm:vite +vx meson # Same as: vx uv:meson + +# Multi-runtime composition +vx --with bun@1.1 --with deno node app.js # Multiple runtimes in PATH +``` + +### Tool Management +```bash +vx install @ # Install tool +vx uninstall # Uninstall tool +vx list # List tools +vx versions # Show versions +vx which # Find tool path +vx switch @ # Switch version +``` + +### Project Management +```bash +vx init # Initialize vx.toml +vx add # Add tool to project +vx remove # Remove tool from project +vx sync # Sync tools from vx.toml +vx lock # Generate vx.lock +vx check # Check constraints +``` + +### Script Execution +```bash +vx run + + + +

VX Performance Report

+

Generated {{TIMESTAMP}} · {{COUNT}} runs analyzed

+ +
+
+

Summary Statistics

+
+
+ +
+

Latest Run Breakdown

+ +
+ +
+

Performance Over Time

+ +
+ +
+

Stage Stacked Area

+ +
+ +
+

Performance Insights

+
+
+ +
+

Run History

+ + + + + +
TimeCommandTotalResolveEnsurePrepareExecuteExit
+
+
+ + + + diff --git a/crates/vx-metrics/tests/exporter_tests.rs b/crates/vx-metrics/tests/exporter_tests.rs new file mode 100644 index 000000000..4f583f6e3 --- /dev/null +++ b/crates/vx-metrics/tests/exporter_tests.rs @@ -0,0 +1,115 @@ +use rstest::rstest; +use vx_metrics::exporter::{JsonFileExporter, SpanRecord}; + +#[test] +fn test_new_exporter_has_no_spans() { + let exporter = JsonFileExporter::new(); + let spans = exporter.take_spans(); + assert!(spans.is_empty()); +} + +#[test] +fn test_take_spans_drains_buffer() { + let exporter = JsonFileExporter::new(); + + // First take: empty + let spans = exporter.take_spans(); + assert!(spans.is_empty()); + + // Second take: still empty (no spans added externally) + let spans = exporter.take_spans(); + assert!(spans.is_empty()); +} + +#[test] +fn test_exporter_debug_format() { + let exporter = JsonFileExporter::new(); + let debug_str = format!("{:?}", exporter); + assert!(debug_str.contains("JsonFileExporter")); + assert!(debug_str.contains("span_count")); + assert!(debug_str.contains("0")); +} + +#[test] +fn test_exporter_clone() { + let exporter = JsonFileExporter::new(); + let cloned = exporter.clone(); + + // Both should be empty + assert!(exporter.take_spans().is_empty()); + assert!(cloned.take_spans().is_empty()); +} + +#[test] +fn test_exporter_default() { + let exporter = JsonFileExporter::default(); + assert!(exporter.take_spans().is_empty()); +} + +#[test] +fn test_span_record_serialization() { + let record = SpanRecord { + name: "test_span".to_string(), + trace_id: "abc123".to_string(), + span_id: "def456".to_string(), + parent_span_id: "0000000000000000".to_string(), + start_time_unix_ns: 1000000, + end_time_unix_ns: 2000000, + duration_ms: 1.0, + status: "ok".to_string(), + attributes: std::collections::HashMap::new(), + events: vec![], + }; + + let json = serde_json::to_string(&record).unwrap(); + assert!(json.contains("test_span")); + assert!(json.contains("abc123")); + + // Should not contain "events" key because it's empty and skip_serializing_if + assert!(!json.contains("events")); +} + +#[rstest] +#[case("ok", false)] +#[case("unset", false)] +#[case("error: something failed", true)] +fn test_span_record_status_variants(#[case] status: &str, #[case] is_error: bool) { + let record = SpanRecord { + name: "test".to_string(), + trace_id: String::new(), + span_id: String::new(), + parent_span_id: String::new(), + start_time_unix_ns: 0, + end_time_unix_ns: 0, + duration_ms: 0.0, + status: status.to_string(), + attributes: std::collections::HashMap::new(), + events: vec![], + }; + + assert_eq!(record.status.starts_with("error"), is_error); +} + +#[test] +fn test_span_record_deserialization() { + let json = r#"{ + "name": "resolve", + "trace_id": "abc", + "span_id": "def", + "parent_span_id": "000", + "start_time_unix_ns": 100, + "end_time_unix_ns": 200, + "duration_ms": 0.1, + "status": "ok", + "attributes": {"runtime": "node"} + }"#; + + let record: SpanRecord = serde_json::from_str(json).unwrap(); + assert_eq!(record.name, "resolve"); + assert_eq!(record.duration_ms, 0.1); + assert_eq!( + record.attributes.get("runtime"), + Some(&serde_json::Value::String("node".to_string())) + ); + assert!(record.events.is_empty()); +} diff --git a/crates/vx-metrics/tests/init_tests.rs b/crates/vx-metrics/tests/init_tests.rs new file mode 100644 index 000000000..1552a382f --- /dev/null +++ b/crates/vx-metrics/tests/init_tests.rs @@ -0,0 +1,153 @@ +use rstest::rstest; +use tempfile::TempDir; +use vx_metrics::MetricsConfig; + +#[test] +fn test_metrics_config_default() { + let config = MetricsConfig::default(); + assert!(!config.debug); + assert!(!config.verbose); + assert!(config.command.is_empty()); + assert!(config.metrics_dir.is_none()); +} + +#[test] +fn test_metrics_guard_exit_code() { + let temp = TempDir::new().unwrap(); + let guard = vx_metrics::init(MetricsConfig { + command: "test".to_string(), + metrics_dir: Some(temp.path().to_path_buf()), + ..Default::default() + }); + + // Default exit code is 0 + let handle = guard.exit_code_handle(); + assert_eq!(handle.load(std::sync::atomic::Ordering::Relaxed), 0); + + // Set exit code + guard.set_exit_code(42); + assert_eq!(handle.load(std::sync::atomic::Ordering::Relaxed), 42); +} + +#[test] +fn test_metrics_guard_exit_code_handle_shared() { + let temp = TempDir::new().unwrap(); + let guard = vx_metrics::init(MetricsConfig { + command: "test".to_string(), + metrics_dir: Some(temp.path().to_path_buf()), + ..Default::default() + }); + + let handle1 = guard.exit_code_handle(); + let handle2 = guard.exit_code_handle(); + + // Both handles should point to the same atomic + guard.set_exit_code(7); + assert_eq!(handle1.load(std::sync::atomic::Ordering::Relaxed), 7); + assert_eq!(handle2.load(std::sync::atomic::Ordering::Relaxed), 7); +} + +// ============================================================================ +// Per-layer filter directive tests +// ============================================================================ + +#[test] +fn test_fmt_filter_normal_mode() { + // Ensure RUST_LOG is not set for this test + unsafe { + std::env::remove_var("RUST_LOG"); + } + let config = MetricsConfig::default(); + let filter = vx_metrics::fmt_filter_directive(&config); + + // Normal mode: only warn and error should be shown on stderr + assert_eq!(filter, "warn,error"); + // Must NOT contain vx=trace or vx=debug + assert!(!filter.contains("trace")); + assert!(!filter.contains("debug")); +} + +#[test] +fn test_fmt_filter_verbose_mode() { + unsafe { + std::env::remove_var("RUST_LOG"); + } + let config = MetricsConfig { + verbose: true, + ..Default::default() + }; + let filter = vx_metrics::fmt_filter_directive(&config); + + assert_eq!(filter, "vx=debug,info"); +} + +#[test] +fn test_fmt_filter_debug_mode() { + unsafe { + std::env::remove_var("RUST_LOG"); + } + let config = MetricsConfig { + debug: true, + ..Default::default() + }; + let filter = vx_metrics::fmt_filter_directive(&config); + + assert_eq!(filter, "debug"); +} + +#[test] +fn test_fmt_filter_debug_takes_precedence_over_verbose() { + unsafe { + std::env::remove_var("RUST_LOG"); + } + let config = MetricsConfig { + debug: true, + verbose: true, + ..Default::default() + }; + let filter = vx_metrics::fmt_filter_directive(&config); + + // debug mode should take precedence + assert_eq!(filter, "debug"); +} + +#[test] +fn test_otel_filter_always_captures_vx_trace() { + unsafe { + std::env::remove_var("RUST_LOG"); + } + let filter = vx_metrics::otel_filter_directive(); + + // OTel filter must always include vx=trace for metrics collection + assert!(filter.contains("vx=trace")); + assert!(filter.contains("warn")); + assert!(filter.contains("error")); +} + +#[rstest] +#[case(false, false, "warn,error")] +#[case(true, false, "vx=debug,info")] +#[case(false, true, "debug")] +#[case(true, true, "debug")] +fn test_fmt_filter_matrix(#[case] verbose: bool, #[case] debug: bool, #[case] expected: &str) { + unsafe { + std::env::remove_var("RUST_LOG"); + } + let config = MetricsConfig { + verbose, + debug, + ..Default::default() + }; + let filter = vx_metrics::fmt_filter_directive(&config); + assert_eq!(filter, expected); +} + +#[test] +fn test_otel_filter_independent_of_config() { + unsafe { + std::env::remove_var("RUST_LOG"); + } + // OTel filter should be the same regardless of verbose/debug settings + let filter = vx_metrics::otel_filter_directive(); + assert_eq!(filter, "vx=trace,warn,error"); +} diff --git a/crates/vx-metrics/tests/report_tests.rs b/crates/vx-metrics/tests/report_tests.rs new file mode 100644 index 000000000..cc44c329e --- /dev/null +++ b/crates/vx-metrics/tests/report_tests.rs @@ -0,0 +1,209 @@ +use rstest::rstest; +use vx_metrics::exporter::SpanRecord; +use vx_metrics::report::{CommandMetrics, StageMetrics}; + +#[test] +fn test_command_metrics_new() { + let metrics = CommandMetrics::new("vx node --version".to_string()); + assert_eq!(metrics.version, "1"); + assert_eq!(metrics.command, "vx node --version"); + assert!(metrics.exit_code.is_none()); + assert_eq!(metrics.total_duration_ms, 0.0); + assert!(metrics.stages.is_empty()); + assert!(metrics.token_savings.is_empty()); + assert!(metrics.spans.is_empty()); +} + +#[test] +fn test_command_metrics_serialization() { + let metrics = CommandMetrics::new("vx go build".to_string()); + let json = serde_json::to_string_pretty(&metrics).unwrap(); + assert!(json.contains("vx go build")); + assert!(json.contains("\"version\": \"1\"")); + // exit_code is None, should be skipped + assert!(!json.contains("exit_code")); + // stages is empty, should be skipped + assert!(!json.contains("stages")); +} + +#[test] +fn test_command_metrics_with_exit_code() { + let mut metrics = CommandMetrics::new("vx node".to_string()); + metrics.exit_code = Some(0); + let json = serde_json::to_string(&metrics).unwrap(); + assert!(json.contains("\"exit_code\":0")); +} + +#[test] +fn test_extract_stages_from_spans() { + let mut metrics = CommandMetrics::new("vx node --version".to_string()); + metrics.spans = vec![ + make_span("resolve", 50.0, "ok"), + make_span("ensure", 800.0, "ok"), + make_span("prepare", 10.0, "ok"), + make_span("execute_process", 374.0, "ok"), + ]; + + metrics.extract_stages_from_spans(); + + assert_eq!(metrics.stages.len(), 4); + assert_eq!(metrics.stages["resolve"].duration_ms, 50.0); + assert!(metrics.stages["resolve"].success); + assert_eq!(metrics.stages["ensure"].duration_ms, 800.0); + assert_eq!(metrics.stages["prepare"].duration_ms, 10.0); + assert_eq!(metrics.stages["execute"].duration_ms, 374.0); +} + +#[test] +fn test_extract_stages_with_error() { + let mut metrics = CommandMetrics::new("vx unknown".to_string()); + metrics.spans = vec![make_span("resolve", 5.0, "error: not found")]; + + metrics.extract_stages_from_spans(); + + assert_eq!(metrics.stages.len(), 1); + assert!(!metrics.stages["resolve"].success); + assert_eq!( + metrics.stages["resolve"].error.as_deref(), + Some("error: not found") + ); +} + +#[test] +fn test_extract_stages_deduplicates() { + let mut metrics = CommandMetrics::new("test".to_string()); + // Two spans named "resolve" - only the first should be kept + metrics.spans = vec![ + make_span("resolve", 10.0, "ok"), + make_span("resolve", 20.0, "ok"), + ]; + + metrics.extract_stages_from_spans(); + + assert_eq!(metrics.stages.len(), 1); + assert_eq!(metrics.stages["resolve"].duration_ms, 10.0); +} + +#[test] +fn test_compute_total_duration_from_root_span() { + let mut metrics = CommandMetrics::new("test".to_string()); + metrics.spans = vec![ + SpanRecord { + name: "root".to_string(), + parent_span_id: "0000000000000000".to_string(), + duration_ms: 1234.0, + ..make_span("root", 1234.0, "ok") + }, + make_span("resolve", 50.0, "ok"), + ]; + metrics.stages.insert( + "resolve".to_string(), + StageMetrics { + duration_ms: 50.0, + success: true, + error: None, + }, + ); + + metrics.compute_total_duration(); + + assert_eq!(metrics.total_duration_ms, 1234.0); +} + +#[test] +fn test_compute_total_duration_from_stages_sum() { + let mut metrics = CommandMetrics::new("test".to_string()); + // No root span - should sum stages + metrics.stages.insert( + "resolve".to_string(), + StageMetrics { + duration_ms: 50.0, + success: true, + error: None, + }, + ); + metrics.stages.insert( + "ensure".to_string(), + StageMetrics { + duration_ms: 100.0, + success: true, + error: None, + }, + ); + + metrics.compute_total_duration(); + + assert_eq!(metrics.total_duration_ms, 150.0); +} + +#[test] +fn test_stage_metrics_serialization() { + let stage = StageMetrics { + duration_ms: 42.5, + success: true, + error: None, + }; + let json = serde_json::to_string(&stage).unwrap(); + assert!(json.contains("42.5")); + assert!(json.contains("true")); + // error is None, should be skipped + assert!(!json.contains("error")); +} + +#[rstest] +#[case("resolve", true)] +#[case("RESOLVE", true)] +#[case("ensure", true)] +#[case("prepare", true)] +#[case("execute", true)] +#[case("execute_process", true)] +#[case("random_span", false)] +fn test_stage_name_matching(#[case] span_name: &str, #[case] should_match: bool) { + let mut metrics = CommandMetrics::new("test".to_string()); + metrics.spans = vec![make_span(span_name, 10.0, "ok")]; + + metrics.extract_stages_from_spans(); + + assert_eq!(!metrics.stages.is_empty(), should_match); +} + +#[test] +fn test_command_metrics_roundtrip() { + let mut metrics = CommandMetrics::new("vx node --version".to_string()); + metrics.exit_code = Some(0); + metrics.total_duration_ms = 1234.5; + metrics.stages.insert( + "resolve".to_string(), + StageMetrics { + duration_ms: 50.0, + success: true, + error: None, + }, + ); + metrics.spans = vec![make_span("resolve", 50.0, "ok")]; + + let json = serde_json::to_string_pretty(&metrics).unwrap(); + let deserialized: CommandMetrics = serde_json::from_str(&json).unwrap(); + + assert_eq!(deserialized.command, "vx node --version"); + assert_eq!(deserialized.exit_code, Some(0)); + assert_eq!(deserialized.total_duration_ms, 1234.5); + assert_eq!(deserialized.stages.len(), 1); + assert_eq!(deserialized.spans.len(), 1); +} + +// Helper to create a test SpanRecord +fn make_span(name: &str, duration_ms: f64, status: &str) -> SpanRecord { + SpanRecord { + name: name.to_string(), + trace_id: "trace123".to_string(), + span_id: "span456".to_string(), + parent_span_id: "parent789".to_string(), + start_time_unix_ns: 0, + end_time_unix_ns: (duration_ms * 1_000_000.0) as u64, + duration_ms, + status: status.to_string(), + attributes: std::collections::HashMap::new(), + events: vec![], + } +} diff --git a/crates/vx-metrics/tests/token_savings_tests.rs b/crates/vx-metrics/tests/token_savings_tests.rs new file mode 100644 index 000000000..76da3b04b --- /dev/null +++ b/crates/vx-metrics/tests/token_savings_tests.rs @@ -0,0 +1,122 @@ +use vx_metrics::report::{CommandMetrics, TokenSavingsRecord}; +use vx_metrics::{ + build_token_savings_record, estimate_tokens, render_token_savings, summarize_token_savings, +}; + +#[test] +fn test_estimate_tokens_uses_stable_char_heuristic() { + assert_eq!(estimate_tokens(""), 0); + assert_eq!(estimate_tokens("abcd"), 1); + assert_eq!(estimate_tokens("abcde"), 2); + assert_eq!(estimate_tokens("你好"), 2); +} + +#[test] +fn test_build_token_savings_record() { + let record = build_token_savings_record( + "ListOutput", + "toon", + "json", + r#"{"runtimes":[{"name":"node"}]}"#, + "runtimes[1]{name}:\n node", + ); + + assert_eq!(record.output_type, "ListOutput"); + assert_eq!(record.output_format, "toon"); + assert_eq!(record.baseline_format, "json"); + assert!(record.baseline_tokens >= record.actual_tokens); + assert!(record.token_delta >= 0); +} + +#[test] +fn test_build_token_savings_record_preserves_negative_delta() { + let record = build_token_savings_record("ListOutput", "toon", "json", "abcd", "abcdabcd"); + + assert_eq!(record.token_delta, -1); + assert!(record.savings_ratio < 0.0); +} + +#[test] +fn test_summarize_token_savings_by_command() { + let mut first = CommandMetrics::new("vx --output-format toon list".to_string()); + first.token_savings = vec![TokenSavingsRecord { + output_type: "ListOutput".to_string(), + output_format: "toon".to_string(), + baseline_format: "json".to_string(), + baseline_bytes: 400, + actual_bytes: 200, + baseline_tokens: 100, + actual_tokens: 50, + token_delta: 50, + savings_ratio: 0.5, + }]; + + let mut second = CommandMetrics::new("vx --compact list node".to_string()); + second.token_savings = vec![TokenSavingsRecord { + output_type: "VersionsOutput".to_string(), + output_format: "compact".to_string(), + baseline_format: "text".to_string(), + baseline_bytes: 80, + actual_bytes: 20, + baseline_tokens: 20, + actual_tokens: 5, + token_delta: 15, + savings_ratio: 0.75, + }]; + + let summary = summarize_token_savings(&[first, second]); + + assert_eq!(summary.runs, 2); + assert_eq!(summary.records, 2); + assert_eq!(summary.baseline_tokens, 120); + assert_eq!(summary.actual_tokens, 55); + assert_eq!(summary.net_saved_tokens, 65); + assert_eq!(summary.commands.len(), 2); + assert_eq!(summary.commands[0].command, "vx --output-format toon list"); +} + +#[test] +fn test_summarize_token_savings_reports_contributing_and_inspected_runs() { + let mut first = CommandMetrics::new("vx --output-format toon list".to_string()); + first.token_savings = vec![TokenSavingsRecord { + output_type: "ListOutput".to_string(), + output_format: "toon".to_string(), + baseline_format: "json".to_string(), + baseline_bytes: 400, + actual_bytes: 200, + baseline_tokens: 100, + actual_tokens: 50, + token_delta: 50, + savings_ratio: 0.5, + }]; + + let empty = CommandMetrics::new("vx metrics --json".to_string()); + + let summary = summarize_token_savings(&[first, empty]); + + assert_eq!(summary.inspected_runs, 2); + assert_eq!(summary.runs, 1); + assert_eq!(summary.records, 1); +} + +#[test] +fn test_render_token_savings_empty() { + let summary = summarize_token_savings(&[]); + let rendered = render_token_savings(&summary); + assert!(rendered.contains("No token savings data")); +} + +#[test] +fn test_command_metrics_deserializes_without_token_savings() { + let json = r#"{ + "version": "1", + "timestamp": "2026-02-07T10:30:00Z", + "command": "vx list", + "total_duration_ms": 12.0, + "spans": [] + }"#; + + let metrics: CommandMetrics = serde_json::from_str(json).unwrap(); + assert!(metrics.stages.is_empty()); + assert!(metrics.token_savings.is_empty()); +} diff --git a/crates/vx-metrics/tests/visualize_tests.rs b/crates/vx-metrics/tests/visualize_tests.rs new file mode 100644 index 000000000..6ed887cd4 --- /dev/null +++ b/crates/vx-metrics/tests/visualize_tests.rs @@ -0,0 +1,258 @@ +//! Tests for the visualization module. + +use std::collections::HashMap; +use vx_metrics::report::{CommandMetrics, StageMetrics}; +use vx_metrics::visualize::{ + generate_ai_summary, generate_html_report, load_metrics, render_comparison, render_insights, + render_summary, +}; + +fn sample_metrics( + total: f64, + resolve: f64, + ensure: f64, + prepare: f64, + execute: f64, +) -> CommandMetrics { + let mut stages = HashMap::new(); + stages.insert( + "resolve".to_string(), + StageMetrics { + duration_ms: resolve, + success: true, + error: None, + }, + ); + stages.insert( + "ensure".to_string(), + StageMetrics { + duration_ms: ensure, + success: true, + error: None, + }, + ); + stages.insert( + "prepare".to_string(), + StageMetrics { + duration_ms: prepare, + success: true, + error: None, + }, + ); + stages.insert( + "execute".to_string(), + StageMetrics { + duration_ms: execute, + success: true, + error: None, + }, + ); + + CommandMetrics { + version: "1".to_string(), + timestamp: "2026-02-07T16:00:00+00:00".to_string(), + command: "vx node --version".to_string(), + exit_code: Some(0), + total_duration_ms: total, + stages, + token_savings: Vec::new(), + spans: Vec::new(), + } +} + +#[test] +fn test_render_summary_contains_stages() { + let m = sample_metrics(1000.0, 100.0, 5.0, 200.0, 600.0); + let output = render_summary(&m); + + assert!(output.contains("vx node --version")); + assert!(output.contains("1000.00ms")); + assert!(output.contains("resolve")); + assert!(output.contains("prepare")); + assert!(output.contains("execute")); + assert!(output.contains("ensure")); +} + +#[test] +fn test_render_summary_shows_overhead() { + // total=1000, stages sum=905, overhead=95 + let m = sample_metrics(1000.0, 100.0, 5.0, 200.0, 600.0); + let output = render_summary(&m); + assert!(output.contains("overhead")); +} + +#[test] +fn test_render_comparison_empty() { + let output = render_comparison(&[]); + assert!(output.contains("No metrics data")); +} + +#[test] +fn test_render_comparison_single() { + let m = sample_metrics(500.0, 50.0, 1.0, 100.0, 300.0); + let output = render_comparison(&[m]); + assert!(output.contains("Performance History")); + assert!(output.contains("vx node --version")); +} + +#[test] +fn test_render_comparison_multiple_with_stats() { + let runs = vec![ + sample_metrics(500.0, 50.0, 1.0, 100.0, 300.0), + sample_metrics(600.0, 60.0, 2.0, 120.0, 350.0), + sample_metrics(450.0, 40.0, 1.0, 90.0, 280.0), + ]; + let output = render_comparison(&runs); + assert!(output.contains("Stats (3 runs)")); + assert!(output.contains("avg=")); + assert!(output.contains("Stage Averages")); +} + +#[test] +fn test_render_insights_empty() { + let output = render_insights(&[]); + assert!(output.is_empty()); +} + +#[test] +fn test_render_insights_slow_prepare() { + let m = sample_metrics(500.0, 50.0, 1.0, 200.0, 200.0); + let output = render_insights(&[m]); + assert!(output.contains("prepare")); + assert!(output.contains("slow")); +} + +#[test] +fn test_render_insights_slow_resolve() { + let m = sample_metrics(500.0, 200.0, 1.0, 50.0, 200.0); + let output = render_insights(&[m]); + assert!(output.contains("resolve")); +} + +#[test] +fn test_render_insights_bottleneck() { + // execute takes >50% of total + let m = sample_metrics(1000.0, 50.0, 1.0, 50.0, 850.0); + let output = render_insights(&[m]); + assert!(output.contains("Bottleneck")); + assert!(output.contains("execute")); +} + +#[test] +fn test_generate_html_report_not_empty() { + let runs = vec![sample_metrics(500.0, 50.0, 1.0, 100.0, 300.0)]; + let html = generate_html_report(&runs); + assert!(html.contains("")); + assert!(html.contains("VX Performance Report")); + assert!(html.contains("chart.js")); + assert!(html.contains("pieChart")); + assert!(html.contains("lineChart")); +} + +#[test] +fn test_generate_ai_summary_structure() { + let runs = vec![ + sample_metrics(500.0, 50.0, 1.0, 100.0, 300.0), + sample_metrics(600.0, 60.0, 2.0, 120.0, 350.0), + ]; + let summary = generate_ai_summary(&runs); + + assert_eq!(summary["runs_analyzed"], 2); + assert!(summary["total_ms"]["avg"].as_f64().unwrap() > 0.0); + assert!(summary["stages"]["resolve"]["avg_ms"].as_f64().unwrap() > 0.0); + assert!(summary["latest_run"]["command"].as_str().unwrap() == "vx node --version"); +} + +#[test] +fn test_generate_ai_summary_percentiles_interpolate() { + let runs = vec![ + sample_metrics(20.0, 2.0, 1.0, 3.0, 4.0), + sample_metrics(10.0, 1.0, 1.0, 2.0, 3.0), + ]; + let summary = generate_ai_summary(&runs); + + assert_eq!(summary["total_ms"]["p50"].as_f64().unwrap(), 15.0); + assert_eq!(summary["total_ms"]["p95"].as_f64().unwrap(), 19.5); +} + +#[test] +fn test_generate_ai_summary_stage_percentage_uses_stage_runs_only() { + let with_stage = sample_metrics(200.0, 100.0, 0.0, 0.0, 0.0); + let mut without_stage = sample_metrics(1000.0, 0.0, 0.0, 0.0, 0.0); + without_stage.stages.clear(); + + let summary = generate_ai_summary(&[with_stage, without_stage]); + + assert_eq!( + summary["stages"]["resolve"]["pct_of_total"] + .as_f64() + .unwrap(), + 50.0 + ); +} + +#[test] +fn test_generate_ai_summary_detects_bottlenecks() { + // prepare > 100ms should trigger bottleneck + let runs = vec![sample_metrics(500.0, 50.0, 1.0, 200.0, 200.0)]; + let summary = generate_ai_summary(&runs); + let bottlenecks = summary["bottlenecks"].as_array().unwrap(); + assert!(!bottlenecks.is_empty()); + assert!(bottlenecks[0]["stage"].as_str().unwrap() == "prepare"); +} + +#[test] +fn test_load_metrics_nonexistent_dir() { + let result = load_metrics(std::path::Path::new("/nonexistent/dir"), 10); + assert!(result.is_err()); +} + +#[test] +fn test_load_metrics_empty_dir() { + let tmp = tempfile::TempDir::new().unwrap(); + let result = load_metrics(tmp.path(), 10).unwrap(); + assert!(result.is_empty()); +} + +#[test] +fn test_load_metrics_with_files() { + let tmp = tempfile::TempDir::new().unwrap(); + + // Write two sample files + let m1 = sample_metrics(500.0, 50.0, 1.0, 100.0, 300.0); + let m2 = sample_metrics(600.0, 60.0, 2.0, 120.0, 350.0); + + std::fs::write( + tmp.path().join("20260207_160000_000.json"), + serde_json::to_string(&m1).unwrap(), + ) + .unwrap(); + std::fs::write( + tmp.path().join("20260207_160100_000.json"), + serde_json::to_string(&m2).unwrap(), + ) + .unwrap(); + + let result = load_metrics(tmp.path(), 10).unwrap(); + assert_eq!(result.len(), 2); + // Newest first + assert_eq!(result[0].total_duration_ms, 600.0); + assert_eq!(result[1].total_duration_ms, 500.0); +} + +#[test] +fn test_load_metrics_respects_limit() { + let tmp = tempfile::TempDir::new().unwrap(); + + for i in 0..5 { + let m = sample_metrics(100.0 * (i + 1) as f64, 10.0, 1.0, 20.0, 50.0); + std::fs::write( + tmp.path().join(format!("2026020{}_160000_000.json", i)), + serde_json::to_string(&m).unwrap(), + ) + .unwrap(); + } + + let result = load_metrics(tmp.path(), 3).unwrap(); + assert_eq!(result.len(), 3); +} diff --git a/crates/vx-migration/Cargo.toml b/crates/vx-migration/Cargo.toml new file mode 100644 index 000000000..1eb8818d0 --- /dev/null +++ b/crates/vx-migration/Cargo.toml @@ -0,0 +1,47 @@ +[package] +name = "vx-migration" +version.workspace = true +edition.workspace = true +description = "Migration framework for vx configuration and data" +license.workspace = true +repository.workspace = true +authors.workspace = true +keywords = ["migration", "version", "upgrade", "compatibility"] +categories = ["development-tools"] + +[dependencies] +# Core +vx-paths = { workspace = true } + +# Serialization +serde = { workspace = true } +serde_json = { workspace = true } +toml = { workspace = true } + +# Async +async-trait = { workspace = true } +tokio = { version = "1.0", features = [ + "rt-multi-thread", + "macros", + "fs", + "sync", +] } + +# Utilities +chrono = { workspace = true } +regex = { workspace = true } +tracing = { workspace = true } +thiserror = { workspace = true } +semver = { workspace = true } +uuid = { version = "1.20", features = ["v4"] } +hostname = "0.4" +workspace-hack = { version = "0.1", path = "../workspace-hack" } + +[dev-dependencies] +rstest = { workspace = true } +tempfile = { workspace = true } +tokio = { version = "1.0", features = [ + "rt-multi-thread", + "macros", + "test-util", +] } diff --git a/crates/vx-migration/src/context.rs b/crates/vx-migration/src/context.rs new file mode 100644 index 000000000..ea38b28d5 --- /dev/null +++ b/crates/vx-migration/src/context.rs @@ -0,0 +1,280 @@ +//! Migration context for sharing state between migrations. + +use crate::error::{MigrationError, MigrationResult}; +use crate::types::MigrationOptions; +use crate::version::Version; +use std::any::Any; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use tokio::sync::RwLock; + +/// Migration context for sharing state +#[derive(Debug)] +pub struct MigrationContext { + /// Root path being migrated + root_path: PathBuf, + /// Migration options + options: MigrationOptions, + /// Detected source version + source_version: Option, + /// Target version + target_version: Option, + /// Shared state storage + state: Arc>>>, + /// Files that have been modified + modified_files: Arc>>, + /// Backup directory + backup_dir: Option, +} + +impl MigrationContext { + /// Create a new context + pub fn new(root_path: impl Into, options: MigrationOptions) -> Self { + Self { + root_path: root_path.into(), + options, + source_version: None, + target_version: None, + state: Arc::new(RwLock::new(HashMap::new())), + modified_files: Arc::new(RwLock::new(Vec::new())), + backup_dir: None, + } + } + + /// Get root path + pub fn root_path(&self) -> &Path { + &self.root_path + } + + /// Get options + pub fn options(&self) -> &MigrationOptions { + &self.options + } + + /// Check if dry-run mode + pub fn is_dry_run(&self) -> bool { + self.options.dry_run + } + + /// Get source version + pub fn source_version(&self) -> Option<&Version> { + self.source_version.as_ref() + } + + /// Set source version + pub fn set_source_version(&mut self, version: Version) { + self.source_version = Some(version); + } + + /// Get target version + pub fn target_version(&self) -> Option<&Version> { + self.target_version.as_ref() + } + + /// Set target version + pub fn set_target_version(&mut self, version: Version) { + self.target_version = Some(version); + } + + /// Get backup directory + pub fn backup_dir(&self) -> Option<&Path> { + self.backup_dir.as_deref() + } + + /// Set backup directory + pub fn set_backup_dir(&mut self, dir: PathBuf) { + self.backup_dir = Some(dir); + } + + /// Store state value + pub async fn set_state(&self, key: impl Into, value: T) { + let mut state = self.state.write().await; + state.insert(key.into(), Box::new(value)); + } + + /// Get state value + pub async fn get_state(&self, key: &str) -> Option { + let state = self.state.read().await; + state.get(key).and_then(|v| v.downcast_ref::().cloned()) + } + + /// Check if state key exists + pub async fn has_state(&self, key: &str) -> bool { + let state = self.state.read().await; + state.contains_key(key) + } + + /// Remove state value + pub async fn remove_state(&self, key: &str) { + let mut state = self.state.write().await; + state.remove(key); + } + + /// Record a modified file + pub async fn record_modified(&self, path: impl Into) { + let mut files = self.modified_files.write().await; + files.push(path.into()); + } + + /// Get all modified files + pub async fn modified_files(&self) -> Vec { + let files = self.modified_files.read().await; + files.clone() + } + + /// Resolve path relative to root + pub fn resolve_path(&self, path: impl AsRef) -> PathBuf { + self.root_path.join(path) + } + + /// Read file content + pub async fn read_file(&self, path: impl AsRef) -> MigrationResult { + let full_path = self.resolve_path(path); + tokio::fs::read_to_string(&full_path) + .await + .map_err(|e| MigrationError::io("Failed to read file", Some(full_path), e)) + } + + /// Write file content (respects dry-run) + pub async fn write_file( + &self, + path: impl AsRef, + content: impl AsRef, + ) -> MigrationResult<()> { + let full_path = self.resolve_path(&path); + + if self.is_dry_run() { + tracing::info!("[dry-run] Would write to: {:?}", full_path); + return Ok(()); + } + + // Ensure parent directory exists + if let Some(parent) = full_path.parent() { + tokio::fs::create_dir_all(parent).await.map_err(|e| { + MigrationError::io("Failed to create directory", Some(parent.to_path_buf()), e) + })?; + } + + tokio::fs::write(&full_path, content.as_ref()) + .await + .map_err(|e| MigrationError::io("Failed to write file", Some(full_path.clone()), e))?; + + self.record_modified(full_path).await; + Ok(()) + } + + /// Rename file (respects dry-run) + pub async fn rename_file( + &self, + from: impl AsRef, + to: impl AsRef, + ) -> MigrationResult<()> { + let from_path = self.resolve_path(&from); + let to_path = self.resolve_path(&to); + + if self.is_dry_run() { + tracing::info!("[dry-run] Would rename: {:?} -> {:?}", from_path, to_path); + return Ok(()); + } + + tokio::fs::rename(&from_path, &to_path) + .await + .map_err(|e| MigrationError::io("Failed to rename file", Some(from_path), e))?; + + self.record_modified(to_path).await; + Ok(()) + } + + /// Delete file (respects dry-run) + pub async fn delete_file(&self, path: impl AsRef) -> MigrationResult<()> { + let full_path = self.resolve_path(&path); + + if self.is_dry_run() { + tracing::info!("[dry-run] Would delete: {:?}", full_path); + return Ok(()); + } + + tokio::fs::remove_file(&full_path) + .await + .map_err(|e| MigrationError::io("Failed to delete file", Some(full_path), e))?; + + Ok(()) + } + + /// Check if file exists + pub async fn file_exists(&self, path: impl AsRef) -> bool { + let full_path = self.resolve_path(path); + tokio::fs::metadata(&full_path).await.is_ok() + } + + /// Check if path is a directory + pub async fn is_dir(&self, path: impl AsRef) -> bool { + let full_path = self.resolve_path(path); + tokio::fs::metadata(&full_path) + .await + .map(|m| m.is_dir()) + .unwrap_or(false) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + #[tokio::test] + async fn test_context_state() { + let temp = TempDir::new().unwrap(); + let ctx = MigrationContext::new(temp.path(), MigrationOptions::default()); + + ctx.set_state("key1", "value1".to_string()).await; + ctx.set_state("key2", 42i32).await; + + assert_eq!( + ctx.get_state::("key1").await, + Some("value1".to_string()) + ); + assert_eq!(ctx.get_state::("key2").await, Some(42)); + assert_eq!(ctx.get_state::("key3").await, None); + + assert!(ctx.has_state("key1").await); + assert!(!ctx.has_state("key3").await); + + ctx.remove_state("key1").await; + assert!(!ctx.has_state("key1").await); + } + + #[tokio::test] + async fn test_context_file_operations() { + let temp = TempDir::new().unwrap(); + let ctx = MigrationContext::new(temp.path(), MigrationOptions::default()); + + // Write file + ctx.write_file("test.txt", "hello").await.unwrap(); + assert!(ctx.file_exists("test.txt").await); + + // Read file + let content = ctx.read_file("test.txt").await.unwrap(); + assert_eq!(content, "hello"); + + // Rename file + ctx.rename_file("test.txt", "renamed.txt").await.unwrap(); + assert!(!ctx.file_exists("test.txt").await); + assert!(ctx.file_exists("renamed.txt").await); + + // Delete file + ctx.delete_file("renamed.txt").await.unwrap(); + assert!(!ctx.file_exists("renamed.txt").await); + } + + #[tokio::test] + async fn test_dry_run_mode() { + let temp = TempDir::new().unwrap(); + let ctx = MigrationContext::new(temp.path(), MigrationOptions::dry_run()); + + // Dry-run should not create file + ctx.write_file("test.txt", "hello").await.unwrap(); + assert!(!ctx.file_exists("test.txt").await); + } +} diff --git a/crates/vx-migration/src/engine.rs b/crates/vx-migration/src/engine.rs new file mode 100644 index 000000000..e88c904b2 --- /dev/null +++ b/crates/vx-migration/src/engine.rs @@ -0,0 +1,378 @@ +//! Migration engine - the main entry point for running migrations. + +use crate::context::MigrationContext; +use crate::error::{MigrationError, MigrationResult}; +use crate::registry::MigrationRegistry; +use crate::traits::{Migration, MigrationHook}; +use crate::types::{ + MigrationMetadata, MigrationOptions, MigrationReport, MigrationStepReport, MigrationStepResult, +}; +use std::path::Path; +use std::sync::Arc; +use std::time::Instant; + +/// Migration engine for executing migrations +pub struct MigrationEngine { + registry: MigrationRegistry, +} + +impl Default for MigrationEngine { + fn default() -> Self { + Self::new() + } +} + +impl MigrationEngine { + /// Create a new engine + pub fn new() -> Self { + Self { + registry: MigrationRegistry::new(), + } + } + + /// Register a migration (builder pattern) + pub fn register(mut self, migration: M) -> Self { + let _ = self.registry.register(migration); + self + } + + /// Register a hook (builder pattern) + pub fn register_hook(mut self, hook: H) -> Self { + self.registry.register_hook(hook); + self + } + + /// Add a migration to the registry + pub fn add_migration(&mut self, migration: M) -> MigrationResult<()> { + self.registry.register(migration) + } + + /// Add a hook to the registry + pub fn add_hook(&mut self, hook: H) { + self.registry.register_hook(hook) + } + + /// Get all migration metadata + pub fn migrations(&self) -> Vec { + self.registry.metadata() + } + + /// Check which migrations need to run + pub async fn check(&self, path: &Path) -> MigrationResult> { + let ctx = MigrationContext::new(path, MigrationOptions::default()); + let migrations = self.registry.sorted()?; + + let mut needed = Vec::new(); + for migration in migrations { + if migration.check(&ctx).await? { + needed.push(migration.metadata()); + } + } + + Ok(needed) + } + + /// Execute migrations + pub async fn migrate( + &self, + path: &Path, + options: &MigrationOptions, + ) -> MigrationResult { + let start = Instant::now(); + let mut ctx = MigrationContext::new(path, options.clone()); + let mut report = MigrationReport::default(); + + // Get sorted migrations + let migrations = self.registry.sorted()?; + let hooks = self.registry.hooks(); + + // Pre-migrate hooks + for hook in hooks { + hook.pre_migrate(&ctx).await.map_err(|e| { + MigrationError::hook(hook.name(), format!("pre_migrate failed: {}", e)) + })?; + } + + // Execute each migration + for migration in migrations { + let metadata = migration.metadata(); + + // Check if migration should be skipped + if options.skip_migrations.contains(&metadata.id) { + report.skipped_count += 1; + report.steps.push(MigrationStepReport { + migration_id: metadata.id.clone(), + migration_name: metadata.name.clone(), + description: metadata.description.clone(), + result: MigrationStepResult::skipped(), + skipped: true, + error: None, + }); + continue; + } + + // Check if only specific migrations should run + if let Some(only) = &options.only_migrations + && !only.contains(&metadata.id) + { + report.skipped_count += 1; + continue; + } + + // Check if migration needs to run + match migration.check(&ctx).await { + Ok(true) => {} + Ok(false) => { + report.skipped_count += 1; + report.steps.push(MigrationStepReport { + migration_id: metadata.id.clone(), + migration_name: metadata.name.clone(), + description: metadata.description.clone(), + result: MigrationStepResult::skipped(), + skipped: true, + error: None, + }); + continue; + } + Err(e) => { + report.failed_count += 1; + report + .errors + .push(format!("Check failed for {}: {}", metadata.id, e)); + if options.rollback_on_failure { + self.rollback_completed(&mut ctx, &report, hooks).await?; + } + report.success = false; + report.total_duration = start.elapsed(); + return Ok(report); + } + } + + // Pre-step hooks + for hook in hooks { + if let Err(e) = hook.pre_step(&ctx, migration.as_ref()).await { + tracing::warn!("Hook {} pre_step failed: {}", hook.name(), e); + } + } + + // Execute migration + let step_start = Instant::now(); + let step_result = match migration.migrate(&mut ctx).await { + Ok(mut result) => { + result.duration = step_start.elapsed(); + report.successful_count += 1; + + // Validate if not dry-run + if !options.dry_run + && let Err(e) = migration.validate(&ctx).await + { + result.warnings.push(format!("Validation warning: {}", e)); + } + + result + } + Err(e) => { + report.failed_count += 1; + let error_msg = format!("Migration {} failed: {}", metadata.id, e); + report.errors.push(error_msg.clone()); + + // Call error hooks + for hook in hooks { + let _ = hook.on_error(&ctx, &e).await; + } + + if options.rollback_on_failure { + self.rollback_completed(&mut ctx, &report, hooks).await?; + } + + report.steps.push(MigrationStepReport { + migration_id: metadata.id.clone(), + migration_name: metadata.name.clone(), + description: metadata.description.clone(), + result: MigrationStepResult::default(), + skipped: false, + error: Some(error_msg), + }); + + report.success = false; + report.total_duration = start.elapsed(); + return Ok(report); + } + }; + + // Post-step hooks + for hook in hooks { + if let Err(e) = hook.post_step(&ctx, migration.as_ref(), &step_result).await { + tracing::warn!("Hook {} post_step failed: {}", hook.name(), e); + } + } + + report.steps.push(MigrationStepReport { + migration_id: metadata.id.clone(), + migration_name: metadata.name.clone(), + description: metadata.description.clone(), + result: step_result, + skipped: false, + error: None, + }); + } + + // Post-migrate hooks + for hook in hooks { + if let Err(e) = hook.post_migrate(&ctx, &report).await { + tracing::warn!("Hook {} post_migrate failed: {}", hook.name(), e); + } + } + + report.success = report.failed_count == 0; + report.total_duration = start.elapsed(); + + Ok(report) + } + + /// Rollback completed migrations + async fn rollback_completed( + &self, + ctx: &mut MigrationContext, + report: &MigrationReport, + hooks: &[Arc], + ) -> MigrationResult<()> { + tracing::info!( + "Rolling back {} completed migrations", + report.successful_count + ); + + // Rollback in reverse order + for step in report.steps.iter().rev() { + if step.skipped || step.error.is_some() { + continue; + } + + if let Some(migration) = self.registry.get(&step.migration_id) + && migration.metadata().reversible + { + // Call rollback hooks + for hook in hooks { + let _ = hook.on_rollback(ctx, migration.as_ref()).await; + } + + if let Err(e) = migration.rollback(ctx).await { + tracing::error!("Rollback failed for {}: {}", step.migration_id, e); + } + } + } + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::types::{Change, ChangeType}; + use crate::version::{Version, VersionRange}; + use async_trait::async_trait; + use std::any::Any; + use std::sync::atomic::{AtomicUsize, Ordering}; + use tempfile::TempDir; + + struct CountingMigration { + id: String, + counter: Arc, + should_run: bool, + } + + impl CountingMigration { + fn new(id: &str, counter: Arc, should_run: bool) -> Self { + Self { + id: id.to_string(), + counter, + should_run, + } + } + } + + #[async_trait] + impl Migration for CountingMigration { + fn metadata(&self) -> MigrationMetadata { + MigrationMetadata::new(&self.id, &self.id, "Test") + .with_versions(VersionRange::any(), Version::new(1, 0, 0)) + } + + async fn check(&self, _ctx: &MigrationContext) -> MigrationResult { + Ok(self.should_run) + } + + async fn migrate( + &self, + _ctx: &mut MigrationContext, + ) -> MigrationResult { + self.counter.fetch_add(1, Ordering::SeqCst); + Ok(MigrationStepResult::success() + .with_change(Change::new(ChangeType::Modified, "test.txt"))) + } + + fn as_any(&self) -> &dyn Any { + self + } + } + + #[tokio::test] + async fn test_engine_migrate() { + let temp = TempDir::new().unwrap(); + let counter = Arc::new(AtomicUsize::new(0)); + + let engine = MigrationEngine::new() + .register(CountingMigration::new("m1", counter.clone(), true)) + .register(CountingMigration::new("m2", counter.clone(), true)) + .register(CountingMigration::new("m3", counter.clone(), false)); + + let result = engine + .migrate(temp.path(), &MigrationOptions::default()) + .await + .unwrap(); + + assert!(result.success); + assert_eq!(result.successful_count, 2); + assert_eq!(result.skipped_count, 1); + assert_eq!(counter.load(Ordering::SeqCst), 2); + } + + #[tokio::test] + async fn test_engine_dry_run() { + let temp = TempDir::new().unwrap(); + let counter = Arc::new(AtomicUsize::new(0)); + + let engine = + MigrationEngine::new().register(CountingMigration::new("m1", counter.clone(), true)); + + let result = engine + .migrate(temp.path(), &MigrationOptions::dry_run()) + .await + .unwrap(); + + assert!(result.success); + assert_eq!(result.successful_count, 1); + assert_eq!(counter.load(Ordering::SeqCst), 1); // Still runs in dry-run + } + + #[tokio::test] + async fn test_engine_skip_migration() { + let temp = TempDir::new().unwrap(); + let counter = Arc::new(AtomicUsize::new(0)); + + let engine = MigrationEngine::new() + .register(CountingMigration::new("m1", counter.clone(), true)) + .register(CountingMigration::new("m2", counter.clone(), true)); + + let mut options = MigrationOptions::default(); + options.skip_migrations.insert("m1".to_string()); + + let result = engine.migrate(temp.path(), &options).await.unwrap(); + + assert!(result.success); + assert_eq!(result.successful_count, 1); + assert_eq!(result.skipped_count, 1); + assert_eq!(counter.load(Ordering::SeqCst), 1); + } +} diff --git a/crates/vx-migration/src/error.rs b/crates/vx-migration/src/error.rs new file mode 100644 index 000000000..0f039f6e2 --- /dev/null +++ b/crates/vx-migration/src/error.rs @@ -0,0 +1,113 @@ +//! Error types for the migration framework. + +use std::path::PathBuf; +use thiserror::Error; + +/// Migration error type +#[derive(Error, Debug)] +pub enum MigrationError { + /// IO error + #[error("IO error at {path:?}: {message}")] + Io { + message: String, + path: Option, + #[source] + source: std::io::Error, + }, + + /// Configuration parsing error + #[error("Config error: {message}")] + Config { + message: String, + #[source] + source: Option, + }, + + /// Version parsing error + #[error("Version error: {0}")] + Version(String), + + /// Migration not found + #[error("Migration not found: {0}")] + NotFound(String), + + /// Migration already executed + #[error("Migration already executed: {0}")] + AlreadyExecuted(String), + + /// Dependency error + #[error("Dependency error: {message}")] + Dependency { message: String }, + + /// Validation error + #[error("Validation error: {0}")] + Validation(String), + + /// Rollback error + #[error("Rollback error: {message}")] + Rollback { + message: String, + migration_id: String, + }, + + /// Context error + #[error("Context error: {0}")] + Context(String), + + /// Hook error + #[error("Hook error in {hook_name}: {message}")] + Hook { hook_name: String, message: String }, + + /// Serialization error + #[error("Serialization error: {0}")] + Serialization(String), + + /// Generic error + #[error("{0}")] + Other(String), +} + +impl MigrationError { + /// Create an IO error + pub fn io(message: impl Into, path: Option, source: std::io::Error) -> Self { + Self::Io { + message: message.into(), + path, + source, + } + } + + /// Create a config error + pub fn config(message: impl Into, source: Option) -> Self { + Self::Config { + message: message.into(), + source, + } + } + + /// Create a dependency error + pub fn dependency(message: impl Into) -> Self { + Self::Dependency { + message: message.into(), + } + } + + /// Create a rollback error + pub fn rollback(migration_id: impl Into, message: impl Into) -> Self { + Self::Rollback { + migration_id: migration_id.into(), + message: message.into(), + } + } + + /// Create a hook error + pub fn hook(hook_name: impl Into, message: impl Into) -> Self { + Self::Hook { + hook_name: hook_name.into(), + message: message.into(), + } + } +} + +/// Result type for migration operations +pub type MigrationResult = Result; diff --git a/crates/vx-migration/src/history.rs b/crates/vx-migration/src/history.rs new file mode 100644 index 000000000..efb8e1a03 --- /dev/null +++ b/crates/vx-migration/src/history.rs @@ -0,0 +1,231 @@ +//! Migration history tracking. + +use crate::error::{MigrationError, MigrationResult}; +use crate::types::{Change, MigrationStepResult}; +use crate::version::Version; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use std::path::{Path, PathBuf}; +use uuid::Uuid; + +/// Migration history +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct MigrationHistory { + /// History format version + pub version: String, + /// History entries + pub entries: Vec, +} + +/// A single history entry +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MigrationHistoryEntry { + /// Unique ID + pub id: String, + /// Migration ID + pub migration_id: String, + /// Timestamp + pub timestamp: DateTime, + /// Source version + pub from_version: Option, + /// Target version + pub to_version: Option, + /// Status + pub status: MigrationStatus, + /// Duration in milliseconds + pub duration_ms: u64, + /// Changes made + pub changes: Vec, + /// Machine identifier + pub machine_id: String, + /// Error message if failed + pub error: Option, +} + +/// Migration status +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum MigrationStatus { + /// Migration completed successfully + Completed, + /// Migration failed + Failed, + /// Migration was rolled back + RolledBack, + /// Migration was skipped + Skipped, +} + +impl MigrationHistory { + /// Create a new history + pub fn new() -> Self { + Self { + version: "1.0.0".to_string(), + entries: Vec::new(), + } + } + + /// Load history from file + pub async fn load(path: &Path) -> MigrationResult { + if !path.exists() { + return Ok(Self::new()); + } + + let content = tokio::fs::read_to_string(path).await.map_err(|e| { + MigrationError::io("Failed to read history", Some(path.to_path_buf()), e) + })?; + + serde_json::from_str(&content) + .map_err(|e| MigrationError::Serialization(format!("Failed to parse history: {}", e))) + } + + /// Save history to file + pub async fn save(&self, path: &Path) -> MigrationResult<()> { + // Ensure parent directory exists + if let Some(parent) = path.parent() { + tokio::fs::create_dir_all(parent).await.map_err(|e| { + MigrationError::io("Failed to create directory", Some(parent.to_path_buf()), e) + })?; + } + + let content = serde_json::to_string_pretty(self).map_err(|e| { + MigrationError::Serialization(format!("Failed to serialize history: {}", e)) + })?; + + tokio::fs::write(path, content).await.map_err(|e| { + MigrationError::io("Failed to write history", Some(path.to_path_buf()), e) + })?; + + Ok(()) + } + + /// Add an entry + pub fn add_entry(&mut self, entry: MigrationHistoryEntry) { + self.entries.push(entry); + } + + /// Get entries for a specific migration + pub fn get_entries(&self, migration_id: &str) -> Vec<&MigrationHistoryEntry> { + self.entries + .iter() + .filter(|e| e.migration_id == migration_id) + .collect() + } + + /// Check if a migration has been completed + pub fn is_completed(&self, migration_id: &str) -> bool { + self.entries + .iter() + .any(|e| e.migration_id == migration_id && e.status == MigrationStatus::Completed) + } + + /// Get the last entry + pub fn last_entry(&self) -> Option<&MigrationHistoryEntry> { + self.entries.last() + } + + /// Get entries count + pub fn len(&self) -> usize { + self.entries.len() + } + + /// Check if empty + pub fn is_empty(&self) -> bool { + self.entries.is_empty() + } + + /// Get default history path + pub fn default_path() -> PathBuf { + vx_paths::VxPaths::default() + .base_dir + .join("migration-history.json") + } +} + +impl MigrationHistoryEntry { + /// Create a new entry + pub fn new(migration_id: impl Into) -> Self { + Self { + id: Uuid::new_v4().to_string(), + migration_id: migration_id.into(), + timestamp: Utc::now(), + from_version: None, + to_version: None, + status: MigrationStatus::Completed, + duration_ms: 0, + changes: Vec::new(), + machine_id: hostname::get() + .map(|h| h.to_string_lossy().to_string()) + .unwrap_or_else(|_| "unknown".to_string()), + error: None, + } + } + + /// Set versions + pub fn with_versions(mut self, from: Option<&Version>, to: Option<&Version>) -> Self { + self.from_version = from.map(|v| v.to_string()); + self.to_version = to.map(|v| v.to_string()); + self + } + + /// Set status + pub fn with_status(mut self, status: MigrationStatus) -> Self { + self.status = status; + self + } + + /// Set from step result + pub fn from_result(mut self, result: &MigrationStepResult) -> Self { + self.duration_ms = result.duration.as_millis() as u64; + self.changes = result.changes.clone(); + self.status = if result.success { + MigrationStatus::Completed + } else { + MigrationStatus::Failed + }; + self + } + + /// Set error + pub fn with_error(mut self, error: impl Into) -> Self { + self.error = Some(error.into()); + self.status = MigrationStatus::Failed; + self + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + #[tokio::test] + async fn test_history_save_load() { + let temp = TempDir::new().unwrap(); + let path = temp.path().join("history.json"); + + let mut history = MigrationHistory::new(); + history.add_entry(MigrationHistoryEntry::new("test-migration")); + + history.save(&path).await.unwrap(); + + let loaded = MigrationHistory::load(&path).await.unwrap(); + assert_eq!(loaded.entries.len(), 1); + assert_eq!(loaded.entries[0].migration_id, "test-migration"); + } + + #[tokio::test] + async fn test_history_queries() { + let mut history = MigrationHistory::new(); + history.add_entry(MigrationHistoryEntry::new("m1").with_status(MigrationStatus::Completed)); + history.add_entry(MigrationHistoryEntry::new("m2").with_status(MigrationStatus::Failed)); + history.add_entry(MigrationHistoryEntry::new("m1").with_status(MigrationStatus::Completed)); + + assert!(history.is_completed("m1")); + assert!(!history.is_completed("m2")); + assert!(!history.is_completed("m3")); + + assert_eq!(history.get_entries("m1").len(), 2); + assert_eq!(history.get_entries("m2").len(), 1); + } +} diff --git a/crates/vx-migration/src/lib.rs b/crates/vx-migration/src/lib.rs new file mode 100644 index 000000000..42502935e --- /dev/null +++ b/crates/vx-migration/src/lib.rs @@ -0,0 +1,51 @@ +//! # vx-migration +//! +//! A pluggable migration framework for vx configuration and data. +//! +//! ## Features +//! +//! - **Plugin-based design**: Add migrations by implementing the `Migration` trait +//! - **Lifecycle hooks**: Support for pre/post migration hooks +//! - **Dependency management**: Define dependencies between migrations +//! - **Dry-run mode**: Preview changes without executing +//! - **History tracking**: Track all migration operations +//! - **Rollback support**: Reversible migrations can be rolled back +//! +//! ## Example +//! +//! ```rust,ignore +//! use vx_migration::prelude::*; +//! use vx_migration::migrations::create_default_engine; +//! +//! let engine = create_default_engine(); +//! let options = MigrationOptions::default(); +//! let result = engine.migrate(Path::new("./my-project"), &options).await?; +//! ``` + +pub mod context; +pub mod engine; +pub mod error; +pub mod history; +pub mod migrations; +pub mod registry; +pub mod traits; +pub mod types; +pub mod version; + +pub use error::{MigrationError, MigrationResult}; +pub use types::{MigrationCategory, MigrationMetadata, MigrationOptions, MigrationPriority}; + +/// Prelude module for convenient imports +pub mod prelude { + pub use crate::context::MigrationContext; + pub use crate::engine::MigrationEngine; + pub use crate::error::{MigrationError, MigrationResult}; + pub use crate::history::{MigrationHistory, MigrationHistoryEntry}; + pub use crate::registry::MigrationRegistry; + pub use crate::traits::{Migration, MigrationHook}; + pub use crate::types::{ + Change, ChangeType, MigrationCategory, MigrationMetadata, MigrationOptions, + MigrationPriority, MigrationReport, MigrationStepResult, + }; + pub use crate::version::{Version, VersionRange}; +} diff --git a/crates/vx-migration/src/migrations/config_v1_to_v2.rs b/crates/vx-migration/src/migrations/config_v1_to_v2.rs new file mode 100644 index 000000000..00d2100d6 --- /dev/null +++ b/crates/vx-migration/src/migrations/config_v1_to_v2.rs @@ -0,0 +1,225 @@ +//! Migration from config v1 to v2 format. + +use crate::context::MigrationContext; +use crate::error::MigrationResult; +use crate::traits::Migration; +use crate::types::{ + Change, ChangeType, MigrationCategory, MigrationMetadata, MigrationPriority, + MigrationStepResult, +}; +use crate::version::{Version, VersionRange}; +use async_trait::async_trait; +use std::any::Any; + +/// Migration from config v1 (`[tools]`) to v2 (`[runtimes]`) format +pub struct ConfigV1ToV2Migration; + +impl ConfigV1ToV2Migration { + /// Create a new migration + pub fn new() -> Self { + Self + } + + /// Transform v1 config to v2 format + fn transform_config(&self, content: &str) -> MigrationResult { + let mut result = content.to_string(); + + // Replace [tools] with [runtimes] + result = result.replace("[tools]", "[runtimes]"); + + // Replace [tools. with [runtimes. + result = result.replace("[tools.", "[runtimes."); + + Ok(result) + } + + /// Check if content is v1 format + fn is_v1_format(&self, content: &str) -> bool { + content.contains("[tools]") || content.contains("[tools.") + } +} + +impl Default for ConfigV1ToV2Migration { + fn default() -> Self { + Self::new() + } +} + +#[async_trait] +impl Migration for ConfigV1ToV2Migration { + fn metadata(&self) -> MigrationMetadata { + MigrationMetadata::new( + "config-v1-to-v2", + "Config v1 to v2 Migration", + "Migrates [tools] section to [runtimes] format", + ) + .with_versions( + VersionRange::lt(Version::new(2, 0, 0)), + Version::new(2, 0, 0), + ) + .with_category(MigrationCategory::Config) + .with_priority(MigrationPriority::High) + .reversible() + } + + async fn check(&self, ctx: &MigrationContext) -> MigrationResult { + let config_path = ctx.root_path().join("vx.toml"); + if !ctx.file_exists(&config_path).await { + return Ok(false); + } + + let content = ctx.read_file(&config_path).await?; + Ok(self.is_v1_format(&content)) + } + + async fn migrate(&self, ctx: &mut MigrationContext) -> MigrationResult { + let mut changes = Vec::new(); + let warnings = Vec::new(); + + let config_path = "vx.toml"; + if ctx.file_exists(config_path).await { + let content = ctx.read_file(config_path).await?; + + if self.is_v1_format(&content) { + let new_content = self.transform_config(&content)?; + ctx.write_file(config_path, &new_content).await?; + + changes.push( + Change::new(ChangeType::Modified, config_path) + .with_description("Converted [tools] to [runtimes]"), + ); + } + } + + Ok(MigrationStepResult { + success: true, + changes, + warnings, + duration: std::time::Duration::ZERO, + }) + } + + async fn rollback(&self, _ctx: &mut MigrationContext) -> MigrationResult<()> { + // Rollback would convert [runtimes] back to [tools] + // For now, we don't implement automatic rollback + Ok(()) + } + + fn as_any(&self) -> &dyn Any { + self + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::types::MigrationOptions; + use tempfile::TempDir; + + #[tokio::test] + async fn test_check_v1_config() { + let temp = TempDir::new().unwrap(); + let config = r#" +[tools] +node = "18.0.0" +go = "1.21.0" +"#; + tokio::fs::write(temp.path().join("vx.toml"), config) + .await + .unwrap(); + + let ctx = MigrationContext::new(temp.path(), MigrationOptions::default()); + let migration = ConfigV1ToV2Migration::new(); + + assert!(migration.check(&ctx).await.unwrap()); + } + + #[tokio::test] + async fn test_check_v2_config() { + let temp = TempDir::new().unwrap(); + let config = r#" +[runtimes] +node = "18.0.0" +go = "1.21.0" +"#; + tokio::fs::write(temp.path().join("vx.toml"), config) + .await + .unwrap(); + + let ctx = MigrationContext::new(temp.path(), MigrationOptions::default()); + let migration = ConfigV1ToV2Migration::new(); + + assert!(!migration.check(&ctx).await.unwrap()); + } + + #[tokio::test] + async fn test_migrate_v1_to_v2() { + let temp = TempDir::new().unwrap(); + let config = r#" +[tools] +node = "18.0.0" + +[tools.go] +version = "1.21.0" +"#; + tokio::fs::write(temp.path().join("vx.toml"), config) + .await + .unwrap(); + + let mut ctx = MigrationContext::new(temp.path(), MigrationOptions::default()); + let migration = ConfigV1ToV2Migration::new(); + + let result = migration.migrate(&mut ctx).await.unwrap(); + assert!(result.success); + assert_eq!(result.changes.len(), 1); + + let new_content = tokio::fs::read_to_string(temp.path().join("vx.toml")) + .await + .unwrap(); + assert!(new_content.contains("[runtimes]")); + assert!(new_content.contains("[runtimes.go]")); + assert!(!new_content.contains("[tools]")); + } + + #[tokio::test] + async fn test_migrate_dry_run() { + let temp = TempDir::new().unwrap(); + let config = r#" +[tools] +node = "18.0.0" +"#; + tokio::fs::write(temp.path().join("vx.toml"), config) + .await + .unwrap(); + + let mut ctx = MigrationContext::new(temp.path(), MigrationOptions::dry_run()); + let migration = ConfigV1ToV2Migration::new(); + + let result = migration.migrate(&mut ctx).await.unwrap(); + assert!(result.success); + + // File should not be modified in dry-run + let content = tokio::fs::read_to_string(temp.path().join("vx.toml")) + .await + .unwrap(); + assert!(content.contains("[tools]")); + } + + #[test] + fn test_transform_config() { + let migration = ConfigV1ToV2Migration::new(); + + let v1 = r#" +[tools] +node = "18.0.0" + +[tools.go] +version = "1.21.0" +"#; + + let v2 = migration.transform_config(v1).unwrap(); + assert!(v2.contains("[runtimes]")); + assert!(v2.contains("[runtimes.go]")); + assert!(!v2.contains("[tools]")); + } +} diff --git a/crates/vx-migration/src/migrations/file_rename.rs b/crates/vx-migration/src/migrations/file_rename.rs new file mode 100644 index 000000000..4a21502f7 --- /dev/null +++ b/crates/vx-migration/src/migrations/file_rename.rs @@ -0,0 +1,184 @@ +//! Migration for renaming config files. + +use crate::context::MigrationContext; +use crate::error::MigrationResult; +use crate::traits::Migration; +use crate::types::{ + Change, MigrationCategory, MigrationMetadata, MigrationPriority, MigrationStepResult, +}; +use crate::version::{Version, VersionRange}; +use async_trait::async_trait; +use std::any::Any; + +/// Placeholder migration for config file renames. +/// +/// NOTE: This migration is currently a no-op because both the source and target +/// file names are `vx.toml`. It exists as a template for future file rename +/// migrations (e.g., if a legacy config name like `.vx.toml` is ever introduced). +pub struct FileRenameMigration; + +impl FileRenameMigration { + /// Create a new migration + pub fn new() -> Self { + Self + } +} + +impl Default for FileRenameMigration { + fn default() -> Self { + Self::new() + } +} + +#[async_trait] +impl Migration for FileRenameMigration { + fn metadata(&self) -> MigrationMetadata { + MigrationMetadata::new( + "file-rename", + "Config File Rename", + "Placeholder for config file rename migration (currently a no-op)", + ) + .with_versions(VersionRange::any(), Version::new(2, 0, 0)) + .with_category(MigrationCategory::FileStructure) + .with_priority(MigrationPriority::Critical) + .reversible() + } + + async fn check(&self, ctx: &MigrationContext) -> MigrationResult { + // NOTE: Both paths currently point to "vx.toml", so this always returns false. + // This will be meaningful once a legacy filename (e.g., ".vx.toml") is introduced. + let old_exists = ctx.file_exists("vx.toml").await; + let new_exists = ctx.file_exists("vx.toml").await; + + Ok(old_exists && !new_exists) + } + + async fn migrate(&self, ctx: &mut MigrationContext) -> MigrationResult { + let mut changes = Vec::new(); + let warnings = Vec::new(); + + if ctx.file_exists("vx.toml").await && !ctx.file_exists("vx.toml").await { + ctx.rename_file("vx.toml", "vx.toml").await?; + changes.push(Change::rename("vx.toml", "vx.toml")); + } + + Ok(MigrationStepResult { + success: true, + changes, + warnings, + duration: std::time::Duration::ZERO, + }) + } + + async fn rollback(&self, ctx: &mut MigrationContext) -> MigrationResult<()> { + if ctx.file_exists("vx.toml").await && !ctx.file_exists("vx.toml").await { + ctx.rename_file("vx.toml", "vx.toml").await?; + } + Ok(()) + } + + fn as_any(&self) -> &dyn Any { + self + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::types::MigrationOptions; + use tempfile::TempDir; + + // Note: Currently CONFIG_FILE_NAME and CONFIG_FILE_NAME_LEGACY are both "vx.toml", + // so this migration is effectively a no-op. These tests verify the current behavior + // where the migration correctly identifies that no rename is needed. + + #[tokio::test] + async fn test_check_same_filename_no_rename_needed() { + // When old and new filenames are the same (both vx.toml), + // check should return false since no rename is needed + let temp = TempDir::new().unwrap(); + tokio::fs::write(temp.path().join("vx.toml"), "[tools]") + .await + .unwrap(); + + let ctx = MigrationContext::new(temp.path(), MigrationOptions::default()); + let migration = FileRenameMigration::new(); + + // Since old_exists && !new_exists is always false when filenames are identical, + // the migration should report no rename needed + assert!(!migration.check(&ctx).await.unwrap()); + } + + #[tokio::test] + async fn test_check_no_config_file() { + let temp = TempDir::new().unwrap(); + + let ctx = MigrationContext::new(temp.path(), MigrationOptions::default()); + let migration = FileRenameMigration::new(); + + // No config file exists, so no rename needed + assert!(!migration.check(&ctx).await.unwrap()); + } + + #[tokio::test] + async fn test_migrate_no_changes_when_same_filename() { + let temp = TempDir::new().unwrap(); + tokio::fs::write(temp.path().join("vx.toml"), "[tools]\nnode = \"18\"") + .await + .unwrap(); + + let mut ctx = MigrationContext::new(temp.path(), MigrationOptions::default()); + let migration = FileRenameMigration::new(); + + let result = migration.migrate(&mut ctx).await.unwrap(); + assert!(result.success); + // No changes since old and new filenames are the same + assert_eq!(result.changes.len(), 0); + + // File should still exist + assert!(temp.path().join("vx.toml").exists()); + } + + #[tokio::test] + async fn test_migrate_dry_run() { + let temp = TempDir::new().unwrap(); + tokio::fs::write(temp.path().join("vx.toml"), "[tools]") + .await + .unwrap(); + + let mut ctx = MigrationContext::new(temp.path(), MigrationOptions::dry_run()); + let migration = FileRenameMigration::new(); + + let result = migration.migrate(&mut ctx).await.unwrap(); + assert!(result.success); + + // File should still exist (no rename happened) + assert!(temp.path().join("vx.toml").exists()); + } + + #[tokio::test] + async fn test_rollback_no_op() { + let temp = TempDir::new().unwrap(); + tokio::fs::write(temp.path().join("vx.toml"), "[runtimes]") + .await + .unwrap(); + + let mut ctx = MigrationContext::new(temp.path(), MigrationOptions::default()); + let migration = FileRenameMigration::new(); + + // Rollback should succeed (no-op when filenames are same) + migration.rollback(&mut ctx).await.unwrap(); + + // File should still exist + assert!(temp.path().join("vx.toml").exists()); + } + + #[tokio::test] + async fn test_metadata() { + let migration = FileRenameMigration::new(); + let metadata = migration.metadata(); + + assert_eq!(metadata.id, "file-rename"); + assert!(metadata.reversible); + } +} diff --git a/crates/vx-migration/src/migrations/mod.rs b/crates/vx-migration/src/migrations/mod.rs new file mode 100644 index 000000000..a1ef82964 --- /dev/null +++ b/crates/vx-migration/src/migrations/mod.rs @@ -0,0 +1,18 @@ +//! Built-in migrations for vx. + +mod config_v1_to_v2; +mod file_rename; +mod version_detector; + +pub use config_v1_to_v2::ConfigV1ToV2Migration; +pub use file_rename::FileRenameMigration; +pub use version_detector::VxVersionDetector; + +use crate::engine::MigrationEngine; + +/// Create a default migration engine with all built-in migrations +pub fn create_default_engine() -> MigrationEngine { + MigrationEngine::new() + .register(FileRenameMigration::new()) + .register(ConfigV1ToV2Migration::new()) +} diff --git a/crates/vx-migration/src/migrations/version_detector.rs b/crates/vx-migration/src/migrations/version_detector.rs new file mode 100644 index 000000000..451cb8a23 --- /dev/null +++ b/crates/vx-migration/src/migrations/version_detector.rs @@ -0,0 +1,194 @@ +//! Version detection for vx configurations. + +use crate::error::MigrationResult; +use crate::traits::VersionDetector; +use crate::version::{Version, VersionRange}; +use async_trait::async_trait; +use std::path::Path; + +/// Version detector for vx configuration files +pub struct VxVersionDetector; + +impl VxVersionDetector { + /// Create a new detector + pub fn new() -> Self { + Self + } + + /// Detect version from config content + fn detect_from_content(&self, content: &str) -> Option { + // `toml::Value` parsing behavior differs across toml crate versions. + // Parse as a full TOML document first, then inspect keys. + let doc: toml::Table = toml::from_str(content).ok()?; + + // Check for explicit version field + if let Some(version) = doc.get("version").and_then(|v| v.as_str()) + && let Ok(v) = version.parse() + { + return Some(v); + } + + // Check for vx section with version + if let Some(vx) = doc.get("vx").and_then(|v| v.as_table()) + && let Some(version) = vx.get("version").and_then(|v| v.as_str()) + && let Ok(v) = version.parse() + { + return Some(v); + } + + // Detect v1 format (has [tools] section) + if doc.contains_key("tools") { + return Some(Version::new(1, 0, 0)); + } + + // Detect v2 format (has [runtimes] section) + if doc.contains_key("runtimes") { + return Some(Version::new(2, 0, 0)); + } + + None + } +} + +impl Default for VxVersionDetector { + fn default() -> Self { + Self::new() + } +} + +#[async_trait] +impl VersionDetector for VxVersionDetector { + fn name(&self) -> &str { + "vx-version-detector" + } + + async fn detect(&self, path: &Path) -> MigrationResult> { + // Check for vx.toml + let vx_toml = path.join("vx.toml"); + if vx_toml.exists() + && let Ok(content) = tokio::fs::read_to_string(&vx_toml).await + { + if let Some(version) = self.detect_from_content(&content) { + return Ok(Some(version)); + } + // If vx.toml exists but no version detected, assume v1 + return Ok(Some(Version::new(1, 0, 0))); + } + + // Check for .vx.toml (old format) + let dot_vx_toml = path.join(".vx.toml"); + if dot_vx_toml.exists() + && let Ok(content) = tokio::fs::read_to_string(&dot_vx_toml).await + { + if let Some(version) = self.detect_from_content(&content) { + return Ok(Some(version)); + } + // If .vx.toml exists but no version detected, assume v1 + return Ok(Some(Version::new(1, 0, 0))); + } + + Ok(None) + } + + fn supported_range(&self) -> VersionRange { + VersionRange::any() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + #[test] + fn test_detect_from_content_v2() { + let detector = VxVersionDetector::new(); + let config = r#" +[runtimes] +node = "18.0.0" +"#; + // Debug: test TOML parsing + let parsed: Result = toml::from_str(config); + assert!(parsed.is_ok(), "TOML should parse: {:?}", parsed.err()); + let value = parsed.unwrap(); + assert!(value.get("runtimes").is_some(), "Should have runtimes key"); + + let version = detector.detect_from_content(config); + assert_eq!(version, Some(Version::new(2, 0, 0))); + } + + #[tokio::test] + async fn test_detect_v1_format() { + let temp = TempDir::new().unwrap(); + let config = r#" +[tools] +node = "18.0.0" +"#; + tokio::fs::write(temp.path().join("vx.toml"), config) + .await + .unwrap(); + + let detector = VxVersionDetector::new(); + let version = detector.detect(temp.path()).await.unwrap(); + assert_eq!(version, Some(Version::new(1, 0, 0))); + } + + #[tokio::test] + async fn test_detect_v2_format() { + let temp = TempDir::new().unwrap(); + let config = r#" +[runtimes] +node = "18.0.0" +"#; + tokio::fs::write(temp.path().join("vx.toml"), config) + .await + .unwrap(); + + let detector = VxVersionDetector::new(); + let version = detector.detect(temp.path()).await.unwrap(); + assert_eq!(version, Some(Version::new(2, 0, 0))); + } + + #[tokio::test] + async fn test_detect_explicit_version() { + let temp = TempDir::new().unwrap(); + let config = r#" +version = "2.1.0" + +[runtimes] +node = "18.0.0" +"#; + tokio::fs::write(temp.path().join("vx.toml"), config) + .await + .unwrap(); + + let detector = VxVersionDetector::new(); + let version = detector.detect(temp.path()).await.unwrap(); + assert_eq!(version, Some(Version::new(2, 1, 0))); + } + + #[tokio::test] + async fn test_detect_old_filename() { + let temp = TempDir::new().unwrap(); + let config = r#" +[tools] +node = "18.0.0" +"#; + tokio::fs::write(temp.path().join("vx.toml"), config) + .await + .unwrap(); + + let detector = VxVersionDetector::new(); + let version = detector.detect(temp.path()).await.unwrap(); + assert_eq!(version, Some(Version::new(1, 0, 0))); + } + + #[tokio::test] + async fn test_detect_no_config() { + let temp = TempDir::new().unwrap(); + + let detector = VxVersionDetector::new(); + let version = detector.detect(temp.path()).await.unwrap(); + assert_eq!(version, None); + } +} diff --git a/crates/vx-migration/src/registry.rs b/crates/vx-migration/src/registry.rs new file mode 100644 index 000000000..a42cd529a --- /dev/null +++ b/crates/vx-migration/src/registry.rs @@ -0,0 +1,249 @@ +//! Migration registry for managing migrations. + +use crate::error::{MigrationError, MigrationResult}; +use crate::traits::{Migration, MigrationHook}; +use crate::types::MigrationMetadata; +use std::collections::HashMap; +use std::sync::Arc; + +/// Registry for migrations and hooks +pub struct MigrationRegistry { + /// Registered migrations + migrations: HashMap>, + /// Registered hooks + hooks: Vec>, +} + +impl Default for MigrationRegistry { + fn default() -> Self { + Self::new() + } +} + +impl MigrationRegistry { + /// Create a new registry + pub fn new() -> Self { + Self { + migrations: HashMap::new(), + hooks: Vec::new(), + } + } + + /// Register a migration + pub fn register(&mut self, migration: M) -> MigrationResult<()> { + let id = migration.metadata().id; + if self.migrations.contains_key(&id) { + return Err(MigrationError::Other(format!( + "Migration '{id}' already registered" + ))); + } + self.migrations.insert(id, Arc::new(migration)); + Ok(()) + } + + /// Register a hook + pub fn register_hook(&mut self, hook: H) { + self.hooks.push(Arc::new(hook)); + } + + /// Get a migration by ID + pub fn get(&self, id: &str) -> Option> { + self.migrations.get(id).cloned() + } + + /// Get all migrations + pub fn all(&self) -> Vec> { + self.migrations.values().cloned().collect() + } + + /// Get all migration metadata + pub fn metadata(&self) -> Vec { + self.migrations.values().map(|m| m.metadata()).collect() + } + + /// Get migrations sorted by priority and dependencies + pub fn sorted(&self) -> MigrationResult>> { + let mut sorted = Vec::new(); + let mut visited = std::collections::HashSet::new(); + let mut visiting = std::collections::HashSet::new(); + + for id in self.migrations.keys() { + self.visit_migration(id, &mut sorted, &mut visited, &mut visiting)?; + } + + // Sort by priority + sorted.sort_by_key(|m| m.metadata().priority); + + Ok(sorted) + } + + /// Topological sort helper + fn visit_migration( + &self, + id: &str, + sorted: &mut Vec>, + visited: &mut std::collections::HashSet, + visiting: &mut std::collections::HashSet, + ) -> MigrationResult<()> { + if visited.contains(id) { + return Ok(()); + } + + if visiting.contains(id) { + return Err(MigrationError::dependency(format!( + "Circular dependency detected: {}", + id + ))); + } + + let migration = self + .migrations + .get(id) + .ok_or_else(|| MigrationError::NotFound(id.to_string()))?; + + visiting.insert(id.to_string()); + + for dep in migration.dependencies() { + self.visit_migration(dep, sorted, visited, visiting)?; + } + + visiting.remove(id); + visited.insert(id.to_string()); + sorted.push(migration.clone()); + + Ok(()) + } + + /// Get all hooks + pub fn hooks(&self) -> &[Arc] { + &self.hooks + } + + /// Check if a migration is registered + pub fn contains(&self, id: &str) -> bool { + self.migrations.contains_key(id) + } + + /// Get migration count + pub fn len(&self) -> usize { + self.migrations.len() + } + + /// Check if empty + pub fn is_empty(&self) -> bool { + self.migrations.is_empty() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::context::MigrationContext; + use crate::types::{MigrationMetadata, MigrationStepResult}; + use crate::version::VersionRange; + use async_trait::async_trait; + use std::any::Any; + + struct TestMigration { + id: String, + deps: Vec, + } + + impl TestMigration { + fn new(id: &str) -> Self { + Self { + id: id.to_string(), + deps: vec![], + } + } + + fn with_deps(id: &str, deps: Vec<&str>) -> Self { + Self { + id: id.to_string(), + deps: deps.into_iter().map(String::from).collect(), + } + } + } + + #[async_trait] + impl Migration for TestMigration { + fn metadata(&self) -> MigrationMetadata { + MigrationMetadata::new(&self.id, &self.id, "Test migration") + .with_versions(VersionRange::any(), crate::version::Version::new(1, 0, 0)) + } + + async fn check(&self, _ctx: &MigrationContext) -> MigrationResult { + Ok(true) + } + + async fn migrate( + &self, + _ctx: &mut MigrationContext, + ) -> MigrationResult { + Ok(MigrationStepResult::success()) + } + + fn dependencies(&self) -> Vec<&str> { + self.deps.iter().map(|s| s.as_str()).collect() + } + + fn as_any(&self) -> &dyn Any { + self + } + } + + #[test] + fn test_register_migration() { + let mut registry = MigrationRegistry::new(); + registry.register(TestMigration::new("test1")).unwrap(); + registry.register(TestMigration::new("test2")).unwrap(); + + assert!(registry.contains("test1")); + assert!(registry.contains("test2")); + assert!(!registry.contains("test3")); + assert_eq!(registry.len(), 2); + } + + #[test] + fn test_duplicate_registration() { + let mut registry = MigrationRegistry::new(); + registry.register(TestMigration::new("test1")).unwrap(); + assert!(registry.register(TestMigration::new("test1")).is_err()); + } + + #[test] + fn test_dependency_sorting() { + let mut registry = MigrationRegistry::new(); + registry + .register(TestMigration::with_deps("c", vec!["b"])) + .unwrap(); + registry + .register(TestMigration::with_deps("b", vec!["a"])) + .unwrap(); + registry.register(TestMigration::new("a")).unwrap(); + + let sorted = registry.sorted().unwrap(); + let ids: Vec<_> = sorted.iter().map(|m| m.metadata().id.clone()).collect(); + + // a should come before b, b before c + let pos_a = ids.iter().position(|id| id == "a").unwrap(); + let pos_b = ids.iter().position(|id| id == "b").unwrap(); + let pos_c = ids.iter().position(|id| id == "c").unwrap(); + + assert!(pos_a < pos_b); + assert!(pos_b < pos_c); + } + + #[test] + fn test_circular_dependency() { + let mut registry = MigrationRegistry::new(); + registry + .register(TestMigration::with_deps("a", vec!["b"])) + .unwrap(); + registry + .register(TestMigration::with_deps("b", vec!["a"])) + .unwrap(); + + assert!(registry.sorted().is_err()); + } +} diff --git a/crates/vx-migration/src/traits.rs b/crates/vx-migration/src/traits.rs new file mode 100644 index 000000000..473719f0e --- /dev/null +++ b/crates/vx-migration/src/traits.rs @@ -0,0 +1,140 @@ +//! Core traits for the migration framework. + +use crate::context::MigrationContext; +use crate::error::{MigrationError, MigrationResult}; +use crate::types::{MigrationMetadata, MigrationReport, MigrationStepResult}; +use crate::version::{Version, VersionRange}; +use async_trait::async_trait; +use std::any::Any; +use std::path::Path; + +/// Migration plugin interface +#[async_trait] +pub trait Migration: Send + Sync { + /// Return migration metadata + fn metadata(&self) -> MigrationMetadata; + + /// Check if this migration needs to run + async fn check(&self, ctx: &MigrationContext) -> MigrationResult; + + /// Execute the migration + async fn migrate(&self, ctx: &mut MigrationContext) -> MigrationResult; + + /// Rollback the migration (optional) + async fn rollback(&self, _ctx: &mut MigrationContext) -> MigrationResult<()> { + Ok(()) // Default: no rollback support + } + + /// Validate migration result (optional) + async fn validate(&self, _ctx: &MigrationContext) -> MigrationResult { + Ok(true) + } + + /// Get dependencies (migration IDs that must run first) + fn dependencies(&self) -> Vec<&str> { + vec![] + } + + /// For downcasting + fn as_any(&self) -> &dyn Any; +} + +/// Migration lifecycle hooks +#[async_trait] +pub trait MigrationHook: Send + Sync { + /// Hook name + fn name(&self) -> &str; + + /// Called before migration starts + async fn pre_migrate(&self, _ctx: &MigrationContext) -> MigrationResult<()> { + Ok(()) + } + + /// Called after migration completes + async fn post_migrate( + &self, + _ctx: &MigrationContext, + _report: &MigrationReport, + ) -> MigrationResult<()> { + Ok(()) + } + + /// Called before each migration step + async fn pre_step( + &self, + _ctx: &MigrationContext, + _migration: &dyn Migration, + ) -> MigrationResult<()> { + Ok(()) + } + + /// Called after each migration step + async fn post_step( + &self, + _ctx: &MigrationContext, + _migration: &dyn Migration, + _result: &MigrationStepResult, + ) -> MigrationResult<()> { + Ok(()) + } + + /// Called on error + async fn on_error( + &self, + _ctx: &MigrationContext, + _error: &MigrationError, + ) -> MigrationResult<()> { + Ok(()) + } + + /// Called on rollback + async fn on_rollback( + &self, + _ctx: &MigrationContext, + _migration: &dyn Migration, + ) -> MigrationResult<()> { + Ok(()) + } +} + +/// Version detector interface +#[async_trait] +pub trait VersionDetector: Send + Sync { + /// Detector name + fn name(&self) -> &str; + + /// Detect version from path + async fn detect(&self, path: &Path) -> MigrationResult>; + + /// Supported version range + fn supported_range(&self) -> VersionRange; +} + +/// Content transformer interface +#[async_trait] +pub trait ContentTransformer: Send + Sync { + /// Transformer name + fn name(&self) -> &str; + + /// Transform content + async fn transform(&self, content: &str) -> MigrationResult; + + /// Check if transformation is needed + fn needs_transform(&self, content: &str) -> bool; +} + +/// Backup manager interface +#[async_trait] +pub trait BackupManager: Send + Sync { + /// Create a backup + async fn backup(&self, ctx: &MigrationContext) -> MigrationResult; + + /// Restore from backup + async fn restore(&self, ctx: &MigrationContext, backup_id: &str) -> MigrationResult<()>; + + /// List available backups + async fn list(&self, ctx: &MigrationContext) -> MigrationResult>; + + /// Delete a backup + async fn delete(&self, backup_id: &str) -> MigrationResult<()>; +} diff --git a/crates/vx-migration/src/types.rs b/crates/vx-migration/src/types.rs new file mode 100644 index 000000000..caffb8846 --- /dev/null +++ b/crates/vx-migration/src/types.rs @@ -0,0 +1,343 @@ +//! Common types for the migration framework. + +use crate::version::{Version, VersionRange}; +use serde::{Deserialize, Serialize}; +use std::collections::HashSet; +use std::path::PathBuf; +use std::time::Duration; + +/// Migration metadata +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MigrationMetadata { + /// Unique identifier + pub id: String, + /// Display name + pub name: String, + /// Description + pub description: String, + /// Source version range + pub from_version: VersionRange, + /// Target version + pub to_version: Version, + /// Migration category + pub category: MigrationCategory, + /// Priority + pub priority: MigrationPriority, + /// Whether this migration is reversible + pub reversible: bool, + /// Whether this is a breaking change + pub breaking: bool, + /// Estimated duration in milliseconds + pub estimated_duration_ms: Option, +} + +impl MigrationMetadata { + /// Create new metadata with required fields + pub fn new( + id: impl Into, + name: impl Into, + description: impl Into, + ) -> Self { + Self { + id: id.into(), + name: name.into(), + description: description.into(), + from_version: VersionRange::any(), + to_version: Version::default(), + category: MigrationCategory::Config, + priority: MigrationPriority::Normal, + reversible: false, + breaking: false, + estimated_duration_ms: None, + } + } + + /// Set version range + pub fn with_versions(mut self, from: VersionRange, to: Version) -> Self { + self.from_version = from; + self.to_version = to; + self + } + + /// Set category + pub fn with_category(mut self, category: MigrationCategory) -> Self { + self.category = category; + self + } + + /// Set priority + pub fn with_priority(mut self, priority: MigrationPriority) -> Self { + self.priority = priority; + self + } + + /// Mark as reversible + pub fn reversible(mut self) -> Self { + self.reversible = true; + self + } + + /// Mark as breaking + pub fn breaking(mut self) -> Self { + self.breaking = true; + self + } +} + +/// Migration category +#[derive(Debug, Clone, PartialEq, Eq, Hash, Default, Serialize, Deserialize)] +pub enum MigrationCategory { + /// Configuration file migration + #[default] + Config, + /// File structure changes + FileStructure, + /// Data format conversion + Data, + /// Schema changes + Schema, + /// Environment setup + Environment, + /// Custom category + Custom(String), +} + +/// Migration priority +#[derive( + Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default, Serialize, Deserialize, +)] +pub enum MigrationPriority { + /// Must execute first + Critical = 0, + /// High priority + High = 1, + /// Default priority + #[default] + Normal = 2, + /// Low priority + Low = 3, + /// Cleanup tasks, execute last + Cleanup = 4, +} + +/// Migration options +#[derive(Debug, Clone, Default)] +pub struct MigrationOptions { + /// Dry-run mode (preview only) + pub dry_run: bool, + /// Create backup before migration + pub backup: bool, + /// Backup directory + pub backup_dir: Option, + /// Target version (None = latest) + pub target_version: Option, + /// Interactive mode + pub interactive: bool, + /// Verbose output + pub verbose: bool, + /// Rollback on failure + pub rollback_on_failure: bool, + /// Migrations to skip + pub skip_migrations: HashSet, + /// Only run these migrations + pub only_migrations: Option>, +} + +impl MigrationOptions { + /// Create options for dry-run + pub fn dry_run() -> Self { + Self { + dry_run: true, + verbose: true, + ..Default::default() + } + } + + /// Create options with backup + pub fn with_backup(backup_dir: Option) -> Self { + Self { + backup: true, + backup_dir, + rollback_on_failure: true, + ..Default::default() + } + } +} + +/// Type of change +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum ChangeType { + /// File created + Created, + /// File modified + Modified, + /// File deleted + Deleted, + /// File renamed + Renamed, + /// File moved + Moved, + /// Permission changed + Permission, + /// Other change + Other(String), +} + +/// A single change made during migration +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Change { + /// Type of change + pub change_type: ChangeType, + /// Path affected + pub path: PathBuf, + /// Old path (for rename/move) + pub old_path: Option, + /// Description + pub description: Option, +} + +impl Change { + /// Create a new change + pub fn new(change_type: ChangeType, path: impl Into) -> Self { + Self { + change_type, + path: path.into(), + old_path: None, + description: None, + } + } + + /// Create a rename change + pub fn rename(old_path: impl Into, new_path: impl Into) -> Self { + Self { + change_type: ChangeType::Renamed, + path: new_path.into(), + old_path: Some(old_path.into()), + description: None, + } + } + + /// Add description + pub fn with_description(mut self, description: impl Into) -> Self { + self.description = Some(description.into()); + self + } +} + +/// Result of a single migration step +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MigrationStepResult { + /// Whether the migration succeeded + pub success: bool, + /// Changes made + pub changes: Vec, + /// Warnings generated + pub warnings: Vec, + /// Duration of the migration + #[serde(with = "duration_serde")] + pub duration: Duration, +} + +impl Default for MigrationStepResult { + fn default() -> Self { + Self { + success: true, + changes: Vec::new(), + warnings: Vec::new(), + duration: Duration::ZERO, + } + } +} + +impl MigrationStepResult { + /// Create a successful result + pub fn success() -> Self { + Self::default() + } + + /// Create a skipped result + pub fn skipped() -> Self { + Self { + success: true, + changes: Vec::new(), + warnings: vec!["Migration skipped".to_string()], + duration: Duration::ZERO, + } + } + + /// Add a change + pub fn with_change(mut self, change: Change) -> Self { + self.changes.push(change); + self + } + + /// Add a warning + pub fn with_warning(mut self, warning: impl Into) -> Self { + self.warnings.push(warning.into()); + self + } + + /// Set duration + pub fn with_duration(mut self, duration: Duration) -> Self { + self.duration = duration; + self + } +} + +/// Overall migration report +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct MigrationReport { + /// Whether the overall migration succeeded + pub success: bool, + /// Number of successful migrations + pub successful_count: usize, + /// Number of skipped migrations + pub skipped_count: usize, + /// Number of failed migrations + pub failed_count: usize, + /// Individual step results + pub steps: Vec, + /// Total duration + #[serde(with = "duration_serde")] + pub total_duration: Duration, + /// Errors encountered + pub errors: Vec, +} + +/// Report for a single migration step +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MigrationStepReport { + /// Migration ID + pub migration_id: String, + /// Migration name + pub migration_name: String, + /// Description + pub description: String, + /// Result + pub result: MigrationStepResult, + /// Whether it was skipped + pub skipped: bool, + /// Error message if failed + pub error: Option, +} + +/// Serde helper for Duration +mod duration_serde { + use serde::{Deserialize, Deserializer, Serialize, Serializer}; + use std::time::Duration; + + pub fn serialize(duration: &Duration, serializer: S) -> Result + where + S: Serializer, + { + duration.as_millis().serialize(serializer) + } + + pub fn deserialize<'de, D>(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let millis = u64::deserialize(deserializer)?; + Ok(Duration::from_millis(millis)) + } +} diff --git a/crates/vx-migration/src/version.rs b/crates/vx-migration/src/version.rs new file mode 100644 index 000000000..a19a952b3 --- /dev/null +++ b/crates/vx-migration/src/version.rs @@ -0,0 +1,289 @@ +//! Version parsing and comparison utilities. + +use crate::error::{MigrationError, MigrationResult}; +use serde::{Deserialize, Serialize}; +use std::cmp::Ordering; +use std::fmt; +use std::str::FromStr; + +/// Semantic version +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct Version { + pub major: u64, + pub minor: u64, + pub patch: u64, + pub pre: Option, +} + +impl Version { + /// Create a new version + pub fn new(major: u64, minor: u64, patch: u64) -> Self { + Self { + major, + minor, + patch, + pre: None, + } + } + + /// Create a version with prerelease + pub fn with_pre(major: u64, minor: u64, patch: u64, pre: impl Into) -> Self { + Self { + major, + minor, + patch, + pre: Some(pre.into()), + } + } + + /// Parse from semver::Version + pub fn from_semver(v: &semver::Version) -> Self { + Self { + major: v.major, + minor: v.minor, + patch: v.patch, + pre: if v.pre.is_empty() { + None + } else { + Some(v.pre.to_string()) + }, + } + } + + /// Convert to semver::Version + pub fn to_semver(&self) -> MigrationResult { + let version_str = if let Some(pre) = &self.pre { + format!("{}.{}.{}-{}", self.major, self.minor, self.patch, pre) + } else { + format!("{}.{}.{}", self.major, self.minor, self.patch) + }; + semver::Version::parse(&version_str) + .map_err(|e| MigrationError::Version(format!("Invalid version: {}", e))) + } +} + +impl Default for Version { + fn default() -> Self { + Self::new(0, 0, 0) + } +} + +impl fmt::Display for Version { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + if let Some(pre) = &self.pre { + write!(f, "{}.{}.{}-{}", self.major, self.minor, self.patch, pre) + } else { + write!(f, "{}.{}.{}", self.major, self.minor, self.patch) + } + } +} + +impl FromStr for Version { + type Err = MigrationError; + + fn from_str(s: &str) -> Result { + // Remove 'v' prefix if present + let s = s.strip_prefix('v').unwrap_or(s); + + let semver = semver::Version::parse(s) + .map_err(|e| MigrationError::Version(format!("Invalid version '{}': {}", s, e)))?; + + Ok(Self::from_semver(&semver)) + } +} + +impl PartialOrd for Version { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Ord for Version { + fn cmp(&self, other: &Self) -> Ordering { + match self.major.cmp(&other.major) { + Ordering::Equal => {} + ord => return ord, + } + match self.minor.cmp(&other.minor) { + Ordering::Equal => {} + ord => return ord, + } + match self.patch.cmp(&other.patch) { + Ordering::Equal => {} + ord => return ord, + } + // Prerelease versions have lower precedence + match (&self.pre, &other.pre) { + (None, None) => Ordering::Equal, + (Some(_), None) => Ordering::Less, + (None, Some(_)) => Ordering::Greater, + (Some(a), Some(b)) => a.cmp(b), + } + } +} + +/// Version range for matching +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct VersionRange { + /// Minimum version (inclusive) + pub min: Option, + /// Maximum version (exclusive) + pub max: Option, + /// Include minimum + pub min_inclusive: bool, + /// Include maximum + pub max_inclusive: bool, +} + +impl VersionRange { + /// Create a range that matches any version + pub fn any() -> Self { + Self { + min: None, + max: None, + min_inclusive: true, + max_inclusive: true, + } + } + + /// Create a range for exact version match + pub fn exact(version: Version) -> Self { + Self { + min: Some(version.clone()), + max: Some(version), + min_inclusive: true, + max_inclusive: true, + } + } + + /// Create a range >= min + pub fn gte(min: Version) -> Self { + Self { + min: Some(min), + max: None, + min_inclusive: true, + max_inclusive: true, + } + } + + /// Create a range < max + pub fn lt(max: Version) -> Self { + Self { + min: None, + max: Some(max), + min_inclusive: true, + max_inclusive: false, + } + } + + /// Create a range [min, max) + pub fn range(min: Version, max: Version) -> Self { + Self { + min: Some(min), + max: Some(max), + min_inclusive: true, + max_inclusive: false, + } + } + + /// Check if version matches this range + pub fn matches(&self, version: &Version) -> bool { + if let Some(min) = &self.min { + let cmp = version.cmp(min); + if self.min_inclusive { + if cmp == Ordering::Less { + return false; + } + } else if cmp != Ordering::Greater { + return false; + } + } + + if let Some(max) = &self.max { + let cmp = version.cmp(max); + if self.max_inclusive { + if cmp == Ordering::Greater { + return false; + } + } else if cmp != Ordering::Less { + return false; + } + } + + true + } +} + +impl Default for VersionRange { + fn default() -> Self { + Self::any() + } +} + +impl fmt::Display for VersionRange { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match (&self.min, &self.max) { + (None, None) => write!(f, "*"), + (Some(min), None) => { + if self.min_inclusive { + write!(f, ">={}", min) + } else { + write!(f, ">{}", min) + } + } + (None, Some(max)) => { + if self.max_inclusive { + write!(f, "<={}", max) + } else { + write!(f, "<{}", max) + } + } + (Some(min), Some(max)) if min == max => write!(f, "={}", min), + (Some(min), Some(max)) => { + let min_op = if self.min_inclusive { ">=" } else { ">" }; + let max_op = if self.max_inclusive { "<=" } else { "<" }; + write!(f, "{}{}, {}{}", min_op, min, max_op, max) + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_version_parse() { + let v = "1.2.3".parse::().unwrap(); + assert_eq!(v.major, 1); + assert_eq!(v.minor, 2); + assert_eq!(v.patch, 3); + assert_eq!(v.pre, None); + + let v = "v1.2.3-beta.1".parse::().unwrap(); + assert_eq!(v.major, 1); + assert_eq!(v.pre, Some("beta.1".to_string())); + } + + #[test] + fn test_version_ordering() { + let v1 = Version::new(1, 0, 0); + let v2 = Version::new(1, 1, 0); + let v3 = Version::new(2, 0, 0); + let v4 = Version::with_pre(1, 0, 0, "alpha"); + + assert!(v1 < v2); + assert!(v2 < v3); + assert!(v4 < v1); // prerelease < release + } + + #[test] + fn test_version_range() { + let range = VersionRange::range(Version::new(1, 0, 0), Version::new(2, 0, 0)); + + assert!(range.matches(&Version::new(1, 0, 0))); + assert!(range.matches(&Version::new(1, 5, 0))); + assert!(!range.matches(&Version::new(0, 9, 0))); + assert!(!range.matches(&Version::new(2, 0, 0))); // exclusive + } +} diff --git a/crates/vx-migration/tests/migration_tests.rs b/crates/vx-migration/tests/migration_tests.rs new file mode 100644 index 000000000..d65d2bedc --- /dev/null +++ b/crates/vx-migration/tests/migration_tests.rs @@ -0,0 +1,322 @@ +//! Migration framework tests + +use rstest::rstest; +use tempfile::TempDir; +use vx_migration::migrations::{ConfigV1ToV2Migration, FileRenameMigration, create_default_engine}; +use vx_migration::prelude::*; +use vx_migration::traits::VersionDetector; +use vx_migration::version::{Version, VersionRange}; + +mod version_tests { + use super::*; + + #[test] + fn test_version_parse() { + let v: Version = "1.2.3".parse().unwrap(); + assert_eq!(v.major, 1); + assert_eq!(v.minor, 2); + assert_eq!(v.patch, 3); + } + + #[test] + fn test_version_with_v_prefix() { + let v: Version = "v1.2.3".parse().unwrap(); + assert_eq!(v.major, 1); + } + + #[test] + fn test_version_prerelease() { + let v: Version = "1.0.0-alpha.1".parse().unwrap(); + assert_eq!(v.pre, Some("alpha.1".to_string())); + } + + #[rstest] + #[case("1.0.0", "2.0.0", std::cmp::Ordering::Less)] + #[case("1.1.0", "1.0.0", std::cmp::Ordering::Greater)] + #[case("1.0.0", "1.0.0", std::cmp::Ordering::Equal)] + #[case("1.0.0-alpha", "1.0.0", std::cmp::Ordering::Less)] + fn test_version_ordering( + #[case] a: &str, + #[case] b: &str, + #[case] expected: std::cmp::Ordering, + ) { + let va: Version = a.parse().unwrap(); + let vb: Version = b.parse().unwrap(); + assert_eq!(va.cmp(&vb), expected); + } + + #[test] + fn test_version_range_any() { + let range = VersionRange::any(); + assert!(range.matches(&Version::new(0, 0, 1))); + assert!(range.matches(&Version::new(100, 0, 0))); + } + + #[test] + fn test_version_range_exact() { + let range = VersionRange::exact(Version::new(1, 0, 0)); + assert!(range.matches(&Version::new(1, 0, 0))); + assert!(!range.matches(&Version::new(1, 0, 1))); + } + + #[test] + fn test_version_range_gte() { + let range = VersionRange::gte(Version::new(2, 0, 0)); + assert!(!range.matches(&Version::new(1, 9, 9))); + assert!(range.matches(&Version::new(2, 0, 0))); + assert!(range.matches(&Version::new(3, 0, 0))); + } + + #[test] + fn test_version_range_lt() { + let range = VersionRange::lt(Version::new(2, 0, 0)); + assert!(range.matches(&Version::new(1, 9, 9))); + assert!(!range.matches(&Version::new(2, 0, 0))); + } +} + +mod context_tests { + use super::*; + + #[tokio::test] + async fn test_context_state() { + let temp = TempDir::new().unwrap(); + let ctx = MigrationContext::new(temp.path(), MigrationOptions::default()); + + ctx.set_state("key", "value".to_string()).await; + assert_eq!( + ctx.get_state::("key").await, + Some("value".to_string()) + ); + } + + #[tokio::test] + async fn test_context_file_operations() { + let temp = TempDir::new().unwrap(); + let ctx = MigrationContext::new(temp.path(), MigrationOptions::default()); + + ctx.write_file("test.txt", "hello").await.unwrap(); + assert!(ctx.file_exists("test.txt").await); + + let content = ctx.read_file("test.txt").await.unwrap(); + assert_eq!(content, "hello"); + } + + #[tokio::test] + async fn test_context_dry_run() { + let temp = TempDir::new().unwrap(); + let ctx = MigrationContext::new(temp.path(), MigrationOptions::dry_run()); + + ctx.write_file("test.txt", "hello").await.unwrap(); + assert!(!ctx.file_exists("test.txt").await); + } +} + +mod registry_tests { + use super::*; + + #[test] + fn test_registry_register() { + let mut registry = MigrationRegistry::new(); + registry.register(FileRenameMigration::new()).unwrap(); + assert!(registry.contains("file-rename")); + } + + #[test] + fn test_registry_duplicate() { + let mut registry = MigrationRegistry::new(); + registry.register(FileRenameMigration::new()).unwrap(); + assert!(registry.register(FileRenameMigration::new()).is_err()); + } +} + +mod engine_tests { + use super::*; + + #[tokio::test] + async fn test_engine_check() { + let temp = TempDir::new().unwrap(); + tokio::fs::write(temp.path().join("vx.toml"), "[tools]\nnode = \"18\"") + .await + .unwrap(); + + let engine = create_default_engine(); + let needed = engine.check(temp.path()).await.unwrap(); + + // Should detect config-v1-to-v2 migration is needed (converts [tools] to [runtimes]) + assert!(!needed.is_empty()); + } + + #[tokio::test] + async fn test_engine_migrate() { + let temp = TempDir::new().unwrap(); + tokio::fs::write(temp.path().join("vx.toml"), "[tools]\nnode = \"18\"") + .await + .unwrap(); + + let engine = create_default_engine(); + let result = engine + .migrate(temp.path(), &MigrationOptions::default()) + .await + .unwrap(); + + assert!(result.success); + // vx.toml should still exist (file-rename is no-op since both names are "vx.toml") + assert!(temp.path().join("vx.toml").exists()); + + let content = tokio::fs::read_to_string(temp.path().join("vx.toml")) + .await + .unwrap(); + // config-v1-to-v2 should have converted [tools] to [runtimes] + assert!(content.contains("[runtimes]")); + } + + #[tokio::test] + async fn test_engine_dry_run() { + let temp = TempDir::new().unwrap(); + tokio::fs::write(temp.path().join("vx.toml"), "[tools]\nnode = \"18\"") + .await + .unwrap(); + + let engine = create_default_engine(); + let result = engine + .migrate(temp.path(), &MigrationOptions::dry_run()) + .await + .unwrap(); + + assert!(result.success); + // File should still exist and content unchanged in dry-run + assert!(temp.path().join("vx.toml").exists()); + + let content = tokio::fs::read_to_string(temp.path().join("vx.toml")) + .await + .unwrap(); + // Content should NOT be changed in dry-run + assert!(content.contains("[tools]")); + } + + #[tokio::test] + async fn test_engine_skip_migration() { + let temp = TempDir::new().unwrap(); + tokio::fs::write(temp.path().join("vx.toml"), "[tools]\nnode = \"18\"") + .await + .unwrap(); + + let engine = create_default_engine(); + let mut options = MigrationOptions::default(); + options.skip_migrations.insert("file-rename".to_string()); + + let result = engine.migrate(temp.path(), &options).await.unwrap(); + + assert!(result.success); + // file-rename was skipped, but it's a no-op anyway; vx.toml should exist + assert!(temp.path().join("vx.toml").exists()); + } +} + +mod migration_tests { + use super::*; + use vx_migration::migrations::VxVersionDetector; + + #[tokio::test] + async fn test_file_rename_migration() { + // Note: Since CONFIG_FILE_NAME and CONFIG_FILE_NAME_LEGACY are both "vx.toml", + // the file-rename migration is effectively a no-op. + let temp = TempDir::new().unwrap(); + tokio::fs::write(temp.path().join("vx.toml"), "[tools]") + .await + .unwrap(); + + let ctx = MigrationContext::new(temp.path(), MigrationOptions::default()); + let migration = FileRenameMigration::new(); + + // check() should return false since old and new filenames are identical + assert!(!migration.check(&ctx).await.unwrap()); + } + + #[tokio::test] + async fn test_file_rename_migration_execute() { + let temp = TempDir::new().unwrap(); + tokio::fs::write(temp.path().join("vx.toml"), "[tools]") + .await + .unwrap(); + + let mut ctx = MigrationContext::new(temp.path(), MigrationOptions::default()); + let migration = FileRenameMigration::new(); + + let result = migration.migrate(&mut ctx).await.unwrap(); + assert!(result.success); + // No changes since filenames are identical + assert_eq!(result.changes.len(), 0); + // File should still exist + assert!(temp.path().join("vx.toml").exists()); + } + + #[tokio::test] + async fn test_config_v1_to_v2_migration() { + let temp = TempDir::new().unwrap(); + tokio::fs::write( + temp.path().join("vx.toml"), + "[tools]\nnode = \"18\"\n\n[tools.go]\nversion = \"1.21\"", + ) + .await + .unwrap(); + + let mut ctx = MigrationContext::new(temp.path(), MigrationOptions::default()); + let migration = ConfigV1ToV2Migration::new(); + + assert!(migration.check(&ctx).await.unwrap()); + + let result = migration.migrate(&mut ctx).await.unwrap(); + assert!(result.success); + + let content = tokio::fs::read_to_string(temp.path().join("vx.toml")) + .await + .unwrap(); + assert!(content.contains("[runtimes]")); + assert!(content.contains("[runtimes.go]")); + } + + #[tokio::test] + async fn test_version_detector() { + let temp = TempDir::new().unwrap(); + tokio::fs::write(temp.path().join("vx.toml"), "[tools]\nnode = \"18\"") + .await + .unwrap(); + + let detector = VxVersionDetector::new(); + let version = detector.detect(temp.path()).await.unwrap(); + + assert_eq!(version, Some(Version::new(1, 0, 0))); + } +} + +mod history_tests { + use super::*; + + #[tokio::test] + async fn test_history_save_load() { + let temp = TempDir::new().unwrap(); + let path = temp.path().join("history.json"); + + let mut history = MigrationHistory::new(); + history.add_entry(MigrationHistoryEntry::new("test-migration")); + + history.save(&path).await.unwrap(); + + let loaded = MigrationHistory::load(&path).await.unwrap(); + assert_eq!(loaded.entries.len(), 1); + } + + #[tokio::test] + async fn test_history_is_completed() { + use vx_migration::history::MigrationStatus; + + let mut history = MigrationHistory::new(); + history.add_entry(MigrationHistoryEntry::new("m1").with_status(MigrationStatus::Completed)); + history.add_entry(MigrationHistoryEntry::new("m2").with_status(MigrationStatus::Failed)); + + assert!(history.is_completed("m1")); + assert!(!history.is_completed("m2")); + } +} diff --git a/crates/vx-msbuild-bridge/Cargo.toml b/crates/vx-msbuild-bridge/Cargo.toml new file mode 100644 index 000000000..039c523e5 --- /dev/null +++ b/crates/vx-msbuild-bridge/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "vx-msbuild-bridge" +version.workspace = true +edition.workspace = true +description = "MSBuild.exe bridge that delegates to system VS MSBuild with Spectre auto-detection - used by vx MSVC provider" +license.workspace = true +repository.workspace = true +homepage.workspace = true +authors.workspace = true +rust-version.workspace = true + +[[bin]] +name = "MSBuild" +path = "src/main.rs" + +# Exclude from cargo-dist releases — this binary is embedded into the main vx +# binary at compile time via include_bytes! in vx-cli/build.rs (Windows only). +# It should NOT be distributed as a standalone release artifact. +[package.metadata.dist] +dist = false + +[dependencies] +workspace-hack = { version = "0.1", path = "../workspace-hack" } +# No external dependencies - self-contained bridge binary diff --git a/crates/vx-msbuild-bridge/src/main.rs b/crates/vx-msbuild-bridge/src/main.rs new file mode 100644 index 000000000..272b4b677 --- /dev/null +++ b/crates/vx-msbuild-bridge/src/main.rs @@ -0,0 +1,191 @@ +//! MSBuild.exe bridge for vx-managed MSVC installations. +//! +//! This binary is placed at `{msvc_store}/MSBuild/Current/Bin/MSBuild.exe` +//! so that tools like node-gyp can discover it via VCINSTALLDIR. +//! +//! ## Search Order +//! +//! 1. **System VS Build Tools MSBuild.exe** — has VCTargets (Microsoft.Cpp.*.props) +//! needed for C/C++ compilation (node-gyp, cmake, etc.) +//! 2. **System VS Community/Professional/Enterprise MSBuild.exe** — same capabilities +//! 3. **dotnet msbuild** — fallback, but lacks VCTargetsPath for C++ projects +//! +//! ## Why not just `dotnet msbuild`? +//! +//! `dotnet msbuild` does NOT include C++ build targets (VCTargetsPath), so .vcxproj +//! files fail with MSB4278: "Microsoft.Cpp.Default.props" not found. +//! The full VS MSBuild.exe includes these targets. +//! +//! ## Spectre Mitigation Auto-Detection +//! +//! Some projects (e.g., node-pty's winpty) require Spectre-mitigated libraries +//! (`Spectre` in .vcxproj). If the system +//! VS installation doesn't have Spectre libs installed, MSBuild fails with MSB8040. +//! +//! This bridge detects whether Spectre libs exist for the active MSVC toolset. +//! If they're missing, it automatically injects `/p:SpectreMitigation=false` to +//! disable the check, allowing compilation to proceed without Spectre mitigation. + +use std::path::{Path, PathBuf}; +use std::process::{Command, ExitCode}; + +/// Well-known MSBuild.exe locations (VS 2022 editions + VS 2019) +const MSBUILD_SEARCH_PATHS: &[&str] = &[ + // VS 2022 Build Tools (most common for CI/headless builds) + r"C:\Program Files (x86)\Microsoft Visual Studio\2022\BuildTools\MSBuild\Current\Bin\MSBuild.exe", + r"C:\Program Files\Microsoft Visual Studio\2022\BuildTools\MSBuild\Current\Bin\MSBuild.exe", + // VS 2022 Community + r"C:\Program Files (x86)\Microsoft Visual Studio\2022\Community\MSBuild\Current\Bin\MSBuild.exe", + r"C:\Program Files\Microsoft Visual Studio\2022\Community\MSBuild\Current\Bin\MSBuild.exe", + // VS 2022 Professional + r"C:\Program Files (x86)\Microsoft Visual Studio\2022\Professional\MSBuild\Current\Bin\MSBuild.exe", + r"C:\Program Files\Microsoft Visual Studio\2022\Professional\MSBuild\Current\Bin\MSBuild.exe", + // VS 2022 Enterprise + r"C:\Program Files (x86)\Microsoft Visual Studio\2022\Enterprise\MSBuild\Current\Bin\MSBuild.exe", + r"C:\Program Files\Microsoft Visual Studio\2022\Enterprise\MSBuild\Current\Bin\MSBuild.exe", + // VS 2019 (legacy) + r"C:\Program Files (x86)\Microsoft Visual Studio\2019\BuildTools\MSBuild\Current\Bin\MSBuild.exe", + r"C:\Program Files (x86)\Microsoft Visual Studio\2019\Community\MSBuild\Current\Bin\MSBuild.exe", + r"C:\Program Files (x86)\Microsoft Visual Studio\2019\Professional\MSBuild\Current\Bin\MSBuild.exe", + r"C:\Program Files (x86)\Microsoft Visual Studio\2019\Enterprise\MSBuild\Current\Bin\MSBuild.exe", +]; + +fn find_system_msbuild() -> Option { + for path_str in MSBUILD_SEARCH_PATHS { + let path = Path::new(path_str); + if path.exists() { + return Some(path.to_path_buf()); + } + } + None +} + +/// Find the VS installation root that contains the given MSBuild.exe path. +/// +/// MSBuild.exe is at `{vs_root}/MSBuild/Current/Bin/MSBuild.exe`, +/// so we go up 4 levels from MSBuild.exe to get the VS root. +fn find_vs_root_for_msbuild(msbuild_path: &Path) -> Option { + // MSBuild.exe -> Bin -> Current -> MSBuild -> {vs_root} + msbuild_path + .parent() // Bin + .and_then(|p| p.parent()) // Current + .and_then(|p| p.parent()) // MSBuild + .and_then(|p| p.parent()) // {vs_root} + .map(|p| p.to_path_buf()) +} + +/// Check whether Spectre-mitigated libraries are installed in the given VS installation. +/// +/// Spectre libs live at: `{vs_root}/VC/Tools/MSVC/{version}/lib/{arch}/spectre/` +/// We check the latest MSVC version directory for the current architecture. +fn has_spectre_libs(vs_root: &Path) -> bool { + let msvc_dir = vs_root.join("VC").join("Tools").join("MSVC"); + if !msvc_dir.exists() { + return false; + } + + // Find the latest MSVC version directory + let latest_version = match std::fs::read_dir(&msvc_dir) { + Ok(entries) => entries + .filter_map(|e| e.ok()) + .filter(|e| e.path().is_dir()) + .filter_map(|e| e.file_name().into_string().ok()) + .max(), + Err(_) => return false, + }; + + let Some(version) = latest_version else { + return false; + }; + + let arch = if cfg!(target_arch = "aarch64") { + "arm64" + } else { + "x64" + }; + + let spectre_dir = msvc_dir + .join(&version) + .join("lib") + .join(arch) + .join("spectre"); + spectre_dir.exists() +} + +/// Check if the caller's args already contain a SpectreMitigation property override. +fn has_spectre_override(args: &[String]) -> bool { + args.iter().any(|arg| { + let lower = arg.to_lowercase(); + lower.contains("/p:spectremitigation=") || lower.contains("-p:spectremitigation=") + }) +} + +fn find_dotnet() -> Option { + // Check well-known paths first + let dotnet_paths = [ + r"C:\Program Files\dotnet\dotnet.exe", + r"C:\Program Files (x86)\dotnet\dotnet.exe", + ]; + for path_str in &dotnet_paths { + let path = Path::new(path_str); + if path.exists() { + return Some(path.to_path_buf()); + } + } + + // Search PATH + let path_var = std::env::var("PATH").ok()?; + for dir in path_var.split(';') { + let candidate = Path::new(dir).join("dotnet.exe"); + if candidate.exists() { + return Some(candidate); + } + } + None +} + +fn main() -> ExitCode { + let caller_args: Vec = std::env::args().skip(1).collect(); + + // Strategy 1: System VS MSBuild.exe (has VCTargets for C++ builds) + if let Some(msbuild) = find_system_msbuild() { + let mut args = caller_args.clone(); + + // Auto-detect missing Spectre libraries and disable SpectreMitigation if needed. + // This prevents MSB8040 errors when .vcxproj files require Spectre libs + // but the VS installation doesn't have them installed. + if !has_spectre_override(&args) + && let Some(vs_root) = find_vs_root_for_msbuild(&msbuild) + && !has_spectre_libs(&vs_root) + { + args.push("/p:SpectreMitigation=false".to_string()); + } + + return run_command(&msbuild, &args); + } + + // Strategy 2: dotnet msbuild (fallback, lacks VCTargetsPath for C++) + if let Some(dotnet) = find_dotnet() { + let mut args = vec!["msbuild".to_string()]; + args.extend(caller_args); + return run_command(&dotnet, &args); + } + + eprintln!("vx MSBuild bridge: no MSBuild.exe or dotnet found."); + eprintln!("Install Visual Studio Build Tools or .NET SDK to enable C++ compilation."); + ExitCode::from(1) +} + +fn run_command(executable: &Path, args: &[String]) -> ExitCode { + match Command::new(executable).args(args).status() { + Ok(status) => ExitCode::from(status.code().unwrap_or(1) as u8), + Err(e) => { + eprintln!( + "vx MSBuild bridge: failed to execute {}: {}", + executable.display(), + e + ); + ExitCode::from(1) + } + } +} diff --git a/crates/vx-output-filter/Cargo.toml b/crates/vx-output-filter/Cargo.toml new file mode 100644 index 000000000..37fbac2e0 --- /dev/null +++ b/crates/vx-output-filter/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "vx-output-filter" +version = { workspace = true } +edition = { workspace = true } +description = "rtk-style output filtering for vx subprocess output" +license = { workspace = true } +repository = { workspace = true } + +[dependencies] +vx-runtime-core = { workspace = true } +anyhow = { workspace = true } +tokio = { workspace = true, features = ["process", "io-util"] } +tracing = { workspace = true } +regex = { workspace = true } +workspace-hack = { version = "0.1", path = "../workspace-hack" } + +[dev-dependencies] +rstest = { workspace = true } +serial_test = "3" +tokio = { workspace = true, features = ["rt", "macros"] } diff --git a/crates/vx-output-filter/src/filter.rs b/crates/vx-output-filter/src/filter.rs new file mode 100644 index 000000000..3e0d99276 --- /dev/null +++ b/crates/vx-output-filter/src/filter.rs @@ -0,0 +1,234 @@ +//! Core output filter — per-line processing logic. +//! +//! The `OutputFilter` applies deduplication, blank-run collapsing, and +//! line-budget enforcement to a stream of text lines from a subprocess. + +use crate::rules::{is_error_line, strip_ansi}; + +/// Filter aggressiveness level — controls how much output is suppressed. +/// +/// Select a level based on how noisy the tool output typically is: +/// +/// | Level | Dedup threshold | Max lines | Use case | +/// |-------------|-----------------|-----------|---------------------------------------| +/// | Light | disabled | unlimited | Verbose tools where every line counts | +/// | Normal | ≥3 identical | 500 | Default for most tools | +/// | Aggressive | ≥2 identical | 100 | Very noisy tools (e.g. `cargo build`) | +/// +/// Set via `VX_FILTER_LEVEL=light|normal|aggressive` or `--filter-level` CLI flag. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum FilterLevel { + /// Light: ANSI stripping and blank-run collapsing only. No dedup, no truncation. + Light, + /// Normal: dedup (≥3 identical lines) + 500-line budget. **Default.** + #[default] + Normal, + /// Aggressive: dedup (≥2 identical lines) + 100-line budget. + Aggressive, +} + +impl FilterLevel { + /// Parse the filter level from the `VX_FILTER_LEVEL` environment variable. + /// + /// Falls back to `Normal` if the variable is absent or unrecognised. + pub fn from_env() -> Self { + match std::env::var("VX_FILTER_LEVEL").as_deref() { + Ok("light") | Ok("Light") | Ok("LIGHT") => FilterLevel::Light, + Ok("aggressive") | Ok("Aggressive") | Ok("AGGRESSIVE") => FilterLevel::Aggressive, + _ => FilterLevel::Normal, + } + } +} + +/// Configuration for the output filter. +#[derive(Debug, Clone, Default)] +pub struct OutputFilterConfig { + /// Collapse ≥N consecutive identical lines into one summary. + /// Set to `usize::MAX` to disable deduplication (Light level). + pub dedup_threshold: usize, + + /// Truncate after this many total emitted lines (None = unlimited). + pub max_lines: Option, + + /// Collapse runs of multiple blank lines into a single blank line. + pub strip_empty_runs: bool, +} + +impl OutputFilterConfig { + /// Build config for the given aggressiveness level. + /// + /// # Examples + /// ``` + /// use vx_output_filter::{FilterLevel, OutputFilterConfig}; + /// let cfg = OutputFilterConfig::for_level(FilterLevel::Aggressive); + /// assert_eq!(cfg.dedup_threshold, 2); + /// assert_eq!(cfg.max_lines, Some(100)); + /// ``` + pub fn for_level(level: FilterLevel) -> Self { + match level { + FilterLevel::Light => Self { + dedup_threshold: usize::MAX, // disabled + max_lines: None, // unlimited + strip_empty_runs: true, + }, + FilterLevel::Normal => Self { + dedup_threshold: 3, + max_lines: Some(500), + strip_empty_runs: true, + }, + FilterLevel::Aggressive => Self { + dedup_threshold: 2, + max_lines: Some(100), + strip_empty_runs: true, + }, + } + } + + /// Sensible compact defaults (Normal level). Alias for `for_level(Normal)`. + pub fn compact_defaults() -> Self { + Self::for_level(FilterLevel::Normal) + } + + /// Return `Some(config)` when `VX_OUTPUT=compact` and stdout is **not** a TTY, + /// otherwise `None`. The level is read from `VX_FILTER_LEVEL` (default: Normal). + pub fn from_env() -> Option { + use std::io::IsTerminal; + let is_compact = matches!( + std::env::var("VX_OUTPUT").as_deref(), + Ok("compact") | Ok("Compact") | Ok("COMPACT") + ); + if is_compact && !std::io::stdout().is_terminal() { + Some(Self::for_level(FilterLevel::from_env())) + } else { + None + } + } +} + +/// Stateful per-stream filter. +/// +/// Call [`OutputFilter::filter_line`] for each output line; call +/// [`OutputFilter::finalize`] at the end to flush any pending dedup summary. +pub struct OutputFilter { + config: OutputFilterConfig, + /// Last line content seen (after ANSI strip) + last_line: Option, + /// How many times the last line has repeated + repeat_count: usize, + /// Total lines emitted so far + emitted: usize, + /// Whether we have hit max_lines + truncated: bool, + /// How many lines were dropped due to truncation + truncated_count: usize, + /// Whether the previous emitted line was blank + last_was_blank: bool, +} + +impl OutputFilter { + /// Create a new filter with the given configuration. + pub fn new(config: OutputFilterConfig) -> Self { + Self { + config, + last_line: None, + repeat_count: 0, + emitted: 0, + truncated: false, + truncated_count: 0, + last_was_blank: false, + } + } + + /// Process one line. Returns zero, one, or two lines to emit. + pub fn filter_line(&mut self, raw: &str) -> Vec { + if self.truncated { + self.truncated_count += 1; + return vec![]; + } + + let clean = strip_ansi(raw); + let is_blank = clean.trim().is_empty(); + + // Collapse blank runs + if is_blank && self.config.strip_empty_runs && self.last_was_blank { + return vec![]; + } + + // Dedup: error lines bypass dedup and always emit + let is_err = is_error_line(&clean); + + let mut out: Vec = Vec::new(); + + if !is_err { + if let Some(ref prev) = self.last_line { + if *prev == clean { + self.repeat_count += 1; + // At or above threshold — swallow; will emit summary on next change + if self.repeat_count >= self.config.dedup_threshold { + return vec![]; + } + } else { + // Line changed — flush dedup summary if needed + if self.repeat_count >= self.config.dedup_threshold { + let extra = self.repeat_count - self.config.dedup_threshold + 1; + if extra > 0 { + out.push(format!(" ... (+{extra} identical lines omitted)")); + } + } + self.last_line = Some(clean.clone()); + self.repeat_count = 1; + } + } else { + self.last_line = Some(clean.clone()); + self.repeat_count = 1; + } + } + + out.push(clean.clone()); + self.last_was_blank = is_blank; + + // Apply max_lines budget + self.emit_lines(out) + } + + fn emit_lines(&mut self, lines: Vec) -> Vec { + let Some(max) = self.config.max_lines else { + self.emitted += lines.len(); + return lines; + }; + let mut result = Vec::new(); + for l in lines { + if self.emitted < max { + self.emitted += 1; + result.push(l); + } else { + self.truncated = true; + self.truncated_count += 1; + } + } + result + } + + /// Flush any pending state and return final summary lines. + pub fn finalize(&mut self) -> Vec { + let mut out = Vec::new(); + + // Flush dedup summary for the last run + if self.last_line.is_some() && self.repeat_count >= self.config.dedup_threshold { + let extra = self.repeat_count - self.config.dedup_threshold + 1; + if extra > 0 { + out.push(format!(" ... (+{extra} identical lines omitted)")); + } + } + + // Truncation summary + if self.truncated_count > 0 { + out.push(format!( + " ... (+{} lines omitted, use default mode to see all output)", + self.truncated_count + )); + } + + out + } +} diff --git a/crates/vx-output-filter/src/lib.rs b/crates/vx-output-filter/src/lib.rs new file mode 100644 index 000000000..91dc4dd44 --- /dev/null +++ b/crates/vx-output-filter/src/lib.rs @@ -0,0 +1,11 @@ +//! rtk-style output filtering for vx subprocess output. +//! +//! Only activates when `VX_OUTPUT=compact` AND stdout is NOT a TTY. +//! Default behavior (TTY or no `VX_OUTPUT=compact`) is completely unchanged. + +pub mod filter; +pub mod rules; +pub mod stream; + +pub use filter::{FilterLevel, OutputFilter, OutputFilterConfig}; +pub use rules::FilterRules; diff --git a/crates/vx-output-filter/src/rules.rs b/crates/vx-output-filter/src/rules.rs new file mode 100644 index 000000000..6ee43f11c --- /dev/null +++ b/crates/vx-output-filter/src/rules.rs @@ -0,0 +1,27 @@ +//! Line classification rules for output filtering. +//! +//! Provides ANSI stripping and error-line detection used by the output filter. + +use regex::Regex; +use std::sync::OnceLock; + +static ERROR_PATTERN: OnceLock = OnceLock::new(); +static ANSI_PATTERN: OnceLock = OnceLock::new(); + +/// Returns `true` if the line looks like an error/fatal/panic message. +/// +/// Error lines are always emitted by the filter regardless of dedup settings. +pub fn is_error_line(line: &str) -> bool { + let re = + ERROR_PATTERN.get_or_init(|| Regex::new(r"(?i)(error|fatal|panic|FAILED|Error:)").unwrap()); + re.is_match(line) +} + +/// Strip ANSI escape sequences from a string. +pub fn strip_ansi(s: &str) -> String { + let re = ANSI_PATTERN.get_or_init(|| Regex::new(r"\x1b\[[0-9;]*[mGKHF]").unwrap()); + re.replace_all(s, "").to_string() +} + +/// Marker type — future expansion point for pluggable rules. +pub struct FilterRules; diff --git a/crates/vx-output-filter/src/stream.rs b/crates/vx-output-filter/src/stream.rs new file mode 100644 index 000000000..df61c8e71 --- /dev/null +++ b/crates/vx-output-filter/src/stream.rs @@ -0,0 +1,73 @@ +//! Async stream runner — spawns a child with piped stdout/stderr +//! and applies an [`OutputFilter`] to each stream. +//! +//! stdin is always inherited so interactive tools keep working. + +use crate::filter::{OutputFilter, OutputFilterConfig}; +use anyhow::Result; +use tokio::io::{AsyncBufReadExt, BufReader}; +use tokio::process::Child; +use tracing::debug; + +/// Run a child process whose stdout/stderr are piped, applying output filtering. +/// +/// # Errors +/// Returns an error if the `stdout` or `stderr` handles are not available +/// (i.e. the caller forgot to set `Stdio::piped()` before spawning). +pub async fn run_filtered_child( + mut child: Child, + config: OutputFilterConfig, +) -> Result { + let stdout = child + .stdout + .take() + .ok_or_else(|| anyhow::anyhow!("stdout not piped — set Stdio::piped() before spawn"))?; + let stderr = child + .stderr + .take() + .ok_or_else(|| anyhow::anyhow!("stderr not piped — set Stdio::piped() before spawn"))?; + + let config_stdout = config.clone(); + let config_stderr = config; + + // Drain stdout on a separate Tokio task + let stdout_task = tokio::spawn(async move { + let mut filter = OutputFilter::new(config_stdout); + let mut reader = BufReader::new(stdout).lines(); + while let Ok(Some(line)) = reader.next_line().await { + for out in filter.filter_line(&line) { + println!("{out}"); + } + } + for out in filter.finalize() { + println!("{out}"); + } + }); + + // Drain stderr on a separate Tokio task + let stderr_task = tokio::spawn(async move { + let mut filter = OutputFilter::new(config_stderr); + let mut reader = BufReader::new(stderr).lines(); + while let Ok(Some(line)) = reader.next_line().await { + for out in filter.filter_line(&line) { + eprintln!("{out}"); + } + } + for out in filter.finalize() { + eprintln!("{out}"); + } + }); + + // Wait for the child process to finish, then drain readers + let status = child.wait().await?; + + // Join the output tasks (ignore individual task errors — output already written) + if let Err(e) = stdout_task.await { + debug!("stdout drain task error: {e}"); + } + if let Err(e) = stderr_task.await { + debug!("stderr drain task error: {e}"); + } + + Ok(status) +} diff --git a/crates/vx-output-filter/tests/filter_tests.rs b/crates/vx-output-filter/tests/filter_tests.rs new file mode 100644 index 000000000..ec972b533 --- /dev/null +++ b/crates/vx-output-filter/tests/filter_tests.rs @@ -0,0 +1,225 @@ +use rstest::rstest; +use serial_test::serial; +use vx_output_filter::filter::{FilterLevel, OutputFilter, OutputFilterConfig}; +use vx_output_filter::rules::{is_error_line, strip_ansi}; + +fn compact_config() -> OutputFilterConfig { + OutputFilterConfig::compact_defaults() +} + +// ── ANSI stripping ──────────────────────────────────────────────────────────── + +#[test] +fn test_ansi_stripped() { + let result = strip_ansi("\x1b[32mhello\x1b[0m world"); + assert_eq!(result, "hello world"); +} + +#[test] +fn test_ansi_stripped_no_codes() { + let plain = "just plain text"; + assert_eq!(strip_ansi(plain), plain); +} + +// ── Dedup / collapse ────────────────────────────────────────────────────────── + +#[test] +fn test_dedup_collapse() { + let mut f = OutputFilter::new(compact_config()); // threshold = 3 + + // Lines 1 and 2 pass through + assert_eq!(f.filter_line("building...").len(), 1); + assert_eq!(f.filter_line("building...").len(), 1); + // Line 3+ is collapsed (at threshold) + assert_eq!(f.filter_line("building...").len(), 0); + assert_eq!(f.filter_line("building...").len(), 0); + + // Finalize emits summary + let summary = f.finalize(); + assert!( + summary.iter().any(|l| l.contains("omitted")), + "finalize should emit omitted-lines summary" + ); +} + +#[test] +fn test_error_lines_bypass_dedup() { + let mut f = OutputFilter::new(compact_config()); + + // Error lines are always emitted even when identical + for _ in 0..5 { + let emitted = f.filter_line("error: compilation failed"); + assert_eq!(emitted.len(), 1, "error line should always be emitted"); + } +} + +// ── Blank-run collapsing ────────────────────────────────────────────────────── + +#[test] +fn test_empty_run_stripped() { + let mut f = OutputFilter::new(compact_config()); + + // First blank is kept + let first = f.filter_line(""); + assert_eq!(first.len(), 1); + + // Consecutive blank is dropped + let second = f.filter_line(""); + assert_eq!( + second.len(), + 0, + "second consecutive blank should be dropped" + ); + + // Non-blank resets the run + let text = f.filter_line("hello"); + assert_eq!(text.len(), 1); +} + +// ── max_lines truncation ────────────────────────────────────────────────────── + +#[test] +fn test_max_lines_overflow_summary() { + let config = OutputFilterConfig { + dedup_threshold: 100, // no dedup + max_lines: Some(3), + strip_empty_runs: false, + }; + let mut f = OutputFilter::new(config); + + for i in 0..6 { + f.filter_line(&format!("line {i}")); + } + + let summary = f.finalize(); + assert!( + summary.iter().any(|l| l.contains("omitted")), + "finalize should report truncated lines" + ); +} + +// ── Parametric happy-path ───────────────────────────────────────────────────── + +#[rstest] +#[case("simple text", "simple text")] +#[case("\x1b[1mbold\x1b[0m", "bold")] +#[case(" spaces ", " spaces ")] +fn test_filter_line_basic(#[case] input: &str, #[case] expected: &str) { + let mut f = OutputFilter::new(compact_config()); + let emitted = f.filter_line(input); + assert_eq!(emitted.len(), 1); + assert_eq!(emitted[0], expected); +} + +// ── Rules ───────────────────────────────────────────────────────────────────── + +#[test] +fn test_is_error_line_detects_error() { + assert!(is_error_line("error: failed to compile")); + assert!(is_error_line("Error: something went wrong")); + assert!(is_error_line("FATAL: out of memory")); + assert!(is_error_line("panic! at the disco")); + assert!(!is_error_line("everything is fine")); +} + +// ── from_env (unit-level) ───────────────────────────────────────────────────── + +#[test] +fn test_from_env_none_by_default() { + // In normal test runs stdout may or may not be a TTY, + // but VX_OUTPUT is not "compact" so from_env() should return None. + // Safety: single-threaded test binary; no concurrent access to VX_OUTPUT + unsafe { std::env::remove_var("VX_OUTPUT") }; + // Note: might return Some in non-TTY CI, but compact is not set → None + let result = OutputFilterConfig::from_env(); + // We only assert it doesn't panic; value depends on env + let _ = result; +} + +// ── FilterLevel ─────────────────────────────────────────────────────────────── + +#[test] +fn test_filter_level_light_config() { + let cfg = OutputFilterConfig::for_level(FilterLevel::Light); + assert_eq!(cfg.dedup_threshold, usize::MAX, "Light disables dedup"); + assert_eq!(cfg.max_lines, None, "Light has no line limit"); + assert!(cfg.strip_empty_runs, "Light still collapses blank runs"); +} + +#[test] +fn test_filter_level_normal_config() { + let cfg = OutputFilterConfig::for_level(FilterLevel::Normal); + assert_eq!(cfg.dedup_threshold, 3, "Normal dedup threshold is 3"); + assert_eq!(cfg.max_lines, Some(500), "Normal line budget is 500"); + assert!(cfg.strip_empty_runs); +} + +#[test] +fn test_filter_level_aggressive_config() { + let cfg = OutputFilterConfig::for_level(FilterLevel::Aggressive); + assert_eq!(cfg.dedup_threshold, 2, "Aggressive dedup threshold is 2"); + assert_eq!(cfg.max_lines, Some(100), "Aggressive line budget is 100"); + assert!(cfg.strip_empty_runs); +} + +#[test] +#[serial] +fn test_filter_level_from_env_default_is_normal() { + unsafe { std::env::remove_var("VX_FILTER_LEVEL") }; + assert_eq!(FilterLevel::from_env(), FilterLevel::Normal); +} + +#[test] +#[serial] +fn test_filter_level_from_env_recognises_light() { + unsafe { std::env::set_var("VX_FILTER_LEVEL", "light") }; + assert_eq!(FilterLevel::from_env(), FilterLevel::Light); + unsafe { std::env::remove_var("VX_FILTER_LEVEL") }; +} + +#[test] +#[serial] +fn test_filter_level_from_env_recognises_aggressive() { + unsafe { std::env::set_var("VX_FILTER_LEVEL", "aggressive") }; + assert_eq!(FilterLevel::from_env(), FilterLevel::Aggressive); + unsafe { std::env::remove_var("VX_FILTER_LEVEL") }; +} + +#[test] +fn test_compact_defaults_equals_normal_level() { + let defaults = OutputFilterConfig::compact_defaults(); + let normal = OutputFilterConfig::for_level(FilterLevel::Normal); + assert_eq!(defaults.dedup_threshold, normal.dedup_threshold); + assert_eq!(defaults.max_lines, normal.max_lines); + assert_eq!(defaults.strip_empty_runs, normal.strip_empty_runs); +} + +#[test] +fn test_aggressive_level_dedup_collapses_at_2() { + let cfg = OutputFilterConfig::for_level(FilterLevel::Aggressive); + let mut f = OutputFilter::new(cfg); // threshold = 2 + + // First line passes (repeat_count becomes 1, which is < threshold 2) + assert_eq!(f.filter_line("building...").len(), 1); + // Second identical line: repeat_count reaches threshold (2 >= 2) → collapsed + assert_eq!(f.filter_line("building...").len(), 0); + // Third is also collapsed + assert_eq!(f.filter_line("building...").len(), 0); +} + +#[test] +fn test_light_level_no_dedup() { + let cfg = OutputFilterConfig::for_level(FilterLevel::Light); + let mut f = OutputFilter::new(cfg); // threshold = usize::MAX (disabled) + + // All identical lines should pass through with Light level + for _ in 0..10 { + assert_eq!(f.filter_line("building...").len(), 1); + } + // finalize should not report any omissions + let summary = f.finalize(); + assert!( + !summary.iter().any(|l| l.contains("omitted")), + "Light level should not suppress any lines" + ); +} diff --git a/crates/vx-paths/Cargo.toml b/crates/vx-paths/Cargo.toml new file mode 100644 index 000000000..d31ebc94e --- /dev/null +++ b/crates/vx-paths/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "vx-paths" +version.workspace = true +edition.workspace = true +description = "Cross-platform path management for vx tool installations" +license.workspace = true +repository.workspace = true +homepage.workspace = true + +[dependencies] +vx-cache = { workspace = true } + +anyhow = { workspace = true } +chrono = { workspace = true } +dirs = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +semver = { workspace = true } +thiserror = { workspace = true } +tracing = { workspace = true } +walkdir = { workspace = true } +which = { workspace = true } +workspace-hack = { version = "0.1", path = "../workspace-hack" } + +[dev-dependencies] +tempfile = { workspace = true } diff --git a/crates/vx-paths/README.md b/crates/vx-paths/README.md new file mode 100644 index 000000000..5b68795e3 --- /dev/null +++ b/crates/vx-paths/README.md @@ -0,0 +1,124 @@ +# vx-paths + +Cross-platform path management for vx tool installations. + +## Overview + +`vx-paths` provides a unified interface for managing tool installation paths across different platforms, ensuring consistent directory structures and proper handling of executable file extensions. + +## Features + +- **Standardized Path Structure**: Enforces `~/.vx/tools///.exe` structure +- **Cross-Platform Support**: Handles executable extensions (.exe on Windows, none on Unix) +- **Configuration Integration**: Supports custom paths via environment variables and configuration +- **Tool Discovery**: Find installed tools and their versions +- **Path Resolution**: Resolve tool paths with version preferences + +## Standard Directory Structure + +``` +~/.vx/ +├── tools/ # Tool installations +│ ├── node/ +│ │ ├── 18.17.0/ +│ │ │ └── node.exe # Windows +│ │ │ └── node # Unix +│ │ └── 20.0.0/ +│ │ └── node.exe +│ └── uv/ +│ └── 0.1.0/ +│ └── uv.exe +├── cache/ # Download cache +├── config/ # Configuration files +└── tmp/ # Temporary files +``` + +## Usage + +### Basic Path Management + +```rust +use vx_paths::PathManager; + +// Create path manager with default locations +let manager = PathManager::new()?; + +// Get tool executable path +let node_path = manager.tool_executable_path("node", "18.17.0"); +// Returns: ~/.vx/tools/node/18.17.0/node.exe (Windows) +// ~/.vx/tools/node/18.17.0/node (Unix) + +// Check if tool is installed +let is_installed = manager.is_tool_version_installed("node", "18.17.0"); + +// List all versions of a tool +let versions = manager.list_tool_versions("node")?; + +// Get latest version +let latest = manager.get_latest_tool_version("node")?; +``` + +### Tool Discovery + +```rust +use vx_paths::{PathManager, PathResolver}; + +let manager = PathManager::new()?; +let resolver = PathResolver::new(manager); + +// Find all executables for a tool +let executables = resolver.find_tool_executables("node")?; + +// Find latest executable +let latest_exe = resolver.find_latest_executable("node")?; + +// Resolve with version preference +let exe_path = resolver.resolve_tool_path("node", Some("18.17.0"))?; +``` + +### Custom Configuration + +```rust +use vx_paths::{PathConfig, PathManager}; + +// Create with custom base directory +let config = PathConfig::with_base_dir("/custom/vx"); +let manager = config.create_path_manager()?; + +// Load from environment variables +let config = PathConfig::from_env(); +let manager = config.create_path_manager()?; +``` + +### Environment Variables + +- `VX_BASE_DIR`: Custom base directory +- `VX_TOOLS_DIR`: Custom tools directory +- `VX_CACHE_DIR`: Custom cache directory +- `VX_CONFIG_DIR`: Custom config directory +- `VX_TMP_DIR`: Custom temporary directory + +## Integration with vx-core + +```rust +use vx_paths::PathManager; +use vx_core::VxConfig; + +// Integrate with vx configuration +let path_manager = PathManager::new()?; +let config = VxConfig { + install_dir: path_manager.tools_dir().to_path_buf(), + cache_dir: path_manager.cache_dir().to_path_buf(), + // ... other config +}; +``` + +## Testing + +```bash +cargo test +``` + +## License + +This project is licensed under the MIT License - see the [LICENSE](../../LICENSE) file for details. diff --git a/crates/vx-paths/src/config.rs b/crates/vx-paths/src/config.rs new file mode 100644 index 000000000..234c4748c --- /dev/null +++ b/crates/vx-paths/src/config.rs @@ -0,0 +1,130 @@ +//! Configuration for path management + +use crate::{PathManager, VxPaths}; +use anyhow::Result; +use serde::{Deserialize, Serialize}; +use std::path::{Path, PathBuf}; + +/// Configuration for path management +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PathConfig { + /// Custom base directory for vx installations + pub base_dir: Option, + /// Custom store directory + pub store_dir: Option, + /// Custom environments directory + pub envs_dir: Option, + /// Custom bin directory + pub bin_dir: Option, + /// Custom cache directory + pub cache_dir: Option, + /// Custom config directory + pub config_dir: Option, + /// Custom temporary directory + pub tmp_dir: Option, +} + +impl PathConfig { + /// Create a new PathConfig with default values + pub fn new() -> Self { + Self { + base_dir: None, + store_dir: None, + envs_dir: None, + bin_dir: None, + cache_dir: None, + config_dir: None, + tmp_dir: None, + } + } + + /// Create PathConfig with custom base directory + pub fn with_base_dir>(base_dir: P) -> Self { + Self { + base_dir: Some(base_dir.as_ref().to_path_buf()), + store_dir: None, + envs_dir: None, + bin_dir: None, + cache_dir: None, + config_dir: None, + tmp_dir: None, + } + } + + /// Create a PathManager from this configuration + pub fn create_path_manager(&self) -> Result { + let paths = self.create_vx_paths()?; + paths.ensure_dirs()?; + Ok(PathManager::from_paths(paths)) + } + + /// Create VxPaths from this configuration + pub fn create_vx_paths(&self) -> Result { + let default_paths = if let Some(base_dir) = &self.base_dir { + VxPaths::with_base_dir(base_dir) + } else { + VxPaths::new()? + }; + + Ok(VxPaths { + base_dir: self.base_dir.clone().unwrap_or(default_paths.base_dir), + store_dir: self.store_dir.clone().unwrap_or(default_paths.store_dir), + npm_tools_dir: default_paths.npm_tools_dir, + pip_tools_dir: default_paths.pip_tools_dir, + choco_tools_dir: default_paths.choco_tools_dir, + envs_dir: self.envs_dir.clone().unwrap_or(default_paths.envs_dir), + bin_dir: self.bin_dir.clone().unwrap_or(default_paths.bin_dir), + cache_dir: self.cache_dir.clone().unwrap_or(default_paths.cache_dir), + config_dir: self.config_dir.clone().unwrap_or(default_paths.config_dir), + tmp_dir: self.tmp_dir.clone().unwrap_or(default_paths.tmp_dir), + providers_dir: default_paths.providers_dir, + // RFC 0025: Global packages CAS + packages_dir: default_paths.packages_dir, + shims_dir: default_paths.shims_dir, + }) + } + + /// Load configuration from environment variables + pub fn from_env() -> Self { + Self { + base_dir: std::env::var("VX_HOME").ok().map(PathBuf::from), + store_dir: std::env::var("VX_STORE_DIR").ok().map(PathBuf::from), + envs_dir: std::env::var("VX_ENVS_DIR").ok().map(PathBuf::from), + bin_dir: std::env::var("VX_BIN_DIR").ok().map(PathBuf::from), + cache_dir: std::env::var("VX_CACHE_DIR").ok().map(PathBuf::from), + config_dir: std::env::var("VX_CONFIG_DIR").ok().map(PathBuf::from), + tmp_dir: std::env::var("VX_TMP_DIR").ok().map(PathBuf::from), + } + } + + /// Merge with another PathConfig, preferring values from other + pub fn merge(&mut self, other: &PathConfig) { + if other.base_dir.is_some() { + self.base_dir = other.base_dir.clone(); + } + if other.store_dir.is_some() { + self.store_dir = other.store_dir.clone(); + } + if other.envs_dir.is_some() { + self.envs_dir = other.envs_dir.clone(); + } + if other.bin_dir.is_some() { + self.bin_dir = other.bin_dir.clone(); + } + if other.cache_dir.is_some() { + self.cache_dir = other.cache_dir.clone(); + } + if other.config_dir.is_some() { + self.config_dir = other.config_dir.clone(); + } + if other.tmp_dir.is_some() { + self.tmp_dir = other.tmp_dir.clone(); + } + } +} + +impl Default for PathConfig { + fn default() -> Self { + Self::new() + } +} diff --git a/crates/vx-paths/src/global_packages.rs b/crates/vx-paths/src/global_packages.rs new file mode 100644 index 000000000..72fc551b3 --- /dev/null +++ b/crates/vx-paths/src/global_packages.rs @@ -0,0 +1,378 @@ +//! Global Package Management (RFC 0025) +//! +//! This module provides data structures and utilities for managing globally +//! installed packages across different package ecosystems (npm, pip, cargo, go, gem). + +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; + +/// A globally installed package +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct GlobalPackage { + /// Package name + pub name: String, + /// Installed version + pub version: String, + /// Ecosystem identifier (npm, pip, cargo, go, gem) + pub ecosystem: String, + /// Installation timestamp (ISO 8601) + pub installed_at: String, + /// Executables provided by this package + pub executables: Vec, + /// Runtime dependency (e.g., node@20 for npm packages) + /// Deprecated: Use `runtime_dependencies` instead + #[serde(skip_serializing_if = "Option::is_none")] + pub runtime_dependency: Option, + /// Multiple runtime dependencies (e.g., node + bun for some packages) + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub runtime_dependencies: Vec, + /// Installation directory + pub install_dir: PathBuf, +} + +impl GlobalPackage { + /// Create a new GlobalPackage + pub fn new( + name: impl Into, + version: impl Into, + ecosystem: impl Into, + install_dir: PathBuf, + ) -> Self { + Self { + name: name.into(), + version: version.into(), + ecosystem: ecosystem.into(), + installed_at: chrono::Utc::now().to_rfc3339(), + executables: Vec::new(), + runtime_dependency: None, + runtime_dependencies: Vec::new(), + install_dir, + } + } + + /// Add an executable to this package + pub fn with_executable(mut self, exe: impl Into) -> Self { + self.executables.push(exe.into()); + self + } + + /// Add multiple executables to this package + pub fn with_executables(mut self, exes: Vec) -> Self { + self.executables.extend(exes); + self + } + + /// Set the runtime dependency (legacy, single dependency) + pub fn with_runtime_dependency( + mut self, + runtime: impl Into, + version: impl Into, + ) -> Self { + let dep = RuntimeDependency { + runtime: runtime.into(), + version: version.into(), + }; + self.runtime_dependency = Some(dep.clone()); + // Also add to the new list for forward compatibility + if !self + .runtime_dependencies + .iter() + .any(|d| d.runtime == dep.runtime) + { + self.runtime_dependencies.push(dep); + } + self + } + + /// Add a runtime dependency (supports multiple dependencies) + pub fn with_runtime_dependencies(mut self, deps: Vec) -> Self { + for dep in deps { + if !self + .runtime_dependencies + .iter() + .any(|d| d.runtime == dep.runtime) + { + self.runtime_dependencies.push(dep); + } + } + // Set legacy field to first dependency for backward compatibility + if self.runtime_dependency.is_none() && !self.runtime_dependencies.is_empty() { + self.runtime_dependency = Some(self.runtime_dependencies[0].clone()); + } + self + } + + /// Get all runtime dependencies (unified API) + /// + /// Returns dependencies from `runtime_dependencies` if set, + /// falls back to `runtime_dependency` for backward compatibility. + pub fn get_runtime_dependencies(&self) -> Vec { + if !self.runtime_dependencies.is_empty() { + return self.runtime_dependencies.clone(); + } + // Fallback to legacy single dependency + self.runtime_dependency.clone().into_iter().collect() + } + + /// Get the unique key for this package (ecosystem:name) + pub fn key(&self) -> String { + format!("{}:{}", self.ecosystem, self.name) + } +} + +/// Runtime dependency for a package +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct RuntimeDependency { + /// Runtime name (e.g., "node", "python") + pub runtime: String, + /// Required version (e.g., "20", "3.11") + pub version: String, +} + +/// Registry of installed global packages +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct PackageRegistry { + /// Map of package key (ecosystem:name) to GlobalPackage + packages: HashMap, + /// Map of executable name to package key + #[serde(default)] + executable_index: HashMap, +} + +impl PackageRegistry { + /// Create a new empty registry + pub fn new() -> Self { + Self { + packages: HashMap::new(), + executable_index: HashMap::new(), + } + } + + /// Load registry from a file + pub fn load(path: &Path) -> Result { + if !path.exists() { + return Ok(Self::new()); + } + + let content = std::fs::read_to_string(path) + .with_context(|| format!("Failed to read registry file: {}", path.display()))?; + + serde_json::from_str(&content) + .with_context(|| format!("Failed to parse registry file: {}", path.display())) + } + + /// Load registry from file or create new if doesn't exist + pub fn load_or_create(path: &Path) -> Result { + Self::load(path) + } + + /// Get all packages as an iterator + pub fn all_packages(&self) -> impl Iterator { + self.packages.values() + } + + /// Save registry to a file + pub fn save(&self, path: &Path) -> Result<()> { + // Ensure parent directory exists + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + + let content = serde_json::to_string_pretty(self).context("Failed to serialize registry")?; + + std::fs::write(path, content) + .with_context(|| format!("Failed to write registry file: {}", path.display())) + } + + /// Register a new package + pub fn register(&mut self, package: GlobalPackage) { + let key = package.key(); + + // Update executable index + for exe in &package.executables { + self.executable_index.insert(exe.clone(), key.clone()); + } + + self.packages.insert(key, package); + } + + /// Unregister a package + pub fn unregister(&mut self, ecosystem: &str, name: &str) -> Option { + let key = format!("{}:{}", ecosystem, name); + + if let Some(package) = self.packages.remove(&key) { + // Remove from executable index + for exe in &package.executables { + self.executable_index.remove(exe); + } + Some(package) + } else { + None + } + } + + /// Get a package by ecosystem and name + pub fn get(&self, ecosystem: &str, name: &str) -> Option<&GlobalPackage> { + let key = format!("{}:{}", ecosystem, name); + self.packages.get(&key) + } + + /// Find a package by executable name + pub fn find_by_executable(&self, exe_name: &str) -> Option<&GlobalPackage> { + self.executable_index + .get(exe_name) + .and_then(|key| self.packages.get(key)) + } + + /// List all packages + pub fn list(&self) -> impl Iterator { + self.packages.values() + } + + /// List packages by ecosystem + pub fn list_by_ecosystem(&self, ecosystem: &str) -> impl Iterator { + let ecosystem = ecosystem.to_lowercase(); + self.packages + .values() + .filter(move |pkg| pkg.ecosystem.to_lowercase() == ecosystem) + } + + /// Check if a package is registered + pub fn contains(&self, ecosystem: &str, name: &str) -> bool { + let key = format!("{}:{}", ecosystem, name); + self.packages.contains_key(&key) + } + + /// Get the number of registered packages + pub fn len(&self) -> usize { + self.packages.len() + } + + /// Check if the registry is empty + pub fn is_empty(&self) -> bool { + self.packages.is_empty() + } + + /// Rebuild the executable index from packages + pub fn rebuild_index(&mut self) { + self.executable_index.clear(); + for (key, package) in &self.packages { + for exe in &package.executables { + self.executable_index.insert(exe.clone(), key.clone()); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_global_package_creation() { + let pkg = GlobalPackage::new("typescript", "5.3.3", "npm", PathBuf::from("/tmp/pkg")) + .with_executable("tsc") + .with_executable("tsserver"); + + assert_eq!(pkg.name, "typescript"); + assert_eq!(pkg.version, "5.3.3"); + assert_eq!(pkg.ecosystem, "npm"); + assert_eq!(pkg.executables, vec!["tsc", "tsserver"]); + assert_eq!(pkg.key(), "npm:typescript"); + } + + #[test] + fn test_global_package_with_runtime() { + let pkg = GlobalPackage::new("typescript", "5.3.3", "npm", PathBuf::from("/tmp/pkg")) + .with_runtime_dependency("node", "20"); + + assert!(pkg.runtime_dependency.is_some()); + let dep = pkg.runtime_dependency.unwrap(); + assert_eq!(dep.runtime, "node"); + assert_eq!(dep.version, "20"); + } + + #[test] + fn test_package_registry() { + let mut registry = PackageRegistry::new(); + + let pkg = GlobalPackage::new("typescript", "5.3.3", "npm", PathBuf::from("/tmp/pkg")) + .with_executable("tsc") + .with_executable("tsserver"); + + registry.register(pkg); + + assert_eq!(registry.len(), 1); + assert!(registry.contains("npm", "typescript")); + assert!(registry.get("npm", "typescript").is_some()); + } + + #[test] + fn test_find_by_executable() { + let mut registry = PackageRegistry::new(); + + let pkg = GlobalPackage::new("typescript", "5.3.3", "npm", PathBuf::from("/tmp/pkg")) + .with_executable("tsc") + .with_executable("tsserver"); + + registry.register(pkg); + + let found = registry.find_by_executable("tsc"); + assert!(found.is_some()); + assert_eq!(found.unwrap().name, "typescript"); + + let found = registry.find_by_executable("tsserver"); + assert!(found.is_some()); + + let not_found = registry.find_by_executable("nonexistent"); + assert!(not_found.is_none()); + } + + #[test] + fn test_list_by_ecosystem() { + let mut registry = PackageRegistry::new(); + + registry.register(GlobalPackage::new( + "typescript", + "5.3.3", + "npm", + PathBuf::from("/tmp/ts"), + )); + registry.register(GlobalPackage::new( + "eslint", + "8.56.0", + "npm", + PathBuf::from("/tmp/eslint"), + )); + registry.register(GlobalPackage::new( + "black", + "24.1.0", + "pip", + PathBuf::from("/tmp/black"), + )); + + let npm_packages: Vec<_> = registry.list_by_ecosystem("npm").collect(); + assert_eq!(npm_packages.len(), 2); + + let pip_packages: Vec<_> = registry.list_by_ecosystem("pip").collect(); + assert_eq!(pip_packages.len(), 1); + } + + #[test] + fn test_unregister() { + let mut registry = PackageRegistry::new(); + + let pkg = GlobalPackage::new("typescript", "5.3.3", "npm", PathBuf::from("/tmp/pkg")) + .with_executable("tsc"); + + registry.register(pkg); + assert!(registry.find_by_executable("tsc").is_some()); + + let removed = registry.unregister("npm", "typescript"); + assert!(removed.is_some()); + assert!(registry.find_by_executable("tsc").is_none()); + assert!(!registry.contains("npm", "typescript")); + } +} diff --git a/crates/vx-paths/src/lib.rs b/crates/vx-paths/src/lib.rs new file mode 100644 index 000000000..42c3ac44c --- /dev/null +++ b/crates/vx-paths/src/lib.rs @@ -0,0 +1,409 @@ +//! Cross-platform path management for vx tool installations +//! +//! This crate provides a unified interface for managing tool installation paths +//! across different platforms, ensuring consistent directory structures and +//! proper handling of executable file extensions. +//! +//! # Platform Redirection +//! +//! vx uses a **platform-agnostic API** with automatic platform-specific storage. +//! +//! - **External API**: Access tools using `//` paths +//! - **Internal Storage**: Files stored in `///` directories +//! - **Automatic Redirection**: PathManager transparently redirects to current platform +//! +//! # Directory Structure +//! +//! ```text +//! ~/.vx/ +//! ├── store/ # Global storage (Content-Addressable) +//! │ ├── node/ +//! │ │ └── 20.0.0/ # Unified version directory (API) +//! │ │ ├── windows-x64/ # Platform-specific (storage) +//! │ │ ├── darwin-x64/ +//! │ │ └── linux-x64/ +//! │ ├── go/ +//! │ │ └── 1.21.0/ +//! │ │ ├── windows-x64/ +//! │ │ └── linux-x64/ +//! │ └── python/ +//! │ └── 3.9.21/ +//! │ ├── windows-x64/ +//! │ └── linux-x64/ +//! │ +//! ├── npm-tools/ # npm package tools (isolated environments) +//! │ └── vite/ +//! │ └── 5.4.0/ +//! │ ├── node_modules/ +//! │ └── bin/vite # shim script +//! │ +//! ├── pip-tools/ # pip package tools (isolated environments) +//! │ └── rez/ +//! │ └── 2.114.0/ +//! │ ├── venv/ +//! │ └── bin/rez # shim script +//! │ +//! ├── envs/ # Virtual environments (links to store) +//! │ ├── default/ # Default environment +//! │ │ └── node -> ../../store/node/20.0.0 +//! │ └── project-abc/ # Project-specific environment +//! │ └── node -> ../../store/node/18.0.0 +//! │ +//! ├── providers/ # User-defined manifest-driven providers +//! │ ├── unix-tools/ # Example: Unix philosophy tools +//! │ │ └── provider.toml +//! │ └── my-custom-tools/ # User's custom tools +//! │ └── provider.toml +//! │ +//! ├── bin/ # Global shims +//! ├── cache/ # Download cache +//! ├── config/ # Configuration +//! └── tmp/ # Temporary files +//! ``` +//! +//! # Offline Bundle Support +//! +//! The platform redirection design enables efficient offline bundles: +//! +//! ```text +//! bundle/ +//! └── store/ +//! └── node/ +//! └── 20.0.0/ +//! ├── windows-x64/ # All platforms in one bundle +//! ├── darwin-x64/ +//! └── linux-x64/ +//! ``` +//! +//! When extracting the bundle, vx automatically selects the correct platform +//! directory for the current system. + +use anyhow::Result; +use std::path::{Path, PathBuf}; + +pub mod config; +pub mod global_packages; +pub mod link; +pub mod manager; +pub mod package_spec; +pub mod platform; +pub mod project; +pub mod resolver; +pub mod runtime_root; +pub mod shims; +pub mod windows; + +pub use config::PathConfig; +pub use global_packages::{GlobalPackage, PackageRegistry, RuntimeDependency}; +pub use link::{LinkResult, LinkStrategy}; +pub use manager::PathManager; +pub use package_spec::PackageSpec; +pub use project::{ + CONFIG_FILE_NAME, CONFIG_FILE_NAME_LEGACY, CONFIG_NAMES, ConfigNotFoundError, LOCK_FILE_NAME, + LOCK_FILE_NAME_LEGACY, LOCK_FILE_NAMES, PROJECT_BIN_DIR, PROJECT_CACHE_DIR, PROJECT_ENV_DIR, + PROJECT_VX_DIR, find_config_file, find_config_file_upward, find_project_root, find_vx_config, + is_in_vx_project, project_env_dir, +}; +pub use resolver::{PathResolver, ToolLocation, ToolSource}; +pub use runtime_root::{ + RuntimeRoot, get_bundled_tool_path, get_latest_runtime_root, get_runtime_root, +}; + +// Re-export platform module utilities for convenience +pub use platform::{ + Arch, Os, Platform, append_to_path, executable_extension, filter_system_path, is_system_path, + is_unix_path, is_windows_path, join_paths_env, join_paths_simple, path_separator, + platform_dir_name, prepend_to_path, split_path, split_path_owned, venv_bin_dir, + with_executable_extension, +}; + +/// Standard vx directory structure +#[derive(Debug, Clone)] +pub struct VxPaths { + /// Base vx directory (~/.vx) + pub base_dir: PathBuf, + /// Global store directory (~/.vx/store) - Content-Addressable Storage + pub store_dir: PathBuf, + /// npm package tools directory (~/.vx/npm-tools) + pub npm_tools_dir: PathBuf, + /// pip package tools directory (~/.vx/pip-tools) + pub pip_tools_dir: PathBuf, + /// choco package tools directory (~/.vx/choco-tools) + pub choco_tools_dir: PathBuf, + /// Virtual environments directory (~/.vx/envs) + pub envs_dir: PathBuf, + /// Global shims directory (~/.vx/bin) + pub bin_dir: PathBuf, + /// Cache directory (~/.vx/cache) + pub cache_dir: PathBuf, + /// Configuration directory (~/.vx/config) + pub config_dir: PathBuf, + /// Temporary directory (~/.vx/tmp) + pub tmp_dir: PathBuf, + /// User providers directory (~/.vx/providers) - Manifest-driven runtimes + pub providers_dir: PathBuf, + /// Global packages CAS directory (~/.vx/packages) - RFC 0025 + pub packages_dir: PathBuf, + /// Global shims directory (~/.vx/shims) - RFC 0025 + pub shims_dir: PathBuf, +} + +impl VxPaths { + /// Create VxPaths with default locations + /// + /// Uses VX_HOME environment variable if set, otherwise defaults to ~/.vx + pub fn new() -> Result { + // Check for VX_HOME environment variable first + if let Ok(vx_home) = std::env::var("VX_HOME") { + return Ok(Self::with_base_dir(vx_home)); + } + + let home_dir = dirs::home_dir() + .ok_or_else(|| anyhow::anyhow!("Could not determine home directory"))?; + + let base_dir = home_dir.join(".vx"); + + Ok(Self { + store_dir: base_dir.join("store"), + npm_tools_dir: base_dir.join("npm-tools"), + pip_tools_dir: base_dir.join("pip-tools"), + choco_tools_dir: base_dir.join("choco-tools"), + envs_dir: base_dir.join("envs"), + bin_dir: base_dir.join("bin"), + cache_dir: base_dir.join("cache"), + config_dir: base_dir.join("config"), + tmp_dir: base_dir.join("tmp"), + providers_dir: base_dir.join("providers"), + packages_dir: base_dir.join("packages"), + shims_dir: base_dir.join("shims"), + base_dir, + }) + } + + /// Create VxPaths with custom base directory + pub fn with_base_dir>(base_dir: P) -> Self { + let base_dir = base_dir.as_ref().to_path_buf(); + + Self { + store_dir: base_dir.join("store"), + npm_tools_dir: base_dir.join("npm-tools"), + pip_tools_dir: base_dir.join("pip-tools"), + choco_tools_dir: base_dir.join("choco-tools"), + envs_dir: base_dir.join("envs"), + bin_dir: base_dir.join("bin"), + cache_dir: base_dir.join("cache"), + config_dir: base_dir.join("config"), + tmp_dir: base_dir.join("tmp"), + providers_dir: base_dir.join("providers"), + packages_dir: base_dir.join("packages"), + shims_dir: base_dir.join("shims"), + base_dir, + } + } + + /// Ensure all directories exist + pub fn ensure_dirs(&self) -> Result<()> { + std::fs::create_dir_all(&self.base_dir)?; + std::fs::create_dir_all(&self.store_dir)?; + std::fs::create_dir_all(&self.npm_tools_dir)?; + std::fs::create_dir_all(&self.pip_tools_dir)?; + std::fs::create_dir_all(&self.choco_tools_dir)?; + std::fs::create_dir_all(&self.envs_dir)?; + std::fs::create_dir_all(&self.bin_dir)?; + std::fs::create_dir_all(&self.cache_dir)?; + std::fs::create_dir_all(&self.config_dir)?; + std::fs::create_dir_all(&self.tmp_dir)?; + std::fs::create_dir_all(&self.providers_dir)?; + std::fs::create_dir_all(&self.packages_dir)?; + std::fs::create_dir_all(&self.shims_dir)?; + Ok(()) + } + + /// Get the store directory for a specific runtime + pub fn runtime_store_dir(&self, runtime_name: &str) -> PathBuf { + self.store_dir.join(runtime_name) + } + + /// Get the store directory for a specific runtime version + pub fn version_store_dir(&self, runtime_name: &str, version: &str) -> PathBuf { + self.runtime_store_dir(runtime_name).join(version) + } + + /// Get the environment directory + pub fn env_dir(&self, env_name: &str) -> PathBuf { + self.envs_dir.join(env_name) + } + + /// Get the default environment directory + pub fn default_env_dir(&self) -> PathBuf { + self.envs_dir.join("default") + } + + // ========== npm-tools paths ========== + + /// Get the npm-tools directory for a specific package + pub fn npm_tool_dir(&self, package_name: &str) -> PathBuf { + self.npm_tools_dir.join(package_name) + } + + /// Get the npm-tools directory for a specific package version + pub fn npm_tool_version_dir(&self, package_name: &str, version: &str) -> PathBuf { + self.npm_tool_dir(package_name).join(version) + } + + /// Get the bin directory for an npm tool + pub fn npm_tool_bin_dir(&self, package_name: &str, version: &str) -> PathBuf { + self.npm_tool_version_dir(package_name, version).join("bin") + } + + // ========== pip-tools paths ========== + + /// Get the pip-tools directory for a specific package + pub fn pip_tool_dir(&self, package_name: &str) -> PathBuf { + self.pip_tools_dir.join(package_name) + } + + /// Get the pip-tools directory for a specific package version + pub fn pip_tool_version_dir(&self, package_name: &str, version: &str) -> PathBuf { + self.pip_tool_dir(package_name).join(version) + } + + /// Get the venv directory for a pip tool + pub fn pip_tool_venv_dir(&self, package_name: &str, version: &str) -> PathBuf { + self.pip_tool_version_dir(package_name, version) + .join("venv") + } + + /// Get the bin directory for a pip tool + pub fn pip_tool_bin_dir(&self, package_name: &str, version: &str) -> PathBuf { + let venv_dir = self.pip_tool_venv_dir(package_name, version); + venv_dir.join(venv_bin_dir()) + } + + // ========== choco-tools paths ========== + + /// Get the choco-tools directory for a specific package + pub fn choco_tool_dir(&self, package_name: &str) -> PathBuf { + self.choco_tools_dir.join(package_name) + } + + /// Get the choco-tools directory for a specific package version + pub fn choco_tool_version_dir(&self, package_name: &str, version: &str) -> PathBuf { + self.choco_tool_dir(package_name).join(version) + } + + // ========== RFC 0025: Global Packages CAS ========== + + /// Get the ecosystem directory for global packages + /// + /// Returns: ~/.vx/packages/{ecosystem} + /// + /// # Example + /// ``` + /// use vx_paths::VxPaths; + /// let paths = VxPaths::with_base_dir("/tmp/vx"); + /// let npm_dir = paths.ecosystem_packages_dir("npm"); + /// assert!(npm_dir.ends_with("packages/npm")); + /// ``` + pub fn ecosystem_packages_dir(&self, ecosystem: &str) -> PathBuf { + self.packages_dir.join(ecosystem.to_lowercase()) + } + + /// Get the package directory for a specific global package + /// + /// Returns: ~/.vx/packages/{ecosystem}/{package}/{version} + /// + /// # Example + /// ``` + /// use vx_paths::VxPaths; + /// let paths = VxPaths::with_base_dir("/tmp/vx"); + /// let ts_dir = paths.global_package_dir("npm", "typescript", "5.3.3"); + /// assert!(ts_dir.ends_with("packages/npm/typescript/5.3.3")); + /// ``` + pub fn global_package_dir(&self, ecosystem: &str, package: &str, version: &str) -> PathBuf { + self.ecosystem_packages_dir(ecosystem) + .join(normalize_package_name(package)) + .join(version) + } + + /// Get the bin directory for a global package + /// + /// Returns: ~/.vx/packages/{ecosystem}/{package}/{version}/bin + pub fn global_package_bin_dir(&self, ecosystem: &str, package: &str, version: &str) -> PathBuf { + self.global_package_dir(ecosystem, package, version) + .join("bin") + } + + /// Get the venv directory for a pip global package + /// + /// Returns: ~/.vx/packages/pip/{package}/{version}/venv + pub fn global_pip_venv_dir(&self, package: &str, version: &str) -> PathBuf { + self.global_package_dir("pip", package, version) + .join("venv") + } + + /// Get the node_modules directory for an npm global package + /// + /// Returns: ~/.vx/packages/npm/{package}/{version}/node_modules + pub fn global_npm_node_modules_dir(&self, package: &str, version: &str) -> PathBuf { + self.global_package_dir("npm", package, version) + .join("node_modules") + } + + /// Get the project-local bin directory + /// + /// Returns: {project_root}/.vx/bin + pub fn project_bin_dir(&self, project_root: &Path) -> PathBuf { + project_root.join(".vx").join("bin") + } + + /// Get the global tools configuration file path + /// + /// Returns: ~/.vx/config/global-tools.toml + pub fn global_tools_config(&self) -> PathBuf { + self.config_dir.join("global-tools.toml") + } + + /// Get the global packages registry file path + /// + /// Returns: ~/.vx/config/packages-registry.json + pub fn packages_registry_file(&self) -> PathBuf { + self.config_dir.join("packages-registry.json") + } +} + +impl Default for VxPaths { + fn default() -> Self { + Self::new().unwrap_or_else(|_| { + // Fallback to current directory if home directory is not available + Self::with_base_dir(".vx") + }) + } +} + +/// Get the executable file extension for the current platform +/// +/// Deprecated: Use `platform::executable_extension()` instead. +#[deprecated(since = "0.6.0", note = "Use platform::executable_extension() instead")] +pub fn executable_extension_legacy() -> &'static str { + platform::executable_extension() +} + +/// Add executable extension to a tool name if needed +/// +/// Deprecated: Use `platform::with_executable_extension()` instead. +#[deprecated( + since = "0.6.0", + note = "Use platform::with_executable_extension() instead" +)] +pub fn with_executable_extension_legacy(tool_name: &str) -> String { + platform::with_executable_extension(tool_name) +} + +/// Normalize package name for filesystem lookup +/// +/// On Windows and macOS (case-insensitive filesystems), convert to lowercase. +/// On Linux, keep the original case. +pub fn normalize_package_name(name: &str) -> String { + platform::normalize_for_comparison(name) +} diff --git a/crates/vx-paths/src/link.rs b/crates/vx-paths/src/link.rs new file mode 100644 index 000000000..3f290e9fa --- /dev/null +++ b/crates/vx-paths/src/link.rs @@ -0,0 +1,256 @@ +//! Link strategy for file system operations +//! +//! This module provides cross-platform linking strategies to avoid +//! duplicating files when creating virtual environments. + +use anyhow::Result; +use std::path::Path; + +/// Link strategy for creating file references +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LinkStrategy { + /// Hard link (same filesystem, fastest, no extra space) + HardLink, + /// Symbolic link (cross-filesystem, Windows needs permissions) + SymLink, + /// Copy-on-Write (macOS APFS, Linux Btrfs/XFS) + CopyOnWrite, + /// Copy (fallback, slowest) + Copy, +} + +impl LinkStrategy { + /// Automatically select the best strategy for the current platform + pub fn auto() -> Self { + if cfg!(target_os = "macos") { + // macOS APFS supports CoW + Self::CopyOnWrite + } else if cfg!(target_os = "linux") { + // Linux: prefer hard links + Self::HardLink + } else if cfg!(target_os = "windows") { + // Windows: hard links work without special permissions + Self::HardLink + } else { + Self::Copy + } + } + + /// Detect the best strategy for a given path + pub fn detect(_path: &Path) -> Self { + // For now, use auto detection based on platform + // TODO: Actually test filesystem capabilities + Self::auto() + } + + /// Get a human-readable name for the strategy + pub fn name(&self) -> &'static str { + match self { + Self::HardLink => "hard link", + Self::SymLink => "symbolic link", + Self::CopyOnWrite => "copy-on-write", + Self::Copy => "copy", + } + } +} + +impl Default for LinkStrategy { + fn default() -> Self { + Self::auto() + } +} + +/// Result of a link operation +#[derive(Debug)] +pub struct LinkResult { + /// Whether the operation was successful + pub success: bool, + /// The strategy that was used + pub strategy: LinkStrategy, + /// Number of files linked + pub files_linked: usize, + /// Number of directories created + pub dirs_created: usize, +} + +impl LinkResult { + /// Create a successful result + pub fn success(strategy: LinkStrategy, files_linked: usize, dirs_created: usize) -> Self { + Self { + success: true, + strategy, + files_linked, + dirs_created, + } + } + + /// Create a failed result + pub fn failed(strategy: LinkStrategy) -> Self { + Self { + success: false, + strategy, + files_linked: 0, + dirs_created: 0, + } + } +} + +/// Create a link from src to dst using the specified strategy +pub fn create_link(src: &Path, dst: &Path, strategy: LinkStrategy) -> Result<()> { + match strategy { + LinkStrategy::HardLink => create_hard_link(src, dst), + LinkStrategy::SymLink => create_symlink(src, dst), + LinkStrategy::CopyOnWrite => create_cow_link(src, dst), + LinkStrategy::Copy => copy_path(src, dst), + } +} + +/// Create a hard link +fn create_hard_link(src: &Path, dst: &Path) -> Result<()> { + if src.is_dir() { + // Directories can't be hard-linked, need to recursively link files + std::fs::create_dir_all(dst)?; + for entry in std::fs::read_dir(src)? { + let entry = entry?; + let src_path = entry.path(); + let dst_path = dst.join(entry.file_name()); + create_hard_link(&src_path, &dst_path)?; + } + } else { + // Ensure parent directory exists + if let Some(parent) = dst.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::hard_link(src, dst)?; + } + Ok(()) +} + +/// Create a symbolic link +fn create_symlink(src: &Path, dst: &Path) -> Result<()> { + // Ensure parent directory exists + if let Some(parent) = dst.parent() { + std::fs::create_dir_all(parent)?; + } + + #[cfg(unix)] + { + std::os::unix::fs::symlink(src, dst)?; + } + + #[cfg(windows)] + { + if src.is_dir() { + std::os::windows::fs::symlink_dir(src, dst)?; + } else { + std::os::windows::fs::symlink_file(src, dst)?; + } + } + + Ok(()) +} + +/// Create a copy-on-write link (or fallback to copy) +fn create_cow_link(src: &Path, dst: &Path) -> Result<()> { + // Ensure parent directory exists + if let Some(parent) = dst.parent() { + std::fs::create_dir_all(parent)?; + } + + #[cfg(target_os = "macos")] + { + // macOS: use clonefile + use std::ffi::CString; + use std::os::unix::ffi::OsStrExt; + + let src_c = CString::new(src.as_os_str().as_bytes())?; + let dst_c = CString::new(dst.as_os_str().as_bytes())?; + + // clonefile is available on macOS 10.12+ + unsafe extern "C" { + fn clonefile(src: *const i8, dst: *const i8, flags: u32) -> i32; + } + + let result = unsafe { clonefile(src_c.as_ptr(), dst_c.as_ptr(), 0) }; + if result == 0 { + return Ok(()); + } + // If clonefile fails, fall back to copy + } + + #[cfg(target_os = "linux")] + { + // Linux: try reflink, fall back to copy + // This requires the reflink crate or ioctl FICLONE + // For now, just copy + } + + // Fallback to regular copy + copy_path(src, dst) +} + +/// Copy a file or directory +fn copy_path(src: &Path, dst: &Path) -> Result<()> { + if src.is_dir() { + std::fs::create_dir_all(dst)?; + for entry in std::fs::read_dir(src)? { + let entry = entry?; + let src_path = entry.path(); + let dst_path = dst.join(entry.file_name()); + copy_path(&src_path, &dst_path)?; + } + } else { + // Ensure parent directory exists + if let Some(parent) = dst.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::copy(src, dst)?; + } + Ok(()) +} + +/// Link a directory tree using the best available strategy +pub fn link_directory(src: &Path, dst: &Path) -> Result { + let strategy = LinkStrategy::detect(src); + + match create_link(src, dst, strategy) { + Ok(()) => { + // Count files and directories + let (files, dirs) = count_entries(dst)?; + Ok(LinkResult::success(strategy, files, dirs)) + } + Err(e) => { + // Try fallback strategies + if strategy != LinkStrategy::Copy && create_link(src, dst, LinkStrategy::Copy).is_ok() { + let (files, dirs) = count_entries(dst)?; + return Ok(LinkResult::success(LinkStrategy::Copy, files, dirs)); + } + Err(e) + } + } +} + +/// Count files and directories in a path +fn count_entries(path: &Path) -> Result<(usize, usize)> { + let mut files = 0; + let mut dirs = 0; + + if path.is_dir() { + dirs += 1; + for entry in std::fs::read_dir(path)? { + let entry = entry?; + let entry_path = entry.path(); + if entry_path.is_dir() { + let (f, d) = count_entries(&entry_path)?; + files += f; + dirs += d; + } else { + files += 1; + } + } + } else { + files += 1; + } + + Ok((files, dirs)) +} diff --git a/crates/vx-paths/src/manager.rs b/crates/vx-paths/src/manager.rs new file mode 100644 index 000000000..dbb9e3b01 --- /dev/null +++ b/crates/vx-paths/src/manager.rs @@ -0,0 +1,528 @@ +//! Path manager for vx tool installations + +use crate::{VxPaths, with_executable_extension}; +use anyhow::Result; +use std::path::{Path, PathBuf}; + +/// Current platform information +/// +/// This is a lightweight platform detection used within vx-paths +/// to avoid circular dependency with vx-runtime. +#[derive(Debug, Clone, Copy)] +pub struct CurrentPlatform { + /// Operating system + pub os: &'static str, + /// Architecture + pub arch: &'static str, +} + +impl CurrentPlatform { + /// Detect current platform + pub fn current() -> Self { + let os = if cfg!(target_os = "windows") { + "windows" + } else if cfg!(target_os = "macos") { + "darwin" + } else if cfg!(target_os = "linux") { + "linux" + } else if cfg!(target_os = "freebsd") { + "freebsd" + } else { + "unknown" + }; + + let arch = if cfg!(target_arch = "x86_64") { + "x64" + } else if cfg!(target_arch = "aarch64") { + "arm64" + } else if cfg!(target_arch = "arm") { + "arm" + } else if cfg!(target_arch = "x86") { + "x86" + } else { + "unknown" + }; + + Self { os, arch } + } + + /// Get platform string for directory names + /// + /// Returns strings like "windows-x64", "darwin-arm64", "linux-x64", etc. + pub fn as_str(&self) -> String { + format!("{}-{}", self.os, self.arch) + } +} + +/// Manages paths for vx tool installations with standardized structure +/// +/// The PathManager provides platform-agnostic access to the vx store. +/// All access to `//` automatically redirects to +/// `///` for the current platform. +/// +/// # Platform Redirection +/// +/// - **External API**: Uses `//` paths +/// - **Internal Storage**: Uses `///` paths +/// - **Automatic Redirection**: The PathManager handles platform redirection transparently +/// +/// # Directory Structure +/// +/// ```text +/// ~/.vx/store/ +/// ├── node/ +/// │ └── 20.0.0/ # Unified version directory +/// │ ├── windows-x64/ # Platform-specific (internal) +/// │ ├── darwin-x64/ +/// │ └── linux-x64/ +/// └── python/ +/// └── 3.9.21/ +/// ├── windows-x64/ +/// └── linux-x64/ +/// ``` +#[derive(Debug, Clone)] +pub struct PathManager { + paths: VxPaths, +} + +impl PathManager { + /// Create a new PathManager with default paths + pub fn new() -> Result { + let paths = VxPaths::new()?; + paths.ensure_dirs()?; + Ok(Self { paths }) + } + + /// Create a new PathManager with custom base directory + pub fn with_base_dir>(base_dir: P) -> Result { + let paths = VxPaths::with_base_dir(base_dir); + paths.ensure_dirs()?; + Ok(Self { paths }) + } + + /// Create a PathManager from existing VxPaths + pub fn from_paths(paths: VxPaths) -> Self { + Self { paths } + } + + // ========== Base Directories ========== + + /// Get the base vx directory + pub fn base_dir(&self) -> &Path { + &self.paths.base_dir + } + + /// Get the global store directory + pub fn store_dir(&self) -> &Path { + &self.paths.store_dir + } + + /// Get the environments directory + pub fn envs_dir(&self) -> &Path { + &self.paths.envs_dir + } + + /// Get the bin directory (for shims) + pub fn bin_dir(&self) -> &Path { + &self.paths.bin_dir + } + + /// Get the cache directory + pub fn cache_dir(&self) -> &Path { + &self.paths.cache_dir + } + + /// Get the config directory + pub fn config_dir(&self) -> &Path { + &self.paths.config_dir + } + + /// Get the temporary directory + pub fn tmp_dir(&self) -> &Path { + &self.paths.tmp_dir + } + + // ========== Store Paths (Content-Addressable Storage) ========== + + /// Get the platform directory name for the current platform + /// + /// Returns platform string like "windows-x64", "darwin-arm64", "linux-x64", etc. + /// + /// This is used internally for platform-specific storage. + pub fn platform_dir_name(&self) -> String { + CurrentPlatform::current().as_str() + } + + /// Get the actual platform-specific store directory for a runtime version + /// + /// Returns: ~/.vx/store/``/``/`` + /// + /// This is the **actual** directory where files are stored. + /// Use this when installing or directly accessing platform-specific files. + /// + /// # Example + /// ```ignore + /// let manager = PathManager::new()?; + /// let platform_dir = manager.platform_store_dir("python", "3.9.21"); + /// // Returns: ~/.vx/store/python/3.9.21/windows-x64 (on Windows x64) + /// ``` + pub fn platform_store_dir(&self, runtime_name: &str, version: &str) -> PathBuf { + self.version_store_dir(runtime_name, version) + .join(self.platform_dir_name()) + } + + /// Get the store directory for a specific runtime + /// Returns: ~/.vx/store/`` + /// + /// This returns the unified runtime directory, not platform-specific. + pub fn runtime_store_dir(&self, runtime_name: &str) -> PathBuf { + self.paths.store_dir.join(runtime_name) + } + + /// Get the store directory for a specific runtime version + /// Returns: ~/.vx/store/``/`` + /// + /// This returns the unified version directory. All file access through this + /// path will automatically redirect to the platform-specific directory. + /// + /// # Platform Redirection + /// When checking if a version exists or accessing files, the PathManager + /// automatically redirects to `///` for + /// the current platform. + pub fn version_store_dir(&self, runtime_name: &str, version: &str) -> PathBuf { + self.runtime_store_dir(runtime_name).join(version) + } + + /// Get the actual executable path in the platform-specific store + /// + /// Returns: `~/.vx/store////bin/.exe` (Windows) + /// + /// This returns the path to the executable in the platform-specific directory. + /// Use this when installing or directly accessing executables. + /// + /// # Example + /// ```ignore + /// let manager = PathManager::new()?; + /// let exe_path = manager.platform_executable_path("python", "3.9.21"); + /// // Returns: ~/.vx/store/python/3.9.21/windows-x64/python.exe (on Windows) + /// ``` + pub fn platform_executable_path(&self, runtime_name: &str, version: &str) -> PathBuf { + let platform_dir = self.platform_store_dir(runtime_name, version); + let executable_name = with_executable_extension(runtime_name); + platform_dir.join("bin").join(executable_name) + } + + /// Get the executable path in the store for a specific runtime version + /// Returns: `~/.vx/store///bin/.exe` (Windows) + /// + /// This is a **unified** path that automatically redirects to the + /// platform-specific directory. Use this for general executable access. + /// + /// # Note + /// For actual file operations (install, check existence), use `platform_executable_path()`. + pub fn store_executable_path(&self, runtime_name: &str, version: &str) -> PathBuf { + let version_dir = self.version_store_dir(runtime_name, version); + let executable_name = with_executable_extension(runtime_name); + version_dir.join("bin").join(executable_name) + } + + /// Check if a runtime version is installed in the store + /// + /// This checks the platform-specific directory: + /// `~/.vx/store////` + pub fn is_version_in_store(&self, runtime_name: &str, version: &str) -> bool { + let platform_dir = self.platform_store_dir(runtime_name, version); + platform_dir.exists() + } + + /// List all installed versions of a runtime in the store + /// + /// This supports both store layouts: + /// - Unified layout: `//` + /// - Legacy platform layout: `///` + /// + /// Returns: List of version strings, sorted by semantic version (highest first) + pub fn list_store_versions(&self, runtime_name: &str) -> Result> { + let runtime_dir = self.runtime_store_dir(runtime_name); + + if !runtime_dir.exists() { + return Ok(Vec::new()); + } + + let current_platform = self.platform_dir_name(); + let mut versions = Vec::new(); + + // Scan version directories + for entry in std::fs::read_dir(&runtime_dir)? { + let entry = entry?; + let path = entry.path(); + + // Only check directories + if !entry.file_type()?.is_dir() { + continue; + } + + // Check if this is a version directory (e.g., "3.13.4") + // Version directories should start with a digit + let version_str = entry.file_name().to_string_lossy().to_string(); + + // Skip non-version directories + if !version_str + .chars() + .next() + .map(|c| c.is_ascii_digit()) + .unwrap_or(false) + { + continue; + } + + // Support both unified version directories and legacy + // platform-specific subdirectories. + let platform_dir = path.join(¤t_platform); + if platform_dir.exists() { + versions.push(version_str); + continue; + } + + let mut has_entries = false; + let mut has_non_platform_entries = false; + + for child in std::fs::read_dir(&path)? { + let child = child?; + has_entries = true; + + let child_name = child.file_name().to_string_lossy().to_string(); + let is_platform_dir = child.file_type()?.is_dir() + && ["windows-", "linux-", "darwin-", "macos-"] + .iter() + .any(|prefix| child_name.starts_with(prefix)); + + if !is_platform_dir { + has_non_platform_entries = true; + break; + } + } + + if !has_entries || has_non_platform_entries { + versions.push(version_str); + } + } + + // Sort by semantic version (highest first) + versions.sort_by(|a, b| { + semver::Version::parse(a) + .and_then(|va| semver::Version::parse(b).map(|vb| vb.cmp(&va))) + .unwrap_or(std::cmp::Ordering::Equal) + .reverse() // Highest first + }); + Ok(versions) + } + + /// List all runtimes in the store + pub fn list_store_runtimes(&self) -> Result> { + if !self.paths.store_dir.exists() { + return Ok(Vec::new()); + } + + let mut runtimes = Vec::new(); + for entry in std::fs::read_dir(&self.paths.store_dir)? { + let entry = entry?; + if entry.file_type()?.is_dir() + && let Some(name) = entry.file_name().to_str() + { + runtimes.push(name.to_string()); + } + } + + runtimes.sort(); + Ok(runtimes) + } + + // ========== Environment Paths ========== + + /// Get the directory for a specific environment + /// Returns: ~/.vx/envs/ + pub fn env_dir(&self, env_name: &str) -> PathBuf { + self.paths.envs_dir.join(env_name) + } + + /// Get the default environment directory + /// Returns: ~/.vx/envs/default + pub fn default_env_dir(&self) -> PathBuf { + self.paths.envs_dir.join("default") + } + + /// Get the runtime link path in an environment + /// Returns: `~/.vx/envs//` + pub fn env_runtime_path(&self, env_name: &str, runtime_name: &str) -> PathBuf { + self.env_dir(env_name).join(runtime_name) + } + + /// List all environments + pub fn list_envs(&self) -> Result> { + if !self.paths.envs_dir.exists() { + return Ok(Vec::new()); + } + + let mut envs = Vec::new(); + for entry in std::fs::read_dir(&self.paths.envs_dir)? { + let entry = entry?; + if entry.file_type()?.is_dir() + && let Some(name) = entry.file_name().to_str() + { + envs.push(name.to_string()); + } + } + + envs.sort(); + Ok(envs) + } + + /// Check if an environment exists + pub fn env_exists(&self, env_name: &str) -> bool { + self.env_dir(env_name).exists() + } + + /// Create an environment directory + pub fn create_env(&self, env_name: &str) -> Result { + let env_dir = self.env_dir(env_name); + std::fs::create_dir_all(&env_dir)?; + Ok(env_dir) + } + + /// Remove an environment + pub fn remove_env(&self, env_name: &str) -> Result<()> { + let env_dir = self.env_dir(env_name); + if env_dir.exists() { + std::fs::remove_dir_all(&env_dir)?; + } + Ok(()) + } + + // ========== Cache and Temp Paths ========== + + /// Get cache path for a tool + pub fn tool_cache_dir(&self, tool_name: &str) -> PathBuf { + self.paths.cache_dir.join(tool_name) + } + + /// Get temporary path for a tool installation + pub fn tool_tmp_dir(&self, tool_name: &str, version: &str) -> PathBuf { + self.paths + .tmp_dir + .join(format!("{}-{}", tool_name, version)) + } + + // ========== npm-tools Paths ========== + + /// Get the npm-tools directory + pub fn npm_tools_dir(&self) -> &Path { + &self.paths.npm_tools_dir + } + + /// Get the npm-tools directory for a specific package + /// Returns: `~/.vx/npm-tools/` + pub fn npm_tool_dir(&self, package_name: &str) -> PathBuf { + self.paths.npm_tools_dir.join(package_name) + } + + /// Get the npm-tools directory for a specific package version + /// Returns: `~/.vx/npm-tools//` + pub fn npm_tool_version_dir(&self, package_name: &str, version: &str) -> PathBuf { + self.npm_tool_dir(package_name).join(version) + } + + /// Get the bin directory for an npm tool + /// Returns: `~/.vx/npm-tools///bin` + pub fn npm_tool_bin_dir(&self, package_name: &str, version: &str) -> PathBuf { + self.npm_tool_version_dir(package_name, version).join("bin") + } + + /// List all installed versions of an npm tool + pub fn list_npm_tool_versions(&self, package_name: &str) -> Result> { + let tool_dir = self.npm_tool_dir(package_name); + if !tool_dir.exists() { + return Ok(Vec::new()); + } + + let mut versions = Vec::new(); + for entry in std::fs::read_dir(&tool_dir)? { + let entry = entry?; + if entry.file_type()?.is_dir() + && let Some(version) = entry.file_name().to_str() + { + versions.push(version.to_string()); + } + } + + versions.sort(); + Ok(versions) + } + + // ========== pip-tools Paths ========== + + /// Get the pip-tools directory + pub fn pip_tools_dir(&self) -> &Path { + &self.paths.pip_tools_dir + } + + /// Get the pip-tools directory for a specific package + /// Returns: `~/.vx/pip-tools/` + pub fn pip_tool_dir(&self, package_name: &str) -> PathBuf { + self.paths.pip_tools_dir.join(package_name) + } + + /// Get the pip-tools directory for a specific package version + /// Returns: `~/.vx/pip-tools//` + pub fn pip_tool_version_dir(&self, package_name: &str, version: &str) -> PathBuf { + self.pip_tool_dir(package_name).join(version) + } + + /// Get the venv directory for a pip tool + /// Returns: `~/.vx/pip-tools///venv` + pub fn pip_tool_venv_dir(&self, package_name: &str, version: &str) -> PathBuf { + self.pip_tool_version_dir(package_name, version) + .join("venv") + } + + /// Get the bin directory for a pip tool + /// Returns: `~/.vx/pip-tools///venv/Scripts` (Windows) or `venv/bin` (Unix) + pub fn pip_tool_bin_dir(&self, package_name: &str, version: &str) -> PathBuf { + let venv_dir = self.pip_tool_venv_dir(package_name, version); + if cfg!(windows) { + venv_dir.join("Scripts") + } else { + venv_dir.join("bin") + } + } + + /// List all installed versions of a pip tool + pub fn list_pip_tool_versions(&self, package_name: &str) -> Result> { + let tool_dir = self.pip_tool_dir(package_name); + if !tool_dir.exists() { + return Ok(Vec::new()); + } + + let mut versions = Vec::new(); + for entry in std::fs::read_dir(&tool_dir)? { + let entry = entry?; + if entry.file_type()?.is_dir() + && let Some(version) = entry.file_name().to_str() + { + versions.push(version.to_string()); + } + } + + versions.sort(); + Ok(versions) + } +} + +impl Default for PathManager { + fn default() -> Self { + Self::new().unwrap_or_else(|_| { + Self::with_base_dir(".vx") + .expect("Failed to create PathManager with fallback directory") + }) + } +} diff --git a/crates/vx-paths/src/package_spec.rs b/crates/vx-paths/src/package_spec.rs new file mode 100644 index 000000000..0af464d58 --- /dev/null +++ b/crates/vx-paths/src/package_spec.rs @@ -0,0 +1,636 @@ +//! Package specification parsing (RFC 0025) +//! +//! This module provides utilities for parsing package specifications in various formats: +//! - `npm:typescript@5.3` +//! - `pip:black@24.1` +//! - `cargo:ripgrep@14` +//! - `go:golangci-lint@1.55` +//! - `gem:bundler@2.5` +//! - `typescript@5.3` (auto-detect ecosystem) + +use anyhow::{Result, anyhow}; +use std::fmt; + +/// Parsed package specification +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PackageSpec { + /// Package manager / ecosystem (npm, pip, cargo, go, gem) + pub ecosystem: String, + /// Package name + pub package: String, + /// Version (optional, "latest" if not specified) + pub version: Option, +} + +impl PackageSpec { + /// Create a new PackageSpec + pub fn new(ecosystem: impl Into, package: impl Into) -> Self { + Self { + ecosystem: ecosystem.into(), + package: package.into(), + version: None, + } + } + + /// Set the version + pub fn with_version(mut self, version: impl Into) -> Self { + self.version = Some(version.into()); + self + } + + /// Get version or "latest" + pub fn version_or_latest(&self) -> &str { + self.version.as_deref().unwrap_or("latest") + } + + /// Parse a package specification string + /// + /// Supported formats: + /// - `ecosystem:package@version` (e.g., `npm:typescript@5.3`) + /// - `ecosystem:package` (e.g., `pip:black`) + /// - `package@version` (auto-detect, e.g., `typescript@5.3`) + /// - `package` (auto-detect, e.g., `typescript`) + pub fn parse(spec: &str) -> Result { + let spec = spec.trim(); + + if spec.is_empty() { + return Err(anyhow!("Empty package specification")); + } + + // Check if ecosystem is specified (contains :) + if let Some(colon_pos) = spec.find(':') { + let ecosystem = &spec[..colon_pos]; + let rest = &spec[colon_pos + 1..]; + + if ecosystem.is_empty() { + return Err(anyhow!("Empty ecosystem in specification: {}", spec)); + } + if rest.is_empty() { + return Err(anyhow!("Empty package name in specification: {}", spec)); + } + + // Validate ecosystem + Self::validate_ecosystem(ecosystem)?; + + // Parse package@version + let (package, version) = Self::parse_package_version(rest)?; + + return Ok(Self { + ecosystem: ecosystem.to_lowercase(), + package, + version, + }); + } + + // No ecosystem specified, try to auto-detect + let (package, version) = Self::parse_package_version(spec)?; + let ecosystem = Self::detect_ecosystem(&package)?; + + Ok(Self { + ecosystem, + package, + version, + }) + } + + /// Parse package@version format + /// + /// Handles scoped npm packages like @scope/package@version correctly + fn parse_package_version(s: &str) -> Result<(String, Option)> { + if s.is_empty() { + return Err(anyhow!("Empty package name")); + } + + // Handle scoped npm packages (e.g., @scope/package@version) + // For scoped packages, we need to find @ that comes after / + let version_at_pos = if s.starts_with('@') { + // Scoped package - find @ after the first / + if let Some(slash_pos) = s.find('/') { + // Look for @ after the slash + s[slash_pos..].rfind('@').map(|pos| slash_pos + pos) + } else { + // No slash found, treat as regular package + s.rfind('@') + } + } else { + // Regular package + s.rfind('@') + }; + + if let Some(at_pos) = version_at_pos { + // Make sure we're not just finding the @ at the start of a scoped package + if at_pos == 0 { + // This is a scoped package without version (@scope/package) + return Ok((s.to_string(), None)); + } + + let package = &s[..at_pos]; + let version = &s[at_pos + 1..]; + + if package.is_empty() { + return Err(anyhow!("Empty package name")); + } + if version.is_empty() { + return Err(anyhow!("Empty version after @")); + } + + Ok((package.to_string(), Some(version.to_string()))) + } else { + Ok((s.to_string(), None)) + } + } + + /// Validate ecosystem name + fn validate_ecosystem(ecosystem: &str) -> Result<()> { + let valid = matches!( + ecosystem.to_lowercase().as_str(), + "npm" | "pip" | "cargo" | "go" | "gem" | "yarn" | "pnpm" | "uv" | "uvx" + ); + + if valid { + Ok(()) + } else { + Err(anyhow!( + "Unknown ecosystem '{}'. Valid options: npm, pip, cargo, go, gem", + ecosystem + )) + } + } + + /// Detect ecosystem from package name using common package registry + fn detect_ecosystem(package: &str) -> Result { + // Common npm packages + let npm_packages = [ + // Build tools + "typescript", + "tsc", + "esbuild", + "rollup", + "parcel", + "webpack", + "vite", + "turbo", + "nx", + // Frameworks + "react", + "vue", + "angular", + "next", + "nuxt", + "svelte", + "astro", + "remix", + // Testing + "jest", + "vitest", + "mocha", + "cypress", + "playwright", + // Linting & Formatting + "eslint", + "prettier", + "biome", + // Runtime tools + "nodemon", + "ts-node", + "tsx", + // Video/Media + "remotion", + "ffmpeg-static", + // AI/CLI tools + "@anthropic-ai/claude-code", + "claude-code", + "@openai/codex", + "codex", + // Package managers & tools + "npm", + "yarn", + "pnpm", + "bun", + // Database tools + "prisma", + "drizzle-kit", + // API tools + "openapi-typescript", + "swagger-cli", + // Other popular tools + "zx", + "concurrently", + "npm-run-all", + "cross-env", + "dotenv-cli", + "http-server", + "serve", + "create-react-app", + "create-next-app", + "create-vite", + "@biomejs/biome", + ]; + + // Common pip packages + let pip_packages = [ + "black", + "ruff", + "mypy", + "pytest", + "nox", + "tox", + "pre-commit", + "flask", + "django", + "fastapi", + "uvicorn", + "gunicorn", + "poetry", + "pdm", + "hatch", + "flit", + "pipx", + "jupyter", + "notebook", + "ipython", + ]; + + // Common cargo packages + let cargo_packages = [ + "ripgrep", + "rg", + "fd-find", + "fd", + "bat", + "exa", + "eza", + "tokei", + "hyperfine", + "just", + "cargo-watch", + "cargo-edit", + "cross", + "wasm-pack", + "trunk", + "tauri-cli", + ]; + + // Common go packages + let go_packages = [ + "golangci-lint", + "gofumpt", + "staticcheck", + "dlv", + "gopls", + "cobra-cli", + "mockgen", + "wire", + ]; + + // Common gem packages + let gem_packages = [ + "bundler", + "rails", + "rake", + "rspec", + "rubocop", + "pry", + "solargraph", + "jekyll", + "sass", + "cocoapods", + ]; + + let package_lower = package.to_lowercase(); + + if npm_packages.iter().any(|p| *p == package_lower) { + return Ok("npm".to_string()); + } + if pip_packages.iter().any(|p| *p == package_lower) { + return Ok("pip".to_string()); + } + if cargo_packages.iter().any(|p| *p == package_lower) { + return Ok("cargo".to_string()); + } + if go_packages.iter().any(|p| *p == package_lower) { + return Ok("go".to_string()); + } + if gem_packages.iter().any(|p| *p == package_lower) { + return Ok("gem".to_string()); + } + + // Default to npm for unknown packages (most common case) + // In practice, user should specify ecosystem explicitly + Err(anyhow!( + "Cannot auto-detect ecosystem for '{}'. Please specify explicitly (e.g., npm:{} or pip:{})", + package, + package, + package + )) + } + + /// Normalize ecosystem name to standard form + pub fn normalize_ecosystem(ecosystem: &str) -> String { + match ecosystem.to_lowercase().as_str() { + "npm" | "yarn" | "pnpm" => "npm".to_string(), + "pip" | "uv" | "uvx" => "pip".to_string(), + "cargo" => "cargo".to_string(), + "go" | "golang" => "go".to_string(), + "gem" | "ruby" | "bundle" => "gem".to_string(), + other => other.to_string(), + } + } +} + +impl fmt::Display for PackageSpec { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + if let Some(ref version) = self.version { + write!(f, "{}:{}@{}", self.ecosystem, self.package, version) + } else { + write!(f, "{}:{}", self.ecosystem, self.package) + } + } +} + +impl std::str::FromStr for PackageSpec { + type Err = anyhow::Error; + + fn from_str(s: &str) -> Result { + Self::parse(s) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_full_spec() { + let spec = PackageSpec::parse("npm:typescript@5.3").unwrap(); + assert_eq!(spec.ecosystem, "npm"); + assert_eq!(spec.package, "typescript"); + assert_eq!(spec.version, Some("5.3".to_string())); + } + + #[test] + fn test_parse_without_version() { + let spec = PackageSpec::parse("pip:black").unwrap(); + assert_eq!(spec.ecosystem, "pip"); + assert_eq!(spec.package, "black"); + assert_eq!(spec.version, None); + assert_eq!(spec.version_or_latest(), "latest"); + } + + #[test] + fn test_parse_auto_detect_npm() { + let spec = PackageSpec::parse("typescript@5.3").unwrap(); + assert_eq!(spec.ecosystem, "npm"); + assert_eq!(spec.package, "typescript"); + assert_eq!(spec.version, Some("5.3".to_string())); + } + + #[test] + fn test_parse_auto_detect_pip() { + let spec = PackageSpec::parse("black@24.1").unwrap(); + assert_eq!(spec.ecosystem, "pip"); + assert_eq!(spec.package, "black"); + } + + #[test] + fn test_parse_auto_detect_cargo() { + let spec = PackageSpec::parse("ripgrep@14").unwrap(); + assert_eq!(spec.ecosystem, "cargo"); + assert_eq!(spec.package, "ripgrep"); + } + + #[test] + fn test_parse_unknown_package() { + let result = PackageSpec::parse("unknown-package@1.0"); + assert!(result.is_err()); + } + + #[test] + fn test_parse_invalid_ecosystem() { + let result = PackageSpec::parse("invalid:package@1.0"); + assert!(result.is_err()); + } + + #[test] + fn test_parse_empty_spec() { + let result = PackageSpec::parse(""); + assert!(result.is_err()); + } + + #[test] + fn test_display() { + let spec = PackageSpec::new("npm", "typescript").with_version("5.3"); + assert_eq!(spec.to_string(), "npm:typescript@5.3"); + + let spec_no_version = PackageSpec::new("pip", "black"); + assert_eq!(spec_no_version.to_string(), "pip:black"); + } + + #[test] + fn test_normalize_ecosystem() { + assert_eq!(PackageSpec::normalize_ecosystem("yarn"), "npm"); + assert_eq!(PackageSpec::normalize_ecosystem("pnpm"), "npm"); + assert_eq!(PackageSpec::normalize_ecosystem("uv"), "pip"); + assert_eq!(PackageSpec::normalize_ecosystem("bundle"), "gem"); + } + + #[test] + fn test_from_str() { + let spec: PackageSpec = "cargo:ripgrep@14".parse().unwrap(); + assert_eq!(spec.ecosystem, "cargo"); + assert_eq!(spec.package, "ripgrep"); + } + + // Real-world package tests for global package management (RFC 0025) + + #[test] + fn test_parse_vitest() { + // vitest is a popular npm test runner + let spec = PackageSpec::parse("vitest@1.0.0").unwrap(); + assert_eq!(spec.ecosystem, "npm"); + assert_eq!(spec.package, "vitest"); + assert_eq!(spec.version, Some("1.0.0".to_string())); + + // Also test explicit ecosystem + let spec2 = PackageSpec::parse("npm:vitest@2.0").unwrap(); + assert_eq!(spec2.ecosystem, "npm"); + assert_eq!(spec2.package, "vitest"); + } + + #[test] + fn test_parse_remotion() { + // remotion is a video creation framework for React + let spec = PackageSpec::parse("remotion@4.0").unwrap(); + assert_eq!(spec.ecosystem, "npm"); + assert_eq!(spec.package, "remotion"); + assert_eq!(spec.version, Some("4.0".to_string())); + + // Without version + let spec2 = PackageSpec::parse("npm:remotion").unwrap(); + assert_eq!(spec2.ecosystem, "npm"); + assert_eq!(spec2.package, "remotion"); + assert_eq!(spec2.version, None); + } + + #[test] + fn test_parse_claude_code() { + // @anthropic-ai/claude-code - AI coding assistant CLI + let spec = PackageSpec::parse("npm:@anthropic-ai/claude-code@1.0").unwrap(); + assert_eq!(spec.ecosystem, "npm"); + assert_eq!(spec.package, "@anthropic-ai/claude-code"); + assert_eq!(spec.version, Some("1.0".to_string())); + + // Short form (auto-detect) + let spec2 = PackageSpec::parse("claude-code@0.2.0").unwrap(); + assert_eq!(spec2.ecosystem, "npm"); + assert_eq!(spec2.package, "claude-code"); + } + + #[test] + fn test_parse_codex() { + // @openai/codex - OpenAI Codex CLI + let spec = PackageSpec::parse("npm:@openai/codex@1.0").unwrap(); + assert_eq!(spec.ecosystem, "npm"); + assert_eq!(spec.package, "@openai/codex"); + assert_eq!(spec.version, Some("1.0".to_string())); + + // Short form + let spec2 = PackageSpec::parse("codex").unwrap(); + assert_eq!(spec2.ecosystem, "npm"); + assert_eq!(spec2.package, "codex"); + } + + #[test] + fn test_parse_common_npm_tools() { + // Build tools + let vite = PackageSpec::parse("vite@5.0").unwrap(); + assert_eq!(vite.ecosystem, "npm"); + assert_eq!(vite.package, "vite"); + + let turbo = PackageSpec::parse("turbo").unwrap(); + assert_eq!(turbo.ecosystem, "npm"); + assert_eq!(turbo.package, "turbo"); + + let esbuild = PackageSpec::parse("esbuild@0.20").unwrap(); + assert_eq!(esbuild.ecosystem, "npm"); + + // Frameworks + let next = PackageSpec::parse("next@14").unwrap(); + assert_eq!(next.ecosystem, "npm"); + + let nuxt = PackageSpec::parse("nuxt@3.10").unwrap(); + assert_eq!(nuxt.ecosystem, "npm"); + + // Testing + let playwright = PackageSpec::parse("playwright@1.42").unwrap(); + assert_eq!(playwright.ecosystem, "npm"); + + let cypress = PackageSpec::parse("cypress@13").unwrap(); + assert_eq!(cypress.ecosystem, "npm"); + } + + #[test] + fn test_parse_common_pip_tools() { + // Python linters + let ruff = PackageSpec::parse("ruff@0.3").unwrap(); + assert_eq!(ruff.ecosystem, "pip"); + assert_eq!(ruff.package, "ruff"); + + let black = PackageSpec::parse("black@24.2").unwrap(); + assert_eq!(black.ecosystem, "pip"); + + let mypy = PackageSpec::parse("mypy@1.8").unwrap(); + assert_eq!(mypy.ecosystem, "pip"); + + // Frameworks + let fastapi = PackageSpec::parse("fastapi@0.110").unwrap(); + assert_eq!(fastapi.ecosystem, "pip"); + + let django = PackageSpec::parse("django@5.0").unwrap(); + assert_eq!(django.ecosystem, "pip"); + + // Testing + let pytest = PackageSpec::parse("pytest@8.0").unwrap(); + assert_eq!(pytest.ecosystem, "pip"); + + let nox = PackageSpec::parse("nox").unwrap(); + assert_eq!(nox.ecosystem, "pip"); + } + + #[test] + fn test_parse_common_cargo_tools() { + // CLI tools + let ripgrep = PackageSpec::parse("ripgrep@14").unwrap(); + assert_eq!(ripgrep.ecosystem, "cargo"); + assert_eq!(ripgrep.package, "ripgrep"); + + let fd = PackageSpec::parse("fd-find@9").unwrap(); + assert_eq!(fd.ecosystem, "cargo"); + + let bat = PackageSpec::parse("bat@0.24").unwrap(); + assert_eq!(bat.ecosystem, "cargo"); + + let hyperfine = PackageSpec::parse("hyperfine@1.18").unwrap(); + assert_eq!(hyperfine.ecosystem, "cargo"); + + let just = PackageSpec::parse("just@1.24").unwrap(); + assert_eq!(just.ecosystem, "cargo"); + + // Cargo extensions + let tauri = PackageSpec::parse("tauri-cli@2.0").unwrap(); + assert_eq!(tauri.ecosystem, "cargo"); + } + + #[test] + fn test_parse_common_go_tools() { + let golangci = PackageSpec::parse("golangci-lint@1.56").unwrap(); + assert_eq!(golangci.ecosystem, "go"); + assert_eq!(golangci.package, "golangci-lint"); + + let gopls = PackageSpec::parse("gopls").unwrap(); + assert_eq!(gopls.ecosystem, "go"); + } + + #[test] + fn test_parse_common_gem_tools() { + let bundler = PackageSpec::parse("bundler@2.5").unwrap(); + assert_eq!(bundler.ecosystem, "gem"); + assert_eq!(bundler.package, "bundler"); + + let rails = PackageSpec::parse("rails@7.1").unwrap(); + assert_eq!(rails.ecosystem, "gem"); + + let rubocop = PackageSpec::parse("rubocop@1.60").unwrap(); + assert_eq!(rubocop.ecosystem, "gem"); + } + + #[test] + fn test_parse_scoped_npm_packages() { + // Scoped packages like @org/package + let biome = PackageSpec::parse("npm:@biomejs/biome@1.5").unwrap(); + assert_eq!(biome.ecosystem, "npm"); + assert_eq!(biome.package, "@biomejs/biome"); + assert_eq!(biome.version, Some("1.5".to_string())); + + let claude = PackageSpec::parse("npm:@anthropic-ai/claude-code").unwrap(); + assert_eq!(claude.ecosystem, "npm"); + assert_eq!(claude.package, "@anthropic-ai/claude-code"); + assert_eq!(claude.version, None); + } + + #[test] + fn test_version_constraints() { + // Simple version + let spec1 = PackageSpec::parse("npm:typescript@5.3.3").unwrap(); + assert_eq!(spec1.version, Some("5.3.3".to_string())); + + // Semver range (passed as string) + let spec2 = PackageSpec::parse("npm:typescript@^5.0").unwrap(); + assert_eq!(spec2.version, Some("^5.0".to_string())); + + // Latest + let spec3 = PackageSpec::parse("npm:typescript").unwrap(); + assert_eq!(spec3.version, None); + assert_eq!(spec3.version_or_latest(), "latest"); + } +} diff --git a/crates/vx-paths/src/platform.rs b/crates/vx-paths/src/platform.rs new file mode 100644 index 000000000..38f5f3c4a --- /dev/null +++ b/crates/vx-paths/src/platform.rs @@ -0,0 +1,656 @@ +//! Cross-platform utilities for vx +//! +//! This module provides a unified interface for platform-specific operations, +//! centralizing all platform detection and path handling logic. +//! +//! # Design Principles +//! +//! - **Avoid `Path::new()` for user input**: Use string comparison to prevent +//! issues with invalid path characters (e.g., `:` in Unix paths from Windows-style inputs) +//! - **Compile-time platform detection**: Use `cfg!()` for efficient platform checks +//! - **Safe PATH handling**: Provide utilities that work correctly with `std::env::join_paths` + +use std::ffi::OsString; +use std::path::PathBuf; + +/// Operating system type +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Os { + Windows, + MacOS, + Linux, + Other, +} + +impl Os { + /// Detect the current operating system at runtime + #[inline] + pub fn current() -> Self { + if cfg!(target_os = "windows") { + Os::Windows + } else if cfg!(target_os = "macos") { + Os::MacOS + } else if cfg!(target_os = "linux") { + Os::Linux + } else { + Os::Other + } + } + + /// Check if this is a Unix-like OS (Linux, macOS, etc.) + #[inline] + pub fn is_unix(&self) -> bool { + matches!(self, Os::Linux | Os::MacOS) + } + + /// Check if this is Windows + #[inline] + pub fn is_windows(&self) -> bool { + matches!(self, Os::Windows) + } +} + +/// CPU architecture +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Arch { + X86_64, + Aarch64, + X86, + Arm, + Other, +} + +impl Arch { + /// Detect the current architecture at runtime + #[inline] + pub fn current() -> Self { + if cfg!(target_arch = "x86_64") { + Arch::X86_64 + } else if cfg!(target_arch = "aarch64") { + Arch::Aarch64 + } else if cfg!(target_arch = "x86") { + Arch::X86 + } else if cfg!(target_arch = "arm") { + Arch::Arm + } else { + Arch::Other + } + } +} + +/// Platform information combining OS and architecture +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Platform { + pub os: Os, + pub arch: Arch, +} + +impl Platform { + /// Get the current platform + #[inline] + pub fn current() -> Self { + Self { + os: Os::current(), + arch: Arch::current(), + } + } + + /// Get the PATH environment variable separator + /// + /// - Windows: `;` + /// - Unix (Linux, macOS): `:` + #[inline] + pub fn path_separator(&self) -> char { + if self.os.is_windows() { ';' } else { ':' } + } + + /// Get the executable file extension + /// + /// - Windows: `.exe` + /// - Unix: `` (empty string) + #[inline] + pub fn executable_extension(&self) -> &'static str { + if self.os.is_windows() { ".exe" } else { "" } + } + + /// Get the Python venv bin directory name + /// + /// - Windows: `Scripts` + /// - Unix: `bin` + #[inline] + pub fn venv_bin_dir(&self) -> &'static str { + if self.os.is_windows() { + "Scripts" + } else { + "bin" + } + } + + /// Get the platform string used in download URLs and directory names + /// + /// Returns strings like "windows-x64", "darwin-arm64", "linux-x64" + pub fn as_str(&self) -> &'static str { + match (self.os, self.arch) { + (Os::Windows, Arch::X86_64) => "windows-x64", + (Os::Windows, Arch::X86) => "windows-x86", + (Os::Windows, Arch::Aarch64) => "windows-arm64", + (Os::MacOS, Arch::X86_64) => "darwin-x64", + (Os::MacOS, Arch::Aarch64) => "darwin-arm64", + (Os::Linux, Arch::X86_64) => "linux-x64", + (Os::Linux, Arch::Aarch64) => "linux-arm64", + (Os::Linux, Arch::Arm) => "linux-arm", + _ => "unknown", + } + } +} + +impl Default for Platform { + fn default() -> Self { + Self::current() + } +} + +// ============================================================================= +// PATH Utilities +// ============================================================================= + +/// Get the current platform's PATH separator +/// +/// This is a convenience function for compile-time platform detection. +#[inline] +pub fn path_separator() -> char { + if cfg!(windows) { ';' } else { ':' } +} + +/// Split a PATH string into individual entries +/// +/// Uses the correct separator for the current platform. +/// +/// # Example +/// ``` +/// use vx_paths::platform::split_path; +/// +/// // On Unix: "/usr/bin:/bin" -> ["/usr/bin", "/bin"] +/// // On Windows: "C:\\Windows;C:\\Users" -> ["C:\\Windows", "C:\\Users"] +/// let paths: Vec<&str> = split_path("/usr/bin:/bin").collect(); +/// ``` +#[inline] +pub fn split_path(path: &str) -> impl Iterator { + path.split(path_separator()).filter(|s| !s.is_empty()) +} + +/// Split a PATH string into owned strings +/// +/// This is useful when you need to collect and store the results. +pub fn split_path_owned(path: &str) -> Vec { + split_path(path).map(|s| s.to_string()).collect() +} + +/// Join multiple paths into a single PATH string +/// +/// Uses the correct separator for the current platform. +/// +/// # Example +/// ``` +/// use vx_paths::platform::join_paths_simple; +/// +/// let paths = vec!["/usr/bin", "/bin"]; +/// let result = join_paths_simple(&paths); +/// // On Unix: "/usr/bin:/bin" +/// // On Windows: "/usr/bin;/bin" +/// ``` +pub fn join_paths_simple>(paths: &[S]) -> String { + paths + .iter() + .map(|s| s.as_ref()) + .collect::>() + .join(&path_separator().to_string()) +} + +/// Join multiple paths using `std::env::join_paths` +/// +/// This is the safe way to create PATH strings that will work correctly +/// with `std::env::set_var`. Unlike `join_paths_simple`, this properly +/// handles paths containing special characters. +/// +/// # Errors +/// +/// Returns an error if any path contains the PATH separator character, +/// which would be invalid. +/// +/// # Example +/// ``` +/// use vx_paths::platform::join_paths_env; +/// +/// let paths = vec!["/usr/bin", "/bin"]; +/// let result = join_paths_env(&paths).unwrap(); +/// ``` +pub fn join_paths_env>(paths: &[S]) -> Result { + let path_bufs: Vec = paths.iter().map(|s| PathBuf::from(s.as_ref())).collect(); + std::env::join_paths(path_bufs) +} + +/// Prepend entries to an existing PATH string +/// +/// # Example +/// ``` +/// use vx_paths::platform::prepend_to_path; +/// +/// let original = "/usr/bin:/bin"; +/// let new_entries = vec!["/my/custom/bin"]; +/// let result = prepend_to_path(original, &new_entries); +/// // Result: "/my/custom/bin:/usr/bin:/bin" +/// ``` +pub fn prepend_to_path>(original: &str, entries: &[S]) -> String { + let mut parts: Vec = entries.iter().map(|s| s.as_ref().to_string()).collect(); + parts.extend(split_path_owned(original)); + join_paths_simple(&parts) +} + +/// Get the platform directory name (e.g., "windows-x64", "darwin-arm64", "linux-x64") +/// +/// This is used for platform-specific subdirectories in the vx store. +pub fn platform_dir_name() -> &'static str { + Platform::current().as_str() +} + +/// Append entries to an existing PATH string +/// +/// # Example +/// ``` +/// use vx_paths::platform::append_to_path; +/// +/// let original = "/usr/bin:/bin"; +/// let new_entries = vec!["/my/custom/bin"]; +/// let result = append_to_path(original, &new_entries); +/// // Result: "/usr/bin:/bin:/my/custom/bin" +/// ``` +pub fn append_to_path>(original: &str, entries: &[S]) -> String { + let mut parts: Vec = split_path_owned(original); + parts.extend(entries.iter().map(|s| s.as_ref().to_string())); + join_paths_simple(&parts) +} + +// ============================================================================= +// Path String Utilities (avoiding Path::new for untrusted input) +// ============================================================================= + +/// Check if a path string looks like a Windows path +/// +/// Uses string analysis only, avoiding `Path::new()` which can fail +/// on Unix when processing Windows-style paths. +#[inline] +pub fn is_windows_path(path: &str) -> bool { + // Check for drive letter (C:, D:, etc.) or backslashes + (path.len() >= 2 && path.chars().nth(1) == Some(':')) + || path.contains('\\') + || path.starts_with("\\\\") // UNC path +} + +/// Check if a path string looks like a Unix path +/// +/// Uses string analysis only. +#[inline] +pub fn is_unix_path(path: &str) -> bool { + path.starts_with('/') +} + +/// Check if a path string matches the current platform's format +#[inline] +pub fn is_native_path(path: &str) -> bool { + if cfg!(windows) { + is_windows_path(path) + } else { + is_unix_path(path) + } +} + +/// Normalize a path string for case-sensitive or case-insensitive comparison +/// +/// - Windows/macOS: lowercase +/// - Linux: unchanged +pub fn normalize_for_comparison(path: &str) -> String { + if cfg!(any(target_os = "windows", target_os = "macos")) { + path.to_lowercase() + } else { + path.to_string() + } +} + +// ============================================================================= +// Executable Utilities +// ============================================================================= + +/// Get the executable extension for the current platform +/// +/// Convenience function using compile-time detection. +#[inline] +pub fn executable_extension() -> &'static str { + if cfg!(target_os = "windows") { + ".exe" + } else { + "" + } +} + +/// Add executable extension to a name if needed +/// +/// # Example +/// ``` +/// use vx_paths::platform::with_executable_extension; +/// +/// let name = with_executable_extension("node"); +/// // Windows: "node.exe" +/// // Unix: "node" +/// ``` +pub fn with_executable_extension(name: &str) -> String { + // Don't add extension if already present + if cfg!(windows) + && !name.ends_with(".exe") + && !name.ends_with(".cmd") + && !name.ends_with(".bat") + { + format!("{}.exe", name) + } else { + name.to_string() + } +} + +/// Get the venv bin directory name for the current platform +#[inline] +pub fn venv_bin_dir() -> &'static str { + if cfg!(windows) { "Scripts" } else { "bin" } +} + +// ============================================================================= +// System Path Detection +// ============================================================================= + +/// System PATH prefixes that should be inherited in isolated mode. +/// +/// These directories contain essential system tools (sh, bash, cat, etc.) +/// that child processes may need. +pub const SYSTEM_PATH_PREFIXES: &[&str] = &[ + // Unix essential directories + "/bin", + "/usr/bin", + "/usr/local/bin", + "/sbin", + "/usr/sbin", + "/usr/local/sbin", + // macOS Homebrew (Apple Silicon) + "/opt/homebrew/bin", + "/opt/homebrew/sbin", + // macOS Homebrew (Intel) + "/usr/local/Cellar", + // Nix + "/nix/var/nix/profiles/default/bin", + "/run/current-system/sw/bin", + // Windows (case-insensitive matching will be used) + "C:\\Windows\\System32", + "C:\\Windows\\SysWOW64", + "C:\\Windows", + "C:\\Windows\\System32\\Wbem", + "C:\\Windows\\System32\\WindowsPowerShell", + "C:\\Windows\\System32\\OpenSSH", +]; + +/// Check if a path is a system path that should be inherited +/// +/// Uses string comparison only, avoiding `Path::new()` to prevent issues +/// with invalid path characters on different platforms. +pub fn is_system_path(path_str: &str) -> bool { + // Normalize for comparison + let normalized = if cfg!(windows) { + path_str.to_lowercase() + } else { + path_str.to_string() + }; + + for prefix in SYSTEM_PATH_PREFIXES { + // Skip prefixes that don't match the current platform + let is_windows_prefix = is_windows_path(prefix); + let is_unix_prefix = is_unix_path(prefix); + + if cfg!(windows) { + if !is_windows_prefix { + continue; + } + // Case-insensitive comparison on Windows + if normalized.starts_with(&prefix.to_lowercase()) { + return true; + } + } else { + if !is_unix_prefix { + continue; + } + // Case-sensitive on Unix + if path_str.starts_with(prefix) || path_str == *prefix { + return true; + } + } + } + + false +} + +/// Filter a PATH string to only include system directories +/// +/// This is used in isolated mode to allow access to essential system tools +/// while excluding user-specific directories. +/// +/// # Example +/// ``` +/// use vx_paths::platform::filter_system_path; +/// +/// let full_path = "/home/user/.local/bin:/usr/local/bin:/usr/bin:/bin"; +/// let filtered = filter_system_path(full_path); +/// // Result: "/usr/local/bin:/usr/bin:/bin" +/// ``` +pub fn filter_system_path(path: &str) -> String { + let filtered: Vec<&str> = split_path(path) + .filter(|entry| is_system_path(entry)) + .collect(); + + join_paths_simple(&filtered) +} + +// ============================================================================= +// Tests +// ============================================================================= + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_path_separator() { + let sep = path_separator(); + if cfg!(windows) { + assert_eq!(sep, ';'); + } else { + assert_eq!(sep, ':'); + } + } + + #[test] + fn test_split_path() { + if cfg!(windows) { + let parts: Vec<_> = split_path("C:\\bin;D:\\tools").collect(); + assert_eq!(parts, vec!["C:\\bin", "D:\\tools"]); + } else { + let parts: Vec<_> = split_path("/usr/bin:/bin").collect(); + assert_eq!(parts, vec!["/usr/bin", "/bin"]); + } + } + + #[test] + fn test_split_path_empty_entries() { + if cfg!(windows) { + let parts: Vec<_> = split_path("C:\\bin;;D:\\tools;").collect(); + assert_eq!(parts, vec!["C:\\bin", "D:\\tools"]); + } else { + let parts: Vec<_> = split_path("/usr/bin::/bin:").collect(); + assert_eq!(parts, vec!["/usr/bin", "/bin"]); + } + } + + #[test] + fn test_join_paths_simple() { + let paths = vec!["/usr/bin", "/bin"]; + let result = join_paths_simple(&paths); + if cfg!(windows) { + assert_eq!(result, "/usr/bin;/bin"); + } else { + assert_eq!(result, "/usr/bin:/bin"); + } + } + + #[test] + fn test_is_windows_path() { + assert!(is_windows_path("C:\\Windows")); + assert!(is_windows_path("D:\\Program Files")); + assert!(is_windows_path("\\\\server\\share")); + assert!(is_windows_path("path\\with\\backslash")); + assert!(!is_windows_path("/usr/bin")); + assert!(!is_windows_path("/home/user")); + } + + #[test] + fn test_is_unix_path() { + assert!(is_unix_path("/usr/bin")); + assert!(is_unix_path("/home/user")); + assert!(is_unix_path("/")); + assert!(!is_unix_path("C:\\Windows")); + assert!(!is_unix_path("relative/path")); + } + + #[test] + fn test_is_system_path_unix() { + // These should match on Unix, not on Windows + if !cfg!(windows) { + assert!(is_system_path("/usr/bin")); + assert!(is_system_path("/bin")); + assert!(is_system_path("/usr/local/bin")); + assert!(is_system_path("/opt/homebrew/bin")); + assert!(!is_system_path("/home/user/.local/bin")); + assert!(!is_system_path("/custom/path")); + } + } + + #[test] + fn test_is_system_path_windows() { + // These should match on Windows, not on Unix + if cfg!(windows) { + assert!(is_system_path("C:\\Windows\\System32")); + assert!(is_system_path("c:\\windows\\system32")); // Case-insensitive + assert!(is_system_path("C:\\Windows")); + assert!(!is_system_path("C:\\Users\\test")); + assert!(!is_system_path("D:\\custom\\path")); + } + } + + #[test] + fn test_filter_system_path_unix() { + if !cfg!(windows) { + let path = "/home/user/.local/bin:/usr/local/bin:/usr/bin:/bin:/custom/path"; + let filtered = filter_system_path(path); + assert_eq!(filtered, "/usr/local/bin:/usr/bin:/bin"); + } + } + + #[test] + fn test_filter_system_path_windows() { + if cfg!(windows) { + let path = "C:\\Users\\test;C:\\Windows\\System32;C:\\Windows;D:\\custom"; + let filtered = filter_system_path(path); + assert_eq!(filtered, "C:\\Windows\\System32;C:\\Windows"); + } + } + + #[test] + fn test_executable_extension() { + let ext = executable_extension(); + if cfg!(windows) { + assert_eq!(ext, ".exe"); + } else { + assert_eq!(ext, ""); + } + } + + #[test] + fn test_with_executable_extension() { + let name = with_executable_extension("node"); + if cfg!(windows) { + assert_eq!(name, "node.exe"); + } else { + assert_eq!(name, "node"); + } + + // Should not double-add extension + if cfg!(windows) { + assert_eq!(with_executable_extension("node.exe"), "node.exe"); + assert_eq!(with_executable_extension("script.cmd"), "script.cmd"); + } + } + + #[test] + fn test_venv_bin_dir() { + let dir = venv_bin_dir(); + if cfg!(windows) { + assert_eq!(dir, "Scripts"); + } else { + assert_eq!(dir, "bin"); + } + } + + #[test] + fn test_prepend_to_path() { + // Use platform-appropriate separator in input + let (original, expected) = if cfg!(windows) { + ("/usr/bin;/bin", "/custom/bin;/usr/bin;/bin") + } else { + ("/usr/bin:/bin", "/custom/bin:/usr/bin:/bin") + }; + let result = prepend_to_path(original, &["/custom/bin"]); + assert_eq!(result, expected); + } + + #[test] + fn test_append_to_path() { + // Use platform-appropriate separator in input + let (original, expected) = if cfg!(windows) { + ("/usr/bin;/bin", "/usr/bin;/bin;/custom/bin") + } else { + ("/usr/bin:/bin", "/usr/bin:/bin:/custom/bin") + }; + let result = append_to_path(original, &["/custom/bin"]); + assert_eq!(result, expected); + } + + #[test] + fn test_platform_current() { + let platform = Platform::current(); + + // Just verify it returns something sensible + assert!(matches!( + platform.os, + Os::Windows | Os::MacOS | Os::Linux | Os::Other + )); + assert!(matches!( + platform.arch, + Arch::X86_64 | Arch::Aarch64 | Arch::X86 | Arch::Arm | Arch::Other + )); + } + + #[test] + fn test_platform_as_str() { + let platform = Platform::current(); + let s = platform.as_str(); + assert!(!s.is_empty()); + // Should contain a dash separator + if s != "unknown" { + assert!(s.contains('-')); + } + } +} diff --git a/crates/vx-paths/src/project.rs b/crates/vx-paths/src/project.rs new file mode 100644 index 000000000..59fa352e6 --- /dev/null +++ b/crates/vx-paths/src/project.rs @@ -0,0 +1,291 @@ +//! Project configuration file discovery +//! +//! This module provides utilities for finding vx configuration files +//! in project directories, and defines all project-related path constants. + +use std::path::{Path, PathBuf}; + +// ============================================ +// Configuration File Constants +// ============================================ + +/// Primary vx configuration file name (preferred) +pub const CONFIG_FILE_NAME: &str = "vx.toml"; + +/// Legacy vx configuration file name (for backward compatibility) +pub const CONFIG_FILE_NAME_LEGACY: &str = "vx.toml"; + +/// Standard vx configuration file names in order of preference +pub const CONFIG_NAMES: &[&str] = &[CONFIG_FILE_NAME, CONFIG_FILE_NAME_LEGACY]; + +// ============================================ +// Project Directory Constants +// ============================================ + +/// Project-local vx directory name +pub const PROJECT_VX_DIR: &str = ".vx"; + +/// Project environment directory (relative to project root) +pub const PROJECT_ENV_DIR: &str = ".vx/env"; + +/// Project cache directory (relative to project root) +pub const PROJECT_CACHE_DIR: &str = ".vx/cache"; + +/// Project bin directory (relative to project root) +pub const PROJECT_BIN_DIR: &str = ".vx/bin"; + +// ============================================ +// Lock File Constants +// ============================================ + +/// Lock file name +pub const LOCK_FILE_NAME: &str = "vx.lock"; + +/// Legacy lock file name +pub const LOCK_FILE_NAME_LEGACY: &str = ".vx.lock"; + +/// Lock file names in order of preference +pub const LOCK_FILE_NAMES: &[&str] = &[LOCK_FILE_NAME, LOCK_FILE_NAME_LEGACY]; + +// ============================================ +// Functions +// ============================================ + +/// Find vx config file in a directory +/// +/// Searches for config files in order of preference: `vx.toml`, `vx.toml` +/// +/// # Example +/// ``` +/// use std::path::Path; +/// use vx_paths::project::find_config_file; +/// +/// let config = find_config_file(Path::new(".")); +/// if let Some(path) = config { +/// println!("Found config at: {}", path.display()); +/// } +/// ``` +pub fn find_config_file(dir: &Path) -> Option { + for name in CONFIG_NAMES { + let path = dir.join(name); + if path.exists() { + return Some(path); + } + } + None +} + +/// Find vx config file by searching up the directory tree +/// +/// Starts from the given directory and searches parent directories +/// until a config file is found or the root is reached. +/// +/// # Example +/// ``` +/// use std::path::Path; +/// use vx_paths::project::find_config_file_upward; +/// +/// let config = find_config_file_upward(Path::new(".")); +/// if let Some(path) = config { +/// println!("Found config at: {}", path.display()); +/// } +/// ``` +pub fn find_config_file_upward(start_dir: &Path) -> Option { + let mut current = start_dir.to_path_buf(); + + loop { + if let Some(config) = find_config_file(¤t) { + return Some(config); + } + + if !current.pop() { + return None; + } + } +} + +/// Get the project root directory (directory containing vx.toml) +/// +/// Searches upward from the given directory. +pub fn find_project_root(start_dir: &Path) -> Option { + find_config_file_upward(start_dir).map(|config| { + config + .parent() + .map(|p| p.to_path_buf()) + .unwrap_or_else(|| start_dir.to_path_buf()) + }) +} + +/// Find vx config file with environment variable support +/// +/// This is the recommended function for finding vx configuration files. +/// It respects the `VX_PROJECT_ROOT` environment variable for CI and test environments. +/// +/// Behavior: +/// - If `VX_PROJECT_ROOT` is set: only search in the specified directory (no upward search) +/// - Otherwise: search upward from `start_dir` to find the config file +/// +/// # Arguments +/// * `start_dir` - The directory to start searching from +/// +/// # Returns +/// * `Ok(PathBuf)` - Path to the found config file +/// * `Err` - If no config file is found +/// +/// # Example +/// ```rust,no_run +/// use std::path::Path; +/// use vx_paths::find_vx_config; +/// +/// let config_path = find_vx_config(Path::new(".")).expect("No vx.toml found"); +/// println!("Config at: {}", config_path.display()); +/// ``` +pub fn find_vx_config(start_dir: &Path) -> Result { + // If VX_PROJECT_ROOT is set, only check the current directory + if std::env::var("VX_PROJECT_ROOT").is_ok() { + return find_config_file(start_dir).ok_or_else(|| ConfigNotFoundError { + search_dir: start_dir.to_path_buf(), + upward_search: false, + }); + } + + // Normal mode: search up the directory tree + find_config_file_upward(start_dir).ok_or_else(|| ConfigNotFoundError { + search_dir: start_dir.to_path_buf(), + upward_search: true, + }) +} + +/// Error returned when no vx configuration file is found +#[derive(Debug, Clone)] +pub struct ConfigNotFoundError { + /// The directory where the search started + pub search_dir: PathBuf, + /// Whether upward search was performed + pub upward_search: bool, +} + +impl std::fmt::Display for ConfigNotFoundError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + if self.upward_search { + write!( + f, + "No {} found in '{}' or parent directories.\nRun 'vx init' to create one.", + CONFIG_FILE_NAME, + self.search_dir.display() + ) + } else { + write!( + f, + "No {} found in '{}'.\nRun 'vx init' to create one.", + CONFIG_FILE_NAME, + self.search_dir.display() + ) + } + } +} + +impl std::error::Error for ConfigNotFoundError {} + +/// Get the project environment directory path +/// +/// Returns the `.vx/env` directory path for a project root. +pub fn project_env_dir(project_root: &Path) -> PathBuf { + project_root.join(PROJECT_ENV_DIR) +} + +/// Check if the current directory is inside a vx project +pub fn is_in_vx_project(dir: &Path) -> bool { + find_config_file_upward(dir).is_some() +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use tempfile::tempdir; + + #[test] + fn test_find_config_file_vx_toml() { + let dir = tempdir().unwrap(); + let config_path = dir.path().join("vx.toml"); + fs::write(&config_path, "[runtimes]").unwrap(); + + let found = find_config_file(dir.path()); + assert_eq!(found, Some(config_path)); + } + + #[test] + fn test_find_config_file_dot_vx_toml() { + let dir = tempdir().unwrap(); + let config_path = dir.path().join("vx.toml"); + fs::write(&config_path, "[runtimes]").unwrap(); + + let found = find_config_file(dir.path()); + assert_eq!(found, Some(config_path)); + } + + #[test] + fn test_find_config_file_prefers_vx_toml() { + let dir = tempdir().unwrap(); + let vx_toml = dir.path().join("vx.toml"); + let dot_vx_toml = dir.path().join("vx.toml"); + fs::write(&vx_toml, "[runtimes]").unwrap(); + fs::write(&dot_vx_toml, "[runtimes]").unwrap(); + + let found = find_config_file(dir.path()); + assert_eq!(found, Some(vx_toml)); + } + + #[test] + fn test_find_config_file_not_found() { + let dir = tempdir().unwrap(); + let found = find_config_file(dir.path()); + assert_eq!(found, None); + } + + #[test] + fn test_find_config_file_upward() { + let dir = tempdir().unwrap(); + let config_path = dir.path().join("vx.toml"); + fs::write(&config_path, "[runtimes]").unwrap(); + + let subdir = dir.path().join("src").join("nested"); + fs::create_dir_all(&subdir).unwrap(); + + let found = find_config_file_upward(&subdir); + assert_eq!(found, Some(config_path)); + } + + #[test] + fn test_find_project_root() { + let dir = tempdir().unwrap(); + let config_path = dir.path().join("vx.toml"); + fs::write(&config_path, "[runtimes]").unwrap(); + + let subdir = dir.path().join("src"); + fs::create_dir_all(&subdir).unwrap(); + + let root = find_project_root(&subdir); + assert_eq!(root, Some(dir.path().to_path_buf())); + } + + #[test] + fn test_project_env_dir() { + let project_root = PathBuf::from("/project"); + let env_dir = project_env_dir(&project_root); + assert_eq!(env_dir, PathBuf::from("/project/.vx/env")); + } + + #[test] + fn test_is_in_vx_project() { + let dir = tempdir().unwrap(); + // Use find_config_file for direct check (not upward search) + assert!(find_config_file(dir.path()).is_none()); + + let config_path = dir.path().join("vx.toml"); + fs::write(&config_path, "[runtimes]").unwrap(); + assert!(find_config_file(dir.path()).is_some()); + assert!(is_in_vx_project(dir.path())); + } +} diff --git a/crates/vx-paths/src/resolver.rs b/crates/vx-paths/src/resolver.rs new file mode 100644 index 000000000..6f50e5a2d --- /dev/null +++ b/crates/vx-paths/src/resolver.rs @@ -0,0 +1,947 @@ +//! Path resolver for finding and resolving tool paths +//! +//! This module provides a unified interface for finding tool executables +//! across all vx-managed directories (store, npm-tools, pip-tools). + +use crate::PathManager; +use anyhow::Result; +use std::path::{Path, PathBuf}; +use std::sync::Mutex; +use vx_cache::ExecPathCache; + +/// Result of finding a tool in vx-managed directories +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ToolLocation { + /// The path to the executable + pub path: PathBuf, + /// The version of the tool + pub version: String, + /// The source of the tool (store, npm-tools, pip-tools) + pub source: ToolSource, +} + +/// Source of a tool installation +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ToolSource { + /// Tool installed in ~/.vx/store + Store, + /// Tool installed in ~/.vx/npm-tools + NpmTools, + /// Tool installed in ~/.vx/pip-tools + PipTools, +} + +impl std::fmt::Display for ToolSource { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ToolSource::Store => write!(f, "store"), + ToolSource::NpmTools => write!(f, "npm-tools"), + ToolSource::PipTools => write!(f, "pip-tools"), + } + } +} + +/// Resolves tool paths and finds installed tools +#[derive(Debug)] +pub struct PathResolver { + manager: PathManager, + /// Executable path cache (bincode-serialized) + exec_cache: Mutex, + /// Cache directory for persisting cache + cache_dir: Option, +} + +impl PathResolver { + /// Create a new PathResolver + pub fn new(manager: PathManager) -> Self { + Self { + manager, + exec_cache: Mutex::new(ExecPathCache::new()), + cache_dir: None, + } + } + + /// Create a PathResolver with default paths + pub fn default_paths() -> Result { + let manager = PathManager::new()?; + let cache_dir = manager.cache_dir().to_path_buf(); + let exec_cache = ExecPathCache::load(&cache_dir); + Ok(Self { + manager, + exec_cache: Mutex::new(exec_cache), + cache_dir: Some(cache_dir), + }) + } + + /// Create a PathResolver with explicit cache directory + pub fn with_cache_dir(manager: PathManager, cache_dir: PathBuf) -> Self { + let exec_cache = ExecPathCache::load(&cache_dir); + Self { + manager, + exec_cache: Mutex::new(exec_cache), + cache_dir: Some(cache_dir), + } + } + + /// Persist the exec path cache to disk. + /// + /// This should be called after operations that modified the cache (e.g., at the + /// end of a command execution, or after install/uninstall). + pub fn save_cache(&self) { + if let Some(ref cache_dir) = self.cache_dir { + let cache = self.exec_cache.lock().expect("exec_cache lock poisoned"); + if let Err(e) = cache.save(cache_dir) { + tracing::debug!("Failed to save exec path cache: {}", e); + } + } + } + + /// Invalidate cache entries for a specific runtime. + /// + /// Call this after installing or uninstalling a runtime version. + pub fn invalidate_runtime_cache(&self, runtime_name: &str) { + let runtime_store_dir = self.manager.runtime_store_dir(runtime_name); + let mut cache = self.exec_cache.lock().expect("exec_cache lock poisoned"); + cache.invalidate_runtime(&runtime_store_dir); + // Persist immediately after invalidation + if let Some(ref cache_dir) = self.cache_dir + && let Err(e) = cache.save(cache_dir) + { + tracing::debug!("Failed to save exec path cache after invalidation: {}", e); + } + } + + /// Clear the entire exec path cache. + pub fn clear_exec_cache(&self) { + let mut cache = self.exec_cache.lock().expect("exec_cache lock poisoned"); + cache.clear(); + if let Some(ref cache_dir) = self.cache_dir { + let _ = ExecPathCache::remove_file(cache_dir); + } + } + + /// Get the path manager + pub fn manager(&self) -> &PathManager { + &self.manager + } + + // ========== Unified Tool Finding API ========== + + /// Find a tool in any vx-managed directory + /// Returns the first found location with version info + pub fn find_tool(&self, tool_name: &str) -> Result> { + // Check store directory first + if let Some(loc) = self.find_in_store(tool_name)? { + return Ok(Some(loc)); + } + + // Check npm-tools directory + if let Some(loc) = self.find_in_npm_tools(tool_name)? { + return Ok(Some(loc)); + } + + // Check pip-tools directory + if let Some(loc) = self.find_in_pip_tools(tool_name)? { + return Ok(Some(loc)); + } + + Ok(None) + } + + /// Find a tool in any vx-managed directory using a specific executable name + /// This is useful for runtimes whose store directory differs from the executable name (e.g., msvc -> cl.exe) + pub fn find_tool_with_executable( + &self, + tool_name: &str, + exe_name: &str, + ) -> Result> { + // Check store directory first + if let Some(loc) = self.find_in_store_with_exe(tool_name, exe_name)? { + return Ok(Some(loc)); + } + + // Check npm-tools directory + if let Some(loc) = self.find_in_npm_tools(tool_name)? { + return Ok(Some(loc)); + } + + // Check pip-tools directory + if let Some(loc) = self.find_in_pip_tools(tool_name)? { + return Ok(Some(loc)); + } + + Ok(None) + } + + /// Find all installations of a tool across all directories + pub fn find_all_tool_installations(&self, tool_name: &str) -> Result> { + self.find_all_tool_installations_with_exe(tool_name, tool_name) + } + + /// Find all installations of a tool across all directories with a specific executable name + /// + /// # Arguments + /// * `tool_name` - The runtime/tool name (used for directory lookup) + /// * `exe_name` - The executable name to search for + pub fn find_all_tool_installations_with_exe( + &self, + tool_name: &str, + exe_name: &str, + ) -> Result> { + let mut locations = Vec::new(); + + // Collect from store + locations.extend(self.find_all_in_store_with_exe(tool_name, exe_name)?); + + // Collect from npm-tools + locations.extend(self.find_all_in_npm_tools(tool_name)?); + + // Collect from pip-tools + locations.extend(self.find_all_in_pip_tools(tool_name)?); + + Ok(locations) + } + + /// Find the latest version of a tool + pub fn find_latest_tool(&self, tool_name: &str) -> Result> { + let all = self.find_all_tool_installations(tool_name)?; + // Return the last one (highest version due to sorting) + Ok(all.into_iter().last()) + } + + /// Find a specific version of a tool + pub fn find_tool_version(&self, tool_name: &str, version: &str) -> Option { + self.find_tool_version_with_executable(tool_name, version, tool_name) + } + + /// Find a specific version of a tool with a specific executable name + /// + /// # Arguments + /// * `tool_name` - The runtime/tool name (used for directory lookup) + /// * `version` - The version to find + /// * `exe_name` - The executable name to search for + pub fn find_tool_version_with_executable( + &self, + tool_name: &str, + version: &str, + exe_name: &str, + ) -> Option { + // New layout: try version_store_dir first (no platform subdirectory). + // Old layout: fall back to platform_store_dir for old installations. + let version_store_dir = self.manager.version_store_dir(tool_name, version); + if let Some(path) = self.find_executable_in_dir(&version_store_dir, exe_name) { + return Some(ToolLocation { + path, + version: version.to_string(), + source: ToolSource::Store, + }); + } + let platform_store_dir = self.manager.platform_store_dir(tool_name, version); + if let Some(path) = self.find_executable_in_dir(&platform_store_dir, exe_name) { + return Some(ToolLocation { + path, + version: version.to_string(), + source: ToolSource::Store, + }); + } + + // Check npm-tools + let npm_bin = self.manager.npm_tool_bin_dir(tool_name, version); + if let Some(path) = self.find_npm_executable(&npm_bin, tool_name) { + return Some(ToolLocation { + path, + version: version.to_string(), + source: ToolSource::NpmTools, + }); + } + + // Check pip-tools + let pip_bin = self.manager.pip_tool_bin_dir(tool_name, version); + if let Some(path) = self.find_pip_executable(&pip_bin, tool_name) { + return Some(ToolLocation { + path, + version: version.to_string(), + source: ToolSource::PipTools, + }); + } + + None + } + + /// Check if a tool is installed (any version, any source) + pub fn is_tool_installed(&self, tool_name: &str) -> Result { + Ok(self.find_tool(tool_name)?.is_some()) + } + + // ========== Store Directory Methods ========== + + /// Find a tool in the store directory + /// + /// # Arguments + /// * `tool_name` - The runtime/tool name (used for directory lookup) + /// * `exe_name` - Optional executable name to search for (defaults to tool_name) + pub fn find_in_store(&self, tool_name: &str) -> Result> { + self.find_in_store_with_exe(tool_name, tool_name) + } + + /// Find a tool in the store directory with a specific executable name + /// + /// This method uses the new directory structure: + /// - New (post-platform-redirection): `///` + /// - Fallback: `//` (for cross-platform tools like vcpkg) + /// + /// # Arguments + /// * `tool_name` - The runtime/tool name (used for directory lookup) + /// * `exe_name` - The executable name to search for + pub fn find_in_store_with_exe( + &self, + tool_name: &str, + exe_name: &str, + ) -> Result> { + let versions = self.manager.list_store_versions(tool_name)?; + // Return the latest version (last after sort) + for version in versions.iter().rev() { + // New layout: try version_store_dir first (no platform subdirectory). + let version_dir = self.manager.version_store_dir(tool_name, version); + if let Some(path) = self.find_executable_in_dir(&version_dir, exe_name) { + return Ok(Some(ToolLocation { + path, + version: version.clone(), + source: ToolSource::Store, + })); + } + + // Old layout fallback: try platform-specific directory. + let platform_dir = self.manager.platform_store_dir(tool_name, version); + if version_dir != platform_dir + && let Some(path) = self.find_executable_in_dir(&platform_dir, exe_name) + { + return Ok(Some(ToolLocation { + path, + version: version.clone(), + source: ToolSource::Store, + })); + } + } + Ok(None) + } + + /// Find all versions of a tool in the store directory + pub fn find_all_in_store(&self, tool_name: &str) -> Result> { + self.find_all_in_store_with_exe(tool_name, tool_name) + } + + /// Find all versions of a tool in the store directory with a specific executable name + /// + /// This method uses the new directory structure: + /// - New (post-platform-redirection): ``/``/``/ + /// - Fallback: ``/``/ (for cross-platform tools like vcpkg) + pub fn find_all_in_store_with_exe( + &self, + tool_name: &str, + exe_name: &str, + ) -> Result> { + let mut locations = Vec::new(); + let versions = self.manager.list_store_versions(tool_name)?; + + for version in &versions { + // New layout: try version_store_dir first (no platform subdirectory). + let version_dir = self.manager.version_store_dir(tool_name, version); + if let Some(path) = self.find_executable_in_dir(&version_dir, exe_name) { + locations.push(ToolLocation { + path, + version: version.clone(), + source: ToolSource::Store, + }); + continue; + } + + // Old layout fallback: try platform-specific directory. + let platform_dir = self.manager.platform_store_dir(tool_name, version); + if version_dir != platform_dir + && let Some(path) = self.find_executable_in_dir(&platform_dir, exe_name) + { + locations.push(ToolLocation { + path, + version: version.clone(), + source: ToolSource::Store, + }); + } + } + + Ok(locations) + } + + // ========== npm-tools Directory Methods ========== + + /// Find a tool in the npm-tools directory + pub fn find_in_npm_tools(&self, tool_name: &str) -> Result> { + let versions = self.manager.list_npm_tool_versions(tool_name)?; + // Return the latest version + for version in versions.iter().rev() { + let bin_dir = self.manager.npm_tool_bin_dir(tool_name, version); + if let Some(path) = self.find_npm_executable(&bin_dir, tool_name) { + return Ok(Some(ToolLocation { + path, + version: version.clone(), + source: ToolSource::NpmTools, + })); + } + } + Ok(None) + } + + /// Find all versions of a tool in the npm-tools directory + pub fn find_all_in_npm_tools(&self, tool_name: &str) -> Result> { + let mut locations = Vec::new(); + let versions = self.manager.list_npm_tool_versions(tool_name)?; + + for version in versions { + let bin_dir = self.manager.npm_tool_bin_dir(tool_name, &version); + if let Some(path) = self.find_npm_executable(&bin_dir, tool_name) { + locations.push(ToolLocation { + path, + version, + source: ToolSource::NpmTools, + }); + } + } + + Ok(locations) + } + + // ========== pip-tools Directory Methods ========== + + /// Find a tool in the pip-tools directory + pub fn find_in_pip_tools(&self, tool_name: &str) -> Result> { + let versions = self.manager.list_pip_tool_versions(tool_name)?; + // Return the latest version + for version in versions.iter().rev() { + let bin_dir = self.manager.pip_tool_bin_dir(tool_name, version); + if let Some(path) = self.find_pip_executable(&bin_dir, tool_name) { + return Ok(Some(ToolLocation { + path, + version: version.clone(), + source: ToolSource::PipTools, + })); + } + } + Ok(None) + } + + /// Find all versions of a tool in the pip-tools directory + pub fn find_all_in_pip_tools(&self, tool_name: &str) -> Result> { + let mut locations = Vec::new(); + let versions = self.manager.list_pip_tool_versions(tool_name)?; + + for version in versions { + let bin_dir = self.manager.pip_tool_bin_dir(tool_name, &version); + if let Some(path) = self.find_pip_executable(&bin_dir, tool_name) { + locations.push(ToolLocation { + path, + version, + source: ToolSource::PipTools, + }); + } + } + + Ok(locations) + } + + // ========== Legacy API (for backward compatibility) ========== + + /// Find all executable paths for a tool (all versions) + pub fn find_tool_executables(&self, tool_name: &str) -> Result> { + let locations = self.find_all_tool_installations(tool_name)?; + Ok(locations.into_iter().map(|loc| loc.path).collect()) + } + + /// Find all executables for a tool with a specific executable name + /// + /// # Arguments + /// * `tool_name` - The runtime/tool name (used for directory lookup) + /// * `exe_name` - The executable name to search for + pub fn find_tool_executables_with_exe( + &self, + tool_name: &str, + exe_name: &str, + ) -> Result> { + let locations = self.find_all_tool_installations_with_exe(tool_name, exe_name)?; + Ok(locations.into_iter().map(|loc| loc.path).collect()) + } + + /// Find the latest executable for a tool + pub fn find_latest_executable(&self, tool_name: &str) -> Result> { + Ok(self.find_latest_tool(tool_name)?.map(|loc| loc.path)) + } + + /// Find the latest executable for a tool when the executable name differs + pub fn find_latest_executable_with_exe( + &self, + tool_name: &str, + exe_name: &str, + ) -> Result> { + let locations = self.find_all_tool_installations_with_exe(tool_name, exe_name)?; + Ok(locations.into_iter().last().map(|loc| loc.path)) + } + + /// Find executable for a specific tool version + pub fn find_version_executable(&self, tool_name: &str, version: &str) -> Option { + self.find_tool_version(tool_name, version) + .map(|loc| loc.path) + } + + /// Get all installed tools with their versions + pub fn get_installed_tools_with_versions(&self) -> Result)>> { + let store_runtimes = self.manager.list_store_runtimes()?; + let mut result = Vec::new(); + + for tool in store_runtimes { + let mut versions = self.manager.list_store_versions(&tool)?; + versions.sort(); + result.push((tool, versions)); + } + + result.sort_by(|a, b| a.0.cmp(&b.0)); + Ok(result) + } + + /// Resolve tool path with version preference + pub fn resolve_tool_path( + &self, + tool_name: &str, + version: Option<&str>, + ) -> Result> { + match version { + Some(v) => Ok(self.find_version_executable(tool_name, v)), + None => self.find_latest_executable(tool_name), + } + } + + // ========== Internal Helper Methods ========== + + /// Find npm executable in a bin directory + fn find_npm_executable(&self, bin_dir: &Path, tool_name: &str) -> Option { + let exe_name = if cfg!(windows) { + format!("{}.cmd", tool_name) + } else { + tool_name.to_string() + }; + let exe_path = bin_dir.join(&exe_name); + if exe_path.exists() { + Some(exe_path) + } else { + None + } + } + + /// Find pip executable in a bin directory + fn find_pip_executable(&self, bin_dir: &Path, tool_name: &str) -> Option { + let exe_name = if cfg!(windows) { + format!("{}.exe", tool_name) + } else { + tool_name.to_string() + }; + let exe_path = bin_dir.join(&exe_name); + if exe_path.exists() { + Some(exe_path) + } else { + None + } + } + + /// Search for an executable in a directory (recursively, up to 8 levels) + /// This handles various archive structures: + /// - Direct: ~/.vx/store/uv/0.9.17/uv + /// - One level: ~/.vx/store/uv/0.9.17/uv-platform/uv + /// - Two levels: ~/.vx/store/go/1.25.5/go/bin/go + /// - Deep: ~/.vx/store/msvc/14.42/VC/Tools/MSVC/14.42.34433/bin/Hostx64/x64/cl.exe + /// - Platform-suffixed: ~/.vx/store/rcedit/2.0.0/rcedit-x64.exe + /// + /// ## Performance optimization + /// + /// Uses a two-phase search strategy: + /// 1. **Quick check** (depth 0-3): Check common locations first (root, bin/, direct subdirs) + /// 2. **Deep search** (depth 4-8): Full walkdir only if quick check fails, with + /// directory filtering to skip known non-target dirs (node_modules, lib, share, etc.) + pub fn find_executable_in_dir(&self, dir: &Path, exe_name: &str) -> Option { + if !dir.exists() { + tracing::trace!( + "find_executable_in_dir: directory does not exist: {}", + dir.display() + ); + return None; + } + + // Check cache first + { + let mut cache = self.exec_cache.lock().expect("exec_cache lock poisoned"); + if let Some(cached) = cache.get(dir, exe_name) { + tracing::trace!( + "find_executable_in_dir: cache hit for '{}' in {} -> {}", + exe_name, + dir.display(), + cached.display() + ); + return Some(cached); + } + } + + tracing::trace!( + "find_executable_in_dir: searching for '{}' in {}", + exe_name, + dir.display() + ); + + // Build list of possible executable names in priority order + let possible_names: Vec = if cfg!(windows) { + vec![ + format!("{}.exe", exe_name), + format!("{}.cmd", exe_name), + exe_name.to_string(), + ] + } else { + vec![exe_name.to_string()] + }; + + // Platform-suffixed patterns (e.g., rcedit-x64, rcedit-arm64) + let platform_suffixes = ["x64", "x86", "arm64", "aarch64", "x86_64"]; + let platform_patterns: Vec = if cfg!(windows) { + platform_suffixes + .iter() + .map(|suffix| format!("{}-{}.exe", exe_name, suffix)) + .collect() + } else { + platform_suffixes + .iter() + .map(|suffix| format!("{}-{}", exe_name, suffix)) + .collect() + }; + + // Phase 1: Quick check common locations (depth 0-3) without full walkdir. + // Most runtimes have their executable in root, bin/, or one-level subdirectory. + // This avoids traversing deep trees like node_modules/ for the common case. + if let Some(result) = self.quick_find_executable(dir, &possible_names, &platform_patterns) { + let mut cache = self.exec_cache.lock().expect("exec_cache lock poisoned"); + cache.put(dir, exe_name, result.clone()); + return Some(result); + } + + // Phase 2: Deep search with directory filtering (depth 4-8). + // Only reached for unusual layouts (e.g., MSVC deep nesting). + // Skip known non-target directories to reduce filesystem traversal. + let mut all_candidates: Vec = Vec::new(); + let mut platform_candidates: Vec = Vec::new(); + + for entry in walkdir::WalkDir::new(dir) + .max_depth(8) + .into_iter() + .filter_entry(|e| !Self::is_skip_directory(e)) + .filter_map(|e| e.ok()) + { + let path = entry.path(); + if path.is_file() + && let Some(name) = path.file_name().and_then(|n| n.to_str()) + { + if possible_names.iter().any(|n| n == name) { + all_candidates.push(path.to_path_buf()); + } else if platform_patterns.iter().any(|p| p == name) { + platform_candidates.push(path.to_path_buf()); + } + } + } + + // Prefer exact matches over platform-suffixed matches + let result = Self::find_best_match(&all_candidates, &possible_names) + .or_else(|| Self::find_best_match(&platform_candidates, &platform_patterns)); + + if let Some(ref path) = result { + tracing::trace!( + "find_executable_in_dir: found executable at {}", + path.display() + ); + let mut cache = self.exec_cache.lock().expect("exec_cache lock poisoned"); + cache.put(dir, exe_name, path.clone()); + } else { + tracing::trace!( + "find_executable_in_dir: no executable found for '{}' in {} (candidates: {}, platform_candidates: {})", + exe_name, + dir.display(), + all_candidates.len(), + platform_candidates.len() + ); + } + + result + } + + /// Quick search for executable in common locations (avoids full walkdir). + /// + /// Checks these locations in order: + /// 1. Root directory (e.g., uv.exe directly in platform dir) + /// 2. bin/ subdirectory (e.g., go/bin/go) + /// 3. One-level subdirectories (e.g., node-v25.6.0-win-x64/node.exe) + /// 4. One-level subdirectory + bin/ (e.g., node-v25.6.0-win-x64/bin/node) + fn quick_find_executable( + &self, + dir: &Path, + possible_names: &[String], + platform_patterns: &[String], + ) -> Option { + // Check 1: Direct files in root + if let Some(found) = Self::check_files_in_dir(dir, possible_names) { + return Some(found); + } + if let Some(found) = Self::check_files_in_dir(dir, platform_patterns) { + return Some(found); + } + + // Check 2: bin/ subdirectory + let bin_dir = dir.join("bin"); + if bin_dir.is_dir() + && let Some(found) = Self::check_files_in_dir(&bin_dir, possible_names) + { + return Some(found); + } + + // Check 3 & 4: One level of subdirectories (and their bin/) + if let Ok(entries) = std::fs::read_dir(dir) { + for entry in entries.filter_map(|e| e.ok()) { + let sub_path = entry.path(); + if !sub_path.is_dir() { + continue; + } + // Skip known non-target directories + if let Some(name) = sub_path.file_name().and_then(|n| n.to_str()) + && matches!( + name, + "node_modules" | "lib" | "share" | "include" | "man" | "doc" | "docs" + ) + { + continue; + } + // Check files in subdirectory + if let Some(found) = Self::check_files_in_dir(&sub_path, possible_names) { + return Some(found); + } + if let Some(found) = Self::check_files_in_dir(&sub_path, platform_patterns) { + return Some(found); + } + // Check subdirectory/bin/ + let sub_bin = sub_path.join("bin"); + if sub_bin.is_dir() + && let Some(found) = Self::check_files_in_dir(&sub_bin, possible_names) + { + return Some(found); + } + } + } + + None + } + + /// Check if any of the named files exist in a directory (no recursion) + fn check_files_in_dir(dir: &Path, names: &[String]) -> Option { + for name in names { + let candidate = dir.join(name); + if candidate.is_file() { + return Some(candidate); + } + } + None + } + + /// Check if a walkdir entry is a directory we should skip during deep search. + /// + /// Skipping these directories significantly reduces filesystem traversal, + /// especially for Node.js installs which have deep node_modules trees. + fn is_skip_directory(entry: &walkdir::DirEntry) -> bool { + if !entry.file_type().is_dir() { + return false; + } + let Some(name) = entry.file_name().to_str() else { + return false; + }; + matches!( + name, + "node_modules" + | ".git" + | ".cache" + | "__pycache__" + | "site-packages" + | "dist-info" + | "egg-info" + ) + } + + /// Helper to find the best matching executable from a list of candidates + /// Returns the one with highest priority: + /// 1. Match by name priority (lowest index in possible_names) + /// 2. Prefer shorter paths (closer to root directory) to avoid picking up + /// nested copies (e.g., corepack shims in node_modules) + fn find_best_match(candidates: &[PathBuf], possible_names: &[String]) -> Option { + for name in possible_names { + // Find all candidates matching this name + let mut matching: Vec<&PathBuf> = candidates + .iter() + .filter(|c| c.file_name().and_then(|n| n.to_str()) == Some(name.as_str())) + .collect(); + + if matching.is_empty() { + continue; + } + + // Sort by path depth (number of components) - prefer shallower paths + // This ensures we pick node-v20.20.0-win-x64/npx.cmd over + // node-v20.20.0-win-x64/node_modules/corepack/shims/nodewin/npx.cmd + matching.sort_by_key(|p| p.components().count()); + + return matching.first().map(|p| (*p).clone()); + } + None + } +} + +impl Default for PathResolver { + fn default() -> Self { + Self::new(PathManager::default()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + #[test] + fn test_path_resolver() { + let temp_dir = TempDir::new().unwrap(); + let manager = PathManager::with_base_dir(temp_dir.path()).unwrap(); + let resolver = PathResolver::new(manager); + + // Initially no tools + assert!(!resolver.is_tool_installed("node").unwrap()); + assert_eq!( + resolver.find_tool_executables("node").unwrap(), + Vec::::new() + ); + assert_eq!(resolver.find_latest_executable("node").unwrap(), None); + + // Create a tool installation in platform-specific store directory + let platform_dir = resolver.manager().platform_store_dir("node", "18.17.0"); + std::fs::create_dir_all(&platform_dir).unwrap(); + let exe_name = if cfg!(windows) { "node.exe" } else { "node" }; + let exe_path = platform_dir.join(exe_name); + std::fs::write(&exe_path, "fake executable").unwrap(); + + // Now it should be found + assert!(resolver.is_tool_installed("node").unwrap()); + assert_eq!( + resolver.find_tool_executables("node").unwrap(), + vec![exe_path.clone()] + ); + assert_eq!( + resolver.find_latest_executable("node").unwrap(), + Some(exe_path.clone()) + ); + assert_eq!( + resolver.find_version_executable("node", "18.17.0"), + Some(exe_path.clone()) + ); + assert_eq!( + resolver.resolve_tool_path("node", None).unwrap(), + Some(exe_path.clone()) + ); + assert_eq!( + resolver.resolve_tool_path("node", Some("18.17.0")).unwrap(), + Some(exe_path) + ); + } + + #[test] + fn test_deep_executable_search() { + let temp_dir = TempDir::new().unwrap(); + let manager = PathManager::with_base_dir(temp_dir.path()).unwrap(); + let resolver = PathResolver::new(manager); + + // Create a deep directory structure like MSVC + // msvc/14.42//VC/Tools/MSVC/14.42.34433/bin/Hostx64/x64/cl.exe + let _version_dir = resolver.manager().version_store_dir("msvc", "14.42"); + let platform_dir = resolver.manager().platform_store_dir("msvc", "14.42"); + let deep_dir = platform_dir.join("VC/Tools/MSVC/14.42.34433/bin/Hostx64/x64"); + std::fs::create_dir_all(&deep_dir).unwrap(); + let exe_name = if cfg!(windows) { "cl.exe" } else { "cl" }; + let exe_path = deep_dir.join(exe_name); + std::fs::write(&exe_path, "fake executable").unwrap(); + + // Should find the executable using find_executable_in_dir + let found = resolver.find_executable_in_dir(&platform_dir, "cl"); + assert_eq!(found, Some(exe_path.clone())); + + // Should find using find_tool_executables_with_exe + let executables = resolver + .find_tool_executables_with_exe("msvc", "cl") + .unwrap(); + assert_eq!(executables, vec![exe_path]); + } + + #[test] + fn test_platform_suffixed_executable_search() { + let temp_dir = TempDir::new().unwrap(); + let manager = PathManager::with_base_dir(temp_dir.path()).unwrap(); + let resolver = PathResolver::new(manager); + + // Create a tool with platform-suffixed executable (like rcedit) + // rcedit/2.0.0//rcedit-x64.exe + let version_dir = resolver.manager().version_store_dir("rcedit", "2.0.0"); + let platform_dir = resolver.manager().platform_store_dir("rcedit", "2.0.0"); + std::fs::create_dir_all(&platform_dir).unwrap(); + let exe_name = if cfg!(windows) { + "rcedit-x64.exe" + } else { + "rcedit-x64" + }; + let exe_path = platform_dir.join(exe_name); + std::fs::write(&exe_path, "fake executable").unwrap(); + + // Should find the platform-suffixed executable when searching for "rcedit" + let found = resolver.find_executable_in_dir(&version_dir, "rcedit"); + assert_eq!(found, Some(exe_path.clone())); + + // Should find using find_in_store + let location = resolver.find_in_store("rcedit").unwrap(); + assert!(location.is_some()); + assert_eq!(location.unwrap().path, exe_path); + } + + #[test] + fn test_exact_match_preferred_over_platform_suffix() { + let temp_dir = TempDir::new().unwrap(); + let manager = PathManager::with_base_dir(temp_dir.path()).unwrap(); + let resolver = PathResolver::new(manager); + + // Create both exact and platform-suffixed executables + let version_dir = resolver.manager().version_store_dir("mytool", "1.0.0"); + std::fs::create_dir_all(&version_dir).unwrap(); + + let exact_name = if cfg!(windows) { + "mytool.exe" + } else { + "mytool" + }; + let suffixed_name = if cfg!(windows) { + "mytool-x64.exe" + } else { + "mytool-x64" + }; + + let exact_path = version_dir.join(exact_name); + let suffixed_path = version_dir.join(suffixed_name); + std::fs::write(&exact_path, "exact executable").unwrap(); + std::fs::write(&suffixed_path, "suffixed executable").unwrap(); + + // Should prefer exact match over platform-suffixed + let found = resolver.find_executable_in_dir(&version_dir, "mytool"); + assert_eq!(found, Some(exact_path)); + } +} diff --git a/crates/vx-paths/src/runtime_root.rs b/crates/vx-paths/src/runtime_root.rs new file mode 100644 index 000000000..b306f9c7a --- /dev/null +++ b/crates/vx-paths/src/runtime_root.rs @@ -0,0 +1,558 @@ +//! Runtime root resolution and environment variable generation +//! +//! This module provides a unified interface for: +//! 1. Resolving the root directory of any runtime installation +//! 2. Generating REZ-like environment variables for runtime execution +//! +//! # REZ-like Environment Variables +//! +//! For each runtime, the following environment variables are generated: +//! +//! | Variable | Description | Example | +//! |----------|-------------|---------| +//! | `VX_{NAME}_ROOT` | Root directory of the runtime installation | `~/.vx/store/node/20.0.0/windows-x64/node-v20.0.0-win-x64` | +//! | `VX_{NAME}_BASE` | Base version directory (without platform) | `~/.vx/store/node/20.0.0` | +//! | `VX_{NAME}_BIN` | Bin directory containing executables | `~/.vx/store/node/20.0.0/windows-x64/node-v20.0.0-win-x64` | +//! | `VX_{NAME}_VERSION` | Resolved version string | `20.0.0` | +//! | `VX_{NAME}_VERSIONS` | All installed versions (colon-separated) | `18.20.0:20.0.0:22.0.0` | +//! +//! # Example +//! +//! ```rust,ignore +//! use vx_paths::{RuntimeRoot, VxPaths}; +//! +//! let paths = VxPaths::new()?; +//! +//! // Get runtime root for a specific version +//! if let Some(root) = RuntimeRoot::find("node", "20.0.0", &paths)? { +//! println!("Node.js root: {}", root.root_dir().display()); +//! println!("Node.js bin: {}", root.bin_dir().display()); +//! +//! // Generate environment variables +//! for (key, value) in root.env_vars() { +//! std::env::set_var(key, value); +//! } +//! } +//! ``` + +use crate::{PathManager, VxPaths, with_executable_extension}; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; + +/// Resolved runtime root information +/// +/// This struct provides access to the runtime's installation directories +/// and can generate REZ-like environment variables. +#[derive(Debug, Clone)] +pub struct RuntimeRoot { + /// Runtime name (e.g., "node", "python", "go") + pub name: String, + /// Resolved version string + pub version: String, + /// Base version directory (~/.vx/store/{name}/{version}) + pub base_dir: PathBuf, + /// Platform-specific directory (~/.vx/store/{name}/{version}/{platform}) + pub platform_dir: PathBuf, + /// Root directory containing the actual runtime files + /// This may be nested within platform_dir for some runtimes + pub root_dir: PathBuf, + /// Bin directory containing executables + pub bin_dir: PathBuf, + /// Path to the main executable + pub executable_path: PathBuf, + /// All installed versions of this runtime + pub all_versions: Vec, +} + +impl RuntimeRoot { + /// Find runtime root for a specific version + /// + /// # Arguments + /// * `name` - Runtime name (e.g., "node", "python") + /// * `version` - Version string (e.g., "20.0.0") + /// * `paths` - VxPaths instance + /// + /// # Returns + /// `Some(RuntimeRoot)` if the version is installed, `None` otherwise + pub fn find(name: &str, version: &str, paths: &VxPaths) -> anyhow::Result> { + let manager = PathManager::from_paths(paths.clone()); + Self::find_with_manager(name, version, &manager) + } + + /// Find runtime root using a PathManager + pub fn find_with_manager( + name: &str, + version: &str, + manager: &PathManager, + ) -> anyhow::Result> { + let base_dir = manager.version_store_dir(name, version); + let platform_dir = manager.platform_store_dir(name, version); + + // New layout: try version dir first; old layout: platform subdir fallback. + let install_dir = if base_dir.exists() { + base_dir.clone() + } else if platform_dir.exists() { + platform_dir.clone() + } else { + return Ok(None); + }; + + // Find the actual root directory within install_dir + // Some runtimes have nested directories (e.g., node-v20.0.0-win-x64) + let (root_dir, bin_dir, executable_path) = Self::resolve_dirs(&install_dir, name)?; + + // Get all installed versions + let all_versions = manager.list_store_versions(name).unwrap_or_default(); + + Ok(Some(Self { + name: name.to_string(), + version: version.to_string(), + base_dir, + platform_dir, + root_dir, + bin_dir, + executable_path, + all_versions, + })) + } + + /// Find the latest installed version of a runtime + pub fn find_latest(name: &str, paths: &VxPaths) -> anyhow::Result> { + let manager = PathManager::from_paths(paths.clone()); + let versions = manager.list_store_versions(name)?; + + if let Some(version) = versions.last() { + Self::find_with_manager(name, version, &manager) + } else { + Ok(None) + } + } + + /// Resolve the actual directories within a platform directory + /// + /// This handles various installation layouts: + /// - Direct: platform_dir/bin/node (Unix standard) + /// - Direct: platform_dir/node.exe (Windows flat) + /// - Nested: platform_dir/node-v20.0.0-win-x64/node.exe (Node.js style) + /// - Nested with bin: platform_dir/go/bin/go (Go style) + fn resolve_dirs( + platform_dir: &Path, + runtime_name: &str, + ) -> anyhow::Result<(PathBuf, PathBuf, PathBuf)> { + let exe_name = with_executable_extension(runtime_name); + + // Strategy 1: Check for executable directly in platform_dir + let direct_exe = platform_dir.join(&exe_name); + if direct_exe.is_file() { + return Ok(( + platform_dir.to_path_buf(), + platform_dir.to_path_buf(), + direct_exe, + )); + } + + // Strategy 2: Check for bin/ subdirectory (Unix standard layout) + let bin_dir = platform_dir.join("bin"); + let bin_exe = bin_dir.join(&exe_name); + if bin_exe.is_file() { + return Ok((platform_dir.to_path_buf(), bin_dir, bin_exe)); + } + + // Strategy 3: Search subdirectories (handles nested layouts like Node.js) + if let Ok(entries) = std::fs::read_dir(platform_dir) { + for entry in entries.filter_map(|e| e.ok()) { + let sub_path = entry.path(); + if !sub_path.is_dir() { + continue; + } + + // Check for executable directly in subdirectory + let sub_exe = sub_path.join(&exe_name); + if sub_exe.is_file() { + return Ok((sub_path.clone(), sub_path, sub_exe)); + } + + // Check for bin/ in subdirectory + let sub_bin_dir = sub_path.join("bin"); + let sub_bin_exe = sub_bin_dir.join(&exe_name); + if sub_bin_exe.is_file() { + return Ok((sub_path, sub_bin_dir, sub_bin_exe)); + } + + // Check one more level deep (for nested structures like Node.js) + if let Ok(sub_entries) = std::fs::read_dir(&sub_path) { + for sub_entry in sub_entries.filter_map(|e| e.ok()) { + let deep_path = sub_entry.path(); + if !deep_path.is_dir() { + continue; + } + + let deep_exe = deep_path.join(&exe_name); + if deep_exe.is_file() { + return Ok((deep_path.clone(), deep_path, deep_exe)); + } + + let deep_bin_dir = deep_path.join("bin"); + let deep_bin_exe = deep_bin_dir.join(&exe_name); + if deep_bin_exe.is_file() { + return Ok((deep_path, deep_bin_dir, deep_bin_exe)); + } + } + } + } + } + + // Fallback: return platform_dir as root, even if executable not found + // This allows callers to handle missing executables + Ok(( + platform_dir.to_path_buf(), + platform_dir.join("bin"), + platform_dir.join("bin").join(&exe_name), + )) + } + + /// Get the root directory path + pub fn root_dir(&self) -> &Path { + &self.root_dir + } + + /// Get the bin directory path + pub fn bin_dir(&self) -> &Path { + &self.bin_dir + } + + /// Get the main executable path + pub fn executable_path(&self) -> &Path { + &self.executable_path + } + + /// Check if the executable exists + pub fn executable_exists(&self) -> bool { + self.executable_path.exists() + } + + /// Get the path to a bundled tool (e.g., npm, npx for node) + /// + /// Some runtimes bundle multiple executables. For example, Node.js bundles + /// npm and npx. This method returns the path to a specific bundled tool. + /// + /// # Arguments + /// * `tool_name` - Name of the bundled tool (e.g., "npm", "npx") + /// + /// # Returns + /// `Some(PathBuf)` if the tool exists in the bin directory, `None` otherwise + /// + /// # Example + /// ```rust,ignore + /// let root = RuntimeRoot::find("node", "20.0.0", &paths)?.unwrap(); + /// if let Some(npm_path) = root.bundled_tool_path("npm") { + /// println!("npm is at: {}", npm_path.display()); + /// } + /// ``` + pub fn bundled_tool_path(&self, tool_name: &str) -> Option { + let exe_name = with_executable_extension(tool_name); + let tool_path = self.bin_dir.join(&exe_name); + + if tool_path.exists() { + return Some(tool_path); + } + + // On Windows, also check for .cmd variants (e.g., npm.cmd) + if cfg!(windows) { + let cmd_path = self.bin_dir.join(format!("{}.cmd", tool_name)); + if cmd_path.exists() { + return Some(cmd_path); + } + } + + None + } + + /// Check if a bundled tool exists + /// + /// # Arguments + /// * `tool_name` - Name of the bundled tool + /// + /// # Returns + /// `true` if the tool exists in the bin directory + pub fn has_bundled_tool(&self, tool_name: &str) -> bool { + self.bundled_tool_path(tool_name).is_some() + } + + /// Generate REZ-like environment variables + /// + /// Returns a HashMap with the following keys: + /// - `VX_{NAME}_ROOT` - Root directory + /// - `VX_{NAME}_BASE` - Base version directory + /// - `VX_{NAME}_BIN` - Bin directory + /// - `VX_{NAME}_VERSION` - Version string + /// - `VX_{NAME}_VERSIONS` - All installed versions (colon-separated) + pub fn env_vars(&self) -> HashMap { + let name_upper = self.name.to_uppercase().replace('-', "_"); + let sep = if cfg!(windows) { ";" } else { ":" }; + + let mut vars = HashMap::new(); + + vars.insert( + format!("VX_{}_ROOT", name_upper), + self.root_dir.display().to_string(), + ); + vars.insert( + format!("VX_{}_BASE", name_upper), + self.base_dir.display().to_string(), + ); + vars.insert( + format!("VX_{}_BIN", name_upper), + self.bin_dir.display().to_string(), + ); + vars.insert(format!("VX_{}_VERSION", name_upper), self.version.clone()); + vars.insert( + format!("VX_{}_VERSIONS", name_upper), + self.all_versions.join(sep), + ); + + vars + } + + /// Generate environment variables with a custom prefix + /// + /// Useful when the runtime name differs from the provider name + /// (e.g., "nodejs" vs "node") + pub fn env_vars_with_prefix(&self, prefix: &str) -> HashMap { + let prefix_upper = prefix.to_uppercase().replace('-', "_"); + let sep = if cfg!(windows) { ";" } else { ":" }; + + let mut vars = HashMap::new(); + + vars.insert( + format!("VX_{}_ROOT", prefix_upper), + self.root_dir.display().to_string(), + ); + vars.insert( + format!("VX_{}_BASE", prefix_upper), + self.base_dir.display().to_string(), + ); + vars.insert( + format!("VX_{}_BIN", prefix_upper), + self.bin_dir.display().to_string(), + ); + vars.insert(format!("VX_{}_VERSION", prefix_upper), self.version.clone()); + vars.insert( + format!("VX_{}_VERSIONS", prefix_upper), + self.all_versions.join(sep), + ); + + vars + } +} + +/// Convenience function to get runtime root +/// +/// # Example +/// +/// ```rust,ignore +/// use vx_paths::get_runtime_root; +/// +/// if let Some(root) = get_runtime_root("node", "20.0.0")? { +/// println!("Node.js root: {}", root.root_dir().display()); +/// } +/// ``` +pub fn get_runtime_root(name: &str, version: &str) -> anyhow::Result> { + let paths = VxPaths::new()?; + RuntimeRoot::find(name, version, &paths) +} + +/// Convenience function to get the latest runtime root +/// +/// # Example +/// +/// ```rust,ignore +/// use vx_paths::get_latest_runtime_root; +/// +/// if let Some(root) = get_latest_runtime_root("node")? { +/// println!("Latest Node.js: {} at {}", root.version, root.root_dir().display()); +/// } +/// ``` +pub fn get_latest_runtime_root(name: &str) -> anyhow::Result> { + let paths = VxPaths::new()?; + RuntimeRoot::find_latest(name, &paths) +} + +/// Convenience function to get a bundled tool path from the latest runtime version +/// +/// This is useful for getting paths to tools that are bundled with a runtime, +/// such as npm/npx bundled with Node.js. +/// +/// # Arguments +/// * `runtime_name` - Name of the runtime (e.g., "node") +/// * `tool_name` - Name of the bundled tool (e.g., "npm", "npx") +/// +/// # Returns +/// `Some(PathBuf)` if the runtime and tool exist, `None` otherwise +/// +/// # Example +/// +/// ```rust,ignore +/// use vx_paths::get_bundled_tool_path; +/// +/// // Get npm path from the latest installed Node.js +/// if let Some(npm_path) = get_bundled_tool_path("node", "npm")? { +/// println!("npm is at: {}", npm_path.display()); +/// } +/// ``` +pub fn get_bundled_tool_path( + runtime_name: &str, + tool_name: &str, +) -> anyhow::Result> { + if let Some(root) = get_latest_runtime_root(runtime_name)? { + Ok(root.bundled_tool_path(tool_name)) + } else { + Ok(None) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + fn create_test_paths(temp_dir: &TempDir) -> VxPaths { + VxPaths::with_base_dir(temp_dir.path()) + } + + fn setup_node_installation(paths: &VxPaths, version: &str) { + let manager = PathManager::from_paths(paths.clone()); + let platform_dir = manager.platform_store_dir("node", version); + + // Create nested Node.js-style layout + let nested_dir = + platform_dir.join(format!("node-v{}-{}", version, manager.platform_dir_name())); + std::fs::create_dir_all(&nested_dir).unwrap(); + + let exe_name = with_executable_extension("node"); + let exe_path = nested_dir.join(&exe_name); + std::fs::write(&exe_path, "fake node").unwrap(); + + // Create npm and npx + for tool in &["npm", "npx"] { + let tool_exe = with_executable_extension(tool); + let tool_path = nested_dir.join(&tool_exe); + std::fs::write(&tool_path, "fake tool").unwrap(); + } + } + + fn setup_go_installation(paths: &VxPaths, version: &str) { + let manager = PathManager::from_paths(paths.clone()); + let platform_dir = manager.platform_store_dir("go", version); + + // Create Go-style layout with bin/ subdirectory + let go_dir = platform_dir.join("go"); + let bin_dir = go_dir.join("bin"); + std::fs::create_dir_all(&bin_dir).unwrap(); + + let exe_name = with_executable_extension("go"); + let exe_path = bin_dir.join(&exe_name); + std::fs::write(&exe_path, "fake go").unwrap(); + } + + #[test] + fn test_find_node_root() { + let temp_dir = TempDir::new().unwrap(); + let paths = create_test_paths(&temp_dir); + setup_node_installation(&paths, "20.0.0"); + + let root = RuntimeRoot::find("node", "20.0.0", &paths) + .unwrap() + .expect("Should find node root"); + + assert_eq!(root.name, "node"); + assert_eq!(root.version, "20.0.0"); + assert!(root.executable_exists()); + } + + #[test] + fn test_find_go_root() { + let temp_dir = TempDir::new().unwrap(); + let paths = create_test_paths(&temp_dir); + setup_go_installation(&paths, "1.21.0"); + + let root = RuntimeRoot::find("go", "1.21.0", &paths) + .unwrap() + .expect("Should find go root"); + + assert_eq!(root.name, "go"); + assert_eq!(root.version, "1.21.0"); + assert!(root.executable_exists()); + // Check that bin_dir ends with "bin" component (cross-platform) + assert!( + root.bin_dir() + .components() + .next_back() + .map(|c| c.as_os_str() == "bin") + .unwrap_or(false), + "bin_dir should end with 'bin' component: {:?}", + root.bin_dir() + ); + } + + #[test] + fn test_find_latest() { + let temp_dir = TempDir::new().unwrap(); + let paths = create_test_paths(&temp_dir); + setup_node_installation(&paths, "18.0.0"); + setup_node_installation(&paths, "20.0.0"); + setup_node_installation(&paths, "22.0.0"); + + let root = RuntimeRoot::find_latest("node", &paths) + .unwrap() + .expect("Should find latest node"); + + assert_eq!(root.version, "22.0.0"); + assert_eq!(root.all_versions.len(), 3); + } + + #[test] + fn test_env_vars() { + let temp_dir = TempDir::new().unwrap(); + let paths = create_test_paths(&temp_dir); + setup_node_installation(&paths, "20.0.0"); + + let root = RuntimeRoot::find("node", "20.0.0", &paths) + .unwrap() + .expect("Should find node root"); + + let vars = root.env_vars(); + + assert!(vars.contains_key("VX_NODE_ROOT")); + assert!(vars.contains_key("VX_NODE_BASE")); + assert!(vars.contains_key("VX_NODE_BIN")); + assert!(vars.contains_key("VX_NODE_VERSION")); + assert!(vars.contains_key("VX_NODE_VERSIONS")); + assert_eq!(vars.get("VX_NODE_VERSION"), Some(&"20.0.0".to_string())); + } + + #[test] + fn test_env_vars_with_prefix() { + let temp_dir = TempDir::new().unwrap(); + let paths = create_test_paths(&temp_dir); + setup_node_installation(&paths, "20.0.0"); + + let root = RuntimeRoot::find("node", "20.0.0", &paths) + .unwrap() + .expect("Should find node root"); + + let vars = root.env_vars_with_prefix("nodejs"); + + assert!(vars.contains_key("VX_NODEJS_ROOT")); + assert!(vars.contains_key("VX_NODEJS_VERSION")); + } + + #[test] + fn test_not_installed() { + let temp_dir = TempDir::new().unwrap(); + let paths = create_test_paths(&temp_dir); + + let root = RuntimeRoot::find("node", "99.99.99", &paths).unwrap(); + assert!(root.is_none()); + } +} diff --git a/crates/vx-paths/src/safety_net.rs b/crates/vx-paths/src/safety_net.rs new file mode 100644 index 000000000..efcc53800 --- /dev/null +++ b/crates/vx-paths/src/safety_net.rs @@ -0,0 +1,209 @@ +//! Safety net for child process environment +//! +//! This module provides functions to ensure that child processes always have +//! access to fundamental system executables (cmd.exe, sh, bash, etc.) and +//! essential environment variables, regardless of how the parent environment +//! was constructed. +//! +//! Both `vx-resolver`'s `command.rs` (final command execution) and +//! `environment.rs` (runtime environment preparation) use these helpers to +//! avoid duplicating the same defensive logic. + +use std::collections::HashMap; +use std::path::Path; + +/// Essential Windows environment variables that must always be present +/// for child processes to function correctly. +/// +/// Without these, fundamental operations like `cmd /c "..."` or PowerShell +/// script execution will fail because the system cannot locate executables. +#[cfg(windows)] +pub const WINDOWS_ESSENTIAL_ENV_VARS: &[&str] = &[ + "SYSTEMROOT", // C:\Windows — needed for cmd.exe, system DLLs + "SYSTEMDRIVE", // C: — base drive letter + "WINDIR", // C:\Windows — legacy alias for SYSTEMROOT + "COMSPEC", // C:\Windows\System32\cmd.exe — default command processor + "PATHEXT", // .COM;.EXE;.BAT;.CMD;... — executable extensions + "OS", // Windows_NT — OS identification + "PROCESSOR_ARCHITECTURE", // AMD64/ARM64 — needed by build tools + "NUMBER_OF_PROCESSORS", // CPU count — used by parallel builds +]; + +/// Get essential system paths that must always be present in PATH. +/// +/// These paths contain fundamental system executables (cmd.exe, powershell, +/// sh, bash, etc.) that child processes expect to find. +/// +/// On Windows, derives paths from `SYSTEMROOT` env var (defaults to +/// `C:\Windows`). On Unix, returns the standard `/bin`, `/usr/bin`, +/// `/usr/local/bin` directories. +pub fn essential_system_paths() -> Vec { + let mut paths = Vec::new(); + + #[cfg(windows)] + { + let system_root = + std::env::var("SYSTEMROOT").unwrap_or_else(|_| r"C:\Windows".to_string()); + + // System32 — contains cmd.exe, powershell.exe, and most system utilities + let system32 = format!(r"{}\System32", system_root); + paths.push(system32.clone()); + + // Wbem — Windows Management Instrumentation tools + paths.push(format!(r"{}\Wbem", system32)); + + // Windows PowerShell 5.x + paths.push(format!(r"{}\WindowsPowerShell\v1.0", system32)); + + // SYSTEMROOT itself (contains some executables) + paths.push(system_root); + + // PowerShell 7+ (if installed) + if let Ok(pf) = std::env::var("ProgramFiles") { + let ps7 = format!(r"{}\PowerShell\7", pf); + if Path::new(&ps7).exists() { + paths.push(ps7); + } + } + } + + #[cfg(unix)] + { + paths.extend([ + "/bin".to_string(), + "/usr/bin".to_string(), + "/usr/local/bin".to_string(), + ]); + } + + paths +} + +/// Ensure essential system paths are present in the given path list. +/// +/// Appends any missing essential paths (that actually exist on disk) to +/// `path_parts`. Uses case-insensitive comparison on Windows. +/// +/// Returns `true` if any paths were added. +pub fn ensure_essential_paths(path_parts: &mut Vec) -> bool { + let essential = essential_system_paths(); + let mut added_any = false; + + for ep in &essential { + let already_present = { + #[cfg(windows)] + { + let ep_lower = ep.to_lowercase(); + path_parts.iter().any(|p| p.to_lowercase() == ep_lower) + } + #[cfg(not(windows))] + { + path_parts.iter().any(|p| p == ep) + } + }; + + if !already_present && Path::new(ep).exists() { + path_parts.push(ep.clone()); + added_any = true; + } + } + + added_any +} + +/// Ensure essential Windows system environment variables are present in `env`. +/// +/// When a runtime environment is built from scratch (or filtered), variables +/// like `SYSTEMROOT`, `COMSPEC`, and `PATHEXT` may be missing. Without them, +/// child processes that invoke `cmd.exe` or PowerShell will fail with errors +/// like "'cmd' is not recognized as an internal or external command". +/// +/// This is a no-op on non-Windows platforms. +pub fn ensure_essential_env_vars(env: &mut HashMap) { + #[cfg(windows)] + { + for var_name in WINDOWS_ESSENTIAL_ENV_VARS { + if !env.keys().any(|k| k.eq_ignore_ascii_case(var_name)) { + if let Ok(value) = std::env::var(var_name) { + env.insert(var_name.to_string(), value); + } + } + } + } + #[cfg(not(windows))] + let _ = env; // suppress unused warning +} + +/// Ensure the directory containing the current `vx` executable is in PATH. +/// +/// This allows sub-processes (e.g., `just` recipes calling `vx npm ci`) to +/// find the `vx` binary without requiring it to be on the system PATH. +/// +/// The directory is appended (not prepended) so it doesn't shadow other tools. +pub fn ensure_vx_in_path(env: &mut HashMap) { + if let Ok(current_exe) = std::env::current_exe() + && let Some(exe_dir) = current_exe.parent() + { + let exe_dir_str = exe_dir.to_string_lossy().to_string(); + let current_path = env.get("PATH").cloned().unwrap_or_default(); + + let already_present = { + #[cfg(windows)] + { + current_path + .to_lowercase() + .contains(&exe_dir_str.to_lowercase()) + } + #[cfg(not(windows))] + { + current_path.contains(&exe_dir_str) + } + }; + + if !already_present { + let sep = if cfg!(windows) { ";" } else { ":" }; + let new_path = if current_path.is_empty() { + exe_dir_str + } else { + format!("{}{}{}", current_path, sep, exe_dir_str) + }; + env.insert("PATH".to_string(), new_path); + } + } +} + +/// Apply the full safety net to an environment map. +/// +/// Convenience function that calls: +/// 1. [`ensure_essential_paths`] — adds missing essential PATH entries +/// 2. [`ensure_essential_env_vars`] — adds missing Windows system env vars +/// 3. [`ensure_vx_in_path`] — ensures `vx` itself is findable +/// +/// This is the recommended entry point for both `command.rs` and +/// `environment.rs`. +pub fn apply_safety_net(env: &mut HashMap) { + // Step 1: ensure essential system paths + { + let current_path = env + .get("PATH") + .cloned() + .or_else(|| std::env::var("PATH").ok()) + .unwrap_or_default(); + + let mut path_parts: Vec = super::split_path(¤t_path) + .map(String::from) + .collect(); + + if ensure_essential_paths(&mut path_parts) { + if let Ok(new_path) = std::env::join_paths(&path_parts) { + env.insert("PATH".to_string(), new_path.to_string_lossy().to_string()); + } + } + } + + // Step 2: ensure Windows essential env vars + ensure_essential_env_vars(env); + + // Step 3: ensure vx is findable + ensure_vx_in_path(env); +} diff --git a/crates/vx-paths/src/shims.rs b/crates/vx-paths/src/shims.rs new file mode 100644 index 000000000..d0a3f0b7b --- /dev/null +++ b/crates/vx-paths/src/shims.rs @@ -0,0 +1,341 @@ +//! Shim Generation for Global Packages (RFC 0025) +//! +//! This module provides cross-platform shim generation for globally installed packages. +//! Shims are wrapper scripts that delegate to the actual package executables. +//! +//! ## REZ-like Dynamic Environment (RFC 0032) +//! +//! Shims support dynamic environment setup similar to REZ. When executing a package +//! that requires a runtime (e.g., npm packages needing Node.js), the shim automatically +//! sets up the runtime's bin directory in PATH before executing the target. +//! +//! ### Example: npm package shim on Windows +//! ```cmd +//! @echo off +//! setlocal +//! set "PATH=C:\Users\user\.vx\store\node\20.0.0\windows-x64\node-v20.0.0-win-x64;%PATH%" +//! "C:\Users\user\.vx\packages\npm\opencode-ai\latest\opencode" %* +//! ``` +//! +//! ### Example: npm package shim on Unix +//! ```sh +//! #!/bin/sh +//! export PATH="/home/user/.vx/store/node/20.0.0/linux-x64/node-v20.0.0-linux-x64:$PATH" +//! exec "/home/user/.vx/packages/npm/opencode-ai/latest/opencode" "$@" +//! ``` +//! +//! ## Future Enhancement: shimexe-core Integration +//! +//! This module can be enhanced to use `shimexe-core` () +//! for advanced shim functionality: +//! - TOML-based shim configuration (.shim.toml files) +//! - Environment variable expansion with ${VAR:default} syntax +//! - HTTP download support for remote tools +//! - Static binary shims (no shell overhead) +//! - Archive extraction support +//! +//! ```toml +//! # Add to Cargo.toml when ready: +//! # shimexe-core = "0.1" +//! ``` + +use anyhow::{Context, Result}; +use std::path::{Path, PathBuf}; + +/// Result of shim creation +#[derive(Debug)] +pub struct ShimResult { + /// Path to the created shim + pub shim_path: PathBuf, + /// Whether this was a new creation or update + pub created: bool, +} + +/// Create a shim for an executable +/// +/// On Unix, creates a shell wrapper script with executable permissions. +/// On Windows, creates a .cmd batch file. +/// +/// # Arguments +/// * `shim_dir` - Directory where the shim should be created +/// * `exe_name` - Name of the executable (without extension) +/// * `target_path` - Full path to the target executable +/// +/// # Returns +/// * `ShimResult` containing the path to the created shim +pub fn create_shim(shim_dir: &Path, exe_name: &str, target_path: &Path) -> Result { + std::fs::create_dir_all(shim_dir) + .with_context(|| format!("Failed to create shim directory: {}", shim_dir.display()))?; + + #[cfg(windows)] + { + create_windows_shim(shim_dir, exe_name, target_path) + } + + #[cfg(not(windows))] + { + create_unix_shim(shim_dir, exe_name, target_path) + } +} + +/// Create a Windows .cmd shim +#[cfg(windows)] +fn create_windows_shim(shim_dir: &Path, exe_name: &str, target_path: &Path) -> Result { + let shim_path = shim_dir.join(format!("{}.cmd", exe_name)); + let created = !shim_path.exists(); + + // Use forward slashes in the script for better compatibility + let target_str = target_path.to_string_lossy(); + + // Create batch script content + let content = format!( + r#"@echo off +setlocal +"{}" %* +"#, + target_str + ); + + std::fs::write(&shim_path, content) + .with_context(|| format!("Failed to write shim: {}", shim_path.display()))?; + + Ok(ShimResult { shim_path, created }) +} + +/// Create a Unix shell wrapper shim +#[cfg(not(windows))] +fn create_unix_shim(shim_dir: &Path, exe_name: &str, target_path: &Path) -> Result { + use std::os::unix::fs::PermissionsExt; + + let shim_path = shim_dir.join(exe_name); + let created = !shim_path.exists(); + + // Create shell script content + let content = format!( + r#"#!/bin/sh +exec "{}" "$@" +"#, + target_path.display() + ); + + std::fs::write(&shim_path, &content) + .with_context(|| format!("Failed to write shim: {}", shim_path.display()))?; + + // Set executable permissions (755) + let mut perms = std::fs::metadata(&shim_path)?.permissions(); + perms.set_mode(0o755); + std::fs::set_permissions(&shim_path, perms) + .with_context(|| format!("Failed to set shim permissions: {}", shim_path.display()))?; + + Ok(ShimResult { shim_path, created }) +} + +/// Remove a shim for an executable +/// +/// # Arguments +/// * `shim_dir` - Directory containing the shim +/// * `exe_name` - Name of the executable (without extension) +pub fn remove_shim(shim_dir: &Path, exe_name: &str) -> Result { + #[cfg(windows)] + let shim_path = shim_dir.join(format!("{}.cmd", exe_name)); + + #[cfg(not(windows))] + let shim_path = shim_dir.join(exe_name); + + if shim_path.exists() { + std::fs::remove_file(&shim_path) + .with_context(|| format!("Failed to remove shim: {}", shim_path.display()))?; + Ok(true) + } else { + Ok(false) + } +} + +/// Get the shim path for an executable name +pub fn get_shim_path(shim_dir: &Path, exe_name: &str) -> PathBuf { + #[cfg(windows)] + { + shim_dir.join(format!("{}.cmd", exe_name)) + } + + #[cfg(not(windows))] + { + shim_dir.join(exe_name) + } +} + +/// Check if a shim exists for an executable +pub fn shim_exists(shim_dir: &Path, exe_name: &str) -> bool { + get_shim_path(shim_dir, exe_name).exists() +} + +/// List all shims in a directory +pub fn list_shims(shim_dir: &Path) -> Result> { + if !shim_dir.exists() { + return Ok(Vec::new()); + } + + let mut shims = Vec::new(); + + for entry in std::fs::read_dir(shim_dir)? { + let entry = entry?; + let path = entry.path(); + + if !path.is_file() { + continue; + } + + let file_name = path.file_name().unwrap_or_default().to_string_lossy(); + + #[cfg(windows)] + { + if let Some(name) = file_name.strip_suffix(".cmd") { + shims.push(name.to_string()); + } + } + + #[cfg(not(windows))] + { + // On Unix, shims don't have extensions + if !file_name.contains('.') { + shims.push(file_name.to_string()); + } + } + } + + shims.sort(); + Ok(shims) +} + +/// Update all shims from a package registry +/// +/// This function synchronizes the shims directory with the package registry, +/// creating new shims for packages that need them and removing stale shims. +pub fn sync_shims_from_registry( + shim_dir: &Path, + packages: &[(String, std::path::PathBuf)], // (exe_name, target_path) +) -> Result { + let mut created = 0; + let mut removed = 0; + let mut errors = Vec::new(); + + // Get existing shims + let existing = list_shims(shim_dir)?; + let expected: std::collections::HashSet<_> = packages.iter().map(|(n, _)| n.clone()).collect(); + + // Remove stale shims + for shim_name in &existing { + if !expected.contains(shim_name) { + match remove_shim(shim_dir, shim_name) { + Ok(true) => removed += 1, + Ok(false) => {} + Err(e) => errors.push(format!("Failed to remove {}: {}", shim_name, e)), + } + } + } + + // Create/update shims + for (exe_name, target_path) in packages { + match create_shim(shim_dir, exe_name, target_path) { + Ok(result) => { + if result.created { + created += 1; + } + } + Err(e) => errors.push(format!("Failed to create {}: {}", exe_name, e)), + } + } + + Ok(SyncResult { + created, + removed, + errors, + }) +} + +/// Result of syncing shims with package registry +#[derive(Debug, Default)] +pub struct SyncResult { + /// Number of shims created + pub created: usize, + /// Number of shims removed + pub removed: usize, + /// Errors encountered during sync + pub errors: Vec, +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + #[test] + fn test_shim_path() { + let dir = Path::new("/tmp/shims"); + + #[cfg(windows)] + assert_eq!(get_shim_path(dir, "tsc"), Path::new("/tmp/shims/tsc.cmd")); + + #[cfg(not(windows))] + assert_eq!(get_shim_path(dir, "tsc"), Path::new("/tmp/shims/tsc")); + } + + #[test] + fn test_create_and_remove_shim() { + let temp = tempdir().unwrap(); + let shim_dir = temp.path().join("shims"); + let target = temp.path().join("bin").join("tool"); + + // Create fake target + std::fs::create_dir_all(target.parent().unwrap()).unwrap(); + std::fs::write(&target, "#!/bin/sh\necho hello").unwrap(); + + // Create shim + let result = create_shim(&shim_dir, "tool", &target).unwrap(); + assert!(result.created); + assert!(result.shim_path.exists()); + + // Check shim exists + assert!(shim_exists(&shim_dir, "tool")); + + // List shims + let shims = list_shims(&shim_dir).unwrap(); + assert_eq!(shims, vec!["tool"]); + + // Remove shim + let removed = remove_shim(&shim_dir, "tool").unwrap(); + assert!(removed); + assert!(!shim_exists(&shim_dir, "tool")); + } + + #[test] + fn test_sync_shims() { + let temp = tempdir().unwrap(); + let shim_dir = temp.path().join("shims"); + let bin_dir = temp.path().join("bin"); + std::fs::create_dir_all(&bin_dir).unwrap(); + + // Create fake executables + let tool1 = bin_dir.join("tool1"); + let tool2 = bin_dir.join("tool2"); + std::fs::write(&tool1, "tool1").unwrap(); + std::fs::write(&tool2, "tool2").unwrap(); + + // Sync shims + let packages = vec![ + ("tool1".to_string(), tool1.clone()), + ("tool2".to_string(), tool2.clone()), + ]; + let result = sync_shims_from_registry(&shim_dir, &packages).unwrap(); + assert_eq!(result.created, 2); + assert_eq!(result.removed, 0); + + // Now sync with only tool1 + let packages = vec![("tool1".to_string(), tool1)]; + let result = sync_shims_from_registry(&shim_dir, &packages).unwrap(); + assert_eq!(result.removed, 1); + + let shims = list_shims(&shim_dir).unwrap(); + assert_eq!(shims, vec!["tool1"]); + } +} diff --git a/crates/vx-paths/src/windows.rs b/crates/vx-paths/src/windows.rs new file mode 100644 index 000000000..d54483f53 --- /dev/null +++ b/crates/vx-paths/src/windows.rs @@ -0,0 +1,365 @@ +//! Windows-specific path handling for long paths +//! +//! Windows has a historical `MAX_PATH` limit of 260 characters. This module provides +//! utilities to work around this limitation by using the extended-length path prefix `\\?\`. +//! +//! ## Background +//! - Windows traditional `MAX_PATH`: 260 characters +//! - Extended-length path prefix `\\?\` allows up to 32,767 characters +//! - Windows 10 1607+ can enable long path support via registry/group policy +//! +//! ## Usage +//! ```rust +//! use std::path::PathBuf; +//! use vx_paths::windows::{to_long_path, check_path_length, PathLengthStatus}; +//! +//! let my_path = PathBuf::from(r"C:\Users\name\.vx\store\node\20.0.0"); +//! +//! // Convert path to extended-length format on Windows +//! let long_path = to_long_path(&my_path); +//! +//! // Check if path exceeds safe length +//! let status = check_path_length(&my_path); +//! if let Some(warning) = status.message() { +//! eprintln!("{}", warning); +//! } +//! ``` + +use std::path::{Path, PathBuf}; + +/// Windows MAX_PATH limit (260 characters including null terminator) +pub const WINDOWS_MAX_PATH: usize = 260; + +/// Warning threshold for path length (leave some margin) +pub const WINDOWS_PATH_WARN_THRESHOLD: usize = 200; + +/// Extended-length path prefix for Windows +pub const EXTENDED_PATH_PREFIX: &str = r"\\?\"; + +/// UNC extended-length path prefix +pub const EXTENDED_UNC_PREFIX: &str = r"\\?\UNC\"; + +/// Result of path length check +#[derive(Debug, Clone)] +pub enum PathLengthStatus { + /// Path is within safe limits + Safe, + /// Path is approaching the limit (warning) + Warning { length: usize, path: PathBuf }, + /// Path exceeds MAX_PATH limit + TooLong { length: usize, path: PathBuf }, +} + +impl PathLengthStatus { + /// Returns true if the path is safe (not too long) + pub fn is_safe(&self) -> bool { + matches!(self, PathLengthStatus::Safe) + } + + /// Returns true if the path is too long + pub fn is_too_long(&self) -> bool { + matches!(self, PathLengthStatus::TooLong { .. }) + } + + /// Get a human-readable message + pub fn message(&self) -> Option { + match self { + PathLengthStatus::Safe => None, + PathLengthStatus::Warning { length, path } => Some(format!( + "Path length ({}) approaching Windows limit ({}): {}", + length, + WINDOWS_MAX_PATH, + path.display() + )), + PathLengthStatus::TooLong { length, path } => Some(format!( + "Path length ({}) exceeds Windows limit ({}): {}", + length, + WINDOWS_MAX_PATH, + path.display() + )), + } + } +} + +/// Convert a path to extended-length format on Windows. +/// +/// This function adds the `\\?\` prefix to absolute paths on Windows, +/// allowing paths longer than MAX_PATH (260 characters). +/// +/// On non-Windows platforms, this function returns the path unchanged. +/// +/// # Example +/// ``` +/// use std::path::PathBuf; +/// use vx_paths::windows::to_long_path; +/// +/// let path = PathBuf::from(r"C:\Users\name\.vx\store\node\20.0.0\bin\node.exe"); +/// let long_path = to_long_path(&path); +/// // On Windows: \\?\C:\Users\name\.vx\store\node\20.0.0\bin\node.exe +/// // On other platforms: C:\Users\name\.vx\store\node\20.0.0\bin\node.exe +/// ``` +#[cfg(windows)] +pub fn to_long_path(path: &Path) -> PathBuf { + let path_str = path.to_string_lossy(); + + // Already has extended-length prefix + if path_str.starts_with(EXTENDED_PATH_PREFIX) { + return path.to_path_buf(); + } + + // Handle UNC paths (\\server\share) + if path_str.starts_with(r"\\") && !path_str.starts_with(r"\\?") { + // Convert \\server\share to \\?\UNC\server\share + let unc_path = &path_str[2..]; // Remove leading \\ + return PathBuf::from(format!("{}{}", EXTENDED_UNC_PREFIX, unc_path)); + } + + // Only apply to absolute paths + if path.is_absolute() { + PathBuf::from(format!("{}{}", EXTENDED_PATH_PREFIX, path_str)) + } else { + path.to_path_buf() + } +} + +/// Convert a path to extended-length format (no-op on non-Windows) +#[cfg(not(windows))] +pub fn to_long_path(path: &Path) -> PathBuf { + path.to_path_buf() +} + +/// Remove the extended-length prefix from a path. +/// +/// This is useful for display purposes, as paths with the `\\?\` prefix +/// can be confusing to users. +#[cfg(windows)] +pub fn from_long_path(path: &Path) -> PathBuf { + let path_str = path.to_string_lossy(); + + if let Some(unc_path) = path_str.strip_prefix(EXTENDED_UNC_PREFIX) { + // Convert \\?\UNC\server\share back to \\server\share + return PathBuf::from(format!(r"\\{}", unc_path)); + } + + if let Some(regular_path) = path_str.strip_prefix(EXTENDED_PATH_PREFIX) { + return PathBuf::from(regular_path); + } + + path.to_path_buf() +} + +/// Remove the extended-length prefix from a path (no-op on non-Windows) +#[cfg(not(windows))] +pub fn from_long_path(path: &Path) -> PathBuf { + path.to_path_buf() +} + +/// Check if a path length is safe for Windows. +/// +/// Returns a status indicating whether the path is safe, approaching the limit, +/// or too long. +pub fn check_path_length(path: &Path) -> PathLengthStatus { + let path_len = path.to_string_lossy().len(); + + if path_len >= WINDOWS_MAX_PATH { + PathLengthStatus::TooLong { + length: path_len, + path: path.to_path_buf(), + } + } else if path_len >= WINDOWS_PATH_WARN_THRESHOLD { + PathLengthStatus::Warning { + length: path_len, + path: path.to_path_buf(), + } + } else { + PathLengthStatus::Safe + } +} + +/// Check if Windows long path support is enabled via registry. +/// +/// Returns `true` if long path support is enabled, `false` otherwise. +/// On non-Windows platforms, this always returns `true`. +#[cfg(windows)] +pub fn is_long_path_enabled() -> bool { + use std::process::Command; + + // Try to read the registry key + // HKLM\SYSTEM\CurrentControlSet\Control\FileSystem\LongPathsEnabled + let output = Command::new("reg") + .args([ + "query", + r"HKLM\SYSTEM\CurrentControlSet\Control\FileSystem", + "/v", + "LongPathsEnabled", + ]) + .output(); + + match output { + Ok(output) => { + let stdout = String::from_utf8_lossy(&output.stdout); + // Look for "LongPathsEnabled REG_DWORD 0x1" in output + stdout.contains("0x1") + } + Err(_) => false, + } +} + +/// Check if Windows long path support is enabled (always true on non-Windows) +#[cfg(not(windows))] +pub fn is_long_path_enabled() -> bool { + true +} + +/// Get a message about enabling Windows long path support +pub fn get_long_path_enable_instructions() -> &'static str { + r#" +To enable Windows long path support: + +Option 1: Run this PowerShell command (requires Administrator): + New-ItemProperty -Path "HKLM:\SYSTEM\CurrentControlSet\Control\FileSystem" ` + -Name "LongPathsEnabled" -Value 1 -PropertyType DWORD -Force + +Option 2: Via Group Policy (Windows 10 Pro/Enterprise): + 1. Open gpedit.msc + 2. Navigate to: Computer Configuration > Administrative Templates > System > Filesystem + 3. Enable "Enable Win32 long paths" + +Option 3: Set VX_HOME to a shorter path: + $env:VX_HOME = "C:\vx" + +After enabling, restart your terminal or reboot Windows. +"# +} + +/// Log a warning about path length if necessary +pub fn warn_if_path_too_long(path: &Path) { + #[cfg(windows)] + { + let status = check_path_length(path); + if let Some(message) = status.message() { + match status { + PathLengthStatus::TooLong { .. } => { + tracing::error!("{}", message); + if !is_long_path_enabled() { + tracing::error!( + "Consider enabling Windows long path support or using a shorter VX_HOME path" + ); + } + } + PathLengthStatus::Warning { .. } => { + tracing::warn!("{}", message); + } + PathLengthStatus::Safe => {} + } + } + } + + #[cfg(not(windows))] + { + let _ = path; // Suppress unused warning + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_check_path_length_safe() { + let path = PathBuf::from(r"C:\short\path"); + let status = check_path_length(&path); + assert!(status.is_safe()); + assert!(!status.is_too_long()); + assert!(status.message().is_none()); + } + + #[test] + fn test_check_path_length_warning() { + // Create a path just over the warning threshold + let long_segment = "a".repeat(WINDOWS_PATH_WARN_THRESHOLD); + let path = PathBuf::from(format!(r"C:\{}", long_segment)); + let status = check_path_length(&path); + + match status { + PathLengthStatus::Warning { length, .. } => { + assert!(length >= WINDOWS_PATH_WARN_THRESHOLD); + } + PathLengthStatus::TooLong { .. } => { + // Also acceptable if it exceeds MAX_PATH + } + _ => panic!("Expected Warning or TooLong status"), + } + } + + #[test] + fn test_check_path_length_too_long() { + // Create a path over MAX_PATH + let long_segment = "a".repeat(WINDOWS_MAX_PATH); + let path = PathBuf::from(format!(r"C:\{}", long_segment)); + let status = check_path_length(&path); + assert!(status.is_too_long()); + assert!(status.message().is_some()); + } + + #[cfg(windows)] + #[test] + fn test_to_long_path() { + // Regular absolute path + let path = PathBuf::from(r"C:\Users\name\file.txt"); + let long_path = to_long_path(&path); + assert_eq!(long_path.to_string_lossy(), r"\\?\C:\Users\name\file.txt"); + + // Already has prefix + let path = PathBuf::from(r"\\?\C:\Users\name\file.txt"); + let long_path = to_long_path(&path); + assert_eq!(long_path.to_string_lossy(), r"\\?\C:\Users\name\file.txt"); + + // UNC path + let path = PathBuf::from(r"\\server\share\file.txt"); + let long_path = to_long_path(&path); + assert_eq!( + long_path.to_string_lossy(), + r"\\?\UNC\server\share\file.txt" + ); + + // Relative path (should not be modified) + let path = PathBuf::from(r"relative\path"); + let long_path = to_long_path(&path); + assert_eq!(long_path.to_string_lossy(), r"relative\path"); + } + + #[cfg(windows)] + #[test] + fn test_from_long_path() { + // Extended path + let path = PathBuf::from(r"\\?\C:\Users\name\file.txt"); + let short_path = from_long_path(&path); + assert_eq!(short_path.to_string_lossy(), r"C:\Users\name\file.txt"); + + // Extended UNC path + let path = PathBuf::from(r"\\?\UNC\server\share\file.txt"); + let short_path = from_long_path(&path); + assert_eq!(short_path.to_string_lossy(), r"\\server\share\file.txt"); + + // Regular path (should not be modified) + let path = PathBuf::from(r"C:\Users\name\file.txt"); + let short_path = from_long_path(&path); + assert_eq!(short_path.to_string_lossy(), r"C:\Users\name\file.txt"); + } + + #[cfg(not(windows))] + #[test] + fn test_to_long_path_non_windows() { + let path = PathBuf::from("/home/user/file.txt"); + let long_path = to_long_path(&path); + assert_eq!(long_path, path); + } + + #[cfg(not(windows))] + #[test] + fn test_is_long_path_enabled_non_windows() { + // Always true on non-Windows + assert!(is_long_path_enabled()); + } +} diff --git a/crates/vx-paths/tests/link_tests.rs b/crates/vx-paths/tests/link_tests.rs new file mode 100644 index 000000000..4e9aee730 --- /dev/null +++ b/crates/vx-paths/tests/link_tests.rs @@ -0,0 +1,89 @@ +//! Link strategy tests + +use tempfile::TempDir; +use vx_paths::{LinkStrategy, link}; + +#[test] +fn test_link_strategy_auto() { + let strategy = LinkStrategy::auto(); + + // Should return a valid strategy for any platform + assert!(matches!( + strategy, + LinkStrategy::HardLink + | LinkStrategy::SymLink + | LinkStrategy::CopyOnWrite + | LinkStrategy::Copy + )); +} + +#[test] +fn test_link_strategy_name() { + assert_eq!(LinkStrategy::HardLink.name(), "hard link"); + assert_eq!(LinkStrategy::SymLink.name(), "symbolic link"); + assert_eq!(LinkStrategy::CopyOnWrite.name(), "copy-on-write"); + assert_eq!(LinkStrategy::Copy.name(), "copy"); +} + +#[test] +fn test_create_link_copy() { + let temp_dir = TempDir::new().unwrap(); + let src = temp_dir.path().join("src"); + let dst = temp_dir.path().join("dst"); + + // Create source file + std::fs::write(&src, "test content").unwrap(); + + // Copy + link::create_link(&src, &dst, LinkStrategy::Copy).unwrap(); + + // Verify + assert!(dst.exists()); + assert_eq!(std::fs::read_to_string(&dst).unwrap(), "test content"); +} + +#[test] +fn test_create_link_copy_directory() { + let temp_dir = TempDir::new().unwrap(); + let src = temp_dir.path().join("src_dir"); + let dst = temp_dir.path().join("dst_dir"); + + // Create source directory with files + std::fs::create_dir_all(&src).unwrap(); + std::fs::write(src.join("file1.txt"), "content1").unwrap(); + std::fs::write(src.join("file2.txt"), "content2").unwrap(); + + // Copy + link::create_link(&src, &dst, LinkStrategy::Copy).unwrap(); + + // Verify + assert!(dst.exists()); + assert!(dst.join("file1.txt").exists()); + assert!(dst.join("file2.txt").exists()); + assert_eq!( + std::fs::read_to_string(dst.join("file1.txt")).unwrap(), + "content1" + ); +} + +#[test] +fn test_link_directory() { + let temp_dir = TempDir::new().unwrap(); + let src = temp_dir.path().join("src_dir"); + let dst = temp_dir.path().join("dst_dir"); + + // Create source directory with files + std::fs::create_dir_all(&src).unwrap(); + std::fs::write(src.join("file1.txt"), "content1").unwrap(); + std::fs::create_dir_all(src.join("subdir")).unwrap(); + std::fs::write(src.join("subdir/file2.txt"), "content2").unwrap(); + + // Link + let result = link::link_directory(&src, &dst).unwrap(); + + // Verify + assert!(result.success); + assert!(dst.exists()); + assert!(dst.join("file1.txt").exists()); + assert!(dst.join("subdir/file2.txt").exists()); +} diff --git a/crates/vx-paths/tests/manager_tests.rs b/crates/vx-paths/tests/manager_tests.rs new file mode 100644 index 000000000..3868ce236 --- /dev/null +++ b/crates/vx-paths/tests/manager_tests.rs @@ -0,0 +1,105 @@ +//! PathManager tests + +use tempfile::TempDir; +use vx_paths::PathManager; + +#[test] +fn test_path_manager_creation() { + let temp_dir = TempDir::new().unwrap(); + let base_dir = temp_dir.path().join(".vx"); + let manager = PathManager::with_base_dir(&base_dir).unwrap(); + + assert!(manager.store_dir().exists()); + assert!(manager.envs_dir().exists()); + assert!(manager.bin_dir().exists()); + assert!(manager.cache_dir().exists()); + assert!(manager.config_dir().exists()); + assert!(manager.tmp_dir().exists()); +} + +#[test] +fn test_store_paths() { + let temp_dir = TempDir::new().unwrap(); + let base_dir = temp_dir.path().join(".vx"); + let manager = PathManager::with_base_dir(&base_dir).unwrap(); + + let runtime_dir = manager.runtime_store_dir("node"); + let version_dir = manager.version_store_dir("node", "20.0.0"); + let exe_path = manager.store_executable_path("node", "20.0.0"); + + assert_eq!(runtime_dir, base_dir.join("store/node")); + assert_eq!(version_dir, base_dir.join("store/node/20.0.0")); + + if cfg!(target_os = "windows") { + assert_eq!(exe_path, base_dir.join("store/node/20.0.0/bin/node.exe")); + } else { + assert_eq!(exe_path, base_dir.join("store/node/20.0.0/bin/node")); + } +} + +#[test] +fn test_env_paths() { + let temp_dir = TempDir::new().unwrap(); + let base_dir = temp_dir.path().join(".vx"); + let manager = PathManager::with_base_dir(&base_dir).unwrap(); + + let env_dir = manager.env_dir("my-project"); + let default_env = manager.default_env_dir(); + let runtime_path = manager.env_runtime_path("my-project", "node"); + + assert_eq!(env_dir, base_dir.join("envs/my-project")); + assert_eq!(default_env, base_dir.join("envs/default")); + assert_eq!(runtime_path, base_dir.join("envs/my-project/node")); +} + +#[test] +fn test_env_management() { + let temp_dir = TempDir::new().unwrap(); + let base_dir = temp_dir.path().join(".vx"); + let manager = PathManager::with_base_dir(&base_dir).unwrap(); + + // Initially no envs + assert!(!manager.env_exists("test-env")); + assert!(manager.list_envs().unwrap().is_empty()); + + // Create env + manager.create_env("test-env").unwrap(); + assert!(manager.env_exists("test-env")); + assert_eq!(manager.list_envs().unwrap(), vec!["test-env"]); + + // Remove env + manager.remove_env("test-env").unwrap(); + assert!(!manager.env_exists("test-env")); +} + +#[test] +fn test_store_version_check() { + let temp_dir = TempDir::new().unwrap(); + let base_dir = temp_dir.path().join(".vx"); + let manager = PathManager::with_base_dir(&base_dir).unwrap(); + + // Initially not in store + assert!(!manager.is_version_in_store("node", "20.0.0")); + + // Create platform-specific directory (required for is_version_in_store) + // The new directory structure is: /// + let platform_dir = manager.platform_store_dir("node", "20.0.0"); + std::fs::create_dir_all(&platform_dir).unwrap(); + + // Now it should be detected + assert!(manager.is_version_in_store("node", "20.0.0")); + assert_eq!(manager.list_store_versions("node").unwrap(), vec!["20.0.0"]); + assert_eq!(manager.list_store_runtimes().unwrap(), vec!["node"]); +} + +#[test] +fn test_list_store_versions_supports_unified_version_dirs() { + let temp_dir = TempDir::new().unwrap(); + let base_dir = temp_dir.path().join(".vx"); + let manager = PathManager::with_base_dir(&base_dir).unwrap(); + + let version_dir = manager.version_store_dir("uv", "0.11.6"); + std::fs::create_dir_all(&version_dir).unwrap(); + + assert_eq!(manager.list_store_versions("uv").unwrap(), vec!["0.11.6"]); +} diff --git a/crates/vx-paths/tests/paths_tests.rs b/crates/vx-paths/tests/paths_tests.rs new file mode 100644 index 000000000..3f2cc4397 --- /dev/null +++ b/crates/vx-paths/tests/paths_tests.rs @@ -0,0 +1,161 @@ +//! VxPaths tests + +use std::path::PathBuf; +use vx_paths::{VxPaths, executable_extension, normalize_package_name, with_executable_extension}; + +#[test] +fn test_vx_paths_creation() { + let paths = VxPaths::with_base_dir("/tmp/test-vx"); + + assert_eq!(paths.base_dir, PathBuf::from("/tmp/test-vx")); + assert_eq!(paths.store_dir, PathBuf::from("/tmp/test-vx/store")); + assert_eq!(paths.envs_dir, PathBuf::from("/tmp/test-vx/envs")); + assert_eq!(paths.bin_dir, PathBuf::from("/tmp/test-vx/bin")); + assert_eq!(paths.cache_dir, PathBuf::from("/tmp/test-vx/cache")); + assert_eq!(paths.config_dir, PathBuf::from("/tmp/test-vx/config")); + assert_eq!(paths.tmp_dir, PathBuf::from("/tmp/test-vx/tmp")); + // RFC 0025: New directories + assert_eq!(paths.packages_dir, PathBuf::from("/tmp/test-vx/packages")); + assert_eq!(paths.shims_dir, PathBuf::from("/tmp/test-vx/shims")); +} + +#[test] +fn test_runtime_store_dir() { + let paths = VxPaths::with_base_dir("/tmp/test-vx"); + + assert_eq!( + paths.runtime_store_dir("node"), + PathBuf::from("/tmp/test-vx/store/node") + ); +} + +#[test] +fn test_version_store_dir() { + let paths = VxPaths::with_base_dir("/tmp/test-vx"); + + assert_eq!( + paths.version_store_dir("node", "20.0.0"), + PathBuf::from("/tmp/test-vx/store/node/20.0.0") + ); +} + +#[test] +fn test_env_dir() { + let paths = VxPaths::with_base_dir("/tmp/test-vx"); + + assert_eq!( + paths.env_dir("my-project"), + PathBuf::from("/tmp/test-vx/envs/my-project") + ); +} + +#[test] +fn test_default_env_dir() { + let paths = VxPaths::with_base_dir("/tmp/test-vx"); + + assert_eq!( + paths.default_env_dir(), + PathBuf::from("/tmp/test-vx/envs/default") + ); +} + +#[test] +fn test_executable_extension() { + if cfg!(target_os = "windows") { + assert_eq!(executable_extension(), ".exe"); + assert_eq!(with_executable_extension("node"), "node.exe"); + } else { + assert_eq!(executable_extension(), ""); + assert_eq!(with_executable_extension("node"), "node"); + } +} + +// ========== RFC 0025: Global Packages Tests ========== + +#[test] +fn test_ecosystem_packages_dir() { + let paths = VxPaths::with_base_dir("/tmp/test-vx"); + + assert_eq!( + paths.ecosystem_packages_dir("npm"), + PathBuf::from("/tmp/test-vx/packages/npm") + ); + assert_eq!( + paths.ecosystem_packages_dir("pip"), + PathBuf::from("/tmp/test-vx/packages/pip") + ); + assert_eq!( + paths.ecosystem_packages_dir("cargo"), + PathBuf::from("/tmp/test-vx/packages/cargo") + ); +} + +#[test] +fn test_global_package_dir() { + let paths = VxPaths::with_base_dir("/tmp/test-vx"); + + let pkg_dir = paths.global_package_dir("npm", "typescript", "5.3.3"); + assert!(pkg_dir.ends_with("packages/npm/typescript/5.3.3")); +} + +#[test] +fn test_global_package_bin_dir() { + let paths = VxPaths::with_base_dir("/tmp/test-vx"); + + let bin_dir = paths.global_package_bin_dir("npm", "typescript", "5.3.3"); + assert!(bin_dir.ends_with("packages/npm/typescript/5.3.3/bin")); +} + +#[test] +fn test_global_pip_venv_dir() { + let paths = VxPaths::with_base_dir("/tmp/test-vx"); + + let venv_dir = paths.global_pip_venv_dir("black", "24.1.0"); + assert!(venv_dir.ends_with("packages/pip/black/24.1.0/venv")); +} + +#[test] +fn test_global_npm_node_modules_dir() { + let paths = VxPaths::with_base_dir("/tmp/test-vx"); + + let nm_dir = paths.global_npm_node_modules_dir("typescript", "5.3.3"); + assert!(nm_dir.ends_with("packages/npm/typescript/5.3.3/node_modules")); +} + +#[test] +fn test_project_bin_dir() { + let paths = VxPaths::with_base_dir("/tmp/test-vx"); + let project_root = PathBuf::from("/home/user/my-project"); + + let bin_dir = paths.project_bin_dir(&project_root); + assert_eq!(bin_dir, PathBuf::from("/home/user/my-project/.vx/bin")); +} + +#[test] +fn test_global_tools_config() { + let paths = VxPaths::with_base_dir("/tmp/test-vx"); + + let config = paths.global_tools_config(); + assert!(config.ends_with("config/global-tools.toml")); +} + +#[test] +fn test_packages_registry_file() { + let paths = VxPaths::with_base_dir("/tmp/test-vx"); + + let registry = paths.packages_registry_file(); + assert!(registry.ends_with("config/packages-registry.json")); +} + +#[test] +fn test_normalize_package_name() { + // On case-insensitive filesystems (Windows/macOS), normalize to lowercase + // On Linux, keep original case + let normalized = normalize_package_name("TypeScript"); + + #[cfg(any(target_os = "windows", target_os = "macos"))] + assert_eq!(normalized, "typescript"); + + #[cfg(not(any(target_os = "windows", target_os = "macos")))] + assert_eq!(normalized, "TypeScript"); +} diff --git a/crates/vx-paths/tests/platform_redirection_tests.rs b/crates/vx-paths/tests/platform_redirection_tests.rs new file mode 100644 index 000000000..fba36b1cb --- /dev/null +++ b/crates/vx-paths/tests/platform_redirection_tests.rs @@ -0,0 +1,124 @@ +//! Platform redirection tests + +use std::fs; +use vx_paths::PathManager; + +#[test] +fn test_platform_dir_name() { + let manager = PathManager::new().unwrap(); + let platform_name = manager.platform_dir_name(); + + // Platform name should contain os and arch + assert!(platform_name.contains('-')); + + // Common platforms + #[cfg(target_os = "windows")] + { + assert!(platform_name.starts_with("windows")); + #[cfg(target_arch = "x86_64")] + assert_eq!(platform_name, "windows-x64"); + } + + #[cfg(target_os = "macos")] + { + assert!(platform_name.starts_with("darwin")); + #[cfg(target_arch = "x86_64")] + assert_eq!(platform_name, "darwin-x64"); + } + + #[cfg(target_os = "linux")] + { + assert!(platform_name.starts_with("linux")); + #[cfg(target_arch = "x86_64")] + assert_eq!(platform_name, "linux-x64"); + } +} + +#[test] +fn test_platform_store_dir() { + let manager = PathManager::new().unwrap(); + let platform_dir = manager.platform_store_dir("node", "20.0.0"); + + // Should contain platform-specific subdirectory + let platform_name = manager.platform_dir_name(); + assert!(platform_dir.ends_with(format!("node/20.0.0/{}", platform_name))); +} + +#[test] +fn test_is_version_in_store_with_platform() { + let manager = PathManager::new().unwrap(); + + // Create a platform-specific directory + let platform_dir = manager.platform_store_dir("test-tool", "1.0.0"); + fs::create_dir_all(&platform_dir).unwrap(); + + // Should detect as installed + assert!(manager.is_version_in_store("test-tool", "1.0.0")); + + // Clean up + fs::remove_dir_all(&platform_dir).unwrap(); + + // Should not detect as installed + assert!(!manager.is_version_in_store("test-tool", "1.0.0")); +} + +#[test] +fn test_list_store_versions_filters_by_platform() { + let manager = PathManager::new().unwrap(); + + // Use a unique test tool name to avoid conflicts with parallel tests + let test_tool = format!("test-tool-{}", std::process::id()); + + // Clean up any existing test directory first + let runtime_dir = manager.runtime_store_dir(&test_tool); + if runtime_dir.exists() { + fs::remove_dir_all(&runtime_dir).unwrap(); + } + + // Create base version directory + let base_dir = manager.version_store_dir(&test_tool, "1.0.0"); + fs::create_dir_all(&base_dir).unwrap(); + + // Create wrong platform directory (not the current platform) + let current_platform = manager.platform_dir_name(); + let wrong_platform = if current_platform.contains("windows") { + "linux-x64" + } else { + "windows-x64" + }; + let wrong_platform_dir = base_dir.join(wrong_platform); + fs::create_dir_all(&wrong_platform_dir).unwrap(); + + // Verify the wrong platform directory exists but correct one doesn't + assert!( + wrong_platform_dir.exists(), + "Wrong platform dir should exist: {:?}", + wrong_platform_dir + ); + let correct_platform_dir = manager.platform_store_dir(&test_tool, "1.0.0"); + assert!( + !correct_platform_dir.exists(), + "Correct platform dir should NOT exist yet: {:?}", + correct_platform_dir + ); + + // Should NOT list the version (wrong platform only) + let versions = manager.list_store_versions(&test_tool).unwrap(); + assert!( + versions.is_empty(), + "Expected no versions when only wrong platform '{}' exists (current: '{}'), but got: {:?}", + wrong_platform, + current_platform, + versions + ); + + // Create correct platform directory + fs::create_dir_all(&correct_platform_dir).unwrap(); + + // Should list the version (correct platform) + let versions = manager.list_store_versions(&test_tool).unwrap(); + assert_eq!(versions, vec!["1.0.0"]); + + // Clean up + fs::remove_dir_all(&runtime_dir).unwrap(); +} diff --git a/crates/vx-project-analyzer/Cargo.toml b/crates/vx-project-analyzer/Cargo.toml new file mode 100644 index 000000000..85961db39 --- /dev/null +++ b/crates/vx-project-analyzer/Cargo.toml @@ -0,0 +1,49 @@ +[package] +name = "vx-project-analyzer" +version.workspace = true +edition.workspace = true +description = "Project analyzer for vx - detects dependencies, scripts, and tools" +license.workspace = true +repository.workspace = true + +[dependencies] +vx-runtime-core = { workspace = true } +vx-config = { workspace = true } +vx-paths = { workspace = true } + +# Serialization +serde = { workspace = true } +serde_json = { workspace = true } +toml = { workspace = true } + +# Async runtime +tokio = { workspace = true } +async-trait = { workspace = true } + +# Error handling +anyhow = { workspace = true } +thiserror = { workspace = true } + +# File system +walkdir = { workspace = true } +glob = { workspace = true } + +# Regex for script parsing +regex = { workspace = true } + +# Tracing +tracing = { workspace = true } + +# Tool detection +which = { workspace = true } +workspace-hack = { version = "0.1", path = "../workspace-hack" } + +[dev-dependencies] +rstest = { workspace = true } +tempfile = { workspace = true } +tokio = { workspace = true, features = [ + "test-util", + "rt-multi-thread", + "macros", +] } +pretty_assertions = { workspace = true } diff --git a/crates/vx-project-analyzer/examples/analyze_project.rs b/crates/vx-project-analyzer/examples/analyze_project.rs new file mode 100644 index 000000000..5d4cb4ba9 --- /dev/null +++ b/crates/vx-project-analyzer/examples/analyze_project.rs @@ -0,0 +1,73 @@ +//! Example: Analyze a project directory +//! +//! Run with: cargo run -p vx-project-analyzer --example analyze_project -- + +use std::path::Path; +use vx_project_analyzer::{AnalyzerConfig, ProjectAnalyzer}; + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + let args: Vec = std::env::args().collect(); + let root = if args.len() > 1 { + Path::new(&args[1]) + } else { + Path::new(".") + }; + + println!("=== Analyzing: {} ===\n", root.display()); + + let analyzer = ProjectAnalyzer::new(AnalyzerConfig::default()); + let analysis = analyzer.analyze(root).await?; + + println!("📦 Detected ecosystems: {:?}", analysis.ecosystems); + println!(); + + // Display detected frameworks + if !analysis.frameworks.is_empty() { + println!("🖥️ Detected frameworks:"); + for framework in &analysis.frameworks { + print!(" - {}", framework.framework); + if let Some(version) = &framework.version { + print!(" v{}", version); + } + if let Some(build_tool) = &framework.build_tool { + print!(" (build: {})", build_tool); + } + println!(); + if let Some(config_path) = &framework.config_path { + println!(" Config: {}", config_path.display()); + } + for (key, value) in &framework.metadata { + println!(" {}: {}", key, value); + } + } + println!(); + } + + println!("📋 Dependencies: {} found", analysis.dependencies.len()); + for dep in analysis.dependencies.iter().take(10) { + println!( + " - {} ({}) [{}]", + dep.name, + dep.version.as_deref().unwrap_or("*"), + dep.ecosystem + ); + } + if analysis.dependencies.len() > 10 { + println!(" ... and {} more", analysis.dependencies.len() - 10); + } + println!(); + + println!("📜 Scripts: {} found", analysis.scripts.len()); + for script in &analysis.scripts { + println!(" - {}: `{}`", script.name, script.command); + } + println!(); + + println!("🔧 Required tools:"); + for tool in &analysis.required_tools { + println!(" - {}: {}", tool.name, tool.reason); + } + + Ok(()) +} diff --git a/crates/vx-project-analyzer/src/analyzer.rs b/crates/vx-project-analyzer/src/analyzer.rs new file mode 100644 index 000000000..769c47d06 --- /dev/null +++ b/crates/vx-project-analyzer/src/analyzer.rs @@ -0,0 +1,546 @@ +//! Core project analyzer + +use crate::common::JustfileAnalyzer; +use crate::dependency::InstallMethod; +use crate::ecosystem::Ecosystem; +use crate::error::{AnalyzerError, AnalyzerResult}; +use crate::frameworks::{FrameworkDetector, all_framework_detectors}; +use crate::languages::{LanguageAnalyzer, all_analyzers}; +use crate::sync::{SyncManager, VxConfigSnapshot}; +use crate::types::{AuditFinding, AuditSeverity, ProjectAnalysis, RequiredTool, Script}; +use serde::{Deserialize, Serialize}; +use std::collections::HashSet; +use std::path::{Path, PathBuf}; +use tracing::{debug, info}; +use vx_paths::project::{CONFIG_FILE_NAME, CONFIG_FILE_NAME_LEGACY}; + +/// Configuration for the project analyzer +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AnalyzerConfig { + /// Whether to check if dependencies are installed + pub check_installed: bool, + + /// Whether to check if tools are available + pub check_tools: bool, + + /// Whether to generate sync actions + pub generate_sync_actions: bool, + + /// Maximum depth to search for project files + pub max_depth: usize, +} + +impl Default for AnalyzerConfig { + fn default() -> Self { + Self { + check_installed: true, + check_tools: true, + generate_sync_actions: true, + max_depth: 3, + } + } +} + +/// Project analyzer for detecting dependencies, scripts, and tools +pub struct ProjectAnalyzer { + config: AnalyzerConfig, + analyzers: Vec>, + framework_detectors: Vec>, + justfile_analyzer: JustfileAnalyzer, + sync_manager: SyncManager, +} + +impl ProjectAnalyzer { + /// Create a new project analyzer with default config + pub fn new(config: AnalyzerConfig) -> Self { + Self { + config, + analyzers: all_analyzers(), + framework_detectors: all_framework_detectors(), + justfile_analyzer: JustfileAnalyzer::new(), + sync_manager: SyncManager::new(), + } + } + + /// Analyze a project directory + pub async fn analyze(&self, root: &Path) -> AnalyzerResult { + if !root.exists() { + return Err(AnalyzerError::ProjectNotFound { + path: root.to_path_buf(), + }); + } + + let root = root.canonicalize()?; + info!("Analyzing project at: {}", root.display()); + + let mut analysis = ProjectAnalysis::new(root.clone()); + + // Collect directories to analyze (root + immediate subdirectories for monorepo support) + let dirs_to_analyze = self.collect_analysis_dirs(&root).await; + + // Detect ecosystems and run language-specific analyzers + for analyzer in &self.analyzers { + for dir in &dirs_to_analyze { + if analyzer.detect(dir) { + let is_subdir = dir != &root; + if is_subdir { + debug!( + "Detected {} project in subdirectory: {}", + analyzer.name(), + dir.display() + ); + } else { + debug!("Detected {} project", analyzer.name()); + } + + let ecosystem = match analyzer.name() { + "Python" => Ecosystem::Python, + "Node.js" => Ecosystem::NodeJs, + "Rust" => Ecosystem::Rust, + "Go" => Ecosystem::Go, + "C++" => Ecosystem::Cpp, + ".NET/C#" => Ecosystem::DotNet, + _ => Ecosystem::Unknown, + }; + + if !analysis.ecosystems.contains(&ecosystem) { + analysis.ecosystems.push(ecosystem); + } + + // Analyze dependencies + match analyzer.analyze_dependencies(dir).await { + Ok(deps) => { + debug!("Found {} dependencies in {}", deps.len(), dir.display()); + analysis.dependencies.extend(deps); + } + Err(e) => { + debug!("Failed to analyze dependencies in {}: {}", dir.display(), e); + } + } + + // Analyze scripts (only from root directory to avoid duplicates) + if !is_subdir { + match analyzer.analyze_scripts(dir).await { + Ok(scripts) => { + debug!("Found {} scripts", scripts.len()); + analysis.scripts.extend(scripts); + } + Err(e) => { + debug!("Failed to analyze scripts: {}", e); + } + } + } + + // Get required tools + let tools = analyzer.required_tools(&analysis.dependencies, &analysis.scripts); + debug!( + "Required tools: {:?}", + tools.iter().map(|t| &t.name).collect::>() + ); + analysis.required_tools.extend(tools); + } + } + } + + // Detect application frameworks (Electron, Tauri, etc.) + for detector in &self.framework_detectors { + if detector.detect(&root) { + debug!("Detected {} framework", detector.framework()); + + // Get framework info + match detector.get_info(&root).await { + Ok(info) => { + debug!("Framework info: {:?}", info); + analysis.frameworks.push(info); + } + Err(e) => { + debug!("Failed to get framework info: {}", e); + } + } + + // Get framework-specific required tools + let tools = detector.required_tools(&analysis.dependencies, &analysis.scripts); + debug!( + "Framework required tools: {:?}", + tools.iter().map(|t| &t.name).collect::>() + ); + analysis.required_tools.extend(tools); + + // Get additional scripts from framework + match detector.additional_scripts(&root).await { + Ok(scripts) => { + debug!("Found {} framework-specific scripts", scripts.len()); + analysis.scripts.extend(scripts); + } + Err(e) => { + debug!("Failed to get framework scripts: {}", e); + } + } + } + } + + // Run common/cross-language analyzers + // Justfile analyzer - runs once regardless of detected languages + if self.justfile_analyzer.detect(&root) { + debug!("Detected justfile"); + match self.justfile_analyzer.analyze_scripts(&root).await { + Ok(scripts) => { + debug!("Found {} justfile recipes", scripts.len()); + analysis.scripts.extend(scripts); + + // Add 'just' as a required tool + analysis.required_tools.push(RequiredTool::new( + "just", + Ecosystem::Unknown, // just is language-agnostic + "Command runner (justfile)", + InstallMethod::vx("just"), + )); + } + Err(e) => { + debug!("Failed to analyze justfile: {}", e); + } + } + } + + // Deduplicate scripts - keep first occurrence (higher priority sources) + analysis.scripts = deduplicate_scripts(analysis.scripts); + + // Deduplicate required tools + analysis.required_tools.sort_by(|a, b| a.name.cmp(&b.name)); + analysis.required_tools.dedup_by(|a, b| a.name == b.name); + + // Check tool availability + if self.config.check_tools { + self.check_tool_availability(&mut analysis).await; + } + + // Generate sync actions + if self.config.generate_sync_actions { + // Prefer existing config file, otherwise use new format + let vx_config_path = if root.join(CONFIG_FILE_NAME_LEGACY).exists() { + root.join(CONFIG_FILE_NAME_LEGACY) + } else { + root.join(CONFIG_FILE_NAME) + }; + let existing = VxConfigSnapshot::load(&vx_config_path).await?; + analysis.sync_actions = self + .sync_manager + .generate_actions(&analysis, existing.as_ref()); + } + + // Run audit checks + self.run_audit_checks(&root, &mut analysis).await; + + Ok(analysis) + } + + /// Run audit checks on the project + async fn run_audit_checks(&self, root: &Path, analysis: &mut ProjectAnalysis) { + // Check for missing lockfiles + self.audit_missing_lockfiles(root, analysis).await; + + // Check for unpinned dependencies + self.audit_unpinned_dependencies(analysis); + + // Check for mixed ecosystems + self.audit_mixed_ecosystems(analysis); + } + + /// Audit for missing lockfiles when dependencies exist + async fn audit_missing_lockfiles(&self, root: &Path, analysis: &mut ProjectAnalysis) { + // Node.js: has package.json with dependencies but no lockfile + if root.join("package.json").exists() { + let has_deps = analysis + .dependencies + .iter() + .any(|d| d.ecosystem == Ecosystem::NodeJs); + + if has_deps { + let has_lockfile = root.join("package-lock.json").exists() + || root.join("yarn.lock").exists() + || root.join("pnpm-lock.yaml").exists() + || root.join("bun.lockb").exists(); + + if !has_lockfile { + analysis.audit_findings.push( + AuditFinding::new( + AuditSeverity::Warning, + "Missing lockfile for Node.js project", + "Project has dependencies but no lockfile (package-lock.json, yarn.lock, pnpm-lock.yaml, or bun.lockb). This can lead to inconsistent builds.", + ) + .with_suggestion("Run 'npm install', 'yarn', 'pnpm install', or 'bun install' to generate a lockfile") + .with_file(root.join("package.json")), + ); + } + } + } + + // Python: has pyproject.toml with dependencies but no lockfile + if root.join("pyproject.toml").exists() { + let has_deps = analysis + .dependencies + .iter() + .any(|d| d.ecosystem == Ecosystem::Python); + + if has_deps { + let has_lockfile = root.join("uv.lock").exists() + || root.join("poetry.lock").exists() + || root.join("Pipfile.lock").exists() + || root.join("pdm.lock").exists(); + + if !has_lockfile { + analysis.audit_findings.push( + AuditFinding::new( + AuditSeverity::Warning, + "Missing lockfile for Python project", + "Project has dependencies but no lockfile. This can lead to inconsistent builds.", + ) + .with_suggestion("Run 'uv lock' or your package manager's lock command") + .with_file(root.join("pyproject.toml")), + ); + } + } + } + } + + /// Audit for unpinned dependencies (using 'latest', '*', etc.) + fn audit_unpinned_dependencies(&self, analysis: &mut ProjectAnalysis) { + let unpinned: Vec<_> = analysis + .dependencies + .iter() + .filter(|d| { + if let Some(ref version) = d.version { + let v = version.to_lowercase(); + v == "latest" || v == "*" || v.is_empty() + } else { + false + } + }) + .collect(); + + if !unpinned.is_empty() { + let names: Vec<_> = unpinned.iter().map(|d| d.name.as_str()).collect(); + analysis.audit_findings.push( + AuditFinding::new( + AuditSeverity::Warning, + "Unpinned dependencies detected", + format!( + "The following dependencies use unpinned versions (latest, *, etc.): {}. This can lead to unexpected breaking changes.", + names.join(", ") + ), + ) + .with_suggestion("Pin dependencies to specific versions or version ranges"), + ); + } + } + + /// Audit for mixed ecosystems in the same project + fn audit_mixed_ecosystems(&self, analysis: &mut ProjectAnalysis) { + // Filter out Unknown ecosystem + let real_ecosystems: Vec<_> = analysis + .ecosystems + .iter() + .filter(|e| **e != Ecosystem::Unknown) + .collect(); + + if real_ecosystems.len() > 1 { + let ecosystem_names: Vec<_> = + real_ecosystems.iter().map(|e| format!("{:?}", e)).collect(); + analysis.audit_findings.push( + AuditFinding::new( + AuditSeverity::Info, + "Mixed ecosystem project detected", + format!( + "This project uses multiple ecosystems: {}. Consider using vx to manage all runtimes consistently.", + ecosystem_names.join(", ") + ), + ) + .with_suggestion("Use 'vx sync' to ensure all required tools are available"), + ); + } + } + + /// Check if required tools are available + async fn check_tool_availability(&self, analysis: &mut ProjectAnalysis) { + for tool in &mut analysis.required_tools { + tool.is_available = is_tool_available(&tool.name).await; + } + + // Also check tools in scripts + for script in &mut analysis.scripts { + for tool in &mut script.tools { + tool.is_available = is_tool_available(&tool.name).await; + } + } + } + + /// Collect directories to analyze for monorepo support. + /// + /// Returns the root directory plus any immediate subdirectories that might + /// contain language-specific project files (e.g., `codex-rs/` containing Cargo.toml). + async fn collect_analysis_dirs(&self, root: &Path) -> Vec { + let mut dirs = vec![root.to_path_buf()]; + + // Only scan subdirectories if max_depth > 1 + if self.config.max_depth <= 1 { + return dirs; + } + + // Common monorepo subdirectory patterns + let monorepo_indicators = [ + // Language-specific markers + "Cargo.toml", + "go.mod", + "package.json", + "pyproject.toml", + // .NET markers + "global.json", + "Directory.Build.props", + ]; + + // File extensions that indicate a project subdirectory (.NET uses variable-named files) + let project_extensions = ["csproj", "fsproj", "sln"]; + + // Common container directories for monorepos (packages/apps/etc.) + let monorepo_containers = [ + "packages", "apps", "services", "examples", "modules", "libs", + ]; + + // Scan immediate subdirectories + if let Ok(mut entries) = tokio::fs::read_dir(root).await { + while let Ok(Some(entry)) = entries.next_entry().await { + let path = entry.path(); + + // Skip hidden directories and common non-project directories + let Some(name) = path.file_name().and_then(|n| n.to_str()) else { + continue; + }; + + if name.starts_with('.') + || name == "node_modules" + || name == "target" + || name == "dist" + || name == "build" + || name == "vendor" + || name == "__pycache__" + || name == ".git" + { + continue; + } + + if path.is_dir() { + let mut pushed = false; + // Check if this subdirectory contains any project markers (fixed filenames) + for marker in &monorepo_indicators { + if path.join(marker).exists() { + debug!("Found monorepo subdirectory: {}", path.display()); + dirs.push(path.clone()); + pushed = true; + break; + } + } + + // Check for project files by extension (.csproj, .fsproj, .sln) + if !pushed && has_files_with_any_extension(&path, &project_extensions) { + debug!( + "Found project subdirectory (by extension): {}", + path.display() + ); + dirs.push(path.clone()); + pushed = true; + } + + // If this is a common monorepo container (e.g., packages/), scan one level deeper + if !pushed + && monorepo_containers.contains(&name) + && let Ok(mut subentries) = tokio::fs::read_dir(&path).await + { + while let Ok(Some(child)) = subentries.next_entry().await { + let child_path = child.path(); + if !child_path.is_dir() { + continue; + } + let mut child_pushed = false; + for marker in &monorepo_indicators { + if child_path.join(marker).exists() { + debug!("Found monorepo subdirectory: {}", child_path.display()); + dirs.push(child_path.clone()); + child_pushed = true; + break; + } + } + // Also check extensions in nested container dirs + if !child_pushed + && has_files_with_any_extension(&child_path, &project_extensions) + { + debug!( + "Found project subdirectory (by extension): {}", + child_path.display() + ); + dirs.push(child_path.clone()); + } + } + } + } + } + } + + dirs + } + + /// Get the sync manager + pub fn sync_manager(&self) -> &SyncManager { + &self.sync_manager + } +} + +/// Check if a directory contains files with any of the given extensions (non-recursive) +fn has_files_with_any_extension(dir: &Path, extensions: &[&str]) -> bool { + if let Ok(entries) = std::fs::read_dir(dir) { + for entry in entries.flatten() { + let path = entry.path(); + if let Some(ext) = path.extension().and_then(|e| e.to_str()) + && extensions.iter().any(|e| ext.eq_ignore_ascii_case(e)) + { + return true; + } + } + } + false +} + +/// Check if a tool is available in PATH or via vx +async fn is_tool_available(name: &str) -> bool { + // First check PATH + if which::which(name).is_ok() { + return true; + } + + // Check if it's a vx-managed tool + // This would need integration with vx-paths to check installed tools + // For now, just check PATH + false +} + +impl Default for ProjectAnalyzer { + fn default() -> Self { + Self::new(AnalyzerConfig::default()) + } +} + +/// Deduplicate scripts, keeping the first occurrence of each name. +/// +/// This preserves priority order: explicit config > detected scripts. +fn deduplicate_scripts(scripts: Vec