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#[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 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
69pub 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
77pub struct ConfigSummary {
79 pub config_file: PathBuf,
80 pub file_exists: bool,
81 pub active_profile: String,
83 pub profiles: Vec<ProfileSummary>,
85 pub env_overrides: Vec<(&'static str, String)>,
88}
89
90pub 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
119pub 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 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 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
248pub 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 #[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}