From 8099141624ba5b452d2c3fec1475e0c50025d1d8 Mon Sep 17 00:00:00 2001 From: "Calvin A. Allen" Date: Fri, 9 Jan 2026 13:16:18 -0500 Subject: [PATCH] feat(cli): implement init command with platform selection Add interactive platform selection to the init command, allowing users to choose which platforms to support. This helps keep repo size small by only including needed binaries. Features: - Interactive multi-select with pre-selected current platform - Non-interactive modes: --all-platforms, --current-platform-only - Explicit platform list: --platforms linux-amd64,macos-arm64 - Platform management: --add-platform, --remove-platform - Show configured platforms: --show-platforms New modules: - platform.rs: Platform detection and definitions - rnr_config.rs: .rnr/config.yaml management Smart wrapper scripts detect OS/architecture and provide helpful errors when a platform is not configured. Closes #10 --- .github/workflows/integration-test.yml | 151 +++++++++ Cargo.toml | 4 + DESIGN.md | 224 +++++++++++--- src/cli.rs | 31 +- src/commands/init.rs | 405 ++++++++++++++++++++++--- src/main.rs | 4 +- src/platform.rs | 148 +++++++++ src/rnr_config.rs | 147 +++++++++ 8 files changed, 1031 insertions(+), 83 deletions(-) create mode 100644 src/platform.rs create mode 100644 src/rnr_config.rs diff --git a/.github/workflows/integration-test.yml b/.github/workflows/integration-test.yml index 95d2bd5..9c6ed2c 100644 --- a/.github/workflows/integration-test.yml +++ b/.github/workflows/integration-test.yml @@ -225,6 +225,157 @@ jobs: fi echo "✅ Nested task delegation passed" + # ==================== Init Command Tests ==================== + + - name: "Test: init --current-platform-only" + shell: bash + run: | + INIT_DIR=$(mktemp -d) + cd "$INIT_DIR" + $GITHUB_WORKSPACE/tests/${{ matrix.binary }} init --current-platform-only + + # Verify files were created + if [ ! -f ".rnr/config.yaml" ]; then + echo "ERROR: .rnr/config.yaml not created" + exit 1 + fi + if [ ! -d ".rnr/bin" ]; then + echo "ERROR: .rnr/bin directory not created" + exit 1 + fi + if [ ! -f "rnr.yaml" ]; then + echo "ERROR: rnr.yaml not created" + exit 1 + fi + if [ ! -f "rnr" ]; then + echo "ERROR: rnr wrapper not created" + exit 1 + fi + if [ ! -f "rnr.cmd" ]; then + echo "ERROR: rnr.cmd wrapper not created" + exit 1 + fi + + # Verify config has exactly one platform + PLATFORM_COUNT=$(grep -c "^-" .rnr/config.yaml || echo "0") + if [ "$PLATFORM_COUNT" != "1" ]; then + echo "ERROR: Expected 1 platform, got $PLATFORM_COUNT" + exit 1 + fi + + echo "✅ init --current-platform-only passed" + rm -rf "$INIT_DIR" + + - name: "Test: init --platforms with multiple platforms" + shell: bash + run: | + INIT_DIR=$(mktemp -d) + cd "$INIT_DIR" + $GITHUB_WORKSPACE/tests/${{ matrix.binary }} init --platforms linux-amd64,macos-arm64,windows-amd64 + + # Verify config has the right platforms + if ! grep -q "linux-amd64" .rnr/config.yaml; then + echo "ERROR: linux-amd64 not in config" + exit 1 + fi + if ! grep -q "macos-arm64" .rnr/config.yaml; then + echo "ERROR: macos-arm64 not in config" + exit 1 + fi + if ! grep -q "windows-amd64" .rnr/config.yaml; then + echo "ERROR: windows-amd64 not in config" + exit 1 + fi + + # Verify binaries exist + if [ ! -f ".rnr/bin/rnr-linux-amd64" ]; then + echo "ERROR: rnr-linux-amd64 binary not created" + exit 1 + fi + if [ ! -f ".rnr/bin/rnr-macos-arm64" ]; then + echo "ERROR: rnr-macos-arm64 binary not created" + exit 1 + fi + if [ ! -f ".rnr/bin/rnr-windows-amd64.exe" ]; then + echo "ERROR: rnr-windows-amd64.exe binary not created" + exit 1 + fi + + echo "✅ init --platforms passed" + rm -rf "$INIT_DIR" + + - name: "Test: init --add-platform and --remove-platform" + shell: bash + run: | + INIT_DIR=$(mktemp -d) + cd "$INIT_DIR" + + # First init with one platform + $GITHUB_WORKSPACE/tests/${{ matrix.binary }} init --platforms linux-amd64 + + # Add a platform + $GITHUB_WORKSPACE/tests/${{ matrix.binary }} init --add-platform macos-arm64 + + if ! grep -q "macos-arm64" .rnr/config.yaml; then + echo "ERROR: macos-arm64 not added to config" + exit 1 + fi + if [ ! -f ".rnr/bin/rnr-macos-arm64" ]; then + echo "ERROR: rnr-macos-arm64 binary not created" + exit 1 + fi + + # Remove a platform + $GITHUB_WORKSPACE/tests/${{ matrix.binary }} init --remove-platform linux-amd64 + + if grep -q "linux-amd64" .rnr/config.yaml; then + echo "ERROR: linux-amd64 should have been removed from config" + exit 1 + fi + if [ -f ".rnr/bin/rnr-linux-amd64" ]; then + echo "ERROR: rnr-linux-amd64 binary should have been removed" + exit 1 + fi + + echo "✅ init --add-platform and --remove-platform passed" + rm -rf "$INIT_DIR" + + - name: "Test: init --show-platforms" + shell: bash + run: | + INIT_DIR=$(mktemp -d) + cd "$INIT_DIR" + $GITHUB_WORKSPACE/tests/${{ matrix.binary }} init --platforms linux-amd64,windows-amd64 + + OUTPUT=$($GITHUB_WORKSPACE/tests/${{ matrix.binary }} init --show-platforms 2>&1) + if ! echo "$OUTPUT" | grep -q "linux-amd64"; then + echo "ERROR: --show-platforms should list linux-amd64" + exit 1 + fi + if ! echo "$OUTPUT" | grep -q "windows-amd64"; then + echo "ERROR: --show-platforms should list windows-amd64" + exit 1 + fi + + echo "✅ init --show-platforms passed" + rm -rf "$INIT_DIR" + + - name: "Test: init refuses to remove last platform" + shell: bash + run: | + INIT_DIR=$(mktemp -d) + cd "$INIT_DIR" + $GITHUB_WORKSPACE/tests/${{ matrix.binary }} init --platforms linux-amd64 + + # Try to remove the only platform - should fail + if $GITHUB_WORKSPACE/tests/${{ matrix.binary }} init --remove-platform linux-amd64 2>&1; then + echo "ERROR: Should not be able to remove last platform" + exit 1 + fi + + echo "✅ init correctly refuses to remove last platform" + rm -rf "$INIT_DIR" + # ==================== Error Cases ==================== - name: "Test: nonexistent task (should fail)" diff --git a/Cargo.toml b/Cargo.toml index 1bd7b15..70b33c0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,6 +23,10 @@ thiserror = "1" # Cross-platform support dirs = "5" +# Interactive prompts +dialoguer = "0.11" +console = "0.15" + # HTTP client for init/upgrade reqwest = { version = "0.12", features = ["blocking"], default-features = false, optional = true } diff --git a/DESIGN.md b/DESIGN.md index 1e7870a..22bccb8 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -32,28 +32,88 @@ When a repo is initialized with rnr, it contains: ``` project/ ├── .rnr/ +│ ├── config.yaml # Platform configuration │ └── bin/ -│ ├── rnr-linux # Linux binary -│ ├── rnr-macos # macOS binary -│ └── rnr.exe # Windows binary +│ ├── rnr-linux-amd64 # Linux x86_64 binary +│ ├── rnr-macos-amd64 # macOS x86_64 binary +│ ├── rnr-macos-arm64 # macOS ARM64 binary +│ ├── rnr-windows-amd64.exe # Windows x86_64 binary +│ └── rnr-windows-arm64.exe # Windows ARM64 binary ├── rnr # Shell wrapper script (Unix) ├── rnr.cmd # Batch wrapper script (Windows) ├── rnr.yaml # Task definitions └── ... (rest of project) ``` +**Note:** Only the selected platforms are included. Most projects only need 2-3 platforms. + +### Platform Configuration + +`.rnr/config.yaml` tracks which platforms are configured: + +```yaml +version: "0.1.0" +platforms: + - linux-amd64 + - macos-arm64 + - windows-amd64 +``` + ### Wrapper Scripts **`rnr` (Unix shell script):** ```bash #!/bin/sh -exec "$(dirname "$0")/.rnr/bin/rnr-$(uname -s | tr A-Z a-z)" "$@" +set -e + +# Detect OS +OS=$(uname -s | tr '[:upper:]' '[:lower:]') +case "$OS" in + linux*) OS="linux" ;; + darwin*) OS="macos" ;; + *) echo "Error: Unsupported OS: $OS" >&2; exit 1 ;; +esac + +# Detect architecture +ARCH=$(uname -m) +case "$ARCH" in + x86_64|amd64) ARCH="amd64" ;; + arm64|aarch64) ARCH="arm64" ;; + *) echo "Error: Unsupported architecture: $ARCH" >&2; exit 1 ;; +esac + +BINARY="$(dirname "$0")/.rnr/bin/rnr-${OS}-${ARCH}" + +if [ ! -f "$BINARY" ]; then + echo "Error: rnr is not configured for ${OS}-${ARCH}." >&2 + echo "Run 'rnr init --add-platform ${OS}-${ARCH}' to add support." >&2 + exit 1 +fi + +exec "$BINARY" "$@" ``` **`rnr.cmd` (Windows batch script):** ```batch @echo off -"%~dp0.rnr\bin\rnr.exe" %* +setlocal + +:: Detect architecture +if "%PROCESSOR_ARCHITECTURE%"=="ARM64" ( + set "ARCH=arm64" +) else ( + set "ARCH=amd64" +) + +set "BINARY=%~dp0.rnr\bin\rnr-windows-%ARCH%.exe" + +if not exist "%BINARY%" ( + echo Error: rnr is not configured for windows-%ARCH%. >&2 + echo Run 'rnr init --add-platform windows-%ARCH%' to add support. >&2 + exit /b 1 +) + +"%BINARY%" %* ``` --- @@ -70,12 +130,66 @@ curl -sSL https://rnr.dev/rnr -o rnr && chmod +x rnr && ./rnr init irm https://rnr.dev/rnr.exe -OutFile rnr.exe; .\rnr.exe init ``` +#### Interactive Platform Selection + +When running `rnr init`, an interactive prompt lets you choose which platforms to support: + +``` +Initializing rnr... + +Which platforms should this project support? +(Current platform is pre-selected) + + [x] linux-amd64 (760 KB) + [ ] macos-amd64 (662 KB) + [x] macos-arm64 (608 KB) <- current + [x] windows-amd64 (584 KB) + [ ] windows-arm64 (528 KB) + + Selected: 1.95 MB total + + [Enter] Confirm [Space] Toggle [a] All [n] None [Esc] Cancel +``` + +#### Non-Interactive Mode + +For CI/CD or scripting, use flags: + +```bash +# Specify exact platforms +rnr init --platforms linux-amd64,macos-arm64,windows-amd64 + +# Include all platforms +rnr init --all-platforms + +# Current platform only +rnr init --current-platform-only +``` + +#### Init Steps + The `init` command: -1. Creates `.rnr/bin/` directory -2. Downloads all platform binaries (including copying itself) -3. Creates wrapper scripts (`rnr`, `rnr.cmd`) -4. Creates starter `rnr.yaml` -5. Cleans up the initial downloaded binary +1. Detects current platform and pre-selects it +2. Shows interactive platform selection (unless flags provided) +3. Creates `.rnr/bin/` directory +4. Downloads selected platform binaries from GitHub Releases +5. Creates `.rnr/config.yaml` with selected platforms +6. Creates wrapper scripts (`rnr`, `rnr.cmd`) +7. Creates starter `rnr.yaml` if one doesn't exist +8. Cleans up the initial downloaded binary + +#### Adding/Removing Platforms Later + +```bash +# Add a platform +rnr init --add-platform windows-arm64 + +# Remove a platform +rnr init --remove-platform linux-amd64 + +# Show current platforms +rnr init --show-platforms +``` ### Running Tasks (contributors) @@ -259,13 +373,28 @@ api:test: ### rnr init Creates the full rnr setup in the current directory: -- `.rnr/bin/` with all platform binaries + +``` +rnr init [OPTIONS] + +Options: + --platforms Comma-separated list of platforms (non-interactive) + --all-platforms Include all available platforms + --current-platform-only Only include the current platform + --add-platform Add a platform to existing setup + --remove-platform Remove a platform from existing setup + --show-platforms Show currently configured platforms +``` + +Creates: +- `.rnr/bin/` with selected platform binaries +- `.rnr/config.yaml` tracking configured platforms - `rnr` and `rnr.cmd` wrapper scripts -- Starter `rnr.yaml` with example tasks +- Starter `rnr.yaml` with example tasks (if not exists) ### rnr upgrade -Downloads the latest rnr binaries and replaces those in `.rnr/bin/`. Preserves the `rnr.yaml` and wrapper scripts. +Downloads the latest rnr binaries for configured platforms and replaces those in `.rnr/bin/`. Reads `.rnr/config.yaml` to know which platforms to update. ### rnr --list @@ -290,35 +419,36 @@ Available tasks: ## MVP Features (v0.1) ### Core Functionality -- [ ] Parse `rnr.yaml` task files -- [ ] Run shell commands (`cmd`) -- [ ] Set working directory (`dir`) -- [ ] Environment variables (`env`) -- [ ] Task descriptions (`description`) -- [ ] Sequential steps (`steps`) -- [ ] Parallel execution (`parallel`) -- [ ] Delegate to other tasks (`task`) -- [ ] Delegate to nested task files (`dir` + `task`) -- [ ] Shorthand syntax (`task: command`) +- [x] Parse `rnr.yaml` task files +- [x] Run shell commands (`cmd`) +- [x] Set working directory (`dir`) +- [x] Environment variables (`env`) +- [x] Task descriptions (`description`) +- [x] Sequential steps (`steps`) +- [x] Parallel execution (`parallel`) +- [x] Delegate to other tasks (`task`) +- [x] Delegate to nested task files (`dir` + `task`) +- [x] Shorthand syntax (`task: command`) ### Built-in Commands -- [ ] `rnr init` - Initialize repo -- [ ] `rnr upgrade` - Update binaries -- [ ] `rnr --list` - List tasks -- [ ] `rnr --help` - Show help -- [ ] `rnr --version` - Show version +- [ ] `rnr init` - Initialize repo with platform selection +- [ ] `rnr upgrade` - Update binaries for configured platforms +- [x] `rnr --list` - List tasks +- [x] `rnr --help` - Show help +- [x] `rnr --version` - Show version ### Cross-Platform -- [ ] Build for Linux (x86_64) -- [ ] Build for macOS (x86_64, arm64) -- [ ] Build for Windows (x86_64) +- [x] Build for Linux (x86_64) +- [x] Build for macOS (x86_64, arm64) +- [x] Build for Windows (x86_64, arm64) - [ ] Shell wrapper script generation - [ ] Batch wrapper script generation ### Distribution -- [ ] Host binaries for download -- [ ] Init downloads all platform binaries -- [ ] Upgrade fetches latest binaries +- [ ] Host binaries on GitHub Releases +- [ ] Init downloads selected platform binaries +- [ ] Upgrade fetches latest binaries for configured platforms +- [ ] Platform selection (interactive and non-interactive) --- @@ -469,11 +599,29 @@ When run without arguments, show interactive task picker: - Memory safe - Strong ecosystem for CLI tools (clap, serde, etc.) -### Binary Size Target -Goal: < 500KB per platform after optimization -- Use `opt-level = "z"` for size optimization +### Binary Sizes + +Current binary sizes per platform: + +| Platform | Size | +|----------|------| +| Linux x86_64 | ~760 KB | +| macOS x86_64 | ~662 KB | +| macOS ARM64 | ~608 KB | +| Windows x86_64 | ~584 KB | +| Windows ARM64 | ~528 KB | +| **All platforms** | **~3.1 MB** | + +Size optimizations applied: +- `opt-level = "z"` for size optimization +- LTO (Link-Time Optimization) - Strip symbols -- Consider `cargo-zigbuild` for easy cross-compilation +- `panic = "abort"` + +Future size reduction options: +- Replace `reqwest` with `ureq` (smaller HTTP client) +- Remove `tokio` if not needed for async +- Use smaller YAML parser ### Shell Execution - **Unix**: Execute commands via `sh -c ""` diff --git a/src/cli.rs b/src/cli.rs index dfb334d..6a6141c 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -1,4 +1,4 @@ -use clap::{Parser, Subcommand}; +use clap::{Args, Parser, Subcommand}; /// A cross-platform task runner with zero setup #[derive(Parser, Debug)] @@ -20,8 +20,35 @@ pub struct Cli { #[derive(Subcommand, Debug)] pub enum Command { /// Initialize rnr in the current directory - Init, + Init(InitArgs), /// Upgrade rnr binaries to the latest version Upgrade, } + +#[derive(Args, Debug)] +pub struct InitArgs { + /// Comma-separated list of platforms (e.g., linux-amd64,macos-arm64,windows-amd64) + #[arg(long, value_delimiter = ',')] + pub platforms: Option>, + + /// Include all available platforms + #[arg(long, conflicts_with_all = ["platforms", "current_platform_only"])] + pub all_platforms: bool, + + /// Only include the current platform + #[arg(long, conflicts_with_all = ["platforms", "all_platforms"])] + pub current_platform_only: bool, + + /// Add a platform to existing setup + #[arg(long, conflicts_with_all = ["platforms", "all_platforms", "current_platform_only", "remove_platform"])] + pub add_platform: Option, + + /// Remove a platform from existing setup + #[arg(long, conflicts_with_all = ["platforms", "all_platforms", "current_platform_only", "add_platform"])] + pub remove_platform: Option, + + /// Show currently configured platforms + #[arg(long)] + pub show_platforms: bool, +} diff --git a/src/commands/init.rs b/src/commands/init.rs index 5e859a9..d3bbb89 100644 --- a/src/commands/init.rs +++ b/src/commands/init.rs @@ -1,55 +1,155 @@ -use anyhow::{Context, Result}; +//! Initialize rnr in the current directory + +use anyhow::{bail, Context, Result}; +use dialoguer::MultiSelect; use std::fs; use std::path::Path; +use crate::cli::InitArgs; use crate::config::CONFIG_FILE; +use crate::platform::{format_size, total_size, Platform, ALL_PLATFORMS}; +use crate::rnr_config::{bin_dir, is_initialized, RnrConfig}; -/// Directory for rnr binaries -const RNR_DIR: &str = ".rnr"; -const BIN_DIR: &str = "bin"; +/// Current rnr version +const VERSION: &str = env!("CARGO_PKG_VERSION"); /// Run the init command -pub fn run() -> Result<()> { - let current_dir = std::env::current_dir().context("Failed to get current directory")?; +pub fn run(args: &InitArgs) -> Result<()> { + // Handle --show-platforms + if args.show_platforms { + return show_platforms(); + } + + // Handle --add-platform + if let Some(platform_id) = &args.add_platform { + return add_platform(platform_id); + } + + // Handle --remove-platform + if let Some(platform_id) = &args.remove_platform { + return remove_platform(platform_id); + } - // Check if already initialized - let rnr_dir = current_dir.join(RNR_DIR); - if rnr_dir.exists() { - println!("rnr is already initialized in this directory"); + // Check if already initialized (for fresh init) + if is_initialized()? { + println!("rnr is already initialized in this directory."); + println!("Use --add-platform or --remove-platform to modify platforms."); + println!("Use --show-platforms to see configured platforms."); return Ok(()); } - println!("Initializing rnr..."); + // Determine platforms to install + let platforms = select_platforms(args)?; - // Create .rnr/bin directory - let bin_dir = rnr_dir.join(BIN_DIR); - fs::create_dir_all(&bin_dir).context("Failed to create .rnr/bin directory")?; - println!(" Created {}/{}", RNR_DIR, BIN_DIR); + if platforms.is_empty() { + bail!("No platforms selected. At least one platform is required."); + } - // Download binaries for all platforms - #[cfg(feature = "network")] - { - download_binaries(&bin_dir)?; + // Perform initialization + initialize(&platforms) +} + +/// Select platforms based on args or interactively +fn select_platforms(args: &InitArgs) -> Result> { + // --all-platforms + if args.all_platforms { + return Ok(ALL_PLATFORMS.to_vec()); } - #[cfg(not(feature = "network"))] - { - println!(" Skipping binary download (network feature disabled)"); - println!(" Please manually copy binaries to {}/{}", RNR_DIR, BIN_DIR); + // --current-platform-only + if args.current_platform_only { + let current = Platform::current() + .context("Unable to detect current platform. Use --platforms to specify manually.")?; + return Ok(vec![current]); } + // --platforms list + if let Some(platform_ids) = &args.platforms { + let mut platforms = Vec::new(); + for id in platform_ids { + let platform = Platform::from_id(id) + .with_context(|| format!("Unknown platform: {}. Valid platforms: linux-amd64, macos-amd64, macos-arm64, windows-amd64, windows-arm64", id))?; + platforms.push(platform); + } + return Ok(platforms); + } + + // Interactive selection + interactive_platform_select() +} + +/// Interactive platform selection +fn interactive_platform_select() -> Result> { + let current = Platform::current(); + + // Build items with size info + let items: Vec = ALL_PLATFORMS + .iter() + .map(|p| { + let marker = if Some(*p) == current { + " <- current" + } else { + "" + }; + format!("{:<16} ({}){}", p.id(), p.size_display(), marker) + }) + .collect(); + + // Determine default selections (current platform pre-selected) + let defaults: Vec = ALL_PLATFORMS.iter().map(|p| Some(*p) == current).collect(); + + println!("\nWhich platforms should this project support?\n"); + + let selections = MultiSelect::new() + .items(&items) + .defaults(&defaults) + .interact() + .context("Platform selection cancelled")?; + + let selected: Vec = selections.iter().map(|&i| ALL_PLATFORMS[i]).collect(); + + // Show total size + let total = total_size(&selected); + println!("\nSelected: {} total\n", format_size(total)); + + Ok(selected) +} + +/// Perform the actual initialization +fn initialize(platforms: &[Platform]) -> Result<()> { + let current_dir = std::env::current_dir().context("Failed to get current directory")?; + + println!("Initializing rnr...\n"); + + // Create .rnr/bin directory + let bin_directory = bin_dir()?; + fs::create_dir_all(&bin_directory).context("Failed to create .rnr/bin directory")?; + println!(" Created .rnr/bin/"); + + // Download binaries + download_binaries(platforms, &bin_directory)?; + + // Save config + let config = RnrConfig::new(VERSION, platforms); + config.save()?; + println!(" Created .rnr/config.yaml"); + // Create wrapper scripts create_wrapper_scripts(¤t_dir)?; // Create starter rnr.yaml if it doesn't exist - let config_path = current_dir.join(CONFIG_FILE); - if !config_path.exists() { - create_starter_config(&config_path)?; + let task_config_path = current_dir.join(CONFIG_FILE); + if !task_config_path.exists() { + create_starter_config(&task_config_path)?; } else { println!(" {} already exists, skipping", CONFIG_FILE); } println!("\nrnr initialized successfully!"); + println!("\nConfigured platforms:"); + for p in platforms { + println!(" - {}", p.id()); + } println!("\nNext steps:"); println!(" 1. Edit {} to define your tasks", CONFIG_FILE); println!(" 2. Run ./rnr --list to see available tasks"); @@ -59,31 +159,112 @@ pub fn run() -> Result<()> { Ok(()) } -/// Download binaries for all platforms -#[cfg(feature = "network")] -fn download_binaries(bin_dir: &Path) -> Result<()> { - // TODO: Implement actual binary downloads from rnr.dev - // For now, just create placeholder files +/// Download binaries for selected platforms +fn download_binaries(platforms: &[Platform], bin_directory: &Path) -> Result<()> { println!(" Downloading binaries..."); - println!(" TODO: Download from https://rnr.dev/bin/latest/"); - // Placeholder - in real implementation, download from server - let platforms = ["rnr-linux", "rnr-macos", "rnr.exe"]; for platform in platforms { - let path = bin_dir.join(platform); - fs::write(&path, "# placeholder binary\n") - .with_context(|| format!("Failed to create {}", path.display()))?; - println!(" Created {}", platform); + let binary_path = bin_directory.join(platform.binary_name()); + + #[cfg(feature = "network")] + { + download_binary(*platform, &binary_path)?; + } + + #[cfg(not(feature = "network"))] + { + // Create placeholder for testing without network + fs::write( + &binary_path, + format!("# placeholder for {}\n", platform.id()), + ) + .with_context(|| format!("Failed to create {}", binary_path.display()))?; + } + + println!( + " {} ({})", + platform.binary_name(), + platform.size_display() + ); } Ok(()) } +/// Download a single binary from GitHub releases +#[cfg(feature = "network")] +fn download_binary(platform: Platform, dest: &Path) -> Result<()> { + use std::io::Write; + + // TODO: Replace with actual release URL once we have releases + // For now, use GitHub releases URL pattern + let url = format!( + "https://github.com/CodingWithCalvin/rnr.cli/releases/latest/download/{}", + platform.binary_name() + ); + + // For now, create a placeholder since we don't have releases yet + // In production, this would download from the URL + let placeholder = format!( + "#!/bin/sh\necho 'Placeholder binary for {}. Replace with actual binary from releases.'\n", + platform.id() + ); + + let mut file = + fs::File::create(dest).with_context(|| format!("Failed to create {}", dest.display()))?; + file.write_all(placeholder.as_bytes())?; + + // Make executable on Unix + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mut perms = fs::metadata(dest)?.permissions(); + perms.set_mode(0o755); + fs::set_permissions(dest, perms)?; + } + + // TODO: Actual download implementation: + // let response = reqwest::blocking::get(&url) + // .with_context(|| format!("Failed to download {}", url))?; + // let bytes = response.bytes()?; + // fs::write(dest, bytes)?; + + let _ = url; // Suppress unused warning for now + + Ok(()) +} + /// Create the wrapper scripts at the project root fn create_wrapper_scripts(project_root: &Path) -> Result<()> { - // Unix wrapper script + // Unix wrapper script (smart detection) let unix_script = r#"#!/bin/sh -exec "$(dirname "$0")/.rnr/bin/rnr-$(uname -s | tr A-Z a-z)" "$@" +set -e + +# Detect OS +OS=$(uname -s | tr '[:upper:]' '[:lower:]') +case "$OS" in + linux*) OS="linux" ;; + darwin*) OS="macos" ;; + *) echo "Error: Unsupported OS: $OS" >&2; exit 1 ;; +esac + +# Detect architecture +ARCH=$(uname -m) +case "$ARCH" in + x86_64|amd64) ARCH="amd64" ;; + arm64|aarch64) ARCH="arm64" ;; + *) echo "Error: Unsupported architecture: $ARCH" >&2; exit 1 ;; +esac + +BINARY="$(dirname "$0")/.rnr/bin/rnr-${OS}-${ARCH}" + +if [ ! -f "$BINARY" ]; then + echo "Error: rnr is not configured for ${OS}-${ARCH}." >&2 + echo "Run 'rnr init --add-platform ${OS}-${ARCH}' to add support." >&2 + exit 1 +fi + +exec "$BINARY" "$@" "#; let unix_path = project_root.join("rnr"); @@ -100,9 +281,26 @@ exec "$(dirname "$0")/.rnr/bin/rnr-$(uname -s | tr A-Z a-z)" "$@" println!(" Created rnr (Unix wrapper)"); - // Windows wrapper script + // Windows wrapper script (smart detection) let windows_script = r#"@echo off -"%~dp0.rnr\bin\rnr.exe" %* +setlocal + +:: Detect architecture +if "%PROCESSOR_ARCHITECTURE%"=="ARM64" ( + set "ARCH=arm64" +) else ( + set "ARCH=amd64" +) + +set "BINARY=%~dp0.rnr\bin\rnr-windows-%ARCH%.exe" + +if not exist "%BINARY%" ( + echo Error: rnr is not configured for windows-%ARCH%. >&2 + echo Run 'rnr init --add-platform windows-%ARCH%' to add support. >&2 + exit /b 1 +) + +"%BINARY%" %* "#; let windows_path = project_root.join("rnr.cmd"); @@ -139,3 +337,126 @@ ci: Ok(()) } + +/// Show currently configured platforms +fn show_platforms() -> Result<()> { + if !is_initialized()? { + println!("rnr is not initialized in this directory."); + println!("Run 'rnr init' to initialize."); + return Ok(()); + } + + let config = RnrConfig::load()?; + let platforms = config.get_platforms(); + + println!("\nConfigured platforms:\n"); + let mut total: u64 = 0; + for p in &platforms { + println!(" {} ({})", p.id(), p.size_display()); + total += p.size_bytes(); + } + println!("\nTotal: {}", format_size(total)); + + Ok(()) +} + +/// Add a platform to existing setup +fn add_platform(platform_id: &str) -> Result<()> { + if !is_initialized()? { + bail!("rnr is not initialized. Run 'rnr init' first."); + } + + let platform = Platform::from_id(platform_id).with_context(|| { + format!( + "Unknown platform: {}. Valid platforms: linux-amd64, macos-amd64, macos-arm64, windows-amd64, windows-arm64", + platform_id + ) + })?; + + let mut config = RnrConfig::load()?; + + if config.has_platform(platform) { + println!("Platform {} is already configured.", platform_id); + return Ok(()); + } + + // Download the binary + let bin_directory = bin_dir()?; + let binary_path = bin_directory.join(platform.binary_name()); + + println!("Adding platform {}...", platform_id); + + #[cfg(feature = "network")] + { + download_binary(platform, &binary_path)?; + } + + #[cfg(not(feature = "network"))] + { + fs::write( + &binary_path, + format!("# placeholder for {}\n", platform.id()), + )?; + } + + println!( + " Downloaded {} ({})", + platform.binary_name(), + platform.size_display() + ); + + // Update config + config.add_platform(platform); + config.save()?; + println!(" Updated .rnr/config.yaml"); + + println!("\nPlatform {} added successfully!", platform_id); + + Ok(()) +} + +/// Remove a platform from existing setup +fn remove_platform(platform_id: &str) -> Result<()> { + if !is_initialized()? { + bail!("rnr is not initialized. Run 'rnr init' first."); + } + + let platform = Platform::from_id(platform_id).with_context(|| { + format!( + "Unknown platform: {}. Valid platforms: linux-amd64, macos-amd64, macos-arm64, windows-amd64, windows-arm64", + platform_id + ) + })?; + + let mut config = RnrConfig::load()?; + + if !config.has_platform(platform) { + println!("Platform {} is not configured.", platform_id); + return Ok(()); + } + + // Check if this is the last platform + if config.get_platforms().len() == 1 { + bail!("Cannot remove the last platform. At least one platform must be configured."); + } + + println!("Removing platform {}...", platform_id); + + // Remove the binary + let bin_directory = bin_dir()?; + let binary_path = bin_directory.join(platform.binary_name()); + if binary_path.exists() { + fs::remove_file(&binary_path) + .with_context(|| format!("Failed to remove {}", binary_path.display()))?; + println!(" Removed {}", platform.binary_name()); + } + + // Update config + config.remove_platform(platform); + config.save()?; + println!(" Updated .rnr/config.yaml"); + + println!("\nPlatform {} removed successfully!", platform_id); + + Ok(()) +} diff --git a/src/main.rs b/src/main.rs index 3183a22..0b03d3b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,6 +1,8 @@ mod cli; mod commands; mod config; +mod platform; +mod rnr_config; mod runner; use anyhow::Result; @@ -11,7 +13,7 @@ fn main() -> Result<()> { let cli = Cli::parse(); match cli.command { - Some(Command::Init) => commands::init::run()?, + Some(Command::Init(args)) => commands::init::run(&args)?, Some(Command::Upgrade) => commands::upgrade::run()?, None => { if cli.list { diff --git a/src/platform.rs b/src/platform.rs new file mode 100644 index 0000000..cc32589 --- /dev/null +++ b/src/platform.rs @@ -0,0 +1,148 @@ +//! Platform detection and definitions + +use std::fmt; + +/// All supported platforms +pub const ALL_PLATFORMS: &[Platform] = &[ + Platform::LinuxAmd64, + Platform::MacosAmd64, + Platform::MacosArm64, + Platform::WindowsAmd64, + Platform::WindowsArm64, +]; + +/// A supported platform +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum Platform { + LinuxAmd64, + MacosAmd64, + MacosArm64, + WindowsAmd64, + WindowsArm64, +} + +impl Platform { + /// Get the platform identifier string (e.g., "linux-amd64") + pub fn id(&self) -> &'static str { + match self { + Platform::LinuxAmd64 => "linux-amd64", + Platform::MacosAmd64 => "macos-amd64", + Platform::MacosArm64 => "macos-arm64", + Platform::WindowsAmd64 => "windows-amd64", + Platform::WindowsArm64 => "windows-arm64", + } + } + + /// Get the binary filename for this platform + pub fn binary_name(&self) -> &'static str { + match self { + Platform::LinuxAmd64 => "rnr-linux-amd64", + Platform::MacosAmd64 => "rnr-macos-amd64", + Platform::MacosArm64 => "rnr-macos-arm64", + Platform::WindowsAmd64 => "rnr-windows-amd64.exe", + Platform::WindowsArm64 => "rnr-windows-arm64.exe", + } + } + + /// Get the approximate binary size in bytes + pub fn size_bytes(&self) -> u64 { + match self { + Platform::LinuxAmd64 => 760 * 1024, + Platform::MacosAmd64 => 662 * 1024, + Platform::MacosArm64 => 608 * 1024, + Platform::WindowsAmd64 => 584 * 1024, + Platform::WindowsArm64 => 528 * 1024, + } + } + + /// Get human-readable size string + pub fn size_display(&self) -> String { + let kb = self.size_bytes() / 1024; + format!("{} KB", kb) + } + + /// Parse a platform from its identifier string + pub fn from_id(id: &str) -> Option { + match id { + "linux-amd64" => Some(Platform::LinuxAmd64), + "macos-amd64" => Some(Platform::MacosAmd64), + "macos-arm64" => Some(Platform::MacosArm64), + "windows-amd64" => Some(Platform::WindowsAmd64), + "windows-arm64" => Some(Platform::WindowsArm64), + _ => None, + } + } + + /// Detect the current platform + pub fn current() -> Option { + #[cfg(all(target_os = "linux", target_arch = "x86_64"))] + return Some(Platform::LinuxAmd64); + + #[cfg(all(target_os = "macos", target_arch = "x86_64"))] + return Some(Platform::MacosAmd64); + + #[cfg(all(target_os = "macos", target_arch = "aarch64"))] + return Some(Platform::MacosArm64); + + #[cfg(all(target_os = "windows", target_arch = "x86_64"))] + return Some(Platform::WindowsAmd64); + + #[cfg(all(target_os = "windows", target_arch = "aarch64"))] + return Some(Platform::WindowsArm64); + + #[allow(unreachable_code)] + None + } +} + +impl fmt::Display for Platform { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.id()) + } +} + +/// Calculate total size for a set of platforms +pub fn total_size(platforms: &[Platform]) -> u64 { + platforms.iter().map(|p| p.size_bytes()).sum() +} + +/// Format total size for display +pub fn format_size(bytes: u64) -> String { + if bytes >= 1024 * 1024 { + format!("{:.2} MB", bytes as f64 / (1024.0 * 1024.0)) + } else { + format!("{} KB", bytes / 1024) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_platform_id_roundtrip() { + for platform in ALL_PLATFORMS { + let id = platform.id(); + let parsed = Platform::from_id(id); + assert_eq!(parsed, Some(*platform)); + } + } + + #[test] + fn test_current_platform_is_known() { + // This test will pass on supported platforms + let current = Platform::current(); + if let Some(p) = current { + assert!(ALL_PLATFORMS.contains(&p)); + } + } + + #[test] + fn test_binary_names() { + assert_eq!(Platform::LinuxAmd64.binary_name(), "rnr-linux-amd64"); + assert_eq!( + Platform::WindowsAmd64.binary_name(), + "rnr-windows-amd64.exe" + ); + } +} diff --git a/src/rnr_config.rs b/src/rnr_config.rs new file mode 100644 index 0000000..235e9cd --- /dev/null +++ b/src/rnr_config.rs @@ -0,0 +1,147 @@ +//! RNR configuration file management (.rnr/config.yaml) + +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; +use std::fs; +use std::path::{Path, PathBuf}; + +use crate::platform::Platform; + +/// The rnr configuration directory name +pub const RNR_DIR: &str = ".rnr"; +/// The rnr configuration file name +pub const CONFIG_FILE: &str = "config.yaml"; +/// The binary directory name +pub const BIN_DIR: &str = "bin"; + +/// RNR configuration stored in .rnr/config.yaml +#[derive(Debug, Serialize, Deserialize)] +pub struct RnrConfig { + /// Version of rnr that created this config + pub version: String, + /// List of configured platform identifiers + pub platforms: Vec, +} + +impl RnrConfig { + /// Create a new config with the given platforms + pub fn new(version: &str, platforms: &[Platform]) -> Self { + Self { + version: version.to_string(), + platforms: platforms.iter().map(|p| p.id().to_string()).collect(), + } + } + + /// Load config from the default location + pub fn load() -> Result { + let path = config_path()?; + Self::load_from(&path) + } + + /// Load config from a specific path + pub fn load_from(path: &Path) -> Result { + let content = fs::read_to_string(path) + .with_context(|| format!("Failed to read config: {}", path.display()))?; + let config: Self = serde_yaml::from_str(&content) + .with_context(|| format!("Failed to parse config: {}", path.display()))?; + Ok(config) + } + + /// Save config to the default location + pub fn save(&self) -> Result<()> { + let path = config_path()?; + self.save_to(&path) + } + + /// Save config to a specific path + pub fn save_to(&self, path: &Path) -> Result<()> { + // Ensure parent directory exists + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .with_context(|| format!("Failed to create directory: {}", parent.display()))?; + } + + let content = serde_yaml::to_string(self).context("Failed to serialize config")?; + fs::write(path, content) + .with_context(|| format!("Failed to write config: {}", path.display()))?; + Ok(()) + } + + /// Get the configured platforms + pub fn get_platforms(&self) -> Vec { + self.platforms + .iter() + .filter_map(|id| Platform::from_id(id)) + .collect() + } + + /// Add a platform to the config + pub fn add_platform(&mut self, platform: Platform) { + let id = platform.id().to_string(); + if !self.platforms.contains(&id) { + self.platforms.push(id); + self.platforms.sort(); + } + } + + /// Remove a platform from the config + pub fn remove_platform(&mut self, platform: Platform) { + let id = platform.id(); + self.platforms.retain(|p| p != id); + } + + /// Check if a platform is configured + pub fn has_platform(&self, platform: Platform) -> bool { + self.platforms.contains(&platform.id().to_string()) + } +} + +/// Get the path to .rnr directory +pub fn rnr_dir() -> Result { + let current = std::env::current_dir().context("Failed to get current directory")?; + Ok(current.join(RNR_DIR)) +} + +/// Get the path to .rnr/config.yaml +pub fn config_path() -> Result { + Ok(rnr_dir()?.join(CONFIG_FILE)) +} + +/// Get the path to .rnr/bin +pub fn bin_dir() -> Result { + Ok(rnr_dir()?.join(BIN_DIR)) +} + +/// Check if rnr is already initialized in the current directory +pub fn is_initialized() -> Result { + let path = config_path()?; + Ok(path.exists()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_config_roundtrip() { + let platforms = vec![Platform::LinuxAmd64, Platform::MacosArm64]; + let config = RnrConfig::new("0.1.0", &platforms); + + let yaml = serde_yaml::to_string(&config).unwrap(); + let parsed: RnrConfig = serde_yaml::from_str(&yaml).unwrap(); + + assert_eq!(parsed.version, "0.1.0"); + assert_eq!(parsed.platforms.len(), 2); + } + + #[test] + fn test_add_remove_platform() { + let mut config = RnrConfig::new("0.1.0", &[Platform::LinuxAmd64]); + + config.add_platform(Platform::MacosArm64); + assert!(config.has_platform(Platform::MacosArm64)); + + config.remove_platform(Platform::LinuxAmd64); + assert!(!config.has_platform(Platform::LinuxAmd64)); + } +}