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. The active profile
5//! is whichever block matches `[profile.<name>]`; the literal name `default`
6//! plays the special-default role.
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)]
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/// Fully resolved runtime settings (after merging file + env + flags).
49#[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    // Write to a tempfile in the same directory, then rename into place so a
96    // mid-write crash never leaves a truncated or partially-written config.
97    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
127/// Update a profile's tenant_id in the file and persist atomically.
128pub 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
142/// Merge a `ConfigFile` with environment variables and explicit CLI flags.
143///
144/// Precedence (highest first): explicit flag → env var → profile field → built-in default.
145pub 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        // Round-trip works.
317        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        // No leftover temp files in the parent.
326        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}