Skip to main content

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!("{env_domain}_API_TOKEN"))?)
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 {domain} in config or environment variable"
189                    ))
190                })?);
191            }
192            Ok(ConfigFile {
193                inner: config,
194                domain_key: domain_key.to_string(),
195                project_path_key: project_path_key.to_string(),
196            })
197        } else {
198            Err(error::gen(format!(
199                "No config data found for domain {domain}"
200            )))
201        }
202    }
203
204    fn get_members_from_config(&self) -> Vec<Member> {
205        if let Some(domain_config) = &self.inner.domains.get(&self.domain_key) {
206            let members = domain_config
207                .projects
208                .get(&self.project_path_key)
209                .and_then(|project_config| {
210                    project_config
211                        .merge_requests
212                        .as_ref()
213                        .and_then(|merge_request_config| self.get_members(merge_request_config))
214                })
215                .or_else(|| {
216                    domain_config
217                        .merge_requests
218                        .as_ref()
219                        .and_then(|merge_request_config| self.get_members(merge_request_config))
220                });
221            members.unwrap_or_default()
222        } else {
223            vec![]
224        }
225    }
226
227    fn get_members(&self, merge_request_config: &MergeRequestConfig) -> Option<Vec<Member>> {
228        merge_request_config.members.as_ref().map(|users| {
229            users
230                .iter()
231                .map(|user_info| match user_info {
232                    UserInfo::UsernameOnly(username) => Member::builder()
233                        .username(username.clone())
234                        .mr_member_type(MrMemberType::Filled)
235                        .build()
236                        .unwrap(),
237                    UserInfo::UsernameID { username, id } => Member::builder()
238                        .username(username.clone())
239                        .id(*id as i64)
240                        .mr_member_type(MrMemberType::Filled)
241                        .build()
242                        .unwrap(),
243                    UserInfo::UsernameIDString { username, id } => Member::builder()
244                        .username(username.clone())
245                        .id(id.parse::<i64>().expect("User ID must be a number"))
246                        .mr_member_type(MrMemberType::Filled)
247                        .build()
248                        .unwrap(),
249                })
250                .collect()
251        })
252    }
253}
254
255impl ConfigProperties for ConfigFile {
256    fn api_token(&self) -> &str {
257        if let Some(domain) = self.inner.domains.get(&self.domain_key) {
258            domain.api_token.as_deref().unwrap_or_default()
259        } else {
260            ""
261        }
262    }
263
264    fn cache_location(&self) -> Option<&str> {
265        if let Some(domain) = self.inner.domains.get(&self.domain_key) {
266            domain.cache_location.as_deref()
267        } else {
268            None
269        }
270    }
271
272    fn preferred_assignee_username(&self) -> Option<Member> {
273        if let Some(domain_config) = &self.inner.domains.get(&self.domain_key) {
274            domain_config
275                .projects
276                .get(&self.project_path_key)
277                .and_then(|project_config| {
278                    project_config
279                        .merge_requests
280                        .as_ref()
281                        .and_then(|merge_request_config| {
282                            merge_request_config
283                                .preferred_assignee_username
284                                .as_ref()
285                                .map(|user_info| match user_info {
286                                    UserInfo::UsernameOnly(username) => Member::builder()
287                                        .username(username.clone())
288                                        .mr_member_type(MrMemberType::Filled)
289                                        .build()
290                                        .unwrap(),
291                                    UserInfo::UsernameID { username, id } => Member::builder()
292                                        .username(username.clone())
293                                        .mr_member_type(MrMemberType::Filled)
294                                        .id(*id as i64)
295                                        .build()
296                                        .unwrap(),
297                                    UserInfo::UsernameIDString { username, id } => {
298                                        // TODO - should propagate error when
299                                        // parsing fails
300                                        Member::builder()
301                                            .username(username.clone())
302                                            .mr_member_type(MrMemberType::Filled)
303                                            .id(id
304                                                .parse::<i64>()
305                                                .expect("User ID must be a number"))
306                                            .build()
307                                            .unwrap()
308                                    }
309                                })
310                        })
311                })
312                .or_else(|| {
313                    domain_config
314                        .merge_requests
315                        .as_ref()
316                        .and_then(|merge_request_config| {
317                            merge_request_config
318                                .preferred_assignee_username
319                                .as_ref()
320                                .map(|user_info| match user_info {
321                                    UserInfo::UsernameOnly(username) => Member::builder()
322                                        .username(username.clone())
323                                        .mr_member_type(MrMemberType::Filled)
324                                        .build()
325                                        .unwrap(),
326                                    UserInfo::UsernameID { username, id } => Member::builder()
327                                        .username(username.clone())
328                                        .mr_member_type(MrMemberType::Filled)
329                                        .id(*id as i64)
330                                        .build()
331                                        .unwrap(),
332                                    UserInfo::UsernameIDString { username, id } => {
333                                        Member::builder()
334                                            .username(username.clone())
335                                            .mr_member_type(MrMemberType::Filled)
336                                            .id(id
337                                                .parse::<i64>()
338                                                .expect("User ID must be a number"))
339                                            .build()
340                                            .unwrap()
341                                    }
342                                })
343                        })
344                })
345        } else {
346            None
347        }
348    }
349
350    fn merge_request_members(&self) -> Vec<Member> {
351        self.get_members_from_config()
352    }
353
354    fn merge_request_description_signature(&self) -> &str {
355        if let Some(domain_config) = &self.inner.domains.get(&self.domain_key) {
356            domain_config
357                .projects
358                .get(&self.project_path_key)
359                .and_then(|project_config| {
360                    project_config
361                        .merge_requests
362                        .as_ref()
363                        .and_then(|merge_request_config| {
364                            merge_request_config.description_signature.as_deref()
365                        })
366                })
367                .unwrap_or_else(|| {
368                    domain_config
369                        .merge_requests
370                        .as_ref()
371                        .and_then(|merge_request_config| {
372                            merge_request_config.description_signature.as_deref()
373                        })
374                        .unwrap_or_default()
375                })
376        } else {
377            ""
378        }
379    }
380
381    fn get_cache_expiration(&self, api_operation: &ApiOperation) -> &str {
382        self.inner
383            .domains
384            .get(&self.domain_key)
385            .and_then(|domain_config| {
386                domain_config
387                    .cache_expirations
388                    .as_ref()
389                    .and_then(|cache_expirations| cache_expirations.settings.get(api_operation))
390            })
391            .map(|s| s.as_str())
392            .unwrap_or_else(|| EXPIRE_IMMEDIATELY)
393    }
394
395    fn get_max_pages(&self, api_operation: &ApiOperation) -> u32 {
396        self.inner
397            .domains
398            .get(&self.domain_key)
399            .and_then(|domain_config| {
400                domain_config
401                    .max_pages_api
402                    .as_ref()
403                    .and_then(|max_pages| max_pages.settings.get(api_operation))
404            })
405            .copied()
406            .unwrap_or(REST_API_MAX_PAGES)
407    }
408
409    fn rate_limit_remaining_threshold(&self) -> u32 {
410        self.inner
411            .domains
412            .get(&self.domain_key)
413            .and_then(|domain_config| domain_config.rate_limit_remaining_threshold)
414            .unwrap_or(RATE_LIMIT_REMAINING_THRESHOLD)
415    }
416}
417
418impl ConfigProperties for Arc<ConfigFile> {
419    fn api_token(&self) -> &str {
420        self.as_ref().api_token()
421    }
422
423    fn cache_location(&self) -> Option<&str> {
424        self.as_ref().cache_location()
425    }
426
427    fn preferred_assignee_username(&self) -> Option<Member> {
428        self.as_ref().preferred_assignee_username()
429    }
430
431    fn merge_request_description_signature(&self) -> &str {
432        self.as_ref().merge_request_description_signature()
433    }
434
435    fn get_cache_expiration(&self, api_operation: &ApiOperation) -> &str {
436        self.as_ref().get_cache_expiration(api_operation)
437    }
438
439    fn get_max_pages(&self, api_operation: &ApiOperation) -> u32 {
440        self.as_ref().get_max_pages(api_operation)
441    }
442
443    fn rate_limit_remaining_threshold(&self) -> u32 {
444        self.as_ref().rate_limit_remaining_threshold()
445    }
446
447    fn merge_request_members(&self) -> Vec<Member> {
448        self.as_ref().merge_request_members()
449    }
450}
451
452#[cfg(test)]
453mod test {
454    use crate::cmds::project::MrMemberType;
455
456    use super::*;
457
458    fn no_env(_: &str) -> Result<String> {
459        Err(error::gen("No env var"))
460    }
461
462    #[test]
463    fn test_config_ok() {
464        let config_data = r#"
465        [gitlab_com]
466        api_token = '1234'
467        cache_location = "/home/user/.config/mr_cache"
468        rate_limit_remaining_threshold=15
469
470        [gitlab_com.merge_requests]
471        preferred_assignee_username = "jordilin"
472        description_signature = "- devops team :-)"
473        members = [
474            { username = 'jdoe', id = 1231 },
475            { username = 'jane', id = 1232 }
476        ]
477
478        [gitlab_com.max_pages_api]
479        merge_request = 2
480        pipeline = 3
481        project = 4
482        container_registry = 5
483        single_page = 6
484        release = 7
485        gist = 8
486        repository_tag = 9
487
488        [gitlab_com.cache_expirations]
489        merge_request = "30m"
490        pipeline = "0s"
491        project = "90d"
492        container_registry = "0s"
493        single_page = "0s"
494        release = "4h"
495        gist = "1w"
496        repository_tag = "0s"
497        "#;
498        let domain = "gitlab.com";
499        let reader = vec![std::io::Cursor::new(config_data)];
500        let project_path = "/jordilin/gitar";
501        let url = RemoteURL::new(domain.to_string(), project_path.to_string());
502        let config = Arc::new(ConfigFile::new(reader, &url, no_env).unwrap());
503        assert_eq!("1234", config.api_token());
504        assert_eq!(
505            "/home/user/.config/mr_cache",
506            config.cache_location().unwrap()
507        );
508        assert_eq!(15, config.rate_limit_remaining_threshold());
509        assert_eq!(
510            "- devops team :-)",
511            config.merge_request_description_signature()
512        );
513        let preferred_assignee_user = config.preferred_assignee_username().unwrap();
514        assert_eq!("jordilin", preferred_assignee_user.username);
515        assert_eq!(MrMemberType::Filled, preferred_assignee_user.mr_member_type);
516        assert_eq!(2, config.get_max_pages(&ApiOperation::MergeRequest));
517        assert_eq!(3, config.get_max_pages(&ApiOperation::Pipeline));
518        assert_eq!(4, config.get_max_pages(&ApiOperation::Project));
519        assert_eq!(5, config.get_max_pages(&ApiOperation::ContainerRegistry));
520        assert_eq!(6, config.get_max_pages(&ApiOperation::SinglePage));
521        assert_eq!(7, config.get_max_pages(&ApiOperation::Release));
522        assert_eq!(8, config.get_max_pages(&ApiOperation::Gist));
523        assert_eq!(9, config.get_max_pages(&ApiOperation::RepositoryTag));
524
525        assert_eq!(
526            "30m",
527            config.get_cache_expiration(&ApiOperation::MergeRequest)
528        );
529        assert_eq!("0s", config.get_cache_expiration(&ApiOperation::Pipeline));
530        assert_eq!("90d", config.get_cache_expiration(&ApiOperation::Project));
531        assert_eq!(
532            "0s",
533            config.get_cache_expiration(&ApiOperation::ContainerRegistry)
534        );
535        assert_eq!("0s", config.get_cache_expiration(&ApiOperation::SinglePage));
536        assert_eq!("4h", config.get_cache_expiration(&ApiOperation::Release));
537        assert_eq!("1w", config.get_cache_expiration(&ApiOperation::Gist));
538        assert_eq!(
539            "0s",
540            config.get_cache_expiration(&ApiOperation::RepositoryTag)
541        );
542        let members = config.merge_request_members();
543        assert_eq!(2, members.len());
544        assert_eq!("jdoe", members[0].username);
545        assert_eq!(1231, members[0].id);
546        assert_eq!(MrMemberType::Filled, members[0].mr_member_type);
547        assert_eq!("jane", members[1].username);
548        assert_eq!(1232, members[1].id);
549    }
550
551    #[test]
552    fn test_config_defaults() {
553        let config_data = r#"
554        [github_com]
555        api_token = '1234'
556        "#;
557        let domain = "github.com";
558        let reader = vec![std::io::Cursor::new(config_data)];
559        let project_path = "/jordilin/gitar";
560        let url = RemoteURL::new(domain.to_string(), project_path.to_string());
561        let config = Arc::new(ConfigFile::new(reader, &url, no_env).unwrap());
562        for api_operation in ApiOperation::iter() {
563            assert_eq!(REST_API_MAX_PAGES, config.get_max_pages(&api_operation));
564            assert_eq!(
565                EXPIRE_IMMEDIATELY,
566                config.get_cache_expiration(&api_operation)
567            );
568        }
569        assert_eq!(
570            RATE_LIMIT_REMAINING_THRESHOLD,
571            config.rate_limit_remaining_threshold()
572        );
573        assert_eq!(None, config.cache_location());
574        assert_eq!(None, config.preferred_assignee_username());
575        assert_eq!("", config.merge_request_description_signature());
576    }
577
578    #[test]
579    fn test_config_with_overridden_project_specific_settings() {
580        let config_data = r#"
581        [gitlab_com]
582        api_token = '1234'
583        cache_location = "/home/user/.config/mr_cache"
584        rate_limit_remaining_threshold=15
585
586        [gitlab_com.merge_requests]
587        preferred_assignee_username = "jordilin"
588        description_signature = "- devops team :-)"
589        members = [
590            { username = 'jdoe', id = 1231 }
591        ]
592
593        # Project specific settings for /datateam/projecta
594        [gitlab_com.datateam_projecta.merge_requests]
595        preferred_assignee_username = 'jdoe'
596        description_signature = '- data team projecta :-)'
597        members = [ { username = 'jane', id = 1234 } ]"#;
598
599        let domain = "gitlab.com";
600        let reader = vec![std::io::Cursor::new(config_data)];
601        let project_path = "datateam/projecta";
602        let url = RemoteURL::new(domain.to_string(), project_path.to_string());
603        let config = Arc::new(ConfigFile::new(reader, &url, no_env).unwrap());
604        let preferred_assignee_user = config.preferred_assignee_username().unwrap();
605        assert_eq!("jdoe", preferred_assignee_user.username);
606        assert_eq!(
607            "- data team projecta :-)",
608            config.merge_request_description_signature()
609        );
610        let members = config.merge_request_members();
611        assert_eq!(1, members.len());
612        assert_eq!("jane", members[0].username);
613        assert_eq!(1234, members[0].id);
614    }
615
616    #[test]
617    fn test_config_with_overridden_project_specific_settings_multiple_readers() {
618        let config_data = r#"
619        [gitlab_com]
620        api_token = '1234'
621        cache_location = "/home/user/.config/mr_cache"
622        rate_limit_remaining_threshold=15
623
624        [gitlab_com.merge_requests]
625        preferred_assignee_username = "jordilin"
626        description_signature = "- devops team :-)"
627        members = [
628            { username = 'jdoe', id = 1231 }
629        ]"#;
630
631        let config_data_2 = r#"
632        # Project specific settings for /datateam/projecta
633        [gitlab_com.datateam_projecta.merge_requests]
634        preferred_assignee_username = 'jdoe'
635        description_signature = '- data team projecta :-)'
636        members = [ { username = 'jane', id = 1234 } ]"#;
637
638        let domain = "gitlab.com";
639        let reader = vec![
640            std::io::Cursor::new(config_data),
641            std::io::Cursor::new(config_data_2),
642        ];
643        let project_path = "datateam/projecta";
644        let url = RemoteURL::new(domain.to_string(), project_path.to_string());
645        let config = Arc::new(ConfigFile::new(reader, &url, no_env).unwrap());
646        let preferred_assignee_user = config.preferred_assignee_username().unwrap();
647        assert_eq!("jdoe", preferred_assignee_user.username);
648        assert_eq!(
649            "- data team projecta :-)",
650            config.merge_request_description_signature()
651        );
652        let members = config.merge_request_members();
653        assert_eq!(1, members.len());
654        assert_eq!("jane", members[0].username);
655        assert_eq!(1234, members[0].id);
656    }
657
658    #[test]
659    fn test_config_multiple_readers_same_headers_is_error() {
660        let config_data = r#"
661        [gitlab_com]
662        api_token = '1234'
663        cache_location = "/home/user/.config/mr_cache"
664        rate_limit_remaining_threshold=15
665
666        [gitlab_com.merge_requests]
667        preferred_assignee_username = "jordilin"
668        description_signature = "- devops team :-)"
669        members = [
670            { username = 'jdoe', id = 1231 }
671        ]"#;
672
673        let config_data_2 = r#"
674        [gitlab_com]
675        api_token = '1234'
676        cache_location = "/home/user/.config/mr_cache"
677        rate_limit_remaining_threshold=15"#;
678
679        let domain = "gitlab.com";
680        let reader = vec![
681            std::io::Cursor::new(config_data),
682            std::io::Cursor::new(config_data_2),
683        ];
684        let project_path = "datateam/projecta";
685        let url = RemoteURL::new(domain.to_string(), project_path.to_string());
686        assert!(ConfigFile::new(reader, &url, no_env).is_err());
687    }
688
689    #[test]
690    fn test_config_preferred_assignee_username_with_id() {
691        let config_data = r#"
692        [gitlab_com]
693        api_token = '1234'
694        cache_location = "/home/user/.config/mr_cache"
695        rate_limit_remaining_threshold=15
696
697        [gitlab_com.merge_requests]
698        preferred_assignee_username = { username = 'jdoe', id = 1231 }
699
700        # Project specific settings for /datateam/projecta
701        [gitlab_com.datateam_projecta.merge_requests]
702        preferred_assignee_username = { username = 'jordilin', id = 1234 }
703        "#;
704
705        let domain = "gitlab.com";
706        let reader = vec![std::io::Cursor::new(config_data)];
707        let project_path = "datateam_projecta";
708        let url = RemoteURL::new(domain.to_string(), project_path.to_string());
709        let config = Arc::new(ConfigFile::new(reader, &url, no_env).unwrap());
710        let preferred_assignee_user = config.preferred_assignee_username().unwrap();
711        assert_eq!("jordilin", preferred_assignee_user.username);
712    }
713
714    #[test]
715    fn test_no_api_token_is_err() {
716        let config_data = r#"
717        [gitlab_com]
718        api_token_typo=1234"#;
719        let domain = "gitlab.com";
720        let reader = vec![std::io::Cursor::new(config_data)];
721        let project_path = "/jordilin/gitar";
722        let url = RemoteURL::new(domain.to_string(), project_path.to_string());
723        assert!(ConfigFile::new(reader, &url, no_env).is_err());
724    }
725
726    #[test]
727    fn test_config_no_data() {
728        let config_data = "";
729        let domain = "gitlab.com";
730        let reader = vec![std::io::Cursor::new(config_data)];
731        let project_path = "/jordilin/gitar";
732        let url = RemoteURL::new(domain.to_string(), project_path.to_string());
733        assert!(ConfigFile::new(reader, &url, no_env).is_err());
734    }
735
736    fn env(_: &str) -> Result<String> {
737        Ok("1234".to_string())
738    }
739
740    #[test]
741    fn test_use_gitlab_com_api_token_envvar() {
742        let config_data = r#"
743        [gitlab_com]
744        "#;
745        let domain = "gitlab.com";
746        let reader = vec![std::io::Cursor::new(config_data)];
747        let project_path = "/jordilin/gitar";
748        let url = RemoteURL::new(domain.to_string(), project_path.to_string());
749        let config = Arc::new(ConfigFile::new(reader, &url, env).unwrap());
750        assert_eq!("1234", config.api_token());
751    }
752
753    #[test]
754    fn test_use_sub_domain_gitlab_token_env_var() {
755        let config_data = r#"
756        [gitlab_company_com]
757        "#;
758        let domain = "gitlab.company.com";
759        let reader = vec![std::io::Cursor::new(config_data)];
760        let project_path = "/jordilin/gitar";
761        let url = RemoteURL::new(domain.to_string(), project_path.to_string());
762        let config = Arc::new(ConfigFile::new(reader, &url, env).unwrap());
763        assert_eq!("1234", config.api_token());
764    }
765
766    #[test]
767    fn test_domain_without_top_level_domain_token_envvar() {
768        let config_data = r#"
769        [gitlabweb]
770        "#;
771        let domain = "gitlabweb";
772        let reader = vec![std::io::Cursor::new(config_data)];
773        let project_path = "/jordilin/gitar";
774        let url = RemoteURL::new(domain.to_string(), project_path.to_string());
775        let config = Arc::new(ConfigFile::new(reader, &url, env).unwrap());
776        assert_eq!("1234", config.api_token());
777    }
778
779    #[test]
780    fn test_no_config_requires_auth_env_token_and_no_cache() {
781        let domain = "gitlabwebnoconfig";
782        let config = NoConfig::new(domain, env).unwrap();
783        assert_eq!("1234", config.api_token());
784        assert_eq!(None, config.cache_location());
785    }
786
787    #[test]
788    fn test_no_config_no_env_token_is_error() {
789        let domain = "gitlabwebnoenv.com";
790        let config_res = NoConfig::new(domain, no_env);
791        match config_res {
792            Err(err) => match err.downcast_ref::<error::GRError>() {
793                Some(error::GRError::PreconditionNotMet(val)) => {
794                    assert_eq!("Configuration not found, so it is expected environment variable GITLABWEBNOENV_API_TOKEN to be set.", val)
795                }
796                _ => panic!("Expected error::GRError::PreconditionNotMet"),
797            },
798            _ => panic!("Expected error"),
799        }
800    }
801
802    #[test]
803    fn test_default_config_file() {
804        // This is the case when browsing and no configuration is needed.
805        let config = ConfigFile::default();
806        assert_eq!("", config.api_token());
807        assert_eq!(None, config.cache_location());
808        assert_eq!(
809            RATE_LIMIT_REMAINING_THRESHOLD,
810            config.rate_limit_remaining_threshold()
811        );
812        assert_eq!(None, config.preferred_assignee_username());
813        assert_eq!("", config.merge_request_description_signature());
814    }
815
816    #[test]
817    fn test_config_with_member_ids_strings() {
818        let config_data = r#"
819        [gitlab_com]
820        api_token = '1234'
821        cache_location = "/home/user/.config/mr_cache"
822        rate_limit_remaining_threshold=15
823
824        [gitlab_com.merge_requests]
825        preferred_assignee_username = { username = "jordilin", id = "1234" }
826        description_signature = "- devops team :-)"
827        members = [
828            { username = 'jdoe', id = '1231' }
829        ]"#;
830
831        let domain = "gitlab.com";
832        let reader = vec![std::io::Cursor::new(config_data)];
833        let project_path = "datateam/projecta";
834        let url = RemoteURL::new(domain.to_string(), project_path.to_string());
835        let config = Arc::new(ConfigFile::new(reader, &url, no_env).unwrap());
836        let preferred_assignee_user = config.preferred_assignee_username().unwrap();
837        assert_eq!("jordilin", preferred_assignee_user.username);
838        assert_eq!(
839            "- devops team :-)",
840            config.merge_request_description_signature()
841        );
842        let members = config.merge_request_members();
843        assert_eq!(1, members.len());
844        assert_eq!("jdoe", members[0].username);
845        assert_eq!(1231, members[0].id);
846    }
847
848    #[test]
849    fn test_config_with_overridden_project_specific_settings_member_id_strings() {
850        let config_data = r#"
851        [gitlab_com]
852        api_token = '1234'
853        cache_location = "/home/user/.config/mr_cache"
854        rate_limit_remaining_threshold=15
855
856        [gitlab_com.merge_requests]
857        preferred_assignee_username = "jordilin"
858        description_signature = "- devops team :-)"
859        members = [
860            { username = 'jdoe', id = "1234" }
861        ]
862
863        # Project specific settings for /datateam/projecta
864        [gitlab_com.datateam_projecta.merge_requests]
865        preferred_assignee_username = { username = 'jdoe', id = '1234' }
866        description_signature = '- data team projecta :-)'
867        members = [ { username = 'jane', id = "1235" } ]"#;
868
869        let domain = "gitlab.com";
870        let reader = vec![std::io::Cursor::new(config_data)];
871        let project_path = "datateam/projecta";
872        let url = RemoteURL::new(domain.to_string(), project_path.to_string());
873        let config = Arc::new(ConfigFile::new(reader, &url, no_env).unwrap());
874        let preferred_assignee_user = config.preferred_assignee_username().unwrap();
875        assert_eq!("jdoe", preferred_assignee_user.username);
876        assert_eq!(
877            "- data team projecta :-)",
878            config.merge_request_description_signature()
879        );
880        let members = config.merge_request_members();
881        assert_eq!(1, members.len());
882        assert_eq!("jane", members[0].username);
883        assert_eq!(1235, members[0].id);
884    }
885}