use anyhow::{Context, Result, anyhow};
use std::path::Path;
use usage_rs::config::{
Layer, LayerCtx, LayerError, LayerOutput, Layers, Origin, SourceKind, Ty, Value,
};
pub use usage_rs::config::{CliLayer, EnvLayer};
#[derive(usage_rs::Config, Debug, Clone, PartialEq)]
pub struct Settings {
#[usage(
default("GITHUB_TOKEN", "GH_TOKEN"),
cli("--env-deny"),
env = "TAK_ENV_DENY",
parse = "list_by_comma",
source("config", "env.deny"),
example("tak run --env-deny AWS_PROFILE --env-deny AWS_REGION"),
example("TAK_ENV_DENY=GITHUB_TOKEN,GH_TOKEN,NPM_TOKEN tak run"),
since = "0.0.3"
)]
pub env_deny: Vec<String>,
#[usage(
default(),
cli("--env-allow"),
env = "TAK_ENV_ALLOW",
parse = "list_by_comma",
source("config", "env.allow"),
example("tak run --env-deny GITHUB_TOKEN --env-allow GITHUB_TOKEN"),
since = "0.0.3"
)]
pub env_allow: Vec<String>,
#[usage(
default = 1.0,
cli("--gate-pct"),
env = "TAK_GATE_PCT",
source("config", "gate.pct"),
example("tak compare origin/main --gate-pct 0.5"),
example("TAK_GATE_PCT=5 tak compare origin/main"),
since = "0.0.4"
)]
pub gate_pct: f64,
#[usage(
default = true,
cli("--no-credit"),
env = "TAK_CREDIT",
source("config", "report.credit"),
example("tak compare origin/main --no-credit"),
example("TAK_CREDIT=0 tak compare origin/main"),
since = "0.0.4"
)]
pub credit: bool,
#[usage(
default = "",
default_note = "derived from the machine",
cli("--runner"),
env = "TAK_RUNNER",
source("config", "runner.class"),
example("TAK_RUNNER=gha-linux-x64-rust1.85 tak run --record"),
example("tak run --runner gha-linux-x64-glibc2.39"),
since = "0.0.6"
)]
pub runner_class: String,
}
pub fn config_source() -> SourceKind {
SourceKind::new("config")
}
pub struct TakConfigLayer {
found: Option<(std::path::PathBuf, toml::Table)>,
}
impl TakConfigLayer {
pub fn find(start: &Path) -> Result<Self> {
for dir in start.ancestors() {
let path = dir.join(crate::config::FILE_NAME);
if path.is_file() {
let text = std::fs::read_to_string(&path)
.with_context(|| format!("could not read {}", path.display()))?;
let table: toml::Table = text
.parse()
.with_context(|| format!("could not parse {}", path.display()))?;
return Ok(Self {
found: Some((path, table)),
});
}
}
Ok(Self { found: None })
}
pub fn empty() -> Self {
Self { found: None }
}
#[cfg(test)]
fn from_text(text: &str) -> Self {
Self {
found: Some((
std::path::PathBuf::from("tak.toml"),
text.parse().expect("test TOML parses"),
)),
}
}
}
fn value_of(v: &toml::Value) -> Value {
match v {
toml::Value::String(s) => Value::String(s.clone()),
toml::Value::Integer(i) => Value::Int(*i),
toml::Value::Float(f) => Value::Float(*f),
toml::Value::Boolean(b) => Value::Bool(*b),
toml::Value::Datetime(d) => Value::String(d.to_string()),
toml::Value::Array(items) => Value::List(items.iter().map(value_of).collect()),
toml::Value::Table(entries) => Value::Map(
entries
.iter()
.map(|(k, v)| (k.clone(), value_of(v)))
.collect(),
),
}
}
fn written_as(ty: &Ty, v: &toml::Value) -> bool {
match ty {
Ty::Bool => v.is_bool(),
Ty::Int | Ty::Uint => v.is_integer(),
Ty::Float => v.is_float() || v.is_integer(),
Ty::String | Ty::Path | Ty::Url | Ty::Duration => v.is_str(),
Ty::List(item) | Ty::Set(item) => v
.as_array()
.is_some_and(|items| items.iter().all(|item_value| written_as(item, item_value))),
Ty::Map(value) => v
.as_table()
.is_some_and(|entries| entries.values().all(|entry| written_as(value, entry))),
Ty::Option(inner) => written_as(inner, v),
_ => true,
}
}
impl Layer for TakConfigLayer {
fn source(&self) -> SourceKind {
config_source()
}
fn load(&self, ctx: &LayerCtx) -> Result<LayerOutput, LayerError> {
let mut out = LayerOutput::new();
let Some((path, table)) = &self.found else {
return Ok(out);
};
let registry = ctx.registry();
for (id, config_key) in registry.bindings(self.source()) {
let mut parts = config_key.split('.');
let mut current = table.get(parts.next().unwrap_or_default());
for part in parts {
current = match current {
None => break,
Some(toml::Value::Table(t)) => t.get(part),
Some(_) => {
return Err(LayerError::Unreadable {
source: path.display().to_string(),
why: format!("`{config_key}` is not a table of settings"),
});
}
};
}
let Some(raw) = current else {
continue;
};
let meta = registry.get(id);
if !written_as(&meta.ty, raw) {
return Err(LayerError::Unreadable {
source: path.display().to_string(),
why: format!("`{config_key}` expected {}", meta.ty.describe()),
});
}
let origin = Origin::new(self.source(), path.display().to_string());
match ctx.entry_from_value(meta.key, value_of(raw), origin) {
Ok(entry) => out.push(entry),
Err(warning) => {
return Err(LayerError::Unreadable {
source: path.display().to_string(),
why: warning.message,
});
}
}
}
Ok(out)
}
}
struct SkipBlankRunner<'a>(&'a dyn Layer);
impl Layer for SkipBlankRunner<'_> {
fn source(&self) -> SourceKind {
self.0.source()
}
fn load(&self, ctx: &LayerCtx) -> Result<LayerOutput, LayerError> {
let mut out = self.0.load(ctx)?;
let runner = ctx.prop("runner_class").map(|found| found.id);
out.entries.retain(|entry| {
Some(entry.prop) != runner
|| !matches!(&entry.value, Value::String(s) if s.trim().is_empty())
});
Ok(out)
}
}
impl Default for Settings {
fn default() -> Self {
let resolved = usage_rs::config::resolve(Self::SETTINGS_REGISTRY, Layers::new())
.expect("no layers were given, so there is nothing to fail");
Self::read(&resolved).expect("every setting declares a default")
}
}
impl Settings {
pub fn resolve(cli: &CliLayer, env: &EnvLayer, config: &TakConfigLayer) -> Result<Self> {
let cli = SkipBlankRunner(cli);
let env = SkipBlankRunner(env);
let config = SkipBlankRunner(config);
let resolved = usage_rs::config::resolve(
Self::SETTINGS_REGISTRY,
Layers::new().then(&cli).then(&env).then(&config),
)
.map_err(|e| anyhow!("{e}"))?;
for warning in usage_rs::config::explain::warnings(&resolved) {
eprintln!("warning: {warning}");
}
let mut settings = Self::read(&resolved).map_err(|e| anyhow!("{e}"))?;
settings.env_deny.retain(|name| !name.is_empty());
settings.env_allow.retain(|name| !name.is_empty());
Ok(settings)
}
pub fn from_process(cli: &CliLayer) -> Result<Self> {
let config =
TakConfigLayer::find(&std::env::current_dir()?).context("could not read settings")?;
Self::resolve(cli, &EnvLayer::from_process(), &config)
}
pub fn display_value(&self, name: &str) -> Option<String> {
match name {
"env_allow" => Some(format!("{:?}", self.env_allow)),
"env_deny" => Some(format!("{:?}", self.env_deny)),
"credit" => Some(format!("{}", self.credit)),
"runner_class" => Some(if self.runner_class.is_empty() {
"(derived)".to_string()
} else {
self.runner_class.clone()
}),
"gate_pct" => Some(format!("{}", self.gate_pct)),
_ => None,
}
}
pub fn scrubbed_env(&self) -> impl Iterator<Item = &str> {
self.env_deny
.iter()
.filter(|name| !self.env_allow.contains(name))
.map(String::as_str)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn no_cli() -> CliLayer {
CliLayer::new(std::iter::empty::<(String, String)>())
}
fn no_env() -> EnvLayer {
EnvLayer::new(std::iter::empty::<(String, String)>())
}
fn env(vars: &[(&str, &str)]) -> EnvLayer {
EnvLayer::new(
vars.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect::<Vec<_>>(),
)
}
#[test]
fn the_default_scrubs_forge_tokens() {
let s = Settings::default();
let scrubbed: Vec<_> = s.scrubbed_env().collect();
assert!(scrubbed.contains(&"GITHUB_TOKEN"));
assert!(scrubbed.contains(&"GH_TOKEN"));
}
#[test]
fn allow_subtracts_from_deny() {
let s = Settings {
env_deny: vec!["A".into(), "B".into()],
env_allow: vec!["B".into()],
..Settings::default()
};
assert_eq!(s.scrubbed_env().collect::<Vec<_>>(), ["A"]);
}
#[test]
fn allowing_an_undenied_variable_does_nothing() {
let s = Settings {
env_deny: vec!["A".into()],
env_allow: vec!["ZZZ".into()],
..Settings::default()
};
assert_eq!(s.scrubbed_env().collect::<Vec<_>>(), ["A"]);
}
#[test]
fn cli_beats_env_beats_config() {
let cfg = TakConfigLayer::from_text("[env]\ndeny = [\"FROM_CONFIG\"]\n");
let with_env = env(&[("TAK_ENV_DENY", "FROM_ENV")]);
let from_config = Settings::resolve(&no_cli(), &no_env(), &cfg).unwrap();
assert_eq!(from_config.env_deny, ["FROM_CONFIG"]);
let from_env = Settings::resolve(&no_cli(), &with_env, &cfg).unwrap();
assert_eq!(from_env.env_deny, ["FROM_ENV"]);
let cli = no_cli().with_value("env_deny", Value::List(vec![Value::from("FROM_CLI")]));
let from_cli = Settings::resolve(&cli, &with_env, &cfg).unwrap();
assert_eq!(from_cli.env_deny, ["FROM_CLI"]);
}
#[test]
fn an_absent_source_defers_rather_than_clearing() {
let cfg = TakConfigLayer::from_text("[env]\ndeny = [\"FROM_CONFIG\"]\n");
let s = Settings::resolve(&no_cli(), &no_env(), &cfg).unwrap();
assert_eq!(s.env_deny, ["FROM_CONFIG"]);
}
#[test]
fn an_empty_variable_means_an_empty_list() {
let cfg = TakConfigLayer::from_text("[env]\ndeny = [\"FROM_CONFIG\"]\n");
let s = Settings::resolve(&no_cli(), &env(&[("TAK_ENV_DENY", "")]), &cfg).unwrap();
assert!(s.env_deny.is_empty());
}
#[test]
fn a_variable_is_split_on_commas_and_trimmed() {
let with_env = env(&[("TAK_ENV_DENY", " A , B ,, C ")]);
let s = Settings::resolve(&no_cli(), &with_env, &TakConfigLayer::empty()).unwrap();
assert_eq!(s.env_deny, ["A", "B", "C"]);
}
#[test]
fn a_blank_cli_runner_falls_through() {
let cfg = TakConfigLayer::from_text("[runner]\nclass = \"from-config\"\n");
let cli = no_cli().with("runner_class", " ");
let s = Settings::resolve(&cli, &no_env(), &cfg).unwrap();
assert_eq!(s.runner_class, "from-config");
}
#[test]
fn a_blank_runner_variable_falls_through() {
let cfg = TakConfigLayer::from_text("[runner]\nclass = \"from-config\"\n");
let s = Settings::resolve(&no_cli(), &env(&[("TAK_RUNNER", "")]), &cfg).unwrap();
assert_eq!(s.runner_class, "from-config");
}
#[test]
fn non_setting_keys_are_not_looked_at() {
let cfg = TakConfigLayer::from_text(
"[bench.startup]\ncmd = \"./x --version\"\n[gate]\npct = 0.5\n",
);
let s = Settings::resolve(&no_cli(), &no_env(), &cfg).unwrap();
assert_eq!(s.gate_pct, 0.5);
}
#[test]
fn a_wrongly_typed_config_key_is_an_error() {
let cfg = TakConfigLayer::from_text("[env]\ndeny = \"not a list\"\n");
let err = Settings::resolve(&no_cli(), &no_env(), &cfg).unwrap_err();
assert!(format!("{err:#}").contains("env.deny"), "{err:#}");
}
#[test]
fn every_declared_env_var_is_honoured() {
for meta in Settings::SETTINGS_PROPS {
for var in meta.envs {
let sentinel = if meta.ty == Ty::Bool {
"false"
} else {
"12345"
};
let with_env = env(&[(var, sentinel)]);
let got =
Settings::resolve(&no_cli(), &with_env, &TakConfigLayer::empty()).unwrap();
assert_ne!(
got,
Settings::default(),
"`{}` declares {var} but setting it changes nothing",
meta.key
);
}
}
}
fn config_sentinel(ty: &Ty) -> String {
match ty {
Ty::List(_) => "[\"SENTINEL\"]".to_string(),
Ty::Float => "12345.0".to_string(),
Ty::Bool => "false".to_string(),
Ty::String => "\"SENTINEL\"".to_string(),
other => panic!(
"the drift check has no sentinel for type `{}`",
other.name()
),
}
}
#[test]
fn every_declared_config_key_is_honoured() {
let kind = config_source();
for meta in Settings::SETTINGS_PROPS {
for (source, key) in meta.bindings {
if *source != kind.name() {
continue;
}
let text = format!("{key} = {}\n", config_sentinel(&meta.ty));
let cfg = TakConfigLayer::from_text(&text);
let got = Settings::resolve(&no_cli(), &no_env(), &cfg).unwrap();
assert_ne!(
got,
Settings::default(),
"`{}` declares config key `{key}` but setting it changes nothing",
meta.key
);
}
}
}
#[test]
fn every_setting_has_an_accessor() {
let s = Settings::default();
for meta in Settings::SETTINGS_PROPS {
assert!(
s.display_value(meta.key).is_some(),
"`{}` has no accessor in Settings::display_value",
meta.key
);
}
}
#[test]
fn every_setting_declares_a_source() {
for meta in Settings::SETTINGS_PROPS {
assert!(
!meta.cli.is_empty() || !meta.envs.is_empty() || !meta.bindings.is_empty(),
"`{}` has no sources",
meta.key
);
}
}
}