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.".into(),
57                )
58            })?;
59
60        Ok(Self {
61            account_id,
62            client_id,
63            client_secret,
64        })
65    }
66}
67
68pub fn config_path() -> PathBuf {
69    config_dir()
70        .unwrap_or_else(|| PathBuf::from(".config"))
71        .join("zoom-cli")
72        .join("config.toml")
73}
74
75fn config_dir() -> Option<PathBuf> {
76    #[cfg(target_os = "windows")]
77    {
78        dirs::config_dir()
79    }
80    #[cfg(not(target_os = "windows"))]
81    {
82        std::env::var_os("XDG_CONFIG_HOME")
83            .filter(|v| !v.is_empty())
84            .map(PathBuf::from)
85            .or_else(|| dirs::home_dir().map(|h| h.join(".config")))
86    }
87}
88
89fn load_file_profile(profile: Option<&str>) -> Result<RawProfile, ApiError> {
90    let path = config_path();
91    let content = match std::fs::read_to_string(&path) {
92        Ok(c) => c,
93        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(RawProfile::default()),
94        Err(e) => return Err(ApiError::Other(format!("Failed to read config: {e}"))),
95    };
96
97    let raw: RawConfig = toml::from_str(&content)
98        .map_err(|e| ApiError::Other(format!("Failed to parse config: {e}")))?;
99
100    let profile_name = profile
101        .filter(|s| !s.trim().is_empty())
102        .map(str::to_owned)
103        .or_else(|| env_var("ZOOM_PROFILE"));
104
105    match profile_name {
106        None => Ok(raw.default),
107        Some(name) if name == "default" => Ok(raw.default),
108        Some(name) => {
109            let available: Vec<&str> = raw.profiles.keys().map(String::as_str).collect();
110            raw.profiles.get(&name).cloned().ok_or_else(|| {
111                ApiError::Other(format!(
112                    "Profile '{name}' not found. Available: {}",
113                    if available.is_empty() {
114                        "none".to_owned()
115                    } else {
116                        available.join(", ")
117                    }
118                ))
119            })
120        }
121    }
122}
123
124fn env_var(name: &str) -> Option<String> {
125    normalize(std::env::var(name).ok())
126}
127
128fn normalize(value: Option<String>) -> Option<String> {
129    value.and_then(|v| {
130        let t = v.trim().to_owned();
131        if t.is_empty() { None } else { Some(t) }
132    })
133}
134
135/// Write (or overwrite) a single profile in the config file, preserving other profiles.
136///
137/// Creates the config directory and file if they don't exist, then sets
138/// permissions to 0600 on unix.
139pub fn write_profile(
140    path: &Path,
141    profile_name: &str,
142    account_id: &str,
143    client_id: &str,
144    client_secret: &str,
145) -> Result<(), ApiError> {
146    let content = match std::fs::read_to_string(path) {
147        Ok(c) => c,
148        Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
149        Err(e) => return Err(ApiError::Other(format!("Failed to read config: {e}"))),
150    };
151
152    let mut table: toml::Table = if content.trim().is_empty() {
153        toml::Table::new()
154    } else {
155        toml::from_str(&content)
156            .map_err(|e| ApiError::Other(format!("Failed to parse config: {e}")))?
157    };
158
159    let mut profile = toml::Table::new();
160    profile.insert("account_id".into(), toml::Value::String(account_id.to_owned()));
161    profile.insert("client_id".into(), toml::Value::String(client_id.to_owned()));
162    profile.insert("client_secret".into(), toml::Value::String(client_secret.to_owned()));
163    table.insert(profile_name.to_owned(), toml::Value::Table(profile));
164
165    if let Some(parent) = path.parent() {
166        std::fs::create_dir_all(parent)
167            .map_err(|e| ApiError::Other(format!("Cannot create config directory: {e}")))?;
168    }
169
170    let serialized = toml::to_string_pretty(&table)
171        .map_err(|e| ApiError::Other(format!("Failed to serialize config: {e}")))?;
172    std::fs::write(path, serialized)
173        .map_err(|e| ApiError::Other(format!("Failed to write config: {e}")))?;
174
175    #[cfg(unix)]
176    {
177        use std::os::unix::fs::PermissionsExt;
178        let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600));
179    }
180
181    Ok(())
182}
183
184pub fn schema_config_path_description() -> &'static str {
185    #[cfg(not(target_os = "windows"))]
186    {
187        "~/.config/zoom-cli/config.toml (or $XDG_CONFIG_HOME/zoom-cli/config.toml)"
188    }
189    #[cfg(target_os = "windows")]
190    {
191        "%APPDATA%\\zoom-cli\\config.toml"
192    }
193}
194
195#[cfg(test)]
196mod tests {
197    use super::*;
198    use crate::test_support::{EnvVarGuard, ProcessEnvLock, set_config_dir_env, write_config};
199    use tempfile::TempDir;
200
201    fn clear_zoom_env() -> (EnvVarGuard, EnvVarGuard, EnvVarGuard, EnvVarGuard) {
202        (
203            EnvVarGuard::unset("ZOOM_ACCOUNT_ID"),
204            EnvVarGuard::unset("ZOOM_CLIENT_ID"),
205            EnvVarGuard::unset("ZOOM_CLIENT_SECRET"),
206            EnvVarGuard::unset("ZOOM_PROFILE"),
207        )
208    }
209
210    #[test]
211    fn load_reads_default_profile_from_file() {
212        let _lock = ProcessEnvLock::acquire().unwrap();
213        let dir = TempDir::new().unwrap();
214        write_config(
215            dir.path(),
216            r#"
217[default]
218account_id = "acct-001"
219client_id = "cid-001"
220client_secret = "csec-001"
221"#,
222        )
223        .unwrap();
224
225        let _cfg_dir = set_config_dir_env(dir.path());
226        let _env = clear_zoom_env();
227
228        let cfg = Config::load(None).unwrap();
229        assert_eq!(cfg.account_id, "acct-001");
230        assert_eq!(cfg.client_id, "cid-001");
231        assert_eq!(cfg.client_secret, "csec-001");
232    }
233
234    #[test]
235    fn load_env_vars_override_file() {
236        let _lock = ProcessEnvLock::acquire().unwrap();
237        let dir = TempDir::new().unwrap();
238        write_config(
239            dir.path(),
240            r#"
241[default]
242account_id = "file-account"
243client_id = "file-client"
244client_secret = "file-secret"
245"#,
246        )
247        .unwrap();
248
249        let _cfg_dir = set_config_dir_env(dir.path());
250        let _acct = EnvVarGuard::set("ZOOM_ACCOUNT_ID", "env-account");
251        let _cid = EnvVarGuard::unset("ZOOM_CLIENT_ID");
252        let _csec = EnvVarGuard::unset("ZOOM_CLIENT_SECRET");
253        let _prof = EnvVarGuard::unset("ZOOM_PROFILE");
254
255        let cfg = Config::load(None).unwrap();
256        assert_eq!(cfg.account_id, "env-account", "env var must win over file");
257        assert_eq!(
258            cfg.client_id, "file-client",
259            "file value used when env absent"
260        );
261    }
262
263    #[test]
264    fn load_blank_env_vars_fall_back_to_file() {
265        let _lock = ProcessEnvLock::acquire().unwrap();
266        let dir = TempDir::new().unwrap();
267        write_config(
268            dir.path(),
269            r#"
270[default]
271account_id = "acct"
272client_id = "cid"
273client_secret = "csec"
274"#,
275        )
276        .unwrap();
277
278        let _cfg_dir = set_config_dir_env(dir.path());
279        let _acct = EnvVarGuard::set("ZOOM_ACCOUNT_ID", "   ");
280        let _cid = EnvVarGuard::set("ZOOM_CLIENT_ID", "");
281        let _csec = EnvVarGuard::unset("ZOOM_CLIENT_SECRET");
282        let _prof = EnvVarGuard::unset("ZOOM_PROFILE");
283
284        let cfg = Config::load(None).unwrap();
285        assert_eq!(cfg.account_id, "acct");
286        assert_eq!(cfg.client_id, "cid");
287    }
288
289    #[test]
290    fn load_missing_credentials_returns_error() {
291        let _lock = ProcessEnvLock::acquire().unwrap();
292        let dir = TempDir::new().unwrap();
293        let _cfg_dir = set_config_dir_env(dir.path());
294        let _env = clear_zoom_env();
295
296        let err = Config::load(None).unwrap_err();
297        assert!(matches!(err, ApiError::InvalidInput(_)));
298        assert!(err.to_string().contains("account_id"));
299    }
300
301    #[test]
302    fn load_named_profile_from_file() {
303        let _lock = ProcessEnvLock::acquire().unwrap();
304        let dir = TempDir::new().unwrap();
305        write_config(
306            dir.path(),
307            r#"
308[default]
309account_id = "def-acct"
310client_id = "def-cid"
311client_secret = "def-csec"
312
313[work]
314account_id = "work-acct"
315client_id = "work-cid"
316client_secret = "work-csec"
317"#,
318        )
319        .unwrap();
320
321        let _cfg_dir = set_config_dir_env(dir.path());
322        let _env = clear_zoom_env();
323
324        let cfg = Config::load(Some("work".into())).unwrap();
325        assert_eq!(cfg.account_id, "work-acct");
326        assert_eq!(cfg.client_id, "work-cid");
327    }
328
329    #[test]
330    fn load_zoom_profile_env_selects_named_profile() {
331        let _lock = ProcessEnvLock::acquire().unwrap();
332        let dir = TempDir::new().unwrap();
333        write_config(
334            dir.path(),
335            r#"
336[default]
337account_id = "def-acct"
338client_id = "def-cid"
339client_secret = "def-csec"
340
341[staging]
342account_id = "staging-acct"
343client_id = "staging-cid"
344client_secret = "staging-csec"
345"#,
346        )
347        .unwrap();
348
349        let _cfg_dir = set_config_dir_env(dir.path());
350        let _acct = EnvVarGuard::unset("ZOOM_ACCOUNT_ID");
351        let _cid = EnvVarGuard::unset("ZOOM_CLIENT_ID");
352        let _csec = EnvVarGuard::unset("ZOOM_CLIENT_SECRET");
353        let _prof = EnvVarGuard::set("ZOOM_PROFILE", "staging");
354
355        let cfg = Config::load(None).unwrap();
356        assert_eq!(cfg.account_id, "staging-acct");
357    }
358
359    #[test]
360    fn load_unknown_profile_returns_descriptive_error() {
361        let _lock = ProcessEnvLock::acquire().unwrap();
362        let dir = TempDir::new().unwrap();
363        write_config(
364            dir.path(),
365            r#"
366[work]
367account_id = "w-acct"
368client_id = "w-cid"
369client_secret = "w-csec"
370"#,
371        )
372        .unwrap();
373
374        let _cfg_dir = set_config_dir_env(dir.path());
375        let _env = clear_zoom_env();
376
377        let err = Config::load(Some("nonexistent".into())).unwrap_err();
378        let msg = err.to_string();
379        assert!(msg.contains("nonexistent"));
380        assert!(msg.contains("work"), "error should list available profiles");
381    }
382
383    #[test]
384    fn load_invalid_toml_returns_error() {
385        let _lock = ProcessEnvLock::acquire().unwrap();
386        let dir = TempDir::new().unwrap();
387        write_config(dir.path(), "account_id = [invalid").unwrap();
388
389        let _cfg_dir = set_config_dir_env(dir.path());
390        let _env = clear_zoom_env();
391
392        let err = Config::load(None).unwrap_err();
393        assert!(matches!(err, ApiError::Other(_)));
394        assert!(err.to_string().contains("parse"));
395    }
396
397    #[test]
398    fn missing_config_file_yields_informative_missing_field_error() {
399        let _lock = ProcessEnvLock::acquire().unwrap();
400        let dir = TempDir::new().unwrap();
401        let _cfg_dir = set_config_dir_env(dir.path());
402        let _env = clear_zoom_env();
403
404        let err = Config::load(None).unwrap_err();
405        assert!(matches!(err, ApiError::InvalidInput(_)));
406    }
407}