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 trimmed = v.trim();
244        if trimmed.is_empty() {
245            None
246        } else if trimmed.len() == v.len() {
247            Some(v)
248        } else {
249            Some(trimmed.to_owned())
250        }
251    })
252}
253
254/// Write (or overwrite) a single profile in the config file, preserving other
255/// profiles and any comments or formatting in unmodified sections.
256///
257/// Creates the config directory and file if they don't exist, then sets
258/// permissions to 0600 on unix.
259pub fn write_profile(
260    path: &Path,
261    profile_name: &str,
262    account_id: &str,
263    client_id: &str,
264    client_secret: &str,
265) -> Result<(), ApiError> {
266    let content = match std::fs::read_to_string(path) {
267        Ok(c) => c,
268        Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
269        Err(e) => return Err(ApiError::Other(format!("Failed to read config: {e}"))),
270    };
271
272    let mut doc: toml_edit::DocumentMut = content
273        .parse()
274        .map_err(|e| ApiError::Other(format!("Failed to parse config: {e}")))?;
275
276    let mut profile = toml_edit::Table::new();
277    profile["account_id"] = toml_edit::value(account_id);
278    profile["client_id"] = toml_edit::value(client_id);
279    profile["client_secret"] = toml_edit::value(client_secret);
280    doc[profile_name] = toml_edit::Item::Table(profile);
281
282    if let Some(parent) = path.parent() {
283        std::fs::create_dir_all(parent)
284            .map_err(|e| ApiError::Other(format!("Cannot create config directory: {e}")))?;
285    }
286
287    write_config_file(path, &doc.to_string())?;
288    Ok(())
289}
290
291fn write_config_file(path: &Path, content: &str) -> Result<(), ApiError> {
292    use std::io::Write;
293    #[cfg(unix)]
294    {
295        use std::os::unix::fs::OpenOptionsExt;
296        let mut file = std::fs::OpenOptions::new()
297            .write(true)
298            .create(true)
299            .truncate(true)
300            .mode(0o600)
301            .open(path)
302            .map_err(|e| ApiError::Other(format!("Cannot write config: {e}")))?;
303        file.write_all(content.as_bytes())
304            .map_err(|e| ApiError::Other(format!("Write error: {e}")))?;
305    }
306    #[cfg(not(unix))]
307    {
308        std::fs::write(path, content.as_bytes())
309            .map_err(|e| ApiError::Other(format!("Cannot write config: {e}")))?;
310    }
311    Ok(())
312}
313
314/// Remove a named profile from the config file, preserving comments and
315/// formatting in the remaining sections.
316///
317/// Returns `Ok(())` if removed, `Err(ApiError::NotFound)` if the profile
318/// doesn't exist, and `Err(ApiError::Other(...))` for IO/parse failures.
319pub fn delete_profile(path: &Path, profile_name: &str) -> Result<(), ApiError> {
320    let content = match std::fs::read_to_string(path) {
321        Ok(c) => c,
322        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
323            return Err(ApiError::NotFound(format!(
324                "Config file not found: {}",
325                path.display()
326            )));
327        }
328        Err(e) => return Err(ApiError::Other(format!("Failed to read config: {e}"))),
329    };
330
331    let mut doc: toml_edit::DocumentMut = content
332        .parse()
333        .map_err(|e| ApiError::Other(format!("Invalid config: {e}")))?;
334
335    // Both "default" and named profiles are stored as top-level TOML keys.
336    if doc.remove(profile_name).is_none() {
337        return Err(ApiError::NotFound(format!(
338            "Profile '{}' not found.",
339            profile_name
340        )));
341    }
342
343    write_config_file(path, &doc.to_string())?;
344    Ok(())
345}
346
347pub fn schema_config_path_description() -> &'static str {
348    #[cfg(not(target_os = "windows"))]
349    {
350        "~/.config/zoom-cli/config.toml (or $XDG_CONFIG_HOME/zoom-cli/config.toml)"
351    }
352    #[cfg(target_os = "windows")]
353    {
354        "%APPDATA%\\zoom-cli\\config.toml"
355    }
356}
357
358#[cfg(test)]
359mod tests {
360    use super::*;
361    use crate::test_support::{EnvVarGuard, ProcessEnvLock, set_config_dir_env, write_config};
362    use tempfile::TempDir;
363
364    fn clear_zoom_env() -> (EnvVarGuard, EnvVarGuard, EnvVarGuard, EnvVarGuard) {
365        (
366            EnvVarGuard::unset("ZOOM_ACCOUNT_ID"),
367            EnvVarGuard::unset("ZOOM_CLIENT_ID"),
368            EnvVarGuard::unset("ZOOM_CLIENT_SECRET"),
369            EnvVarGuard::unset("ZOOM_PROFILE"),
370        )
371    }
372
373    #[test]
374    fn load_reads_default_profile_from_file() {
375        let _lock = ProcessEnvLock::acquire().unwrap();
376        let dir = TempDir::new().unwrap();
377        write_config(
378            dir.path(),
379            r#"
380[default]
381account_id = "acct-001"
382client_id = "cid-001"
383client_secret = "csec-001"
384"#,
385        )
386        .unwrap();
387
388        let _cfg_dir = set_config_dir_env(dir.path());
389        let _env = clear_zoom_env();
390
391        let cfg = Config::load(None).unwrap();
392        assert_eq!(cfg.account_id, "acct-001");
393        assert_eq!(cfg.client_id, "cid-001");
394        assert_eq!(cfg.client_secret, "csec-001");
395    }
396
397    #[test]
398    fn load_env_vars_override_file() {
399        let _lock = ProcessEnvLock::acquire().unwrap();
400        let dir = TempDir::new().unwrap();
401        write_config(
402            dir.path(),
403            r#"
404[default]
405account_id = "file-account"
406client_id = "file-client"
407client_secret = "file-secret"
408"#,
409        )
410        .unwrap();
411
412        let _cfg_dir = set_config_dir_env(dir.path());
413        let _acct = EnvVarGuard::set("ZOOM_ACCOUNT_ID", "env-account");
414        let _cid = EnvVarGuard::unset("ZOOM_CLIENT_ID");
415        let _csec = EnvVarGuard::unset("ZOOM_CLIENT_SECRET");
416        let _prof = EnvVarGuard::unset("ZOOM_PROFILE");
417
418        let cfg = Config::load(None).unwrap();
419        assert_eq!(cfg.account_id, "env-account", "env var must win over file");
420        assert_eq!(
421            cfg.client_id, "file-client",
422            "file value used when env absent"
423        );
424    }
425
426    #[test]
427    fn load_blank_env_vars_fall_back_to_file() {
428        let _lock = ProcessEnvLock::acquire().unwrap();
429        let dir = TempDir::new().unwrap();
430        write_config(
431            dir.path(),
432            r#"
433[default]
434account_id = "acct"
435client_id = "cid"
436client_secret = "csec"
437"#,
438        )
439        .unwrap();
440
441        let _cfg_dir = set_config_dir_env(dir.path());
442        let _acct = EnvVarGuard::set("ZOOM_ACCOUNT_ID", "   ");
443        let _cid = EnvVarGuard::set("ZOOM_CLIENT_ID", "");
444        let _csec = EnvVarGuard::unset("ZOOM_CLIENT_SECRET");
445        let _prof = EnvVarGuard::unset("ZOOM_PROFILE");
446
447        let cfg = Config::load(None).unwrap();
448        assert_eq!(cfg.account_id, "acct");
449        assert_eq!(cfg.client_id, "cid");
450    }
451
452    #[test]
453    fn load_missing_credentials_returns_error() {
454        let _lock = ProcessEnvLock::acquire().unwrap();
455        let dir = TempDir::new().unwrap();
456        let _cfg_dir = set_config_dir_env(dir.path());
457        let _env = clear_zoom_env();
458
459        let err = Config::load(None).unwrap_err();
460        assert!(matches!(err, ApiError::InvalidInput(_)));
461        assert!(err.to_string().contains("account_id"));
462    }
463
464    #[test]
465    fn load_named_profile_from_file() {
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[work]
477account_id = "work-acct"
478client_id = "work-cid"
479client_secret = "work-csec"
480"#,
481        )
482        .unwrap();
483
484        let _cfg_dir = set_config_dir_env(dir.path());
485        let _env = clear_zoom_env();
486
487        let cfg = Config::load(Some("work".into())).unwrap();
488        assert_eq!(cfg.account_id, "work-acct");
489        assert_eq!(cfg.client_id, "work-cid");
490    }
491
492    #[test]
493    fn load_zoom_profile_env_selects_named_profile() {
494        let _lock = ProcessEnvLock::acquire().unwrap();
495        let dir = TempDir::new().unwrap();
496        write_config(
497            dir.path(),
498            r#"
499[default]
500account_id = "def-acct"
501client_id = "def-cid"
502client_secret = "def-csec"
503
504[staging]
505account_id = "staging-acct"
506client_id = "staging-cid"
507client_secret = "staging-csec"
508"#,
509        )
510        .unwrap();
511
512        let _cfg_dir = set_config_dir_env(dir.path());
513        let _acct = EnvVarGuard::unset("ZOOM_ACCOUNT_ID");
514        let _cid = EnvVarGuard::unset("ZOOM_CLIENT_ID");
515        let _csec = EnvVarGuard::unset("ZOOM_CLIENT_SECRET");
516        let _prof = EnvVarGuard::set("ZOOM_PROFILE", "staging");
517
518        let cfg = Config::load(None).unwrap();
519        assert_eq!(cfg.account_id, "staging-acct");
520    }
521
522    #[test]
523    fn load_unknown_profile_returns_descriptive_error() {
524        let _lock = ProcessEnvLock::acquire().unwrap();
525        let dir = TempDir::new().unwrap();
526        write_config(
527            dir.path(),
528            r#"
529[work]
530account_id = "w-acct"
531client_id = "w-cid"
532client_secret = "w-csec"
533"#,
534        )
535        .unwrap();
536
537        let _cfg_dir = set_config_dir_env(dir.path());
538        let _env = clear_zoom_env();
539
540        let err = Config::load(Some("nonexistent".into())).unwrap_err();
541        let msg = err.to_string();
542        assert!(msg.contains("nonexistent"));
543        assert!(msg.contains("work"), "error should list available profiles");
544    }
545
546    #[test]
547    fn load_invalid_toml_returns_error() {
548        let _lock = ProcessEnvLock::acquire().unwrap();
549        let dir = TempDir::new().unwrap();
550        write_config(dir.path(), "account_id = [invalid").unwrap();
551
552        let _cfg_dir = set_config_dir_env(dir.path());
553        let _env = clear_zoom_env();
554
555        let err = Config::load(None).unwrap_err();
556        assert!(matches!(err, ApiError::Other(_)));
557        assert!(err.to_string().contains("parse"));
558    }
559
560    #[test]
561    fn missing_config_file_yields_informative_missing_field_error() {
562        let _lock = ProcessEnvLock::acquire().unwrap();
563        let dir = TempDir::new().unwrap();
564        let _cfg_dir = set_config_dir_env(dir.path());
565        let _env = clear_zoom_env();
566
567        let err = Config::load(None).unwrap_err();
568        assert!(matches!(err, ApiError::InvalidInput(_)));
569    }
570
571    #[test]
572    fn write_profile_preserves_comments_in_other_sections() {
573        let dir = TempDir::new().unwrap();
574        let path = dir.path().join("config.toml");
575        // Write a file with a comment above an existing profile.
576        std::fs::write(
577            &path,
578            "# This comment must survive\n[work]\naccount_id = \"w\"\nclient_id = \"w\"\nclient_secret = \"w\"\n",
579        )
580        .unwrap();
581
582        write_profile(&path, "default", "acct", "cid", "csec").unwrap();
583
584        let after = std::fs::read_to_string(&path).unwrap();
585        assert!(
586            after.contains("# This comment must survive"),
587            "write_profile must not destroy comments in unrelated sections"
588        );
589        assert!(after.contains("[default]"), "new profile must be present");
590        assert!(
591            after.contains("[work]"),
592            "existing profile must be preserved"
593        );
594    }
595
596    #[test]
597    fn delete_profile_preserves_comments_in_remaining_sections() {
598        let dir = TempDir::new().unwrap();
599        let path = dir.path().join("config.toml");
600        std::fs::write(
601            &path,
602            "# Keep this\n[default]\naccount_id = \"d\"\nclient_id = \"d\"\nclient_secret = \"d\"\n\n[work]\naccount_id = \"w\"\nclient_id = \"w\"\nclient_secret = \"w\"\n",
603        )
604        .unwrap();
605
606        delete_profile(&path, "work").unwrap();
607
608        let after = std::fs::read_to_string(&path).unwrap();
609        assert!(
610            after.contains("# Keep this"),
611            "delete_profile must not destroy comments in remaining sections"
612        );
613        assert!(after.contains("[default]"), "default profile must remain");
614        assert!(!after.contains("[work]"), "deleted profile must be gone");
615    }
616}