use std::collections::HashMap;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use serde_json::Value as Json;
pub const RELATIVE_PATH: &str = ".yah/qed/globals.toml";
pub const DOMAIN_PREFIX: &str = "qed";
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
pub struct CampGlobals {
#[serde(default)]
pub release: ReleaseGlobals,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
pub struct ReleaseGlobals {
#[serde(default)]
pub tag_hygiene: TagHygiene,
}
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
pub enum TagHygiene {
#[default]
Block,
Warn,
}
impl TagHygiene {
pub fn as_str(self) -> &'static str {
match self {
Self::Block => "block",
Self::Warn => "warn",
}
}
}
impl CampGlobals {
pub fn path_for(camp_root: &Path) -> PathBuf {
camp_root.join(RELATIVE_PATH)
}
pub fn load_default(camp_root: &Path) -> Self {
Self::load_from(&Self::path_for(camp_root))
}
pub fn load_from(path: &Path) -> Self {
match std::fs::read_to_string(path) {
Ok(text) => toml::from_str(&text).unwrap_or_else(|e| {
tracing::warn!(
path = %path.display(),
error = %e,
"qed globals: parse failed; using built-in defaults",
);
Self::default()
}),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Self::default(),
Err(e) => {
tracing::warn!(
path = %path.display(),
error = %e,
"qed globals: read failed; using built-in defaults",
);
Self::default()
}
}
}
pub fn save_to(&self, path: &Path) -> std::io::Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let text = toml::to_string_pretty(self)
.expect("CampGlobals has no non-serializable field types");
let dir = path.parent().unwrap_or_else(|| Path::new("."));
let tmp_name = format!(
".{}.tmp",
path.file_name()
.and_then(|s| s.to_str())
.unwrap_or("globals.toml")
);
let tmp = dir.join(tmp_name);
std::fs::write(&tmp, text.as_bytes())?;
std::fs::rename(&tmp, path)
}
pub fn as_flat_vars(&self) -> HashMap<String, String> {
let value = serde_json::to_value(self).expect("CampGlobals is plain data");
let mut out = HashMap::new();
flatten_into(&mut out, DOMAIN_PREFIX, &value);
out
}
pub fn as_param_defaults(&self) -> HashMap<String, String> {
let prefix = format!("{DOMAIN_PREFIX}_");
self.as_flat_vars()
.into_iter()
.filter_map(|(k, v)| k.strip_prefix(prefix.as_str()).map(|k| (k.to_string(), v)))
.collect()
}
pub fn resolve_params(
&self,
pipeline: &crate::types::Pipeline,
supplied: &HashMap<String, String>,
) -> Result<HashMap<String, String>, crate::types::ParamError> {
let mut merged = supplied.clone();
for (k, v) in self.as_param_defaults() {
merged.entry(k).or_insert(v);
}
pipeline.resolve_params(&merged)
}
}
fn flatten_into(out: &mut HashMap<String, String>, prefix: &str, value: &Json) {
match value {
Json::Object(map) => {
for (k, v) in map {
flatten_into(out, &format!("{prefix}_{k}"), v);
}
}
Json::String(s) => {
out.insert(prefix.to_string(), s.clone());
}
Json::Bool(b) => {
out.insert(prefix.to_string(), b.to_string());
}
Json::Number(n) => {
out.insert(prefix.to_string(), n.to_string());
}
Json::Null | Json::Array(_) => {}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn missing_file_loads_built_in_defaults() {
let tmp = tempfile::tempdir().unwrap();
let g = CampGlobals::load_from(&tmp.path().join("globals.toml"));
assert_eq!(g, CampGlobals::default());
assert_eq!(g.release.tag_hygiene, TagHygiene::Block);
}
#[test]
fn save_to_roundtrips_through_load_from() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join(".yah/qed/globals.toml");
let g = CampGlobals {
release: ReleaseGlobals {
tag_hygiene: TagHygiene::Warn,
},
};
g.save_to(&path).unwrap();
let loaded = CampGlobals::load_from(&path);
assert_eq!(loaded, g);
}
#[test]
fn save_to_writes_real_nested_toml_tables() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("globals.toml");
CampGlobals {
release: ReleaseGlobals {
tag_hygiene: TagHygiene::Warn,
},
}
.save_to(&path)
.unwrap();
let text = std::fs::read_to_string(&path).unwrap();
assert!(text.contains("[release]"), "expected a [release] table:\n{text}");
assert!(text.contains("tag_hygiene"));
}
#[test]
fn corrupt_file_falls_back_to_defaults_rather_than_erroring() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("globals.toml");
std::fs::write(&path, "not = [valid toml").unwrap();
assert_eq!(CampGlobals::load_from(&path), CampGlobals::default());
}
#[test]
fn as_flat_vars_joins_domain_and_path_with_underscores() {
let g = CampGlobals {
release: ReleaseGlobals {
tag_hygiene: TagHygiene::Warn,
},
};
assert_eq!(
g.as_flat_vars().get("qed_release_tag_hygiene").map(String::as_str),
Some("warn")
);
}
#[test]
fn as_param_defaults_drops_the_domain_prefix() {
let g = CampGlobals {
release: ReleaseGlobals {
tag_hygiene: TagHygiene::Warn,
},
};
let defaults = g.as_param_defaults();
assert_eq!(
defaults.get("release_tag_hygiene").map(String::as_str),
Some("warn")
);
assert!(
!defaults.contains_key("qed_release_tag_hygiene"),
"the qed_ domain segment is implicit inside `yah qed run`, not a param name"
);
}
fn release_tag_hygiene_pipeline() -> crate::types::Pipeline {
let mut params = HashMap::new();
params.insert(
"release_tag_hygiene".to_string(),
crate::types::ParamDef {
required: false,
description: None,
default: Some("block".to_string()),
options: vec!["block".to_string(), "warn".to_string()],
options_from: None,
options_cmd: None,
},
);
crate::types::Pipeline {
name: "version-bump".to_string(),
params,
..Default::default()
}
}
#[test]
fn resolve_params_fills_from_global_when_operator_supplies_nothing() {
let g = CampGlobals {
release: ReleaseGlobals {
tag_hygiene: TagHygiene::Warn,
},
};
let resolved = g
.resolve_params(&release_tag_hygiene_pipeline(), &HashMap::new())
.unwrap();
assert_eq!(
resolved.get("release_tag_hygiene").map(String::as_str),
Some("warn")
);
}
#[test]
fn resolve_params_lets_an_explicit_param_override_the_global() {
let g = CampGlobals {
release: ReleaseGlobals {
tag_hygiene: TagHygiene::Warn,
},
};
let mut supplied = HashMap::new();
supplied.insert("release_tag_hygiene".to_string(), "block".to_string());
let resolved = g
.resolve_params(&release_tag_hygiene_pipeline(), &supplied)
.unwrap();
assert_eq!(
resolved.get("release_tag_hygiene").map(String::as_str),
Some("block")
);
}
}