Skip to main content

sharepoint_cli/
config.rs

1//! Configuration: profile-based TOML at `~/.config/sharepoint/config.toml`,
2//! merged with `SHAREPOINT_*` env vars and CLI flags.
3//!
4//! There is intentionally no separate `[default]` section. Profiles live under
5//! `[profile.<name>]`; `active_profile` selects the default when no override is
6//! provided, and the literal name `default` remains the fallback.
7
8use 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, skip_serializing_if = "Option::is_none")]
34    pub active_profile: Option<String>,
35    #[serde(default)]
36    pub profile: BTreeMap<String, Profile>,
37}
38
39#[derive(Debug, Default, Serialize, Deserialize, Clone)]
40pub struct Profile {
41    pub tenant_id: Option<String>,
42    pub client_id: Option<String>,
43    pub default_site: Option<String>,
44    #[serde(default)]
45    pub read_only: bool,
46    #[serde(default)]
47    pub sites: BTreeMap<String, String>,
48}
49
50/// Fully resolved runtime settings (after merging file + env + flags).
51#[derive(Debug, Clone)]
52pub struct ResolvedConfig {
53    pub profile_name: String,
54    pub tenant_id: Option<String>,
55    pub client_id: Option<String>,
56    pub default_site: Option<String>,
57    pub read_only: bool,
58    pub site_aliases: BTreeMap<String, String>,
59    pub graph_endpoint: String,
60    pub login_endpoint: String,
61    pub debug_http: bool,
62    pub access_token_override: Option<String>,
63    pub refresh_token_seed: Option<String>,
64}
65
66pub fn config_path() -> Result<PathBuf> {
67    let base = dirs::config_dir()
68        .ok_or_else(|| CliError::Other("could not determine config dir".into()))?;
69    Ok(base.join("sharepoint").join("config.toml"))
70}
71
72pub fn token_cache_path() -> Result<PathBuf> {
73    let base =
74        dirs::cache_dir().ok_or_else(|| CliError::Other("could not determine cache dir".into()))?;
75    Ok(base.join("sharepoint").join("tokens.json"))
76}
77
78pub fn load_file(path: &Path) -> Result<ConfigFile> {
79    if !path.exists() {
80        return Ok(ConfigFile::default());
81    }
82    let text = std::fs::read_to_string(path)
83        .map_err(|e| CliError::Other(format!("read {}: {e}", path.display())))?;
84    let cfg: ConfigFile = toml::from_str(&text)
85        .map_err(|e| CliError::Input(format!("parse {}: {e}", path.display())))?;
86    Ok(cfg)
87}
88
89pub fn save_file(path: &Path, cfg: &ConfigFile) -> Result<()> {
90    let parent = path.parent().unwrap_or_else(|| Path::new("."));
91    std::fs::create_dir_all(parent)
92        .map_err(|e| CliError::Other(format!("mkdir {}: {e}", parent.display())))?;
93
94    let body = toml::to_string_pretty(cfg)
95        .map_err(|e| CliError::Other(format!("serialize config: {e}")))?;
96
97    // Write to a tempfile in the same directory, then rename into place so a
98    // mid-write crash never leaves a truncated or partially-written config.
99    let mut tmp = tempfile::Builder::new()
100        .prefix(".config-")
101        .suffix(".toml.tmp")
102        .tempfile_in(parent)
103        .map_err(|e| CliError::Other(format!("tempfile in {}: {e}", parent.display())))?;
104    tmp.write_all(body.as_bytes())
105        .map_err(|e| CliError::Other(format!("write tempfile: {e}")))?;
106    tmp.flush()
107        .map_err(|e| CliError::Other(format!("flush tempfile: {e}")))?;
108
109    set_mode_0600(tmp.path())?;
110    tmp.persist(path)
111        .map_err(|e| CliError::Other(format!("persist tempfile: {e}")))?;
112    Ok(())
113}
114
115#[cfg(unix)]
116fn set_mode_0600(path: &Path) -> Result<()> {
117    use std::os::unix::fs::PermissionsExt;
118    let perms = std::fs::Permissions::from_mode(0o600);
119    std::fs::set_permissions(path, perms)
120        .map_err(|e| CliError::Other(format!("chmod 0600 {}: {e}", path.display())))?;
121    Ok(())
122}
123
124#[cfg(not(unix))]
125fn set_mode_0600(_path: &Path) -> Result<()> {
126    Ok(())
127}
128
129/// Update a profile's tenant_id in the file and persist atomically.
130pub fn write_profile_tenant_id(path: &Path, profile: &str, tenant_id: &str) -> Result<()> {
131    let mut file = load_file(path)?;
132    let entry = file.profile.entry(profile.to_string()).or_default();
133    entry.tenant_id = Some(tenant_id.to_string());
134    save_file(path, &file)
135}
136
137fn parse_bool_env(value: &str) -> bool {
138    matches!(
139        value.trim().to_ascii_lowercase().as_str(),
140        "1" | "true" | "yes" | "on"
141    )
142}
143
144/// Merge a `ConfigFile` with environment variables and explicit CLI flags.
145///
146/// Precedence (highest first): explicit flag → env var → profile field → built-in default.
147pub fn resolve(
148    file: &ConfigFile,
149    profile_flag: Option<&str>,
150    env: &dyn Fn(&str) -> Option<String>,
151) -> Result<ResolvedConfig> {
152    let profile_name = profile_flag
153        .map(str::to_owned)
154        .or_else(|| env(ENV_PROFILE))
155        .or_else(|| file.active_profile.clone())
156        .unwrap_or_else(|| DEFAULT_PROFILE.to_string());
157
158    let profile = file.profile.get(&profile_name).cloned().unwrap_or_default();
159
160    let tenant_id = env(ENV_TENANT).or(profile.tenant_id);
161    let client_id = env(ENV_CLIENT_ID).or(profile.client_id);
162    let default_site = env(ENV_DEFAULT_SITE).or(profile.default_site);
163
164    let read_only = env(ENV_READ_ONLY)
165        .map(|v| parse_bool_env(&v))
166        .unwrap_or(profile.read_only);
167
168    let graph_endpoint =
169        env(ENV_GRAPH_ENDPOINT).unwrap_or_else(|| DEFAULT_GRAPH_ENDPOINT.to_string());
170    let login_endpoint =
171        env(ENV_LOGIN_ENDPOINT).unwrap_or_else(|| DEFAULT_LOGIN_ENDPOINT.to_string());
172    let debug_http = env(ENV_DEBUG_HTTP)
173        .map(|v| parse_bool_env(&v))
174        .unwrap_or(false);
175
176    Ok(ResolvedConfig {
177        profile_name,
178        tenant_id,
179        client_id,
180        default_site,
181        read_only,
182        site_aliases: profile.sites,
183        graph_endpoint,
184        login_endpoint,
185        debug_http,
186        access_token_override: env(ENV_ACCESS_TOKEN),
187        refresh_token_seed: env(ENV_REFRESH_TOKEN),
188    })
189}
190
191pub fn profile_summaries(file: &ConfigFile) -> Vec<serde_json::Value> {
192    file.profile
193        .iter()
194        .map(|(name, profile)| {
195            serde_json::json!({
196                "name": name,
197                "active": file.active_profile.as_deref().unwrap_or(DEFAULT_PROFILE) == name,
198                "tenant_id": profile.tenant_id,
199                "client_id": profile.client_id,
200                "default_site": profile.default_site,
201                "read_only": profile.read_only,
202            })
203        })
204        .collect()
205}
206
207pub fn use_profile(path: &Path, name: &str) -> Result<()> {
208    let mut file = load_file(path)?;
209    if !file.profile.contains_key(name) {
210        return Err(CliError::NotFound(format!("profile '{name}'")));
211    }
212    file.active_profile = Some(name.into());
213    save_file(path, &file)
214}
215
216pub fn remove_profile(path: &Path, name: &str) -> Result<Option<Profile>> {
217    let mut file = load_file(path)?;
218    let removed = file.profile.remove(name);
219    if removed.is_some() {
220        if file.active_profile.as_deref() == Some(name) {
221            file.active_profile = file.profile.keys().next().cloned();
222        }
223        save_file(path, &file)?;
224    }
225    Ok(removed)
226}
227
228#[cfg(test)]
229mod tests {
230    use super::*;
231
232    fn empty_env(_: &str) -> Option<String> {
233        None
234    }
235
236    #[test]
237    fn missing_profile_yields_empty_resolved() {
238        let file = ConfigFile::default();
239        let r = resolve(&file, None, &empty_env).unwrap();
240        assert_eq!(r.profile_name, "default");
241        assert!(r.tenant_id.is_none());
242        assert!(!r.read_only);
243        assert_eq!(r.graph_endpoint, DEFAULT_GRAPH_ENDPOINT);
244    }
245
246    #[test]
247    fn profile_fields_resolve_when_no_env() {
248        let mut file = ConfigFile::default();
249        let p = Profile {
250            tenant_id: Some("contoso.onmicrosoft.com".into()),
251            default_site: Some("Marketing".into()),
252            read_only: true,
253            ..Default::default()
254        };
255        file.profile.insert("default".into(), p);
256        let r = resolve(&file, None, &empty_env).unwrap();
257        assert_eq!(r.tenant_id.as_deref(), Some("contoso.onmicrosoft.com"));
258        assert_eq!(r.default_site.as_deref(), Some("Marketing"));
259        assert!(r.read_only);
260    }
261
262    #[test]
263    fn env_overrides_profile() {
264        let mut file = ConfigFile::default();
265        let p = Profile {
266            tenant_id: Some("from-file".into()),
267            ..Default::default()
268        };
269        file.profile.insert("default".into(), p);
270        let env = |k: &str| match k {
271            ENV_TENANT => Some("from-env".to_string()),
272            _ => None,
273        };
274        let r = resolve(&file, None, &env).unwrap();
275        assert_eq!(r.tenant_id.as_deref(), Some("from-env"));
276    }
277
278    #[test]
279    fn flag_overrides_env_for_profile_name() {
280        let env = |k: &str| match k {
281            ENV_PROFILE => Some("from-env".to_string()),
282            _ => None,
283        };
284        let r = resolve(&ConfigFile::default(), Some("from-flag"), &env).unwrap();
285        assert_eq!(r.profile_name, "from-flag");
286    }
287
288    #[test]
289    fn active_profile_is_used_below_explicit_and_environment_overrides() {
290        let mut file = ConfigFile {
291            active_profile: Some("active".into()),
292            ..ConfigFile::default()
293        };
294        file.profile.insert(
295            "active".into(),
296            Profile {
297                tenant_id: Some("active-tenant".into()),
298                ..Profile::default()
299            },
300        );
301        let resolved = resolve(&file, None, &empty_env).unwrap();
302        assert_eq!(resolved.profile_name, "active");
303        assert_eq!(resolved.tenant_id.as_deref(), Some("active-tenant"));
304
305        let env = |key: &str| (key == ENV_PROFILE).then(|| "environment".to_string());
306        assert_eq!(
307            resolve(&file, None, &env).unwrap().profile_name,
308            "environment"
309        );
310        assert_eq!(
311            resolve(&file, Some("explicit"), &env).unwrap().profile_name,
312            "explicit"
313        );
314    }
315
316    #[test]
317    fn use_and_remove_profile_update_active_selection() {
318        let dir = tempfile::tempdir().unwrap();
319        let path = dir.path().join("config.toml");
320        let mut file = ConfigFile::default();
321        file.profile.insert("alpha".into(), Profile::default());
322        file.profile.insert("beta".into(), Profile::default());
323        save_file(&path, &file).unwrap();
324
325        use_profile(&path, "beta").unwrap();
326        assert_eq!(
327            load_file(&path).unwrap().active_profile.as_deref(),
328            Some("beta")
329        );
330
331        assert!(remove_profile(&path, "beta").unwrap().is_some());
332        let updated = load_file(&path).unwrap();
333        assert_eq!(updated.active_profile.as_deref(), Some("alpha"));
334        assert!(!updated.profile.contains_key("beta"));
335    }
336
337    #[test]
338    fn read_only_env_recognizes_truthy_values() {
339        for raw in ["1", "true", "TRUE", "yes", "on"] {
340            let env = |k: &str| match k {
341                ENV_READ_ONLY => Some(raw.to_string()),
342                _ => None,
343            };
344            let r = resolve(&ConfigFile::default(), None, &env).unwrap();
345            assert!(r.read_only, "expected read_only for {raw:?}");
346        }
347    }
348
349    #[test]
350    fn read_only_env_off_for_falsy_values() {
351        for raw in ["0", "false", "no", "", "off"] {
352            let env = |k: &str| match k {
353                ENV_READ_ONLY => Some(raw.to_string()),
354                _ => None,
355            };
356            let r = resolve(&ConfigFile::default(), None, &env).unwrap();
357            assert!(!r.read_only, "expected !read_only for {raw:?}");
358        }
359    }
360
361    #[test]
362    fn round_trip_save_and_load() {
363        let dir = tempfile::tempdir().unwrap();
364        let path = dir.path().join("config.toml");
365        let mut cfg = ConfigFile::default();
366        let mut sites = BTreeMap::new();
367        sites.insert(
368            "Marketing".into(),
369            "https://contoso.sharepoint.com/sites/Marketing".into(),
370        );
371        let p = Profile {
372            tenant_id: Some("contoso.onmicrosoft.com".into()),
373            sites,
374            ..Default::default()
375        };
376        cfg.profile.insert("default".into(), p);
377        save_file(&path, &cfg).unwrap();
378        let loaded = load_file(&path).unwrap();
379        let p = loaded.profile.get("default").unwrap();
380        assert_eq!(p.tenant_id.as_deref(), Some("contoso.onmicrosoft.com"));
381        assert_eq!(
382            p.sites.get("Marketing").map(String::as_str),
383            Some("https://contoso.sharepoint.com/sites/Marketing")
384        );
385    }
386
387    #[test]
388    fn missing_file_returns_empty() {
389        let dir = tempfile::tempdir().unwrap();
390        let path = dir.path().join("does-not-exist.toml");
391        let cfg = load_file(&path).unwrap();
392        assert!(cfg.profile.is_empty());
393    }
394
395    #[test]
396    fn save_file_does_not_leave_temp_artifacts_on_success() {
397        let dir = tempfile::tempdir().unwrap();
398        let path = dir.path().join("sharepoint").join("config.toml");
399        let mut file = ConfigFile::default();
400        file.profile
401            .entry("default".to_string())
402            .or_default()
403            .tenant_id = Some("11111111-1111-1111-1111-111111111111".to_string());
404        save_file(&path, &file).unwrap();
405        // Round-trip works.
406        let reloaded = load_file(&path).unwrap();
407        assert_eq!(
408            reloaded
409                .profile
410                .get("default")
411                .and_then(|p| p.tenant_id.as_deref()),
412            Some("11111111-1111-1111-1111-111111111111"),
413        );
414        // No leftover temp files in the parent.
415        let parent = path.parent().unwrap();
416        let leftovers: Vec<_> = std::fs::read_dir(parent)
417            .unwrap()
418            .filter_map(|e| e.ok())
419            .filter(|e| e.file_name() != "config.toml")
420            .collect();
421        assert!(leftovers.is_empty(), "temp file left behind: {leftovers:?}");
422    }
423}