Skip to main content

teams_cli/
config.rs

1use std::collections::BTreeMap;
2use std::io::Write;
3use std::path::PathBuf;
4
5use serde::{Deserialize, Serialize};
6
7use crate::error::AppError;
8
9/// The maintained, multitenant public-client registration shipped with teams-cli.
10pub 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/// Selects an account surface, never a cached account within that surface.
32#[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/// Browser sessions use a dedicated profile rather than the installed Teams app.
43#[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    /// Desktop only: sending is an explicit opt-in, so profiles without it stay read-only.
114    #[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
218/// Resolve desktop lifecycle settings while still allowing an unconfigured explicit client.
219pub 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        // On desktop the variable can only add the restriction, never lift it.
256        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
288/// Resolve the profile a command would use without requiring that profile to
289/// be configured. Interactive recovery screens need the name even on first
290/// use, before there is a profile for [`load`] to return.
291pub 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
299/// Resolve backend selection without consulting OAuth configuration or credentials.
300pub 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}