1use std::collections::BTreeMap;
2use std::io::Write;
3use std::path::PathBuf;
4
5use serde::{Deserialize, Serialize};
6
7use crate::error::AppError;
8
9pub 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
131pub 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}