ez-token 0.1.0

CLI tool for generating OAuth2 access tokens via PKCE and Client Credentials for Microsoft Entra ID and Auth0
Documentation
use crate::cli::args::{AuthArgs, ConfigAction, ProviderKind};
use crate::cli::output::{AppEmoji, print_header};
use crate::config::cli_config::CliConfig;
use console::style;
use miette::Result;

/// Dispatches a `config` subcommand action to the appropriate handler.
///
/// # Errors
///
/// Returns an error if saving the configuration fails or if the config
/// file path cannot be determined.
pub fn handle(action: ConfigAction, config: &mut CliConfig, profile_name: &str) -> Result<()> {
    match action {
        ConfigAction::Set { auth } => handle_set(config, profile_name, auth),
        ConfigAction::Show => handle_show(config, profile_name),
        ConfigAction::List => handle_list(config, profile_name),
    }
}

/// Saves the provided authentication arguments to the active profile.
///
/// Only fields that are explicitly provided are updated — omitted fields
/// retain their existing values. The updated configuration is persisted
/// to disk immediately.
///
/// # Errors
///
/// Returns an error if the configuration cannot be saved or the config
/// file path cannot be determined.
fn handle_set(config: &mut CliConfig, profile_name: &str, auth: AuthArgs) -> Result<()> {
    let profile = config.profiles.entry(profile_name.to_string()).or_default();

    if let Some(p) = auth.provider {
        profile.provider = Some(p);
    }
    if let Some(t) = auth.tenant_id {
        profile.tenant_id = Some(t);
    }
    if let Some(d) = auth.domain {
        profile.domain = Some(d);
    }
    if let Some(a) = auth.audience {
        profile.audience = Some(a);
    }
    if let Some(c) = auth.client_id {
        profile.client_id = Some(c);
    }
    if let Some(s) = auth.scopes {
        profile.default_scopes = Some(s);
    }

    config.save()?;

    let path = CliConfig::get_path()?;
    println!(
        "{} Configuration saved to {}",
        AppEmoji::Floppy.as_emoji(),
        style(path.to_string_lossy()).dim()
    );
    println!("Updated profile: {}", style(profile_name).bold().cyan());
    Ok(())
}

/// Displays the current configuration for the active profile.
///
/// Shows provider-specific fields (Tenant ID for Microsoft, Domain and Audience
/// for Auth0), Client ID, Scopes, and the path to the config file.
/// Fields that have not been set are displayed as `"not set"`
fn handle_show(config: &CliConfig, profile_name: &str) -> Result<()> {
    let profile = config
        .profiles
        .get(profile_name)
        .cloned()
        .unwrap_or_default();

    print_header(&format!("Profile: [{}]", profile_name));

    let display = |label: &str, value: &Option<String>| {
        let v = value.as_deref().unwrap_or("not set");
        println!("{}  {}", label, style(v).cyan());
    };

    let provider_str = profile.provider.as_ref().map(|p| match p {
        ProviderKind::Microsoft => "Microsoft Entra ID",
        ProviderKind::Auth0 => "Auth0",
    });
    println!(
        "Provider:   {}",
        style(provider_str.unwrap_or("not set")).cyan()
    );

    match profile.provider {
        Some(ProviderKind::Microsoft) | None => {
            display("Tenant ID:", &profile.tenant_id);
        }
        Some(ProviderKind::Auth0) => {
            display("Domain:   ", &profile.domain);
            display("Audience: ", &profile.audience);
        }
    }

    display("Client ID:", &profile.client_id);
    display("Scopes:   ", &profile.default_scopes);

    if let Ok(path) = CliConfig::get_path() {
        println!("\nLocation:  {}", style(path.to_string_lossy()).dim());
    }

    Ok(())
}

/// Lists all saved profiles, highlighting the currently active one.
///
/// The active profile is marked with an asterisk (`*`) and displayed
/// in green. All other profiles are listed with an indent.
fn handle_list(config: &CliConfig, profile_name: &str) -> Result<()> {
    print_header("Available Profiles:");

    for name in config.profiles.keys() {
        if name == profile_name {
            println!("* {}", style(name).green().bold());
        } else {
            println!("  {}", name);
        }
    }

    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::config::cli_config::Profile;

    #[test]
    fn test_handle_set_creates_new_profile_works() {
        let mut config = CliConfig::default();
        let args = AuthArgs {
            provider: Some(ProviderKind::Auth0),
            domain: Some("test.auth0.com".to_string()),
            ..Default::default()
        };

        let result = handle_set(&mut config, "test-env", args);
        assert!(result.is_ok());

        let profile = config
            .profiles
            .get("test-env")
            .expect("Profile should be created");
        assert_eq!(profile.provider, Some(ProviderKind::Auth0));
        assert_eq!(profile.domain.as_deref(), Some("test.auth0.com"));
        assert!(profile.tenant_id.is_none());
        assert!(profile.client_id.is_none());
    }

    #[test]
    fn test_handle_set_update_works() {
        let mut config = CliConfig::default();

        let mut existing_profile = Profile::default();
        existing_profile.client_id = Some("old-client".to_string());
        existing_profile.tenant_id = Some("old-tenant".to_string());
        config.profiles.insert("prod".to_string(), existing_profile);

        let args = AuthArgs {
            client_id: Some("new-client".to_string()),
            ..Default::default()
        };

        let result = handle_set(&mut config, "prod", args);
        assert!(result.is_ok());

        let profile = config.profiles.get("prod").unwrap();
        assert_eq!(profile.client_id.as_deref(), Some("new-client"));
        assert_eq!(profile.tenant_id.as_deref(), Some("old-tenant"));
    }
}