mod common;
use common::yaml_scenario::{Expect, Scenario, Step};
use hocon::{Config, ParseOptions, ResolveOptions};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
const DR_YAML_DIR: &str = "tests/testdata/hocon/deferred-resolution";
const DR_EXPECTED_DIR: &str = "tests/testdata/expected/deferred-resolution";
const SCENARIO_SKIP: &[(&str, &str)] = &[
(
"dr17",
"E11 package-include — covered by programmatic tests; YAML runner cannot register packages",
),
(
"dr12",
"origin format differs from Lightbend — rs.hocon position info diverges; \
resolution semantics covered by other error scenarios",
),
#[cfg(not(feature = "serde"))]
(
"dr01",
"fromMap source — requires the 'serde' feature (run `cargo test --features serde`)",
),
#[cfg(not(feature = "serde"))]
(
"dr02",
"fromMap source — requires the 'serde' feature (run `cargo test --features serde`)",
),
#[cfg(not(feature = "serde"))]
(
"dr03",
"fromMap source — requires the 'serde' feature (run `cargo test --features serde`)",
),
#[cfg(not(feature = "serde"))]
(
"dr15",
"fromMap source — requires the 'serde' feature (run `cargo test --features serde`)",
),
#[cfg(not(feature = "serde"))]
(
"dr16",
"fromMap source — requires the 'serde' feature (run `cargo test --features serde`)",
),
#[cfg(not(feature = "serde"))]
(
"dr29",
"fromMap source — requires the 'serde' feature (run `cargo test --features serde`)",
),
];
fn scenario_id(filename: &str) -> String {
let base = filename.strip_suffix(".yaml").unwrap_or(filename);
if let Some(dash) = base.find('-') {
base[..dash].to_owned()
} else {
base.to_owned()
}
}
fn skip_reason(id: &str) -> Option<&'static str> {
SCENARIO_SKIP.iter().find_map(|(prefix, reason)| {
if id.starts_with(prefix) {
Some(*reason)
} else {
None
}
})
}
#[test]
fn deferred_resolution_fixtures() {
let yaml_dir = Path::new(DR_YAML_DIR);
if !yaml_dir.exists() {
eprintln!(
"SKIP: {} does not exist; run 'make testdata' to fetch fixture corpus",
DR_YAML_DIR
);
return;
}
let mut entries: Vec<_> = std::fs::read_dir(yaml_dir)
.expect("read_dir failed")
.filter_map(|e| e.ok())
.filter(|e| e.path().extension().is_some_and(|x| x == "yaml"))
.collect();
entries.sort_by_key(|e| e.file_name());
let mut failures = 0usize;
let mut skipped = 0usize;
let mut passed = 0usize;
for entry in &entries {
let path = entry.path();
let name = entry.file_name();
let filename = name.to_string_lossy();
let id = scenario_id(&filename);
if let Some(reason) = skip_reason(&id) {
eprintln!("SKIP {}: {}", id, reason);
skipped += 1;
continue;
}
match run_scenario(&id, &path) {
Ok(()) => {
passed += 1;
}
Err(msg) => {
eprintln!("FAIL {}: {}", id, msg);
failures += 1;
}
}
}
eprintln!(
"\nDeferred-resolution fixtures: {} passed, {} skipped, {} failed",
passed, skipped, failures
);
assert_eq!(failures, 0, "{} scenario(s) failed", failures);
}
fn run_scenario(id: &str, yaml_path: &Path) -> Result<(), String> {
let data = std::fs::read_to_string(yaml_path)
.map_err(|e| format!("read {}: {}", yaml_path.display(), e))?;
let sc: Scenario = serde_yaml::from_str(&data)
.map_err(|e| format!("yaml parse {}: {}", yaml_path.display(), e))?;
let mut artefacts: HashMap<String, Config> = HashMap::new();
let mut source_errors: HashMap<String, String> = HashMap::new();
for (name, src) in &sc.sources {
match build_source(src) {
Ok(cfg) => {
artefacts.insert(name.clone(), cfg);
}
Err(e) => {
source_errors.insert(name.clone(), e);
}
}
}
let mut step_errors: Vec<Option<String>> = Vec::new();
let mut final_name = "result".to_owned();
for (i, step) in sc.build.iter().enumerate() {
let err = execute_step(i, step, &mut artefacts, &source_errors);
if !step.r#as.is_empty() {
final_name = step.r#as.clone();
}
step_errors.push(err);
}
match sc.expect.outcome.as_str() {
"success" => validate_success(id, &sc.expect, &artefacts, &final_name, &step_errors),
"error" => validate_error(id, &sc.expect, &step_errors),
other => Err(format!("unknown expect.outcome {:?}", other)),
}
}
fn build_source(src: &common::yaml_scenario::Source) -> Result<Config, String> {
if let Some(ref text) = src.parse_string {
let mut opts = ParseOptions::defaults();
if let Some(ref po) = src.parse_options {
let resolve_subst = po.resolve_substitutions.unwrap_or(false);
opts = opts.with_resolve_substitutions(resolve_subst);
if let Some(ref od) = po.origin_description {
opts = opts.with_origin_description(od.clone());
}
} else {
opts = opts.with_resolve_substitutions(false);
}
if let Some(ref od) = src.origin_description {
opts = opts.with_origin_description(od.clone());
}
hocon::parse_string_with_options(text, opts).map_err(|e| format!("{}", e))
} else if src.from_map.is_some() {
build_from_map_source(src)
} else {
Ok(hocon::empty(src.origin_description.as_deref()))
}
}
#[cfg(feature = "serde")]
fn build_from_map_source(src: &common::yaml_scenario::Source) -> Result<Config, String> {
let yaml_val = src.from_map.as_ref().unwrap();
let json_val = yaml_to_json(yaml_val);
let map = match json_val {
serde_json::Value::Object(m) => m,
other => return Err(format!("fromMap must be a mapping, got {:?}", other)),
};
hocon::from_map(map, src.origin_description.as_deref()).map_err(|e| format!("{}", e))
}
#[cfg(not(feature = "serde"))]
fn build_from_map_source(src: &common::yaml_scenario::Source) -> Result<Config, String> {
let _ = src;
Err("fromMap requires the 'serde' feature; rerun with --features serde".to_owned())
}
fn yaml_to_json(val: &serde_yaml::Value) -> serde_json::Value {
match val {
serde_yaml::Value::Null => serde_json::Value::Null,
serde_yaml::Value::Bool(b) => serde_json::Value::Bool(*b),
serde_yaml::Value::Number(n) => {
if let Some(i) = n.as_i64() {
serde_json::Value::Number(serde_json::Number::from(i))
} else if let Some(f) = n.as_f64() {
serde_json::Number::from_f64(f)
.map(serde_json::Value::Number)
.unwrap_or(serde_json::Value::Null)
} else {
serde_json::Value::Null
}
}
serde_yaml::Value::String(s) => serde_json::Value::String(s.clone()),
serde_yaml::Value::Sequence(arr) => {
serde_json::Value::Array(arr.iter().map(yaml_to_json).collect())
}
serde_yaml::Value::Mapping(m) => {
let mut map = serde_json::Map::new();
for (k, v) in m {
if let serde_yaml::Value::String(ks) = k {
map.insert(ks.clone(), yaml_to_json(v));
} else {
map.insert(format!("{:?}", k), yaml_to_json(v));
}
}
serde_json::Value::Object(map)
}
serde_yaml::Value::Tagged(tv) => yaml_to_json(&tv.value),
}
}
fn execute_step(
_i: usize,
step: &Step,
artefacts: &mut HashMap<String, Config>,
source_errors: &HashMap<String, String>,
) -> Option<String> {
match step.op.as_str() {
"take" => {
let src_name = step.source.as_deref().unwrap_or("");
if let Some(e) = source_errors.get(src_name) {
return Some(e.clone());
}
if let Some(cfg) = artefacts.get(src_name).cloned() {
artefacts.insert(step.r#as.clone(), cfg);
None
} else {
Some(format!("take: source {:?} not found", src_name))
}
}
"withFallback" => {
let this_name = step.this.as_deref().unwrap_or("");
let other_name = step.other.as_deref().unwrap_or("");
let base = match artefacts.get(this_name).cloned() {
Some(c) => c,
None => return Some(format!("withFallback: this={:?} not found", this_name)),
};
let fb = match artefacts.get(other_name).cloned() {
Some(c) => c,
None => {
if let Some(e) = source_errors.get(other_name) {
return Some(e.clone());
}
return Some(format!("withFallback: other={:?} not found", other_name));
}
};
artefacts.insert(step.r#as.clone(), base.with_fallback(&fb));
None
}
"resolve" => {
let this_name = step.this.as_deref().unwrap_or("");
let base = match artefacts.get(this_name).cloned() {
Some(c) => c,
None => return Some(format!("resolve: this={:?} not found", this_name)),
};
let mut opts = ResolveOptions::defaults().with_use_system_environment(false);
if let Some(b) = step.allow_unresolved {
opts = opts.with_allow_unresolved(b);
}
if let Some(b) = step.use_system_environment {
opts = opts.with_use_system_environment(b);
}
match base.resolve(opts) {
Ok(resolved) => {
artefacts.insert(step.r#as.clone(), resolved);
None
}
Err(e) => Some(format!("{}", e)),
}
}
"resolveWith" => {
let this_name = step.this.as_deref().unwrap_or("");
let src_name = step.source.as_deref().unwrap_or("");
let base = match artefacts.get(this_name).cloned() {
Some(c) => c,
None => return Some(format!("resolveWith: this={:?} not found", this_name)),
};
let src_cfg = match artefacts.get(src_name).cloned() {
Some(c) => c,
None => {
if let Some(e) = source_errors.get(src_name) {
return Some(e.clone());
}
return Some(format!("resolveWith: source={:?} not found", src_name));
}
};
let mut opts = ResolveOptions::defaults().with_use_system_environment(false);
if let Some(b) = step.allow_unresolved {
opts = opts.with_allow_unresolved(b);
}
if let Some(b) = step.use_system_environment {
opts = opts.with_use_system_environment(b);
}
match base.resolve_with(&src_cfg, opts) {
Ok(resolved) => {
artefacts.insert(step.r#as.clone(), resolved);
None
}
Err(e) => Some(format!("{}", e)),
}
}
"extract" => {
let this_name = step.this.as_deref().unwrap_or("");
let path = step.path.as_deref().unwrap_or("");
let base = match artefacts.get(this_name).cloned() {
Some(c) => c,
None => return Some(format!("extract: this={:?} not found", this_name)),
};
match base.get_config(path) {
Ok(sub) => {
artefacts.insert(step.r#as.clone(), sub);
None
}
Err(e) => Some(format!("extract: get_config({:?}): {}", path, e)),
}
}
other => Some(format!("unknown op {:?}", other)),
}
}
fn validate_success(
id: &str,
expect: &Expect,
artefacts: &HashMap<String, Config>,
final_name: &str,
step_errors: &[Option<String>],
) -> Result<(), String> {
for (i, e) in step_errors.iter().enumerate() {
if let Some(msg) = e {
return Err(format!("unexpected error at step {}: {}", i, msg));
}
}
let cfg = artefacts
.get(final_name)
.ok_or_else(|| format!("final artefact {:?} not found", final_name))?;
if let Some(expected_resolved) = expect.is_resolved {
if cfg.is_resolved() != expected_resolved {
return Err(format!(
"isResolved = {}, want {}",
cfg.is_resolved(),
expected_resolved
));
}
}
if cfg.is_resolved() {
let actual_json = hocon::_render_json_for_test(cfg);
let expected_path = find_expected_json(id)?;
if let Some(path) = expected_path {
let expected_raw =
std::fs::read_to_string(&path).map_err(|e| format!("read expected JSON: {}", e))?;
if !json_equal(&expected_raw, &actual_json) {
return Err(format!(
"JSON mismatch\n want: {}\n got: {}",
expected_raw.trim(),
actual_json
));
}
} else if let Some(ref hint) = expect.json {
if !json_equal(hint, &actual_json) {
return Err(format!(
"in-YAML JSON mismatch\n want: {}\n got: {}",
hint, actual_json
));
}
}
}
for g in &expect.getter {
run_getter_assert(id, cfg, g)?;
}
Ok(())
}
fn validate_error(
_id: &str,
expect: &Expect,
step_errors: &[Option<String>],
) -> Result<(), String> {
let first_err = step_errors
.iter()
.enumerate()
.find_map(|(i, e)| e.as_ref().map(|msg| (i, msg.as_str())));
let (err_idx, err_msg) = first_err.ok_or_else(|| {
format!(
"expected error (category={:?}) but all steps succeeded",
expect.error_category
)
})?;
if let Some(expected_at) = expect.error_at {
if err_idx != expected_at {
return Err(format!(
"errorAt = {}, want {} (err={})",
err_idx, expected_at, err_msg
));
}
}
if let Some(ref category) = expect.error_category {
if !category_matches_msg(category, err_msg) {
return Err(format!(
"error category mismatch: want {:?}, got msg={:?}",
category, err_msg
));
}
}
if let Some(ref contains) = expect.error_contains {
if !err_msg.contains(contains.as_str()) {
eprintln!(
"note: errorContains hint {:?} not in {:?} (impl-specific; not failing)",
contains, err_msg
);
}
}
Ok(())
}
fn category_matches_msg(category: &str, err_msg: &str) -> bool {
let lower = err_msg.to_lowercase();
match category {
"ParseError" => {
lower.contains("parse") || lower.contains("lex") || lower.contains("syntax")
}
"ResolveError" => {
lower.contains("resolve")
|| lower.contains("substitut")
|| lower.contains("circular")
|| lower.contains("cycle")
|| lower.contains("self-referential")
|| lower.contains("could not resolve")
}
"NotResolved" => lower.contains("not resolved") || lower.contains("unresolved"),
"TypeError" => {
lower.contains("type")
|| lower.contains("concat")
|| lower.contains("cannot concat")
|| lower.contains("parse")
|| lower.contains("resolve")
}
"CycleError" => {
lower.contains("circular")
|| lower.contains("cycle")
|| lower.contains("self-referential")
|| lower.contains("resolve")
|| lower.contains("could not resolve")
}
_ => false,
}
}
fn run_getter_assert(
id: &str,
cfg: &Config,
g: &common::yaml_scenario::GetterAssert,
) -> Result<(), String> {
if let Some(ref expect_error) = g.expect_error {
match expect_error.as_str() {
"NotResolved" => {
let result = cfg.get_string(&g.path);
if result.is_ok() {
return Err(format!(
"[{}] getter {:?}: expected NotResolved error, got Ok",
id, g.path
));
}
let err_msg = format!("{}", result.unwrap_err());
if !err_msg.to_lowercase().contains("not resolved")
&& !err_msg.to_lowercase().contains("unresolved")
{
return Err(format!(
"[{}] getter {:?}: expected NotResolved error, got {:?}",
id, g.path, err_msg
));
}
return Ok(());
}
other => {
return Err(format!(
"[{}] getter {:?}: unknown expectError {:?}",
id, g.path, other
));
}
}
}
if let Some(ref expected) = g.expect_string {
let got = cfg
.get_string(&g.path)
.map_err(|e| format!("[{}] getter {:?}: {}", id, g.path, e))?;
if &got != expected {
return Err(format!(
"[{}] getter {:?}: got {:?}, want {:?}",
id, g.path, got, expected
));
}
}
if let Some(expected_int) = g.expect_int {
let got = cfg
.get_i64(&g.path)
.map_err(|e| format!("[{}] getter {:?}: {}", id, g.path, e))?;
if got != expected_int {
return Err(format!(
"[{}] getter {:?}: got {}, want {}",
id, g.path, got, expected_int
));
}
}
if let Some(expected_bool) = g.expect_bool {
let got = cfg
.get_bool(&g.path)
.map_err(|e| format!("[{}] getter {:?}: {}", id, g.path, e))?;
if got != expected_bool {
return Err(format!(
"[{}] getter {:?}: got {}, want {}",
id, g.path, got, expected_bool
));
}
}
Ok(())
}
fn find_expected_json(id: &str) -> Result<Option<PathBuf>, String> {
let dir = Path::new(DR_EXPECTED_DIR);
if !dir.exists() {
return Ok(None);
}
let entries =
std::fs::read_dir(dir).map_err(|e| format!("read_dir {}: {}", dir.display(), e))?;
for entry in entries.filter_map(|e| e.ok()) {
let name = entry.file_name();
let name = name.to_string_lossy();
if name.starts_with(&format!("{}-", id)) && name.ends_with("-expected.json") {
return Ok(Some(entry.path()));
}
}
Ok(None)
}
fn json_equal(a: &str, b: &str) -> bool {
let va: serde_json::Value = match serde_json::from_str(a) {
Ok(v) => v,
Err(_) => return false,
};
let vb: serde_json::Value = match serde_json::from_str(b) {
Ok(v) => v,
Err(_) => return false,
};
va == vb
}