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