awsmfa 0.3.2

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

use super::{aws_home, ConfFile, ConfLoader};
use anyhow::anyhow;
use std::path::{Path, PathBuf};

const PROFILE: &str = r"^\[profile\s+(.+)\]$";
const FILENAME: &str = "config";
const MFA_SERIAL: &str = "mfa_serial";

#[derive(Debug)]
pub struct Config {
    content: ConfFile,
}

impl Config {
    pub fn new() -> Result<Self> {
        let path = filepath()?;
        Self::load(path.as_path())
    }

    pub fn mfa_serial(&self, profile: &str) -> Result<&str> {
        self.content
            .profile(profile)
            .ok_or(anyhow!(
                "Not Found profile: {} at {}",
                profile,
                filepath()?.to_string_lossy(),
            ))?
            .get(MFA_SERIAL)
            .ok_or(anyhow!(
                "Not Found mfa_serial in profile {} at {}",
                profile,
                filepath()?.to_string_lossy(),
            ))
    }

    pub fn set_mfa_profile(self, src: &str, dst: &str) -> Result<Self> {
        let profile = self
            .content
            .profile(src)
            .cloned()
            .ok_or(anyhow!(
                "Not Found profile: {} at {}",
                src,
                filepath()?.to_string_lossy(),
            ))?
            .rename(dst)
            .remove(MFA_SERIAL);

        let content = self.content.set(profile);

        Ok(Self { content })
    }

    pub fn save(&self) -> Result<()> {
        let path = filepath()?;
        self.write(path.as_path())
    }

    fn load(path: &Path) -> Result<Self> {
        let fmt = Box::new(|p: &str| {
            if p == "default" {
                "[default]".to_string()
            } else {
                format!("[profile {p}]")
            }
        });

        let content = ConfLoader::new()
            .set_path(path)
            .set_reg_profile(PROFILE)
            .set_formatter(fmt)
            .load()?;

        Ok(Self { content })
    }

    fn write(&self, path: &Path) -> Result<()> {
        self.content.write(path)
    }
}

fn filepath() -> Result<PathBuf> {
    Ok(aws_home()?.join(FILENAME))
}

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

    fn build() -> Config {
        let path = Path::new("mock/test_config");
        Config::load(path).unwrap()
    }

    #[test]
    fn it_gets_mfa_serial_from_profile() {
        let config = build();

        let result = config.mfa_serial("test");
        assert!(result.is_ok());

        let mfa_serial = result.unwrap();
        assert_eq!(mfa_serial, "arn:aws:iam::999999999999:mfa/user");

        let result = config.mfa_serial("default");
        assert!(result.is_err());
    }

    #[test]
    fn it_copies_profile_without_mfa_serial() {
        let config = build();

        let result = config.set_mfa_profile("test", "test_v2");
        assert!(result.is_ok());

        let config = result.unwrap();
        assert_eq!(
            config.mfa_serial("test").unwrap(),
            "arn:aws:iam::999999999999:mfa/user"
        );
        assert!(config.mfa_serial("test_v2").is_err());

        let test = config.content.profile("test").unwrap();
        let test_v2 = config.content.profile("test_v2").unwrap();

        assert_eq!(test.get("region"), test_v2.get("region"));
        assert_eq!(test.get("output"), test_v2.get("output"));
    }

    #[test]
    fn it_writes_to_file() {
        let config0 = build();
        let path = Path::new("mock/write_test_config");

        let result = config0.write(path);
        assert!(result.is_ok());

        let config1 = Config::load(path).unwrap();

        assert_eq!(config0.content, config1.content);
    }
}