1use 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#[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
105pub 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}