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#[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
190/// Resolve the profile a command would use without requiring that profile to
191/// be configured. Interactive recovery screens need the name even on first
192/// use, before there is a profile for [`load`] to return.
193pub 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
201/// Resolve backend selection without consulting OAuth configuration or credentials.
202pub 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}