gr/
remote.rs

1use std::fmt::{self, Display, Formatter};
2use std::fs::File;
3use std::path::{Path, PathBuf};
4
5use crate::api_traits::{
6    Cicd, CicdJob, CicdRunner, CodeGist, CommentMergeRequest, ContainerRegistry, Deploy,
7    DeployAsset, MergeRequest, ProjectMember, RemoteProject, RemoteTag, TrendingProjectURL,
8    UserInfo,
9};
10use crate::cache::{filesystem::FileCache, nocache::NoCache};
11use crate::config::{env_token, ConfigFile, NoConfig};
12use crate::display::Format;
13use crate::error::GRError;
14use crate::github::Github;
15use crate::gitlab::Gitlab;
16use crate::io::{CmdInfo, HttpResponse, HttpRunner, ShellResponse, TaskRunner};
17use crate::time::Milliseconds;
18use crate::{cli, error, get_default_config_path, http, log_debug, log_info};
19use crate::{git, Result};
20use std::sync::Arc;
21
22pub mod query;
23
24/// List cli args can be used across multiple APIs that support pagination.
25#[derive(Builder, Clone)]
26pub struct ListRemoteCliArgs {
27    #[builder(default)]
28    pub from_page: Option<i64>,
29    #[builder(default)]
30    pub to_page: Option<i64>,
31    #[builder(default)]
32    pub num_pages: bool,
33    #[builder(default)]
34    pub num_resources: bool,
35    #[builder(default)]
36    pub page_number: Option<i64>,
37    #[builder(default)]
38    pub created_after: Option<String>,
39    #[builder(default)]
40    pub created_before: Option<String>,
41    #[builder(default)]
42    pub sort: ListSortMode,
43    #[builder(default)]
44    pub flush: bool,
45    #[builder(default)]
46    pub throttle_time: Option<Milliseconds>,
47    #[builder(default)]
48    pub throttle_range: Option<(Milliseconds, Milliseconds)>,
49    #[builder(default)]
50    pub get_args: GetRemoteCliArgs,
51}
52
53impl ListRemoteCliArgs {
54    pub fn builder() -> ListRemoteCliArgsBuilder {
55        ListRemoteCliArgsBuilder::default()
56    }
57}
58
59#[derive(Builder, Clone, Default)]
60pub struct GetRemoteCliArgs {
61    #[builder(default)]
62    pub no_headers: bool,
63    #[builder(default)]
64    pub format: Format,
65    #[builder(default)]
66    pub cache_args: CacheCliArgs,
67    #[builder(default)]
68    pub display_optional: bool,
69    #[builder(default)]
70    pub backoff_max_retries: u32,
71    #[builder(default)]
72    pub backoff_retry_after: u64,
73}
74
75impl GetRemoteCliArgs {
76    pub fn builder() -> GetRemoteCliArgsBuilder {
77        GetRemoteCliArgsBuilder::default()
78    }
79}
80
81#[derive(Builder, Clone, Default)]
82pub struct CacheCliArgs {
83    #[builder(default)]
84    pub refresh: bool,
85    #[builder(default)]
86    pub no_cache: bool,
87}
88
89impl CacheCliArgs {
90    pub fn builder() -> CacheCliArgsBuilder {
91        CacheCliArgsBuilder::default()
92    }
93}
94
95/// List body args is a common structure that can be used across multiple APIs
96/// that support pagination. `list` operations in traits that accept some sort
97/// of List related arguments can encapsulate this structure. Example of those
98/// is `MergeRequestListBodyArgs`. This can be consumed by Github and Gitlab
99/// clients when executing HTTP requests.
100#[derive(Builder, Clone)]
101pub struct ListBodyArgs {
102    #[builder(setter(strip_option), default)]
103    pub page: Option<i64>,
104    #[builder(setter(strip_option), default)]
105    pub max_pages: Option<i64>,
106    #[builder(default)]
107    pub created_after: Option<String>,
108    #[builder(default)]
109    pub created_before: Option<String>,
110    #[builder(default)]
111    pub sort_mode: ListSortMode,
112    #[builder(default)]
113    pub flush: bool,
114    #[builder(default)]
115    pub throttle_time: Option<Milliseconds>,
116    #[builder(default)]
117    pub throttle_range: Option<(Milliseconds, Milliseconds)>,
118    // Carry display format for flush operations
119    #[builder(default)]
120    pub get_args: GetRemoteCliArgs,
121}
122
123impl ListBodyArgs {
124    pub fn builder() -> ListBodyArgsBuilder {
125        ListBodyArgsBuilder::default()
126    }
127}
128
129pub fn validate_from_to_page(remote_cli_args: &ListRemoteCliArgs) -> Result<Option<ListBodyArgs>> {
130    if remote_cli_args.page_number.is_some() {
131        return Ok(Some(
132            ListBodyArgs::builder()
133                .page(remote_cli_args.page_number.unwrap())
134                .max_pages(1)
135                .sort_mode(remote_cli_args.sort.clone())
136                .created_after(remote_cli_args.created_after.clone())
137                .created_before(remote_cli_args.created_before.clone())
138                .build()
139                .unwrap(),
140        ));
141    }
142    // TODO - this can probably be validated at the CLI level
143    let body_args = match (remote_cli_args.from_page, remote_cli_args.to_page) {
144        (Some(from_page), Some(to_page)) => {
145            if from_page < 0 || to_page < 0 {
146                return Err(GRError::PreconditionNotMet(
147                    "from_page and to_page must be a positive number".to_string(),
148                )
149                .into());
150            }
151            if from_page >= to_page {
152                return Err(GRError::PreconditionNotMet(
153                    "from_page must be less than to_page".to_string(),
154                )
155                .into());
156            }
157
158            let max_pages = to_page - from_page + 1;
159            Some(
160                ListBodyArgs::builder()
161                    .page(from_page)
162                    .max_pages(max_pages)
163                    .sort_mode(remote_cli_args.sort.clone())
164                    .flush(remote_cli_args.flush)
165                    .throttle_time(remote_cli_args.throttle_time)
166                    .throttle_range(remote_cli_args.throttle_range)
167                    .get_args(remote_cli_args.get_args.clone())
168                    .build()
169                    .unwrap(),
170            )
171        }
172        (Some(_), None) => {
173            return Err(
174                GRError::PreconditionNotMet("from_page requires the to_page".to_string()).into(),
175            );
176        }
177        (None, Some(to_page)) => {
178            if to_page < 0 {
179                return Err(GRError::PreconditionNotMet(
180                    "to_page must be a positive number".to_string(),
181                )
182                .into());
183            }
184            Some(
185                ListBodyArgs::builder()
186                    .page(1)
187                    .max_pages(to_page)
188                    .sort_mode(remote_cli_args.sort.clone())
189                    .flush(remote_cli_args.flush)
190                    .throttle_time(remote_cli_args.throttle_time)
191                    .throttle_range(remote_cli_args.throttle_range)
192                    .get_args(remote_cli_args.get_args.clone())
193                    .build()
194                    .unwrap(),
195            )
196        }
197        (None, None) => None,
198    };
199    match (
200        remote_cli_args.created_after.clone(),
201        remote_cli_args.created_before.clone(),
202    ) {
203        (Some(created_after), Some(created_before)) => {
204            if let Some(body_args) = &body_args {
205                return Ok(Some(
206                    ListBodyArgs::builder()
207                        .page(body_args.page.unwrap())
208                        .max_pages(body_args.max_pages.unwrap())
209                        .created_after(Some(created_after.to_string()))
210                        .created_before(Some(created_before.to_string()))
211                        .sort_mode(remote_cli_args.sort.clone())
212                        .flush(remote_cli_args.flush)
213                        .throttle_time(remote_cli_args.throttle_time)
214                        .throttle_range(remote_cli_args.throttle_range)
215                        .get_args(remote_cli_args.get_args.clone())
216                        .build()
217                        .unwrap(),
218                ));
219            }
220            Ok(Some(
221                ListBodyArgs::builder()
222                    .created_after(Some(created_after.to_string()))
223                    .created_before(Some(created_before.to_string()))
224                    .sort_mode(remote_cli_args.sort.clone())
225                    .flush(remote_cli_args.flush)
226                    .throttle_time(remote_cli_args.throttle_time)
227                    .throttle_range(remote_cli_args.throttle_range)
228                    .get_args(remote_cli_args.get_args.clone())
229                    .build()
230                    .unwrap(),
231            ))
232        }
233        (Some(created_after), None) => {
234            if let Some(body_args) = &body_args {
235                return Ok(Some(
236                    ListBodyArgs::builder()
237                        .page(body_args.page.unwrap())
238                        .max_pages(body_args.max_pages.unwrap())
239                        .created_after(Some(created_after.to_string()))
240                        .sort_mode(remote_cli_args.sort.clone())
241                        .flush(remote_cli_args.flush)
242                        .throttle_time(remote_cli_args.throttle_time)
243                        .throttle_range(remote_cli_args.throttle_range)
244                        .get_args(remote_cli_args.get_args.clone())
245                        .build()
246                        .unwrap(),
247                ));
248            }
249            Ok(Some(
250                ListBodyArgs::builder()
251                    .created_after(Some(created_after.to_string()))
252                    .sort_mode(remote_cli_args.sort.clone())
253                    .flush(remote_cli_args.flush)
254                    .throttle_time(remote_cli_args.throttle_time)
255                    .throttle_range(remote_cli_args.throttle_range)
256                    .get_args(remote_cli_args.get_args.clone())
257                    .build()
258                    .unwrap(),
259            ))
260        }
261        (None, Some(created_before)) => {
262            if let Some(body_args) = &body_args {
263                return Ok(Some(
264                    ListBodyArgs::builder()
265                        .page(body_args.page.unwrap())
266                        .max_pages(body_args.max_pages.unwrap())
267                        .created_before(Some(created_before.to_string()))
268                        .sort_mode(remote_cli_args.sort.clone())
269                        .flush(remote_cli_args.flush)
270                        .throttle_time(remote_cli_args.throttle_time)
271                        .throttle_range(remote_cli_args.throttle_range)
272                        .get_args(remote_cli_args.get_args.clone())
273                        .build()
274                        .unwrap(),
275                ));
276            }
277            Ok(Some(
278                ListBodyArgs::builder()
279                    .created_before(Some(created_before.to_string()))
280                    .sort_mode(remote_cli_args.sort.clone())
281                    .flush(remote_cli_args.flush)
282                    .throttle_time(remote_cli_args.throttle_time)
283                    .throttle_range(remote_cli_args.throttle_range)
284                    .get_args(remote_cli_args.get_args.clone())
285                    .build()
286                    .unwrap(),
287            ))
288        }
289        (None, None) => {
290            if let Some(body_args) = &body_args {
291                return Ok(Some(
292                    ListBodyArgs::builder()
293                        .page(body_args.page.unwrap())
294                        .max_pages(body_args.max_pages.unwrap())
295                        .sort_mode(remote_cli_args.sort.clone())
296                        .flush(remote_cli_args.flush)
297                        .throttle_time(remote_cli_args.throttle_time)
298                        .throttle_range(remote_cli_args.throttle_range)
299                        .get_args(remote_cli_args.get_args.clone())
300                        .build()
301                        .unwrap(),
302                ));
303            }
304            Ok(Some(
305                ListBodyArgs::builder()
306                    .sort_mode(remote_cli_args.sort.clone())
307                    .flush(remote_cli_args.flush)
308                    .throttle_time(remote_cli_args.throttle_time)
309                    .throttle_range(remote_cli_args.throttle_range)
310                    .get_args(remote_cli_args.get_args.clone())
311                    .build()
312                    .unwrap(),
313            ))
314        }
315    }
316}
317
318pub struct URLQueryParamBuilder {
319    url: String,
320}
321
322impl URLQueryParamBuilder {
323    pub fn new(url: &str) -> Self {
324        URLQueryParamBuilder {
325            url: url.to_string(),
326        }
327    }
328
329    pub fn add_param(&mut self, key: &str, value: &str) -> &mut Self {
330        if self.url.contains('?') {
331            self.url.push_str(&format!("&{}={}", key, value));
332        } else {
333            self.url.push_str(&format!("?{}={}", key, value));
334        }
335        self
336    }
337
338    pub fn build(&self) -> String {
339        self.url.clone()
340    }
341}
342
343#[derive(Clone, Debug, Default, PartialEq)]
344pub enum ListSortMode {
345    #[default]
346    Asc,
347    Desc,
348}
349
350#[derive(Clone, Debug, PartialEq)]
351pub enum CacheType {
352    File,
353    None,
354}
355
356use crate::config::ConfigProperties;
357macro_rules! get {
358    ($func_name:ident, $trait_name:ident) => {
359        paste::paste! {
360            pub fn $func_name(
361                domain: String,
362                path: String,
363                config: Arc<dyn ConfigProperties + Send + Sync + 'static>,
364                cache_args: Option<&CacheCliArgs>,
365                cache_type: CacheType,
366            ) -> Result<Arc<dyn $trait_name + Send + Sync + 'static>> {
367                let refresh_cache = cache_args.map_or(false, |args| args.refresh);
368                let no_cache_args = cache_args.map_or(false, |args| args.no_cache);
369
370                log_debug!("cache_type: {:?}", cache_type);
371                log_debug!("no_cache_args: {:?}", no_cache_args);
372                log_debug!("cache location: {:?}", config.cache_location());
373
374                if cache_type == CacheType::None || no_cache_args || config.cache_location().is_none() {
375                    log_info!("No cache used for {}", stringify!($func_name));
376                    let runner = Arc::new(http::Client::new(NoCache, config.clone(), refresh_cache));
377                    [<create_remote_ $func_name>](domain, path, config, runner)
378                } else {
379                    log_info!("File cache used for {}", stringify!($func_name));
380                    let file_cache = FileCache::new(config.clone());
381                    file_cache.validate_cache_location()?;
382                    let runner = Arc::new(http::Client::new(file_cache, config.clone(), refresh_cache));
383                    [<create_remote_ $func_name>](domain, path, config, runner)
384                }
385            }
386
387            fn [<create_remote_ $func_name>]<R>(
388                domain: String,
389                path: String,
390                config: Arc<dyn ConfigProperties>,
391                runner: Arc<R>,
392            ) -> Result<Arc<dyn $trait_name + Send + Sync + 'static>>
393            where
394                R: HttpRunner<Response = HttpResponse> + Send + Sync + 'static,
395            {
396                let github_domain_regex = regex::Regex::new(r"^github").unwrap();
397                let gitlab_domain_regex = regex::Regex::new(r"^gitlab").unwrap();
398                let remote: Arc<dyn $trait_name + Send + Sync + 'static> =
399                    if github_domain_regex.is_match(&domain) {
400                        Arc::new(Github::new(config, &domain, &path, runner))
401                    } else if gitlab_domain_regex.is_match(&domain) {
402                        Arc::new(Gitlab::new(config, &domain, &path, runner))
403                    } else {
404                        return Err(error::gen(format!("Unsupported domain: {}", &domain)));
405                    };
406                Ok(remote)
407            }
408        }
409    };
410}
411
412get!(get_mr, MergeRequest);
413get!(get_cicd, Cicd);
414get!(get_project, RemoteProject);
415get!(get_tag, RemoteTag);
416get!(get_user, UserInfo);
417get!(get_project_member, ProjectMember);
418get!(get_registry, ContainerRegistry);
419get!(get_deploy, Deploy);
420get!(get_deploy_asset, DeployAsset);
421get!(get_auth_user, UserInfo);
422get!(get_cicd_runner, CicdRunner);
423get!(get_comment_mr, CommentMergeRequest);
424get!(get_trending, TrendingProjectURL);
425get!(get_gist, CodeGist);
426get!(get_cicd_job, CicdJob);
427
428pub fn extract_domain_path(repo_cli: &str) -> (String, String) {
429    let parts: Vec<&str> = repo_cli.split('/').collect();
430    let domain = parts[0].to_string();
431    let path = parts[1..].join("/");
432    (domain, path)
433}
434
435/// Given a CLI command, the command can work as long as user is in a cd
436/// repository, user passes --domain flag (DomainArgs) or --repo flag
437/// (RepoArgs). Some CLI commands might work with one variant, with both, with
438/// all or might have no requirement at all.
439pub enum CliDomainRequirements {
440    CdInLocalRepo,
441    DomainArgs,
442    RepoArgs,
443}
444
445#[derive(Clone, Debug, Default)]
446pub struct RemoteURL {
447    /// Domain of the project. Ex github.com
448    domain: String,
449    /// Path to the project. Ex jordilin/gitar
450    path: String,
451    /// Config encoded project path. Ex jordilin_gitar
452    /// This is used as a key in TOML configuration in order to retrieve project
453    /// specific configuration that overrides its domain specific one.
454    config_encoded_project_path: String,
455    config_encoded_domain: String,
456}
457
458impl RemoteURL {
459    pub fn new(domain: String, path: String) -> Self {
460        let config_encoded_project_path = path.replace("/", "_");
461        let config_encoded_domain = domain.replace(".", "_");
462        RemoteURL {
463            domain,
464            path,
465            config_encoded_project_path,
466            config_encoded_domain,
467        }
468    }
469
470    pub fn domain(&self) -> &str {
471        &self.domain
472    }
473
474    pub fn path(&self) -> &str {
475        &self.path
476    }
477
478    pub fn config_encoded_project_path(&self) -> &str {
479        &self.config_encoded_project_path
480    }
481
482    pub fn config_encoded_domain(&self) -> &str {
483        &self.config_encoded_domain
484    }
485}
486
487impl CliDomainRequirements {
488    pub fn check<R: TaskRunner<Response = ShellResponse>>(
489        &self,
490        cli_args: &cli::CliArgs,
491        runner: &R,
492        mr_target_repo: &Option<&str>,
493    ) -> Result<RemoteURL> {
494        match self {
495            CliDomainRequirements::CdInLocalRepo => match git::remote_url(runner) {
496                Ok(CmdInfo::RemoteUrl(url)) => {
497                    // If target_repo is provided, then target's
498                    // <repo_owner>/<repo_name> takes preference. Domain is kept
499                    // as is from the forked repo.
500                    if let Some(target_repo) = mr_target_repo {
501                        Ok(RemoteURL::new(
502                            url.domain().to_string(),
503                            target_repo.to_string(),
504                        ))
505                    } else {
506                        Ok(url)
507                    }
508                }
509                Err(err) => Err(GRError::GitRemoteUrlNotFound(format!("{}", err)).into()),
510                _ => Err(GRError::ApplicationError(
511                    "Could not get remote url during startup. \
512                        main::get_config_domain_path - Please open a bug to \
513                        https://github.com/jordilin/gitar"
514                        .to_string(),
515                )
516                .into()),
517            },
518            CliDomainRequirements::DomainArgs => {
519                if cli_args.domain.is_some() {
520                    Ok(RemoteURL::new(
521                        cli_args.domain.as_ref().unwrap().to_string(),
522                        "".to_string(),
523                    ))
524                } else {
525                    Err(GRError::DomainExpected("Missing domain information".to_string()).into())
526                }
527            }
528            CliDomainRequirements::RepoArgs => {
529                if cli_args.repo.is_some() {
530                    let (domain, path) = extract_domain_path(cli_args.repo.as_ref().unwrap());
531                    Ok(RemoteURL::new(domain, path))
532                } else {
533                    Err(GRError::RepoExpected("Missing repository information".to_string()).into())
534                }
535            }
536        }
537    }
538}
539
540impl Display for CliDomainRequirements {
541    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
542        match self {
543            CliDomainRequirements::CdInLocalRepo => write!(f, "cd to a git repository"),
544            CliDomainRequirements::DomainArgs => write!(f, "provide --domain option"),
545            CliDomainRequirements::RepoArgs => write!(f, "provide --repo option"),
546        }
547    }
548}
549
550pub fn url<R: TaskRunner<Response = ShellResponse>>(
551    cli_args: &cli::CliArgs,
552    requirements: &[CliDomainRequirements],
553    runner: &R,
554    mr_target_repo: &Option<&str>,
555) -> Result<RemoteURL> {
556    let mut errors = Vec::new();
557    for requirement in requirements {
558        match requirement.check(cli_args, runner, mr_target_repo) {
559            Ok(url) => return Ok(url),
560            Err(err) => {
561                errors.push(err);
562            }
563        }
564    }
565    let trace = errors
566        .iter()
567        .map(|e| format!("{}", e))
568        .collect::<Vec<String>>()
569        .join("\n");
570
571    let expectations_missed_trace = requirements
572        .iter()
573        .map(|r| format!("{}", r))
574        .collect::<Vec<String>>()
575        .join(" OR ");
576
577    Err(GRError::PreconditionNotMet(format!(
578        "\n\nMissed requirements: {}\n\n Errors:\n\n {}",
579        expectations_missed_trace, trace
580    ))
581    .into())
582}
583
584/// Reads configuration from TOML file. The config_file is the main default
585/// config file and it holds global configuration. Additionally, this function
586/// will attempt to gather configurations named after the domain and the project
587/// we are targeting. This is so the main config does not become unnecessarily
588/// large when providing merge request configuration for a specific project. The
589/// total configuration is as if we concatenated them all into one, so headers
590/// cannot be named the same across different configuration files. The
591/// configuration for a specific domain or project can go to either config file
592/// but cannot be mixed. Ex.
593///
594/// - gitar.toml - left empty
595/// - github_com.toml - holds configuration for github.com
596/// - github_com_jordilin_gitar.toml - holds configuration for jordilin/gitar
597///
598/// But also, we could just have:
599///
600/// - gitar.toml - left empty
601/// - github_com_jordilin_gitar.toml - holds configuration for gitub.com and
602///   jordilin/gitar
603/// - github_com.toml - left empty
604///
605/// Up to the user how he/she wants to organize the TOML configuration across
606/// files as long as TOML headers are unique and abide by the configuration
607/// format supported by Gitar.
608///
609/// If all files are missing, then a default configuration is returned. That is
610/// gitar works with no configuration as long as auth tokens are provided via
611/// environment variables. Ex. CI/CD use cases and one-offs.
612pub fn read_config(
613    config_path: ConfigFilePath,
614    url: &RemoteURL,
615) -> Result<Arc<dyn ConfigProperties>> {
616    let enc_domain = url.config_encoded_domain();
617
618    let domain_config_file = config_path.directory.join(format!("{}.toml", enc_domain));
619    let domain_project_file = config_path.directory.join(format!(
620        "{}_{}.toml",
621        enc_domain,
622        url.config_encoded_project_path()
623    ));
624
625    log_debug!("config_file: {:?}", config_path.file_name);
626    log_debug!("domain_config_file: {:?}", domain_config_file);
627    log_debug!("domain_project_config_file: {:?}", domain_project_file);
628
629    let mut extra_configs = [domain_config_file, domain_project_file]
630        .into_iter()
631        .map(PathBuf::from)
632        .collect::<Vec<PathBuf>>();
633
634    fn open_files(file_paths: &[PathBuf]) -> Vec<File> {
635        file_paths
636            .iter()
637            .filter_map(|path| match File::open(path) {
638                Ok(file) => Some(file),
639                Err(e) => {
640                    log_debug!("Could not open file: {:?} - {}", path, e);
641                    None
642                }
643            })
644            .collect()
645    }
646
647    extra_configs.push(config_path.file_name);
648    let files = open_files(&extra_configs);
649    if files.is_empty() {
650        let config = NoConfig::new(url.domain(), env_token)?;
651        return Ok(Arc::new(config));
652    }
653    let config = ConfigFile::new(files, url, env_token)?;
654    Ok(Arc::new(config))
655}
656
657/// ConfigFilePath is in charge of computing the default config file name and
658/// its parent directory based on global CLI arguments.
659pub struct ConfigFilePath {
660    directory: PathBuf,
661    file_name: PathBuf,
662}
663
664impl ConfigFilePath {
665    pub fn new(cli_args: &cli::CliArgs) -> Self {
666        let directory = if let Some(ref config) = cli_args.config {
667            &Path::new(config).to_path_buf()
668        } else {
669            get_default_config_path()
670        };
671        let file_name = directory.join("gitar.toml");
672        ConfigFilePath {
673            directory: directory.clone(),
674            file_name,
675        }
676    }
677
678    pub fn directory(&self) -> &PathBuf {
679        &self.directory
680    }
681
682    pub fn file_name(&self) -> &PathBuf {
683        &self.file_name
684    }
685}
686
687#[cfg(test)]
688mod test {
689    use cli::CliArgs;
690
691    use crate::test::utils::MockRunner;
692
693    use super::*;
694
695    #[test]
696    fn test_cli_from_to_pages_valid_range() {
697        let from_page = Option::Some(1);
698        let to_page = Option::Some(3);
699        let args = ListRemoteCliArgs::builder()
700            .from_page(from_page)
701            .to_page(to_page)
702            .build()
703            .unwrap();
704        let args = validate_from_to_page(&args).unwrap().unwrap();
705        assert_eq!(args.page, Some(1));
706        assert_eq!(args.max_pages, Some(3));
707    }
708
709    #[test]
710    fn test_cli_from_to_pages_invalid_range() {
711        let from_page = Some(5);
712        let to_page = Some(2);
713        let args = ListRemoteCliArgs::builder()
714            .from_page(from_page)
715            .to_page(to_page)
716            .build()
717            .unwrap();
718        let args = validate_from_to_page(&args);
719        match args {
720            Err(err) => match err.downcast_ref::<error::GRError>() {
721                Some(error::GRError::PreconditionNotMet(_)) => (),
722                _ => panic!("Expected error::GRError::PreconditionNotMet"),
723            },
724            _ => panic!("Expected error"),
725        }
726    }
727
728    #[test]
729    fn test_cli_from_page_negative_number_is_error() {
730        let from_page = Some(-5);
731        let to_page = Some(5);
732        let args = ListRemoteCliArgs::builder()
733            .from_page(from_page)
734            .to_page(to_page)
735            .build()
736            .unwrap();
737        let args = validate_from_to_page(&args);
738        match args {
739            Err(err) => match err.downcast_ref::<error::GRError>() {
740                Some(error::GRError::PreconditionNotMet(_)) => (),
741                _ => panic!("Expected error::GRError::PreconditionNotMet"),
742            },
743            _ => panic!("Expected error"),
744        }
745    }
746
747    #[test]
748    fn test_cli_to_page_negative_number_is_error() {
749        let from_page = Some(5);
750        let to_page = Some(-5);
751        let args = ListRemoteCliArgs::builder()
752            .from_page(from_page)
753            .to_page(to_page)
754            .build()
755            .unwrap();
756        let args = validate_from_to_page(&args);
757        match args {
758            Err(err) => match err.downcast_ref::<error::GRError>() {
759                Some(error::GRError::PreconditionNotMet(_)) => (),
760                _ => panic!("Expected error::GRError::PreconditionNotMet"),
761            },
762            _ => panic!("Expected error"),
763        }
764    }
765
766    #[test]
767    fn test_cli_from_page_without_to_page_is_error() {
768        let from_page = Some(5);
769        let to_page = None;
770        let args = ListRemoteCliArgs::builder()
771            .from_page(from_page)
772            .to_page(to_page)
773            .build()
774            .unwrap();
775        let args = validate_from_to_page(&args);
776        match args {
777            Err(err) => match err.downcast_ref::<error::GRError>() {
778                Some(error::GRError::PreconditionNotMet(_)) => (),
779                _ => panic!("Expected error::GRError::PreconditionNotMet"),
780            },
781            _ => panic!("Expected error"),
782        }
783    }
784
785    #[test]
786    fn test_if_from_and_to_provided_must_be_positive() {
787        let from_page = Some(-5);
788        let to_page = Some(-5);
789        let args = ListRemoteCliArgs::builder()
790            .from_page(from_page)
791            .to_page(to_page)
792            .build()
793            .unwrap();
794        let args = validate_from_to_page(&args);
795        match args {
796            Err(err) => match err.downcast_ref::<error::GRError>() {
797                Some(error::GRError::PreconditionNotMet(_)) => (),
798                _ => panic!("Expected error::GRError::PreconditionNotMet"),
799            },
800            _ => panic!("Expected error"),
801        }
802    }
803
804    #[test]
805    fn test_if_page_number_provided_max_pages_is_1() {
806        let page_number = Some(5);
807        let args = ListRemoteCliArgs::builder()
808            .page_number(page_number)
809            .build()
810            .unwrap();
811        let args = validate_from_to_page(&args).unwrap().unwrap();
812        assert_eq!(args.page, Some(5));
813        assert_eq!(args.max_pages, Some(1));
814    }
815
816    #[test]
817    fn test_include_created_after_in_list_body_args() {
818        let created_after = "2021-01-01T00:00:00Z";
819        let args = ListRemoteCliArgs::builder()
820            .created_after(Some(created_after.to_string()))
821            .build()
822            .unwrap();
823        let args = validate_from_to_page(&args).unwrap().unwrap();
824        assert_eq!(args.created_after.unwrap(), created_after);
825    }
826
827    #[test]
828    fn test_includes_from_to_page_and_created_after_in_list_body_args() {
829        let from_page = Some(1);
830        let to_page = Some(3);
831        let created_after = "2021-01-01T00:00:00Z";
832        let args = ListRemoteCliArgs::builder()
833            .from_page(from_page)
834            .to_page(to_page)
835            .created_after(Some(created_after.to_string()))
836            .build()
837            .unwrap();
838        let args = validate_from_to_page(&args).unwrap().unwrap();
839        assert_eq!(args.page, Some(1));
840        assert_eq!(args.max_pages, Some(3));
841        assert_eq!(args.created_after.unwrap(), created_after);
842    }
843
844    #[test]
845    fn test_includes_sort_mode_in_list_body_args_used_with_created_after() {
846        let args = ListRemoteCliArgs::builder()
847            .created_after(Some("2021-01-01T00:00:00Z".to_string()))
848            .sort(ListSortMode::Desc)
849            .build()
850            .unwrap();
851        let args = validate_from_to_page(&args).unwrap().unwrap();
852        assert_eq!(args.sort_mode, ListSortMode::Desc);
853    }
854
855    #[test]
856    fn test_includes_sort_mode_in_list_body_args_used_with_from_to_page() {
857        let from_page = Some(1);
858        let to_page = Some(3);
859        let args = ListRemoteCliArgs::builder()
860            .from_page(from_page)
861            .to_page(to_page)
862            .sort(ListSortMode::Desc)
863            .build()
864            .unwrap();
865        let args = validate_from_to_page(&args).unwrap().unwrap();
866        assert_eq!(args.sort_mode, ListSortMode::Desc);
867    }
868
869    #[test]
870    fn test_includes_sort_mode_in_list_body_args_used_with_page_number() {
871        let page_number = Some(1);
872        let args = ListRemoteCliArgs::builder()
873            .page_number(page_number)
874            .sort(ListSortMode::Desc)
875            .build()
876            .unwrap();
877        let args = validate_from_to_page(&args).unwrap().unwrap();
878        assert_eq!(args.sort_mode, ListSortMode::Desc);
879    }
880
881    #[test]
882    fn test_add_created_after_with_page_number() {
883        let page_number = Some(1);
884        let created_after = "2021-01-01T00:00:00Z";
885        let args = ListRemoteCliArgs::builder()
886            .page_number(page_number)
887            .created_after(Some(created_after.to_string()))
888            .build()
889            .unwrap();
890        let args = validate_from_to_page(&args).unwrap().unwrap();
891        assert_eq!(args.page.unwrap(), 1);
892        assert_eq!(args.max_pages.unwrap(), 1);
893        assert_eq!(args.created_after.unwrap(), created_after);
894    }
895
896    #[test]
897    fn test_add_created_before_with_page_number() {
898        let page_number = Some(1);
899        let created_before = "2021-01-01T00:00:00Z";
900        let args = ListRemoteCliArgs::builder()
901            .page_number(page_number)
902            .created_before(Some(created_before.to_string()))
903            .build()
904            .unwrap();
905        let args = validate_from_to_page(&args).unwrap().unwrap();
906        assert_eq!(args.page.unwrap(), 1);
907        assert_eq!(args.max_pages.unwrap(), 1);
908        assert_eq!(args.created_before.unwrap(), created_before);
909    }
910
911    #[test]
912    fn test_add_created_before_with_from_to_page() {
913        let from_page = Some(1);
914        let to_page = Some(3);
915        let created_before = "2021-01-01T00:00:00Z";
916        let args = ListRemoteCliArgs::builder()
917            .from_page(from_page)
918            .to_page(to_page)
919            .created_before(Some(created_before.to_string()))
920            .sort(ListSortMode::Desc)
921            .build()
922            .unwrap();
923        let args = validate_from_to_page(&args).unwrap().unwrap();
924        assert_eq!(args.page.unwrap(), 1);
925        assert_eq!(args.max_pages.unwrap(), 3);
926        assert_eq!(args.created_before.unwrap(), created_before);
927        assert_eq!(args.sort_mode, ListSortMode::Desc);
928    }
929
930    #[test]
931    fn test_add_crated_before_with_no_created_after_option_and_no_page_number() {
932        let created_before = "2021-01-01T00:00:00Z";
933        let args = ListRemoteCliArgs::builder()
934            .created_before(Some(created_before.to_string()))
935            .sort(ListSortMode::Desc)
936            .build()
937            .unwrap();
938        let args = validate_from_to_page(&args).unwrap().unwrap();
939        assert_eq!(args.created_before.unwrap(), created_before);
940        assert_eq!(args.sort_mode, ListSortMode::Desc);
941    }
942
943    #[test]
944    fn test_adds_created_after_and_created_before_with_from_to_page() {
945        let from_page = Some(1);
946        let to_page = Some(3);
947        let created_after = "2021-01-01T00:00:00Z";
948        let created_before = "2021-01-02T00:00:00Z";
949        let args = ListRemoteCliArgs::builder()
950            .from_page(from_page)
951            .to_page(to_page)
952            .created_after(Some(created_after.to_string()))
953            .created_before(Some(created_before.to_string()))
954            .sort(ListSortMode::Desc)
955            .build()
956            .unwrap();
957        let args = validate_from_to_page(&args).unwrap().unwrap();
958        assert_eq!(args.page.unwrap(), 1);
959        assert_eq!(args.max_pages.unwrap(), 3);
960        assert_eq!(args.created_after.unwrap(), created_after);
961        assert_eq!(args.created_before.unwrap(), created_before);
962        assert_eq!(args.sort_mode, ListSortMode::Desc);
963    }
964
965    #[test]
966    fn test_add_created_after_and_before_no_from_to_page_options() {
967        let created_after = "2021-01-01T00:00:00Z";
968        let created_before = "2021-01-02T00:00:00Z";
969        let args = ListRemoteCliArgs::builder()
970            .created_after(Some(created_after.to_string()))
971            .created_before(Some(created_before.to_string()))
972            .sort(ListSortMode::Desc)
973            .build()
974            .unwrap();
975        let args = validate_from_to_page(&args).unwrap().unwrap();
976        assert_eq!(args.created_after.unwrap(), created_after);
977        assert_eq!(args.created_before.unwrap(), created_before);
978        assert_eq!(args.sort_mode, ListSortMode::Desc);
979    }
980
981    #[test]
982    fn test_if_only_to_page_provided_max_pages_is_to_page() {
983        let to_page = Some(3);
984        let args = ListRemoteCliArgs::builder()
985            .to_page(to_page)
986            .build()
987            .unwrap();
988        let args = validate_from_to_page(&args).unwrap().unwrap();
989        assert_eq!(args.page, Some(1));
990        assert_eq!(args.max_pages, Some(3));
991    }
992
993    #[test]
994    fn test_if_only_to_page_provided_and_negative_number_is_error() {
995        let to_page = Some(-3);
996        let args = ListRemoteCliArgs::builder()
997            .to_page(to_page)
998            .build()
999            .unwrap();
1000        let args = validate_from_to_page(&args);
1001        match args {
1002            Err(err) => match err.downcast_ref::<error::GRError>() {
1003                Some(error::GRError::PreconditionNotMet(_)) => (),
1004                _ => panic!("Expected error::GRError::PreconditionNotMet"),
1005            },
1006            _ => panic!("Expected error"),
1007        }
1008    }
1009
1010    #[test]
1011    fn test_if_sort_provided_use_it() {
1012        let args = ListRemoteCliArgs::builder()
1013            .sort(ListSortMode::Desc)
1014            .build()
1015            .unwrap();
1016        let args = validate_from_to_page(&args).unwrap().unwrap();
1017        assert_eq!(args.sort_mode, ListSortMode::Desc);
1018    }
1019
1020    #[test]
1021    fn test_if_flush_option_provided_use_it() {
1022        let args = ListRemoteCliArgs::builder().flush(true).build().unwrap();
1023        let args = validate_from_to_page(&args).unwrap().unwrap();
1024        assert!(args.flush);
1025    }
1026
1027    #[test]
1028    fn test_query_param_builder_no_params() {
1029        let url = "https://example.com";
1030        let url = URLQueryParamBuilder::new(url).build();
1031        assert_eq!(url, "https://example.com");
1032    }
1033
1034    #[test]
1035    fn test_query_param_builder_with_params() {
1036        let url = "https://example.com";
1037        let url = URLQueryParamBuilder::new(url)
1038            .add_param("key", "value")
1039            .add_param("key2", "value2")
1040            .build();
1041        assert_eq!(url, "https://example.com?key=value&key2=value2");
1042    }
1043
1044    #[test]
1045    fn test_retrieve_domain_path_from_repo_cli_flag() {
1046        let repo_cli = "github.com/jordilin/gitar";
1047        let (domain, path) = extract_domain_path(repo_cli);
1048        assert_eq!("github.com", domain);
1049        assert_eq!("jordilin/gitar", path);
1050    }
1051
1052    #[test]
1053    fn test_cli_requires_cd_local_repo_run_git_remote() {
1054        let cli_args = CliArgs::new(0, None, None, None);
1055        let response = ShellResponse::builder()
1056            .body("git@github.com:jordilin/gitar.git".to_string())
1057            .build()
1058            .unwrap();
1059        let runner = MockRunner::new(vec![response]);
1060        let requirements = vec![CliDomainRequirements::CdInLocalRepo];
1061        let url = url(&cli_args, &requirements, &runner, &None).unwrap();
1062        assert_eq!("github.com", url.domain());
1063        assert_eq!("jordilin/gitar", url.path());
1064    }
1065
1066    #[test]
1067    fn test_cli_requires_cd_local_repo_run_git_remote_error() {
1068        let cli_args = CliArgs::new(0, None, None, None);
1069        let response = ShellResponse::builder()
1070            .body("".to_string())
1071            .build()
1072            .unwrap();
1073        let runner = MockRunner::new(vec![response]);
1074        let requirements = vec![CliDomainRequirements::CdInLocalRepo];
1075        let result = url(&cli_args, &requirements, &runner, &None);
1076        match result {
1077            Err(err) => match err.downcast_ref::<error::GRError>() {
1078                Some(error::GRError::PreconditionNotMet(_)) => (),
1079                _ => panic!("Expected error::GRError::GitRemoteUrlNotFound"),
1080            },
1081            _ => panic!("Expected error"),
1082        }
1083    }
1084
1085    #[test]
1086    fn test_cli_requires_repo_args_or_cd_repo_fails_on_cd_repo() {
1087        let cli_args = CliArgs::new(0, Some("github.com/jordilin/gitar".to_string()), None, None);
1088        let requirements = vec![
1089            CliDomainRequirements::CdInLocalRepo,
1090            CliDomainRequirements::RepoArgs,
1091        ];
1092        let response = ShellResponse::builder()
1093            .body("".to_string())
1094            .build()
1095            .unwrap();
1096        let url = url(
1097            &cli_args,
1098            &requirements,
1099            &MockRunner::new(vec![response]),
1100            &None,
1101        )
1102        .unwrap();
1103        assert_eq!("github.com", url.domain());
1104        assert_eq!("jordilin/gitar", url.path());
1105        assert_eq!("jordilin_gitar", url.config_encoded_project_path());
1106    }
1107
1108    #[test]
1109    fn test_cli_requires_domain_args_or_cd_repo_fails_on_cd_repo() {
1110        let cli_args = CliArgs::new(0, None, Some("github.com".to_string()), None);
1111        let requirements = vec![
1112            CliDomainRequirements::CdInLocalRepo,
1113            CliDomainRequirements::DomainArgs,
1114        ];
1115        let response = ShellResponse::builder()
1116            .body("".to_string())
1117            .build()
1118            .unwrap();
1119        let url = url(
1120            &cli_args,
1121            &requirements,
1122            &MockRunner::new(vec![response]),
1123            &None,
1124        )
1125        .unwrap();
1126        assert_eq!("github.com", url.domain());
1127        assert_eq!("", url.path());
1128    }
1129
1130    #[test]
1131    fn test_remote_url() {
1132        let remote_url = RemoteURL::new("github.com".to_string(), "jordilin/gitar".to_string());
1133        assert_eq!("github.com", remote_url.domain());
1134        assert_eq!("jordilin/gitar", remote_url.path());
1135    }
1136
1137    #[test]
1138    fn test_get_config_encoded_project_path() {
1139        let remote_url = RemoteURL::new("github.com".to_string(), "jordilin/gitar".to_string());
1140        assert_eq!("jordilin_gitar", remote_url.config_encoded_project_path());
1141    }
1142
1143    #[test]
1144    fn test_get_config_encoded_project_path_multiple_groups() {
1145        let remote_url = RemoteURL::new(
1146            "gitlab.com".to_string(),
1147            "team/subgroup/project".to_string(),
1148        );
1149        assert_eq!(
1150            "team_subgroup_project",
1151            remote_url.config_encoded_project_path()
1152        );
1153    }
1154
1155    #[test]
1156    fn test_get_config_encoded_domain() {
1157        let remote_url = RemoteURL::new("github.com".to_string(), "jordilin/gitar".to_string());
1158        assert_eq!("github_com", remote_url.config_encoded_domain());
1159    }
1160
1161    #[test]
1162    fn test_remote_url_from_optional_target_repo() {
1163        let target_repo = Some("jordilin/gitar");
1164        let cli_args = CliArgs::default();
1165        // Huck Finn opens a PR from a forked repo over to the main repo
1166        // jordilin/gitar
1167        let response = ShellResponse::builder()
1168            .body("git@github.com:hfinn/gitar.git".to_string())
1169            .build()
1170            .unwrap();
1171        let runner = MockRunner::new(vec![response]);
1172        let requirements = vec![CliDomainRequirements::CdInLocalRepo];
1173        let url = url(&cli_args, &requirements, &runner, &target_repo).unwrap();
1174        assert_eq!("github.com", url.domain());
1175        assert_eq!("jordilin/gitar", url.path());
1176    }
1177}