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)); + } +}