use std::collections::BTreeMap;
use std::path::Path;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct FieldDef {
pub key: String,
pub label: String,
#[serde(rename = "type", default = "string_type")]
pub field_type: String,
#[serde(default = "yes")]
pub secret: bool,
#[serde(default = "yes")]
pub required: bool,
}
fn yes() -> bool {
true
}
fn string_type() -> String {
"std:string".to_string()
}
#[derive(Debug, Clone, Default)]
pub struct FieldRegistry {
fields: BTreeMap<String, FieldDef>,
}
impl FieldRegistry {
pub fn load(dir: &Path) -> std::io::Result<Self> {
let mut reg = Self::default();
if !dir.is_dir() {
return Ok(reg);
}
let mut entries: Vec<_> = std::fs::read_dir(dir)?
.filter_map(Result::ok)
.map(|e| e.path())
.filter(|p| p.extension().is_some_and(|x| x == "toml"))
.collect();
entries.sort();
for path in entries {
let text = std::fs::read_to_string(&path)?;
let def: FieldDef = toml::from_str(&text).map_err(std::io::Error::other)?;
if def.key.starts_with("std:") {
eprintln!(
"act: warning: {} defines '{}', and the std: namespace is the \
spec's — ignoring the file. Name the field in your own \
namespace instead.",
path.display(),
def.key
);
continue;
}
reg.fields.insert(def.key.clone(), def);
}
Ok(reg)
}
pub fn get(&self, name: &str) -> Option<&FieldDef> {
self.fields.get(name)
}
pub fn resolve(&self, name: &str) -> FieldDef {
self.get(name).cloned().unwrap_or_else(|| FieldDef {
key: name.to_string(),
label: name.to_string(),
field_type: string_type(),
secret: true,
required: true,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn no_field_name_is_well_known() {
let r = FieldRegistry::default();
for gone in ["std:username", "std:password", "std:token"] {
assert!(
r.get(gone).is_none(),
"{gone} was vocabulary, and vocabulary is not the host's to issue"
);
}
}
#[test]
fn a_type_is_never_a_field_name() {
let r = FieldRegistry::default();
for gone in ["std:string", "std:oauth2", "std:basic", "std:opaque"] {
assert!(r.get(gone).is_none(), "{gone} is a type, never a name");
}
}
#[test]
fn a_name_resolves_to_a_secret_string_labelled_by_itself() {
let d = FieldRegistry::default().resolve("acme:token");
assert_eq!(d.key, "acme:token");
assert_eq!(d.label, "acme:token");
assert_eq!(d.field_type, "std:string");
assert!(d.secret);
}
#[test]
fn an_operator_definition_resolves_over_the_bare_name() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("t.toml"),
"key = \"acme:tenant\"\nlabel = \"Tenant\"\n",
)
.unwrap();
let r = FieldRegistry::load(dir.path()).unwrap();
assert_eq!(
r.resolve("acme:tenant").label,
"Tenant",
"the operator's word, not the raw name"
);
}
#[test]
fn operator_files_add_names_but_never_mint_a_std_one() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("acme.toml"),
"key = \"acme:tenant\"\nlabel = \"Tenant\"\nsecret = false\n",
)
.unwrap();
std::fs::write(
dir.path().join("evil.toml"),
"key = \"std:password\"\nlabel = \"Hijacked\"\n",
)
.unwrap();
let r = FieldRegistry::load(dir.path()).unwrap();
let acme = r.get("acme:tenant").expect("operator names load");
assert!(!acme.secret, "an operator may say a field is not material");
assert_eq!(acme.field_type, "std:string", "type defaults when omitted");
assert!(
r.get("std:password").is_none(),
"the std: namespace is the spec's; a local file must not mint into it"
);
}
#[test]
fn a_toml_field_may_name_its_type() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("acme.toml"),
"key = \"acme:tok\"\nlabel = \"Tok\"\ntype = \"std:oauth2\"\n",
)
.unwrap();
let r = FieldRegistry::load(dir.path()).unwrap();
assert_eq!(r.get("acme:tok").unwrap().field_type, "std:oauth2");
}
}