awsmfa 0.3.1

The automation tool for Multi-Factor Authentication (MFA) process to use awscli.
Documentation
use crate::Result;

use super::{ConfigFileBase, Profile};
use anyhow::anyhow;

const MFA_SERIAL: &str = "mfa_serial";

#[derive(Debug)]
pub struct Config {
    profiles: Vec<Profile>,
}

impl ConfigFileBase for Config {
    const FILENAME: &'static str = "config";
    const PROFILE_PATTERN: &'static str = r"\[profile\s+(.+)\]";

    fn build(profiles: Vec<Profile>) -> Self {
        Self { profiles }
    }

    fn fmt_profile(profile: &Profile) -> String {
        if profile.name == "default" {
            format!("[default]\n{}", profile.lines.join("\n"))
        } else {
            format!("[profile {}]\n{}", profile.name, profile.lines.join("\n"))
        }
    }

    fn profiles(&self) -> &[Profile] {
        &self.profiles
    }
}

impl Config {
    pub fn get_mfa_serial(&self, name: &str) -> Result<String> {
        self.get_attr(name, MFA_SERIAL)
    }

    pub fn set_mfa_profile(self, src: &str, dst: &str) -> Result<Self> {
        let lines = self
            .get(src)?
            .lines
            .into_iter()
            .filter(|line| match line.split_once('=') {
                Some((k, _)) => k.trim() != MFA_SERIAL,
                None => true,
            })
            .collect::<Vec<String>>();

        let profile = Profile {
            name: dst.into(),
            lines,
        };

        Ok(self.set(profile))
    }

    fn get_attr(&self, name: &str, key: &str) -> Result<String> {
        self.get(name)?
            .lines
            .iter()
            .find_map(|line| {
                line.split_once('=').and_then(|(k, v)| {
                    if k.trim() == key {
                        Some(v.trim())
                    } else {
                        None
                    }
                })
            })
            .map(String::from)
            .ok_or(anyhow!("Not Found key: {} in profile: {}", key, name))
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::path::Path;

    #[test]
    fn it_reads_config() {
        let path = Path::new("mock/test_config");
        let config = Config::load(path);
        assert!(config.is_ok());

        let Config { profiles } = config.unwrap();
        assert_eq!(profiles.len(), 2);

        let profile = profiles.get(0).unwrap();
        assert_eq!(profile.name, "default");
        assert_eq!(profile.lines, vec!["region = us-east-1", "output = yaml",]);

        let profile = profiles.get(1).unwrap();
        assert_eq!(profile.name, "test");
        assert_eq!(
            profile.lines,
            vec!["region = ap-northeast-1", "output = json",]
        );
    }

    #[test]
    fn it_gets_mfa_serial() {
        let config = Config {
            profiles: vec![Profile {
                name: "tanaka".into(),
                lines: vec![
                    "region = ap-northeast-1".into(),
                    "output = json".into(),
                    "mfa_serial = xxxxxxxxxxxxxxxx".into(),
                ],
            }],
        };

        assert!(config.get_mfa_serial("takahashi").is_err());
        assert_eq!(config.get_mfa_serial("tanaka").unwrap(), "xxxxxxxxxxxxxxxx");
    }

    #[test]
    fn it_sets_mfa_profile() {
        let config = Config {
            profiles: vec![Profile {
                name: "tanaka".into(),
                lines: vec![
                    "region = ap-northeast-1".into(),
                    "output = json".into(),
                    "mfa_serial = xxxxxxxxxxxxxxxx".into(),
                ],
            }],
        };

        let config = config.set_mfa_profile("tanaka", "takahashi");
        assert!(config.is_ok());

        let Config { profiles } = config.unwrap();
        assert_eq!(profiles.len(), 2);

        let profile = profiles.get(0).unwrap();
        assert_eq!(profile.name, "tanaka");
        assert_eq!(
            profile.lines,
            vec![
                "region = ap-northeast-1",
                "output = json",
                "mfa_serial = xxxxxxxxxxxxxxxx",
            ]
        );

        let profile = profiles.get(1).unwrap();
        assert_eq!(profile.name, "takahashi");
        assert_eq!(
            profile.lines,
            vec!["region = ap-northeast-1", "output = json",]
        );
    }

    #[test]
    fn it_writes_config() {
        let config = Config {
            profiles: vec![
                Profile {
                    name: "tanaka".into(),
                    lines: vec!["foobarbaz".into()],
                },
                Profile {
                    name: "takahashi".into(),
                    lines: vec!["foo".into(), "bar".into()],
                },
            ],
        };

        let path = Path::new("mock/write_test_config");
        config.write(path).unwrap();

        let config = Config::load(path);
        assert!(config.is_ok());

        let Config { profiles } = config.unwrap();
        assert_eq!(profiles.len(), 2);

        let profile = profiles.get(0).unwrap();
        assert_eq!(profile.name, "tanaka");
        assert_eq!(profile.lines, vec!["foobarbaz"]);

        let profile = profiles.get(1).unwrap();
        assert_eq!(profile.name, "takahashi");
        assert_eq!(profile.lines, vec!["foo", "bar"]);
    }
}