runtime-cli 0.1.0

Command-line client for managing git projects on Runtime
Documentation
//! `runtime repo` — list / view / clone.
//!
//! `list` returns repositories visible to the configured account
//! (filtered down to those whose `ownerSlug` matches the configured
//! username for the "my repos" feel). `view` prints the repo's
//! metadata. `clone` shells out to `git clone` with the HTTPS URL
//! derived from the configured host (`<host>/<owner>/<name>.git`).

use std::io::Write;

use anyhow::{anyhow, Context, Result};
use clap::{Args, Subcommand};
use serde_json::{json, Value};

use crate::client;
use crate::commands::resolve_repo_slug;
use crate::output::{render_record, render_table, FormatChoice};

#[derive(Debug, Subcommand)]
pub enum RepoCmd {
    /// List repositories owned by the configured account.
    List(ListArgs),
    /// Show metadata for a single repository.
    View(ViewArgs),
    /// Clone a repository via `git clone`.
    Clone(CloneArgs),
}

#[derive(Debug, Args)]
pub struct ListArgs {
    /// Include repositories visible to the viewer but not owned by them.
    #[arg(long)]
    pub all: bool,
    #[arg(long, default_value_t = false)]
    pub json: bool,
    #[arg(long, default_value_t = false)]
    pub tsv: bool,
}

#[derive(Debug, Args)]
pub struct ViewArgs {
    /// `owner/name` slug.
    pub slug: String,
    #[arg(long, default_value_t = false)]
    pub json: bool,
    #[arg(long, default_value_t = false)]
    pub tsv: bool,
}

#[derive(Debug, Args)]
pub struct CloneArgs {
    /// `owner/name` slug.
    pub slug: String,
    /// Destination directory. Defaults to `name`.
    pub dest: Option<String>,
}

pub fn run(cmd: RepoCmd) -> Result<()> {
    match cmd {
        RepoCmd::List(a) => list(a),
        RepoCmd::View(a) => view(a),
        RepoCmd::Clone(a) => clone(a),
    }
}

fn list(args: ListArgs) -> Result<()> {
    let (cli, cfg) = client::authenticated()?;
    let data = cli.execute(LIST_REPOS, json!({}))?;
    let repos = data["repositories"]
        .as_array()
        .ok_or_else(|| anyhow!("repositories field missing"))?;
    let rows = repo_rows(repos, &cfg.username, args.all);
    let mut stdout = std::io::stdout().lock();
    let fmt = FormatChoice {
        json: args.json,
        tsv: args.tsv,
    }
    .resolve();
    stdout
        .write_all(render_table(&["repo", "visibility", "description"], &rows, fmt).as_bytes())?;
    Ok(())
}

fn view(args: ViewArgs) -> Result<()> {
    let (cli, _cfg) = client::authenticated()?;
    let (owner, name) = resolve_repo_slug(&args.slug)?;
    let data = cli.execute(LIST_REPOS, json!({}))?;
    let repos = data["repositories"]
        .as_array()
        .ok_or_else(|| anyhow!("repositories field missing"))?;
    let r = repos
        .iter()
        .find(|r| r["ownerSlug"].as_str() == Some(&owner) && r["name"].as_str() == Some(&name))
        .ok_or_else(|| anyhow!("no repository named {}/{}", owner, name))?;
    let pairs = repo_record_pairs(r, &owner, &name);
    let mut stdout = std::io::stdout().lock();
    let fmt = FormatChoice {
        json: args.json,
        tsv: args.tsv,
    }
    .resolve();
    stdout.write_all(render_record(&pairs, fmt).as_bytes())?;
    Ok(())
}

fn clone(args: CloneArgs) -> Result<()> {
    let (cli, _cfg) = client::authenticated()?;
    // clone always needs an explicit slug — there's no "the repo we're
    // in" semantics for the thing we're about to materialize.
    let (owner, name) = crate::commands::parse_slug(&args.slug)?;
    let url = format!("{}/{}/{}.git", cli.host(), owner, name);
    let dest = args.dest.unwrap_or_else(|| name.clone());
    let status = std::process::Command::new("git")
        .arg("clone")
        .arg(&url)
        .arg(&dest)
        .status()
        .context("run `git clone`")?;
    if !status.success() {
        return Err(anyhow!("git clone exited with status {status}"));
    }
    Ok(())
}

const LIST_REPOS: &str =
    "{ repositories { id ownerSlug name description visibility defaultBranch updatedAt } }";

fn repo_rows(repos: &[Value], username: &str, include_all: bool) -> Vec<Vec<String>> {
    repos
        .iter()
        .filter(|r| include_all || r["ownerSlug"].as_str() == Some(username))
        .map(|r| {
            vec![
                format!(
                    "{}/{}",
                    r["ownerSlug"].as_str().unwrap_or(""),
                    r["name"].as_str().unwrap_or("")
                ),
                r["visibility"].as_str().unwrap_or("").to_string(),
                r["description"].as_str().unwrap_or("").to_string(),
            ]
        })
        .collect()
}

fn repo_record_pairs(repo: &Value, owner: &str, name: &str) -> Vec<(&'static str, String)> {
    vec![
        ("Repo", format!("{owner}/{name}")),
        (
            "Visibility",
            repo["visibility"].as_str().unwrap_or("").to_string(),
        ),
        (
            "Default branch",
            repo["defaultBranch"].as_str().unwrap_or("").to_string(),
        ),
        (
            "Description",
            repo["description"].as_str().unwrap_or("").to_string(),
        ),
        (
            "Updated",
            repo["updatedAt"].as_str().unwrap_or("").to_string(),
        ),
    ]
}

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

    #[test]
    fn repo_rows_filter_to_viewer_unless_all_is_set() {
        let repos = vec![
            json!({
                "ownerSlug": "bri",
                "name": "runtime",
                "visibility": "PUBLIC",
                "description": "Runtime platform"
            }),
            json!({
                "ownerSlug": "runtime-labs",
                "name": "ops",
                "visibility": "PRIVATE",
                "description": "Operations"
            }),
            json!({}),
        ];

        assert_eq!(
            repo_rows(&repos, "bri", false),
            vec![vec!["bri/runtime", "PUBLIC", "Runtime platform"]]
        );
        assert_eq!(
            repo_rows(&repos, "bri", true),
            vec![
                vec!["bri/runtime", "PUBLIC", "Runtime platform"],
                vec!["runtime-labs/ops", "PRIVATE", "Operations"],
                vec!["/", "", ""],
            ]
        );
    }

    #[test]
    fn repo_record_pairs_format_defaults_without_leaking_api_shape() {
        let repo = json!({
            "visibility": "PRIVATE",
            "defaultBranch": "trunk",
            "description": "A repo",
            "updatedAt": "2026-06-18T01:00:00Z"
        });

        assert_eq!(
            repo_record_pairs(&repo, "bri", "runtime"),
            vec![
                ("Repo", "bri/runtime".to_string()),
                ("Visibility", "PRIVATE".to_string()),
                ("Default branch", "trunk".to_string()),
                ("Description", "A repo".to_string()),
                ("Updated", "2026-06-18T01:00:00Z".to_string()),
            ]
        );

        assert_eq!(
            repo_record_pairs(&json!({}), "owner", "repo"),
            vec![
                ("Repo", "owner/repo".to_string()),
                ("Visibility", String::new()),
                ("Default branch", String::new()),
                ("Description", String::new()),
                ("Updated", String::new()),
            ]
        );
    }
}