runtime_cli/commands/
mod.rs1pub 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
13pub(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
25pub(crate) fn resolve_repo_slug(arg: &str) -> Result<(String, String)> {
32 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
67pub(crate) fn parse_remote_url(url: &str) -> Option<(String, String)> {
75 let stripped = url.trim_end_matches(".git").trim_end_matches('/');
76 if let Some(rest) = stripped.split_once(':').and_then(|(_, r)| {
78 if stripped.contains('@') && !stripped.contains("://") {
80 Some(r)
81 } else {
82 None
83 }
84 }) {
85 return split_owner_name(rest);
86 }
87 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 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}