runtime-cli 0.1.0

Command-line client for managing git projects on Runtime
Documentation
pub mod api;
pub mod auth;
pub mod backup;
pub mod issue;
pub mod org;
pub mod pr;
pub mod release;
pub mod repo;
pub mod run;

use anyhow::{anyhow, Result};

/// Split an `owner/name` slug into its two halves. Used everywhere
/// repo arguments are accepted.
pub(crate) fn parse_slug(slug: &str) -> Result<(String, String)> {
    let (owner, name) = slug
        .split_once('/')
        .ok_or_else(|| anyhow!("expected `<owner>/<name>`, got `{slug}`"))?;
    if owner.is_empty() || name.is_empty() {
        return Err(anyhow!("expected `<owner>/<name>`, got `{slug}`"));
    }
    Ok((owner.to_string(), name.to_string()))
}

/// Issue #31 — accept an explicit `owner/name` slug or, when blank,
/// derive it from `git config --get remote.origin.url` in the current
/// working directory. Mirrors `gh repo view` semantics: if you're
/// `cd`'d into a clone of a runtime repo, the slug is ambient.
///
/// Errors with a usable hint when both paths fail.
pub(crate) fn resolve_repo_slug(arg: &str) -> Result<(String, String)> {
    // `-` is the explicit sentinel for "auto-detect from cwd". An
    // empty string can't be used because clap requires positional args
    // before optional ones (the `number` positional that follows
    // `repo` on most commands is required), so `runtime issue view
    // - 42` is the way to ask for cwd-detect on numbered subcommands.
    if !arg.is_empty() && arg != "-" {
        return parse_slug(arg);
    }
    let url = git_remote_origin_url().ok_or_else(|| {
        anyhow!("no repo argument and no git remote in cwd — pass `<owner>/<name>` explicitly")
    })?;
    parse_remote_url(&url).ok_or_else(|| {
        anyhow!(
            "cwd's git remote `{url}` doesn't match the runtime URL shape; pass `<owner>/<name>` explicitly"
        )
    })
}

fn git_remote_origin_url() -> Option<String> {
    let out = std::process::Command::new("git")
        .args(["config", "--get", "remote.origin.url"])
        .output()
        .ok()?;
    if !out.status.success() {
        return None;
    }
    let s = String::from_utf8(out.stdout).ok()?;
    let t = s.trim();
    if t.is_empty() {
        None
    } else {
        Some(t.to_string())
    }
}

/// Pull the `<owner>/<name>` from any of the four URL shapes the
/// CLI is likely to see:
///
/// * `https://<host>[:port]/<owner>/<name>.git`
/// * `https://<host>[:port]/<owner>/<name>`
/// * `ssh://git@<host>[:port]/<owner>/<name>.git`
/// * `git@<host>:<owner>/<name>.git` (scp-style)
pub(crate) fn parse_remote_url(url: &str) -> Option<(String, String)> {
    let stripped = url.trim_end_matches(".git").trim_end_matches('/');
    // scp-style: `git@host:owner/name`
    if let Some(rest) = stripped.split_once(':').and_then(|(_, r)| {
        // require the first half to look like `user@host`
        if stripped.contains('@') && !stripped.contains("://") {
            Some(r)
        } else {
            None
        }
    }) {
        return split_owner_name(rest);
    }
    // URL-style: parse path from the last two path components.
    let path = match stripped.split_once("://") {
        Some((_scheme, rest)) => rest.split_once('/')?.1,
        None => return None,
    };
    split_owner_name(path)
}

fn split_owner_name(path: &str) -> Option<(String, String)> {
    let trimmed = path.trim_matches('/');
    let parts: Vec<&str> = trimmed.split('/').collect();
    if parts.len() < 2 {
        return None;
    }
    // For paths longer than 2 segments (e.g. with a sub-group), take
    // the last two — matches gitlab/-style paths most of the time.
    let owner = parts[parts.len() - 2];
    let name = parts[parts.len() - 1];
    if owner.is_empty() || name.is_empty() {
        return None;
    }
    Some((owner.to_string(), name.to_string()))
}

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

    #[test]
    fn parse_slug_happy() {
        let (o, n) = parse_slug("alice/r1").unwrap();
        assert_eq!(o, "alice");
        assert_eq!(n, "r1");
    }

    #[test]
    fn parse_slug_rejects_bad_shapes() {
        assert!(parse_slug("alice").is_err());
        assert!(parse_slug("/r1").is_err());
        assert!(parse_slug("alice/").is_err());
    }

    #[test]
    fn parse_remote_url_https_with_dot_git() {
        assert_eq!(
            parse_remote_url("https://example.com/alice/r1.git"),
            Some(("alice".into(), "r1".into()))
        );
    }

    #[test]
    fn parse_remote_url_https_without_dot_git() {
        assert_eq!(
            parse_remote_url("https://example.com:8443/alice/r1"),
            Some(("alice".into(), "r1".into()))
        );
    }

    #[test]
    fn parse_remote_url_ssh_url() {
        assert_eq!(
            parse_remote_url("ssh://git@example.com:2222/alice/r1.git"),
            Some(("alice".into(), "r1".into()))
        );
    }

    #[test]
    fn parse_remote_url_scp_style() {
        assert_eq!(
            parse_remote_url("git@example.com:alice/r1.git"),
            Some(("alice".into(), "r1".into()))
        );
    }

    #[test]
    fn parse_remote_url_rejects_non_url() {
        assert_eq!(parse_remote_url("hello-world"), None);
        assert_eq!(parse_remote_url("https://example.com/onlypath"), None);
    }
}