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
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    /// Desktop only: sending is an explicit opt-in, so profiles without it stay read-only.
76    #[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        // On desktop the variable can only add the restriction, never lift it.
209        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
240/// Resolve the profile a command would use without requiring that profile to
241/// be configured. Interactive recovery screens need the name even on first
242/// use, before there is a profile for [`load`] to return.
243pub 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
251/// Resolve backend selection without consulting OAuth configuration or credentials.
252pub 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}