runtime-cli 0.1.0

Command-line client for managing git projects on Runtime
Documentation
//! clap derive surface for `runtime`. Top-level subcommands map 1:1 to
//! the modules under `commands/`.

use clap::{Parser, Subcommand, ValueEnum};

use crate::commands::{api, auth, backup, issue, org, pr, release, repo, run};

#[derive(Debug, Parser)]
#[command(
    name = "runtime",
    version,
    about = "Runtime CLI — speak the Runtime API from your shell"
)]
pub struct Cli {
    #[command(subcommand)]
    pub command: Command,
}

#[derive(Debug, Subcommand)]
pub enum Command {
    /// Manage credentials for the configured Runtime instance.
    #[command(subcommand)]
    Auth(auth::AuthCmd),
    /// Repository operations.
    #[command(subcommand)]
    Repo(repo::RepoCmd),
    /// Issue operations.
    #[command(subcommand)]
    Issue(issue::IssueCmd),
    /// Pull-request operations.
    #[command(subcommand)]
    Pr(pr::PrCmd),
    /// Action-run operations.
    #[command(subcommand)]
    Run(run::RunCmd),
    /// Organization operations.
    #[command(subcommand)]
    Org(org::OrgCmd),
    /// Release operations.
    #[command(subcommand)]
    Release(release::ReleaseCmd),
    /// Run a raw GraphQL query against the configured instance.
    Api(api::ApiArgs),
    /// Snapshot / restore the instance state.
    #[command(subcommand)]
    Backup(backup::BackupCmd),
    /// Issue #31 — print a shell completion script for `runtime`.
    /// Pipe the output into the shell's completion directory, e.g.
    /// `runtime completion bash > /usr/local/etc/bash_completion.d/runtime`
    /// or `runtime completion zsh > ~/.zsh/completions/_runtime`.
    Completion {
        /// Target shell: `bash`, `zsh`, `fish`, `elvish`, or `powershell`.
        shell: Shell,
    },
}

/// Re-export of `clap_complete::Shell` as a `ValueEnum` so the `runtime
/// completion <shell>` subcommand can use it directly without a custom
/// parser. Kept in lockstep with `clap_complete::Shell` variants.
#[derive(Debug, Clone, Copy, ValueEnum)]
pub enum Shell {
    Bash,
    Zsh,
    Fish,
    Elvish,
    PowerShell,
}

impl From<Shell> for clap_complete::Shell {
    fn from(s: Shell) -> Self {
        match s {
            Shell::Bash => clap_complete::Shell::Bash,
            Shell::Zsh => clap_complete::Shell::Zsh,
            Shell::Fish => clap_complete::Shell::Fish,
            Shell::Elvish => clap_complete::Shell::Elvish,
            Shell::PowerShell => clap_complete::Shell::PowerShell,
        }
    }
}

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

    /// Smoke-test that every documented top-level command parses.
    #[test]
    fn top_level_parses() {
        let cases = [
            vec!["runtime", "auth", "status"],
            vec![
                "runtime",
                "auth",
                "login",
                "--username",
                "alice",
                "--password-stdin",
            ],
            vec!["runtime", "repo", "list"],
            vec!["runtime", "repo", "view", "alice/r1"],
            vec!["runtime", "repo", "clone", "alice/r1"],
            vec!["runtime", "issue", "list", "alice/r1"],
            vec!["runtime", "issue", "view", "alice/r1", "1"],
            vec![
                "runtime", "issue", "create", "alice/r1", "--title", "T", "--body", "B",
            ],
            vec![
                "runtime", "issue", "comment", "alice/r1", "1", "--body", "hi",
            ],
            vec!["runtime", "issue", "close", "alice/r1", "1"],
            vec!["runtime", "issue", "reopen", "alice/r1", "1"],
            vec!["runtime", "pr", "list", "alice/r1"],
            vec!["runtime", "pr", "view", "alice/r1", "1"],
            vec!["runtime", "pr", "checkout", "alice/r1", "1"],
            vec![
                "runtime",
                "pr",
                "merge",
                "alice/r1",
                "1",
                "--strategy",
                "squash",
            ],
            vec!["runtime", "pr", "review", "alice/r1", "1", "--approve"],
            vec!["runtime", "run", "list", "alice/r1"],
            vec![
                "runtime",
                "run",
                "view",
                "00000000-0000-0000-0000-000000000000",
            ],
            vec![
                "runtime",
                "run",
                "cancel",
                "00000000-0000-0000-0000-000000000000",
            ],
            vec!["runtime", "org", "list"],
            vec!["runtime", "org", "members", "acme"],
            vec!["runtime", "org", "teams", "acme"],
            vec!["runtime", "release", "list", "alice/r1"],
            vec!["runtime", "api", "{ viewer { username } }"],
            // Issue #31 — completion subcommand parses for each shell.
            vec!["runtime", "completion", "bash"],
            vec!["runtime", "completion", "zsh"],
            vec!["runtime", "completion", "fish"],
            vec!["runtime", "completion", "power-shell"],
            vec!["runtime", "backup", "create"],
        ];
        for argv in cases {
            Cli::try_parse_from(argv.iter().copied()).unwrap_or_else(|e| panic!("{argv:?}: {e}"));
        }
    }

    #[test]
    fn auth_login_defaults_to_public_remote() {
        let cli = Cli::try_parse_from([
            "runtime",
            "auth",
            "login",
            "--username",
            "alice",
            "--password-stdin",
        ])
        .unwrap();
        let Command::Auth(auth::AuthCmd::Login(args)) = cli.command else {
            panic!("expected auth login command");
        };
        assert_eq!(args.host.as_deref(), Some("https://gitruntime.com"));
    }

    #[test]
    fn issue_command_options_parse_into_expected_structs() {
        let cli = Cli::try_parse_from([
            "runtime",
            "issue",
            "list",
            "alice/r1",
            "--state",
            "all",
            "--label",
            "bug",
            "--label",
            "ui",
            "--milestone",
            "M1",
            "--json",
        ])
        .unwrap();
        let Command::Issue(issue::IssueCmd::List(args)) = cli.command else {
            panic!("expected issue list command");
        };
        assert_eq!(args.repo, "alice/r1");
        assert_eq!(args.state, "all");
        assert_eq!(args.label, ["bug", "ui"]);
        assert_eq!(args.milestone.as_deref(), Some("M1"));
        assert!(args.json);
        assert!(!args.tsv);

        let cli = Cli::try_parse_from([
            "runtime",
            "issue",
            "set-state",
            "alice/r1",
            "5",
            "--state",
            "In progress",
        ])
        .unwrap();
        let Command::Issue(issue::IssueCmd::SetState(args)) = cli.command else {
            panic!("expected issue set-state command");
        };
        assert_eq!(args.repo, "alice/r1");
        assert_eq!(args.number, 5);
        assert_eq!(args.state, "In progress");

        assert!(
            Cli::try_parse_from(["runtime", "issue", "create", "alice/r1", "--title", ""]).is_err()
        );
    }

    #[test]
    fn pr_command_options_and_conflicts_parse_as_expected() {
        let cli = Cli::try_parse_from([
            "runtime", "pr", "create", "alice/r1", "--title", "Ship it", "--body", "Ready",
            "--head", "feature", "--base", "main",
        ])
        .unwrap();
        let Command::Pr(pr::PrCmd::Create(args)) = cli.command else {
            panic!("expected pr create command");
        };
        assert_eq!(args.repo, "alice/r1");
        assert_eq!(args.title, "Ship it");
        assert_eq!(args.body.as_deref(), Some("Ready"));
        assert_eq!(args.head, "feature");
        assert_eq!(args.base, "main");

        let cli = Cli::try_parse_from([
            "runtime",
            "pr",
            "comment",
            "alice/r1",
            "3",
            "--body",
            "Looks good",
        ])
        .unwrap();
        let Command::Pr(pr::PrCmd::Comment(args)) = cli.command else {
            panic!("expected pr comment command");
        };
        assert_eq!(args.repo, "alice/r1");
        assert_eq!(args.number, 3);
        assert_eq!(args.body.as_deref(), Some("Looks good"));

        assert!(Cli::try_parse_from([
            "runtime",
            "pr",
            "review",
            "alice/r1",
            "3",
            "--approve",
            "--comment",
        ])
        .is_err());
    }

    #[test]
    fn run_org_and_release_options_parse_into_expected_structs() {
        let cli = Cli::try_parse_from(["runtime", "run", "list", "alice/r1", "--tsv"]).unwrap();
        let Command::Run(run::RunCmd::List(args)) = cli.command else {
            panic!("expected run list command");
        };
        assert_eq!(args.repo, "alice/r1");
        assert!(args.tsv);

        let cli = Cli::try_parse_from([
            "runtime",
            "run",
            "logs",
            "00000000-0000-0000-0000-000000000000",
            "--follow",
        ])
        .unwrap();
        let Command::Run(run::RunCmd::Logs(args)) = cli.command else {
            panic!("expected run logs command");
        };
        assert_eq!(args.id, "00000000-0000-0000-0000-000000000000");
        assert!(args.follow);

        let cli = Cli::try_parse_from(["runtime", "org", "members", "acme", "--json"]).unwrap();
        let Command::Org(org::OrgCmd::Members(args)) = cli.command else {
            panic!("expected org members command");
        };
        assert_eq!(args.login, "acme");
        assert!(args.json);

        let cli = Cli::try_parse_from(["runtime", "org", "teams", "acme", "--tsv"]).unwrap();
        let Command::Org(org::OrgCmd::Teams(args)) = cli.command else {
            panic!("expected org teams command");
        };
        assert_eq!(args.login, "acme");
        assert!(args.tsv);

        let cli =
            Cli::try_parse_from(["runtime", "release", "view", "alice/r1", "v1.0.0", "--json"])
                .unwrap();
        let Command::Release(release::ReleaseCmd::View(args)) = cli.command else {
            panic!("expected release view command");
        };
        assert_eq!(args.repo, "alice/r1");
        assert_eq!(args.tag, "v1.0.0");
        assert!(args.json);

        let cli = Cli::try_parse_from([
            "runtime",
            "release",
            "create",
            "alice/r1",
            "--tag",
            "v1.0.0",
            "--name",
            "Runtime 1",
            "--body",
            "Notes",
            "--draft",
            "--prerelease",
        ])
        .unwrap();
        let Command::Release(release::ReleaseCmd::Create(args)) = cli.command else {
            panic!("expected release create command");
        };
        assert_eq!(args.repo, "alice/r1");
        assert_eq!(args.tag, "v1.0.0");
        assert_eq!(args.name, "Runtime 1");
        assert_eq!(args.body.as_deref(), Some("Notes"));
        assert!(args.draft);
        assert!(args.prerelease);
    }
}