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}