tftio-org-gdocs 0.1.1

Sync org-mode documents to Google Docs and pull reviewer comments back into org-mode
Documentation
//! Configuration and filesystem-path resolution.
//!
//! Paths follow the XDG Base Directory layout on every platform (matching the
//! reference tool's intent and the operator's environment), rather than the
//! macOS `Library/Application Support` convention. Environment is read once, at
//! the binary edge, via [`EnvInputs::from_env`]; everything downstream is a pure
//! function of the resulting typed values (`REPO_INVARIANTS` #5).
//!
//! Per OVR-1, the only JSON surfaces are Google's `credentials.json` (its own
//! format) and the cached OAuth `token.json`; this tool's own configuration is
//! TOML.

use std::path::{Path, PathBuf};

use serde::{Deserialize, Serialize};

use crate::error::{Error, Result};

/// Subdirectory under the XDG bases that holds this tool's files.
const APP_DIR: &str = "org-gdocs";

/// Sanctioned environment inputs, captured once at the process edge.
///
/// Every field corresponds to an OVR-4 / `REPO_INVARIANTS` #5 bootstrap variable
/// that merely *locates* config or data. No tunable behavior is read from the
/// environment — that belongs in [`Config`].
#[derive(Debug, Clone, Default)]
pub struct EnvInputs {
    /// `HOME`, used to derive XDG defaults.
    pub home: Option<PathBuf>,
    /// `XDG_CONFIG_HOME` override for the config base directory.
    pub xdg_config_home: Option<PathBuf>,
    /// `XDG_DATA_HOME` override for the data base directory.
    pub xdg_data_home: Option<PathBuf>,
    /// `ORG_GDOCS_CONFIG_HOME` explicit override for this tool's config dir.
    pub config_home: Option<PathBuf>,
    /// `ORG_GDOCS_DATA_HOME` explicit override for this tool's data dir.
    pub data_home: Option<PathBuf>,
}

impl EnvInputs {
    /// Read the sanctioned environment variables at the process edge.
    #[allow(
        clippy::disallowed_methods,
        reason = "bootstrap env reads that locate config/data, performed once at the edge (REPO_INVARIANTS.md #5, OVR-4)"
    )]
    #[must_use]
    pub fn from_env() -> Self {
        Self {
            home: std::env::var_os("HOME").map(PathBuf::from),
            xdg_config_home: std::env::var_os("XDG_CONFIG_HOME").map(PathBuf::from),
            xdg_data_home: std::env::var_os("XDG_DATA_HOME").map(PathBuf::from),
            config_home: std::env::var_os("ORG_GDOCS_CONFIG_HOME").map(PathBuf::from),
            data_home: std::env::var_os("ORG_GDOCS_DATA_HOME").map(PathBuf::from),
        }
    }
}

/// Resolved on-disk directories for this tool.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Paths {
    /// Directory holding `config.toml` and (by default) `credentials.json`.
    pub config_dir: PathBuf,
    /// Directory holding the cached OAuth `token.json`.
    pub data_dir: PathBuf,
}

impl Paths {
    /// Resolve the config and data directories from captured environment inputs.
    ///
    /// Precedence: an explicit `ORG_GDOCS_*_HOME` override wins; otherwise the
    /// XDG base (`XDG_CONFIG_HOME` / `XDG_DATA_HOME`, else `~/.config` /
    /// `~/.local/share`) joined with the [`APP_DIR`] subdirectory.
    ///
    /// # Errors
    ///
    /// Returns [`Error::PathResolution`] when neither an override nor the XDG
    /// base nor `HOME` is available to anchor a directory.
    pub fn resolve(env: &EnvInputs) -> Result<Self> {
        let config_dir = match &env.config_home {
            Some(dir) => dir.clone(),
            None => xdg_base(
                env.xdg_config_home.as_deref(),
                env.home.as_deref(),
                ".config",
            )?
            .join(APP_DIR),
        };
        let data_dir = match &env.data_home {
            Some(dir) => dir.clone(),
            None => xdg_base(
                env.xdg_data_home.as_deref(),
                env.home.as_deref(),
                ".local/share",
            )?
            .join(APP_DIR),
        };
        Ok(Self {
            config_dir,
            data_dir,
        })
    }

    /// Path to this tool's TOML configuration file.
    #[must_use]
    pub fn config_file(&self) -> PathBuf {
        self.config_dir.join("config.toml")
    }

    /// Default path to the Google OAuth client secret (`credentials.json`).
    #[must_use]
    pub fn default_credentials(&self) -> PathBuf {
        self.config_dir.join("credentials.json")
    }

    /// Default path to the cached OAuth token (`token.json`).
    #[must_use]
    pub fn default_token(&self) -> PathBuf {
        self.data_dir.join("token.json")
    }
}

/// Resolve an XDG base directory: explicit override, else `HOME`/`fallback`.
fn xdg_base(xdg: Option<&Path>, home: Option<&Path>, fallback: &str) -> Result<PathBuf> {
    if let Some(dir) = xdg {
        return Ok(dir.to_path_buf());
    }
    home.map(|h| h.join(fallback))
        .ok_or_else(|| Error::PathResolution(format!("XDG base directory ({fallback})")))
}

/// User-editable configuration (TOML).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Config {
    /// Path to the Google OAuth client secret JSON (Google's format; OVR-1).
    pub credentials_path: PathBuf,
    /// Path to the cached OAuth token JSON (`yup-oauth2`; OVR-1).
    pub token_path: PathBuf,
    /// Emit verbose diagnostics to stderr.
    #[serde(default)]
    pub debug: bool,
}

impl Config {
    /// Build the default configuration for the given resolved paths.
    #[must_use]
    pub fn defaults(paths: &Paths) -> Self {
        Self {
            credentials_path: paths.default_credentials(),
            token_path: paths.default_token(),
            debug: false,
        }
    }

    /// Load configuration from `paths.config_file()`, falling back to defaults
    /// when the file does not exist.
    ///
    /// # Errors
    ///
    /// Returns [`Error::Io`] when the file exists but cannot be read, or
    /// [`Error::ConfigToml`] when its contents are not valid TOML.
    pub fn load(paths: &Paths) -> Result<Self> {
        let file = paths.config_file();
        match std::fs::read_to_string(&file) {
            Ok(text) => Ok(toml::from_str(&text)?),
            Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(Self::defaults(paths)),
            Err(err) => Err(Error::Io {
                path: file,
                source: err,
            }),
        }
    }

    /// Serialize this configuration to TOML, creating the config directory.
    ///
    /// # Errors
    ///
    /// Returns [`Error::ConfigTomlSerialize`] on encoding failure, or
    /// [`Error::Io`] when the directory or file cannot be written.
    pub fn save(&self, paths: &Paths) -> Result<()> {
        std::fs::create_dir_all(&paths.config_dir).map_err(|source| Error::Io {
            path: paths.config_dir.clone(),
            source,
        })?;
        let text = toml::to_string_pretty(self)?;
        let file = paths.config_file();
        std::fs::write(&file, text).map_err(|source| Error::Io { path: file, source })
    }
}

#[cfg(test)]
mod tests {
    use super::{Config, EnvInputs, Paths};
    use std::path::PathBuf;

    fn home_env() -> EnvInputs {
        EnvInputs {
            home: Some(PathBuf::from("/home/jfb")),
            ..EnvInputs::default()
        }
    }

    #[test]
    fn resolve_uses_xdg_defaults_under_home() {
        let paths = Paths::resolve(&home_env()).expect("home present");
        assert_eq!(
            paths.config_dir,
            PathBuf::from("/home/jfb/.config/org-gdocs")
        );
        assert_eq!(
            paths.data_dir,
            PathBuf::from("/home/jfb/.local/share/org-gdocs")
        );
        assert_eq!(
            paths.config_file(),
            PathBuf::from("/home/jfb/.config/org-gdocs/config.toml")
        );
    }

    #[test]
    fn xdg_overrides_win_over_home() {
        let env = EnvInputs {
            home: Some(PathBuf::from("/home/jfb")),
            xdg_config_home: Some(PathBuf::from("/xdg/cfg")),
            xdg_data_home: Some(PathBuf::from("/xdg/data")),
            ..EnvInputs::default()
        };
        let paths = Paths::resolve(&env).expect("xdg present");
        assert_eq!(paths.config_dir, PathBuf::from("/xdg/cfg/org-gdocs"));
        assert_eq!(paths.data_dir, PathBuf::from("/xdg/data/org-gdocs"));
    }

    #[test]
    fn explicit_tool_overrides_win_over_xdg() {
        let env = EnvInputs {
            home: Some(PathBuf::from("/home/jfb")),
            xdg_config_home: Some(PathBuf::from("/xdg/cfg")),
            config_home: Some(PathBuf::from("/explicit/cfg")),
            data_home: Some(PathBuf::from("/explicit/data")),
            ..EnvInputs::default()
        };
        let paths = Paths::resolve(&env).expect("overrides present");
        assert_eq!(paths.config_dir, PathBuf::from("/explicit/cfg"));
        assert_eq!(paths.data_dir, PathBuf::from("/explicit/data"));
    }

    #[test]
    fn resolve_without_home_or_xdg_errors() {
        assert!(Paths::resolve(&EnvInputs::default()).is_err());
    }

    #[test]
    fn defaults_place_credentials_and_token() {
        let paths = Paths::resolve(&home_env()).expect("home present");
        let config = Config::defaults(&paths);
        assert_eq!(
            config.credentials_path,
            PathBuf::from("/home/jfb/.config/org-gdocs/credentials.json")
        );
        assert_eq!(
            config.token_path,
            PathBuf::from("/home/jfb/.local/share/org-gdocs/token.json")
        );
        assert!(!config.debug);
    }

    #[test]
    fn config_round_trips_through_toml() {
        let paths = Paths::resolve(&home_env()).expect("home present");
        let config = Config::defaults(&paths);
        let text = toml::to_string_pretty(&config).expect("serialize");
        let parsed: Config = toml::from_str(&text).expect("deserialize");
        assert_eq!(config, parsed);
    }
}