use std::collections::BTreeMap;
use camino::Utf8Path;
use crate::error::{Error, Result};
use crate::manifest::VarSpec;
const ENV_PREFIX: &str = "KATA_VAR_";
pub(crate) const KATA_DIR_REL: &str = ".kata";
pub(crate) fn matches_vars_pattern(name: &str) -> bool {
if name == "vars.toml" {
return true;
}
let Some(rest) = name.strip_prefix("vars.") else {
return false;
};
let Some(middle) = rest.strip_suffix(".toml") else {
return false;
};
!middle.is_empty() && !middle.starts_with('.')
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum VarSource {
Cli,
Env,
VarsFile,
Applied,
Preset,
TemplateSeed,
Default,
Prompt,
}
impl VarSource {
pub fn should_persist_in_applied(self) -> bool {
matches!(
self,
VarSource::Cli | VarSource::Env | VarSource::Prompt | VarSource::Applied
)
}
}
#[derive(Debug, Clone, Default)]
pub struct VarSources {
pub cli: BTreeMap<String, toml::Value>,
pub env: BTreeMap<String, toml::Value>,
pub vars_file: toml::Table,
pub applied: toml::Table,
pub preset: toml::Table,
pub template_seed: toml::Table,
}
impl VarSources {
pub fn from_env() -> BTreeMap<String, toml::Value> {
let mut out = BTreeMap::new();
for (k, v) in std::env::vars_os() {
let Ok(k) = k.into_string() else { continue };
let Ok(v) = v.into_string() else { continue };
if let Some(name) = k.strip_prefix(ENV_PREFIX) {
out.insert(name.to_string(), toml::Value::String(v));
}
}
out
}
pub fn load_vars_file(pj_root: &Utf8Path) -> Result<toml::Table> {
let kata_dir = pj_root.join(KATA_DIR_REL);
let entries = match std::fs::read_dir(kata_dir.as_std_path()) {
Ok(rd) => rd,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
return Ok(toml::Table::new());
}
Err(e) => return Err(Error::io_at(kata_dir.as_std_path(), e)),
};
let mut paths: Vec<std::path::PathBuf> = Vec::new();
for entry in entries {
let entry = entry.map_err(|e| Error::io_at(kata_dir.as_std_path(), e))?;
let path = entry.path();
let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
continue;
};
if matches_vars_pattern(name) {
paths.push(path);
}
}
paths.sort_by(|a, b| {
let name_a = a.file_name().and_then(|n| n.to_str()).unwrap_or("");
let name_b = b.file_name().and_then(|n| n.to_str()).unwrap_or("");
match (name_a == "vars.toml", name_b == "vars.toml") {
(true, true) => std::cmp::Ordering::Equal,
(true, false) => std::cmp::Ordering::Greater,
(false, true) => std::cmp::Ordering::Less,
(false, false) => name_a.cmp(name_b),
}
});
let mut merged = toml::Table::new();
for path in paths {
let content =
std::fs::read_to_string(&path).map_err(|e| Error::io_at(path.as_path(), e))?;
let parsed: toml::Table = toml::from_str(&content)
.map_err(|e| Error::Config(format!("parse {}: {e}", path.display())))?;
deep_merge_table(&mut merged, parsed);
}
Ok(merged)
}
pub fn get_in_precedence_order(&self, key: &str) -> Vec<(Option<&toml::Value>, VarSource)> {
vec![
(self.template_seed.get(key), VarSource::TemplateSeed),
(self.preset.get(key), VarSource::Preset),
(self.applied.get(key), VarSource::Applied),
(self.vars_file.get(key), VarSource::VarsFile),
(self.env.get(key), VarSource::Env),
(self.cli.get(key), VarSource::Cli),
]
}
}
pub fn deep_merge_table(dst: &mut toml::Table, src: toml::Table) {
for (k, v) in src {
match (dst.get_mut(&k), v) {
(Some(toml::Value::Table(dst_t)), toml::Value::Table(src_t)) => {
deep_merge_table(dst_t, src_t);
}
(_, v) => {
dst.insert(k, v);
}
}
}
}
#[derive(Debug, Clone, Default)]
pub struct ResolvedVars {
pub values: toml::Table,
pub sources: BTreeMap<String, VarSource>,
}
pub struct VarResolver<'a, F> {
pub specs: &'a BTreeMap<String, VarSpec>,
pub sources: &'a VarSources,
pub interactive: bool,
pub prompter: F,
}
impl<'a, F> VarResolver<'a, F>
where
F: FnMut(&str, &VarSpec) -> Result<toml::Value>,
{
pub fn resolve(mut self) -> Result<ResolvedVars> {
let mut out = ResolvedVars::default();
let mut keys: BTreeMap<String, ()> = BTreeMap::new();
for k in self.specs.keys() {
keys.insert(k.clone(), ());
}
for k in self.sources.cli.keys() {
keys.insert(k.clone(), ());
}
for k in self.sources.env.keys() {
keys.insert(k.clone(), ());
}
for k in self.sources.vars_file.keys() {
keys.insert(k.clone(), ());
}
for k in self.sources.applied.keys() {
keys.insert(k.clone(), ());
}
for k in self.sources.preset.keys() {
keys.insert(k.clone(), ());
}
for k in self.sources.template_seed.keys() {
keys.insert(k.clone(), ());
}
for (key, _) in keys {
let spec = self.specs.get(&key);
if let Some((value, source)) = self.resolve_one(&key, spec)? {
out.values.insert(key.clone(), value);
out.sources.insert(key, source);
}
}
Ok(out)
}
fn resolve_one(
&mut self,
key: &str,
spec: Option<&VarSpec>,
) -> Result<Option<(toml::Value, VarSource)>> {
let mut merged: Option<toml::Value> = None;
let mut highest: Option<VarSource> = None;
for (val_opt, source) in self.sources.get_in_precedence_order(key) {
let Some(val) = val_opt else { continue };
highest = Some(source);
merged = Some(match (merged.take(), val.clone()) {
(None, new) => new,
(Some(toml::Value::Table(mut acc)), toml::Value::Table(new_t)) => {
deep_merge_table(&mut acc, new_t);
toml::Value::Table(acc)
}
(Some(_), new) => {
new
}
});
}
if let Some(value) = merged {
return Ok(Some((
value,
highest.expect("highest set whenever merged is"),
)));
}
let spec = match spec {
Some(s) => s,
None => return Ok(None),
};
if let Some(v) = &spec.default {
return Ok(Some((v.clone(), VarSource::Default)));
}
if self.interactive {
let v = (self.prompter)(key, spec)?;
return Ok(Some((v, VarSource::Prompt)));
}
if spec.required {
return Err(Error::Config(format!(
"var `{key}` is required but not provided (cli/env/.kata/vars.toml/applied/preset/template-seed/default all empty)"
)));
}
Ok(None)
}
}
pub fn parse_cli_var(s: &str) -> Result<(String, toml::Value)> {
let (k, v) = s
.split_once('=')
.ok_or_else(|| Error::Config(format!("--var expects `name=value`, got {s:?}")))?;
let k = k.trim().to_string();
let v = v.trim();
if k.is_empty() {
return Err(Error::Config(format!("--var has empty name in {s:?}")));
}
let parsed: toml::Value = if v == "true" {
toml::Value::Boolean(true)
} else if v == "false" {
toml::Value::Boolean(false)
} else if let Ok(n) = v.parse::<i64>() {
toml::Value::Integer(n)
} else if let Ok(n) = v.parse::<f64>() {
toml::Value::Float(n)
} else {
toml::Value::String(v.to_string())
};
Ok((k, parsed))
}
#[cfg(test)]
mod tests {
use super::*;
fn never_prompt(_: &str, _: &VarSpec) -> Result<toml::Value> {
panic!("prompt should not have been called");
}
fn spec_with_default(default: &str) -> BTreeMap<String, VarSpec> {
BTreeMap::from([(
"k".to_string(),
VarSpec {
prompt: None,
default: Some(toml::Value::String(default.into())),
required: false,
choices: None,
pattern: None,
secret: false,
},
)])
}
#[test]
fn cli_wins_over_every_other_source() {
let specs = spec_with_default("from-default");
let sources = VarSources {
cli: BTreeMap::from([("k".to_string(), toml::Value::String("from-cli".into()))]),
env: BTreeMap::from([("k".to_string(), toml::Value::String("from-env".into()))]),
vars_file: toml::Table::from_iter([(
"k".to_string(),
toml::Value::String("from-vars-file".into()),
)]),
applied: toml::Table::from_iter([(
"k".to_string(),
toml::Value::String("from-applied".into()),
)]),
preset: toml::Table::from_iter([(
"k".to_string(),
toml::Value::String("from-preset".into()),
)]),
template_seed: toml::Table::from_iter([(
"k".to_string(),
toml::Value::String("from-template-seed".into()),
)]),
};
let r = VarResolver {
specs: &specs,
sources: &sources,
interactive: false,
prompter: never_prompt,
};
let out = r.resolve().unwrap();
assert_eq!(out.values["k"].as_str(), Some("from-cli"));
assert_eq!(out.sources["k"], VarSource::Cli);
}
fn table(entries: &[(&str, &str)]) -> toml::Value {
toml::Value::Table(toml::Table::from_iter(
entries
.iter()
.map(|(k, v)| (k.to_string(), toml::Value::String((*v).to_string()))),
))
}
#[test]
fn partial_table_in_vars_file_does_not_shadow_template_seed_siblings() {
let specs = BTreeMap::new();
let sources = VarSources {
vars_file: toml::Table::from_iter([(
"actions".to_string(),
table(&[("checkout", "v6.0.2"), ("create_pull_request", "v8")]),
)]),
template_seed: toml::Table::from_iter([(
"actions".to_string(),
table(&[
("checkout", "v6"),
("create_pull_request", "v7"),
("swatinem_rust_cache", "v2"),
]),
)]),
..Default::default()
};
let r = VarResolver {
specs: &specs,
sources: &sources,
interactive: false,
prompter: never_prompt,
};
let out = r.resolve().unwrap();
let actions = out.values["actions"].as_table().expect("table");
assert_eq!(actions["checkout"].as_str(), Some("v6.0.2"));
assert_eq!(actions["create_pull_request"].as_str(), Some("v8"));
assert_eq!(
actions["swatinem_rust_cache"].as_str(),
Some("v2"),
"template_seed's swatinem_rust_cache must survive a partial vars_file overlay",
);
assert_eq!(out.sources["actions"], VarSource::VarsFile);
}
#[test]
fn scalar_in_higher_source_still_overrides_table_in_lower() {
let specs = BTreeMap::new();
let sources = VarSources {
vars_file: toml::Table::from_iter([(
"k".to_string(),
toml::Value::String("scalar-wins".into()),
)]),
template_seed: toml::Table::from_iter([(
"k".to_string(),
table(&[("nested", "from-seed")]),
)]),
..Default::default()
};
let r = VarResolver {
specs: &specs,
sources: &sources,
interactive: false,
prompter: never_prompt,
};
let out = r.resolve().unwrap();
assert_eq!(out.values["k"].as_str(), Some("scalar-wins"));
}
#[test]
fn vars_file_wins_over_applied_preset_template_seed_default() {
let specs = spec_with_default("from-default");
let sources = VarSources {
vars_file: toml::Table::from_iter([(
"k".to_string(),
toml::Value::String("from-vars-file".into()),
)]),
applied: toml::Table::from_iter([(
"k".to_string(),
toml::Value::String("from-applied".into()),
)]),
preset: toml::Table::from_iter([(
"k".to_string(),
toml::Value::String("from-preset".into()),
)]),
template_seed: toml::Table::from_iter([(
"k".to_string(),
toml::Value::String("from-template-seed".into()),
)]),
..Default::default()
};
let r = VarResolver {
specs: &specs,
sources: &sources,
interactive: false,
prompter: never_prompt,
};
let out = r.resolve().unwrap();
assert_eq!(out.values["k"].as_str(), Some("from-vars-file"));
assert_eq!(out.sources["k"], VarSource::VarsFile);
}
#[test]
fn template_seed_feeds_renderer_when_no_vars_file_yet() {
let specs = BTreeMap::new();
let sources = VarSources {
template_seed: toml::Table::from_iter([(
"k".to_string(),
toml::Value::String("from-template-seed".into()),
)]),
..Default::default()
};
let r = VarResolver {
specs: &specs,
sources: &sources,
interactive: false,
prompter: never_prompt,
};
let out = r.resolve().unwrap();
assert_eq!(out.values["k"].as_str(), Some("from-template-seed"));
assert_eq!(out.sources["k"], VarSource::TemplateSeed);
}
#[test]
fn template_seed_is_below_preset() {
let specs = BTreeMap::new();
let sources = VarSources {
preset: toml::Table::from_iter([(
"k".to_string(),
toml::Value::String("from-preset".into()),
)]),
template_seed: toml::Table::from_iter([(
"k".to_string(),
toml::Value::String("from-template-seed".into()),
)]),
..Default::default()
};
let r = VarResolver {
specs: &specs,
sources: &sources,
interactive: false,
prompter: never_prompt,
};
let out = r.resolve().unwrap();
assert_eq!(out.values["k"].as_str(), Some("from-preset"));
assert_eq!(out.sources["k"], VarSource::Preset);
}
#[test]
fn template_seed_above_manifest_default() {
let specs = spec_with_default("from-default");
let sources = VarSources {
template_seed: toml::Table::from_iter([(
"k".to_string(),
toml::Value::String("from-template-seed".into()),
)]),
..Default::default()
};
let r = VarResolver {
specs: &specs,
sources: &sources,
interactive: false,
prompter: never_prompt,
};
let out = r.resolve().unwrap();
assert_eq!(out.values["k"].as_str(), Some("from-template-seed"));
assert_eq!(out.sources["k"], VarSource::TemplateSeed);
}
#[test]
fn provenance_tracks_each_source_correctly() {
let specs = BTreeMap::from([(
"from_default_key".to_string(),
VarSpec {
prompt: None,
default: Some(toml::Value::String("d".into())),
required: false,
choices: None,
pattern: None,
secret: false,
},
)]);
let sources = VarSources {
cli: BTreeMap::from([("cli_key".to_string(), toml::Value::String("c".into()))]),
env: BTreeMap::from([("env_key".to_string(), toml::Value::String("e".into()))]),
vars_file: toml::Table::from_iter([(
"vars_file_key".to_string(),
toml::Value::String("vf".into()),
)]),
applied: toml::Table::from_iter([(
"applied_key".to_string(),
toml::Value::String("a".into()),
)]),
preset: toml::Table::from_iter([(
"preset_key".to_string(),
toml::Value::String("p".into()),
)]),
template_seed: toml::Table::from_iter([(
"template_seed_key".to_string(),
toml::Value::String("ts".into()),
)]),
};
let r = VarResolver {
specs: &specs,
sources: &sources,
interactive: false,
prompter: never_prompt,
};
let out = r.resolve().unwrap();
assert_eq!(out.sources["cli_key"], VarSource::Cli);
assert_eq!(out.sources["env_key"], VarSource::Env);
assert_eq!(out.sources["vars_file_key"], VarSource::VarsFile);
assert_eq!(out.sources["applied_key"], VarSource::Applied);
assert_eq!(out.sources["preset_key"], VarSource::Preset);
assert_eq!(out.sources["template_seed_key"], VarSource::TemplateSeed);
assert_eq!(out.sources["from_default_key"], VarSource::Default);
}
#[test]
fn should_persist_in_applied_includes_user_typed_and_applied_carry() {
assert!(VarSource::Cli.should_persist_in_applied());
assert!(VarSource::Env.should_persist_in_applied());
assert!(VarSource::Prompt.should_persist_in_applied());
assert!(VarSource::Applied.should_persist_in_applied());
assert!(!VarSource::VarsFile.should_persist_in_applied());
assert!(!VarSource::Preset.should_persist_in_applied());
assert!(!VarSource::TemplateSeed.should_persist_in_applied());
assert!(!VarSource::Default.should_persist_in_applied());
}
#[test]
fn deep_merge_table_combines_nested_keys() {
let mut dst = toml::Table::new();
dst.insert(
"actions".to_string(),
toml::Value::Table(toml::Table::from_iter([(
"checkout".to_string(),
toml::Value::String("v6".into()),
)])),
);
let src = toml::Table::from_iter([(
"actions".to_string(),
toml::Value::Table(toml::Table::from_iter([(
"swatinem_rust_cache".to_string(),
toml::Value::String("v2".into()),
)])),
)]);
deep_merge_table(&mut dst, src);
let actions = dst["actions"].as_table().unwrap();
assert_eq!(actions["checkout"].as_str(), Some("v6"));
assert_eq!(actions["swatinem_rust_cache"].as_str(), Some("v2"));
}
#[test]
fn deep_merge_table_later_wins_on_leaf_conflict() {
let mut dst =
toml::Table::from_iter([("k".to_string(), toml::Value::String("first".into()))]);
deep_merge_table(
&mut dst,
toml::Table::from_iter([("k".to_string(), toml::Value::String("second".into()))]),
);
assert_eq!(dst["k"].as_str(), Some("second"));
}
#[test]
fn load_vars_file_returns_empty_when_missing() {
let tmp = tempfile::tempdir().unwrap();
let root = camino::Utf8Path::from_path(tmp.path()).unwrap();
let out = VarSources::load_vars_file(root).unwrap();
assert!(out.is_empty());
}
#[test]
fn load_vars_file_parses_toml() {
let tmp = tempfile::tempdir().unwrap();
let root = camino::Utf8Path::from_path(tmp.path()).unwrap();
std::fs::create_dir_all(root.join(".kata")).unwrap();
std::fs::write(
root.join(".kata/vars.toml"),
"key = \"value\"\n[group]\nnested = 1\n",
)
.unwrap();
let out = VarSources::load_vars_file(root).unwrap();
assert_eq!(out["key"].as_str(), Some("value"));
assert_eq!(out["group"]["nested"].as_integer(), Some(1));
}
#[test]
fn load_vars_file_errors_on_malformed_toml() {
let tmp = tempfile::tempdir().unwrap();
let root = camino::Utf8Path::from_path(tmp.path()).unwrap();
std::fs::create_dir_all(root.join(".kata")).unwrap();
std::fs::write(root.join(".kata/vars.toml"), "this is = not [valid\n").unwrap();
let err = VarSources::load_vars_file(root).unwrap_err();
assert!(matches!(err, Error::Config(_)));
}
#[test]
fn load_vars_file_merges_layered_vars_toml_files() {
let tmp = tempfile::tempdir().unwrap();
let root = camino::Utf8Path::from_path(tmp.path()).unwrap();
std::fs::create_dir_all(root.join(".kata")).unwrap();
std::fs::write(
root.join(".kata/vars.toml"),
"[actions]\ncheckout = \"v4\"\n",
)
.unwrap();
std::fs::write(
root.join(".kata/vars.rust.toml"),
"[actions]\nswatinem = \"v2\"\n",
)
.unwrap();
std::fs::write(
root.join(".kata/vars.react.toml"),
"[actions]\nsetup_node = \"v4\"\n",
)
.unwrap();
let out = VarSources::load_vars_file(root).unwrap();
let actions = out["actions"].as_table().expect("actions table");
assert_eq!(actions["checkout"].as_str(), Some("v4"));
assert_eq!(actions["swatinem"].as_str(), Some("v2"));
assert_eq!(actions["setup_node"].as_str(), Some("v4"));
}
#[test]
fn load_vars_file_bare_vars_toml_wins_on_leaf_conflict() {
let tmp = tempfile::tempdir().unwrap();
let root = camino::Utf8Path::from_path(tmp.path()).unwrap();
std::fs::create_dir_all(root.join(".kata")).unwrap();
std::fs::write(
root.join(".kata/vars.rust.toml"),
"version = \"layer-rust\"\n",
)
.unwrap();
std::fs::write(
root.join(".kata/vars.toml"),
"version = \"consumer-pinned\"\n",
)
.unwrap();
let out = VarSources::load_vars_file(root).unwrap();
assert_eq!(out["version"].as_str(), Some("consumer-pinned"));
}
#[test]
fn load_vars_file_bare_vars_toml_wins_even_when_layer_name_sorts_after_t() {
let tmp = tempfile::tempdir().unwrap();
let root = camino::Utf8Path::from_path(tmp.path()).unwrap();
std::fs::create_dir_all(root.join(".kata")).unwrap();
std::fs::write(
root.join(".kata/vars.web.toml"),
"version = \"layer-web\"\n",
)
.unwrap();
std::fs::write(
root.join(".kata/vars.toml"),
"version = \"consumer-pinned\"\n",
)
.unwrap();
let out = VarSources::load_vars_file(root).unwrap();
assert_eq!(
out["version"].as_str(),
Some("consumer-pinned"),
"`vars.toml` must win on leaf-key conflict regardless of layer-name first letter",
);
}
#[test]
fn matches_vars_pattern_rules() {
assert!(matches_vars_pattern("vars.toml"));
assert!(matches_vars_pattern("vars.rust.toml"));
assert!(matches_vars_pattern("vars.x.toml"));
assert!(!matches_vars_pattern("varz.toml"));
assert!(!matches_vars_pattern("not-vars.toml"));
assert!(!matches_vars_pattern("vars.tom")); assert!(!matches_vars_pattern("vars..toml")); assert!(!matches_vars_pattern("vars.toml.bak"));
assert!(!matches_vars_pattern("vars"));
}
#[test]
fn errors_on_required_missing_non_interactive() {
let specs = BTreeMap::from([(
"needed".to_string(),
VarSpec {
prompt: None,
default: None,
required: true,
choices: None,
pattern: None,
secret: false,
},
)]);
let sources = VarSources::default();
let r = VarResolver {
specs: &specs,
sources: &sources,
interactive: false,
prompter: never_prompt,
};
let err = r.resolve().unwrap_err();
assert!(matches!(err, Error::Config(_)));
}
#[test]
fn manifest_default_used_when_no_source() {
let specs = spec_with_default("d");
let sources = VarSources::default();
let r = VarResolver {
specs: &specs,
sources: &sources,
interactive: false,
prompter: never_prompt,
};
let out = r.resolve().unwrap();
assert_eq!(out.values["k"].as_str(), Some("d"));
assert_eq!(out.sources["k"], VarSource::Default);
}
#[test]
fn parses_cli_var_typed() {
assert_eq!(
parse_cli_var("name=foo").unwrap(),
("name".into(), toml::Value::String("foo".into()))
);
assert_eq!(
parse_cli_var("count=42").unwrap(),
("count".into(), toml::Value::Integer(42))
);
assert_eq!(
parse_cli_var("flag=true").unwrap(),
("flag".into(), toml::Value::Boolean(true))
);
assert!(parse_cli_var("nope").is_err());
assert!(parse_cli_var("=val").is_err());
}
}