Skip to main content

ez_token/config/
cli_config.rs

1use confy::{get_configuration_file_path, load, store};
2use miette::{Context, IntoDiagnostic, Result};
3use serde::{Deserialize, Serialize};
4use std::collections::HashMap;
5use std::path::PathBuf;
6
7use crate::cli::args::ProviderKind;
8
9/// The application name, derived from `Cargo.toml` at compile time.
10///
11/// Used by `confy` to determine the platform-specific configuration directory.
12const APP_NAME: &str = env!("CARGO_PKG_NAME");
13
14/// The configuration file name (without extension).
15const CONFIG_NAME: &str = "config";
16
17/// A named configuration profile storing reusable authentication parameters.
18///
19/// All fields are optional — any field not set will be resolved from CLI
20/// arguments or prompted interactively at runtime.
21///
22/// Profiles are stored in the `ez-token` configuration file and can be
23/// managed via the `ez-token config` subcommands.
24#[derive(Serialize, Deserialize, Debug, Clone, Default)]
25pub struct Profile {
26    /// The identity provider (e.g. `"microsoft"`, `"auth0"`).
27    pub provider: Option<ProviderKind>,
28
29    /// The Microsoft Entra ID Tenant ID (Microsoft only).
30    pub tenant_id: Option<String>,
31
32    /// The Auth0 domain (Auth0 only, e.g. `my-org.eu.auth0.com`).
33    pub domain: Option<String>,
34
35    /// The Auth0 audience (Auth0 only, e.g. `api://ez-token`).
36    pub audience: Option<String>,
37
38    /// The Application (Client) ID registered in Entra ID.
39    pub client_id: Option<String>,
40
41    /// Space-separated list of default OAuth2 scopes for this profile.
42    ///
43    /// # Examples
44    /// - `"User.Read Mail.Read"`
45    /// - `"api://my-api/.default"`
46    pub default_scopes: Option<String>,
47}
48
49/// The top-level configuration file structure for `ez-token`.
50///
51/// Stores a map of named [`Profile`]s, persisted to disk via `confy`.
52#[derive(Serialize, Deserialize, Default, Debug)]
53pub struct CliConfig {
54    /// Named profiles keyed by profile name (e.g., `"default"`, `"prod"`).
55    #[serde(default)]
56    pub profiles: HashMap<String, Profile>,
57}
58
59impl CliConfig {
60    /// Loads the configuration from disk.
61    ///
62    /// If no configuration file exists, returns a default empty [`CliConfig`].
63    ///
64    /// # Errors
65    ///
66    /// Returns an error if the configuration file exists but cannot be
67    /// parsed or read due to invalid format or insufficient permissions.
68    pub fn load() -> Result<Self> {
69        let cfg: CliConfig = load(APP_NAME, CONFIG_NAME)
70            .into_diagnostic()
71            .wrap_err("Failed to load configuration")?;
72        Ok(cfg)
73    }
74
75    /// Persists the current configuration to disk.
76    ///
77    /// Creates the configuration file and any parent directories if they
78    /// do not already exist.
79    ///
80    /// # Errors
81    ///
82    /// Returns an error if the configuration cannot be serialized or written
83    /// to the filesystem.
84    pub fn save(&self) -> Result<()> {
85        store(APP_NAME, CONFIG_NAME, self)
86            .into_diagnostic()
87            .wrap_err("Failed to save configuration")?;
88        Ok(())
89    }
90
91    /// Returns the path to the configuration file on disk.
92    ///
93    /// Useful for displaying the configuration location to the user via
94    /// `ez-token config show` or determining the directory for history files.
95    ///
96    /// # Errors
97    ///
98    /// Returns an error if the platform-specific configuration directory
99    /// cannot be determined.
100    pub fn get_path() -> Result<PathBuf> {
101        let path = get_configuration_file_path(APP_NAME, CONFIG_NAME)
102            .into_diagnostic()
103            .wrap_err("Could not determine config path")?;
104        Ok(path)
105    }
106}
107
108#[cfg(test)]
109mod tests {
110    use super::*;
111
112    #[test]
113    fn test_default_config_is_empty() {
114        let config = CliConfig::default();
115        assert!(config.profiles.is_empty());
116    }
117
118    #[test]
119    fn test_config_serialization_deserialization() {
120        let mut original_config = CliConfig::default();
121
122        original_config.profiles.insert(
123            "prod".to_string(),
124            Profile {
125                provider: Some(ProviderKind::Microsoft),
126                tenant_id: Some("common".to_string()),
127                client_id: Some("12345".to_string()),
128                ..Default::default()
129            },
130        );
131
132        let serialized = serde_json::to_string(&original_config).expect("Should serialize");
133
134        assert!(serialized.contains("prod"));
135        assert!(serialized.contains("Microsoft"));
136        assert!(serialized.contains("common"));
137        assert!(serialized.contains("12345"));
138
139        let deserialized: CliConfig =
140            serde_json::from_str(&serialized).expect("Should deserialize");
141
142        let prod_profile = deserialized.profiles.get("prod").unwrap();
143        assert_eq!(prod_profile.tenant_id.as_deref(), Some("common"));
144    }
145}