use std::collections::BTreeMap;
use crate::error::{Error, Result};
use crate::manifest::VarSpec;
const ENV_PREFIX: &str = "KATA_VAR_";
#[derive(Debug, Clone, Default)]
pub struct VarSources {
pub cli: BTreeMap<String, toml::Value>,
pub env: BTreeMap<String, toml::Value>,
pub applied: toml::Table,
pub preset: 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 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<toml::Table> {
let mut out = toml::Table::new();
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.applied.keys() {
keys.insert(k.clone(), ());
}
for k in self.sources.preset.keys() {
keys.insert(k.clone(), ());
}
for (key, _) in keys {
let spec = self.specs.get(&key);
let value = self.resolve_one(&key, spec)?;
if let Some(v) = value {
out.insert(key, v);
}
}
Ok(out)
}
fn resolve_one(&mut self, key: &str, spec: Option<&VarSpec>) -> Result<Option<toml::Value>> {
if let Some(v) = self.sources.cli.get(key) {
return Ok(Some(v.clone()));
}
if let Some(v) = self.sources.env.get(key) {
return Ok(Some(v.clone()));
}
if let Some(v) = self.sources.applied.get(key) {
return Ok(Some(v.clone()));
}
if let Some(v) = self.sources.preset.get(key) {
return Ok(Some(v.clone()));
}
let spec = match spec {
Some(s) => s,
None => return Ok(None),
};
if let Some(v) = &spec.default {
return Ok(Some(v.clone()));
}
if self.interactive {
let v = (self.prompter)(key, spec)?;
return Ok(Some(v));
}
if spec.required {
return Err(Error::Config(format!(
"var `{key}` is required but not provided (cli/env/applied/preset/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");
}
#[test]
fn cli_wins_over_env_applied_preset_default() {
let specs = BTreeMap::from([(
"k".to_string(),
VarSpec {
prompt: None,
default: Some(toml::Value::String("from-default".into())),
required: false,
choices: None,
pattern: None,
secret: false,
},
)]);
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()))]),
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()),
)]),
};
let r = VarResolver {
specs: &specs,
sources: &sources,
interactive: false,
prompter: never_prompt,
};
let out = r.resolve().unwrap();
assert_eq!(out["k"].as_str(), Some("from-cli"));
}
#[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 = BTreeMap::from([(
"k".to_string(),
VarSpec {
prompt: None,
default: Some(toml::Value::String("d".into())),
required: false,
choices: None,
pattern: None,
secret: false,
},
)]);
let sources = VarSources::default();
let r = VarResolver {
specs: &specs,
sources: &sources,
interactive: false,
prompter: never_prompt,
};
let out = r.resolve().unwrap();
assert_eq!(out["k"].as_str(), Some("d"));
}
#[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());
}
}