rootle 0.9.1

Modal TUI for browsing remote source-control systems (GitHub in-tree, any forge via stdio providers) from the terminal
Documentation
//! CLI arguments (PLAN.md milestone 8) + the provider manager
//! subcommand tree (plans/0010 M3).

use crate::config::Config;
use clap::{Parser, Subcommand};
use std::path::PathBuf;

/// Browse GitHub repos in your terminal — a yazi-like, modal TUI.
#[derive(Debug, Parser)]
#[command(name = "rootle", version, about)]
pub struct Cli {
    /// Open this repo directly (owner/repo, or owner/repo@branch),
    /// skipping the search popup.
    pub repo: Option<String>,

    /// Use this config file instead of ~/.config/rootle/config.toml.
    #[arg(long, value_name = "PATH")]
    pub config: Option<PathBuf>,

    /// Theme name, resolved against ~/.config/rootle/themes/<name>.toml
    /// (missing keys fall back to the embedded catppuccin-mocha).
    #[arg(long, value_name = "NAME")]
    pub theme: Option<String>,

    /// Provider management (plans/0010): install, list, update,
    /// upgrade, pin, remove, use.
    #[command(subcommand)]
    pub provider: Option<ProviderCommand>,

    /// Update rootle itself (tarball/install.sh installs self-update,
    /// checksum-verified; brew/cargo/mise installs get their channel's
    /// command).
    #[arg(long)]
    pub update: bool,

    /// With --update: report only, don't write.
    #[arg(long, requires = "update")]
    pub check: bool,

    /// Scripted, deterministic driver (plans/0023 M1): keys in, cell
    /// grid frames + state JSON out — no PTY, no raw mode. `-` reads
    /// the script from stdin. Viewport: ROOTLE_HEADLESS_COLS/ROWS.
    #[arg(long, value_name = "SCRIPT")]
    pub headless: Option<PathBuf>,
}

/// Install a provider from a GitHub release, manage it locally, and
/// switch the active backend.
#[derive(Debug, Subcommand)]
pub enum ProviderCommand {
    /// Install a provider binary from a release or a plain-HTTP tarball.
    ///
    /// REF: `gitlab` (bare name, resolves to rootledev/rootle-gitlab),
    /// `owner/repo`, `https://github.com/owner/repo`, each optionally
    /// `@tag`-pinned — or a plain-HTTP URL naming the platform tarball
    /// (`…/rootle-NAME[-vX.Y.Z]-TARGET.tar.gz`; the mandatory `.sha256`
    /// sidecar rides next to it). Checksum verification is mandatory.
    ///
    /// Plain-HTTP and `--path` installs are install-and-pin:
    /// `update`/`upgrade` track only releases-API sources (github.com
    /// slugs/URLs); upgrades for the rest come from whatever deployed
    /// them. `--path` is a first-class deployment shape — the steady
    /// state for config-managed installs.
    Install {
        ref_: String,

        /// Pin to the given tag (or the latest when already @tag'd).
        #[arg(long)]
        pin: bool,

        /// Reinstall even when the version is already present.
        #[arg(long)]
        force: bool,

        /// Local binary path — symlink, no network (development).
        #[arg(long, value_name = "PATH")]
        path: Option<PathBuf>,
    },

    /// List installed providers.
    List {
        /// JSON for scripting.
        #[arg(long)]
        json: bool,
    },

    /// Refresh latest-known release tags (non-mutating).
    Update {
        /// A specific provider (default: all).
        name: Option<String>,
    },

    /// Upgrade provider binaries to the latest release.
    Upgrade {
        /// A specific provider, or --all.
        name: Option<String>,

        #[arg(long)]
        all: bool,

        /// Print old → new without swapping.
        #[arg(long)]
        dry_run: bool,

        /// Upgrade pinned providers too.
        #[arg(long)]
        force: bool,
    },

    /// Freeze a provider at its current (or given) tag.
    Pin { name: String, tag: Option<String> },

    /// Unfreeze a provider (resume auto-upgrades).
    Unpin { name: String },

    /// Remove a provider binary and its receipt.
    Remove { name: String },

    /// Activate a provider: writes [provider] in config.toml.
    ///
    /// Extra argv after -- reaches the provider binary (e.g. `use
    /// gitlab -- --instance https://gitlab.example.com`).
    Use {
        name: String,
        #[arg(last = true)]
        extra: Vec<String>,
    },
}

impl Cli {
    /// Split the positional repo argument into (owner, name, ref):
    /// `owner/repo`, or `owner/repo@ref` (plans/0016 M1a — ref is
    /// everything after the first `@`; slashes legal: `release/2.7`).
    pub fn repo_parts(&self) -> Option<(String, String, Option<String>)> {
        let repo = self.repo.as_ref()?;
        let (path, ref_) = match repo.split_once('@') {
            Some((p, r)) if !r.is_empty() => (p, Some(r.to_string())),
            Some((p, _)) => (p, None), // trailing @ pins nothing
            None => (repo.as_str(), None),
        };
        let (owner, name) = path.split_once('/')?;
        if owner.is_empty() || name.is_empty() {
            return None;
        }
        Some((owner.to_string(), name.to_string(), ref_))
    }

    /// The launch theme: --theme beats config; border/nerd-font/
    /// separator ride along. Shared by the TUI and the headless
    /// driver (plans/0023 M1).
    pub fn resolve_theme(&self, config: &Config) -> crate::theme::Theme {
        use crate::theme::{BorderShape, Theme};
        Theme::load(self.theme.as_deref().unwrap_or(&config.theme.name))
            .with_border(BorderShape::parse(&config.ui.border).unwrap_or_default())
            .with_nerd_font(config.ui.nerd_font)
            .with_separator(&config.ui.separator)
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn cli(repo: Option<&str>) -> Cli {
        Cli {
            repo: repo.map(str::to_string),
            config: None,
            theme: None,
            provider: None,
            update: false,
            check: false,
            headless: None,
        }
    }

    #[test]
    fn parses_owner_repo() {
        assert_eq!(
            cli(Some("ratatui/ratatui")).repo_parts(),
            Some(("ratatui".into(), "ratatui".into(), None))
        );
    }

    #[test]
    fn parses_repo_at_ref() {
        assert_eq!(
            cli(Some("ratatui/ratatui@release/2.7")).repo_parts(),
            Some((
                "ratatui".into(),
                "ratatui".into(),
                Some("release/2.7".into())
            ))
        );
        // A bare @ pins nothing.
        assert_eq!(
            cli(Some("ratatui/ratatui@")).repo_parts(),
            Some(("ratatui".into(), "ratatui".into(), None))
        );
    }

    #[test]
    fn rejects_malformed_repo() {
        assert_eq!(cli(Some("noslash")).repo_parts(), None);
        assert_eq!(cli(Some("/empty")).repo_parts(), None);
        assert_eq!(cli(None).repo_parts(), None);
    }

    #[test]
    fn parses_provider_subcommands() {
        use clap::Parser;
        let c = Cli::try_parse_from(["rootle", "provider", "list"]).unwrap();
        assert!(matches!(
            c.provider,
            Some(ProviderCommand::List { json: false })
        ));
        let c = Cli::try_parse_from(["rootle", "provider", "install", "gitlab", "--pin"]).unwrap();
        assert!(matches!(
            c.provider,
            Some(ProviderCommand::Install { ref_, pin: true, .. }) if ref_ == "gitlab"
        ));
        let c = Cli::try_parse_from([
            "rootle",
            "provider",
            "use",
            "gitlab",
            "--",
            "--instance",
            "https://gitlab.example.com",
        ])
        .unwrap();
        assert!(matches!(
            c.provider,
            Some(ProviderCommand::Use { name, extra, .. }) if name == "gitlab" && extra.len() == 2
        ));
    }
}