use std::collections::BTreeMap;
use camino::Utf8Path;
use crate::error::{Error, Result};
use crate::manifest::VarSpec;
const ENV_PREFIX: &str = "KATA_VAR_";
const VARS_FILE_REL: &str = ".kata/vars.toml";
#[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 path = pj_root.join(VARS_FILE_REL);
let content = match std::fs::read_to_string(&path) {
Ok(s) => s,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
return Ok(toml::Table::new());
}
Err(e) => return Err(Error::io_at(path.as_std_path(), e)),
};
toml::from_str(&content).map_err(|e| Error::Config(format!("parse {path}: {e}")))
}
}
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)>> {
if let Some(v) = self.sources.cli.get(key) {
return Ok(Some((v.clone(), VarSource::Cli)));
}
if let Some(v) = self.sources.env.get(key) {
return Ok(Some((v.clone(), VarSource::Env)));
}
if let Some(v) = self.sources.vars_file.get(key) {
return Ok(Some((v.clone(), VarSource::VarsFile)));
}
if let Some(v) = self.sources.applied.get(key) {
return Ok(Some((v.clone(), VarSource::Applied)));
}
if let Some(v) = self.sources.preset.get(key) {
return Ok(Some((v.clone(), VarSource::Preset)));
}
if let Some(v) = self.sources.template_seed.get(key) {
return Ok(Some((v.clone(), VarSource::TemplateSeed)));
}
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);
}
#[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 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());
}
}