1use std::collections::BTreeMap;
9use std::io::Write;
10use std::path::{Path, PathBuf};
11
12use serde::{Deserialize, Serialize};
13
14use crate::error::{CliError, Result};
15
16pub const ENV_PROFILE: &str = "SHAREPOINT_PROFILE";
17pub const ENV_TENANT: &str = "SHAREPOINT_TENANT_ID";
18pub const ENV_CLIENT_ID: &str = "SHAREPOINT_CLIENT_ID";
19pub const ENV_DEFAULT_SITE: &str = "SHAREPOINT_DEFAULT_SITE";
20pub const ENV_READ_ONLY: &str = "SHAREPOINT_READ_ONLY";
21pub const ENV_ACCESS_TOKEN: &str = "SHAREPOINT_ACCESS_TOKEN";
22pub const ENV_REFRESH_TOKEN: &str = "SHAREPOINT_REFRESH_TOKEN";
23pub const ENV_DEBUG_HTTP: &str = "SHAREPOINT_DEBUG_HTTP";
24pub const ENV_GRAPH_ENDPOINT: &str = "MICROSOFT_GRAPH_ENDPOINT";
25pub const ENV_LOGIN_ENDPOINT: &str = "MICROSOFT_LOGIN_ENDPOINT";
26
27pub const DEFAULT_PROFILE: &str = "default";
28pub const DEFAULT_GRAPH_ENDPOINT: &str = "https://graph.microsoft.com/v1.0";
29pub const DEFAULT_LOGIN_ENDPOINT: &str = "https://login.microsoftonline.com";
30
31#[derive(Debug, Default, Serialize, Deserialize, Clone)]
32pub struct ConfigFile {
33 #[serde(default)]
34 pub profile: BTreeMap<String, Profile>,
35}
36
37#[derive(Debug, Default, Serialize, Deserialize, Clone)]
38pub struct Profile {
39 pub tenant_id: Option<String>,
40 pub client_id: Option<String>,
41 pub default_site: Option<String>,
42 #[serde(default)]
43 pub read_only: bool,
44 #[serde(default)]
45 pub sites: BTreeMap<String, String>,
46}
47
48#[derive(Debug, Clone)]
50pub struct ResolvedConfig {
51 pub profile_name: String,
52 pub tenant_id: Option<String>,
53 pub client_id: Option<String>,
54 pub default_site: Option<String>,
55 pub read_only: bool,
56 pub site_aliases: BTreeMap<String, String>,
57 pub graph_endpoint: String,
58 pub login_endpoint: String,
59 pub debug_http: bool,
60 pub access_token_override: Option<String>,
61 pub refresh_token_seed: Option<String>,
62}
63
64pub fn config_path() -> Result<PathBuf> {
65 let base = dirs::config_dir()
66 .ok_or_else(|| CliError::Other("could not determine config dir".into()))?;
67 Ok(base.join("sharepoint").join("config.toml"))
68}
69
70pub fn token_cache_path() -> Result<PathBuf> {
71 let base =
72 dirs::cache_dir().ok_or_else(|| CliError::Other("could not determine cache dir".into()))?;
73 Ok(base.join("sharepoint").join("tokens.json"))
74}
75
76pub fn load_file(path: &Path) -> Result<ConfigFile> {
77 if !path.exists() {
78 return Ok(ConfigFile::default());
79 }
80 let text = std::fs::read_to_string(path)
81 .map_err(|e| CliError::Other(format!("read {}: {e}", path.display())))?;
82 let cfg: ConfigFile = toml::from_str(&text)
83 .map_err(|e| CliError::Input(format!("parse {}: {e}", path.display())))?;
84 Ok(cfg)
85}
86
87pub fn save_file(path: &Path, cfg: &ConfigFile) -> Result<()> {
88 let parent = path.parent().unwrap_or_else(|| Path::new("."));
89 std::fs::create_dir_all(parent)
90 .map_err(|e| CliError::Other(format!("mkdir {}: {e}", parent.display())))?;
91
92 let body = toml::to_string_pretty(cfg)
93 .map_err(|e| CliError::Other(format!("serialize config: {e}")))?;
94
95 let mut tmp = tempfile::Builder::new()
98 .prefix(".config-")
99 .suffix(".toml.tmp")
100 .tempfile_in(parent)
101 .map_err(|e| CliError::Other(format!("tempfile in {}: {e}", parent.display())))?;
102 tmp.write_all(body.as_bytes())
103 .map_err(|e| CliError::Other(format!("write tempfile: {e}")))?;
104 tmp.flush()
105 .map_err(|e| CliError::Other(format!("flush tempfile: {e}")))?;
106
107 set_mode_0600(tmp.path())?;
108 tmp.persist(path)
109 .map_err(|e| CliError::Other(format!("persist tempfile: {e}")))?;
110 Ok(())
111}
112
113#[cfg(unix)]
114fn set_mode_0600(path: &Path) -> Result<()> {
115 use std::os::unix::fs::PermissionsExt;
116 let perms = std::fs::Permissions::from_mode(0o600);
117 std::fs::set_permissions(path, perms)
118 .map_err(|e| CliError::Other(format!("chmod 0600 {}: {e}", path.display())))?;
119 Ok(())
120}
121
122#[cfg(not(unix))]
123fn set_mode_0600(_path: &Path) -> Result<()> {
124 Ok(())
125}
126
127pub fn write_profile_tenant_id(path: &Path, profile: &str, tenant_id: &str) -> Result<()> {
129 let mut file = load_file(path)?;
130 let entry = file.profile.entry(profile.to_string()).or_default();
131 entry.tenant_id = Some(tenant_id.to_string());
132 save_file(path, &file)
133}
134
135fn parse_bool_env(value: &str) -> bool {
136 matches!(
137 value.trim().to_ascii_lowercase().as_str(),
138 "1" | "true" | "yes" | "on"
139 )
140}
141
142pub fn resolve(
146 file: &ConfigFile,
147 profile_flag: Option<&str>,
148 env: &dyn Fn(&str) -> Option<String>,
149) -> Result<ResolvedConfig> {
150 let profile_name = profile_flag
151 .map(str::to_owned)
152 .or_else(|| env(ENV_PROFILE))
153 .unwrap_or_else(|| DEFAULT_PROFILE.to_string());
154
155 let profile = file.profile.get(&profile_name).cloned().unwrap_or_default();
156
157 let tenant_id = env(ENV_TENANT).or(profile.tenant_id);
158 let client_id = env(ENV_CLIENT_ID).or(profile.client_id);
159 let default_site = env(ENV_DEFAULT_SITE).or(profile.default_site);
160
161 let read_only = env(ENV_READ_ONLY)
162 .map(|v| parse_bool_env(&v))
163 .unwrap_or(profile.read_only);
164
165 let graph_endpoint =
166 env(ENV_GRAPH_ENDPOINT).unwrap_or_else(|| DEFAULT_GRAPH_ENDPOINT.to_string());
167 let login_endpoint =
168 env(ENV_LOGIN_ENDPOINT).unwrap_or_else(|| DEFAULT_LOGIN_ENDPOINT.to_string());
169 let debug_http = env(ENV_DEBUG_HTTP)
170 .map(|v| parse_bool_env(&v))
171 .unwrap_or(false);
172
173 Ok(ResolvedConfig {
174 profile_name,
175 tenant_id,
176 client_id,
177 default_site,
178 read_only,
179 site_aliases: profile.sites,
180 graph_endpoint,
181 login_endpoint,
182 debug_http,
183 access_token_override: env(ENV_ACCESS_TOKEN),
184 refresh_token_seed: env(ENV_REFRESH_TOKEN),
185 })
186}
187
188#[cfg(test)]
189mod tests {
190 use super::*;
191
192 fn empty_env(_: &str) -> Option<String> {
193 None
194 }
195
196 #[test]
197 fn missing_profile_yields_empty_resolved() {
198 let file = ConfigFile::default();
199 let r = resolve(&file, None, &empty_env).unwrap();
200 assert_eq!(r.profile_name, "default");
201 assert!(r.tenant_id.is_none());
202 assert!(!r.read_only);
203 assert_eq!(r.graph_endpoint, DEFAULT_GRAPH_ENDPOINT);
204 }
205
206 #[test]
207 fn profile_fields_resolve_when_no_env() {
208 let mut file = ConfigFile::default();
209 let p = Profile {
210 tenant_id: Some("contoso.onmicrosoft.com".into()),
211 default_site: Some("Marketing".into()),
212 read_only: true,
213 ..Default::default()
214 };
215 file.profile.insert("default".into(), p);
216 let r = resolve(&file, None, &empty_env).unwrap();
217 assert_eq!(r.tenant_id.as_deref(), Some("contoso.onmicrosoft.com"));
218 assert_eq!(r.default_site.as_deref(), Some("Marketing"));
219 assert!(r.read_only);
220 }
221
222 #[test]
223 fn env_overrides_profile() {
224 let mut file = ConfigFile::default();
225 let p = Profile {
226 tenant_id: Some("from-file".into()),
227 ..Default::default()
228 };
229 file.profile.insert("default".into(), p);
230 let env = |k: &str| match k {
231 ENV_TENANT => Some("from-env".to_string()),
232 _ => None,
233 };
234 let r = resolve(&file, None, &env).unwrap();
235 assert_eq!(r.tenant_id.as_deref(), Some("from-env"));
236 }
237
238 #[test]
239 fn flag_overrides_env_for_profile_name() {
240 let env = |k: &str| match k {
241 ENV_PROFILE => Some("from-env".to_string()),
242 _ => None,
243 };
244 let r = resolve(&ConfigFile::default(), Some("from-flag"), &env).unwrap();
245 assert_eq!(r.profile_name, "from-flag");
246 }
247
248 #[test]
249 fn read_only_env_recognizes_truthy_values() {
250 for raw in ["1", "true", "TRUE", "yes", "on"] {
251 let env = |k: &str| match k {
252 ENV_READ_ONLY => Some(raw.to_string()),
253 _ => None,
254 };
255 let r = resolve(&ConfigFile::default(), None, &env).unwrap();
256 assert!(r.read_only, "expected read_only for {raw:?}");
257 }
258 }
259
260 #[test]
261 fn read_only_env_off_for_falsy_values() {
262 for raw in ["0", "false", "no", "", "off"] {
263 let env = |k: &str| match k {
264 ENV_READ_ONLY => Some(raw.to_string()),
265 _ => None,
266 };
267 let r = resolve(&ConfigFile::default(), None, &env).unwrap();
268 assert!(!r.read_only, "expected !read_only for {raw:?}");
269 }
270 }
271
272 #[test]
273 fn round_trip_save_and_load() {
274 let dir = tempfile::tempdir().unwrap();
275 let path = dir.path().join("config.toml");
276 let mut cfg = ConfigFile::default();
277 let mut sites = BTreeMap::new();
278 sites.insert(
279 "Marketing".into(),
280 "https://contoso.sharepoint.com/sites/Marketing".into(),
281 );
282 let p = Profile {
283 tenant_id: Some("contoso.onmicrosoft.com".into()),
284 sites,
285 ..Default::default()
286 };
287 cfg.profile.insert("default".into(), p);
288 save_file(&path, &cfg).unwrap();
289 let loaded = load_file(&path).unwrap();
290 let p = loaded.profile.get("default").unwrap();
291 assert_eq!(p.tenant_id.as_deref(), Some("contoso.onmicrosoft.com"));
292 assert_eq!(
293 p.sites.get("Marketing").map(String::as_str),
294 Some("https://contoso.sharepoint.com/sites/Marketing")
295 );
296 }
297
298 #[test]
299 fn missing_file_returns_empty() {
300 let dir = tempfile::tempdir().unwrap();
301 let path = dir.path().join("does-not-exist.toml");
302 let cfg = load_file(&path).unwrap();
303 assert!(cfg.profile.is_empty());
304 }
305
306 #[test]
307 fn save_file_does_not_leave_temp_artifacts_on_success() {
308 let dir = tempfile::tempdir().unwrap();
309 let path = dir.path().join("sharepoint").join("config.toml");
310 let mut file = ConfigFile::default();
311 file.profile
312 .entry("default".to_string())
313 .or_default()
314 .tenant_id = Some("11111111-1111-1111-1111-111111111111".to_string());
315 save_file(&path, &file).unwrap();
316 let reloaded = load_file(&path).unwrap();
318 assert_eq!(
319 reloaded
320 .profile
321 .get("default")
322 .and_then(|p| p.tenant_id.as_deref()),
323 Some("11111111-1111-1111-1111-111111111111"),
324 );
325 let parent = path.parent().unwrap();
327 let leftovers: Vec<_> = std::fs::read_dir(parent)
328 .unwrap()
329 .filter_map(|e| e.ok())
330 .filter(|e| e.file_name() != "config.toml")
331 .collect();
332 assert!(leftovers.is_empty(), "temp file left behind: {leftovers:?}");
333 }
334}