git_view/
lib.rs

1mod error;
2mod git;
3
4use std::borrow::Cow;
5
6use error::{AppError, ErrorType};
7use git::{GitOutput, GitTrait, Local, Url};
8
9pub use git::Git;
10
11#[derive(Default)]
12pub struct GitView<'a> {
13    remote: Option<&'a str>,
14    branch: Option<&'a str>,
15    commit: Option<&'a str>,
16    issue: Option<&'a str>,
17    path: Option<&'a str>,
18    is_print: bool,
19}
20
21impl<'a> GitView<'a> {
22    pub fn new(
23        branch: Option<&'a str>,
24        remote: Option<&'a str>,
25        commit: Option<&'a str>,
26        issue: Option<&'a str>,
27        path: Option<&'a str>,
28        is_print: bool,
29    ) -> Self {
30        Self {
31            remote,
32            branch,
33            commit,
34            issue,
35            path,
36            is_print,
37        }
38    }
39
40    pub fn view_repository(&self, git: impl GitTrait) -> Result<(), AppError> {
41        self.is_valid_repository(&git)?;
42        let local_ref = self.get_local_ref(&git)?;
43        let remote = self.populate_remote(&local_ref, &git)?;
44        let remote_ref = self.get_remote_reference(&local_ref, &remote, &git)?;
45
46        // Retrieve the full git_url
47        // e.g https://github.com/sgoudham/git-view.git
48        let git_url = self.get_git_url(&remote, &git)?;
49        let url = self.parse_git_url(&git_url)?;
50        let final_url = self.generate_final_url(&remote_ref, &url, &git)?;
51
52        if self.is_print {
53            println!("{}", final_url);
54        } else {
55            webbrowser::open(final_url.as_str())?;
56        }
57
58        Ok(())
59    }
60
61    fn is_valid_repository(&self, git: &impl GitTrait) -> Result<(), AppError> {
62        match git.is_valid_repository()? {
63            GitOutput::Ok(_) => Ok(()),
64            GitOutput::Err(_) => Err(AppError::new(
65                ErrorType::MissingGitRepository,
66                "Looks like you're not in a valid git repository!".to_string(),
67            )),
68        }
69    }
70
71    fn get_local_ref(&self, git: &impl GitTrait) -> Result<Local, AppError> {
72        if self.branch.is_none() {
73            match git.get_local_branch()? {
74                GitOutput::Ok(output) => Ok(Local::Branch(Cow::Owned(output))),
75                GitOutput::Err(_) => Ok(Local::NotBranch),
76            }
77        } else {
78            Ok(Local::Branch(Cow::Borrowed(self.branch.as_ref().unwrap())))
79        }
80    }
81
82    /// Populates the remote variable within [`GitView`]
83    /// User Given Remote -> Default Remote in Config -> Tracked Remote -> 'origin'
84    fn populate_remote(
85        &self,
86        local: &Local,
87        git: &impl GitTrait,
88    ) -> Result<Cow<'_, str>, AppError> {
89        // Priority goes to user given remote
90        if self.remote.is_none() {
91            match local {
92                Local::Branch(branch) => {
93                    // Priority then goes to the default remote
94                    match git.get_default_remote()? {
95                        GitOutput::Ok(def) => Ok(Cow::Owned(def)),
96                        // Priority then goes to the tracked remote
97                        GitOutput::Err(_) => match git.get_tracked_remote(branch)? {
98                            GitOutput::Ok(tracked) => Ok(Cow::Owned(tracked)),
99                            // Default to the 'origin' remote
100                            GitOutput::Err(_) => Ok(Cow::Owned("origin".into())),
101                        },
102                    }
103                }
104                Local::NotBranch => Ok(Cow::Owned("origin".into())),
105            }
106        } else {
107            Ok(Cow::Borrowed(self.remote.as_ref().unwrap()))
108        }
109    }
110
111    fn get_remote_reference(
112        &self,
113        local: &'a Local,
114        remote: &'a str,
115        git: &impl GitTrait,
116    ) -> Result<Cow<'a, str>, AppError> {
117        match local {
118            Local::Branch(branch) => {
119                match git.get_upstream_branch(branch)? {
120                    GitOutput::Ok(output) => Ok(Cow::Owned(
121                        output.trim_start_matches("refs/heads/").to_string(),
122                    )),
123                    // Upstream branch doesn't exist, try to retrieve default remote branch
124                    GitOutput::Err(_) => match git.get_default_branch(remote)? {
125                        GitOutput::Ok(default_branch) => {
126                            println!("Cannot verify '{remote}/{branch}' exists, defaulting to '{default_branch}'");
127                            return match default_branch.split_once('/') {
128                                Some((_, split_branch)) => Ok(Cow::Owned(split_branch.into())),
129                                None => Ok(Cow::Borrowed(branch)),
130                            };
131                        }
132                        GitOutput::Err(_) => Err(AppError::new(
133                            ErrorType::MissingDefaultBranch,
134                            format!("Could not verify '{remote}/{branch}' exists and could not retrieve default branch")
135                        )),
136                    },
137                }
138            }
139            // Priority is given to the current tag
140            Local::NotBranch => match git.get_current_tag()? {
141                GitOutput::Ok(tag) => Ok(Cow::Owned(tag)),
142                // Priority is then given the current commit
143                GitOutput::Err(_) => match git.get_current_commit()? {
144                    GitOutput::Ok(commit_hash) => Ok(Cow::Owned(commit_hash)),
145                    // Error out if even the current commit could not be found
146                    GitOutput::Err(err) => Err(AppError::new(ErrorType::CommandFailed, err)),
147                },
148            },
149        }
150    }
151
152    fn get_git_url(&self, remote: &str, git: &impl GitTrait) -> Result<String, AppError> {
153        match git.is_valid_remote(remote)? {
154            GitOutput::Ok(output) => {
155                if output != remote {
156                    Ok(output)
157                } else {
158                    Err(AppError::new(
159                        ErrorType::MissingGitRemote,
160                        format!("Looks like your git remote isn't set for '{}'", remote),
161                    ))
162                }
163            }
164            GitOutput::Err(err) => Err(AppError::new(ErrorType::CommandFailed, err)),
165        }
166    }
167
168    /*
169     * Potential formats:
170     *  - ssh://[user@]host.xz[:port]/path/to/repo.git/
171     *  - git://host.xz[:port]/path/to/repo.git/
172     *  - http[s]://host.xz[:port]/path/to/repo.git/
173     *  - ftp[s]://host.xz[:port]/path/to/repo.git/
174     *  - [user@]host.xz:path/to/repo.git/
175     */
176    fn parse_git_url(&self, git_url: &str) -> Result<Url, AppError> {
177        // rust-url cannot parse 'scp-like' urls -> https://github.com/servo/rust-url/issues/220
178        // Manually parse the url ourselves
179
180        if git_url.contains("://") {
181            match url::Url::parse(git_url) {
182                Ok(url) => Ok(Url::new(
183                    url.scheme(),
184                    url.host_str().map_or_else(|| "github.com", |host| host),
185                    url.path()
186                        .trim_start_matches('/')
187                        .trim_end_matches('/')
188                        .trim_end_matches(".git"),
189                )),
190                Err(_) => Err(AppError::new(
191                    ErrorType::InvalidGitUrl,
192                    format!("Sorry, couldn't parse git url '{}'", git_url),
193                )),
194            }
195        } else {
196            match git_url.split_once(':') {
197                Some((domain, path)) => {
198                    let protocol = "https";
199                    let path = path.trim_end_matches('/').trim_end_matches(".git");
200                    let split_domain = match domain.split_once('@') {
201                        Some((_username, dom)) => dom,
202                        None => domain,
203                    };
204
205                    Ok(Url::new(protocol, split_domain, path))
206                }
207                None => Err(AppError::new(
208                    ErrorType::InvalidGitUrl,
209                    format!("Sorry, couldn't parse git url '{}'", git_url),
210                )),
211            }
212        }
213    }
214
215    fn generate_final_url(
216        &self,
217        remote_ref: &str,
218        url: &Url,
219        git: &impl GitTrait,
220    ) -> Result<String, AppError> {
221        let mut open_url = format!("{}://{}/{}", url.protocol, url.domain, url.path);
222        let escaped_remote_ref = escape_ascii_chars(remote_ref);
223
224        if let Some(issue) = self.issue {
225            self.handle_issue_flag(issue, &escaped_remote_ref, &mut open_url)?;
226            return Ok(open_url);
227        }
228        if let Some(commit) = self.commit {
229            self.handle_commit_flag(commit, &mut open_url, git)?;
230            return Ok(open_url);
231        }
232        if let Some(path) = self.path {
233            let prefix = format!("/tree/{}", escaped_remote_ref);
234            self.handle_path_flag(Some(prefix.as_str()), path, &mut open_url, git)?;
235            return Ok(open_url);
236        }
237
238        open_url.push_str(format!("/tree/{}", escaped_remote_ref).as_str());
239
240        Ok(open_url)
241    }
242
243    fn handle_issue_flag(
244        &self,
245        issue: &str,
246        remote_ref: &str,
247        open_url: &mut String,
248    ) -> Result<(), AppError> {
249        if issue == "branch" {
250            if let Some(issue_num) = capture_digits(remote_ref) {
251                open_url.push_str(format!("/issues/{}", issue_num).as_str());
252            } else {
253                open_url.push_str("/issues");
254            }
255        } else {
256            open_url.push_str(format!("/issues/{}", issue).as_str());
257        }
258
259        Ok(())
260    }
261
262    fn handle_commit_flag(
263        &self,
264        commit: &str,
265        open_url: &mut String,
266        git: &impl GitTrait,
267    ) -> Result<(), AppError> {
268        if commit == "current" {
269            match git.get_current_commit()? {
270                GitOutput::Ok(hash) => {
271                    open_url.push_str(format!("/tree/{}", hash).as_str());
272                }
273                GitOutput::Err(err) => return Err(AppError::new(ErrorType::CommandFailed, err)),
274            };
275        } else {
276            open_url.push_str(format!("/tree/{}", commit).as_str());
277        }
278
279        // path can still be appended after commit hash
280        if let Some(path) = self.path {
281            // prefix is empty because trailing slash will be added
282            self.handle_path_flag(None, path, open_url, git)?;
283        }
284
285        Ok(())
286    }
287
288    fn handle_path_flag(
289        &self,
290        prefix: Option<&str>,
291        path: &str,
292        open_url: &mut String,
293        git: &impl GitTrait,
294    ) -> Result<(), AppError> {
295        if path == "current-working-directory" {
296            match git.get_current_working_directory()? {
297                GitOutput::Ok(cwd) => {
298                    // If the current working directory is not the root of the repo, append it
299                    if !cwd.is_empty() {
300                        open_url.push_str(format!("{}/{}", prefix.unwrap(), cwd).as_str());
301                    }
302                }
303                GitOutput::Err(err) => return Err(AppError::new(ErrorType::CommandFailed, err)),
304            }
305        } else if let Some(prefix) = prefix {
306            open_url.push_str(format!("{}/{}", prefix, path).as_str());
307        } else {
308            open_url.push_str(format!("/{}", path).as_str());
309        }
310
311        Ok(())
312    }
313}
314
315fn capture_digits(remote_ref: &str) -> Option<&str> {
316    let mut start = 0;
317    let mut end = 0;
318    let mut found = false;
319
320    for (indice, char) in remote_ref.char_indices() {
321        if found {
322            if char.is_numeric() {
323                end = indice;
324            } else {
325                break;
326            }
327        } else if char.is_numeric() {
328            start = indice;
329            found = true;
330        }
331    }
332
333    if found {
334        Some(&remote_ref[start..=end])
335    } else {
336        None
337    }
338}
339
340fn escape_ascii_chars(remote_ref: &str) -> Cow<'_, str> {
341    // I could use this below but I wanted to be more comfortable with Cow
342    // branch.replace('%', "%25").replace('#', "%23");
343
344    if remote_ref.contains(['%', '#']) {
345        let mut escaped_str = String::with_capacity(remote_ref.len());
346
347        for char in remote_ref.chars() {
348            match char {
349                '%' => escaped_str.push_str("%25"),
350                '#' => escaped_str.push_str("%23"),
351                _ => escaped_str.push(char),
352            };
353        }
354
355        Cow::Owned(escaped_str)
356    } else {
357        Cow::Borrowed(remote_ref)
358    }
359}
360
361#[cfg(test)]
362mod lib_tests {
363    use crate::GitView;
364
365    impl<'a> GitView<'a> {
366        fn builder() -> GitViewBuilder<'a> {
367            GitViewBuilder::default()
368        }
369    }
370
371    #[derive(Default)]
372    pub(crate) struct GitViewBuilder<'a> {
373        remote: Option<&'a str>,
374        branch: Option<&'a str>,
375        commit: Option<&'a str>,
376        issue: Option<&'a str>,
377        path: Option<&'a str>,
378        is_print: bool,
379    }
380
381    impl<'a> GitViewBuilder<'a> {
382        pub(crate) fn with_remote(mut self, remote: &'a str) -> Self {
383            self.remote = Some(remote);
384            self
385        }
386
387        pub(crate) fn with_branch(mut self, branch: &'a str) -> Self {
388            self.branch = Some(branch);
389            self
390        }
391
392        pub(crate) fn with_commit(mut self, commit: &'a str) -> Self {
393            self.commit = Some(commit);
394            self
395        }
396
397        pub(crate) fn with_issue(mut self, issue: &'a str) -> Self {
398            self.issue = Some(issue);
399            self
400        }
401
402        pub(crate) fn with_path(mut self, path: &'a str) -> Self {
403            self.path = Some(path);
404            self
405        }
406
407        pub(crate) fn build(self) -> GitView<'a> {
408            GitView::new(
409                self.branch,
410                self.remote,
411                self.commit,
412                self.issue,
413                self.path,
414                self.is_print,
415            )
416        }
417    }
418
419    mod is_valid_repository {
420        use crate::{
421            error::ErrorType,
422            git::{GitOutput, MockGitTrait},
423            GitView,
424        };
425
426        #[test]
427        fn yes() {
428            let handler = GitView::default();
429            let mut mock = MockGitTrait::default();
430
431            mock.expect_is_valid_repository()
432                .returning(|| Ok(GitOutput::Ok("Valid".to_owned())));
433
434            let is_valid_repository = handler.is_valid_repository(&mock);
435
436            assert!(is_valid_repository.is_ok());
437        }
438
439        #[test]
440        fn no() {
441            let handler = GitView::default();
442            let mut mock = MockGitTrait::default();
443
444            mock.expect_is_valid_repository()
445                .returning(|| Ok(GitOutput::Err("Error".to_owned())));
446
447            let is_valid_repository = handler.is_valid_repository(&mock);
448
449            assert!(is_valid_repository.is_err());
450            let error = is_valid_repository.as_ref().unwrap_err();
451            assert_eq!(error.error_type, ErrorType::MissingGitRepository);
452            assert_eq!(
453                error.error_str,
454                "Looks like you're not in a valid git repository!"
455            );
456        }
457    }
458
459    mod get_local_ref {
460        use std::borrow::Cow;
461
462        use crate::{
463            git::{GitOutput, MockGitTrait},
464            GitView, Local,
465        };
466
467        #[test]
468        fn user_given_branch() {
469            let handler = GitView::builder().with_branch("main").build();
470            let mock = MockGitTrait::default();
471            let expected_local_ref = Ok(Local::Branch(Cow::Borrowed("main")));
472
473            let actual_local_ref = handler.get_local_ref(&mock);
474
475            assert!(actual_local_ref.is_ok());
476            assert_eq!(actual_local_ref, expected_local_ref);
477        }
478
479        #[test]
480        fn is_branch() {
481            let handler = GitView::default();
482            let mut mock = MockGitTrait::default();
483            let expected_local_ref = Ok(Local::Branch(Cow::Borrowed("dev")));
484
485            mock.expect_get_local_branch()
486                .returning(|| Ok(GitOutput::Ok("dev".into())));
487
488            let actual_local_ref = handler.get_local_ref(&mock);
489
490            assert!(actual_local_ref.is_ok());
491            assert_eq!(actual_local_ref, expected_local_ref);
492        }
493
494        #[test]
495        fn is_not_branch() {
496            let handler = GitView::default();
497            let mut mock = MockGitTrait::default();
498            let expected_local_ref = Ok(Local::NotBranch);
499
500            mock.expect_get_local_branch()
501                .returning(|| Ok(GitOutput::Err("Error".into())));
502
503            let actual_local_ref = handler.get_local_ref(&mock);
504
505            assert!(actual_local_ref.is_ok());
506            assert_eq!(actual_local_ref, expected_local_ref);
507        }
508    }
509
510    mod populate_remote {
511        use std::borrow::Cow;
512
513        use mockall::predicate::eq;
514
515        use crate::{
516            git::{GitOutput, MockGitTrait},
517            GitView, Local,
518        };
519
520        #[test]
521        fn is_not_branch() {
522            let handler = GitView::builder().with_remote("origin").build();
523            let mock = MockGitTrait::default();
524
525            let actual_remote = handler.populate_remote(&Local::NotBranch, &mock);
526
527            assert!(actual_remote.is_ok());
528            assert_eq!(actual_remote.unwrap(), "origin");
529        }
530
531        #[test]
532        fn user_given_remote() {
533            let handler = GitView::builder().with_remote("origin").build();
534            let mock = MockGitTrait::default();
535
536            let actual_remote = handler.populate_remote(&Local::Branch(Cow::Borrowed("")), &mock);
537
538            assert!(actual_remote.is_ok());
539            assert_eq!(actual_remote.unwrap(), handler.remote.unwrap());
540        }
541
542        #[test]
543        fn is_default_remote() {
544            let handler = GitView::default();
545            let mut mock = MockGitTrait::default();
546
547            mock.expect_get_default_remote()
548                .returning(|| Ok(GitOutput::Ok("default_remote".into())));
549
550            let actual_remote = handler.populate_remote(&Local::Branch(Cow::Borrowed("")), &mock);
551
552            assert!(actual_remote.is_ok());
553            assert_eq!(actual_remote.unwrap(), "default_remote");
554        }
555
556        #[test]
557        fn is_tracked_remote() {
558            let handler = GitView::default();
559            let mut mock = MockGitTrait::default();
560
561            mock.expect_get_default_remote()
562                .returning(|| Ok(GitOutput::Err("error".into())));
563            mock.expect_get_tracked_remote()
564                .with(eq("branch"))
565                .returning(|_| Ok(GitOutput::Ok("tracked_remote".into())));
566
567            let actual_remote =
568                handler.populate_remote(&Local::Branch(Cow::Borrowed("branch")), &mock);
569
570            assert!(actual_remote.is_ok());
571            assert_eq!(actual_remote.unwrap(), "tracked_remote");
572        }
573
574        #[test]
575        fn is_not_default_or_tracked() {
576            let handler = GitView::default();
577            let mut mock = MockGitTrait::default();
578
579            mock.expect_get_default_remote()
580                .returning(|| Ok(GitOutput::Err("error".into())));
581            mock.expect_get_tracked_remote()
582                .with(eq("branch"))
583                .returning(|_| Ok(GitOutput::Err("error".into())));
584
585            let actual_remote =
586                handler.populate_remote(&Local::Branch(Cow::Borrowed("branch")), &mock);
587
588            assert!(actual_remote.is_ok());
589            assert_eq!(actual_remote.unwrap(), "origin");
590        }
591    }
592
593    mod get_remote_reference {
594        use std::borrow::Cow;
595
596        use crate::{
597            error::ErrorType,
598            git::{GitOutput, MockGitTrait},
599            GitView, Local,
600        };
601
602        #[test]
603        fn is_branch_and_exists_on_remote() {
604            let handler = GitView::default();
605            let local = Local::Branch(Cow::Borrowed("main"));
606            let mut mock = MockGitTrait::default();
607
608            mock.expect_get_upstream_branch()
609                .returning(|_| Ok(GitOutput::Ok("refs/heads/main".into())));
610
611            let actual_upstream_branch = handler.get_remote_reference(&local, "origin", &mock);
612
613            assert!(actual_upstream_branch.is_ok());
614            assert_eq!(actual_upstream_branch.unwrap(), "main");
615        }
616
617        #[test]
618        fn is_branch_and_successfully_get_default() {
619            let handler = GitView::default();
620            let local = Local::Branch(Cow::Borrowed("main"));
621            let mut mock = MockGitTrait::default();
622
623            mock.expect_get_upstream_branch()
624                .returning(|_| Ok(GitOutput::Err("error".into())));
625            mock.expect_get_default_branch()
626                .returning(|_| Ok(GitOutput::Ok("origin/main".into())));
627
628            let actual_upstream_branch = handler.get_remote_reference(&local, "origin", &mock);
629
630            assert!(actual_upstream_branch.is_ok());
631            assert_eq!(actual_upstream_branch.unwrap(), "main")
632        }
633
634        #[test]
635        fn is_branch_and_fail_to_get_default() {
636            let handler = GitView::default();
637            let local = Local::Branch(Cow::Borrowed("testing"));
638            let mut mock = MockGitTrait::default();
639
640            mock.expect_get_upstream_branch()
641                .returning(|_| Ok(GitOutput::Err("error".into())));
642            mock.expect_get_default_branch()
643                .returning(|_| Ok(GitOutput::Err("error".into())));
644
645            let actual_upstream_branch = handler.get_remote_reference(&local, "origin", &mock);
646
647            assert!(actual_upstream_branch.is_err());
648            assert_eq!(
649                actual_upstream_branch.unwrap_err().error_str,
650                "Could not verify 'origin/testing' exists and could not retrieve default branch"
651            );
652        }
653
654        #[test]
655        fn not_branch_and_get_current_tag() {
656            let handler = GitView::default();
657            let local = Local::NotBranch;
658            let mut mock = MockGitTrait::default();
659
660            mock.expect_get_current_tag()
661                .returning(|| Ok(GitOutput::Ok("v1.0.0".into())));
662
663            let actual_upstream_branch = handler.get_remote_reference(&local, "origin", &mock);
664
665            assert!(actual_upstream_branch.is_ok());
666            assert_eq!(actual_upstream_branch.unwrap(), "v1.0.0")
667        }
668
669        #[test]
670        fn not_branch_and_get_current_commit() {
671            let handler = GitView::default();
672            let local = Local::NotBranch;
673            let mut mock = MockGitTrait::default();
674
675            mock.expect_get_current_tag()
676                .returning(|| Ok(GitOutput::Err("error".into())));
677            mock.expect_get_current_commit()
678                .returning(|| Ok(GitOutput::Ok("hash".into())));
679
680            let actual_upstream_branch = handler.get_remote_reference(&local, "origin", &mock);
681
682            assert!(actual_upstream_branch.is_ok());
683            assert_eq!(actual_upstream_branch.unwrap(), "hash")
684        }
685
686        #[test]
687        fn not_branch_and_no_tag_or_commit() {
688            let handler = GitView::default();
689            let local = Local::NotBranch;
690            let mut mock = MockGitTrait::default();
691
692            mock.expect_get_current_tag()
693                .returning(|| Ok(GitOutput::Err("error".into())));
694            mock.expect_get_current_commit()
695                .returning(|| Ok(GitOutput::Err("error".into())));
696
697            let actual_upstream_branch = handler.get_remote_reference(&local, "origin", &mock);
698
699            assert!(actual_upstream_branch.is_err());
700
701            let error = actual_upstream_branch.as_ref().unwrap_err();
702            assert_eq!(error.error_type, ErrorType::CommandFailed);
703            assert_eq!(error.error_str, "error");
704        }
705    }
706
707    mod get_git_url {
708        use crate::{
709            error::{AppError, ErrorType},
710            git::{GitOutput, MockGitTrait},
711            GitView,
712        };
713
714        #[test]
715        fn is_valid_remote() {
716            let handler = GitView::default();
717            let expected_remote = "origin";
718            let mut mock = MockGitTrait::default();
719
720            mock.expect_is_valid_remote()
721                .returning(|_| Ok(GitOutput::Ok("https://github.com/sgoudham/git-view".into())));
722
723            let actual_remote = handler.get_git_url(expected_remote, &mock);
724
725            assert!(actual_remote.is_ok());
726            assert_eq!(
727                actual_remote.unwrap(),
728                "https://github.com/sgoudham/git-view"
729            )
730        }
731
732        #[test]
733        fn is_not_valid_remote() {
734            let handler = GitView::default();
735            let expected_remote = "origin";
736            let mut mock = MockGitTrait::default();
737
738            mock.expect_is_valid_remote()
739                .returning(|_| Ok(GitOutput::Ok("origin".into())));
740
741            let actual_remote = handler.get_git_url(expected_remote, &mock);
742
743            assert!(actual_remote.is_err());
744            assert_eq!(
745                actual_remote.unwrap_err().error_str,
746                "Looks like your git remote isn't set for 'origin'"
747            );
748        }
749
750        #[test]
751        fn command_failed() {
752            let handler = GitView::default();
753            let expected_remote = "origin";
754            let mut mock = MockGitTrait::default();
755
756            mock.expect_is_valid_remote()
757                .returning(|_| Err(AppError::new(ErrorType::CommandFailed, "error".into())));
758
759            let actual_remote = handler.get_git_url(expected_remote, &mock);
760
761            assert!(actual_remote.is_err());
762            assert_eq!(actual_remote.unwrap_err().error_str, "error");
763        }
764    }
765
766    mod parse_git_url {
767        use crate::{error::AppError, GitView};
768        use test_case::test_case;
769
770        #[test_case("https://github.com:8080/sgoudham/git-view.git" ; "with port")]
771        #[test_case("https://github.com/sgoudham/git-view.git"      ; "normal")]
772        #[test_case("https://github.com/sgoudham/git-view.git/"     ; "with trailing slash")]
773        fn https(git_url: &str) -> Result<(), AppError> {
774            let handler = GitView::default();
775
776            let url = handler.parse_git_url(git_url)?;
777
778            assert_eq!(url.protocol, "https");
779            assert_eq!(url.domain, "github.com");
780            assert_eq!(url.path, "sgoudham/git-view");
781
782            Ok(())
783        }
784
785        #[test_case("git@github.com:sgoudham/git-view.git"  ; "with username")]
786        #[test_case("github.com:sgoudham/git-view.git"      ; "normal")]
787        #[test_case("github.com:sgoudham/git-view.git/"     ; "with trailing slash")]
788        fn scp_like(git_url: &str) -> Result<(), AppError> {
789            let handler = GitView::default();
790
791            let url = handler.parse_git_url(git_url)?;
792
793            assert_eq!(url.protocol, "https");
794            assert_eq!(url.domain, "github.com");
795            assert_eq!(url.path, "sgoudham/git-view");
796
797            Ok(())
798        }
799
800        #[test]
801        fn invalid_git_url() {
802            let handler = GitView::default();
803            let git_url_normal = "This isn't a git url";
804
805            let error = handler.parse_git_url(git_url_normal);
806
807            assert!(error.is_err());
808            assert_eq!(
809                error.unwrap_err().error_str,
810                "Sorry, couldn't parse git url 'This isn't a git url'"
811            );
812        }
813    }
814
815    mod generate_final_url {
816        use crate::{
817            git::{GitOutput, MockGitTrait, Url},
818            GitView,
819        };
820        use test_case::test_case;
821
822        #[test]
823        fn is_latest_commit() {
824            let handler = GitView::builder().with_commit("current").build();
825            let url = Url::new("https", "github.com", "sgoudham/git-view");
826            let expected_final_url = "https://github.com/sgoudham/git-view/tree/eafdb9a";
827            let mut mock = MockGitTrait::default();
828
829            mock.expect_get_current_commit()
830                .returning(|| Ok(GitOutput::Ok("eafdb9a".into())));
831
832            let actual_final_url = handler.generate_final_url("main", &url, &mock);
833
834            assert!(actual_final_url.is_ok());
835            assert_eq!(actual_final_url.unwrap(), expected_final_url);
836        }
837
838        #[test]
839        fn is_user_commit() {
840            let handler = GitView::builder()
841                .with_commit("8s2jl250as7f234jasfjj")
842                .build();
843            let url = Url::new("https", "github.com", "sgoudham/git-view");
844            let expected_final_url =
845                "https://github.com/sgoudham/git-view/tree/8s2jl250as7f234jasfjj";
846            let mock = MockGitTrait::default();
847
848            let actual_final_url = handler.generate_final_url("main", &url, &mock);
849
850            assert!(actual_final_url.is_ok());
851            assert_eq!(actual_final_url.unwrap(), expected_final_url);
852        }
853
854        #[test]
855        fn is_latest_commit_with_path_current_working_directory() {
856            let handler = GitView::builder()
857                .with_commit("current")
858                .with_path("src/main.rs")
859                .build();
860            let url = Url::new("https", "github.com", "sgoudham/git-view");
861            let expected_final_url =
862                "https://github.com/sgoudham/git-view/tree/eafdb9a/src/main.rs";
863
864            let mut mock = MockGitTrait::default();
865            mock.expect_get_current_commit()
866                .returning(|| Ok(GitOutput::Ok("eafdb9a".into())));
867
868            let actual_final_url = handler.generate_final_url("main", &url, &mock);
869
870            assert!(actual_final_url.is_ok());
871            assert_eq!(actual_final_url.unwrap(), expected_final_url);
872        }
873
874        #[test_case("main" ; "main")]
875        #[test_case("master" ; "master")]
876        fn is_master_or_main(branch: &str) {
877            let handler = GitView::default();
878            let url = Url::new("https", "github.com", "sgoudham/git-view");
879            let expected_final_url = format!("https://github.com/sgoudham/git-view/tree/{branch}");
880            let mock = MockGitTrait::default();
881
882            let actual_final_url = handler.generate_final_url(branch, &url, &mock);
883
884            assert!(actual_final_url.is_ok());
885            assert_eq!(actual_final_url.unwrap(), expected_final_url);
886        }
887
888        #[test_case("main" ; "main")]
889        #[test_case("master" ; "master")]
890        fn is_master_or_main_with_issue_flag(branch: &str) {
891            let handler = GitView::builder().with_issue("branch").build();
892            let url = Url::new("https", "github.com", "sgoudham/git-view");
893            let expected_final_url = "https://github.com/sgoudham/git-view/issues";
894            let mock = MockGitTrait::default();
895
896            let actual_final_url = handler.generate_final_url(branch, &url, &mock);
897
898            assert!(actual_final_url.is_ok());
899            assert_eq!(actual_final_url.unwrap(), expected_final_url);
900        }
901
902        #[test]
903        fn is_user_issue() {
904            let handler = GitView::builder().with_issue("branch").build();
905            let url = Url::new("https", "github.com", "sgoudham/git-view");
906            let expected_final_url = "https://github.com/sgoudham/git-view/issues/1234";
907            let mock = MockGitTrait::default();
908
909            let actual_final_url = handler.generate_final_url("TICKET-1234", &url, &mock);
910
911            assert!(actual_final_url.is_ok());
912            assert_eq!(actual_final_url.unwrap(), expected_final_url);
913        }
914
915        #[test]
916        fn is_user_issue_with_args() {
917            let handler = GitView::builder().with_issue("42").build();
918            let url = Url::new("https", "github.com", "sgoudham/git-view");
919            let expected_final_url = "https://github.com/sgoudham/git-view/issues/42";
920            let mock = MockGitTrait::default();
921
922            let actual_final_url = handler.generate_final_url("main", &url, &mock);
923
924            assert!(actual_final_url.is_ok());
925            assert_eq!(actual_final_url.unwrap(), expected_final_url);
926        }
927
928        #[test]
929        fn is_normal_branch() {
930            let handler = GitView::builder().build();
931            let url = Url::new("https", "github.com", "sgoudham/git-view");
932            let expected_final_url = "https://github.com/sgoudham/git-view/tree/%23test%23";
933            let mock = MockGitTrait::default();
934
935            let actual_final_url = handler.generate_final_url("#test#", &url, &mock);
936
937            assert!(actual_final_url.is_ok());
938            assert_eq!(actual_final_url.unwrap(), expected_final_url);
939        }
940
941        #[test]
942        fn is_user_path() {
943            let handler = GitView::builder().with_path("src/main.rs").build();
944            let url = Url::new("https", "github.com", "sgoudham/git-view");
945            let expected_final_url = "https://github.com/sgoudham/git-view/tree/main/src/main.rs";
946            let mock = MockGitTrait::default();
947
948            let actual_final_url = handler.generate_final_url("main", &url, &mock);
949
950            assert!(actual_final_url.is_ok());
951            assert_eq!(actual_final_url.unwrap(), expected_final_url);
952        }
953
954        #[test]
955        fn is_path_at_repo_root() {
956            let handler = GitView::builder()
957                .with_path("current-working-directory")
958                .build();
959            let url = Url::new("https", "github.com", "sgoudham/git-view");
960            let expected_final_url = "https://github.com/sgoudham/git-view";
961
962            let mut mock = MockGitTrait::default();
963            mock.expect_get_current_working_directory()
964                .returning(|| Ok(GitOutput::Ok("".into())));
965
966            let actual_final_url = handler.generate_final_url("main", &url, &mock);
967
968            assert!(actual_final_url.is_ok());
969            assert_eq!(actual_final_url.unwrap(), expected_final_url);
970        }
971
972        #[test]
973        fn is_path_at_sub_directory() {
974            let handler = GitView::builder()
975                .with_path("current-working-directory")
976                .build();
977            let url = Url::new("https", "github.com", "sgoudham/git-view");
978            let expected_final_url = "https://github.com/sgoudham/git-view/tree/main/src/";
979
980            // `git rev-parse --show-prefix` returns relative path with a trailing slash
981            let mut mock = MockGitTrait::default();
982            mock.expect_get_current_working_directory()
983                .returning(|| Ok(GitOutput::Ok("src/".into())));
984
985            let actual_final_url = handler.generate_final_url("main", &url, &mock);
986
987            assert!(actual_final_url.is_ok());
988            assert_eq!(actual_final_url.unwrap(), expected_final_url);
989        }
990    }
991
992    mod capture_digits {
993        use test_case::test_case;
994
995        use crate::capture_digits;
996
997        #[test_case("🥵🥵Hazel🥵-1234🥵🥵",     "1234"                      ; "with emojis")]
998        #[test_case("TICKET-1234-To-V10",       "1234"                      ; "with multiple issue numbers")]
999        #[test_case("TICKET-1234",              "1234"                      ; "with issue number at end")]
1000        #[test_case("1234-TICKET",              "1234"                      ; "with issue number at start")]
1001        #[test_case("1234",                     "1234"                      ; "with no letters")]
1002        fn branch(input: &str, expected_remote_ref: &str) {
1003            let actual_remote_ref = capture_digits(input);
1004            assert_eq!(actual_remote_ref, Some(expected_remote_ref));
1005        }
1006
1007        #[test]
1008        fn branch_no_numbers() {
1009            let input = "TICKET-WITH-NO-NUMBERS";
1010            let actual_remote_ref = capture_digits(input);
1011            assert_eq!(actual_remote_ref, None);
1012        }
1013    }
1014
1015    mod escape_ascii_chars {
1016        use test_case::test_case;
1017
1018        use crate::escape_ascii_chars;
1019
1020        #[test_case("🥵🥵Hazel🥵-%1234#🥵🥵",   "🥵🥵Hazel🥵-%251234%23🥵🥵"    ; "with emojis")]
1021        #[test_case("TICKET-%1234#",            "TICKET-%251234%23"             ; "with hashtag and percentage")]
1022        #[test_case("TICKET-%1234",             "TICKET-%251234"                ; "with percentage")]
1023        #[test_case("TICKET-#1234",             "TICKET-%231234"                ; "with hashtag")]
1024        #[test_case("TICKET",                   "TICKET"                        ; "with only alphabet")]
1025        fn branch(input: &str, expected_remote_ref: &str) {
1026            let actual_remote_ref = escape_ascii_chars(input);
1027            assert_eq!(actual_remote_ref, expected_remote_ref);
1028        }
1029    }
1030}