1#[derive(Debug, Clone, PartialEq, Eq)]
18pub struct RepoSplit {
19 pub owner: String,
21 pub repo: String,
23}
24
25impl RepoSplit {
26 #[must_use]
38 pub fn new(owner: &str, repo: &str) -> Self {
39 Self {
40 owner: owner.to_string(),
41 repo: repo.to_string(),
42 }
43 }
44}
45
46impl std::fmt::Display for RepoSplit {
47 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
58 write!(f, "{}/{}", self.owner, self.repo)
59 }
60}
61
62pub fn parse_repo_spec(input: &str) -> anyhow::Result<RepoSplit> {
91 let input = input.trim();
92 anyhow::ensure!(!input.is_empty(), "repository spec cannot be empty");
93
94 let parts: Vec<&str> = input.split('/').collect();
95 anyhow::ensure!(
96 parts.len() == 2,
97 "invalid repository spec '{input}': expected OWNER/REPO format"
98 );
99
100 let owner = parts[0].trim();
101 let repo = parts[1].trim();
102
103 anyhow::ensure!(!owner.is_empty(), "repository owner cannot be empty");
104 anyhow::ensure!(!repo.is_empty(), "repository name cannot be empty");
105
106 let repo = repo.strip_suffix(".git").unwrap_or(repo).to_string();
108
109 Ok(RepoSplit {
110 owner: owner.to_string(),
111 repo,
112 })
113}
114
115#[must_use]
136pub fn detect_remote() -> Option<RepoSplit> {
137 let repo = gix::discover(std::env::current_dir().ok()?).ok()?;
138
139 let remote = repo.find_remote("origin").ok().or_else(|| {
141 let names: Vec<_> = repo.remote_names().into_iter().collect();
142 let first_name = names.first()?.clone();
143 repo.find_remote(first_name.as_ref()).ok()
144 })?;
145
146 let url = remote.url(gix::remote::Direction::Fetch)?;
147 let host = url.host.as_deref()?;
148
149 if host != "github.com" && !host.contains('.') {
151 return None;
152 }
153
154 let path = std::str::from_utf8(&url.path).ok()?;
155 let path = path.strip_prefix('/').unwrap_or(path);
157 let path = path.strip_suffix(".git").unwrap_or(path);
158
159 let (owner, repo) = path.split_once('/')?;
160
161 if owner.is_empty() || repo.is_empty() {
162 return None;
163 }
164
165 Some(RepoSplit {
166 owner: owner.to_string(),
167 repo: repo.to_string(),
168 })
169}
170
171#[cfg(test)]
172#[allow(clippy::expect_used)]
173mod tests {
174 use super::*;
175
176 #[test]
177 fn parse_valid_repo_spec() {
178 let spec = parse_repo_spec("octocat/hello-world").expect("valid spec");
179 assert_eq!(spec.owner, "octocat");
180 assert_eq!(spec.repo, "hello-world");
181 }
182
183 #[test]
184 fn parse_repo_spec_with_git_suffix() {
185 let spec = parse_repo_spec("octocat/repo.git").expect("valid spec");
186 assert_eq!(spec.owner, "octocat");
187 assert_eq!(spec.repo, "repo");
188 }
189
190 #[test]
191 fn parse_repo_spec_with_whitespace() {
192 let spec = parse_repo_spec(" octocat/hello-world ").expect("valid spec");
193 assert_eq!(spec.owner, "octocat");
194 assert_eq!(spec.repo, "hello-world");
195 }
196
197 #[test]
198 fn parse_repo_spec_empty_input() {
199 let err = parse_repo_spec("").expect_err("should fail on empty input");
200 assert!(err.to_string().contains("cannot be empty"));
201 }
202
203 #[test]
204 fn parse_repo_spec_no_slash() {
205 let err = parse_repo_spec("justarepo").expect_err("should fail without slash");
206 assert!(err.to_string().contains("OWNER/REPO"));
207 }
208
209 #[test]
210 fn parse_repo_spec_too_many_slashes() {
211 let err = parse_repo_spec("a/b/c").expect_err("should fail with too many slashes");
212 assert!(err.to_string().contains("OWNER/REPO"));
213 }
214
215 #[test]
216 fn parse_repo_spec_empty_owner() {
217 let err = parse_repo_spec("/repo").expect_err("should fail with empty owner");
218 assert!(err.to_string().contains("owner cannot be empty"));
219 }
220
221 #[test]
222 fn parse_repo_spec_empty_repo() {
223 let err = parse_repo_spec("owner/").expect_err("should fail with empty repo");
224 assert!(err.to_string().contains("name cannot be empty"));
225 }
226
227 #[test]
228 fn reposplit_new_and_to_string() {
229 let spec = RepoSplit::new("octocat", "hello-world");
230 assert_eq!(spec.to_string(), "octocat/hello-world");
231 }
232
233 #[test]
234 fn reposplit_equality() {
235 let a = RepoSplit::new("foo", "bar");
236 let b = RepoSplit::new("foo", "bar");
237 let c = RepoSplit::new("foo", "baz");
238 assert_eq!(a, b);
239 assert_ne!(a, c);
240 }
241
242 #[test]
243 fn parse_repo_spec_with_dotgit_in_middle() {
244 let spec = parse_repo_spec("octocat/my.repo").expect("valid spec");
246 assert_eq!(spec.repo, "my.repo");
247 }
248
249 #[test]
250 fn detect_remote_no_git_repo() {
251 let result = detect_remote();
253 let _ = result;
256 }
257}