1use std::collections::BTreeMap;
2use std::io::Write;
3use std::path::PathBuf;
4
5use serde::{Deserialize, Serialize};
6
7use crate::error::AppError;
8
9pub const DEFAULT_CLIENT_ID: &str = "66ebad71-1604-48fc-a086-0d4caa24988b";
11pub const DEFAULT_TENANT: &str = "organizations";
12pub const DEFAULT_DESKTOP_PORT: u16 = 9222;
13
14#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, clap::ValueEnum)]
15#[serde(rename_all = "lowercase")]
16pub enum BackendKind {
17 #[default]
18 Graph,
19 Desktop,
20}
21
22impl BackendKind {
23 pub fn as_str(self) -> &'static str {
24 match self {
25 Self::Graph => "graph",
26 Self::Desktop => "desktop",
27 }
28 }
29}
30
31#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, clap::ValueEnum)]
33#[serde(rename_all = "snake_case")]
34pub enum DesktopAccount {
35 #[default]
36 Auto,
37 Personal,
38 #[value(name = "work-school")]
39 WorkSchool,
40}
41
42#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, clap::ValueEnum)]
44#[serde(rename_all = "snake_case")]
45pub enum DesktopClientKind {
46 #[default]
47 Native,
48 Browser,
49 BrowserCore,
50}
51
52impl DesktopClientKind {
53 pub fn validate_account(self, account: DesktopAccount) -> Result<(), AppError> {
54 if self == Self::BrowserCore && account != DesktopAccount::Personal {
55 return Err(AppError::Unsupported("The lightweight browser supports personal accounts only. For work/school, use --desktop-client browser --desktop-account work-school.".into()));
56 }
57 Ok(())
58 }
59 pub fn default_port(self) -> u16 {
60 if self == Self::BrowserCore {
61 9223
62 } else {
63 DEFAULT_DESKTOP_PORT
64 }
65 }
66 pub fn default_account(self) -> DesktopAccount {
67 if self == Self::BrowserCore {
68 DesktopAccount::Personal
69 } else {
70 DesktopAccount::Auto
71 }
72 }
73 pub fn is_browser(self) -> bool {
74 matches!(self, Self::Browser | Self::BrowserCore)
75 }
76
77 pub fn as_str(self) -> &'static str {
78 match self {
79 Self::Native => "native",
80 Self::Browser => "browser",
81 Self::BrowserCore => "browser-core",
82 }
83 }
84}
85
86impl DesktopAccount {
87 pub fn as_str(self) -> &'static str {
88 match self {
89 Self::Auto => "auto",
90 Self::Personal => "personal",
91 Self::WorkSchool => "work_school",
92 }
93 }
94
95 #[cfg(any(target_os = "macos", target_os = "linux", windows, test))]
96 pub(crate) fn from_request(value: &serde_json::Value) -> Result<Self, AppError> {
97 match value.as_str() {
98 None if value.is_null() => Ok(Self::Auto),
99 Some("auto") => Ok(Self::Auto),
100 Some("personal") => Ok(Self::Personal),
101 Some("work_school") => Ok(Self::WorkSchool),
102 _ => Err(AppError::InvalidInput(
103 "invalid desktop account type".into(),
104 )),
105 }
106 }
107}
108
109#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
110#[serde(deny_unknown_fields)]
111pub struct PendingBrowserSetup {
112 pub name: String,
113 pub port: u16,
114 pub account: DesktopAccount,
115 pub client: DesktopClientKind,
116}
117
118#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, clap::ValueEnum)]
119#[serde(rename_all = "snake_case")]
120pub enum CredentialStore {
121 #[default]
122 Native,
123 Pass,
124}
125
126impl CredentialStore {
127 pub fn as_str(self) -> &'static str {
128 match self {
129 Self::Native => "native",
130 Self::Pass => "pass",
131 }
132 }
133}
134
135#[derive(Debug, Clone, Serialize, Deserialize)]
136pub struct Profile {
137 #[serde(default)]
138 pub credential_store: CredentialStore,
139 #[serde(default, skip_serializing_if = "Option::is_none")]
140 pub pass_store_dir: Option<PathBuf>,
141 #[serde(default)]
142 pub backend: BackendKind,
143 #[serde(default)]
144 pub client_id: String,
145 #[serde(default = "default_tenant")]
146 pub tenant: String,
147 #[serde(default)]
148 pub read_only: bool,
149 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
151 pub allow_send: bool,
152 #[serde(default = "default_desktop_port")]
153 pub desktop_port: u16,
154 #[serde(default)]
155 pub desktop_account: DesktopAccount,
156 #[serde(default)]
157 pub desktop_client: DesktopClientKind,
158 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
160 pub browser_auto_start: bool,
161}
162
163impl Profile {
164 pub fn require_graph(&self) -> Result<(), AppError> {
165 if self.backend == BackendKind::Desktop {
166 Err(crate::desktop::messaging_unsupported())
167 } else {
168 Ok(())
169 }
170 }
171
172 pub fn require_writable(&self) -> Result<(), AppError> {
173 if self.read_only && self.backend == BackendKind::Desktop {
174 return Err(AppError::ReadOnly(
175 "desktop profiles are read-only unless created with `teams init --backend desktop --allow-send`".into(),
176 ));
177 }
178 if self.read_only {
179 Err(AppError::ReadOnly(
180 "read-only mode is enabled (unset TEAMS_READ_ONLY or disable read_only in the active profile to allow writes)"
181 .into(),
182 ))
183 } else {
184 Ok(())
185 }
186 }
187}
188
189fn default_tenant() -> String {
190 DEFAULT_TENANT.into()
191}
192
193fn default_desktop_port() -> u16 {
194 DEFAULT_DESKTOP_PORT
195}
196
197#[derive(Debug, Default, Serialize, Deserialize)]
198struct ConfigFile {
199 #[serde(default)]
200 active_profile: Option<String>,
201 #[serde(default)]
202 profiles: BTreeMap<String, Profile>,
203}
204
205#[derive(Debug, Serialize)]
206pub struct ProfileSummary {
207 pub name: String,
208 pub active: bool,
209 pub tenant: String,
210 pub client_id: String,
211 pub read_only: bool,
212 pub backend: BackendKind,
213}
214
215pub fn path() -> PathBuf {
216 if let Some(base) = std::env::var_os("XDG_CONFIG_HOME").filter(|v| !v.is_empty()) {
217 PathBuf::from(base).join("teams").join("config.toml")
218 } else {
219 config_base().join("teams").join("config.toml")
220 }
221}
222
223pub fn exists() -> bool {
224 path().is_file()
225}
226
227pub fn save(profile_name: &str, profile: Profile) -> Result<PathBuf, AppError> {
228 let path = path();
229 let mut config = read_file()?;
230 config.profiles.insert(profile_name.to_string(), profile);
231 config.active_profile = Some(profile_name.to_string());
232 write_file(&path, &config)?;
233 Ok(path)
234}
235
236fn write_file(path: &std::path::Path, config: &ConfigFile) -> Result<(), AppError> {
237 let parent = path
238 .parent()
239 .ok_or_else(|| AppError::Unexpected("configuration path has no parent".into()))?;
240 std::fs::create_dir_all(parent)?;
241 let body = toml::to_string_pretty(&config).map_err(|e| AppError::Unexpected(e.to_string()))?;
242 let mut temp = tempfile::Builder::new()
243 .prefix(".config-")
244 .suffix(".toml.tmp")
245 .tempfile_in(parent)?;
246 temp.write_all(body.as_bytes())?;
247 temp.flush()?;
248 #[cfg(unix)]
249 {
250 use std::os::unix::fs::PermissionsExt;
251 std::fs::set_permissions(temp.path(), std::fs::Permissions::from_mode(0o600))?;
252 }
253 temp.persist(path).map_err(|error| error.error)?;
254 Ok(())
255}
256
257pub fn connection_profile(requested: Option<&str>) -> Result<Option<Profile>, AppError> {
259 let config = read_file()?;
260 let name = selected_profile_name_from(requested, config.active_profile.as_deref());
261 Ok(config.profiles.get(&name).cloned())
262}
263
264pub fn profiles() -> Result<BTreeMap<String, Profile>, AppError> {
265 Ok(read_file()?.profiles)
266}
267
268pub fn load(requested: Option<&str>) -> Result<(String, Profile), AppError> {
269 let config = read_file()?;
270 let name = selected_profile_name_from(requested, config.active_profile.as_deref());
271 let stored = config.profiles.get(&name);
272 let backend = stored.map(|p| p.backend).unwrap_or_default();
273 let client_id = if backend == BackendKind::Desktop {
274 String::new()
275 } else {
276 std::env::var("TEAMS_CLIENT_ID")
277 .ok()
278 .or_else(|| stored.map(|profile| profile.client_id.clone()))
279 .filter(|id| !id.trim().is_empty())
280 .ok_or_else(|| {
281 AppError::InvalidInput(format!(
282 "profile '{name}' is not configured; run `teams init` or set TEAMS_CLIENT_ID"
283 ))
284 })?
285 };
286 let tenant = if backend == BackendKind::Desktop {
287 String::new()
288 } else {
289 std::env::var("TEAMS_TENANT")
290 .ok()
291 .or_else(|| stored.map(|profile| profile.tenant.clone()))
292 .unwrap_or_else(default_tenant)
293 };
294 let stored_read_only = stored.is_some_and(|profile| {
295 profile.read_only || (backend == BackendKind::Desktop && !profile.allow_send)
296 });
297 let read_only = match std::env::var("TEAMS_READ_ONLY") {
298 Ok(value) if backend == BackendKind::Desktop => {
300 parse_bool("TEAMS_READ_ONLY", &value)? || stored_read_only
301 }
302 Ok(value) => parse_bool("TEAMS_READ_ONLY", &value)?,
303 Err(std::env::VarError::NotPresent) => stored_read_only,
304 Err(error) => {
305 return Err(AppError::InvalidInput(format!(
306 "cannot read TEAMS_READ_ONLY: {error}"
307 )));
308 }
309 };
310 let desktop_port = stored
311 .map(|p| p.desktop_port)
312 .unwrap_or(DEFAULT_DESKTOP_PORT);
313 if backend == BackendKind::Desktop && desktop_port < 1024 {
314 return Err(AppError::InvalidInput(
315 "desktop_port must be between 1024 and 65535".into(),
316 ));
317 }
318 let profile = Profile {
319 credential_store: stored.map(|p| p.credential_store).unwrap_or_default(),
320 pass_store_dir: stored.and_then(|p| p.pass_store_dir.clone()),
321 backend,
322 client_id,
323 tenant,
324 read_only,
325 allow_send: backend == BackendKind::Desktop && !read_only,
326 desktop_port,
327 desktop_account: stored.map(|p| p.desktop_account).unwrap_or_default(),
328 desktop_client: stored.map(|p| p.desktop_client).unwrap_or_default(),
329 browser_auto_start: stored.is_some_and(|p| p.browser_auto_start),
330 };
331 Ok((name, profile))
332}
333
334pub fn selected_profile_name(requested: Option<&str>) -> Result<String, AppError> {
338 let config = read_file()?;
339 Ok(selected_profile_name_from(
340 requested,
341 config.active_profile.as_deref(),
342 ))
343}
344
345pub fn selected_backend(requested: Option<&str>) -> Result<BackendKind, AppError> {
347 let config = read_file()?;
348 let name = selected_profile_name_from(requested, config.active_profile.as_deref());
349 Ok(config
350 .profiles
351 .get(&name)
352 .map(|p| p.backend)
353 .unwrap_or_default())
354}
355
356fn selected_profile_name_from(requested: Option<&str>, active: Option<&str>) -> String {
357 requested
358 .map(str::to_owned)
359 .or_else(|| std::env::var("TEAMS_PROFILE").ok())
360 .or_else(|| active.map(str::to_owned))
361 .unwrap_or_else(|| "default".into())
362}
363
364fn parse_bool(name: &str, value: &str) -> Result<bool, AppError> {
365 match value.trim().to_ascii_lowercase().as_str() {
366 "1" | "true" | "yes" | "on" => Ok(true),
367 "0" | "false" | "no" | "off" => Ok(false),
368 _ => Err(AppError::InvalidInput(format!(
369 "{name} must be true or false"
370 ))),
371 }
372}
373
374#[cfg(windows)]
375fn config_base() -> PathBuf {
376 dirs::config_dir().unwrap_or_else(|| PathBuf::from(".config"))
377}
378
379#[cfg(not(windows))]
380fn config_base() -> PathBuf {
381 dirs::home_dir()
382 .map(|home| home.join(".config"))
383 .unwrap_or_else(|| PathBuf::from(".config"))
384}
385
386pub fn configured_profile(requested: Option<&str>) -> Option<(String, Profile)> {
387 load(requested).ok()
388}
389
390pub fn profile_summaries() -> Result<Vec<ProfileSummary>, AppError> {
391 let config = read_file()?;
392 Ok(config
393 .profiles
394 .iter()
395 .map(|(name, profile)| ProfileSummary {
396 name: name.clone(),
397 active: config.active_profile.as_deref() == Some(name.as_str()),
398 tenant: if profile.backend == BackendKind::Desktop {
399 String::new()
400 } else {
401 profile.tenant.clone()
402 },
403 client_id: if profile.backend == BackendKind::Desktop {
404 String::new()
405 } else {
406 profile.client_id.clone()
407 },
408 read_only: profile.read_only
409 || (profile.backend == BackendKind::Desktop && !profile.allow_send),
410 backend: profile.backend,
411 })
412 .collect())
413}
414
415pub fn use_profile(name: &str) -> Result<(), AppError> {
416 let path = path();
417 let mut config = read_file()?;
418 if !config.profiles.contains_key(name) {
419 return Err(AppError::InvalidInput(format!(
420 "profile '{name}' is not configured"
421 )));
422 }
423 config.active_profile = Some(name.into());
424 write_file(&path, &config)
425}
426
427pub fn remove_profile(name: &str) -> Result<bool, AppError> {
428 let path = path();
429 let mut config = read_file()?;
430 let removed = config.profiles.remove(name).is_some();
431 if removed {
432 if config.active_profile.as_deref() == Some(name) {
433 config.active_profile = config.profiles.keys().next().cloned();
434 }
435 write_file(&path, &config)?;
436 }
437 Ok(removed)
438}
439
440fn read_file() -> Result<ConfigFile, AppError> {
441 match std::fs::read_to_string(path()) {
442 Ok(body) => toml::from_str(&body)
443 .map_err(|e| AppError::InvalidInput(format!("cannot parse {}: {e}", path().display()))),
444 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(ConfigFile::default()),
445 Err(e) => Err(e.into()),
446 }
447}