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