Skip to main content

org_gdocs/
config.rs

1//! Configuration and filesystem-path resolution.
2//!
3//! Paths follow the XDG Base Directory layout on every platform (matching the
4//! reference tool's intent and the operator's environment), rather than the
5//! macOS `Library/Application Support` convention. Environment is read once, at
6//! the binary edge, via [`EnvInputs::from_env`]; everything downstream is a pure
7//! function of the resulting typed values (`REPO_INVARIANTS` #5).
8//!
9//! Per OVR-1, the only JSON surfaces are Google's `credentials.json` (its own
10//! format) and the cached OAuth `token.json`; this tool's own configuration is
11//! TOML.
12
13use std::path::{Path, PathBuf};
14
15use serde::{Deserialize, Serialize};
16
17use crate::error::{Error, Result};
18
19/// Subdirectory under the XDG bases that holds this tool's files.
20const APP_DIR: &str = "org-gdocs";
21
22/// Sanctioned environment inputs, captured once at the process edge.
23///
24/// Every field corresponds to an OVR-4 / `REPO_INVARIANTS` #5 bootstrap variable
25/// that merely *locates* config or data. No tunable behavior is read from the
26/// environment — that belongs in [`Config`].
27#[derive(Debug, Clone, Default)]
28pub struct EnvInputs {
29    /// `HOME`, used to derive XDG defaults.
30    pub home: Option<PathBuf>,
31    /// `XDG_CONFIG_HOME` override for the config base directory.
32    pub xdg_config_home: Option<PathBuf>,
33    /// `XDG_DATA_HOME` override for the data base directory.
34    pub xdg_data_home: Option<PathBuf>,
35    /// `ORG_GDOCS_CONFIG_HOME` explicit override for this tool's config dir.
36    pub config_home: Option<PathBuf>,
37    /// `ORG_GDOCS_DATA_HOME` explicit override for this tool's data dir.
38    pub data_home: Option<PathBuf>,
39}
40
41impl EnvInputs {
42    /// Read the sanctioned environment variables at the process edge.
43    #[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/// Resolved on-disk directories for this tool.
60#[derive(Debug, Clone, PartialEq, Eq)]
61pub struct Paths {
62    /// Directory holding `config.toml` and (by default) `credentials.json`.
63    pub config_dir: PathBuf,
64    /// Directory holding the cached OAuth `token.json`.
65    pub data_dir: PathBuf,
66}
67
68impl Paths {
69    /// Resolve the config and data directories from captured environment inputs.
70    ///
71    /// Precedence: an explicit `ORG_GDOCS_*_HOME` override wins; otherwise the
72    /// XDG base (`XDG_CONFIG_HOME` / `XDG_DATA_HOME`, else `~/.config` /
73    /// `~/.local/share`) joined with the [`APP_DIR`] subdirectory.
74    ///
75    /// # Errors
76    ///
77    /// Returns [`Error::PathResolution`] when neither an override nor the XDG
78    /// base nor `HOME` is available to anchor a directory.
79    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    /// Path to this tool's TOML configuration file.
105    #[must_use]
106    pub fn config_file(&self) -> PathBuf {
107        self.config_dir.join("config.toml")
108    }
109
110    /// Default path to the Google OAuth client secret (`credentials.json`).
111    #[must_use]
112    pub fn default_credentials(&self) -> PathBuf {
113        self.config_dir.join("credentials.json")
114    }
115
116    /// Default path to the cached OAuth token (`token.json`).
117    #[must_use]
118    pub fn default_token(&self) -> PathBuf {
119        self.data_dir.join("token.json")
120    }
121}
122
123/// Resolve an XDG base directory: explicit override, else `HOME`/`fallback`.
124fn 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/// User-editable configuration (TOML).
133#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
134pub struct Config {
135    /// Path to the Google OAuth client secret JSON (Google's format; OVR-1).
136    pub credentials_path: PathBuf,
137    /// Path to the cached OAuth token JSON (`yup-oauth2`; OVR-1).
138    pub token_path: PathBuf,
139    /// Emit verbose diagnostics to stderr.
140    #[serde(default)]
141    pub debug: bool,
142}
143
144impl Config {
145    /// Build the default configuration for the given resolved paths.
146    #[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    /// Load configuration from `paths.config_file()`, falling back to defaults
156    /// when the file does not exist.
157    ///
158    /// # Errors
159    ///
160    /// Returns [`Error::Io`] when the file exists but cannot be read, or
161    /// [`Error::ConfigToml`] when its contents are not valid TOML.
162    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    /// Serialize this configuration to TOML, creating the config directory.
175    ///
176    /// # Errors
177    ///
178    /// Returns [`Error::ConfigTomlSerialize`] on encoding failure, or
179    /// [`Error::Io`] when the directory or file cannot be written.
180    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}