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.".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
135pub 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}