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 default_port(self) -> u16 {
54 if self == Self::BrowserCore {
55 9223
56 } else {
57 DEFAULT_DESKTOP_PORT
58 }
59 }
60 pub fn default_account(self) -> DesktopAccount {
61 if self == Self::BrowserCore {
62 DesktopAccount::Personal
63 } else {
64 DesktopAccount::Auto
65 }
66 }
67 pub fn is_browser(self) -> bool {
68 matches!(self, Self::Browser | Self::BrowserCore)
69 }
70
71 pub fn as_str(self) -> &'static str {
72 match self {
73 Self::Native => "native",
74 Self::Browser => "browser",
75 Self::BrowserCore => "browser-core",
76 }
77 }
78}
79
80impl DesktopAccount {
81 pub fn as_str(self) -> &'static str {
82 match self {
83 Self::Auto => "auto",
84 Self::Personal => "personal",
85 Self::WorkSchool => "work_school",
86 }
87 }
88
89 #[cfg(any(target_os = "macos", target_os = "linux", windows, test))]
90 pub(crate) fn from_request(value: &serde_json::Value) -> Result<Self, AppError> {
91 match value.as_str() {
92 None if value.is_null() => Ok(Self::Auto),
93 Some("auto") => Ok(Self::Auto),
94 Some("personal") => Ok(Self::Personal),
95 Some("work_school") => Ok(Self::WorkSchool),
96 _ => Err(AppError::InvalidInput(
97 "invalid desktop account type".into(),
98 )),
99 }
100 }
101}
102
103#[derive(Debug, Clone, Serialize, Deserialize)]
104pub struct Profile {
105 #[serde(default)]
106 pub backend: BackendKind,
107 #[serde(default)]
108 pub client_id: String,
109 #[serde(default = "default_tenant")]
110 pub tenant: String,
111 #[serde(default)]
112 pub read_only: bool,
113 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
115 pub allow_send: bool,
116 #[serde(default = "default_desktop_port")]
117 pub desktop_port: u16,
118 #[serde(default)]
119 pub desktop_account: DesktopAccount,
120 #[serde(default)]
121 pub desktop_client: DesktopClientKind,
122}
123
124impl Profile {
125 pub fn require_graph(&self) -> Result<(), AppError> {
126 if self.backend == BackendKind::Desktop {
127 Err(crate::desktop::messaging_unsupported())
128 } else {
129 Ok(())
130 }
131 }
132
133 pub fn require_writable(&self) -> Result<(), AppError> {
134 if self.read_only && self.backend == BackendKind::Desktop {
135 return Err(AppError::ReadOnly(
136 "desktop profiles are read-only unless created with `teams init --backend desktop --allow-send`".into(),
137 ));
138 }
139 if self.read_only {
140 Err(AppError::ReadOnly(
141 "read-only mode is enabled (unset TEAMS_READ_ONLY or disable read_only in the active profile to allow writes)"
142 .into(),
143 ))
144 } else {
145 Ok(())
146 }
147 }
148}
149
150fn default_tenant() -> String {
151 DEFAULT_TENANT.into()
152}
153
154fn default_desktop_port() -> u16 {
155 DEFAULT_DESKTOP_PORT
156}
157
158#[derive(Debug, Default, Serialize, Deserialize)]
159struct ConfigFile {
160 #[serde(default)]
161 active_profile: Option<String>,
162 #[serde(default)]
163 profiles: BTreeMap<String, Profile>,
164}
165
166#[derive(Debug, Serialize)]
167pub struct ProfileSummary {
168 pub name: String,
169 pub active: bool,
170 pub tenant: String,
171 pub client_id: String,
172 pub read_only: bool,
173 pub backend: BackendKind,
174}
175
176pub fn path() -> PathBuf {
177 if let Some(base) = std::env::var_os("XDG_CONFIG_HOME").filter(|v| !v.is_empty()) {
178 PathBuf::from(base).join("teams").join("config.toml")
179 } else {
180 config_base().join("teams").join("config.toml")
181 }
182}
183
184pub fn exists() -> bool {
185 path().is_file()
186}
187
188pub fn save(profile_name: &str, profile: Profile) -> Result<PathBuf, AppError> {
189 let path = path();
190 let mut config = read_file()?;
191 config.profiles.insert(profile_name.to_string(), profile);
192 config.active_profile = Some(profile_name.to_string());
193 write_file(&path, &config)?;
194 Ok(path)
195}
196
197fn write_file(path: &std::path::Path, config: &ConfigFile) -> Result<(), AppError> {
198 let parent = path
199 .parent()
200 .ok_or_else(|| AppError::Unexpected("configuration path has no parent".into()))?;
201 std::fs::create_dir_all(parent)?;
202 let body = toml::to_string_pretty(&config).map_err(|e| AppError::Unexpected(e.to_string()))?;
203 let mut temp = tempfile::Builder::new()
204 .prefix(".config-")
205 .suffix(".toml.tmp")
206 .tempfile_in(parent)?;
207 temp.write_all(body.as_bytes())?;
208 temp.flush()?;
209 #[cfg(unix)]
210 {
211 use std::os::unix::fs::PermissionsExt;
212 std::fs::set_permissions(temp.path(), std::fs::Permissions::from_mode(0o600))?;
213 }
214 temp.persist(path).map_err(|error| error.error)?;
215 Ok(())
216}
217
218pub fn connection_profile(requested: Option<&str>) -> Result<Option<Profile>, AppError> {
220 let config = read_file()?;
221 let name = selected_profile_name_from(requested, config.active_profile.as_deref());
222 Ok(config.profiles.get(&name).cloned())
223}
224
225pub fn load(requested: Option<&str>) -> Result<(String, Profile), AppError> {
226 let config = read_file()?;
227 let name = selected_profile_name_from(requested, config.active_profile.as_deref());
228 let stored = config.profiles.get(&name);
229 let backend = stored.map(|p| p.backend).unwrap_or_default();
230 let client_id = if backend == BackendKind::Desktop {
231 String::new()
232 } else {
233 std::env::var("TEAMS_CLIENT_ID")
234 .ok()
235 .or_else(|| stored.map(|profile| profile.client_id.clone()))
236 .filter(|id| !id.trim().is_empty())
237 .ok_or_else(|| {
238 AppError::InvalidInput(format!(
239 "profile '{name}' is not configured; run `teams init` or set TEAMS_CLIENT_ID"
240 ))
241 })?
242 };
243 let tenant = if backend == BackendKind::Desktop {
244 String::new()
245 } else {
246 std::env::var("TEAMS_TENANT")
247 .ok()
248 .or_else(|| stored.map(|profile| profile.tenant.clone()))
249 .unwrap_or_else(default_tenant)
250 };
251 let stored_read_only = stored.is_some_and(|profile| {
252 profile.read_only || (backend == BackendKind::Desktop && !profile.allow_send)
253 });
254 let read_only = match std::env::var("TEAMS_READ_ONLY") {
255 Ok(value) if backend == BackendKind::Desktop => {
257 parse_bool("TEAMS_READ_ONLY", &value)? || stored_read_only
258 }
259 Ok(value) => parse_bool("TEAMS_READ_ONLY", &value)?,
260 Err(std::env::VarError::NotPresent) => stored_read_only,
261 Err(error) => {
262 return Err(AppError::InvalidInput(format!(
263 "cannot read TEAMS_READ_ONLY: {error}"
264 )));
265 }
266 };
267 let desktop_port = stored
268 .map(|p| p.desktop_port)
269 .unwrap_or(DEFAULT_DESKTOP_PORT);
270 if backend == BackendKind::Desktop && desktop_port < 1024 {
271 return Err(AppError::InvalidInput(
272 "desktop_port must be between 1024 and 65535".into(),
273 ));
274 }
275 let profile = Profile {
276 backend,
277 client_id,
278 tenant,
279 read_only,
280 allow_send: backend == BackendKind::Desktop && !read_only,
281 desktop_port,
282 desktop_account: stored.map(|p| p.desktop_account).unwrap_or_default(),
283 desktop_client: stored.map(|p| p.desktop_client).unwrap_or_default(),
284 };
285 Ok((name, profile))
286}
287
288pub fn selected_profile_name(requested: Option<&str>) -> Result<String, AppError> {
292 let config = read_file()?;
293 Ok(selected_profile_name_from(
294 requested,
295 config.active_profile.as_deref(),
296 ))
297}
298
299pub fn selected_backend(requested: Option<&str>) -> Result<BackendKind, AppError> {
301 let config = read_file()?;
302 let name = selected_profile_name_from(requested, config.active_profile.as_deref());
303 Ok(config
304 .profiles
305 .get(&name)
306 .map(|p| p.backend)
307 .unwrap_or_default())
308}
309
310fn selected_profile_name_from(requested: Option<&str>, active: Option<&str>) -> String {
311 requested
312 .map(str::to_owned)
313 .or_else(|| std::env::var("TEAMS_PROFILE").ok())
314 .or_else(|| active.map(str::to_owned))
315 .unwrap_or_else(|| "default".into())
316}
317
318fn parse_bool(name: &str, value: &str) -> Result<bool, AppError> {
319 match value.trim().to_ascii_lowercase().as_str() {
320 "1" | "true" | "yes" | "on" => Ok(true),
321 "0" | "false" | "no" | "off" => Ok(false),
322 _ => Err(AppError::InvalidInput(format!(
323 "{name} must be true or false"
324 ))),
325 }
326}
327
328#[cfg(windows)]
329fn config_base() -> PathBuf {
330 dirs::config_dir().unwrap_or_else(|| PathBuf::from(".config"))
331}
332
333#[cfg(not(windows))]
334fn config_base() -> PathBuf {
335 dirs::home_dir()
336 .map(|home| home.join(".config"))
337 .unwrap_or_else(|| PathBuf::from(".config"))
338}
339
340pub fn configured_profile(requested: Option<&str>) -> Option<(String, Profile)> {
341 load(requested).ok()
342}
343
344pub fn profile_summaries() -> Result<Vec<ProfileSummary>, AppError> {
345 let config = read_file()?;
346 Ok(config
347 .profiles
348 .iter()
349 .map(|(name, profile)| ProfileSummary {
350 name: name.clone(),
351 active: config.active_profile.as_deref() == Some(name.as_str()),
352 tenant: if profile.backend == BackendKind::Desktop {
353 String::new()
354 } else {
355 profile.tenant.clone()
356 },
357 client_id: if profile.backend == BackendKind::Desktop {
358 String::new()
359 } else {
360 profile.client_id.clone()
361 },
362 read_only: profile.read_only
363 || (profile.backend == BackendKind::Desktop && !profile.allow_send),
364 backend: profile.backend,
365 })
366 .collect())
367}
368
369pub fn use_profile(name: &str) -> Result<(), AppError> {
370 let path = path();
371 let mut config = read_file()?;
372 if !config.profiles.contains_key(name) {
373 return Err(AppError::InvalidInput(format!(
374 "profile '{name}' is not configured"
375 )));
376 }
377 config.active_profile = Some(name.into());
378 write_file(&path, &config)
379}
380
381pub fn remove_profile(name: &str) -> Result<bool, AppError> {
382 let path = path();
383 let mut config = read_file()?;
384 let removed = config.profiles.remove(name).is_some();
385 if removed {
386 if config.active_profile.as_deref() == Some(name) {
387 config.active_profile = config.profiles.keys().next().cloned();
388 }
389 write_file(&path, &config)?;
390 }
391 Ok(removed)
392}
393
394fn read_file() -> Result<ConfigFile, AppError> {
395 match std::fs::read_to_string(path()) {
396 Ok(body) => toml::from_str(&body)
397 .map_err(|e| AppError::InvalidInput(format!("cannot parse {}: {e}", path().display()))),
398 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(ConfigFile::default()),
399 Err(e) => Err(e.into()),
400 }
401}