use std::collections::BTreeMap;
use std::fmt;
use std::path::PathBuf;
use crate::registry::PropId;
use crate::resolve::Resolved;
use crate::source::Origin;
use crate::ty::TypeError;
use crate::value::{one_line, Value};
pub trait FromValue: Sized {
fn from_value(value: &Value) -> Result<Self, TypeError>;
}
#[derive(Debug, Clone, PartialEq)]
pub struct ReadError {
pub key: &'static str,
pub origin: Option<Origin>,
pub kind: ReadErrorKind,
}
#[derive(Debug, Clone, PartialEq)]
pub enum ReadErrorKind {
Type(TypeError),
Missing,
}
impl fmt::Display for ReadError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match &self.kind {
ReadErrorKind::Type(err) => write!(
f,
"{} expected {} but has `{}`",
self.key,
err.expected,
one_line(&err.found)
)?,
ReadErrorKind::Missing => write!(f, "{} has no value and no default", self.key)?,
}
if let Some(origin) = &self.origin {
write!(f, " (set by {})", one_line(origin.describe()))?;
}
Ok(())
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct ReadErrors(pub Vec<ReadError>);
impl fmt::Display for ReadErrors {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
for (i, error) in self.0.iter().enumerate() {
if i > 0 {
f.write_str("\n")?;
}
write!(f, "{error}")?;
}
Ok(())
}
}
impl std::error::Error for ReadErrors {}
pub struct Fold<'a> {
resolved: &'a Resolved,
errors: Vec<ReadError>,
lossy: bool,
}
impl Resolved {
pub fn fold(&self) -> Fold<'_> {
Fold {
resolved: self,
errors: Vec::new(),
lossy: false,
}
}
pub fn fold_lossy(&self) -> Fold<'_> {
Fold {
resolved: self,
errors: Vec::new(),
lossy: true,
}
}
pub fn read<T: FromValue>(&self, id: PropId) -> Result<Option<T>, ReadError> {
let mut fold = self.fold();
let value = fold.optional(id);
match fold.errors.pop() {
Some(error) => Err(error),
None => Ok(value),
}
}
}
impl Fold<'_> {
pub fn optional<T: FromValue>(&mut self, id: PropId) -> Option<T> {
let value = self.resolved.get(id)?;
match T::from_value(value) {
Ok(value) => Some(value),
Err(err) => {
self.errors.push(ReadError {
key: self.resolved.registry().get(id).key,
origin: self.resolved.origin(id).cloned(),
kind: ReadErrorKind::Type(err),
});
self.fallback(id)
}
}
}
pub fn required<T: FromValue>(&mut self, id: PropId) -> Option<T> {
if self.resolved.get(id).is_none() {
self.errors.push(ReadError {
key: self.resolved.registry().get(id).key,
origin: None,
kind: ReadErrorKind::Missing,
});
return None;
}
self.optional(id)
}
fn fallback<T: FromValue>(&self, id: PropId) -> Option<T> {
if !self.lossy {
return None;
}
let default = self.resolved.registry().get(id).default?;
T::from_value(&default.to_value()).ok()
}
pub fn errors(&self) -> &[ReadError] {
&self.errors
}
pub fn finish(self) -> Result<(), ReadErrors> {
if self.errors.is_empty() {
Ok(())
} else {
Err(ReadErrors(self.errors))
}
}
pub fn into_errors(self) -> ReadErrors {
ReadErrors(self.errors)
}
}
fn mismatch(expected: &'static str, value: &Value) -> TypeError {
TypeError {
expected,
found: crate::value::shown(value),
}
}
impl FromValue for Value {
fn from_value(value: &Value) -> Result<Self, TypeError> {
Ok(value.clone())
}
}
impl FromValue for bool {
fn from_value(value: &Value) -> Result<Self, TypeError> {
match value {
Value::Bool(b) => Ok(*b),
other => Err(mismatch("a boolean", other)),
}
}
}
impl FromValue for i64 {
fn from_value(value: &Value) -> Result<Self, TypeError> {
match value {
Value::Int(i) => Ok(*i),
other => Err(mismatch("an integer", other)),
}
}
}
impl FromValue for u64 {
fn from_value(value: &Value) -> Result<Self, TypeError> {
match value {
Value::Int(i) => {
Self::try_from(*i).map_err(|_| mismatch("a non-negative integer", value))
}
other => Err(mismatch("a non-negative integer", other)),
}
}
}
impl FromValue for f64 {
fn from_value(value: &Value) -> Result<Self, TypeError> {
match value {
Value::Float(f) => Ok(*f),
Value::Int(i) => Ok(*i as Self),
other => Err(mismatch("a number", other)),
}
}
}
impl FromValue for f32 {
fn from_value(value: &Value) -> Result<Self, TypeError> {
let wide = f64::from_value(value)?;
let narrow = wide as Self;
if narrow.is_infinite() && wide.is_finite() {
return Err(mismatch("a number that fits 32 bits", value));
}
Ok(narrow)
}
}
macro_rules! narrower_int {
($($ty:ty => $expected:literal,)*) => {$(
impl FromValue for $ty {
fn from_value(value: &Value) -> Result<Self, TypeError> {
match value {
Value::Int(i) => {
Self::try_from(*i).map_err(|_| mismatch($expected, value))
}
other => Err(mismatch($expected, other)),
}
}
}
)*};
}
narrower_int! {
u8 => "a non-negative integer that fits 8 bits",
u16 => "a non-negative integer that fits 16 bits",
u32 => "a non-negative integer that fits 32 bits",
usize => "a non-negative integer",
i8 => "an integer that fits 8 bits",
i16 => "an integer that fits 16 bits",
i32 => "an integer that fits 32 bits",
isize => "an integer",
}
impl FromValue for String {
fn from_value(value: &Value) -> Result<Self, TypeError> {
match value {
Value::String(s) => Ok(s.clone()),
other => Err(mismatch("a string", other)),
}
}
}
impl FromValue for PathBuf {
fn from_value(value: &Value) -> Result<Self, TypeError> {
match value {
Value::String(s) => Ok(Self::from(s)),
other => Err(mismatch("a path", other)),
}
}
}
impl<T: FromValue> FromValue for Vec<T> {
fn from_value(value: &Value) -> Result<Self, TypeError> {
match value {
Value::List(items) => items.iter().map(T::from_value).collect(),
other => Err(mismatch("a list", other)),
}
}
}
impl<T: FromValue> FromValue for BTreeMap<String, T> {
fn from_value(value: &Value) -> Result<Self, TypeError> {
match value {
Value::Map(entries) => entries
.iter()
.map(|(key, value)| T::from_value(value).map(|value| (key.clone(), value)))
.collect(),
other => Err(mismatch("a table", other)),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::layer::{Layer, LayerCtx, LayerError, LayerOutput};
use crate::registry::{PropMeta, Registry};
use crate::resolve::{resolve, Layers};
use crate::source::SourceKind;
use crate::ty::{Parser, Ty};
use crate::value::Const;
#[test]
fn a_value_too_wide_for_the_field_is_reported_rather_than_wrapped() {
u8::from_value(&Value::Int(256)).expect_err("256 does not fit 8 bits");
i8::from_value(&Value::Int(-129)).expect_err("-129 does not fit 8 bits");
usize::from_value(&Value::Int(-1)).expect_err("-1 is not non-negative");
assert_eq!(u8::from_value(&Value::Int(255)).expect("255 fits"), 255);
f32::from_value(&Value::Float(1e300)).expect_err("1e300 is not an f32");
f32::from_value(&Value::Float(-1e300)).expect_err("-1e300 is not an f32");
assert_eq!(
f32::from_value(&Value::Float(0.1)).expect("0.1 fits"),
0.1_f32
);
assert!(f32::from_value(&Value::Float(f64::INFINITY))
.expect("an infinity reads as one")
.is_infinite());
}
static PROPS: &[PropMeta] = &[
PropMeta {
default: Some(Const::Int(4)),
envs: &["MYCLI_JOBS"],
..PropMeta::new("jobs", Ty::Uint)
},
PropMeta {
default: Some(Const::Bool(false)),
envs: &["MYCLI_RAW"],
..PropMeta::new("raw", Ty::Bool)
},
PropMeta {
envs: &["MYCLI_CACHE_DIR"],
..PropMeta::new("cache_dir", Ty::Option(&Ty::Path))
},
PropMeta {
envs: &["MYCLI_EXCLUDE"],
parse: Some(Parser::ListByComma),
..PropMeta::new("exclude", Ty::List(&Ty::String))
},
PropMeta {
envs: &["MYCLI_PORTS"],
parse: Some(Parser::ListByComma),
..PropMeta::new("ports", Ty::List(&Ty::Uint))
},
PropMeta {
envs: &["MYCLI_ALIASES"],
..PropMeta::new("aliases", Ty::Map(&Ty::String))
},
PropMeta {
envs: &["MYCLI_RATIO"],
..PropMeta::new("ratio", Ty::Float)
},
PropMeta::new("profile", Ty::String),
];
const REGISTRY: Registry = Registry::new(PROPS);
fn id(key: &str) -> PropId {
REGISTRY.lookup(key).expect("declared").id
}
struct Text(&'static [(&'static str, &'static str)]);
impl Layer for Text {
fn source(&self) -> SourceKind {
SourceKind::ENV
}
fn load(&self, ctx: &LayerCtx) -> Result<LayerOutput, LayerError> {
let mut out = LayerOutput::new();
for (key, raw) in self.0 {
let origin = Origin::new(SourceKind::ENV, format!("MYCLI_{}", key.to_uppercase()));
match ctx.entry_for_key(key, raw, origin) {
Ok(entry) => out.push(entry),
Err(warning) => out.warn(warning),
}
}
Ok(out)
}
}
#[test]
fn a_resolution_reads_as_the_types_a_struct_holds() {
let layer = Text(&[
("jobs", "8"),
("raw", "yes"),
("cache_dir", "/tmp/cache"),
("exclude", "target,dist"),
("ports", "80,443"),
("ratio", "0.5"),
("profile", "release"),
]);
let resolved = resolve(REGISTRY, Layers::new().then(&layer)).expect("resolves");
let mut fold = resolved.fold();
let jobs: Option<u64> = fold.required(id("jobs"));
let raw: Option<bool> = fold.required(id("raw"));
let cache_dir: Option<PathBuf> = fold.optional(id("cache_dir"));
let exclude: Option<Vec<String>> = fold.required(id("exclude"));
let ports: Option<Vec<u64>> = fold.required(id("ports"));
let ratio: Option<f64> = fold.required(id("ratio"));
let profile: Option<String> = fold.required(id("profile"));
fold.finish().expect("every value fits its field");
assert_eq!(jobs, Some(8));
assert_eq!(raw, Some(true));
assert_eq!(cache_dir, Some(PathBuf::from("/tmp/cache")));
assert_eq!(
exclude,
Some(vec!["target".to_string(), "dist".to_string()]),
"a list-typed setting keeps the order the file gave it"
);
assert_eq!(
ports,
Some(vec![80, 443]),
"and reads its items as the type"
);
assert_eq!(ratio, Some(0.5));
assert_eq!(profile, Some("release".to_string()));
}
#[test]
fn a_declared_default_is_read_like_any_other_value() {
let resolved = resolve(REGISTRY, Layers::new()).expect("resolves");
let mut fold = resolved.fold();
let jobs: Option<u64> = fold.required(id("jobs"));
let cache_dir: Option<PathBuf> = fold.optional(id("cache_dir"));
let exclude: Option<Vec<String>> = fold.optional(id("exclude"));
assert_eq!(jobs, Some(4));
assert_eq!(cache_dir, None, "no default, and absence is not an error");
assert_eq!(exclude, None);
}
#[test]
fn a_setting_with_no_value_and_no_default_says_which_one() {
let resolved = resolve(REGISTRY, Layers::new()).expect("resolves");
let mut fold = resolved.fold();
let profile: Option<String> = fold.required(id("profile"));
assert_eq!(profile, None);
let err = fold.finish().expect_err("should not read");
assert_eq!(err.to_string(), "profile has no value and no default");
}
#[test]
fn a_value_the_field_cannot_hold_names_where_it_came_from() {
let mut resolved = resolve(REGISTRY, Layers::new()).expect("resolves");
resolved.coerced(id("jobs"), Value::Int(-1), "one job when raw");
let mut fold = resolved.fold();
let jobs: Option<u64> = fold.required(id("jobs"));
assert_eq!(jobs, None);
let err = fold.finish().expect_err("should not read");
assert_eq!(
err.to_string(),
"jobs expected a non-negative integer but has `-1` (set by one job when raw)"
);
}
#[test]
fn every_bad_value_is_reported_and_not_only_the_first() {
let mut resolved = resolve(REGISTRY, Layers::new()).expect("resolves");
resolved.coerced(id("jobs"), Value::Int(-1), "a hook");
resolved.coerced(id("raw"), Value::String("sometimes".into()), "a hook");
resolved.coerced(
id("ports"),
Value::List(vec![Value::Int(80), Value::Int(-443)]),
"a hook",
);
let mut fold = resolved.fold();
let _: Option<u64> = fold.required(id("jobs"));
let _: Option<bool> = fold.required(id("raw"));
let _: Option<Vec<u64>> = fold.required(id("ports"));
let _: Option<String> = fold.required(id("profile"));
let err = fold.finish().expect_err("should not read");
let message = err.to_string();
let lines: Vec<&str> = message.lines().collect();
assert_eq!(lines.len(), 4, "{err}");
assert!(
lines[0].starts_with("jobs expected a non-negative integer"),
"{err}"
);
assert!(
lines[1].starts_with("raw expected a boolean but has `sometimes`"),
"{err}"
);
assert!(
lines[2].starts_with("ports expected a non-negative integer but has `-443`"),
"{err}"
);
assert!(lines[3].starts_with("profile has no value"), "{err}");
}
#[test]
fn a_failure_stays_on_its_own_line_whatever_the_value_holds() {
let mut resolved = resolve(REGISTRY, Layers::new()).expect("resolves");
resolved.coerced(
id("jobs"),
Value::String("two\nor three".into()),
"a hook\nover two lines",
);
let mut fold = resolved.fold();
let _: Option<u64> = fold.required(id("jobs"));
let _: Option<String> = fold.required(id("profile"));
let err = fold.finish().expect_err("should not read");
let message = err.to_string();
assert_eq!(message.lines().count(), 2, "{message}");
assert!(
message.starts_with(
"jobs expected a non-negative integer but has `two\\nor three` \
(set by a hook\\nover two lines)"
),
"{message}"
);
}
#[test]
fn a_failure_about_an_empty_value_still_names_one() {
let mut resolved = resolve(REGISTRY, Layers::new()).expect("resolves");
resolved.coerced(id("jobs"), Value::List(Vec::new()), "a hook");
let mut fold = resolved.fold();
let jobs: Option<u64> = fold.required(id("jobs"));
assert_eq!(jobs, None);
let err = fold.finish().expect_err("a list is not an integer");
assert_eq!(
err.to_string(),
"jobs expected a non-negative integer but has `[]` (set by a hook)"
);
}
#[test]
fn a_type_only_the_tool_understands_is_read_as_whatever_the_field_says() {
static ANY: &[PropMeta] = &[PropMeta::new("either", Ty::Any)];
const ANY_REGISTRY: Registry = Registry::new(ANY);
let layer = Text(&[]);
let mut resolved = resolve(ANY_REGISTRY, Layers::new().then(&layer)).expect("resolves");
let id = ANY_REGISTRY.lookup("either").expect("declared").id;
resolved.coerced(id, Value::Map(BTreeMap::new()), "a hook");
let mut fold = resolved.fold();
let text: Option<String> = fold.optional(id);
assert_eq!(text, None);
let err = fold.finish().expect_err("a table is not a string");
assert!(
err.to_string().starts_with("either expected a string"),
"{err}"
);
}
#[test]
fn a_table_setting_reads_as_a_map_of_the_declared_type() {
let layer = Text(&[("aliases", "lts")]);
let mut resolved = resolve(REGISTRY, Layers::new().then(&layer)).expect("resolves");
resolved.coerced(
id("aliases"),
Value::Map(
[("node".to_string(), Value::from("20"))]
.into_iter()
.collect(),
),
"a hook",
);
let mut fold = resolved.fold();
let aliases: Option<BTreeMap<String, String>> = fold.optional(id("aliases"));
fold.finish().expect("reads");
assert_eq!(
aliases,
Some(
[("node".to_string(), "20".to_string())]
.into_iter()
.collect()
)
);
}
#[test]
fn one_setting_can_be_read_without_a_fold() {
let layer = Text(&[("jobs", "12")]);
let resolved = resolve(REGISTRY, Layers::new().then(&layer)).expect("resolves");
assert_eq!(resolved.read::<u64>(id("jobs")), Ok(Some(12)));
assert_eq!(resolved.read::<PathBuf>(id("cache_dir")), Ok(None));
let err = resolved
.read::<bool>(id("jobs"))
.expect_err("not a boolean");
assert_eq!(
err.to_string(),
"jobs expected a boolean but has `12` (set by MYCLI_JOBS)"
);
}
#[test]
fn a_lossy_fold_falls_back_to_the_declared_default_and_still_reports() {
let resolved = resolve(REGISTRY, Layers::new().then(&Text(&[]))).expect("resolves");
let mut resolved = resolved;
resolved.coerced(id("jobs"), Value::Int(-1), "a hook that got it wrong");
let mut strict = resolved.fold();
assert_eq!(strict.required::<u64>(id("jobs")), None);
assert_eq!(strict.required::<bool>(id("raw")), Some(false));
strict.finish().expect_err("one field did not read");
let mut lossy = resolved.fold_lossy();
assert_eq!(
lossy.required::<u64>(id("jobs")),
Some(4),
"the declared default, not the hook's -1"
);
assert_eq!(lossy.required::<bool>(id("raw")), Some(false));
let errors = lossy.into_errors();
assert_eq!(errors.0.len(), 1, "{errors}");
assert_eq!(errors.0[0].key, "jobs");
}
#[test]
fn a_lossy_fold_does_not_invent_a_default_that_was_never_declared() {
let resolved = resolve(REGISTRY, Layers::new().then(&Text(&[]))).expect("resolves");
let mut lossy = resolved.fold_lossy();
assert_eq!(lossy.required::<String>(id("profile")), None);
let errors = lossy.into_errors();
assert_eq!(errors.0.len(), 1, "{errors}");
assert!(matches!(errors.0[0].kind, ReadErrorKind::Missing));
}
#[test]
fn a_lossy_fold_leaves_an_optional_field_empty_when_its_value_is_bad() {
let resolved = resolve(
REGISTRY,
Layers::new().then(&Text(&[("MYCLI_RATIO", "0.5")])),
)
.expect("resolves");
let mut resolved = resolved;
resolved.coerced(id("ratio"), Value::from("not a number"), "a hook, again");
let mut lossy = resolved.fold_lossy();
assert_eq!(lossy.optional::<f64>(id("ratio")), None);
assert_eq!(lossy.into_errors().0.len(), 1);
}
}