Skip to main content

gor/
repository.rs

1//! Repository specification parsing and remote URL detection.
2//!
3//! Provides utilities for parsing `OWNER/REPO` strings and detecting
4//! repository information from the current directory's git remote.
5
6/// A parsed repository specification consisting of an owner and repo name.
7///
8/// # Examples
9///
10/// ```
11/// use gor::repository::{parse_repo_spec, RepoSplit};
12///
13/// let spec = parse_repo_spec("octocat/hello-world").expect("valid spec");
14/// assert_eq!(spec.owner, "octocat");
15/// assert_eq!(spec.repo, "hello-world");
16/// ```
17#[derive(Debug, Clone, PartialEq, Eq)]
18pub struct RepoSplit {
19    /// The repository owner (user or organization).
20    pub owner: String,
21    /// The repository name.
22    pub repo: String,
23}
24
25impl RepoSplit {
26    /// Create a new `RepoSplit` from owner and repo strings.
27    ///
28    /// # Examples
29    ///
30    /// ```
31    /// use gor::repository::RepoSplit;
32    ///
33    /// let spec = RepoSplit::new("octocat", "hello-world");
34    /// assert_eq!(spec.owner, "octocat");
35    /// assert_eq!(spec.repo, "hello-world");
36    /// ```
37    #[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    /// Format as `OWNER/REPO`.
48    ///
49    /// # Examples
50    ///
51    /// ```
52    /// use gor::repository::RepoSplit;
53    ///
54    /// let spec = RepoSplit::new("octocat", "hello-world");
55    /// assert_eq!(spec.to_string(), "octocat/hello-world");
56    /// ```
57    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
58        write!(f, "{}/{}", self.owner, self.repo)
59    }
60}
61
62/// Parse a repository spec string in `OWNER/REPO` format.
63///
64/// The input must contain exactly one `/` separator. Trailing `.git` is
65/// stripped from the repo name. Empty owner or repo segments are rejected.
66///
67/// # Errors
68///
69/// Returns an error if the input is empty, lacks a `/`, contains multiple `/`
70/// separators, or has empty owner or repo segments.
71///
72/// # Examples
73///
74/// ```
75/// use gor::repository::parse_repo_spec;
76///
77/// let spec = parse_repo_spec("octocat/hello-world").expect("valid spec");
78/// assert_eq!(spec.owner, "octocat");
79/// assert_eq!(spec.repo, "hello-world");
80///
81/// // Trailing .git is stripped
82/// let spec = parse_repo_spec("octocat/repo.git").expect("valid spec");
83/// assert_eq!(spec.repo, "repo");
84///
85/// // Invalid inputs
86/// assert!(parse_repo_spec("").is_err());
87/// assert!(parse_repo_spec("no-slash").is_err());
88/// assert!(parse_repo_spec("too/many/slashes").is_err());
89/// ```
90pub 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    // Strip trailing .git if present
107    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/// Detect the repository from the current directory's git remote.
116///
117/// Opens the git repository by discovering from the current directory,
118/// finds the `origin` remote (or the first available remote), and parses
119/// its URL to extract the owner and repository name.
120///
121/// Supports both HTTPS URLs (`https://github.com/owner/repo.git`) and
122/// SSH URLs (`git@github.com:owner/repo.git`).
123///
124/// Returns `None` if no git repository is found, no remote is configured,
125/// or the remote URL cannot be parsed as a GitHub repository.
126///
127/// # Examples
128///
129/// ```no_run
130/// use gor::repository::detect_remote;
131///
132/// // This will return None if not in a git repo with a GitHub remote
133/// let result = detect_remote();
134/// ```
135#[must_use]
136pub fn detect_remote() -> Option<RepoSplit> {
137    let repo = gix::discover(std::env::current_dir().ok()?).ok()?;
138
139    // Try to find "origin" first, then fall back to the first available remote
140    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    // Only handle github.com and GHES hosts
150    if host != "github.com" && !host.contains('.') {
151        return None;
152    }
153
154    let path = std::str::from_utf8(&url.path).ok()?;
155    // Strip leading '/' and trailing '.git'
156    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        // Only trailing .git should be stripped
245        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        // When not in a git repo, detect_remote should return None
252        let result = detect_remote();
253        // This may or may not be in a git repo depending on the test environment
254        // We just verify it doesn't panic
255        let _ = result;
256    }
257}