rootle 0.13.1

Modal TUI for browsing remote source-control systems 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.
    #[arg(value_parser = repository_argument)]
    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
    /// (overrides the named builtin; unknown names start from Hone).
    #[arg(long, value_name = "NAME")]
    pub theme: Option<String>,

    /// Application maintenance or explicitly namespaced provider management.
    #[command(subcommand)]
    pub command: Option<RootCommand>,

    /// Update rootle and its managed providers (equivalent to `rootle update`).
    #[arg(long, conflicts_with_all = ["headless", "repo", "theme", "config"])]
    pub update: bool,

    /// With --update: report available upgrades without installing them.
    #[arg(long, requires = "update")]
    pub check: bool,

    /// Run a script without a terminal; `-` reads stdin. Viewport: ROOTLE_HEADLESS_COLS/ROWS.
    ///
    /// Directives, one per line (blank lines and lines starting with # are ignored):
    ///   keys <text>   send keys; tokens: <esc> <cr> <bs> <tab> <space>
    ///                 <up> <down> <left> <right>
    ///   settle [ms]   wait for all workers and their queued follow-ups
    ///                 (default 10000ms); timeout stops the script and exits nonzero
    ///   wait <ms>     process events for a fixed duration
    ///   frame         print the rendered cell grid
    ///   state         print one JSON state record
    ///
    /// Startup waits for outstanding loads (up to 10000ms) before step one.
    /// After navigation, use settle before sampling frame or state.
    /// Settled means work finished, not success: inspect state for provider errors.
    ///
    /// Example: printf 'settle\nframe\nstate\n' | rootle owner/repo --headless -
    #[arg(long, value_name = "SCRIPT", verbatim_doc_comment)]
    pub headless: Option<PathBuf>,

    /// Record all diagnostic categories in a new session file.
    /// Omit PATH (or use ALL) for an automatic XDG-state log path.
    #[arg(long, global = true, num_args = 0..=1, require_equals = true, default_missing_value = "ALL", value_name = "PATH")]
    pub log: Option<std::ffi::OsString>,

    /// Write diagnostics to this new file; existing files are refused.
    /// ROOTLE_TRACE supplies the path when no CLI path is selected.
    #[arg(long, global = true, value_name = "PATH", conflicts_with = "log")]
    pub log_file: Option<PathBuf>,

    /// Include sensitive input, visible UI text and provider stderr.
    #[arg(long, global = true)]
    pub log_content: bool,
}

#[derive(Debug, Subcommand)]
pub enum RootCommand {
    /// Update rootle itself, then refresh and upgrade managed providers.
    Update {
        /// Report available upgrades without replacing binaries.
        #[arg(long)]
        check: bool,
    },
    /// Update only rootle (package-manager installs receive their upgrade command).
    SelfUpdate {
        /// Report the application update without replacing the executable.
        #[arg(long)]
        check: bool,
    },
    /// Install, inspect, update or configure provider binaries.
    Provider {
        #[command(subcommand)]
        command: ProviderCommand,
    },
}

/// 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>,
    },
}

fn split_repository(value: &str) -> Option<(&str, &str, Option<&str>)> {
    let (path, revision) = match value.split_once('@') {
        Some((path, revision)) => (path, (!revision.is_empty()).then_some(revision)),
        None => (value, None),
    };
    let (owner, name) = path.split_once('/')?;
    (!owner.is_empty() && !name.is_empty()).then_some((owner, name, revision))
}

fn repository_argument(value: &str) -> Result<String, String> {
    split_repository(value)
        .map(|_| value.to_owned())
        .ok_or_else(|| "expected owner/repo[@revision]".to_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 (owner, name, revision) = split_repository(self.repo.as_deref()?)?;
        Some((
            owner.to_string(),
            name.to_string(),
            revision.map(str::to_string),
        ))
    }

    /// Reject ignored root-level options while permitting global diagnostics
    /// on either side of a subcommand.
    pub fn validate_execution(&self) -> Result<(), &'static str> {
        if self.command.is_some()
            && (self.repo.is_some()
                || self.headless.is_some()
                || self.update
                || self.config.is_some()
                || self.theme.is_some())
        {
            return Err(
                "maintenance commands cannot be combined with a repository, --headless, --update, --config or --theme",
            );
        }
        Ok(())
    }

    /// 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,
            command: None,
            update: false,
            check: false,
            headless: None,
            log: None,
            log_file: None,
            log_content: false,
        }
    }

    #[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.command,
            Some(RootCommand::Provider {
                command: ProviderCommand::List { json: false }
            })
        ));
        let c = Cli::try_parse_from(["rootle", "provider", "install", "gitlab", "--pin"]).unwrap();
        assert!(matches!(
            c.command,
            Some(RootCommand::Provider { command: 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.command,
            Some(RootCommand::Provider { command: ProviderCommand::Use { name, extra, .. } }) if name == "gitlab" && extra.len() == 2
        ));
    }
    #[test]
    fn commands_reject_arguments_that_would_otherwise_be_ignored() {
        for arguments in [
            vec!["rootle", "owner/repo", "provider", "list"],
            vec!["rootle", "--headless", "-", "update"],
            vec!["rootle", "--update", "provider", "list"],
        ] {
            let parsed = Cli::try_parse_from(arguments.iter().copied());
            assert!(
                match parsed {
                    Err(_) => true,
                    Ok(cli) => cli.validate_execution().is_err(),
                },
                "{arguments:?} must fail"
            );
        }
        assert!(Cli::try_parse_from(["rootle", "--update", "--headless", "-"]).is_err());
    }

    #[test]
    fn global_diagnostics_work_before_and_after_commands() {
        assert!(Cli::try_parse_from(["rootle", "--log", "provider", "list"]).is_ok());
        assert!(Cli::try_parse_from(["rootle", "self-update", "--check", "--log"]).is_ok());
    }
}