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::path::{Path, PathBuf};
10
11use serde::{Deserialize, Serialize};
12
13use crate::error::{CliError, Result};
14
15pub const ENV_PROFILE: &str = "SHAREPOINT_PROFILE";
16pub const ENV_TENANT: &str = "SHAREPOINT_TENANT_ID";
17pub const ENV_CLIENT_ID: &str = "SHAREPOINT_CLIENT_ID";
18pub const ENV_DEFAULT_SITE: &str = "SHAREPOINT_DEFAULT_SITE";
19pub const ENV_READ_ONLY: &str = "SHAREPOINT_READ_ONLY";
20pub const ENV_ACCESS_TOKEN: &str = "SHAREPOINT_ACCESS_TOKEN";
21pub const ENV_REFRESH_TOKEN: &str = "SHAREPOINT_REFRESH_TOKEN";
22pub const ENV_DEBUG_HTTP: &str = "SHAREPOINT_DEBUG_HTTP";
23pub const ENV_GRAPH_ENDPOINT: &str = "MICROSOFT_GRAPH_ENDPOINT";
24pub const ENV_LOGIN_ENDPOINT: &str = "MICROSOFT_LOGIN_ENDPOINT";
25
26pub const DEFAULT_PROFILE: &str = "default";
27pub const DEFAULT_GRAPH_ENDPOINT: &str = "https://graph.microsoft.com/v1.0";
28pub const DEFAULT_LOGIN_ENDPOINT: &str = "https://login.microsoftonline.com";
29
30#[derive(Debug, Default, Serialize, Deserialize, Clone)]
31pub struct ConfigFile {
32    #[serde(default)]
33    pub profile: BTreeMap<String, Profile>,
34}
35
36#[derive(Debug, Default, Serialize, Deserialize, Clone)]
37pub struct Profile {
38    pub tenant_id: Option<String>,
39    pub client_id: Option<String>,
40    pub default_site: Option<String>,
41    #[serde(default)]
42    pub read_only: bool,
43    #[serde(default)]
44    pub sites: BTreeMap<String, String>,
45}
46
47/// Fully resolved runtime settings (after merging file + env + flags).
48#[derive(Debug, Clone)]
49pub struct ResolvedConfig {
50    pub profile_name: String,
51    pub tenant_id: Option<String>,
52    pub client_id: Option<String>,
53    pub default_site: Option<String>,
54    pub read_only: bool,
55    pub site_aliases: BTreeMap<String, String>,
56    pub graph_endpoint: String,
57    pub login_endpoint: String,
58    pub debug_http: bool,
59    pub access_token_override: Option<String>,
60    pub refresh_token_seed: Option<String>,
61}
62
63pub fn config_path() -> Result<PathBuf> {
64    let base = dirs::config_dir()
65        .ok_or_else(|| CliError::Other("could not determine config dir".into()))?;
66    Ok(base.join("sharepoint").join("config.toml"))
67}
68
69pub fn token_cache_path() -> Result<PathBuf> {
70    let base =
71        dirs::cache_dir().ok_or_else(|| CliError::Other("could not determine cache dir".into()))?;
72    Ok(base.join("sharepoint").join("tokens.json"))
73}
74
75pub fn load_file(path: &Path) -> Result<ConfigFile> {
76    if !path.exists() {
77        return Ok(ConfigFile::default());
78    }
79    let text = std::fs::read_to_string(path)
80        .map_err(|e| CliError::Other(format!("read {}: {e}", path.display())))?;
81    let cfg: ConfigFile = toml::from_str(&text)
82        .map_err(|e| CliError::Input(format!("parse {}: {e}", path.display())))?;
83    Ok(cfg)
84}
85
86pub fn save_file(path: &Path, cfg: &ConfigFile) -> Result<()> {
87    if let Some(parent) = path.parent() {
88        std::fs::create_dir_all(parent)
89            .map_err(|e| CliError::Other(format!("mkdir {}: {e}", parent.display())))?;
90    }
91    let body = toml::to_string_pretty(cfg)
92        .map_err(|e| CliError::Other(format!("serialize config: {e}")))?;
93    std::fs::write(path, body)
94        .map_err(|e| CliError::Other(format!("write {}: {e}", path.display())))?;
95    Ok(())
96}
97
98fn parse_bool_env(value: &str) -> bool {
99    matches!(
100        value.trim().to_ascii_lowercase().as_str(),
101        "1" | "true" | "yes" | "on"
102    )
103}
104
105/// Merge a `ConfigFile` with environment variables and explicit CLI flags.
106///
107/// Precedence (highest first): explicit flag → env var → profile field → built-in default.
108pub fn resolve(
109    file: &ConfigFile,
110    profile_flag: Option<&str>,
111    env: &dyn Fn(&str) -> Option<String>,
112) -> Result<ResolvedConfig> {
113    let profile_name = profile_flag
114        .map(str::to_owned)
115        .or_else(|| env(ENV_PROFILE))
116        .unwrap_or_else(|| DEFAULT_PROFILE.to_string());
117
118    let profile = file.profile.get(&profile_name).cloned().unwrap_or_default();
119
120    let tenant_id = env(ENV_TENANT).or(profile.tenant_id);
121    let client_id = env(ENV_CLIENT_ID).or(profile.client_id);
122    let default_site = env(ENV_DEFAULT_SITE).or(profile.default_site);
123
124    let read_only = env(ENV_READ_ONLY)
125        .map(|v| parse_bool_env(&v))
126        .unwrap_or(profile.read_only);
127
128    let graph_endpoint =
129        env(ENV_GRAPH_ENDPOINT).unwrap_or_else(|| DEFAULT_GRAPH_ENDPOINT.to_string());
130    let login_endpoint =
131        env(ENV_LOGIN_ENDPOINT).unwrap_or_else(|| DEFAULT_LOGIN_ENDPOINT.to_string());
132    let debug_http = env(ENV_DEBUG_HTTP)
133        .map(|v| parse_bool_env(&v))
134        .unwrap_or(false);
135
136    Ok(ResolvedConfig {
137        profile_name,
138        tenant_id,
139        client_id,
140        default_site,
141        read_only,
142        site_aliases: profile.sites,
143        graph_endpoint,
144        login_endpoint,
145        debug_http,
146        access_token_override: env(ENV_ACCESS_TOKEN),
147        refresh_token_seed: env(ENV_REFRESH_TOKEN),
148    })
149}
150
151#[cfg(test)]
152mod tests {
153    use super::*;
154
155    fn empty_env(_: &str) -> Option<String> {
156        None
157    }
158
159    #[test]
160    fn missing_profile_yields_empty_resolved() {
161        let file = ConfigFile::default();
162        let r = resolve(&file, None, &empty_env).unwrap();
163        assert_eq!(r.profile_name, "default");
164        assert!(r.tenant_id.is_none());
165        assert!(!r.read_only);
166        assert_eq!(r.graph_endpoint, DEFAULT_GRAPH_ENDPOINT);
167    }
168
169    #[test]
170    fn profile_fields_resolve_when_no_env() {
171        let mut file = ConfigFile::default();
172        let p = Profile {
173            tenant_id: Some("contoso.onmicrosoft.com".into()),
174            default_site: Some("Marketing".into()),
175            read_only: true,
176            ..Default::default()
177        };
178        file.profile.insert("default".into(), p);
179        let r = resolve(&file, None, &empty_env).unwrap();
180        assert_eq!(r.tenant_id.as_deref(), Some("contoso.onmicrosoft.com"));
181        assert_eq!(r.default_site.as_deref(), Some("Marketing"));
182        assert!(r.read_only);
183    }
184
185    #[test]
186    fn env_overrides_profile() {
187        let mut file = ConfigFile::default();
188        let p = Profile {
189            tenant_id: Some("from-file".into()),
190            ..Default::default()
191        };
192        file.profile.insert("default".into(), p);
193        let env = |k: &str| match k {
194            ENV_TENANT => Some("from-env".to_string()),
195            _ => None,
196        };
197        let r = resolve(&file, None, &env).unwrap();
198        assert_eq!(r.tenant_id.as_deref(), Some("from-env"));
199    }
200
201    #[test]
202    fn flag_overrides_env_for_profile_name() {
203        let env = |k: &str| match k {
204            ENV_PROFILE => Some("from-env".to_string()),
205            _ => None,
206        };
207        let r = resolve(&ConfigFile::default(), Some("from-flag"), &env).unwrap();
208        assert_eq!(r.profile_name, "from-flag");
209    }
210
211    #[test]
212    fn read_only_env_recognizes_truthy_values() {
213        for raw in ["1", "true", "TRUE", "yes", "on"] {
214            let env = |k: &str| match k {
215                ENV_READ_ONLY => Some(raw.to_string()),
216                _ => None,
217            };
218            let r = resolve(&ConfigFile::default(), None, &env).unwrap();
219            assert!(r.read_only, "expected read_only for {raw:?}");
220        }
221    }
222
223    #[test]
224    fn read_only_env_off_for_falsy_values() {
225        for raw in ["0", "false", "no", "", "off"] {
226            let env = |k: &str| match k {
227                ENV_READ_ONLY => Some(raw.to_string()),
228                _ => None,
229            };
230            let r = resolve(&ConfigFile::default(), None, &env).unwrap();
231            assert!(!r.read_only, "expected !read_only for {raw:?}");
232        }
233    }
234
235    #[test]
236    fn round_trip_save_and_load() {
237        let dir = tempfile::tempdir().unwrap();
238        let path = dir.path().join("config.toml");
239        let mut cfg = ConfigFile::default();
240        let mut sites = BTreeMap::new();
241        sites.insert(
242            "Marketing".into(),
243            "https://contoso.sharepoint.com/sites/Marketing".into(),
244        );
245        let p = Profile {
246            tenant_id: Some("contoso.onmicrosoft.com".into()),
247            sites,
248            ..Default::default()
249        };
250        cfg.profile.insert("default".into(), p);
251        save_file(&path, &cfg).unwrap();
252        let loaded = load_file(&path).unwrap();
253        let p = loaded.profile.get("default").unwrap();
254        assert_eq!(p.tenant_id.as_deref(), Some("contoso.onmicrosoft.com"));
255        assert_eq!(
256            p.sites.get("Marketing").map(String::as_str),
257            Some("https://contoso.sharepoint.com/sites/Marketing")
258        );
259    }
260
261    #[test]
262    fn missing_file_returns_empty() {
263        let dir = tempfile::tempdir().unwrap();
264        let path = dir.path().join("does-not-exist.toml");
265        let cfg = load_file(&path).unwrap();
266        assert!(cfg.profile.is_empty());
267    }
268}