Skip to main content

runtime_cli/commands/
mod.rs

1pub mod api;
2pub mod auth;
3pub mod backup;
4pub mod issue;
5pub mod org;
6pub mod pr;
7pub mod release;
8pub mod repo;
9pub mod run;
10
11use anyhow::{anyhow, Result};
12
13/// Split an `owner/name` slug into its two halves. Used everywhere
14/// repo arguments are accepted.
15pub(crate) fn parse_slug(slug: &str) -> Result<(String, String)> {
16    let (owner, name) = slug
17        .split_once('/')
18        .ok_or_else(|| anyhow!("expected `<owner>/<name>`, got `{slug}`"))?;
19    if owner.is_empty() || name.is_empty() {
20        return Err(anyhow!("expected `<owner>/<name>`, got `{slug}`"));
21    }
22    Ok((owner.to_string(), name.to_string()))
23}
24
25/// Issue #31 — accept an explicit `owner/name` slug or, when blank,
26/// derive it from `git config --get remote.origin.url` in the current
27/// working directory. Mirrors `gh repo view` semantics: if you're
28/// `cd`'d into a clone of a runtime repo, the slug is ambient.
29///
30/// Errors with a usable hint when both paths fail.
31pub(crate) fn resolve_repo_slug(arg: &str) -> Result<(String, String)> {
32    // `-` is the explicit sentinel for "auto-detect from cwd". An
33    // empty string can't be used because clap requires positional args
34    // before optional ones (the `number` positional that follows
35    // `repo` on most commands is required), so `runtime issue view
36    // - 42` is the way to ask for cwd-detect on numbered subcommands.
37    if !arg.is_empty() && arg != "-" {
38        return parse_slug(arg);
39    }
40    let url = git_remote_origin_url().ok_or_else(|| {
41        anyhow!("no repo argument and no git remote in cwd — pass `<owner>/<name>` explicitly")
42    })?;
43    parse_remote_url(&url).ok_or_else(|| {
44        anyhow!(
45            "cwd's git remote `{url}` doesn't match the runtime URL shape; pass `<owner>/<name>` explicitly"
46        )
47    })
48}
49
50fn git_remote_origin_url() -> Option<String> {
51    let out = std::process::Command::new("git")
52        .args(["config", "--get", "remote.origin.url"])
53        .output()
54        .ok()?;
55    if !out.status.success() {
56        return None;
57    }
58    let s = String::from_utf8(out.stdout).ok()?;
59    let t = s.trim();
60    if t.is_empty() {
61        None
62    } else {
63        Some(t.to_string())
64    }
65}
66
67/// Pull the `<owner>/<name>` from any of the four URL shapes the
68/// CLI is likely to see:
69///
70/// * `https://<host>[:port]/<owner>/<name>.git`
71/// * `https://<host>[:port]/<owner>/<name>`
72/// * `ssh://git@<host>[:port]/<owner>/<name>.git`
73/// * `git@<host>:<owner>/<name>.git` (scp-style)
74pub(crate) fn parse_remote_url(url: &str) -> Option<(String, String)> {
75    let stripped = url.trim_end_matches(".git").trim_end_matches('/');
76    // scp-style: `git@host:owner/name`
77    if let Some(rest) = stripped.split_once(':').and_then(|(_, r)| {
78        // require the first half to look like `user@host`
79        if stripped.contains('@') && !stripped.contains("://") {
80            Some(r)
81        } else {
82            None
83        }
84    }) {
85        return split_owner_name(rest);
86    }
87    // URL-style: parse path from the last two path components.
88    let path = match stripped.split_once("://") {
89        Some((_scheme, rest)) => rest.split_once('/')?.1,
90        None => return None,
91    };
92    split_owner_name(path)
93}
94
95fn split_owner_name(path: &str) -> Option<(String, String)> {
96    let trimmed = path.trim_matches('/');
97    let parts: Vec<&str> = trimmed.split('/').collect();
98    if parts.len() < 2 {
99        return None;
100    }
101    // For paths longer than 2 segments (e.g. with a sub-group), take
102    // the last two — matches gitlab/-style paths most of the time.
103    let owner = parts[parts.len() - 2];
104    let name = parts[parts.len() - 1];
105    if owner.is_empty() || name.is_empty() {
106        return None;
107    }
108    Some((owner.to_string(), name.to_string()))
109}
110
111#[cfg(test)]
112mod tests {
113    use super::*;
114
115    #[test]
116    fn parse_slug_happy() {
117        let (o, n) = parse_slug("alice/r1").unwrap();
118        assert_eq!(o, "alice");
119        assert_eq!(n, "r1");
120    }
121
122    #[test]
123    fn parse_slug_rejects_bad_shapes() {
124        assert!(parse_slug("alice").is_err());
125        assert!(parse_slug("/r1").is_err());
126        assert!(parse_slug("alice/").is_err());
127    }
128
129    #[test]
130    fn parse_remote_url_https_with_dot_git() {
131        assert_eq!(
132            parse_remote_url("https://example.com/alice/r1.git"),
133            Some(("alice".into(), "r1".into()))
134        );
135    }
136
137    #[test]
138    fn parse_remote_url_https_without_dot_git() {
139        assert_eq!(
140            parse_remote_url("https://example.com:8443/alice/r1"),
141            Some(("alice".into(), "r1".into()))
142        );
143    }
144
145    #[test]
146    fn parse_remote_url_ssh_url() {
147        assert_eq!(
148            parse_remote_url("ssh://git@example.com:2222/alice/r1.git"),
149            Some(("alice".into(), "r1".into()))
150        );
151    }
152
153    #[test]
154    fn parse_remote_url_scp_style() {
155        assert_eq!(
156            parse_remote_url("git@example.com:alice/r1.git"),
157            Some(("alice".into(), "r1".into()))
158        );
159    }
160
161    #[test]
162    fn parse_remote_url_rejects_non_url() {
163        assert_eq!(parse_remote_url("hello-world"), None);
164        assert_eq!(parse_remote_url("https://example.com/onlypath"), None);
165    }
166}