blob: 017ede59df360ad5fb6571a87e0bb93a3666d541 [file] [log] [blame]
// Copyright 2025 The Fuchsia Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
// DESIGN
//
// TestConfigBuilder combines input from the command line, environment variables, and JSON files
// to create a single, aggregate test configuration as a generic serde_json::Value tree. The
// resulting Value tree is parsed into a specific type using serde_json::from_value.
//
// TestConfigBuilder relies heavily on the test configuration schema to validate the parameter
// assignments it processes. Final validation includes validating the final Value against the
// schema.
//
// Processing of the command line and environment variables employs the parsers module from
// this crate. The parsers handle the particulars of those use cases (e.g. arrays don't have
// brackets around them, naked integers qualify as strings, etc). In those cases, schema type
// information is used to decide what parser to use. Various attempts were made to use serde_json
// and valico::dsl for this parsing, but those proved inadequate or conterproductive for various
// reasons.
//
// JSON files are not checked for type mismatches until final validation against the schema.
// However, parameters assigned values in JSON files are checked against the schema to see if
// the parameter name is allowed as a property of the top-level object. This is done simply
// because more informative errors are generated this way.
use crate::env::EnvLike;
use crate::errors::{BuildError, UsageError};
use crate::logger::Logger;
use crate::name::Name;
use crate::parsers::parser_for_parameter;
use crate::schema::{PropertyType, Schema};
use serde::Deserialize;
use serde_json::Value;
use serde_json5;
use std::collections::{HashMap, HashSet, VecDeque};
use std::fs;
use std::fs::File;
use std::io::BufReader;
use std::path::PathBuf;
use valico::common::error::ValicoError;
use valico::json_schema;
use valico::json_schema::validators::ValidationState;
const NO_STRICT_OPTION: &str = "no_strict";
const NO_OPTION_PREFIX: &str = "no_";
const FROM_ENV_KEYWORD: &str = "from_env";
const TRY_FROM_ENV_KEYWORD: &str = "try_from_env";
/// Builds a configuration from an `EnvLike`, an abstraction of `std::env`, and returns it.
pub fn build_from_env_like<T: for<'a> Deserialize<'a>, E: EnvLike, L: Logger>(
env_like: &E,
schema: Schema,
logger: &mut L,
) -> Result<T, BuildError> {
let param_values_by_name =
TestConfigBuilder::from_env_like(env_like, schema, logger)?.param_values_by_name;
let param_values_by_string =
param_values_by_name.into_iter().map(|(k, v)| (k.to_string(), v)).collect();
Ok(serde_json::from_value(Value::Object(param_values_by_string))?)
}
/// Builds a test configuration based on command line arguments, environment variables, and JSON
/// files.
#[derive(Debug, PartialEq, Default)]
struct TestConfigBuilder {
/// The validated test configuration schema in `Value` form.
schema: Schema,
/// Whether the builder is currently in strict mode.
strict: bool,
/// Test parameter values by name. The configuration is built here and later parsed into the
/// target struct.
param_values_by_name: HashMap<Name, Value>,
/// Names of parameters in `param_values_by_name` that were assigned in non-strict mode and
/// have not yet been assigned in strict mode. This collection is used to allow non-strict
/// assignments to override a single strict assignment.
overrides: HashSet<Name>,
/// JSON files that have been included.
include: Vec<PathBuf>,
/// JSON files that should be included but which have not been processed. Every member of this
/// collection also appears in `include`.
unprocessed_includes: VecDeque<PathBuf>,
/// Parameters that appear in the schema and are required for this test configuration. This
/// mechanism is distinct from the 'required' section of the schema in that the parameters
/// listed here are only required for this particular test configuration, whereas the schema
/// required section lists parameters that are required in all test configurations.
require: Vec<Name>,
/// Parameters that appear in the schema and are prohibited for this test configuration.
prohibit: Vec<Name>,
}
/// Used for deserializing an include file.
#[derive(Debug, Deserialize)]
struct IncludeFile {
#[serde(flatten)]
values_by_name: HashMap<Name, Value>,
}
/// Builds a test configuration based on the command line, referenced environment variables, and
/// referenced JSON files.
impl TestConfigBuilder {
/// Creates a new `TestConfigBuilder` from an `EnvLike`, an abstraction of `std::env`.
fn from_env_like<E: EnvLike, L: Logger>(
env_like: &E,
schema: Schema,
logger: &mut L,
) -> Result<Self, BuildError> {
let mut to_return = Self::from_arg_iter(env_like.args(), schema, logger)?;
while let Some(include) = to_return.unprocessed_includes.pop_front() {
to_return.process_include(include, env_like, logger)?;
}
to_return.validate()?;
Ok(to_return)
}
/// Create a new `TestConfigBuilder` from an iterator over argument strings.
fn from_arg_iter<L: Logger>(
args: impl Iterator<Item = String>,
schema: Schema,
logger: &mut L,
) -> Result<Self, UsageError> {
let mut to_return = TestConfigBuilder { schema, ..Default::default() };
logger.start_command_line();
for arg in args {
if let Some(stripped_arg) = arg.strip_prefix("--") {
if let Some((arg_name, value)) = stripped_arg.split_once('=') {
// --name=value
let name = Name::from_arg_name(arg_name);
match &name {
Name::Schema => {
// Processed in `from_env`.
logger.schema_option(value);
}
Name::Debug => {
return Err(UsageError::UnexpectedOptionValue {
option: name,
got: Value::from(value),
});
}
Name::Strict
| Name::Include
| Name::Require
| Name::Prohibit
| Name::Parameter(_) => {
to_return.add_name_and_text_value(name, value, logger)?;
}
}
} else {
// --name or --no-name
let name = Name::from_arg_name(stripped_arg);
match &name {
Name::Schema => {
return Err(UsageError::MissingValue(name));
}
Name::Debug => {
logger.debug_option();
}
Name::Strict
| Name::Include
| Name::Require
| Name::Prohibit
| Name::Parameter(_) => {
to_return.add_name_no_value(name, logger)?;
}
}
}
} else {
return Err(UsageError::UnexpectedPositionalArgument(arg));
}
}
Ok(to_return)
}
/// Process a single JSON file. If new includes are encountered, they will get pushed to the
/// back of `unprocessed_includes` and processed later.
fn process_include<E: EnvLike, L: Logger>(
&mut self,
path: PathBuf,
env_like: &E,
logger: &mut L,
) -> Result<(), BuildError> {
logger.start_include(&path);
let file = File::open(&path)
.map_err(|e| BuildError::FailedToOpenInclude { path: path.clone(), source: e })?;
let mut reader = BufReader::new(file);
let include_file: IncludeFile = serde_json5::from_reader(&mut reader)
.map_err(|e| BuildError::FailedToParseInclude { path: path.clone(), source: e })?;
for (name, value) in include_file.values_by_name {
// Strictly speaking, we could leave this check to validation by schema, but the
// errors produced by the validator are less useful than we get this way.
if name.can_be_added(&self.schema) {
if let Some(value) = self.maybe_subst_from_env(&name, value, env_like, logger)? {
self.add_name_and_value(name, value, logger)?;
}
} else {
return Err(UsageError::UnrecognizedParameter(name).into());
}
}
Ok(())
}
/// Adds a name/value pair to `param_values_by_name`. The name must be in lower_snake_case. The value
/// is parsed as appropriate. This function is used for parameters given on the command
/// line with a value (e.g. --foo=bar).
fn add_name_and_text_value<L: Logger>(
&mut self,
name: Name,
value_str: &str,
logger: &mut L,
) -> Result<(), UsageError> {
let value = parser_for_parameter(&name, &self.schema)?.parse(&name, value_str)?;
self.add_name_and_value(name, value, logger)
}
/// Adds a name/value pair as expressed by a command line option with no value.
fn add_name_no_value<L: Logger>(
&mut self,
name: Name,
logger: &mut L,
) -> Result<(), UsageError> {
match name {
Name::Parameter(p) if p == NO_STRICT_OPTION => {
// Strict may not be turned off.
return Err(UsageError::InvalidStrictValue(String::from("false")));
}
Name::Strict => {
// Strict may be turned on this way.
return self.add_name_and_value(name, Value::Bool(true), logger);
}
Name::Include | Name::Require | Name::Prohibit => {
// These options require values.
return Err(UsageError::MissingValue(name));
}
Name::Debug | Name::Schema | Name::Parameter(_) => {}
}
if let Some(scheme) = self.schema.properties.get(&name) {
// The name is a valid parameter in the schema. Set it to true if its type
// is boolean, otherwise, complain that the value was not provided.
if scheme.property_type == PropertyType::Boolean {
return self.add_name_and_value(name, Value::Bool(true), logger);
} else {
return Err(UsageError::MissingValue(name));
}
} else if name.starts_with(NO_OPTION_PREFIX) {
// The name starts with 'no_'. If the rest of the name is a valid boolean
// parameter in the schema, set it to false.
let name = name.strip_prefix(NO_OPTION_PREFIX).unwrap();
if let Some(scheme) = self.schema.properties.get(&name) {
if scheme.property_type == PropertyType::Boolean {
return self.add_name_and_value(name, Value::Bool(false), logger);
}
}
} else {
// The name does not start with 'no_'. If the name with 'no_' prepended is
// a valid boolean parameter in the schema, set it to false.
let mut no_name = String::from(NO_OPTION_PREFIX);
no_name.push_str(name.as_str());
let name = Name::from(no_name);
if let Some(scheme) = self.schema.properties.get(&name) {
if scheme.property_type == PropertyType::Boolean {
return self.add_name_and_value(name, Value::Bool(false), logger);
}
}
}
Err(UsageError::UnrecognizedParameter(name))
}
/// Adds a name/value pair to `param_values_by_name`. The name must be in lower_snake_case.
fn add_name_and_value<L: Logger>(
&mut self,
name: Name,
mut value: Value,
logger: &mut L,
) -> Result<(), UsageError> {
match name {
Name::Strict => match value {
Value::Bool(true) => {
logger.strict();
self.strict = true;
}
v => {
return Err(UsageError::InvalidStrictValue(v.to_string()));
}
},
Name::Include => {
for item_string in strings_in_option_array_value(Name::Include, value)? {
let path = parse_include_path(item_string.as_str())?;
if !self.include.contains(&path) {
logger.add_include(&path);
self.include.push(path.clone());
self.unprocessed_includes.push_back(path);
} else {
logger.include_already_added(&path);
}
}
}
Name::Require => {
for item_string in strings_in_option_array_value(Name::Require, value)? {
let item_name = Name::from_arg_name(item_string.as_str());
if !item_name.is_viable_parameter_name() {
return Err(UsageError::InvalidParameterName {
option: Name::Require,
got: item_name,
});
}
if !self.require.contains(&item_name) {
logger.add_require(&item_name);
self.require.push(item_name);
} else {
logger.require_already_added(&item_name);
}
}
}
Name::Prohibit => {
for item_string in strings_in_option_array_value(Name::Prohibit, value)? {
let item_name = Name::from_str(item_string.as_str());
if !item_name.is_viable_parameter_name() {
return Err(UsageError::InvalidParameterName {
option: Name::Prohibit,
got: item_name,
});
}
if !self.prohibit.contains(&item_name) {
logger.add_prohibit(&item_name);
self.prohibit.push(item_name);
} else {
logger.prohibit_already_added(&item_name);
}
}
}
_ => {
if let Some(mut existing) = self.param_values_by_name.get_mut(&name) {
// Parameter already assigned a value.
if let Value::Array(vector) = &mut existing {
// Array parameter already assigned a value. Append the new value.
logger.add_to_array(&name, &value);
vector.append(
value.as_array_mut().expect("value merged into array is array"),
);
} else {
// Non-array parameter already assigned a value.
if self.strict {
// Strict mode.
if !self.overrides.remove(&name) {
// The name has been assigned previously in strict mode, so we fail.
return Err(UsageError::ParamAlreadyStrictlyAssigned(name));
} else {
// The name was assigned in non-strict mode and has not been
// assigned previously in strict mode. We already removed the
// name from `overrides` so any subsequent assignments in strict
// mode will fail.
logger.overridden_add_parameter_strict_ignored(&name, &value);
}
} else {
// Non-strict mode. We make the assignment, overriding the previous
// assignment, which was also made in non-strict mode. We know the
// name is already in `overrides`, because that happened when it
// was initially assigned in non-strict mode.
logger.add_parameter_non_strict(&name, &value);
let _ = self.param_values_by_name.insert(name, value);
}
}
} else {
// Parameter not already assigned a value.
if value.is_array() {
logger.add_to_array(&name, &value);
} else if self.strict {
logger.add_parameter_strict(&name, &value);
} else {
logger.add_parameter_non_strict(&name, &value);
self.overrides.insert(name.clone());
}
let _ = self.param_values_by_name.insert(name, value);
}
}
}
Ok(())
}
// Determines if `value` calls for substitution from the environment and produces the
// substitute value. Returns `Ok(Some(value))` if no substitution should occur. Returns
// Ok(Some(env_value)) if a substitution should occur (env_value is the value to substitute).
// Returns Ok(None) if a try_from_env substitution referenced an undefined environment
// variable.
fn maybe_subst_from_env<E: EnvLike, L: Logger>(
&self,
name: &Name,
value: Value,
env_like: &E,
logger: &mut L,
) -> Result<Option<Value>, UsageError> {
if let Some(obj) = value.as_object() {
if obj.len() == 1 {
match (obj.get(FROM_ENV_KEYWORD), obj.get(TRY_FROM_ENV_KEYWORD)) {
(Some(var_name_value), None) => {
if let Some(value) = self.get_subst_value(name, var_name_value, env_like)? {
logger.from_env(var_name_value, &value);
return Ok(Some(value));
} else {
return Err(UsageError::FromEnvUndefined {
parameter: name.clone(),
var_name_value: var_name_value.clone(),
});
}
}
(None, Some(var_name_value)) => {
if let Some(value) = self.get_subst_value(name, var_name_value, env_like)? {
logger.try_from_env(var_name_value, &value);
return Ok(Some(value));
} else {
logger.try_from_env_undefined(name, var_name_value);
return Ok(None);
}
}
(Some(_), Some(_)) => {}
(None, None) => {}
}
}
}
Ok(Some(value))
}
// Gets the value for a from_env/try_from_env construct where `name` is the property being
// defined and `var_name_value` is the value of the from_env/try_from_env property. If
// `var_name_value` is not a string, an error is returned.
fn get_subst_value<E: EnvLike>(
&self,
name: &Name,
var_name_value: &Value,
env_like: &E,
) -> Result<Option<Value>, UsageError> {
if let Some(var_name_string) = var_name_value.as_str() {
if let Ok(var_value_string) = env_like.var(var_name_string) {
Ok(Some(
parser_for_parameter(name, &self.schema)?
.parse(&name, var_value_string.as_str())?,
))
} else {
Ok(None)
}
} else {
Err(UsageError::FromEnvNotString {
parameter: name.clone(),
var_name_value: var_name_value.clone(),
})
}
}
/// Validates `self`.
fn validate(&self) -> Result<(), BuildError> {
assert!(
self.unprocessed_includes.is_empty(),
"TestConfigBuilder contains unprocessed includes."
);
let mut errors = vec![];
// Ensure that all the parameters that have been required are in the schema and that
// those parameters have been supplied.
for required in &self.require {
if !self.schema.properties.contains_key(required) {
errors.push(UsageError::UnknownRequiredParameter(required.clone()).into());
} else if !self.param_values_by_name.contains_key(required) {
errors.push(UsageError::MissingRequiredParameter(required.clone()).into());
}
}
// Ensure that all the parameters that have been prohibited are in the schema and that
// those parameters have not been supplied.
for prohibited in &self.prohibit {
if !self.schema.properties.contains_key(prohibited) {
errors.push(UsageError::UnknownProhibitedParameter(prohibited.clone()).into());
} else if self.param_values_by_name.contains_key(prohibited) {
errors.push(UsageError::DefinedProhibitedParameter(prohibited.clone()).into());
}
}
let mut scope = json_schema::Scope::new();
let validator = scope
.compile_and_return(self.schema.as_value.clone(), /*ban_unknown=*/ true)
.expect("Schema to compile");
let param_values_by_string =
self.param_values_by_name.iter().map(|(k, v)| (k.to_string(), v.clone())).collect();
let validation_state = validator.validate(&Value::Object(param_values_by_string));
if !validation_state.is_strictly_valid() {
match validation_state_to_build_error(validation_state) {
BuildError::ValidationMultiple(mut schema_errors) => {
errors.append(&mut schema_errors);
}
schema_error => {
errors.push(schema_error);
}
}
}
if errors.is_empty() {
Ok(())
} else if errors.len() == 1 {
Err(errors.remove(0))
} else {
Err(BuildError::ValidationMultiple(errors))
}
}
}
/// Returns an iterator for the strings in a `Value::Array` of `Value::String`. Returns an error
/// if anything else is found.
///
/// This is used for options 'include', 'require' and 'prohibit' only. Note that errors are only
/// generated for JSON assignments, because command line and environment variable values were
/// parsed with our parsers to be of the correct type.
fn strings_in_option_array_value(
option_name: Name,
value: Value,
) -> Result<impl Iterator<Item = String>, UsageError> {
let item_values = match value {
Value::Array(vector) => vector,
v => {
return Err(UsageError::UnexpectedOptionValue { option: option_name, got: v });
}
};
for item_value in &item_values {
if !item_value.is_string() {
return Err(UsageError::UnexpectedOptionValue {
option: option_name,
got: item_value.clone(),
});
}
}
Ok(item_values.into_iter().map(|item_value| String::from(item_value.as_str().unwrap())))
}
/// Parse an include file path, checking that the file exists and is a file.
fn parse_include_path(path: &str) -> Result<PathBuf, UsageError> {
let path = PathBuf::from(path);
if !path.exists() {
return Err(UsageError::IncludedPathDoesNotExist(path));
}
if let Ok(metadata) = fs::metadata(&path) {
if !metadata.is_file() {
return Err(UsageError::IncludedPathIsNotAFile(path));
}
} else {
return Err(UsageError::IncludedPathUnreadable(path));
}
Ok(path)
}
/// Creates a `UsageError` from a failed `ValidationState`.
fn validation_state_to_build_error(validation_state: ValidationState) -> BuildError {
let mut errors = vec![];
for missing in &validation_state.missing {
errors.push(UsageError::MissingParameterRequiredBySchema(missing.to_string()).into());
}
for e in &validation_state.errors {
errors.push(validation_error_to_build_error(&e));
}
if errors.is_empty() {
BuildError::UnclassifiedSchemaState(Box::new(validation_state))
} else if errors.len() == 1 {
errors.pop().unwrap()
} else {
BuildError::ValidationMultiple(errors)
}
}
/// Creates a `UsageError` from a failed `ValicoError`.
fn validation_error_to_build_error(validation_error: &Box<dyn ValicoError>) -> BuildError {
match validation_error.get_code() {
"required" => UsageError::MissingParameterRequiredBySchema(validation_error_simple_path(
&validation_error,
))
.into(),
"wrong_type" => UsageError::SchemaTypeMismatch {
parameter: validation_error_simple_path(&validation_error),
detail: String::from(
validation_error.get_detail().expect("WrongType error has detail"),
),
}
.into(),
_ => BuildError::UnclassifiedSchemaError(format!("{:?}", validation_error)),
}
}
fn validation_error_simple_path(validation_error: &Box<dyn ValicoError>) -> String {
String::from(validation_error.get_path().split('/').next_back().unwrap())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::env::testutils::FakeEnv;
use crate::logger::NullLogger;
use crate::schema::tests::fake_schema;
use assert_matches::assert_matches;
use serde_json::{Map, Number, json};
use std::fs;
use std::str::FromStr;
use tempfile::{NamedTempFile, tempdir};
fn option_name() -> Name {
Name::from_str("test_option_name")
}
/// Asserts that a `Result<TestConfigBuilder, BuilderError>` wraps `usage_error`
#[track_caller]
fn assert_usage_error(result: Result<TestConfigBuilder, BuildError>, usage_error: UsageError) {
assert_matches!(result, Err(BuildError::IncorrectUsage(e)) if e == usage_error)
}
#[test]
fn test_strings_in_option_array_value() {
assert_eq!(
Err(UsageError::UnexpectedOptionValue {
option: option_name(),
got: Value::from_str("{}").unwrap(),
}),
strings_in_option_array_value(option_name(), json!({})).map(|_| ())
);
assert_eq!(
Err(UsageError::UnexpectedOptionValue {
option: option_name(),
got: Value::from_str("4").unwrap(),
}),
strings_in_option_array_value(option_name(), json!(4)).map(|_| ())
);
assert_eq!(
Err(UsageError::UnexpectedOptionValue {
option: option_name(),
got: Value::from_str("\"squint\"").unwrap(),
}),
strings_in_option_array_value(option_name(), json!("squint")).map(|_| ())
);
assert_eq!(
Err(UsageError::UnexpectedOptionValue {
option: option_name(),
got: Value::from_str("{}").unwrap(),
}),
strings_in_option_array_value(option_name(), json!([{}])).map(|_| ())
);
assert_eq!(
Err(UsageError::UnexpectedOptionValue {
option: option_name(),
got: Value::from_str("4").unwrap(),
}),
strings_in_option_array_value(option_name(), json!([4])).map(|_| ())
);
assert_eq!(
Err(UsageError::UnexpectedOptionValue {
option: option_name(),
got: Value::from_str("4").unwrap(),
}),
strings_in_option_array_value(option_name(), json!(["squint", 4])).map(|_| ())
);
assert_eq!(
vec![String::from("squint")],
strings_in_option_array_value(option_name(), json!(["squint"]))
.unwrap()
.collect::<Vec<String>>()
);
assert_eq!(
vec![String::from("squint"), String::from("frown")],
strings_in_option_array_value(option_name(), json!(["squint", "frown"]))
.unwrap()
.collect::<Vec<String>>()
);
}
#[test]
fn test_parse_include_path() {
// Existent file.
let temp_file = NamedTempFile::new().expect("to create temporary file");
let temp_file_path = temp_file.path().to_str().unwrap().to_string();
assert!(parse_include_path(temp_file_path.as_str()).is_ok());
temp_file.close().expect("to close temporary file");
// Non-existent file.
let result = parse_include_path("/non_existent_file");
assert_eq!(
result,
Err(UsageError::IncludedPathDoesNotExist(PathBuf::from("/non_existent_file")))
);
// Existent directory.
let temp_dir = tempdir().expect("to create temporary directory");
let temp_dir_path = temp_dir.path();
let result = parse_include_path(temp_dir_path.to_str().unwrap());
assert_eq!(
result,
Err(UsageError::IncludedPathIsNotAFile(PathBuf::from(temp_dir_path.to_str().unwrap())))
);
temp_dir.close().expect("to close temporary directory");
}
#[test]
fn test_is_viable_parameter_name() {
assert!(!Name::Schema.is_viable_parameter_name());
assert!(!Name::Debug.is_viable_parameter_name());
assert!(!Name::Strict.is_viable_parameter_name());
assert!(!Name::Include.is_viable_parameter_name());
assert!(!Name::Require.is_viable_parameter_name());
assert!(!Name::Prohibit.is_viable_parameter_name());
assert!(!Name::from_str("ahoy!").is_viable_parameter_name());
assert!(!Name::from_str("1_thing").is_viable_parameter_name());
assert!(Name::from_str("_one_thing").is_viable_parameter_name());
assert!(Name::from_str("snorkel").is_viable_parameter_name());
}
#[test]
// Tests the case in which no arguments are supplied.
fn test_no_args() {
let fake_env = FakeEnv::new("", "");
let under_test =
TestConfigBuilder::from_env_like(&fake_env, fake_schema(), &mut NullLogger)
.expect("Ok result");
assert!(under_test.param_values_by_name.is_empty());
}
#[test]
// Tests the case in which an unexpected positional argument is provided.
fn test_unexpected_positional_arg() {
let fake_env = FakeEnv::new("foo", "");
let under_test =
TestConfigBuilder::from_env_like(&fake_env, fake_schema(), &mut NullLogger);
assert_usage_error(
under_test,
UsageError::UnexpectedPositionalArgument(String::from("foo")),
);
}
#[test]
// Tests cases in which values are missing for known parameters that require them.
fn test_missing_values() {
let fake_env = FakeEnv::new("--include", "");
let under_test =
TestConfigBuilder::from_env_like(&fake_env, fake_schema(), &mut NullLogger);
assert_usage_error(under_test, UsageError::MissingValue(Name::Include));
let fake_env = FakeEnv::new("--require", "");
let under_test =
TestConfigBuilder::from_env_like(&fake_env, fake_schema(), &mut NullLogger);
assert_usage_error(under_test, UsageError::MissingValue(Name::Require));
let fake_env = FakeEnv::new("--prohibit", "");
let under_test =
TestConfigBuilder::from_env_like(&fake_env, fake_schema(), &mut NullLogger);
assert_usage_error(under_test, UsageError::MissingValue(Name::Prohibit));
let fake_env = FakeEnv::new("--host-test-binary", "");
let under_test =
TestConfigBuilder::from_env_like(&fake_env, fake_schema(), &mut NullLogger);
assert_usage_error(
under_test,
UsageError::MissingValue(Name::from_str("host_test_binary")),
);
let fake_env = FakeEnv::new("--host-test-args", "");
let under_test =
TestConfigBuilder::from_env_like(&fake_env, fake_schema(), &mut NullLogger);
assert_usage_error(under_test, UsageError::MissingValue(Name::from_str("host_test_args")));
let fake_env = FakeEnv::new("--output-directory", "");
let under_test =
TestConfigBuilder::from_env_like(&fake_env, fake_schema(), &mut NullLogger);
assert_usage_error(
under_test,
UsageError::MissingValue(Name::from_str("output_directory")),
);
}
#[test]
// Tests cases in which disallowed values are supplied for known parameters.
fn test_values_not_allowed() {
let fake_env = FakeEnv::new("--require=include", "");
let under_test =
TestConfigBuilder::from_env_like(&fake_env, fake_schema(), &mut NullLogger);
assert_usage_error(
under_test,
UsageError::InvalidParameterName { option: Name::Require, got: Name::Include },
);
let fake_env = FakeEnv::new("--require=require", "");
let under_test =
TestConfigBuilder::from_env_like(&fake_env, fake_schema(), &mut NullLogger);
assert_usage_error(
under_test,
UsageError::InvalidParameterName { option: Name::Require, got: Name::Require },
);
let fake_env = FakeEnv::new("--require=prohibit", "");
let under_test =
TestConfigBuilder::from_env_like(&fake_env, fake_schema(), &mut NullLogger);
assert_usage_error(
under_test,
UsageError::InvalidParameterName { option: Name::Require, got: Name::Prohibit },
);
let fake_env = FakeEnv::new("--prohibit=include", "");
let under_test =
TestConfigBuilder::from_env_like(&fake_env, fake_schema(), &mut NullLogger);
assert_usage_error(
under_test,
UsageError::InvalidParameterName { option: Name::Prohibit, got: Name::Include },
);
let fake_env = FakeEnv::new("--prohibit=require", "");
let under_test =
TestConfigBuilder::from_env_like(&fake_env, fake_schema(), &mut NullLogger);
assert_usage_error(
under_test,
UsageError::InvalidParameterName { option: Name::Prohibit, got: Name::Require },
);
let fake_env = FakeEnv::new("--prohibit=prohibit", "");
let under_test =
TestConfigBuilder::from_env_like(&fake_env, fake_schema(), &mut NullLogger);
assert_usage_error(
under_test,
UsageError::InvalidParameterName { option: Name::Prohibit, got: Name::Prohibit },
);
}
#[test]
// Tests cases in which values of the wrong type are supplied for known parameters.
fn test_values_wrong_type() {
let fake_env = FakeEnv::new("--include=1", "");
let under_test =
TestConfigBuilder::from_env_like(&fake_env, fake_schema(), &mut NullLogger);
assert_usage_error(under_test, UsageError::IncludedPathDoesNotExist(PathBuf::from("1")));
let fake_env = FakeEnv::new("--include=1,something", "");
let under_test =
TestConfigBuilder::from_env_like(&fake_env, fake_schema(), &mut NullLogger);
assert_usage_error(under_test, UsageError::IncludedPathDoesNotExist(PathBuf::from("1")));
let fake_env = FakeEnv::new("--require=1", "");
let under_test =
TestConfigBuilder::from_env_like(&fake_env, fake_schema(), &mut NullLogger);
assert_usage_error(
under_test,
UsageError::InvalidParameterName { option: Name::Require, got: Name::from_str("1") },
);
let fake_env = FakeEnv::new("--require=1,something", "");
let under_test =
TestConfigBuilder::from_env_like(&fake_env, fake_schema(), &mut NullLogger);
assert_usage_error(
under_test,
UsageError::InvalidParameterName { option: Name::Require, got: Name::from_str("1") },
);
let fake_env = FakeEnv::new("--prohibit=1", "");
let under_test =
TestConfigBuilder::from_env_like(&fake_env, fake_schema(), &mut NullLogger);
assert_usage_error(
under_test,
UsageError::InvalidParameterName { option: Name::Prohibit, got: Name::from_str("1") },
);
let fake_env = FakeEnv::new("--prohibit=1,something", "");
let under_test =
TestConfigBuilder::from_env_like(&fake_env, fake_schema(), &mut NullLogger);
assert_usage_error(
under_test,
UsageError::InvalidParameterName { option: Name::Prohibit, got: Name::from_str("1") },
);
}
#[test]
// Tests the processing of parameters as args.
fn test_args() {
let fake_env = FakeEnv::new(
"--true=true --false=false --true-simple --no-false-simple --negative-bool --zero=0 \
--string=foo --array_of_number=1,2,3,4",
"",
);
let under_test =
TestConfigBuilder::from_env_like(&fake_env, fake_schema(), &mut NullLogger)
.expect("Ok result");
assert_eq!(
under_test.param_values_by_name,
HashMap::from_iter(
vec![
(Name::from_str("true"), Value::Bool(true)),
(Name::from_str("false"), Value::Bool(false)),
(Name::from_str("true_simple"), Value::Bool(true)),
(Name::from_str("false_simple"), Value::Bool(false)),
(Name::from_str("no_negative_bool"), Value::Bool(false)),
(Name::from_str("zero"), Value::Number(Number::from(0))),
(Name::from_str("string"), Value::String(String::from("foo"))),
(
Name::from_str("array_of_number"),
Value::Array(vec![
Value::Number(Number::from(1)),
Value::Number(Number::from(2)),
Value::Number(Number::from(3)),
Value::Number(Number::from(4))
])
),
]
.into_iter()
)
);
let fake_env = FakeEnv::new("--zero-point-one=0.1 --negative-zero-point-one=-0.1", "");
let under_test =
TestConfigBuilder::from_env_like(&fake_env, fake_schema(), &mut NullLogger)
.expect("Ok result");
assert_eq!(
under_test
.param_values_by_name
.get(&Name::from_str("zero_point_one"))
.unwrap()
.as_f64()
.unwrap(),
0.1
);
assert_eq!(
under_test
.param_values_by_name
.get(&Name::from_str("negative_zero_point_one"))
.unwrap()
.as_f64()
.unwrap(),
-0.1
);
let fake_env = FakeEnv::new("--squeak", "");
assert_usage_error(
TestConfigBuilder::from_env_like(&fake_env, fake_schema(), &mut NullLogger),
UsageError::UnrecognizedParameter(Name::from_str("squeak")),
);
let fake_env = FakeEnv::new("--zero", "");
assert_usage_error(
TestConfigBuilder::from_env_like(&fake_env, fake_schema(), &mut NullLogger),
UsageError::MissingValue(Name::from_str("zero")),
);
}
#[test]
// Tests the processing of include parameter in args.
fn test_include() {
// Successful reference from a command line.
let temp_file = NamedTempFile::new().expect("Failed to create temporary file");
let temp_file_path = temp_file.path().to_str().unwrap();
fs::write(temp_file_path, "{}").expect("Failed to write to temporary file");
let fake_env = FakeEnv::new(format!("--include={}", temp_file_path).as_str(), "");
let under_test =
TestConfigBuilder::from_env_like(&fake_env, fake_schema(), &mut NullLogger)
.expect("Ok result");
assert_eq!(under_test.include, vec![PathBuf::from_str(temp_file_path).unwrap(),]);
// Non-existent file.
let fake_env = FakeEnv::new("--include=/non_existent_file", "");
assert_usage_error(
TestConfigBuilder::from_env_like(&fake_env, fake_schema(), &mut NullLogger),
UsageError::IncludedPathDoesNotExist(PathBuf::from("/non_existent_file")),
);
// File containing non-JSON.
let temp_file = NamedTempFile::new().expect("Able to create temporary file");
let temp_file_path = temp_file.path().to_str().unwrap();
fs::write(temp_file_path, "spagga!").expect("Able to write to temporary file");
let fake_env = FakeEnv::new(format!("--include={}", temp_file_path).as_str(), "");
assert_matches!(
TestConfigBuilder::from_env_like(&fake_env, fake_schema(), &mut NullLogger),
Err(BuildError::FailedToParseInclude { path: p, source: _ })
if p == PathBuf::from_str(temp_file_path).unwrap()
);
temp_file.close().expect("Failed to close temporary file");
}
#[test]
// Tests the processing of require parameter in args.
fn test_require() {
let fake_env = FakeEnv::new("--require=a,b,c,d,e --a=a --b=b --c=c --d=d --e=e", "");
let under_test =
TestConfigBuilder::from_env_like(&fake_env, fake_schema(), &mut NullLogger)
.expect("Ok result");
let mut sorted = under_test.require.clone();
sorted.sort();
assert_eq!(
sorted,
vec![
Name::from_str("a"),
Name::from_str("b"),
Name::from_str("c"),
Name::from_str("d"),
Name::from_str("e"),
]
);
}
#[test]
// Tests the processing of prohibit parameter in args.
fn test_prohibit() {
let fake_env = FakeEnv::new("--prohibit=a,b,c,d,e", "");
let under_test =
TestConfigBuilder::from_env_like(&fake_env, fake_schema(), &mut NullLogger)
.expect("Ok result");
let mut sorted = under_test.prohibit.clone();
sorted.sort();
assert_eq!(
sorted,
vec![
Name::from_str("a"),
Name::from_str("b"),
Name::from_str("c"),
Name::from_str("d"),
Name::from_str("e"),
]
);
}
#[test]
// Tests the processing of an include.
fn test_include_params() {
let temp_file = NamedTempFile::new().expect("Failed to create temporary file");
let temp_file_path = temp_file.path().to_str().unwrap();
fs::write(
temp_file_path,
r#"{// JSON5 allows comments and trailing commas
"true":true, "false":false, zero:0, "string":"foo",
"array_of_number":[1,2,3,4,],}"#,
)
.expect("Failed to write to temporary file");
let fake_env = FakeEnv::new(format!("--include={}", temp_file_path).as_str(), "");
let under_test =
TestConfigBuilder::from_env_like(&fake_env, fake_schema(), &mut NullLogger)
.expect("Ok result");
assert_eq!(under_test.include, vec![PathBuf::from_str(temp_file_path).unwrap()]);
assert_eq!(
under_test.param_values_by_name,
HashMap::from_iter(vec![
(Name::from_str("true"), Value::Bool(true)),
(Name::from_str("false"), Value::Bool(false)),
(Name::from_str("zero"), Value::Number(Number::from(0))),
(Name::from_str("string"), Value::String(String::from("foo"))),
(
Name::from_str("array_of_number"),
Value::Array(vec![
Value::Number(Number::from(1)),
Value::Number(Number::from(2)),
Value::Number(Number::from(3)),
Value::Number(Number::from(4))
])
),
])
);
temp_file.close().expect("Failed to close temporary file");
}
#[test]
// Tests the processing of nested includes.
fn test_nested_includes() {
let temp_file_outer = NamedTempFile::new().expect("Failed to create temporary file");
let temp_file_outer_path = temp_file_outer.path().to_str().unwrap();
let temp_file_inner = NamedTempFile::new().expect("Failed to create temporary file");
let temp_file_inner_path = temp_file_inner.path().to_str().unwrap();
fs::write(temp_file_outer_path, format!("{{\"include\":[\"{}\"]}}", temp_file_inner_path))
.expect("Failed to write to temporary file");
fs::write(temp_file_inner_path, "{}").expect("Failed to write to temporary file");
let fake_env = FakeEnv::new(format!("--include={}", temp_file_outer_path).as_str(), "");
let under_test =
TestConfigBuilder::from_env_like(&fake_env, fake_schema(), &mut NullLogger)
.expect("Ok result");
assert_eq!(
under_test.include,
vec![
PathBuf::from_str(temp_file_outer_path).unwrap(),
PathBuf::from_str(temp_file_inner_path).unwrap()
]
);
temp_file_inner.close().expect("Failed to close temporary file");
temp_file_outer.close().expect("Failed to close temporary file");
}
#[test]
// Tests the processing of a JSON object in an include file.
fn test_object() {
let temp_file = NamedTempFile::new().expect("Failed to create temporary file");
let temp_file_path = temp_file.path().to_str().unwrap();
fs::write(temp_file_path, r#"{"object":{"foo":1,"bar":2}}"#)
.expect("Failed to write to temporary file");
let fake_env = FakeEnv::new(format!("--include={}", temp_file_path).as_str(), "");
let under_test =
TestConfigBuilder::from_env_like(&fake_env, fake_schema(), &mut NullLogger)
.expect("Ok result");
let mut object_map = serde_json::Map::new();
object_map.insert(String::from("foo"), Value::Number(Number::from(1)));
object_map.insert(String::from("bar"), Value::Number(Number::from(2)));
assert_eq!(under_test.include, vec![PathBuf::from_str(temp_file_path).unwrap()]);
assert_eq!(
under_test.param_values_by_name,
HashMap::from_iter(vec![(Name::from_str("object"), Value::Object(object_map)),])
);
temp_file.close().expect("Failed to close temporary file");
}
#[test]
// Tests strictness rules implementation.
fn test_strict() {
// Multiple non-strict assignments yields the last value assigned.
let fake_env = FakeEnv::new("---foo=a --foo=b", "");
let under_test =
TestConfigBuilder::from_env_like(&fake_env, fake_schema(), &mut NullLogger)
.expect("Ok result");
assert_eq!(
String::from("b"),
under_test.param_values_by_name.get(&Name::from_str("foo")).unwrap().as_str().unwrap(),
);
// One strict assignment after an override returns the override value.
let fake_env = FakeEnv::new("---foo=a --strict --foo=b", "");
let under_test =
TestConfigBuilder::from_env_like(&fake_env, fake_schema(), &mut NullLogger)
.expect("Ok result");
assert_eq!(
String::from("a"),
under_test.param_values_by_name.get(&Name::from_str("foo")).unwrap().as_str().unwrap(),
);
// Multiple strict assignments are not allowed.
let fake_env = FakeEnv::new("--strict --foo=a --foo=b", "");
assert_usage_error(
TestConfigBuilder::from_env_like(&fake_env, fake_schema(), &mut NullLogger),
UsageError::ParamAlreadyStrictlyAssigned(Name::from_str("foo")),
);
// Multiple strict assignments are not allowed, even for overridden parameters.
let fake_env = FakeEnv::new("--foo=a --strict --foo=b --foo=c", "");
assert_usage_error(
TestConfigBuilder::from_env_like(&fake_env, fake_schema(), &mut NullLogger),
UsageError::ParamAlreadyStrictlyAssigned(Name::from_str("foo")),
);
// Multiple stricts are ok.
let fake_env = FakeEnv::new("--strict --strict", "");
let _ = TestConfigBuilder::from_env_like(&fake_env, fake_schema(), &mut NullLogger)
.expect("Ok result");
// Can't set strict to false.
let fake_env = FakeEnv::new("--no-strict", "");
assert_usage_error(
TestConfigBuilder::from_env_like(&fake_env, fake_schema(), &mut NullLogger),
UsageError::InvalidStrictValue(String::from("false")),
);
}
#[test]
// Tests that an unknown parameter assigned in JSON causes an error.
fn test_include_unknown() {
let temp_file = NamedTempFile::new().expect("Failed to create temporary file");
let temp_file_path = temp_file.path().to_str().unwrap();
fs::write(temp_file_path, r#"{tunnels: 4}"#).expect("Failed to write to temporary file");
let fake_env = FakeEnv::new(format!("--include={}", temp_file_path).as_str(), "");
assert_usage_error(
TestConfigBuilder::from_env_like(&fake_env, fake_schema(), &mut NullLogger),
UsageError::UnrecognizedParameter(Name::from_str("tunnels")),
);
temp_file.close().expect("Failed to close temporary file");
}
#[test]
// Tests that array assignments are aggregated.
fn test_array_aggregation() {
let fake_env = FakeEnv::new("--strict --array_of_number=1 --array_of_number=2,3", "");
let under_test =
TestConfigBuilder::from_env_like(&fake_env, fake_schema(), &mut NullLogger)
.expect("Ok result");
assert_eq!(
under_test.param_values_by_name,
HashMap::from_iter(vec![(
Name::from_str("array_of_number"),
Value::Array(vec![
Value::Number(Number::from(1)),
Value::Number(Number::from(2)),
Value::Number(Number::from(3))
])
)])
);
}
#[test]
// Tests final builder validation.
fn test_final_validation() {
let fake_env = FakeEnv::new("--require=chunky", "");
assert_usage_error(
TestConfigBuilder::from_env_like(&fake_env, fake_schema(), &mut NullLogger),
UsageError::UnknownRequiredParameter(Name::from_str("chunky")),
);
let fake_env = FakeEnv::new("--require=foo", "");
assert_usage_error(
TestConfigBuilder::from_env_like(&fake_env, fake_schema(), &mut NullLogger),
UsageError::MissingRequiredParameter(Name::from_str("foo")),
);
let fake_env = FakeEnv::new("--prohibit=chunky", "");
assert_usage_error(
TestConfigBuilder::from_env_like(&fake_env, fake_schema(), &mut NullLogger),
UsageError::UnknownProhibitedParameter(Name::from_str("chunky")),
);
let fake_env = FakeEnv::new("--prohibit=foo --foo=bar", "");
assert_usage_error(
TestConfigBuilder::from_env_like(&fake_env, fake_schema(), &mut NullLogger),
UsageError::DefinedProhibitedParameter(Name::from_str("foo")),
);
// In order to test final validation against the schema, we need to assign the wrong
// type of value to a parameter in a JSON file. Unknown parameter names are caught
// earlier regardless of the source, and type mismatches are caught early when the
// parsers are used (command line and environment variables).
let temp_file = NamedTempFile::new().expect("Failed to create temporary file");
let temp_file_path = temp_file.path().to_str().unwrap();
fs::write(temp_file_path, r#"{true: "horse"}"#).expect("Failed to write to temporary file");
let fake_env = FakeEnv::new(format!("--include={}", temp_file_path).as_str(), "");
assert_usage_error(
TestConfigBuilder::from_env_like(&fake_env, fake_schema(), &mut NullLogger),
UsageError::SchemaTypeMismatch {
parameter: String::from("true"),
detail: String::from("The value must be boolean"),
},
);
temp_file.close().expect("Failed to close temporary file");
}
#[test]
// Tests TestConfigBuilder::maybe_subst_from_env.
fn test_maybe_subst_from_env() {
let fake_env = FakeEnv::new("", "BOOL=false NUMBER=123 STRING=foo ARRAY=1,2");
let under_test =
TestConfigBuilder::from_env_like(&fake_env, fake_schema(), &mut NullLogger)
.expect("Ok result");
let name = Name::Parameter(String::from("test_name"));
// No substitution.
let result =
under_test.maybe_subst_from_env(&name, json!(false), &fake_env, &mut NullLogger);
assert_matches!(result, Ok(Some(Value::Bool(false))));
let result = under_test.maybe_subst_from_env(&name, json!(123), &fake_env, &mut NullLogger);
assert_matches!(result, Ok(Some(v)) if v == Value::Number(Number::from(123)));
let result =
under_test.maybe_subst_from_env(&name, json!("foo"), &fake_env, &mut NullLogger);
assert_matches!(result, Ok(Some(v)) if v == Value::String(String::from("foo")));
let result =
under_test.maybe_subst_from_env(&name, json!([1, 2]), &fake_env, &mut NullLogger);
assert_matches!(result,Ok(Some(v))
if v == Value::Array(vec![Value::Number(Number::from(1)),
Value::Number(Number::from(2))]));
let result = under_test.maybe_subst_from_env(&name, json!({}), &fake_env, &mut NullLogger);
assert_matches!(result, Ok(Some(v)) if v == Value::Object(Map::new()));
// Env var name not a string
let result = under_test.maybe_subst_from_env(
&name,
json!({ "from_env": 123 }),
&fake_env,
&mut NullLogger,
);
assert_matches!(result, Err(e)
if e == UsageError::FromEnvNotString {
parameter: name.clone(),
var_name_value: Value::Number(Number::from(123))
} );
let result = under_test.maybe_subst_from_env(
&name,
json!({ "try_from_env": 123 }),
&fake_env,
&mut NullLogger,
);
assert_matches!(result, Err(e)
if e == UsageError::FromEnvNotString {
parameter: name.clone(),
var_name_value: Value::Number(Number::from(123))
} );
// Udefined env var.
let result = under_test.maybe_subst_from_env(
&name,
json!({ "from_env": "undefined" }),
&fake_env,
&mut NullLogger,
);
assert_matches!(result, Err(e)if e == UsageError::FromEnvUndefined {
parameter: name.clone(),
var_name_value: Value::String(String::from("undefined"))
} );
let result = under_test.maybe_subst_from_env(
&name,
json!({ "try_from_env": "undefined" }),
&fake_env,
&mut NullLogger,
);
assert_matches!(result, Ok(None));
// Successful substitution.
let result = under_test.maybe_subst_from_env(
&Name::Parameter(String::from("true")), // Declared in schema as bool
json!({ "from_env": "BOOL" }),
&fake_env,
&mut NullLogger,
);
assert_matches!(result, Ok(Some(Value::Bool(false))));
let result = under_test.maybe_subst_from_env(
&Name::Parameter(String::from("zero")), // Declared in schema as number
json!({ "from_env": "NUMBER" }),
&fake_env,
&mut NullLogger,
);
assert_matches!(result, Ok(Some(v)) if v == Value::Number(Number::from(123)));
let result = under_test.maybe_subst_from_env(
&Name::Parameter(String::from("string")), // Declared in schema as number
json!({ "from_env": "STRING" }),
&fake_env,
&mut NullLogger,
);
assert_matches!(result, Ok(Some(v)) if v == Value::String(String::from("foo")));
let result = under_test.maybe_subst_from_env(
&Name::Parameter(String::from("array_of_number")), // Declared in schema as array of numbers
json!({ "from_env": "ARRAY" }),
&fake_env,
&mut NullLogger,
);
assert_matches!(result,Ok(Some(v))
if v == Value::Array(vec![Value::Number(Number::from(1)),
Value::Number(Number::from(2))]));
let result = under_test.maybe_subst_from_env(
&Name::Parameter(String::from("true")), // Declared in schema as bool
json!({ "try_from_env": "BOOL" }),
&fake_env,
&mut NullLogger,
);
assert_matches!(result, Ok(Some(Value::Bool(false))));
let result = under_test.maybe_subst_from_env(
&Name::Parameter(String::from("zero")), // Declared in schema as number
json!({ "try_from_env": "NUMBER" }),
&fake_env,
&mut NullLogger,
);
assert_matches!(result, Ok(Some(v)) if v == Value::Number(Number::from(123)));
let result = under_test.maybe_subst_from_env(
&Name::Parameter(String::from("string")), // Declared in schema as number
json!({ "try_from_env": "STRING" }),
&fake_env,
&mut NullLogger,
);
assert_matches!(result, Ok(Some(v)) if v == Value::String(String::from("foo")));
let result = under_test.maybe_subst_from_env(
&Name::Parameter(String::from("array_of_number")), // Declared in schema as array of numbers
json!({ "try_from_env": "ARRAY" }),
&fake_env,
&mut NullLogger,
);
assert_matches!(result,Ok(Some(v))
if v == Value::Array(vec![Value::Number(Number::from(1)),
Value::Number(Number::from(2))]));
}
#[test]
// Tests that from_env object works in a JSON file.
fn test_from_env() {
let temp_file = NamedTempFile::new().expect("Failed to create temporary file");
let temp_file_path = temp_file.path().to_str().unwrap();
let json = json!({"true": {"from_env": "BOOL"}});
fs::write(temp_file_path, serde_json::to_string(&json).expect("JSON can be serialized"))
.expect("Failed to write to temporary file");
let fake_env = FakeEnv::new(format!("--include={}", temp_file_path).as_str(), "BOOL=false");
let under_test =
TestConfigBuilder::from_env_like(&fake_env, fake_schema(), &mut NullLogger)
.expect("Ok result");
assert_matches!(
under_test.param_values_by_name.get(&Name::Parameter(String::from("true"))),
Some(&Value::Bool(false))
);
}
}