use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::error::{Error, Result};
const APP_DIR: &str = "org-gdocs";
#[derive(Debug, Clone, Default)]
pub struct EnvInputs {
pub home: Option<PathBuf>,
pub xdg_config_home: Option<PathBuf>,
pub xdg_data_home: Option<PathBuf>,
pub config_home: Option<PathBuf>,
pub data_home: Option<PathBuf>,
}
impl EnvInputs {
#[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),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Paths {
pub config_dir: PathBuf,
pub data_dir: PathBuf,
}
impl Paths {
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,
})
}
#[must_use]
pub fn config_file(&self) -> PathBuf {
self.config_dir.join("config.toml")
}
#[must_use]
pub fn default_credentials(&self) -> PathBuf {
self.config_dir.join("credentials.json")
}
#[must_use]
pub fn default_token(&self) -> PathBuf {
self.data_dir.join("token.json")
}
}
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})")))
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Config {
pub credentials_path: PathBuf,
pub token_path: PathBuf,
#[serde(default)]
pub debug: bool,
}
impl Config {
#[must_use]
pub fn defaults(paths: &Paths) -> Self {
Self {
credentials_path: paths.default_credentials(),
token_path: paths.default_token(),
debug: false,
}
}
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,
}),
}
}
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);
}
}