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
47pub fn path() -> PathBuf {
48 if let Some(base) = std::env::var_os("XDG_CONFIG_HOME").filter(|v| !v.is_empty()) {
49 PathBuf::from(base).join("teams").join("config.toml")
50 } else {
51 config_base().join("teams").join("config.toml")
52 }
53}
54
55pub fn exists() -> bool {
56 path().is_file()
57}
58
59pub fn save(profile_name: &str, profile: Profile) -> Result<PathBuf, AppError> {
60 let path = path();
61 let mut config = read_file()?;
62 config.profiles.insert(profile_name.to_string(), profile);
63 config.active_profile = Some(profile_name.to_string());
64 let parent = path
65 .parent()
66 .ok_or_else(|| AppError::Unexpected("configuration path has no parent".into()))?;
67 std::fs::create_dir_all(parent)?;
68 let body = toml::to_string_pretty(&config).map_err(|e| AppError::Unexpected(e.to_string()))?;
69 let mut temp = tempfile::Builder::new()
70 .prefix(".config-")
71 .suffix(".toml.tmp")
72 .tempfile_in(parent)?;
73 temp.write_all(body.as_bytes())?;
74 temp.flush()?;
75 #[cfg(unix)]
76 {
77 use std::os::unix::fs::PermissionsExt;
78 std::fs::set_permissions(temp.path(), std::fs::Permissions::from_mode(0o600))?;
79 }
80 temp.persist(&path).map_err(|error| error.error)?;
81 Ok(path)
82}
83
84pub fn load(requested: Option<&str>) -> Result<(String, Profile), AppError> {
85 let config = read_file()?;
86 let name = selected_profile_name_from(requested, config.active_profile.as_deref());
87 let stored = config.profiles.get(&name);
88 let client_id = std::env::var("TEAMS_CLIENT_ID")
89 .ok()
90 .or_else(|| stored.map(|profile| profile.client_id.clone()))
91 .ok_or_else(|| {
92 AppError::InvalidInput(format!(
93 "profile '{name}' is not configured; run `teams init` or set TEAMS_CLIENT_ID"
94 ))
95 })?;
96 let tenant = std::env::var("TEAMS_TENANT")
97 .ok()
98 .or_else(|| stored.map(|profile| profile.tenant.clone()))
99 .unwrap_or_else(default_tenant);
100 let read_only = match std::env::var("TEAMS_READ_ONLY") {
101 Ok(value) => parse_bool("TEAMS_READ_ONLY", &value)?,
102 Err(std::env::VarError::NotPresent) => stored.is_some_and(|profile| profile.read_only),
103 Err(error) => {
104 return Err(AppError::InvalidInput(format!(
105 "cannot read TEAMS_READ_ONLY: {error}"
106 )));
107 }
108 };
109 let profile = Profile {
110 client_id,
111 tenant,
112 read_only,
113 };
114 Ok((name, profile))
115}
116
117pub fn selected_profile_name(requested: Option<&str>) -> Result<String, AppError> {
121 let config = read_file()?;
122 Ok(selected_profile_name_from(
123 requested,
124 config.active_profile.as_deref(),
125 ))
126}
127
128fn selected_profile_name_from(requested: Option<&str>, active: Option<&str>) -> String {
129 requested
130 .map(str::to_owned)
131 .or_else(|| std::env::var("TEAMS_PROFILE").ok())
132 .or_else(|| active.map(str::to_owned))
133 .unwrap_or_else(|| "default".into())
134}
135
136fn parse_bool(name: &str, value: &str) -> Result<bool, AppError> {
137 match value.trim().to_ascii_lowercase().as_str() {
138 "1" | "true" | "yes" | "on" => Ok(true),
139 "0" | "false" | "no" | "off" => Ok(false),
140 _ => Err(AppError::InvalidInput(format!(
141 "{name} must be true or false"
142 ))),
143 }
144}
145
146#[cfg(windows)]
147fn config_base() -> PathBuf {
148 dirs::config_dir().unwrap_or_else(|| PathBuf::from(".config"))
149}
150
151#[cfg(not(windows))]
152fn config_base() -> PathBuf {
153 dirs::home_dir()
154 .map(|home| home.join(".config"))
155 .unwrap_or_else(|| PathBuf::from(".config"))
156}
157
158pub fn configured_profile(requested: Option<&str>) -> Option<(String, Profile)> {
159 load(requested).ok()
160}
161
162fn read_file() -> Result<ConfigFile, AppError> {
163 match std::fs::read_to_string(path()) {
164 Ok(body) => toml::from_str(&body)
165 .map_err(|e| AppError::InvalidInput(format!("cannot parse {}: {e}", path().display()))),
166 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(ConfigFile::default()),
167 Err(e) => Err(e.into()),
168 }
169}