Skip to main content

zoom_cli/
config.rs

1use std::collections::BTreeMap;
2use std::path::{Path, PathBuf};
3
4use serde::Deserialize;
5
6use crate::api::ApiError;
7
8#[derive(Debug, Deserialize, Default, Clone)]
9struct RawProfile {
10    pub account_id: Option<String>,
11    pub client_id: Option<String>,
12    pub client_secret: Option<String>,
13}
14
15#[derive(Debug, Deserialize, Default)]
16struct RawConfig {
17    #[serde(default)]
18    default: RawProfile,
19    #[serde(flatten)]
20    profiles: BTreeMap<String, RawProfile>,
21}
22
23/// Resolved credentials for the active profile.
24#[derive(Debug, Clone)]
25pub struct Config {
26    pub account_id: String,
27    pub client_id: String,
28    pub client_secret: String,
29}
30
31impl Config {
32    /// Load config with priority: env vars > config file profile.
33    pub fn load(profile_arg: Option<String>) -> Result<Self, ApiError> {
34        let file_profile = load_file_profile(profile_arg.as_deref())?;
35
36        let account_id = env_var("ZOOM_ACCOUNT_ID")
37            .or_else(|| normalize(file_profile.account_id))
38            .ok_or_else(|| {
39                ApiError::InvalidInput(
40                    "No account_id configured. Run 'zoom init' or set ZOOM_ACCOUNT_ID.".into(),
41                )
42            })?;
43
44        let client_id = env_var("ZOOM_CLIENT_ID")
45            .or_else(|| normalize(file_profile.client_id))
46            .ok_or_else(|| {
47                ApiError::InvalidInput(
48                    "No client_id configured. Run 'zoom init' or set ZOOM_CLIENT_ID.".into(),
49                )
50            })?;
51
52        let client_secret = env_var("ZOOM_CLIENT_SECRET")
53            .or_else(|| normalize(file_profile.client_secret))
54            .ok_or_else(|| {
55                ApiError::InvalidInput(
56                    "No client_secret configured. Run 'zoom init' or set ZOOM_CLIENT_SECRET."
57                        .into(),
58                )
59            })?;
60
61        Ok(Self {
62            account_id,
63            client_id,
64            client_secret,
65        })
66    }
67}
68
69/// Per-profile credential values as stored in the config file.
70pub struct ProfileSummary {
71    pub name: String,
72    pub account_id: Option<String>,
73    pub client_id: Option<String>,
74    pub client_secret: Option<String>,
75}
76
77/// Full configuration state for display — no credential resolution or validation.
78pub struct ConfigSummary {
79    pub config_file: PathBuf,
80    pub file_exists: bool,
81    /// The profile that will be used (from --profile arg, ZOOM_PROFILE, or "default").
82    pub active_profile: String,
83    /// All profiles found in the config file, "default" first.
84    pub profiles: Vec<ProfileSummary>,
85    /// Environment variables that are set and will override file values.
86    /// Each entry is `(var_name, raw_value)`.
87    pub env_overrides: Vec<(&'static str, String)>,
88}
89
90/// Read all config state for display without resolving or validating credentials.
91pub fn load_for_show(profile_arg: Option<&str>) -> ConfigSummary {
92    let path = config_path();
93    let file_exists = path.exists();
94
95    let active_profile = profile_arg
96        .filter(|s| !s.trim().is_empty())
97        .map(str::to_owned)
98        .or_else(|| env_var("ZOOM_PROFILE"))
99        .unwrap_or_else(|| "default".to_owned());
100
101    let profiles = read_all_profiles(&path);
102
103    let mut env_overrides = Vec::new();
104    for var in ["ZOOM_ACCOUNT_ID", "ZOOM_CLIENT_ID", "ZOOM_CLIENT_SECRET"] {
105        if let Some(v) = normalize(std::env::var(var).ok()) {
106            env_overrides.push((var, v));
107        }
108    }
109
110    ConfigSummary {
111        config_file: path,
112        file_exists,
113        active_profile,
114        profiles,
115        env_overrides,
116    }
117}
118
119/// Read the raw credential values for a specific profile, for use when updating.
120///
121/// Returns `None` if the config file does not exist, cannot be parsed, or does
122/// not contain the requested profile with all three credentials present.
123pub fn read_profile_credentials(
124    path: &Path,
125    profile_name: &str,
126) -> Option<(String, String, String)> {
127    let content = std::fs::read_to_string(path).ok()?;
128    let raw: RawConfig = toml::from_str(&content).ok()?;
129
130    let p = if profile_name == "default" {
131        raw.default
132    } else {
133        raw.profiles.get(profile_name)?.clone()
134    };
135
136    Some((
137        normalize(p.account_id)?,
138        normalize(p.client_id)?,
139        normalize(p.client_secret)?,
140    ))
141}
142
143fn read_all_profiles(path: &Path) -> Vec<ProfileSummary> {
144    let content = match std::fs::read_to_string(path) {
145        Ok(c) => c,
146        Err(_) => return Vec::new(),
147    };
148    let raw: RawConfig = match toml::from_str(&content) {
149        Ok(r) => r,
150        Err(_) => return Vec::new(),
151    };
152
153    let mut profiles = Vec::new();
154
155    // "default" is deserialized into the dedicated field, not the flatten map.
156    if raw.default.account_id.is_some()
157        || raw.default.client_id.is_some()
158        || raw.default.client_secret.is_some()
159    {
160        profiles.push(ProfileSummary {
161            name: "default".to_owned(),
162            account_id: raw.default.account_id,
163            client_id: raw.default.client_id,
164            client_secret: raw.default.client_secret,
165        });
166    }
167
168    // BTreeMap iteration is already in alphabetical order.
169    for (name, p) in raw.profiles {
170        profiles.push(ProfileSummary {
171            name,
172            account_id: p.account_id,
173            client_id: p.client_id,
174            client_secret: p.client_secret,
175        });
176    }
177
178    profiles
179}
180
181pub fn config_path() -> PathBuf {
182    config_dir()
183        .unwrap_or_else(|| PathBuf::from(".config"))
184        .join("zoom-cli")
185        .join("config.toml")
186}
187
188fn config_dir() -> Option<PathBuf> {
189    #[cfg(target_os = "windows")]
190    {
191        dirs::config_dir()
192    }
193    #[cfg(not(target_os = "windows"))]
194    {
195        std::env::var_os("XDG_CONFIG_HOME")
196            .filter(|v| !v.is_empty())
197            .map(PathBuf::from)
198            .or_else(|| dirs::home_dir().map(|h| h.join(".config")))
199    }
200}
201
202fn load_file_profile(profile: Option<&str>) -> Result<RawProfile, ApiError> {
203    let path = config_path();
204    let content = match std::fs::read_to_string(&path) {
205        Ok(c) => c,
206        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(RawProfile::default()),
207        Err(e) => return Err(ApiError::Other(format!("Failed to read config: {e}"))),
208    };
209
210    let raw: RawConfig = toml::from_str(&content)
211        .map_err(|e| ApiError::Other(format!("Failed to parse config: {e}")))?;
212
213    let profile_name = profile
214        .filter(|s| !s.trim().is_empty())
215        .map(str::to_owned)
216        .or_else(|| env_var("ZOOM_PROFILE"));
217
218    match profile_name {
219        None => Ok(raw.default),
220        Some(name) if name == "default" => Ok(raw.default),
221        Some(name) => {
222            let available: Vec<&str> = raw.profiles.keys().map(String::as_str).collect();
223            raw.profiles.get(&name).cloned().ok_or_else(|| {
224                ApiError::Other(format!(
225                    "Profile '{name}' not found. Available: {}",
226                    if available.is_empty() {
227                        "none".to_owned()
228                    } else {
229                        available.join(", ")
230                    }
231                ))
232            })
233        }
234    }
235}
236
237fn env_var(name: &str) -> Option<String> {
238    normalize(std::env::var(name).ok())
239}
240
241fn normalize(value: Option<String>) -> Option<String> {
242    value.and_then(|v| {
243        let t = v.trim().to_owned();
244        if t.is_empty() { None } else { Some(t) }
245    })
246}
247
248/// Write (or overwrite) a single profile in the config file, preserving other profiles.
249///
250/// Creates the config directory and file if they don't exist, then sets
251/// permissions to 0600 on unix.
252pub fn write_profile(
253    path: &Path,
254    profile_name: &str,
255    account_id: &str,
256    client_id: &str,
257    client_secret: &str,
258) -> Result<(), ApiError> {
259    let content = match std::fs::read_to_string(path) {
260        Ok(c) => c,
261        Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
262        Err(e) => return Err(ApiError::Other(format!("Failed to read config: {e}"))),
263    };
264
265    let mut table: toml::Table = if content.trim().is_empty() {
266        toml::Table::new()
267    } else {
268        toml::from_str(&content)
269            .map_err(|e| ApiError::Other(format!("Failed to parse config: {e}")))?
270    };
271
272    let mut profile = toml::Table::new();
273    profile.insert(
274        "account_id".into(),
275        toml::Value::String(account_id.to_owned()),
276    );
277    profile.insert(
278        "client_id".into(),
279        toml::Value::String(client_id.to_owned()),
280    );
281    profile.insert(
282        "client_secret".into(),
283        toml::Value::String(client_secret.to_owned()),
284    );
285    table.insert(profile_name.to_owned(), toml::Value::Table(profile));
286
287    if let Some(parent) = path.parent() {
288        std::fs::create_dir_all(parent)
289            .map_err(|e| ApiError::Other(format!("Cannot create config directory: {e}")))?;
290    }
291
292    let serialized = toml::to_string_pretty(&table)
293        .map_err(|e| ApiError::Other(format!("Failed to serialize config: {e}")))?;
294    // On Unix, create the file with mode 0o600 in a single syscall so there is
295    // no window between creation (with a permissive umask) and chmod.
296    #[cfg(unix)]
297    {
298        use std::io::Write;
299        use std::os::unix::fs::OpenOptionsExt;
300        let mut file = std::fs::OpenOptions::new()
301            .write(true)
302            .create(true)
303            .truncate(true)
304            .mode(0o600)
305            .open(path)
306            .map_err(|e| ApiError::Other(format!("Failed to write config: {e}")))?;
307        file.write_all(serialized.as_bytes())
308            .map_err(|e| ApiError::Other(format!("Failed to write config: {e}")))?;
309    }
310    #[cfg(not(unix))]
311    {
312        std::fs::write(path, serialized)
313            .map_err(|e| ApiError::Other(format!("Failed to write config: {e}")))?;
314    }
315
316    Ok(())
317}
318
319pub fn schema_config_path_description() -> &'static str {
320    #[cfg(not(target_os = "windows"))]
321    {
322        "~/.config/zoom-cli/config.toml (or $XDG_CONFIG_HOME/zoom-cli/config.toml)"
323    }
324    #[cfg(target_os = "windows")]
325    {
326        "%APPDATA%\\zoom-cli\\config.toml"
327    }
328}
329
330#[cfg(test)]
331mod tests {
332    use super::*;
333    use crate::test_support::{EnvVarGuard, ProcessEnvLock, set_config_dir_env, write_config};
334    use tempfile::TempDir;
335
336    fn clear_zoom_env() -> (EnvVarGuard, EnvVarGuard, EnvVarGuard, EnvVarGuard) {
337        (
338            EnvVarGuard::unset("ZOOM_ACCOUNT_ID"),
339            EnvVarGuard::unset("ZOOM_CLIENT_ID"),
340            EnvVarGuard::unset("ZOOM_CLIENT_SECRET"),
341            EnvVarGuard::unset("ZOOM_PROFILE"),
342        )
343    }
344
345    #[test]
346    fn load_reads_default_profile_from_file() {
347        let _lock = ProcessEnvLock::acquire().unwrap();
348        let dir = TempDir::new().unwrap();
349        write_config(
350            dir.path(),
351            r#"
352[default]
353account_id = "acct-001"
354client_id = "cid-001"
355client_secret = "csec-001"
356"#,
357        )
358        .unwrap();
359
360        let _cfg_dir = set_config_dir_env(dir.path());
361        let _env = clear_zoom_env();
362
363        let cfg = Config::load(None).unwrap();
364        assert_eq!(cfg.account_id, "acct-001");
365        assert_eq!(cfg.client_id, "cid-001");
366        assert_eq!(cfg.client_secret, "csec-001");
367    }
368
369    #[test]
370    fn load_env_vars_override_file() {
371        let _lock = ProcessEnvLock::acquire().unwrap();
372        let dir = TempDir::new().unwrap();
373        write_config(
374            dir.path(),
375            r#"
376[default]
377account_id = "file-account"
378client_id = "file-client"
379client_secret = "file-secret"
380"#,
381        )
382        .unwrap();
383
384        let _cfg_dir = set_config_dir_env(dir.path());
385        let _acct = EnvVarGuard::set("ZOOM_ACCOUNT_ID", "env-account");
386        let _cid = EnvVarGuard::unset("ZOOM_CLIENT_ID");
387        let _csec = EnvVarGuard::unset("ZOOM_CLIENT_SECRET");
388        let _prof = EnvVarGuard::unset("ZOOM_PROFILE");
389
390        let cfg = Config::load(None).unwrap();
391        assert_eq!(cfg.account_id, "env-account", "env var must win over file");
392        assert_eq!(
393            cfg.client_id, "file-client",
394            "file value used when env absent"
395        );
396    }
397
398    #[test]
399    fn load_blank_env_vars_fall_back_to_file() {
400        let _lock = ProcessEnvLock::acquire().unwrap();
401        let dir = TempDir::new().unwrap();
402        write_config(
403            dir.path(),
404            r#"
405[default]
406account_id = "acct"
407client_id = "cid"
408client_secret = "csec"
409"#,
410        )
411        .unwrap();
412
413        let _cfg_dir = set_config_dir_env(dir.path());
414        let _acct = EnvVarGuard::set("ZOOM_ACCOUNT_ID", "   ");
415        let _cid = EnvVarGuard::set("ZOOM_CLIENT_ID", "");
416        let _csec = EnvVarGuard::unset("ZOOM_CLIENT_SECRET");
417        let _prof = EnvVarGuard::unset("ZOOM_PROFILE");
418
419        let cfg = Config::load(None).unwrap();
420        assert_eq!(cfg.account_id, "acct");
421        assert_eq!(cfg.client_id, "cid");
422    }
423
424    #[test]
425    fn load_missing_credentials_returns_error() {
426        let _lock = ProcessEnvLock::acquire().unwrap();
427        let dir = TempDir::new().unwrap();
428        let _cfg_dir = set_config_dir_env(dir.path());
429        let _env = clear_zoom_env();
430
431        let err = Config::load(None).unwrap_err();
432        assert!(matches!(err, ApiError::InvalidInput(_)));
433        assert!(err.to_string().contains("account_id"));
434    }
435
436    #[test]
437    fn load_named_profile_from_file() {
438        let _lock = ProcessEnvLock::acquire().unwrap();
439        let dir = TempDir::new().unwrap();
440        write_config(
441            dir.path(),
442            r#"
443[default]
444account_id = "def-acct"
445client_id = "def-cid"
446client_secret = "def-csec"
447
448[work]
449account_id = "work-acct"
450client_id = "work-cid"
451client_secret = "work-csec"
452"#,
453        )
454        .unwrap();
455
456        let _cfg_dir = set_config_dir_env(dir.path());
457        let _env = clear_zoom_env();
458
459        let cfg = Config::load(Some("work".into())).unwrap();
460        assert_eq!(cfg.account_id, "work-acct");
461        assert_eq!(cfg.client_id, "work-cid");
462    }
463
464    #[test]
465    fn load_zoom_profile_env_selects_named_profile() {
466        let _lock = ProcessEnvLock::acquire().unwrap();
467        let dir = TempDir::new().unwrap();
468        write_config(
469            dir.path(),
470            r#"
471[default]
472account_id = "def-acct"
473client_id = "def-cid"
474client_secret = "def-csec"
475
476[staging]
477account_id = "staging-acct"
478client_id = "staging-cid"
479client_secret = "staging-csec"
480"#,
481        )
482        .unwrap();
483
484        let _cfg_dir = set_config_dir_env(dir.path());
485        let _acct = EnvVarGuard::unset("ZOOM_ACCOUNT_ID");
486        let _cid = EnvVarGuard::unset("ZOOM_CLIENT_ID");
487        let _csec = EnvVarGuard::unset("ZOOM_CLIENT_SECRET");
488        let _prof = EnvVarGuard::set("ZOOM_PROFILE", "staging");
489
490        let cfg = Config::load(None).unwrap();
491        assert_eq!(cfg.account_id, "staging-acct");
492    }
493
494    #[test]
495    fn load_unknown_profile_returns_descriptive_error() {
496        let _lock = ProcessEnvLock::acquire().unwrap();
497        let dir = TempDir::new().unwrap();
498        write_config(
499            dir.path(),
500            r#"
501[work]
502account_id = "w-acct"
503client_id = "w-cid"
504client_secret = "w-csec"
505"#,
506        )
507        .unwrap();
508
509        let _cfg_dir = set_config_dir_env(dir.path());
510        let _env = clear_zoom_env();
511
512        let err = Config::load(Some("nonexistent".into())).unwrap_err();
513        let msg = err.to_string();
514        assert!(msg.contains("nonexistent"));
515        assert!(msg.contains("work"), "error should list available profiles");
516    }
517
518    #[test]
519    fn load_invalid_toml_returns_error() {
520        let _lock = ProcessEnvLock::acquire().unwrap();
521        let dir = TempDir::new().unwrap();
522        write_config(dir.path(), "account_id = [invalid").unwrap();
523
524        let _cfg_dir = set_config_dir_env(dir.path());
525        let _env = clear_zoom_env();
526
527        let err = Config::load(None).unwrap_err();
528        assert!(matches!(err, ApiError::Other(_)));
529        assert!(err.to_string().contains("parse"));
530    }
531
532    #[test]
533    fn missing_config_file_yields_informative_missing_field_error() {
534        let _lock = ProcessEnvLock::acquire().unwrap();
535        let dir = TempDir::new().unwrap();
536        let _cfg_dir = set_config_dir_env(dir.path());
537        let _env = clear_zoom_env();
538
539        let err = Config::load(None).unwrap_err();
540        assert!(matches!(err, ApiError::InvalidInput(_)));
541    }
542}