1use git_url_parse::GitUrl;
2use thiserror::Error;
3
4use crate::options::Opt;
5
6#[macro_use]
7extern crate log;
8
9mod git;
10pub mod options;
11
12const BITBUCKET_HOSTNAME: &str = "bitbucket.org";
13const GITHUB_HOSTNAME: &str = "github.com";
14const GITLAB_HOSTNAME: &str = "gitlab.com";
15const GITEA_HOSTNAME: &str = "gitea.io";
16
17#[derive(Debug, Eq, Error, PartialEq, Clone)]
18pub enum Issue {
19 #[error("Command failed, please run command inside a git directory")]
20 NotInAGitRepository,
21 #[error("No matching remote url found for '{0}' remote name")]
22 NoRemoteMatching(String),
23 #[error("No remote available")]
24 NoRemoteAvailable,
25 #[error("Not able to open system browser")]
26 NotAbleToOpenSystemBrowser,
27 #[error("Unable to open browser '{0}'")]
28 BrowserNotAvailable(String),
29 #[error("Unable to get remote parts, please open an issue as it might come from the code")]
30 UnableToGetRemoteParts,
31 #[error("Unknown provider")]
32 UnknownProvider,
33}
34
35pub struct Success;
36type Result = core::result::Result<Success, Issue>;
37
38impl Issue {
39 pub fn exit_code(&self) -> i32 {
40 match self {
41 Self::NotInAGitRepository => 1,
42 Self::NoRemoteMatching(..) => 2,
43 Self::NoRemoteAvailable => 3,
44 Self::NotAbleToOpenSystemBrowser => 4,
45 Self::BrowserNotAvailable(..) => 5,
46 Self::UnableToGetRemoteParts => 6,
47 Self::UnknownProvider => 7,
48 }
49 }
50}
51
52enum GitProvider {
53 GitHub,
54 GitLab,
55 Bitbucket,
56 Gitea,
57}
58
59impl Default for GitProvider {
60 fn default() -> Self {
61 Self::GitHub
62 }
63}
64
65impl GitProvider {
66 fn hostname(&self) -> String {
67 match self {
68 Self::GitHub => GITHUB_HOSTNAME,
69 Self::GitLab => GITLAB_HOSTNAME,
70 Self::Bitbucket => BITBUCKET_HOSTNAME,
71 Self::Gitea => GITEA_HOSTNAME,
72 }
73 .to_string()
74 }
75}
76
77pub struct RemoteParts {
78 domain: String,
79 repository: String,
80}
81
82struct MergeRequestParts {
83 path: String,
84 tail: String,
85}
86
87const DEFAULT_REMOTE_ORIGIN: &str = "origin";
88
89fn get_remote_parts(url: &str) -> anyhow::Result<RemoteParts> {
90 let giturl = GitUrl::parse(url).map_err(|_| Issue::UnableToGetRemoteParts)?;
91
92 let domain = giturl
93 .host
94 .map_or(GitProvider::GitHub.hostname(), |m| m.as_str().to_string());
95
96 let repository = giturl
97 .path
98 .replace(".git", "") .split('/')
100 .filter(|s| !s.is_empty())
101 .collect::<Vec<&str>>()
102 .join("/");
103
104 Ok(RemoteParts { domain, repository })
105}
106
107fn get_merge_request_parts(domain: &str) -> anyhow::Result<MergeRequestParts, Issue> {
108 match domain {
109 GITHUB_HOSTNAME => Ok(MergeRequestParts {
110 path: "pulls".to_string(),
111 tail: "".to_string(),
112 }),
113 GITLAB_HOSTNAME => Ok(MergeRequestParts {
114 path: "-/merge_requests".to_string(),
115 tail: "".to_string(),
116 }),
117 BITBUCKET_HOSTNAME => Ok(MergeRequestParts {
118 path: "pull-requests".to_string(),
119 tail: "".to_string(),
120 }),
121 GITEA_HOSTNAME => Ok(MergeRequestParts {
122 path: "pulls".to_string(),
123 tail: "".to_string(),
124 }),
125 _ => Err(Issue::UnknownProvider),
126 }
127}
128
129pub fn run(opt: Opt) -> Result {
130 debug!("Verbose mode is active");
132
133 let repo = git::get_repo()?;
134
135 let reference = if let Some(tag) = opt.tag {
138 tag
139 } else {
140 opt.branch.unwrap_or_else(|| {
144 debug!("No branch given, getting current one");
145 git::get_branch(&repo)
146 })
147 };
148
149 let remote_name = &opt
150 .remote
151 .unwrap_or_else(|| String::from(DEFAULT_REMOTE_ORIGIN));
152
153 debug!("Getting remote url for '{}' remote name", remote_name);
154
155 let optional_remote = repo
156 .find_remote(remote_name)
157 .map_err(|_| Issue::NoRemoteMatching(remote_name.clone()))?;
158
159 let remote_url = optional_remote
160 .url()
161 .ok_or(())
162 .map_err(|_| Issue::NoRemoteAvailable)?;
163
164 let RemoteParts { domain, repository } = get_remote_parts(remote_url).unwrap();
165
166 let (path, tail) = if let Some(commit) = opt.commit {
167 let path = if domain == GitProvider::Bitbucket.hostname() {
168 "commits"
169 } else {
170 "commit"
171 };
172
173 (path, commit)
174 } else {
175 let path = if domain == GitProvider::Bitbucket.hostname() {
176 "src"
177 } else {
178 "tree"
179 };
180
181 (path, reference)
182 };
183
184 let (path, tail) = if opt.merge_request {
185 debug!("Getting merge request parts for domain '{}'", domain);
186 let MergeRequestParts { path, tail } = get_merge_request_parts(&domain).unwrap();
187 (path, tail)
188 } else {
189 (path.to_owned(), tail)
190 };
191
192 let url = generate_url(&domain, &repository, &path, &tail);
194
195 match opt.browser {
197 Some(option_browser) => {
198 debug!("Browser '{}' given as option", option_browser);
199
200 if option_browser == *"" {
201 println!("{}", url);
202 }
203
204 open::with(&url, &option_browser)
205 .map_err(|_| Issue::BrowserNotAvailable(option_browser))?;
206
207 Ok(Success)
208 }
209 None => {
210 match open::that(&url) {
212 Ok(_) => {
213 debug!("Default browser is now opened");
214 Ok(Success)
215 }
216 Err(_) => Err(Issue::NotAbleToOpenSystemBrowser),
217 }
218 }
219 }
220}
221
222fn generate_url(domain: &str, repository: &str, path: &str, tail: &str) -> String {
223 format!(
224 "https://{domain}/{repository}/{path}/{tail}",
225 domain = domain,
226 path = path,
227 repository = repository,
228 tail = tail
229 )
230}
231
232#[cfg(test)]
233mod tests {
234 use super::*;
236
237 #[test]
238 fn test_without_ssh_git_and_without_extension_url_parts() {
239 let RemoteParts { domain, repository } =
240 get_remote_parts("git@github.com:yoannfleurydev/gitweb").unwrap();
241
242 assert_eq!(domain, "github.com");
243 assert_eq!(repository, "yoannfleurydev/gitweb");
244 }
245
246 #[test]
247 fn test_without_ssh_git_url_parts() {
248 let RemoteParts { domain, repository } =
249 get_remote_parts("git@github.com:yoannfleurydev/gitweb.git").unwrap();
250
251 assert_eq!(domain, "github.com");
252 assert_eq!(repository, "yoannfleurydev/gitweb");
253 }
254
255 #[test]
256 fn test_with_ssh_and_multiple_subgroups_git_url_parts() {
257 let RemoteParts { domain, repository } =
258 get_remote_parts("ssh://git@gitlab.com/group/subgroup/subsubgroup/design-system.git")
259 .unwrap();
260
261 assert_eq!(domain, "gitlab.com");
262 assert_eq!(repository, "group/subgroup/subsubgroup/design-system");
263 }
264
265 #[test]
266 fn test_with_ssh_and_port_git_url_parts() {
267 let RemoteParts { domain, repository } =
268 get_remote_parts("ssh://user@host.xz:22/path/to/repo.git/").unwrap();
269
270 assert_eq!(domain, "host.xz");
271 assert_eq!(repository, "path/to/repo");
272 }
273
274 #[test]
275 fn test_with_http_and_port_git_url_parts() {
276 let RemoteParts { domain, repository } =
277 get_remote_parts("http://host.xz:80/path/to/repo.git/").unwrap();
278
279 assert_eq!(domain, "host.xz");
280 assert_eq!(repository, "path/to/repo");
281 }
282
283 #[test]
284 fn test_with_http_dash_and_port_git_url_parts() {
285 let RemoteParts { domain, repository } =
286 get_remote_parts("http://host-dash.xz:80/path/to/repo.git/").unwrap();
287
288 assert_eq!(domain, "host-dash.xz");
289 assert_eq!(repository, "path/to/repo");
290 }
291
292 #[test]
293 fn test_with_http_git_url_parts() {
294 let RemoteParts { domain, repository } =
295 get_remote_parts("https://host.xz/path/to/repo.git/").unwrap();
296
297 assert_eq!(domain, "host.xz");
298 assert_eq!(repository, "path/to/repo");
299 }
300
301 #[test]
302 fn test_get_merge_request_parts_with_github() {
303 let MergeRequestParts { path, tail } = get_merge_request_parts(GITHUB_HOSTNAME).unwrap();
304
305 assert_eq!(path, "pulls");
306 assert_eq!(tail, "");
307 }
308
309 #[test]
310 fn test_get_merge_request_parts_with_gitlab() {
311 let MergeRequestParts { path, tail } = get_merge_request_parts(GITLAB_HOSTNAME).unwrap();
312
313 assert_eq!(path, "-/merge_requests");
314 assert_eq!(tail, "");
315 }
316
317 #[test]
318 fn test_get_merge_request_parts_with_bitbucket() {
319 let MergeRequestParts { path, tail } = get_merge_request_parts(BITBUCKET_HOSTNAME).unwrap();
320
321 assert_eq!(path, "pull-requests");
322 assert_eq!(tail, "");
323 }
324
325 #[test]
326 fn test_get_merge_request_parts_with_gitea() {
327 let MergeRequestParts { path, tail } = get_merge_request_parts(GITEA_HOSTNAME).unwrap();
328
329 assert_eq!(path, "pulls");
330 assert_eq!(tail, "");
331 }
332
333 #[test]
334 fn test_get_merge_request_parts_with_unknown_provider() {
335 let result = get_merge_request_parts("host.xz");
336
337 assert_eq!(result.err(), Some(Issue::UnknownProvider));
338 }
339}