use crate::registry::{PropId, Registry};
use crate::source::{Origin, SourceKind};
use crate::value::Value;
#[derive(Debug, Clone, PartialEq)]
pub struct Entry {
pub prop: PropId,
pub value: Value,
pub origin: Origin,
pub renamed_from: Option<&'static str>,
pub written_key: Option<&'static str>,
}
impl Entry {
pub fn new(prop: PropId, value: Value, origin: Origin) -> Self {
Self {
prop,
value,
origin,
renamed_from: None,
written_key: None,
}
}
}
#[derive(Debug, Clone, PartialEq)]
#[non_exhaustive]
pub struct Warning {
pub message: String,
pub origin: Option<Origin>,
pub kind: WarningKind,
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum WarningKind {
UnknownSetting,
WrongType,
NotAllowed,
OutOfScope,
Deprecated,
Removed,
Renamed,
NotRead,
#[default]
Other,
}
impl Warning {
pub fn new(message: impl Into<String>) -> Self {
Self {
message: message.into(),
origin: None,
kind: WarningKind::Other,
}
}
pub fn at(message: impl Into<String>, origin: Origin) -> Self {
Self {
message: message.into(),
origin: Some(origin),
kind: WarningKind::Other,
}
}
pub fn of(mut self, kind: WarningKind) -> Self {
self.kind = kind;
self
}
}
#[derive(Debug, Default, Clone, PartialEq)]
pub struct LayerOutput {
pub entries: Vec<Entry>,
pub warnings: Vec<Warning>,
}
impl LayerOutput {
pub fn new() -> Self {
Self::default()
}
pub fn push(&mut self, entry: Entry) {
self.entries.push(entry);
}
pub fn warn(&mut self, warning: Warning) {
self.warnings.push(warning);
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum LayerError {
Unreadable { source: String, why: String },
}
impl std::fmt::Display for LayerError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Unreadable { source, why } => write!(f, "could not read {source}: {why}"),
}
}
}
impl std::error::Error for LayerError {}
pub struct LayerCtx {
registry: Registry,
}
impl LayerCtx {
pub fn new(registry: Registry) -> Self {
Self { registry }
}
pub fn registry(&self) -> Registry {
self.registry
}
pub fn prop(&self, key: &str) -> Option<crate::registry::Lookup> {
self.registry.lookup(key)
}
fn folded(&self, id: PropId) -> (PropId, Option<&'static str>) {
let meta = self.registry.get(id);
match meta.renamed_to.and_then(|key| self.registry.lookup(key)) {
Some(target) if target.id != id => (target.id, Some(meta.key)),
_ => (id, None),
}
}
pub fn parse(&self, id: PropId, raw: &str) -> Result<Value, crate::ty::TypeError> {
let (id, _) = self.folded(id);
let meta = self.registry.get(id);
let value = match meta.parse {
Some(parser) => parser.split(raw),
None => Value::String(raw.to_string()),
};
meta.ty.coerce(value)
}
pub fn entry(&self, id: PropId, raw: &str, origin: Origin) -> Result<Entry, Warning> {
let (id, renamed_from) = self.folded(id);
match self.parse(id, raw) {
Ok(value) => {
let key = renamed_from.unwrap_or(self.registry.get(id).key);
if let Some(refused) = self.refused(id, &value, key, &origin) {
return Err(refused);
}
Ok(Entry {
renamed_from,
..Entry::new(id, value, origin)
})
}
Err(err) => {
let key = renamed_from.unwrap_or(self.registry.get(id).key);
Err(Warning::at(
format!("{key} expected {} but has `{}`", err.expected, err.found),
origin,
)
.of(WarningKind::WrongType))
}
}
}
}
impl LayerCtx {
fn refused(&self, id: PropId, value: &Value, key: &str, origin: &Origin) -> Option<Warning> {
let meta = self.registry.get(id);
let refused = meta.refuses(value)?;
Some(
Warning::at(
format!(
"{key} expected one of {} but has `{}`",
meta.allowed(),
crate::value::shown(refused)
),
origin.clone(),
)
.of(WarningKind::NotAllowed),
)
}
pub fn entry_for_key(&self, key: &str, raw: &str, origin: Origin) -> Result<Entry, Warning> {
let Some(found) = self.prop(key) else {
return Err(Warning::at(format!("unknown setting `{key}`"), origin)
.of(WarningKind::UnknownSetting));
};
match self.parse(found.id, raw) {
Ok(value) => {
if let Some(refused) = self.refused(found.id, &value, found.written, &origin) {
return Err(refused);
}
Ok(Entry {
renamed_from: found.renamed_from,
written_key: Some(found.written),
..Entry::new(found.id, value, origin)
})
}
Err(err) => Err(Warning::at(
format!(
"{} expected {} but has `{}`",
found.written, err.expected, err.found
),
origin,
)
.of(WarningKind::WrongType)),
}
}
pub fn entry_from_value(
&self,
key: &str,
value: Value,
origin: Origin,
) -> Result<Entry, Warning> {
let Some(found) = self.prop(key) else {
return Err(Warning::at(format!("unknown setting `{key}`"), origin)
.of(WarningKind::UnknownSetting));
};
let meta = self.registry.get(found.id);
match meta.ty.coerce(value) {
Ok(value) => {
if let Some(refused) = self.refused(found.id, &value, found.written, &origin) {
return Err(refused);
}
Ok(Entry {
renamed_from: found.renamed_from,
written_key: Some(found.written),
..Entry::new(found.id, value, origin)
})
}
Err(err) => Err(Warning::at(
format!(
"{} expected {} but has `{}`",
found.written, err.expected, err.found
),
origin,
)
.of(WarningKind::WrongType)),
}
}
}
pub trait Layer {
fn source(&self) -> SourceKind;
fn load(&self, ctx: &LayerCtx) -> Result<LayerOutput, LayerError>;
}
#[cfg(test)]
mod tests {
use super::WarningKind;
use super::*;
use crate::registry::PropMeta;
use crate::ty::{Parser, Ty};
use crate::value::Const;
static PROPS: &[PropMeta] = &[
PropMeta {
aliases: &["parallelism"],
..PropMeta::new("jobs", Ty::Uint)
},
PropMeta {
parse: Some(Parser::ListByComma),
..PropMeta::new("exclude", Ty::List(&Ty::String))
},
PropMeta {
choices: &[Const::Str("yes"), Const::Str("no")],
..PropMeta::new("colour", Ty::Bool)
},
PropMeta {
choices: &[Const::Int(1), Const::Int(2)],
..PropMeta::new("level", Ty::Any)
},
PropMeta {
aliases: &["storage"],
choices: &[
Const::Str("git"),
Const::Str("patch-file"),
Const::Str("none"),
],
..PropMeta::new("stash", Ty::String)
},
PropMeta {
parse: Some(Parser::ListByComma),
choices: &[Const::Str("yes"), Const::Str("no")],
..PropMeta::new("flags", Ty::List(&Ty::Bool))
},
PropMeta {
parse: Some(Parser::ListByComma),
choices: &[Const::Str("lint"), Const::Str("test")],
..PropMeta::new("skip", Ty::List(&Ty::String))
},
];
const REGISTRY: Registry = Registry::new(PROPS);
#[test]
fn a_value_the_spec_does_not_allow_is_refused_with_the_list_of_what_is() {
let ctx = LayerCtx::new(REGISTRY);
let origin = Origin::new(SourceKind::ENV, "HK_STASH");
let warning = ctx
.entry_for_key("stash", "svn", origin.clone())
.expect_err("not one of the three");
assert_eq!(
warning.message,
"stash expected one of git, patch-file, none but has `svn`"
);
assert_eq!(warning.kind, WarningKind::NotAllowed);
let alias_warning = ctx
.entry_for_key("storage", "svn", origin.clone())
.expect_err("alias should use the same choices");
assert_eq!(
alias_warning.message,
"storage expected one of git, patch-file, none but has `svn`"
);
assert_eq!(
ctx.entry_for_key("jobs", "lots", origin.clone())
.expect_err("not a number")
.kind,
WarningKind::WrongType
);
assert_eq!(
ctx.entry_for_key("stash", "git", origin.clone())
.map(|entry| entry.value),
Ok(Value::from("git"))
);
let alias = ctx.entry_for_key("storage", "git", origin.clone()).unwrap();
assert_eq!(alias.written_key, Some("storage"));
assert_eq!(alias.renamed_from, None);
let warning = ctx
.entry_for_key("skip", "lint,fmt", origin.clone())
.expect_err("`fmt` is not one of them");
assert_eq!(
warning.message,
"skip expected one of lint, test but has `fmt`"
);
assert!(ctx.entry_for_key("skip", "lint,test", origin).is_ok());
}
#[test]
fn a_choice_is_read_the_way_the_declared_type_reads_it() {
let ctx = LayerCtx::new(REGISTRY);
let origin = Origin::new(SourceKind::ENV, "HK_COLOUR");
assert_eq!(
ctx.entry_for_key("colour", "yes", origin.clone())
.map(|entry| entry.value),
Ok(Value::Bool(true))
);
assert_eq!(
ctx.entry_for_key("colour", "no", origin.clone())
.map(|entry| entry.value),
Ok(Value::Bool(false))
);
assert_eq!(
ctx.entry_for_key("colour", "true", origin.clone())
.map(|entry| entry.value),
Ok(Value::Bool(true))
);
assert_eq!(
ctx.entry_for_key("flags", "yes,no", origin.clone())
.map(|entry| entry.value),
Ok(Value::List(vec![Value::Bool(true), Value::Bool(false)]))
);
let warning = ctx
.entry_for_key("flags", "yes,maybe", origin)
.expect_err("`maybe` is not a boolean");
assert_eq!(warning.message, "flags expected a boolean but has `maybe`");
}
#[test]
fn a_float_choice_is_the_number_the_spec_wrote() {
static PROPS: &[PropMeta] = &[
PropMeta {
choices: &[Const::Float(1.0), Const::Float(1.5)],
..PropMeta::new("scale", Ty::String)
},
PropMeta {
choices: &[Const::Int(1), Const::Float(1.5)],
..PropMeta::new("ratio", Ty::Float)
},
];
const REGISTRY: Registry = Registry::new(PROPS);
let ctx = LayerCtx::new(REGISTRY);
let origin = Origin::new(SourceKind::ENV, "HK_SCALE");
assert_eq!(
ctx.entry_for_key("scale", "1.0", origin.clone())
.map(|entry| entry.value),
Ok(Value::from("1.0"))
);
let warning = ctx
.entry_for_key("scale", "1", origin.clone())
.expect_err("`1` is not `1.0`");
assert_eq!(
warning.message,
"scale expected one of 1.0, 1.5 but has `1`"
);
assert_eq!(
ctx.entry_for_key("ratio", "1", origin)
.map(|entry| entry.value),
Ok(Value::Float(1.0))
);
}
#[test]
fn a_type_nothing_coerces_compares_its_choices_as_written() {
let ctx = LayerCtx::new(REGISTRY);
let origin = Origin::new(SourceKind::ENV, "HK_LEVEL");
assert_eq!(
ctx.entry_for_key("level", "2", origin.clone())
.map(|entry| entry.value),
Ok(Value::from("2"))
);
let warning = ctx
.entry_for_key("level", "3", origin.clone())
.expect_err("not one of them");
assert_eq!(warning.message, "level expected one of 1, 2 but has `3`");
let warning = ctx
.entry_from_value("level", Value::List(vec![Value::Int(1)]), origin)
.expect_err("a list of one choice is not that choice");
assert_eq!(warning.message, "level expected one of 1, 2 but has `1`");
}
#[test]
fn a_setting_with_no_choices_takes_what_its_type_takes() {
let ctx = LayerCtx::new(REGISTRY);
let origin = Origin::new(SourceKind::ENV, "HK_JOBS");
assert_eq!(
ctx.entry_for_key("jobs", "8", origin).map(|e| e.value),
Ok(Value::Int(8))
);
}
#[test]
fn a_structured_value_is_held_to_the_same_choices() {
let ctx = LayerCtx::new(REGISTRY);
let origin = Origin::new(SourceKind::FILE, "hk.toml");
let warning = ctx
.entry_from_value(
"skip",
Value::List(vec![Value::from("test"), Value::from("deploy")]),
origin.clone(),
)
.expect_err("`deploy` is not one of them");
assert_eq!(
warning.message,
"skip expected one of lint, test but has `deploy`"
);
assert!(ctx
.entry_from_value("skip", Value::List(vec![Value::from("test")]), origin)
.is_ok());
}
#[test]
fn a_raw_string_is_read_the_way_the_spec_says() {
let ctx = LayerCtx::new(REGISTRY);
let jobs = ctx.prop("jobs").expect("declared").id;
assert_eq!(ctx.parse(jobs, "4"), Ok(Value::Int(4)));
let exclude = ctx.prop("exclude").expect("declared").id;
assert_eq!(
ctx.parse(exclude, "target,node_modules"),
Ok(Value::List(vec![
Value::from("target"),
Value::from("node_modules")
]))
);
}
#[test]
fn an_alias_is_read_with_the_metadata_of_the_setting_it_became() {
static PROPS: &[PropMeta] = &[
PropMeta {
parse: Some(Parser::ListByComma),
..PropMeta::new("exclude", Ty::List(&Ty::String))
},
PropMeta {
renamed_to: Some("exclude"),
..PropMeta::new("excludes", Ty::String)
},
];
const REGISTRY: Registry = Registry::new(PROPS);
let ctx = LayerCtx::new(REGISTRY);
let alias = PropId(1);
assert_eq!(
ctx.parse(alias, "target,vendor"),
Ok(Value::List(vec![
Value::from("target"),
Value::from("vendor")
]))
);
let entry = ctx
.entry(
alias,
"target",
Origin::new(SourceKind::new("git"), "hk.excludes"),
)
.expect("should parse");
assert_eq!(entry.prop, PropId(0));
assert_eq!(entry.renamed_from, Some("excludes"));
}
#[test]
fn a_bad_value_becomes_a_warning_that_names_where_it_came_from() {
let ctx = LayerCtx::new(REGISTRY);
let jobs = ctx.prop("jobs").expect("declared").id;
let origin = Origin::new(SourceKind::ENV, "HK_JOBS");
let warning = ctx
.entry(jobs, "lots", origin.clone())
.expect_err("should not be an entry");
assert_eq!(
warning.message,
"jobs expected a non-negative integer but has `lots`"
);
assert_eq!(warning.origin, Some(origin));
let alias_warning = ctx
.entry_for_key(
"parallelism",
"lots",
Origin::new(SourceKind::FILE, "config.toml"),
)
.expect_err("alias value should still be checked");
assert_eq!(
alias_warning.message,
"parallelism expected a non-negative integer but has `lots`"
);
let structured_alias_warning = ctx
.entry_from_value(
"parallelism",
Value::from("lots"),
Origin::new(SourceKind::FILE, "config.toml"),
)
.expect_err("structured alias value should still be checked");
assert_eq!(structured_alias_warning.message, alias_warning.message);
static RENAMED: &[PropMeta] = &[
PropMeta::new("jobs", Ty::Uint),
PropMeta {
renamed_to: Some("jobs"),
..PropMeta::new("concurrency", Ty::Uint)
},
];
const WITH_ALIAS: Registry = Registry::new(RENAMED);
let ctx = LayerCtx::new(WITH_ALIAS);
let warning = ctx
.entry(
PropId(1),
"lots",
Origin::new(SourceKind::ENV, "HK_CONCURRENCY"),
)
.expect_err("should not be an entry");
assert!(
warning.message.starts_with("concurrency expected"),
"{}",
warning.message
);
}
}