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 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    /// Desktop only: sending is an explicit opt-in, so profiles without it stay read-only.
150    #[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    /// Set only after opting in; native Teams and automation never auto-connect.
159    #[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
257/// Resolve desktop lifecycle settings while still allowing an unconfigured explicit client.
258pub 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        // On desktop the variable can only add the restriction, never lift it.
299        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
334/// Resolve the profile a command would use without requiring that profile to
335/// be configured. Interactive recovery screens need the name even on first
336/// use, before there is a profile for [`load`] to return.
337pub 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
345/// Resolve backend selection without consulting OAuth configuration or credentials.
346pub 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}