1use std::path::{Path, PathBuf};
14
15use serde::{Deserialize, Serialize};
16
17use crate::error::{Error, Result};
18
19const APP_DIR: &str = "org-gdocs";
21
22#[derive(Debug, Clone, Default)]
28pub struct EnvInputs {
29 pub home: Option<PathBuf>,
31 pub xdg_config_home: Option<PathBuf>,
33 pub xdg_data_home: Option<PathBuf>,
35 pub config_home: Option<PathBuf>,
37 pub data_home: Option<PathBuf>,
39}
40
41impl EnvInputs {
42 #[allow(
44 clippy::disallowed_methods,
45 reason = "bootstrap env reads that locate config/data, performed once at the edge (REPO_INVARIANTS.md #5, OVR-4)"
46 )]
47 #[must_use]
48 pub fn from_env() -> Self {
49 Self {
50 home: std::env::var_os("HOME").map(PathBuf::from),
51 xdg_config_home: std::env::var_os("XDG_CONFIG_HOME").map(PathBuf::from),
52 xdg_data_home: std::env::var_os("XDG_DATA_HOME").map(PathBuf::from),
53 config_home: std::env::var_os("ORG_GDOCS_CONFIG_HOME").map(PathBuf::from),
54 data_home: std::env::var_os("ORG_GDOCS_DATA_HOME").map(PathBuf::from),
55 }
56 }
57}
58
59#[derive(Debug, Clone, PartialEq, Eq)]
61pub struct Paths {
62 pub config_dir: PathBuf,
64 pub data_dir: PathBuf,
66}
67
68impl Paths {
69 pub fn resolve(env: &EnvInputs) -> Result<Self> {
80 let config_dir = match &env.config_home {
81 Some(dir) => dir.clone(),
82 None => xdg_base(
83 env.xdg_config_home.as_deref(),
84 env.home.as_deref(),
85 ".config",
86 )?
87 .join(APP_DIR),
88 };
89 let data_dir = match &env.data_home {
90 Some(dir) => dir.clone(),
91 None => xdg_base(
92 env.xdg_data_home.as_deref(),
93 env.home.as_deref(),
94 ".local/share",
95 )?
96 .join(APP_DIR),
97 };
98 Ok(Self {
99 config_dir,
100 data_dir,
101 })
102 }
103
104 #[must_use]
106 pub fn config_file(&self) -> PathBuf {
107 self.config_dir.join("config.toml")
108 }
109
110 #[must_use]
112 pub fn default_credentials(&self) -> PathBuf {
113 self.config_dir.join("credentials.json")
114 }
115
116 #[must_use]
118 pub fn default_token(&self) -> PathBuf {
119 self.data_dir.join("token.json")
120 }
121}
122
123fn xdg_base(xdg: Option<&Path>, home: Option<&Path>, fallback: &str) -> Result<PathBuf> {
125 if let Some(dir) = xdg {
126 return Ok(dir.to_path_buf());
127 }
128 home.map(|h| h.join(fallback))
129 .ok_or_else(|| Error::PathResolution(format!("XDG base directory ({fallback})")))
130}
131
132#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
134pub struct Config {
135 pub credentials_path: PathBuf,
137 pub token_path: PathBuf,
139 #[serde(default)]
141 pub debug: bool,
142}
143
144impl Config {
145 #[must_use]
147 pub fn defaults(paths: &Paths) -> Self {
148 Self {
149 credentials_path: paths.default_credentials(),
150 token_path: paths.default_token(),
151 debug: false,
152 }
153 }
154
155 pub fn load(paths: &Paths) -> Result<Self> {
163 let file = paths.config_file();
164 match std::fs::read_to_string(&file) {
165 Ok(text) => Ok(toml::from_str(&text)?),
166 Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(Self::defaults(paths)),
167 Err(err) => Err(Error::Io {
168 path: file,
169 source: err,
170 }),
171 }
172 }
173
174 pub fn save(&self, paths: &Paths) -> Result<()> {
181 std::fs::create_dir_all(&paths.config_dir).map_err(|source| Error::Io {
182 path: paths.config_dir.clone(),
183 source,
184 })?;
185 let text = toml::to_string_pretty(self)?;
186 let file = paths.config_file();
187 std::fs::write(&file, text).map_err(|source| Error::Io { path: file, source })
188 }
189}
190
191#[cfg(test)]
192mod tests {
193 use super::{Config, EnvInputs, Paths};
194 use std::path::PathBuf;
195
196 fn home_env() -> EnvInputs {
197 EnvInputs {
198 home: Some(PathBuf::from("/home/jfb")),
199 ..EnvInputs::default()
200 }
201 }
202
203 #[test]
204 fn resolve_uses_xdg_defaults_under_home() {
205 let paths = Paths::resolve(&home_env()).expect("home present");
206 assert_eq!(
207 paths.config_dir,
208 PathBuf::from("/home/jfb/.config/org-gdocs")
209 );
210 assert_eq!(
211 paths.data_dir,
212 PathBuf::from("/home/jfb/.local/share/org-gdocs")
213 );
214 assert_eq!(
215 paths.config_file(),
216 PathBuf::from("/home/jfb/.config/org-gdocs/config.toml")
217 );
218 }
219
220 #[test]
221 fn xdg_overrides_win_over_home() {
222 let env = EnvInputs {
223 home: Some(PathBuf::from("/home/jfb")),
224 xdg_config_home: Some(PathBuf::from("/xdg/cfg")),
225 xdg_data_home: Some(PathBuf::from("/xdg/data")),
226 ..EnvInputs::default()
227 };
228 let paths = Paths::resolve(&env).expect("xdg present");
229 assert_eq!(paths.config_dir, PathBuf::from("/xdg/cfg/org-gdocs"));
230 assert_eq!(paths.data_dir, PathBuf::from("/xdg/data/org-gdocs"));
231 }
232
233 #[test]
234 fn explicit_tool_overrides_win_over_xdg() {
235 let env = EnvInputs {
236 home: Some(PathBuf::from("/home/jfb")),
237 xdg_config_home: Some(PathBuf::from("/xdg/cfg")),
238 config_home: Some(PathBuf::from("/explicit/cfg")),
239 data_home: Some(PathBuf::from("/explicit/data")),
240 ..EnvInputs::default()
241 };
242 let paths = Paths::resolve(&env).expect("overrides present");
243 assert_eq!(paths.config_dir, PathBuf::from("/explicit/cfg"));
244 assert_eq!(paths.data_dir, PathBuf::from("/explicit/data"));
245 }
246
247 #[test]
248 fn resolve_without_home_or_xdg_errors() {
249 assert!(Paths::resolve(&EnvInputs::default()).is_err());
250 }
251
252 #[test]
253 fn defaults_place_credentials_and_token() {
254 let paths = Paths::resolve(&home_env()).expect("home present");
255 let config = Config::defaults(&paths);
256 assert_eq!(
257 config.credentials_path,
258 PathBuf::from("/home/jfb/.config/org-gdocs/credentials.json")
259 );
260 assert_eq!(
261 config.token_path,
262 PathBuf::from("/home/jfb/.local/share/org-gdocs/token.json")
263 );
264 assert!(!config.debug);
265 }
266
267 #[test]
268 fn config_round_trips_through_toml() {
269 let paths = Paths::resolve(&home_env()).expect("home present");
270 let config = Config::defaults(&paths);
271 let text = toml::to_string_pretty(&config).expect("serialize");
272 let parsed: Config = toml::from_str(&text).expect("deserialize");
273 assert_eq!(config, parsed);
274 }
275}