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";
12
13#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct Profile {
15    pub client_id: String,
16    #[serde(default = "default_tenant")]
17    pub tenant: String,
18    #[serde(default)]
19    pub read_only: bool,
20}
21
22impl Profile {
23    pub fn require_writable(&self) -> Result<(), AppError> {
24        if self.read_only {
25            Err(AppError::ReadOnly(
26                "read-only mode is enabled (unset TEAMS_READ_ONLY or disable read_only in the active profile to allow writes)"
27                    .into(),
28            ))
29        } else {
30            Ok(())
31        }
32    }
33}
34
35fn default_tenant() -> String {
36    DEFAULT_TENANT.into()
37}
38
39#[derive(Debug, Default, Serialize, Deserialize)]
40struct ConfigFile {
41    #[serde(default)]
42    active_profile: Option<String>,
43    #[serde(default)]
44    profiles: BTreeMap<String, Profile>,
45}
46
47#[derive(Debug, Serialize)]
48pub struct ProfileSummary {
49    pub name: String,
50    pub active: bool,
51    pub tenant: String,
52    pub client_id: String,
53    pub read_only: bool,
54}
55
56pub fn path() -> PathBuf {
57    if let Some(base) = std::env::var_os("XDG_CONFIG_HOME").filter(|v| !v.is_empty()) {
58        PathBuf::from(base).join("teams").join("config.toml")
59    } else {
60        config_base().join("teams").join("config.toml")
61    }
62}
63
64pub fn exists() -> bool {
65    path().is_file()
66}
67
68pub fn save(profile_name: &str, profile: Profile) -> Result<PathBuf, AppError> {
69    let path = path();
70    let mut config = read_file()?;
71    config.profiles.insert(profile_name.to_string(), profile);
72    config.active_profile = Some(profile_name.to_string());
73    write_file(&path, &config)?;
74    Ok(path)
75}
76
77fn write_file(path: &std::path::Path, config: &ConfigFile) -> Result<(), AppError> {
78    let parent = path
79        .parent()
80        .ok_or_else(|| AppError::Unexpected("configuration path has no parent".into()))?;
81    std::fs::create_dir_all(parent)?;
82    let body = toml::to_string_pretty(&config).map_err(|e| AppError::Unexpected(e.to_string()))?;
83    let mut temp = tempfile::Builder::new()
84        .prefix(".config-")
85        .suffix(".toml.tmp")
86        .tempfile_in(parent)?;
87    temp.write_all(body.as_bytes())?;
88    temp.flush()?;
89    #[cfg(unix)]
90    {
91        use std::os::unix::fs::PermissionsExt;
92        std::fs::set_permissions(temp.path(), std::fs::Permissions::from_mode(0o600))?;
93    }
94    temp.persist(path).map_err(|error| error.error)?;
95    Ok(())
96}
97
98pub fn load(requested: Option<&str>) -> Result<(String, Profile), AppError> {
99    let config = read_file()?;
100    let name = selected_profile_name_from(requested, config.active_profile.as_deref());
101    let stored = config.profiles.get(&name);
102    let client_id = std::env::var("TEAMS_CLIENT_ID")
103        .ok()
104        .or_else(|| stored.map(|profile| profile.client_id.clone()))
105        .ok_or_else(|| {
106            AppError::InvalidInput(format!(
107                "profile '{name}' is not configured; run `teams init` or set TEAMS_CLIENT_ID"
108            ))
109        })?;
110    let tenant = std::env::var("TEAMS_TENANT")
111        .ok()
112        .or_else(|| stored.map(|profile| profile.tenant.clone()))
113        .unwrap_or_else(default_tenant);
114    let read_only = match std::env::var("TEAMS_READ_ONLY") {
115        Ok(value) => parse_bool("TEAMS_READ_ONLY", &value)?,
116        Err(std::env::VarError::NotPresent) => stored.is_some_and(|profile| profile.read_only),
117        Err(error) => {
118            return Err(AppError::InvalidInput(format!(
119                "cannot read TEAMS_READ_ONLY: {error}"
120            )));
121        }
122    };
123    let profile = Profile {
124        client_id,
125        tenant,
126        read_only,
127    };
128    Ok((name, profile))
129}
130
131/// Resolve the profile a command would use without requiring that profile to
132/// be configured. Interactive recovery screens need the name even on first
133/// use, before there is a profile for [`load`] to return.
134pub fn selected_profile_name(requested: Option<&str>) -> Result<String, AppError> {
135    let config = read_file()?;
136    Ok(selected_profile_name_from(
137        requested,
138        config.active_profile.as_deref(),
139    ))
140}
141
142fn selected_profile_name_from(requested: Option<&str>, active: Option<&str>) -> String {
143    requested
144        .map(str::to_owned)
145        .or_else(|| std::env::var("TEAMS_PROFILE").ok())
146        .or_else(|| active.map(str::to_owned))
147        .unwrap_or_else(|| "default".into())
148}
149
150fn parse_bool(name: &str, value: &str) -> Result<bool, AppError> {
151    match value.trim().to_ascii_lowercase().as_str() {
152        "1" | "true" | "yes" | "on" => Ok(true),
153        "0" | "false" | "no" | "off" => Ok(false),
154        _ => Err(AppError::InvalidInput(format!(
155            "{name} must be true or false"
156        ))),
157    }
158}
159
160#[cfg(windows)]
161fn config_base() -> PathBuf {
162    dirs::config_dir().unwrap_or_else(|| PathBuf::from(".config"))
163}
164
165#[cfg(not(windows))]
166fn config_base() -> PathBuf {
167    dirs::home_dir()
168        .map(|home| home.join(".config"))
169        .unwrap_or_else(|| PathBuf::from(".config"))
170}
171
172pub fn configured_profile(requested: Option<&str>) -> Option<(String, Profile)> {
173    load(requested).ok()
174}
175
176pub fn profile_summaries() -> Result<Vec<ProfileSummary>, AppError> {
177    let config = read_file()?;
178    Ok(config
179        .profiles
180        .iter()
181        .map(|(name, profile)| ProfileSummary {
182            name: name.clone(),
183            active: config.active_profile.as_deref() == Some(name.as_str()),
184            tenant: profile.tenant.clone(),
185            client_id: profile.client_id.clone(),
186            read_only: profile.read_only,
187        })
188        .collect())
189}
190
191pub fn use_profile(name: &str) -> Result<(), AppError> {
192    let path = path();
193    let mut config = read_file()?;
194    if !config.profiles.contains_key(name) {
195        return Err(AppError::InvalidInput(format!(
196            "profile '{name}' is not configured"
197        )));
198    }
199    config.active_profile = Some(name.into());
200    write_file(&path, &config)
201}
202
203pub fn remove_profile(name: &str) -> Result<bool, AppError> {
204    let path = path();
205    let mut config = read_file()?;
206    let removed = config.profiles.remove(name).is_some();
207    if removed {
208        if config.active_profile.as_deref() == Some(name) {
209            config.active_profile = config.profiles.keys().next().cloned();
210        }
211        write_file(&path, &config)?;
212    }
213    Ok(removed)
214}
215
216fn read_file() -> Result<ConfigFile, AppError> {
217    match std::fs::read_to_string(path()) {
218        Ok(body) => toml::from_str(&body)
219            .map_err(|e| AppError::InvalidInput(format!("cannot parse {}: {e}", path().display()))),
220        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(ConfigFile::default()),
221        Err(e) => Err(e.into()),
222    }
223}