Skip to main content

anodizer_core/git/
slug.rs

1//! Canonical repository-identity resolver.
2//!
3//! The GitHub/GitLab/Gitea owner + name pair (the repository "slug") is needed
4//! by tagging, release, changelog, milestone, container-push, and several
5//! publisher paths. Historically each site re-derived it from the git remote
6//! with its own `git remote get-url origin` parse — so two sites could disagree
7//! (a stale second remote, a detached checkout, a config that hard-codes the
8//! repo in one place but not another).
9//!
10//! [`RepoSlug`] is the single source of truth. Its fields are private and it
11//! can only be obtained from a `resolve_*` function, which applies one fixed
12//! precedence:
13//!
14//! 1. an explicit config override (`release.<host>.owner` / `.name`, both
15//!    non-empty), else
16//! 2. derive once from the `origin` remote.
17//!
18//! No call site re-parses the remote independently; the [`super::remote`]
19//! detectors are crate-private precisely so a future site cannot bypass this
20//! resolver.
21
22use anyhow::{Result, bail};
23use std::path::Path;
24
25use super::remote::{detect_github_repo_in, detect_owner_repo_in};
26
27/// A validated repository identity: `owner` (user/org, or a GitLab nested
28/// `group/subgroup` path) plus the repository `name`.
29///
30/// Construct only via [`resolve_github_slug_in`], [`resolve_github_slug`],
31/// [`resolve_repo_slug_in`], or [`resolve_repo_slug`] — the private fields make
32/// ad-hoc construction (and thus a second, divergent derivation of the repo
33/// identity) impossible outside this module.
34#[derive(Clone, Debug, PartialEq, Eq)]
35pub struct RepoSlug {
36    owner: String,
37    name: String,
38}
39
40impl RepoSlug {
41    /// Repository owner — a user/org login, or a GitLab nested group path
42    /// (`group/subgroup`).
43    pub fn owner(&self) -> &str {
44        &self.owner
45    }
46
47    /// Repository name (the final path segment).
48    pub fn name(&self) -> &str {
49        &self.name
50    }
51
52    /// `owner/name`, the form used in GitHub REST endpoints and clone URLs.
53    pub fn slug(&self) -> String {
54        format!("{}/{}", self.owner, self.name)
55    }
56
57    /// Validated constructor — module-private so the only public path to a
58    /// `RepoSlug` is a `resolve_*` function. Rejects an empty/whitespace owner
59    /// or name (an unusable slug must surface as an error at resolution time,
60    /// not as a silent `""/""` that 404s a downstream API call).
61    fn validated(owner: String, name: String) -> Result<Self> {
62        if owner.trim().is_empty() || name.trim().is_empty() {
63            bail!(
64                "repository slug requires a non-empty owner and name (got {:?}/{:?})",
65                owner,
66                name
67            );
68        }
69        Ok(Self { owner, name })
70    }
71
72    /// Test-only escape hatch for unit tests that need a `RepoSlug` without a
73    /// git fixture. Gated so production code cannot fabricate an unvalidated
74    /// slug.
75    #[cfg(any(test, feature = "test-helpers"))]
76    pub fn for_test(owner: impl Into<String>, name: impl Into<String>) -> Self {
77        Self {
78            owner: owner.into(),
79            name: name.into(),
80        }
81    }
82}
83
84/// Treat an empty/whitespace override as absent so a blank config field falls
85/// through to remote derivation rather than producing an invalid slug.
86fn override_pair<'a>(owner: Option<&'a str>, name: Option<&'a str>) -> Option<(&'a str, &'a str)> {
87    let owner = owner.filter(|v| !v.trim().is_empty())?;
88    let name = name.filter(|v| !v.trim().is_empty())?;
89    Some((owner, name))
90}
91
92/// Resolve the GitHub repository identity for the repo at `cwd`.
93///
94/// Precedence: a non-empty `(override_owner, override_name)` config override
95/// wins; otherwise the `origin` remote is parsed once (github.com URLs only).
96pub fn resolve_github_slug_in(
97    override_owner: Option<&str>,
98    override_name: Option<&str>,
99    cwd: &Path,
100) -> Result<RepoSlug> {
101    if let Some((owner, name)) = override_pair(override_owner, override_name) {
102        return RepoSlug::validated(owner.to_string(), name.to_string());
103    }
104    let (owner, name) = detect_github_repo_in(cwd)?;
105    RepoSlug::validated(owner, name)
106}
107
108/// Process-cwd sibling of [`resolve_github_slug_in`].
109pub fn resolve_github_slug(
110    override_owner: Option<&str>,
111    override_name: Option<&str>,
112) -> Result<RepoSlug> {
113    resolve_github_slug_in(override_owner, override_name, &std::env::current_dir()?)
114}
115
116/// Host-agnostic sibling of [`resolve_github_slug_in`] (GitHub, GitLab, Gitea,
117/// self-hosted).
118///
119/// Precedence is identical, but remote derivation uses the host-agnostic parse
120/// (so a GitLab nested `group/subgroup/repo` remote yields
121/// `owner = "group/subgroup"`).
122pub fn resolve_repo_slug_in(
123    override_owner: Option<&str>,
124    override_name: Option<&str>,
125    cwd: &Path,
126) -> Result<RepoSlug> {
127    if let Some((owner, name)) = override_pair(override_owner, override_name) {
128        return RepoSlug::validated(owner.to_string(), name.to_string());
129    }
130    let (owner, name) = detect_owner_repo_in(cwd)?;
131    RepoSlug::validated(owner, name)
132}
133
134/// Process-cwd sibling of [`resolve_repo_slug_in`].
135pub fn resolve_repo_slug(
136    override_owner: Option<&str>,
137    override_name: Option<&str>,
138) -> Result<RepoSlug> {
139    resolve_repo_slug_in(override_owner, override_name, &std::env::current_dir()?)
140}
141
142#[cfg(test)]
143mod tests {
144    use super::*;
145    use std::process::Command;
146
147    fn git(dir: &Path, args: &[&str]) {
148        let out = crate::test_helpers::output_with_spawn_retry(
149            || {
150                let mut cmd = Command::new("git");
151                cmd.args(args)
152                    .current_dir(dir)
153                    .env("GIT_TERMINAL_PROMPT", "0")
154                    .env("LC_ALL", "C");
155                cmd
156            },
157            "git",
158        );
159        assert!(out.status.success(), "git {args:?} failed");
160    }
161
162    fn repo_with_origin(url: &str) -> tempfile::TempDir {
163        let tmp = tempfile::tempdir().unwrap();
164        git(tmp.path(), &["init", "-q"]);
165        git(tmp.path(), &["remote", "add", "origin", url]);
166        tmp
167    }
168
169    #[test]
170    fn override_wins_over_remote_without_touching_git() {
171        // No git repo at all: an override must still resolve (proves the
172        // override short-circuits remote derivation entirely).
173        let tmp = tempfile::tempdir().unwrap();
174        let slug = resolve_github_slug_in(Some("cfg-owner"), Some("cfg-name"), tmp.path()).unwrap();
175        assert_eq!(slug.owner(), "cfg-owner");
176        assert_eq!(slug.name(), "cfg-name");
177        assert_eq!(slug.slug(), "cfg-owner/cfg-name");
178    }
179
180    #[test]
181    fn empty_override_falls_through_to_remote() {
182        let tmp = repo_with_origin("https://github.com/remote-owner/remote-repo.git");
183        // Both empty -> remote.
184        let slug = resolve_github_slug_in(Some(""), Some("  "), tmp.path()).unwrap();
185        assert_eq!(slug.owner(), "remote-owner");
186        assert_eq!(slug.name(), "remote-repo");
187    }
188
189    #[test]
190    fn partial_override_falls_through_to_remote() {
191        let tmp = repo_with_origin("https://github.com/remote-owner/remote-repo.git");
192        // Only owner set -> not a complete override -> remote wins (no
193        // half-config slug like `owner-only/`).
194        let slug = resolve_github_slug_in(Some("cfg-owner"), None, tmp.path()).unwrap();
195        assert_eq!(slug.owner(), "remote-owner");
196        assert_eq!(slug.name(), "remote-repo");
197    }
198
199    #[test]
200    fn github_remote_derivation() {
201        let tmp = repo_with_origin("git@github.com:gh-owner/gh-repo.git");
202        let slug = resolve_github_slug_in(None, None, tmp.path()).unwrap();
203        assert_eq!(slug.slug(), "gh-owner/gh-repo");
204    }
205
206    #[test]
207    fn host_agnostic_derivation_preserves_nested_groups() {
208        let tmp = repo_with_origin("https://gitlab.com/group/subgroup/repo.git");
209        // github-specific parse rejects a non-github host...
210        assert!(resolve_github_slug_in(None, None, tmp.path()).is_err());
211        // ...but the host-agnostic resolver keeps the nested owner path.
212        let slug = resolve_repo_slug_in(None, None, tmp.path()).unwrap();
213        assert_eq!(slug.owner(), "group/subgroup");
214        assert_eq!(slug.name(), "repo");
215    }
216
217    #[test]
218    fn missing_remote_is_an_error_not_an_empty_slug() {
219        let tmp = tempfile::tempdir().unwrap();
220        git(tmp.path(), &["init", "-q"]);
221        assert!(resolve_github_slug_in(None, None, tmp.path()).is_err());
222        assert!(resolve_repo_slug_in(None, None, tmp.path()).is_err());
223    }
224}