gr/
config.rs

1//! Config file parsing and validation.
2
3use crate::api_defaults::{EXPIRE_IMMEDIATELY, RATE_LIMIT_REMAINING_THRESHOLD, REST_API_MAX_PAGES};
4use crate::api_traits::ApiOperation;
5use crate::cmds::project::{Member, MrMemberType};
6use crate::error::{self, GRError};
7use crate::remote::RemoteURL;
8use crate::Result;
9use serde::Deserialize;
10use std::sync::Arc;
11use std::{collections::HashMap, io::Read};
12
13pub trait ConfigProperties: Send + Sync {
14    fn api_token(&self) -> &str;
15    fn cache_location(&self) -> Option<&str>;
16    fn preferred_assignee_username(&self) -> Option<Member> {
17        None
18    }
19
20    fn merge_request_members(&self) -> Vec<Member> {
21        vec![]
22    }
23
24    fn merge_request_description_signature(&self) -> &str {
25        ""
26    }
27
28    fn get_cache_expiration(&self, _api_operation: &ApiOperation) -> &str {
29        // Defaults to regular HTTP cache expiration mechanisms.
30        "0s"
31    }
32    fn get_max_pages(&self, _api_operation: &ApiOperation) -> u32 {
33        REST_API_MAX_PAGES
34    }
35
36    fn rate_limit_remaining_threshold(&self) -> u32 {
37        RATE_LIMIT_REMAINING_THRESHOLD
38    }
39}
40
41/// The NoConfig struct is used when no configuration is found and it can be
42/// used for CI/CD scenarios where no configuration is needed or for other
43/// one-off scenarios.
44pub struct NoConfig {
45    api_token: String,
46}
47
48impl NoConfig {
49    pub fn new<FE: Fn(&str) -> Result<String>>(domain: &str, env: FE) -> Result<Self> {
50        let api_token_res = env(domain);
51        let api_token = api_token_res.map_err(|_| {
52            GRError::PreconditionNotMet(format!(
53                "Configuration not found, so it is expected environment variable {}_API_TOKEN to be set.",
54                env_var(domain)
55            ))
56        })?;
57        Ok(NoConfig { api_token })
58    }
59}
60
61impl ConfigProperties for NoConfig {
62    fn api_token(&self) -> &str {
63        &self.api_token
64    }
65
66    fn cache_location(&self) -> Option<&str> {
67        None
68    }
69}
70
71#[derive(Deserialize, Clone, Debug)]
72struct ApiSettings {
73    #[serde(flatten)]
74    settings: HashMap<ApiOperation, String>,
75}
76
77#[derive(Deserialize, Clone, Debug)]
78struct MaxPagesApi {
79    #[serde(flatten)]
80    settings: HashMap<ApiOperation, u32>,
81}
82
83#[derive(Deserialize, Clone, Debug)]
84#[serde(untagged)]
85enum UserInfo {
86    /// Github remote. Github REST API requires username only when using the
87    /// REST API.
88    UsernameOnly(String),
89    /// Gitlab remotes. Gitlab REST API requires user ID. This configuration
90    /// allows us to map username with user ID, so we can identify which ID is
91    /// associated to which user.
92    UsernameID {
93        username: String,
94        id: u64,
95    },
96    UsernameIDString {
97        username: String,
98        id: String,
99    },
100}
101
102#[derive(Deserialize, Clone, Debug, Default)]
103struct MergeRequestConfig {
104    preferred_assignee_username: Option<UserInfo>,
105    members: Option<Vec<UserInfo>>,
106    description_signature: Option<String>,
107}
108
109#[derive(Deserialize, Clone, Debug)]
110struct ProjectConfig {
111    merge_requests: Option<MergeRequestConfig>,
112}
113
114#[derive(Deserialize, Clone, Debug, Default)]
115pub struct DomainConfig {
116    api_token: Option<String>,
117    cache_location: Option<String>,
118    merge_requests: Option<MergeRequestConfig>,
119    rate_limit_remaining_threshold: Option<u32>,
120    cache_expirations: Option<ApiSettings>,
121    max_pages_api: Option<MaxPagesApi>,
122    #[serde(flatten)]
123    projects: HashMap<String, ProjectConfig>,
124}
125
126#[derive(Deserialize, Clone, Debug, Default)]
127pub struct ConfigFileInner {
128    #[serde(flatten)]
129    domains: HashMap<String, DomainConfig>,
130}
131
132#[derive(Clone, Debug, Default)]
133pub struct ConfigFile {
134    inner: ConfigFileInner,
135    domain_key: String,
136    project_path_key: String,
137}
138
139pub fn env_token(domain: &str) -> Result<String> {
140    let env_domain = env_var(domain);
141    Ok(std::env::var(format!("{}_API_TOKEN", env_domain))?)
142}
143
144fn env_var(domain: &str) -> String {
145    let domain_fields = domain.split('.').collect::<Vec<&str>>();
146    let env_domain = if domain_fields.len() == 1 {
147        // There's not top level domain, such as .com
148        domain
149    } else {
150        &domain_fields[0..domain_fields.len() - 1].join("_")
151    };
152    env_domain.to_ascii_uppercase()
153}
154
155impl ConfigFile {
156    // TODO: make use of a BufReader instead
157    /// Reads the configuration file and returns a ConfigFile struct that holds
158    /// the configuration data for a given domain and project path.
159    /// domain can be a top level domain such as gitlab.com or a subdomain such
160    /// as gitlab.company.com.
161    /// The project path is the path of the project in the remote after the domain.
162    /// Ex: gitlab.com/jordilin/gitar -> /jordilin/gitar
163    /// This is to allow for overriding project specific configurations such as
164    /// reviewers, assignees, etc.
165    pub fn new<T: Read, FE: Fn(&str) -> Result<String>>(
166        readers: Vec<T>,
167        url: &RemoteURL,
168        env: FE,
169    ) -> Result<ConfigFile> {
170        let mut config_data = String::new();
171        for mut reader in readers.into_iter() {
172            reader.read_to_string(&mut config_data)?;
173        }
174        let mut config: ConfigFileInner = toml::from_str(&config_data)?;
175        let project_path_key = url.config_encoded_project_path();
176        let domain = url.domain();
177        // ENV VAR API token takes preference. For a given domain, we try to fetch
178        // <DOMAIN>_API_TOKEN env var first, then we fallback to the config
179        // file. Given a domain such as gitlab.com, the env var to be set is
180        // GITLAB_API_TOKEN. If the domain is gitlab.<company>.com, the env var
181        // to be set is GITLAB_<COMPANY>_API_TOKEN.
182
183        let domain_key = url.config_encoded_domain();
184        if let Some(domain_config) = config.domains.get_mut(domain_key) {
185            if domain_config.api_token.is_none() {
186                domain_config.api_token = Some(env(domain).map_err(|_| {
187                    GRError::PreconditionNotMet(format!(
188                        "No api_token found for domain {} in config or environment variable",
189                        domain
190                    ))
191                })?);
192            }
193            Ok(ConfigFile {
194                inner: config,
195                domain_key: domain_key.to_string(),
196                project_path_key: project_path_key.to_string(),
197            })
198        } else {
199            Err(error::gen(format!(
200                "No config data found for domain {}",
201                domain
202            )))
203        }
204    }
205
206    fn get_members_from_config(&self) -> Vec<Member> {
207        if let Some(domain_config) = &self.inner.domains.get(&self.domain_key) {
208            let members = domain_config
209                .projects
210                .get(&self.project_path_key)
211                .and_then(|project_config| {
212                    project_config
213                        .merge_requests
214                        .as_ref()
215                        .and_then(|merge_request_config| self.get_members(merge_request_config))
216                })
217                .or_else(|| {
218                    domain_config
219                        .merge_requests
220                        .as_ref()
221                        .and_then(|merge_request_config| self.get_members(merge_request_config))
222                });
223            members.unwrap_or_default()
224        } else {
225            vec![]
226        }
227    }
228
229    fn get_members(&self, merge_request_config: &MergeRequestConfig) -> Option<Vec<Member>> {
230        merge_request_config.members.as_ref().map(|users| {
231            users
232                .iter()
233                .map(|user_info| match user_info {
234                    UserInfo::UsernameOnly(username) => Member::builder()
235                        .username(username.clone())
236                        .mr_member_type(MrMemberType::Filled)
237                        .build()
238                        .unwrap(),
239                    UserInfo::UsernameID { username, id } => Member::builder()
240                        .username(username.clone())
241                        .id(*id as i64)
242                        .mr_member_type(MrMemberType::Filled)
243                        .build()
244                        .unwrap(),
245                    UserInfo::UsernameIDString { username, id } => Member::builder()
246                        .username(username.clone())
247                        .id(id.parse::<i64>().expect("User ID must be a number"))
248                        .mr_member_type(MrMemberType::Filled)
249                        .build()
250                        .unwrap(),
251                })
252                .collect()
253        })
254    }
255}
256
257impl ConfigProperties for ConfigFile {
258    fn api_token(&self) -> &str {
259        if let Some(domain) = self.inner.domains.get(&self.domain_key) {
260            domain.api_token.as_deref().unwrap_or_default()
261        } else {
262            ""
263        }
264    }
265
266    fn cache_location(&self) -> Option<&str> {
267        if let Some(domain) = self.inner.domains.get(&self.domain_key) {
268            domain.cache_location.as_deref()
269        } else {
270            None
271        }
272    }
273
274    fn preferred_assignee_username(&self) -> Option<Member> {
275        if let Some(domain_config) = &self.inner.domains.get(&self.domain_key) {
276            domain_config
277                .projects
278                .get(&self.project_path_key)
279                .and_then(|project_config| {
280                    project_config
281                        .merge_requests
282                        .as_ref()
283                        .and_then(|merge_request_config| {
284                            merge_request_config
285                                .preferred_assignee_username
286                                .as_ref()
287                                .map(|user_info| match user_info {
288                                    UserInfo::UsernameOnly(username) => Member::builder()
289                                        .username(username.clone())
290                                        .mr_member_type(MrMemberType::Filled)
291                                        .build()
292                                        .unwrap(),
293                                    UserInfo::UsernameID { username, id } => Member::builder()
294                                        .username(username.clone())
295                                        .mr_member_type(MrMemberType::Filled)
296                                        .id(*id as i64)
297                                        .build()
298                                        .unwrap(),
299                                    UserInfo::UsernameIDString { username, id } => {
300                                        // TODO - should propagate error when
301                                        // parsing fails
302                                        Member::builder()
303                                            .username(username.clone())
304                                            .mr_member_type(MrMemberType::Filled)
305                                            .id(id
306                                                .parse::<i64>()
307                                                .expect("User ID must be a number"))
308                                            .build()
309                                            .unwrap()
310                                    }
311                                })
312                        })
313                })
314                .or_else(|| {
315                    domain_config
316                        .merge_requests
317                        .as_ref()
318                        .and_then(|merge_request_config| {
319                            merge_request_config
320                                .preferred_assignee_username
321                                .as_ref()
322                                .map(|user_info| match user_info {
323                                    UserInfo::UsernameOnly(username) => Member::builder()
324                                        .username(username.clone())
325                                        .mr_member_type(MrMemberType::Filled)
326                                        .build()
327                                        .unwrap(),
328                                    UserInfo::UsernameID { username, id } => Member::builder()
329                                        .username(username.clone())
330                                        .mr_member_type(MrMemberType::Filled)
331                                        .id(*id as i64)
332                                        .build()
333                                        .unwrap(),
334                                    UserInfo::UsernameIDString { username, id } => {
335                                        Member::builder()
336                                            .username(username.clone())
337                                            .mr_member_type(MrMemberType::Filled)
338                                            .id(id
339                                                .parse::<i64>()
340                                                .expect("User ID must be a number"))
341                                            .build()
342                                            .unwrap()
343                                    }
344                                })
345                        })
346                })
347        } else {
348            None
349        }
350    }
351
352    fn merge_request_members(&self) -> Vec<Member> {
353        self.get_members_from_config()
354    }
355
356    fn merge_request_description_signature(&self) -> &str {
357        if let Some(domain_config) = &self.inner.domains.get(&self.domain_key) {
358            domain_config
359                .projects
360                .get(&self.project_path_key)
361                .and_then(|project_config| {
362                    project_config
363                        .merge_requests
364                        .as_ref()
365                        .and_then(|merge_request_config| {
366                            merge_request_config.description_signature.as_deref()
367                        })
368                })
369                .unwrap_or_else(|| {
370                    domain_config
371                        .merge_requests
372                        .as_ref()
373                        .and_then(|merge_request_config| {
374                            merge_request_config.description_signature.as_deref()
375                        })
376                        .unwrap_or_default()
377                })
378        } else {
379            ""
380        }
381    }
382
383    fn get_cache_expiration(&self, api_operation: &ApiOperation) -> &str {
384        self.inner
385            .domains
386            .get(&self.domain_key)
387            .and_then(|domain_config| {
388                domain_config
389                    .cache_expirations
390                    .as_ref()
391                    .and_then(|cache_expirations| cache_expirations.settings.get(api_operation))
392            })
393            .map(|s| s.as_str())
394            .unwrap_or_else(|| EXPIRE_IMMEDIATELY)
395    }
396
397    fn get_max_pages(&self, api_operation: &ApiOperation) -> u32 {
398        self.inner
399            .domains
400            .get(&self.domain_key)
401            .and_then(|domain_config| {
402                domain_config
403                    .max_pages_api
404                    .as_ref()
405                    .and_then(|max_pages| max_pages.settings.get(api_operation))
406            })
407            .copied()
408            .unwrap_or(REST_API_MAX_PAGES)
409    }
410
411    fn rate_limit_remaining_threshold(&self) -> u32 {
412        self.inner
413            .domains
414            .get(&self.domain_key)
415            .and_then(|domain_config| domain_config.rate_limit_remaining_threshold)
416            .unwrap_or(RATE_LIMIT_REMAINING_THRESHOLD)
417    }
418}
419
420impl ConfigProperties for Arc<ConfigFile> {
421    fn api_token(&self) -> &str {
422        self.as_ref().api_token()
423    }
424
425    fn cache_location(&self) -> Option<&str> {
426        self.as_ref().cache_location()
427    }
428
429    fn preferred_assignee_username(&self) -> Option<Member> {
430        self.as_ref().preferred_assignee_username()
431    }
432
433    fn merge_request_description_signature(&self) -> &str {
434        self.as_ref().merge_request_description_signature()
435    }
436
437    fn get_cache_expiration(&self, api_operation: &ApiOperation) -> &str {
438        self.as_ref().get_cache_expiration(api_operation)
439    }
440
441    fn get_max_pages(&self, api_operation: &ApiOperation) -> u32 {
442        self.as_ref().get_max_pages(api_operation)
443    }
444
445    fn rate_limit_remaining_threshold(&self) -> u32 {
446        self.as_ref().rate_limit_remaining_threshold()
447    }
448
449    fn merge_request_members(&self) -> Vec<Member> {
450        self.as_ref().merge_request_members()
451    }
452}
453
454#[cfg(test)]
455mod test {
456    use crate::cmds::project::MrMemberType;
457
458    use super::*;
459
460    fn no_env(_: &str) -> Result<String> {
461        Err(error::gen("No env var"))
462    }
463
464    #[test]
465    fn test_config_ok() {
466        let config_data = r#"
467        [gitlab_com]
468        api_token = '1234'
469        cache_location = "/home/user/.config/mr_cache"
470        rate_limit_remaining_threshold=15
471
472        [gitlab_com.merge_requests]
473        preferred_assignee_username = "jordilin"
474        description_signature = "- devops team :-)"
475        members = [
476            { username = 'jdoe', id = 1231 },
477            { username = 'jane', id = 1232 }
478        ]
479
480        [gitlab_com.max_pages_api]
481        merge_request = 2
482        pipeline = 3
483        project = 4
484        container_registry = 5
485        single_page = 6
486        release = 7
487        gist = 8
488        repository_tag = 9
489
490        [gitlab_com.cache_expirations]
491        merge_request = "30m"
492        pipeline = "0s"
493        project = "90d"
494        container_registry = "0s"
495        single_page = "0s"
496        release = "4h"
497        gist = "1w"
498        repository_tag = "0s"
499        "#;
500        let domain = "gitlab.com";
501        let reader = vec![std::io::Cursor::new(config_data)];
502        let project_path = "/jordilin/gitar";
503        let url = RemoteURL::new(domain.to_string(), project_path.to_string());
504        let config = Arc::new(ConfigFile::new(reader, &url, no_env).unwrap());
505        assert_eq!("1234", config.api_token());
506        assert_eq!(
507            "/home/user/.config/mr_cache",
508            config.cache_location().unwrap()
509        );
510        assert_eq!(15, config.rate_limit_remaining_threshold());
511        assert_eq!(
512            "- devops team :-)",
513            config.merge_request_description_signature()
514        );
515        let preferred_assignee_user = config.preferred_assignee_username().unwrap();
516        assert_eq!("jordilin", preferred_assignee_user.username);
517        assert_eq!(MrMemberType::Filled, preferred_assignee_user.mr_member_type);
518        assert_eq!(2, config.get_max_pages(&ApiOperation::MergeRequest));
519        assert_eq!(3, config.get_max_pages(&ApiOperation::Pipeline));
520        assert_eq!(4, config.get_max_pages(&ApiOperation::Project));
521        assert_eq!(5, config.get_max_pages(&ApiOperation::ContainerRegistry));
522        assert_eq!(6, config.get_max_pages(&ApiOperation::SinglePage));
523        assert_eq!(7, config.get_max_pages(&ApiOperation::Release));
524        assert_eq!(8, config.get_max_pages(&ApiOperation::Gist));
525        assert_eq!(9, config.get_max_pages(&ApiOperation::RepositoryTag));
526
527        assert_eq!(
528            "30m",
529            config.get_cache_expiration(&ApiOperation::MergeRequest)
530        );
531        assert_eq!("0s", config.get_cache_expiration(&ApiOperation::Pipeline));
532        assert_eq!("90d", config.get_cache_expiration(&ApiOperation::Project));
533        assert_eq!(
534            "0s",
535            config.get_cache_expiration(&ApiOperation::ContainerRegistry)
536        );
537        assert_eq!("0s", config.get_cache_expiration(&ApiOperation::SinglePage));
538        assert_eq!("4h", config.get_cache_expiration(&ApiOperation::Release));
539        assert_eq!("1w", config.get_cache_expiration(&ApiOperation::Gist));
540        assert_eq!(
541            "0s",
542            config.get_cache_expiration(&ApiOperation::RepositoryTag)
543        );
544        let members = config.merge_request_members();
545        assert_eq!(2, members.len());
546        assert_eq!("jdoe", members[0].username);
547        assert_eq!(1231, members[0].id);
548        assert_eq!(MrMemberType::Filled, members[0].mr_member_type);
549        assert_eq!("jane", members[1].username);
550        assert_eq!(1232, members[1].id);
551    }
552
553    #[test]
554    fn test_config_defaults() {
555        let config_data = r#"
556        [github_com]
557        api_token = '1234'
558        "#;
559        let domain = "github.com";
560        let reader = vec![std::io::Cursor::new(config_data)];
561        let project_path = "/jordilin/gitar";
562        let url = RemoteURL::new(domain.to_string(), project_path.to_string());
563        let config = Arc::new(ConfigFile::new(reader, &url, no_env).unwrap());
564        for api_operation in ApiOperation::iter() {
565            assert_eq!(REST_API_MAX_PAGES, config.get_max_pages(&api_operation));
566            assert_eq!(
567                EXPIRE_IMMEDIATELY,
568                config.get_cache_expiration(&api_operation)
569            );
570        }
571        assert_eq!(
572            RATE_LIMIT_REMAINING_THRESHOLD,
573            config.rate_limit_remaining_threshold()
574        );
575        assert_eq!(None, config.cache_location());
576        assert_eq!(None, config.preferred_assignee_username());
577        assert_eq!("", config.merge_request_description_signature());
578    }
579
580    #[test]
581    fn test_config_with_overridden_project_specific_settings() {
582        let config_data = r#"
583        [gitlab_com]
584        api_token = '1234'
585        cache_location = "/home/user/.config/mr_cache"
586        rate_limit_remaining_threshold=15
587
588        [gitlab_com.merge_requests]
589        preferred_assignee_username = "jordilin"
590        description_signature = "- devops team :-)"
591        members = [
592            { username = 'jdoe', id = 1231 }
593        ]
594
595        # Project specific settings for /datateam/projecta
596        [gitlab_com.datateam_projecta.merge_requests]
597        preferred_assignee_username = 'jdoe'
598        description_signature = '- data team projecta :-)'
599        members = [ { username = 'jane', id = 1234 } ]"#;
600
601        let domain = "gitlab.com";
602        let reader = vec![std::io::Cursor::new(config_data)];
603        let project_path = "datateam/projecta";
604        let url = RemoteURL::new(domain.to_string(), project_path.to_string());
605        let config = Arc::new(ConfigFile::new(reader, &url, no_env).unwrap());
606        let preferred_assignee_user = config.preferred_assignee_username().unwrap();
607        assert_eq!("jdoe", preferred_assignee_user.username);
608        assert_eq!(
609            "- data team projecta :-)",
610            config.merge_request_description_signature()
611        );
612        let members = config.merge_request_members();
613        assert_eq!(1, members.len());
614        assert_eq!("jane", members[0].username);
615        assert_eq!(1234, members[0].id);
616    }
617
618    #[test]
619    fn test_config_with_overridden_project_specific_settings_multiple_readers() {
620        let config_data = r#"
621        [gitlab_com]
622        api_token = '1234'
623        cache_location = "/home/user/.config/mr_cache"
624        rate_limit_remaining_threshold=15
625
626        [gitlab_com.merge_requests]
627        preferred_assignee_username = "jordilin"
628        description_signature = "- devops team :-)"
629        members = [
630            { username = 'jdoe', id = 1231 }
631        ]"#;
632
633        let config_data_2 = r#"
634        # Project specific settings for /datateam/projecta
635        [gitlab_com.datateam_projecta.merge_requests]
636        preferred_assignee_username = 'jdoe'
637        description_signature = '- data team projecta :-)'
638        members = [ { username = 'jane', id = 1234 } ]"#;
639
640        let domain = "gitlab.com";
641        let reader = vec![
642            std::io::Cursor::new(config_data),
643            std::io::Cursor::new(config_data_2),
644        ];
645        let project_path = "datateam/projecta";
646        let url = RemoteURL::new(domain.to_string(), project_path.to_string());
647        let config = Arc::new(ConfigFile::new(reader, &url, no_env).unwrap());
648        let preferred_assignee_user = config.preferred_assignee_username().unwrap();
649        assert_eq!("jdoe", preferred_assignee_user.username);
650        assert_eq!(
651            "- data team projecta :-)",
652            config.merge_request_description_signature()
653        );
654        let members = config.merge_request_members();
655        assert_eq!(1, members.len());
656        assert_eq!("jane", members[0].username);
657        assert_eq!(1234, members[0].id);
658    }
659
660    #[test]
661    fn test_config_multiple_readers_same_headers_is_error() {
662        let config_data = r#"
663        [gitlab_com]
664        api_token = '1234'
665        cache_location = "/home/user/.config/mr_cache"
666        rate_limit_remaining_threshold=15
667
668        [gitlab_com.merge_requests]
669        preferred_assignee_username = "jordilin"
670        description_signature = "- devops team :-)"
671        members = [
672            { username = 'jdoe', id = 1231 }
673        ]"#;
674
675        let config_data_2 = r#"
676        [gitlab_com]
677        api_token = '1234'
678        cache_location = "/home/user/.config/mr_cache"
679        rate_limit_remaining_threshold=15"#;
680
681        let domain = "gitlab.com";
682        let reader = vec![
683            std::io::Cursor::new(config_data),
684            std::io::Cursor::new(config_data_2),
685        ];
686        let project_path = "datateam/projecta";
687        let url = RemoteURL::new(domain.to_string(), project_path.to_string());
688        assert!(ConfigFile::new(reader, &url, no_env).is_err());
689    }
690
691    #[test]
692    fn test_config_preferred_assignee_username_with_id() {
693        let config_data = r#"
694        [gitlab_com]
695        api_token = '1234'
696        cache_location = "/home/user/.config/mr_cache"
697        rate_limit_remaining_threshold=15
698
699        [gitlab_com.merge_requests]
700        preferred_assignee_username = { username = 'jdoe', id = 1231 }
701
702        # Project specific settings for /datateam/projecta
703        [gitlab_com.datateam_projecta.merge_requests]
704        preferred_assignee_username = { username = 'jordilin', id = 1234 }
705        "#;
706
707        let domain = "gitlab.com";
708        let reader = vec![std::io::Cursor::new(config_data)];
709        let project_path = "datateam_projecta";
710        let url = RemoteURL::new(domain.to_string(), project_path.to_string());
711        let config = Arc::new(ConfigFile::new(reader, &url, no_env).unwrap());
712        let preferred_assignee_user = config.preferred_assignee_username().unwrap();
713        assert_eq!("jordilin", preferred_assignee_user.username);
714    }
715
716    #[test]
717    fn test_no_api_token_is_err() {
718        let config_data = r#"
719        [gitlab_com]
720        api_token_typo=1234"#;
721        let domain = "gitlab.com";
722        let reader = vec![std::io::Cursor::new(config_data)];
723        let project_path = "/jordilin/gitar";
724        let url = RemoteURL::new(domain.to_string(), project_path.to_string());
725        assert!(ConfigFile::new(reader, &url, no_env).is_err());
726    }
727
728    #[test]
729    fn test_config_no_data() {
730        let config_data = "";
731        let domain = "gitlab.com";
732        let reader = vec![std::io::Cursor::new(config_data)];
733        let project_path = "/jordilin/gitar";
734        let url = RemoteURL::new(domain.to_string(), project_path.to_string());
735        assert!(ConfigFile::new(reader, &url, no_env).is_err());
736    }
737
738    fn env(_: &str) -> Result<String> {
739        Ok("1234".to_string())
740    }
741
742    #[test]
743    fn test_use_gitlab_com_api_token_envvar() {
744        let config_data = r#"
745        [gitlab_com]
746        "#;
747        let domain = "gitlab.com";
748        let reader = vec![std::io::Cursor::new(config_data)];
749        let project_path = "/jordilin/gitar";
750        let url = RemoteURL::new(domain.to_string(), project_path.to_string());
751        let config = Arc::new(ConfigFile::new(reader, &url, env).unwrap());
752        assert_eq!("1234", config.api_token());
753    }
754
755    #[test]
756    fn test_use_sub_domain_gitlab_token_env_var() {
757        let config_data = r#"
758        [gitlab_company_com]
759        "#;
760        let domain = "gitlab.company.com";
761        let reader = vec![std::io::Cursor::new(config_data)];
762        let project_path = "/jordilin/gitar";
763        let url = RemoteURL::new(domain.to_string(), project_path.to_string());
764        let config = Arc::new(ConfigFile::new(reader, &url, env).unwrap());
765        assert_eq!("1234", config.api_token());
766    }
767
768    #[test]
769    fn test_domain_without_top_level_domain_token_envvar() {
770        let config_data = r#"
771        [gitlabweb]
772        "#;
773        let domain = "gitlabweb";
774        let reader = vec![std::io::Cursor::new(config_data)];
775        let project_path = "/jordilin/gitar";
776        let url = RemoteURL::new(domain.to_string(), project_path.to_string());
777        let config = Arc::new(ConfigFile::new(reader, &url, env).unwrap());
778        assert_eq!("1234", config.api_token());
779    }
780
781    #[test]
782    fn test_no_config_requires_auth_env_token_and_no_cache() {
783        let domain = "gitlabwebnoconfig";
784        let config = NoConfig::new(domain, env).unwrap();
785        assert_eq!("1234", config.api_token());
786        assert_eq!(None, config.cache_location());
787    }
788
789    #[test]
790    fn test_no_config_no_env_token_is_error() {
791        let domain = "gitlabwebnoenv.com";
792        let config_res = NoConfig::new(domain, no_env);
793        match config_res {
794            Err(err) => match err.downcast_ref::<error::GRError>() {
795                Some(error::GRError::PreconditionNotMet(val)) => {
796                    assert_eq!("Configuration not found, so it is expected environment variable GITLABWEBNOENV_API_TOKEN to be set.", val)
797                }
798                _ => panic!("Expected error::GRError::PreconditionNotMet"),
799            },
800            _ => panic!("Expected error"),
801        }
802    }
803
804    #[test]
805    fn test_default_config_file() {
806        // This is the case when browsing and no configuration is needed.
807        let config = ConfigFile::default();
808        assert_eq!("", config.api_token());
809        assert_eq!(None, config.cache_location());
810        assert_eq!(
811            RATE_LIMIT_REMAINING_THRESHOLD,
812            config.rate_limit_remaining_threshold()
813        );
814        assert_eq!(None, config.preferred_assignee_username());
815        assert_eq!("", config.merge_request_description_signature());
816    }
817
818    #[test]
819    fn test_config_with_member_ids_strings() {
820        let config_data = r#"
821        [gitlab_com]
822        api_token = '1234'
823        cache_location = "/home/user/.config/mr_cache"
824        rate_limit_remaining_threshold=15
825
826        [gitlab_com.merge_requests]
827        preferred_assignee_username = { username = "jordilin", id = "1234" }
828        description_signature = "- devops team :-)"
829        members = [
830            { username = 'jdoe', id = '1231' }
831        ]"#;
832
833        let domain = "gitlab.com";
834        let reader = vec![std::io::Cursor::new(config_data)];
835        let project_path = "datateam/projecta";
836        let url = RemoteURL::new(domain.to_string(), project_path.to_string());
837        let config = Arc::new(ConfigFile::new(reader, &url, no_env).unwrap());
838        let preferred_assignee_user = config.preferred_assignee_username().unwrap();
839        assert_eq!("jordilin", preferred_assignee_user.username);
840        assert_eq!(
841            "- devops team :-)",
842            config.merge_request_description_signature()
843        );
844        let members = config.merge_request_members();
845        assert_eq!(1, members.len());
846        assert_eq!("jdoe", members[0].username);
847        assert_eq!(1231, members[0].id);
848    }
849
850    #[test]
851    fn test_config_with_overridden_project_specific_settings_member_id_strings() {
852        let config_data = r#"
853        [gitlab_com]
854        api_token = '1234'
855        cache_location = "/home/user/.config/mr_cache"
856        rate_limit_remaining_threshold=15
857
858        [gitlab_com.merge_requests]
859        preferred_assignee_username = "jordilin"
860        description_signature = "- devops team :-)"
861        members = [
862            { username = 'jdoe', id = "1234" }
863        ]
864
865        # Project specific settings for /datateam/projecta
866        [gitlab_com.datateam_projecta.merge_requests]
867        preferred_assignee_username = { username = 'jdoe', id = '1234' }
868        description_signature = '- data team projecta :-)'
869        members = [ { username = 'jane', id = "1235" } ]"#;
870
871        let domain = "gitlab.com";
872        let reader = vec![std::io::Cursor::new(config_data)];
873        let project_path = "datateam/projecta";
874        let url = RemoteURL::new(domain.to_string(), project_path.to_string());
875        let config = Arc::new(ConfigFile::new(reader, &url, no_env).unwrap());
876        let preferred_assignee_user = config.preferred_assignee_username().unwrap();
877        assert_eq!("jdoe", preferred_assignee_user.username);
878        assert_eq!(
879            "- data team projecta :-)",
880            config.merge_request_description_signature()
881        );
882        let members = config.merge_request_members();
883        assert_eq!(1, members.len());
884        assert_eq!("jane", members[0].username);
885        assert_eq!(1235, members[0].id);
886    }
887}