#![allow(clippy::result_large_err)]
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use thiserror::Error;
use crate::paths::Resolver;
#[derive(Debug, Error)]
pub enum ToolConfigError {
#[error("tool config io {path:?}: {source}")]
Io {
path: PathBuf,
#[source]
source: io::Error,
},
#[error("tool config parse {path:?}: {source}")]
Parse {
path: PathBuf,
#[source]
source: Box<toml::de::Error>,
},
#[error("tool config encode: {0}")]
Encode(#[source] Box<toml::ser::Error>),
#[error("resolving default config path: {0}")]
Resolve(#[source] crate::paths::ResolveError),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct RepoConfig {
pub path: PathBuf,
#[serde(skip_serializing_if = "Option::is_none")]
pub url: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ToolConfig {
pub repo: RepoConfig,
}
impl ToolConfig {
pub fn default_path() -> Result<PathBuf, ToolConfigError> {
let r = Resolver::new();
let xdg = r
.resolve_var("XDG_CONFIG")
.map_err(ToolConfigError::Resolve)?;
Ok(PathBuf::from(xdg).join("krypt").join("config.toml"))
}
pub fn load(path: &Path) -> Result<Option<Self>, ToolConfigError> {
let text = match fs::read_to_string(path) {
Ok(s) => s,
Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(None),
Err(e) => {
return Err(ToolConfigError::Io {
path: path.to_path_buf(),
source: e,
});
}
};
let cfg: ToolConfig = toml::from_str(&text).map_err(|source| ToolConfigError::Parse {
path: path.to_path_buf(),
source: Box::new(source),
})?;
Ok(Some(cfg))
}
pub fn save(&self, path: &Path) -> Result<(), ToolConfigError> {
let mk_io = |source: io::Error| ToolConfigError::Io {
path: path.to_path_buf(),
source,
};
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(mk_io)?;
}
let text =
toml::to_string_pretty(self).map_err(|e| ToolConfigError::Encode(Box::new(e)))?;
let mut tmp_name = path.file_name().unwrap_or_default().to_os_string();
tmp_name.push(format!(".krypt-tmp-{}", std::process::id()));
let tmp = path.with_file_name(tmp_name);
let _ = fs::remove_file(&tmp);
fs::write(&tmp, text.as_bytes()).map_err(mk_io)?;
fs::rename(&tmp, path).map_err(mk_io)?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn load_missing_returns_none() {
let dir = tempdir().unwrap();
assert!(
ToolConfig::load(&dir.path().join("config.toml"))
.unwrap()
.is_none()
);
}
#[test]
fn save_then_load_roundtrips_with_url() {
let dir = tempdir().unwrap();
let path = dir.path().join("config.toml");
let cfg = ToolConfig {
repo: RepoConfig {
path: PathBuf::from("/home/x/.config/krypt/repo"),
url: Some("https://github.com/me/dotfiles".into()),
},
};
cfg.save(&path).unwrap();
let loaded = ToolConfig::load(&path).unwrap().unwrap();
assert_eq!(loaded.repo.path, cfg.repo.path);
assert_eq!(loaded.repo.url, cfg.repo.url);
}
#[test]
fn save_then_load_roundtrips_bare() {
let dir = tempdir().unwrap();
let path = dir.path().join("config.toml");
let cfg = ToolConfig {
repo: RepoConfig {
path: PathBuf::from("/home/x/.config/krypt/repo"),
url: None,
},
};
cfg.save(&path).unwrap();
let text = fs::read_to_string(&path).unwrap();
assert!(!text.contains("url"), "url should be omitted when None");
let loaded = ToolConfig::load(&path).unwrap().unwrap();
assert!(loaded.repo.url.is_none());
}
#[test]
fn unknown_field_rejected() {
let dir = tempdir().unwrap();
let path = dir.path().join("config.toml");
fs::write(&path, "[repo]\npath = \"/x\"\nunknown_field = true\n").unwrap();
assert!(matches!(
ToolConfig::load(&path),
Err(ToolConfigError::Parse { .. })
));
}
}