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 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
254pub 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
314pub 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 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 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}