use std::path::{Path, PathBuf};
use serde::Serialize;
use serde::de::DeserializeOwned;
pub const TRUSTY_TOOLS_DIR: &str = ".trusty-tools";
pub const CONFIG_FILE: &str = "config.yaml";
#[derive(Debug, thiserror::Error)]
pub enum ConfigError {
#[error("config I/O error at {path}: {source}")]
Io {
path: PathBuf,
source: std::io::Error,
},
#[error("config YAML error at {path}: {message}")]
Yaml {
path: PathBuf,
message: String,
},
}
pub fn crate_config_dir_at(base: &Path, crate_name: &str) -> PathBuf {
base.join(TRUSTY_TOOLS_DIR).join(crate_name)
}
pub fn crate_config_path_at(base: &Path, crate_name: &str) -> PathBuf {
crate_config_dir_at(base, crate_name).join(CONFIG_FILE)
}
pub fn crate_config_dir(crate_name: &str) -> Option<PathBuf> {
dirs::home_dir().map(|home| crate_config_dir_at(&home, crate_name))
}
pub fn crate_config_path(crate_name: &str) -> Option<PathBuf> {
dirs::home_dir().map(|home| crate_config_path_at(&home, crate_name))
}
pub fn load_at<T: DeserializeOwned>(path: &Path) -> Result<Option<T>, ConfigError> {
let raw = match std::fs::read_to_string(path) {
Ok(raw) => raw,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(e) => {
return Err(ConfigError::Io {
path: path.to_path_buf(),
source: e,
});
}
};
let value = serde_yaml::from_str::<T>(&raw).map_err(|e| ConfigError::Yaml {
path: path.to_path_buf(),
message: e.to_string(),
})?;
Ok(Some(value))
}
pub fn load<T: DeserializeOwned>(crate_name: &str) -> Result<Option<T>, ConfigError> {
match crate_config_path(crate_name) {
Some(path) => load_at(path.as_path()),
None => Ok(None),
}
}
pub fn load_or_default<T: DeserializeOwned + Default>(crate_name: &str) -> T {
match load::<T>(crate_name) {
Ok(Some(value)) => value,
Ok(None) => T::default(),
Err(e) => {
tracing::warn!("{e}; falling back to default {crate_name} config");
T::default()
}
}
}
pub fn save_at<T: Serialize>(path: &Path, value: &T) -> Result<PathBuf, ConfigError> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|e| ConfigError::Io {
path: parent.to_path_buf(),
source: e,
})?;
}
let yaml = serde_yaml::to_string(value).map_err(|e| ConfigError::Yaml {
path: path.to_path_buf(),
message: e.to_string(),
})?;
let header = "# .trusty-tools/<crate>/config.yaml\n\
# Managed by the trusty-tools config convention (#1220).\n\
# Edit by hand or via the trusty-console Config tab.\n\n";
let content = format!("{header}{yaml}");
let tmp = path.with_extension("yaml.tmp");
std::fs::write(&tmp, &content).map_err(|e| ConfigError::Io {
path: tmp.clone(),
source: e,
})?;
std::fs::rename(&tmp, path).map_err(|e| ConfigError::Io {
path: path.to_path_buf(),
source: e,
})?;
Ok(path.to_path_buf())
}
pub fn save<T: Serialize>(crate_name: &str, value: &T) -> Result<PathBuf, ConfigError> {
match crate_config_path(crate_name) {
Some(path) => save_at(path.as_path(), value),
None => Err(ConfigError::Io {
path: PathBuf::from(format!("~/{TRUSTY_TOOLS_DIR}/{crate_name}/{CONFIG_FILE}")),
source: std::io::Error::new(std::io::ErrorKind::NotFound, "home directory unavailable"),
}),
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde::Deserialize;
#[derive(Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
struct Sample {
#[serde(default)]
name: String,
#[serde(default)]
count: u32,
}
#[test]
fn crate_config_path_layout() {
let p = crate_config_path_at(Path::new("/home/bob"), "trusty-mpm");
assert_eq!(
p,
PathBuf::from("/home/bob/.trusty-tools/trusty-mpm/config.yaml")
);
let d = crate_config_dir_at(Path::new("/home/bob"), "trusty-mpm");
assert_eq!(d, PathBuf::from("/home/bob/.trusty-tools/trusty-mpm"));
}
#[test]
fn load_absent_is_none() {
let tmp = tempfile::TempDir::new().unwrap();
let path = crate_config_path_at(tmp.path(), "trusty-mpm");
let got: Option<Sample> = load_at(&path).unwrap();
assert_eq!(got, None);
}
#[test]
fn save_then_load_round_trips() {
let tmp = tempfile::TempDir::new().unwrap();
let path = crate_config_path_at(tmp.path(), "trusty-mpm");
let value = Sample {
name: "demo".into(),
count: 7,
};
let written = save_at(&path, &value).unwrap();
assert_eq!(written, path);
let got: Sample = load_at(&path).unwrap().expect("present");
assert_eq!(got, value);
let raw = std::fs::read_to_string(&path).unwrap();
assert!(raw.contains("trusty-tools config convention"));
}
#[test]
fn load_or_default_on_missing() {
let tmp = tempfile::TempDir::new().unwrap();
let path = crate_config_path_at(tmp.path(), "absent-crate");
assert_eq!(load_at::<Sample>(&path).unwrap(), None);
assert_eq!(
Sample::default(),
Sample {
name: String::new(),
count: 0
}
);
}
#[test]
fn load_malformed_is_err() {
let tmp = tempfile::TempDir::new().unwrap();
let path = crate_config_path_at(tmp.path(), "trusty-mpm");
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(&path, "name: demo\ncount: not-a-number\n").unwrap();
let err = load_at::<Sample>(&path).unwrap_err();
assert!(matches!(err, ConfigError::Yaml { .. }), "got {err:?}");
}
}