gitweb/
lib.rs

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", "") // don't want the .git part
99        .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    // let logger = logger::Logger::new(opt.verbose);
131    debug!("Verbose mode is active");
132
133    let repo = git::get_repo()?;
134
135    // Get the tag to show in the browser. If the option is given, then the value
136    // will be used as it is an alias for branch.
137    let reference = if let Some(tag) = opt.tag {
138        tag
139    } else {
140        // Get the branch to show in the browser. If the option is given, then, the
141        // value will be used, else, the current branch is given, or master if
142        // something went wrong.
143        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    // Generate the requested url that has to be opened in the browser
193    let url = generate_url(&domain, &repository, &path, &tail);
194
195    // If the option is available through the command line, open the given one
196    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            // Open the default web browser on the current system.
211            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    // Note this useful idiom: importing names from outer (for mod tests) scope.
235    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}