use hocon::HoconValue;
use std::collections::HashMap;
use std::fs;
use std::path::PathBuf;
fn fixture_dir() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/testdata/hocon/env-var-list")
}
fn expected_dir() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/testdata/expected/env-var-list")
}
fn fixture_path(name: &str) -> PathBuf {
fixture_dir().join(format!("{}.conf", name))
}
fn env_path(name: &str) -> PathBuf {
fixture_dir().join(format!("{}.env", name))
}
fn expected_path(name: &str) -> PathBuf {
expected_dir().join(format!("{}-expected.json", name))
}
fn parse_env_sidecar(path: &std::path::Path) -> HashMap<String, String> {
let content = fs::read_to_string(path)
.unwrap_or_else(|e| panic!("failed to read env sidecar {}: {}", path.display(), e));
let mut map = HashMap::new();
for line in content.lines() {
let trimmed = line.trim_start_matches([' ', '\t']);
if trimmed.is_empty() || trimmed.starts_with('#') {
continue;
}
if let Some(eq) = trimmed.find('=') {
let key = trimmed[..eq].to_string();
let val = trimmed[eq + 1..].to_string();
map.insert(key, val);
}
}
map
}
fn normalize(v: &serde_json::Value) -> serde_json::Value {
match v {
serde_json::Value::Object(map) => {
let mut m = serde_json::Map::new();
for (k, val) in map {
m.insert(k.clone(), normalize(val));
}
serde_json::Value::Object(m)
}
serde_json::Value::Array(arr) => {
serde_json::Value::Array(arr.iter().map(normalize).collect())
}
serde_json::Value::Number(n) => {
let f = n.as_f64().unwrap_or(0.0);
serde_json::json!(f)
}
other => other.clone(),
}
}
fn hocon_to_json(v: &hocon::HoconValue) -> serde_json::Value {
match v {
hocon::HoconValue::Object(map) => {
let mut m = serde_json::Map::new();
for (k, val) in map {
m.insert(k.clone(), hocon_to_json(val));
}
serde_json::Value::Object(m)
}
hocon::HoconValue::Array(arr) => {
serde_json::Value::Array(arr.iter().map(hocon_to_json).collect())
}
hocon::HoconValue::Scalar(sv) => match sv.value_type {
hocon::ScalarType::Null => serde_json::Value::Null,
hocon::ScalarType::Boolean => serde_json::Value::Bool(sv.raw == "true"),
hocon::ScalarType::Number => {
if !sv.raw.contains('.') && !sv.raw.contains('e') && !sv.raw.contains('E') {
if let Ok(n) = sv.raw.parse::<i64>() {
return serde_json::json!(n as f64);
}
}
if let Ok(f) = sv.raw.parse::<f64>() {
return serde_json::json!(f);
}
serde_json::Value::String(sv.raw.clone())
}
hocon::ScalarType::String => serde_json::Value::String(sv.raw.clone()),
_ => serde_json::Value::String(sv.raw.clone()),
},
_ => unreachable!("unknown HoconValue variant"),
}
}
fn key_to_lookup_path(key: &str) -> String {
if key.is_empty()
|| key.contains('.')
|| key.contains('"')
|| key.contains('\\')
|| key.contains(' ')
|| key.contains('\t')
{
let escaped = key.replace('\\', "\\\\").replace('"', "\\\"");
format!("\"{}\"", escaped)
} else {
key.to_string()
}
}
fn config_to_json(config: &hocon::Config) -> serde_json::Value {
let mut m = serde_json::Map::new();
for key in config.keys() {
let path = key_to_lookup_path(key);
if let Some(val) = config.get(&path) {
m.insert(key.to_string(), hocon_to_json(val));
}
}
normalize(&serde_json::Value::Object(m))
}
fn parse_fixture_with_env(name: &str) -> Result<hocon::Config, hocon::HoconError> {
let env = parse_env_sidecar(&env_path(name));
hocon::parse_file_with_env(fixture_path(name), &env)
}
fn load_expected_json(name: &str) -> serde_json::Value {
let path = expected_path(name);
let s = fs::read_to_string(&path)
.unwrap_or_else(|e| panic!("failed to read expected {}: {}", path.display(), e));
let v: serde_json::Value = serde_json::from_str(&s)
.unwrap_or_else(|e| panic!("invalid JSON in {}: {}", path.display(), e));
normalize(&v)
}
fn assert_fixture_matches(name: &str) {
let cfg = parse_fixture_with_env(name)
.unwrap_or_else(|e| panic!("s13c {}: unexpected parse/resolve error: {:?}", name, e));
let got = config_to_json(&cfg);
let expected = load_expected_json(name);
assert_eq!(
got,
expected,
"s13c {}: output mismatch\n got: {}\n expected: {}",
name,
serde_json::to_string_pretty(&got).unwrap(),
serde_json::to_string_pretty(&expected).unwrap(),
);
}
#[test]
fn s13c_ev01_basic() {
assert_fixture_matches("ev01-basic");
}
#[test]
fn s13c_ev02_stops_at_gap() {
assert_fixture_matches("ev02-stops-at-gap");
}
#[test]
fn s13c_ev04_optional_no_elements() {
assert_fixture_matches("ev04-optional-no-elements");
}
#[test]
fn s13c_ev05_config_defined_wins() {
assert_fixture_matches("ev05-config-defined-wins");
}
#[test]
fn s13c_ev06_concat_prepend() {
assert_fixture_matches("ev06-concat-prepend");
}
#[test]
fn s13c_ev07_concat_append() {
assert_fixture_matches("ev07-concat-append");
}
#[test]
fn s13c_ev09_whitespace_before_suffix() {
assert_fixture_matches("ev09-whitespace-before-suffix");
}
#[test]
fn s13c_ev10_empty_string_element() {
assert_fixture_matches("ev10-empty-string-element");
}
#[test]
fn s13c_ev11_include_context() {
assert_fixture_matches("ev11-include-context");
}
#[test]
fn s13c_ev03_required_no_elements_errors() {
let result = parse_fixture_with_env("ev03-required-no-elements");
assert!(
matches!(result, Err(hocon::HoconError::Resolve(_))),
"ev03: required list with no _0 must raise ResolveError, got: {:?}",
result
);
}
#[test]
fn s13c_s5_required_no_scalar_fallback() {
let mut env = HashMap::new();
env.insert("S13C_BARE".into(), "scalar".into());
let result = hocon::parse_with_env("x = ${S13C_BARE[]}", &env);
assert!(
matches!(result, Err(hocon::HoconError::Resolve(_))),
"S13c.5: required list with no _0 must raise ResolveError even when bare key exists, got: {:?}",
result
);
}
#[test]
fn s13c_s5_optional_no_scalar_fallback() {
let mut env = HashMap::new();
env.insert("S13C_BARE_OPT".into(), "scalar".into());
let cfg = hocon::parse_with_env("x = ${?S13C_BARE_OPT[]}", &env)
.expect("s13c.5: optional list with no _0 must parse OK (key dropped)");
assert!(
cfg.get("x").is_none(),
"S13c.5: optional list with no _0 must drop key (got {:?})",
cfg.get("x")
);
}
#[test]
fn s13c_ev08_self_ref_concat() {
assert_fixture_matches("ev08-self-append");
}
#[test]
fn s13c_cache_disambiguation_scalar_then_list() {
let mut env = HashMap::new();
env.insert("S13C_CACHE_X".to_string(), "scalar-val".to_string());
env.insert("S13C_CACHE_X_0".to_string(), "a".to_string());
env.insert("S13C_CACHE_X_1".to_string(), "b".to_string());
let cfg = hocon::parse_with_env("a = ${S13C_CACHE_X}\nb = ${S13C_CACHE_X[]}", &env)
.expect("parse_with_env");
assert_eq!(cfg.get_string("a").unwrap(), "scalar-val");
let b = cfg.get_list("b").expect("b should be list");
let texts: Vec<String> = b
.iter()
.map(|v| match v {
HoconValue::Scalar(sv) => sv.raw.clone(),
_ => panic!("expected scalar element in b"),
})
.collect();
assert_eq!(texts, vec!["a", "b"]);
}
#[test]
fn s13c_cache_disambiguation_list_then_scalar() {
let mut env = HashMap::new();
env.insert("S13C_CACHE2_X".to_string(), "scalar-val".to_string());
env.insert("S13C_CACHE2_X_0".to_string(), "a".to_string());
env.insert("S13C_CACHE2_X_1".to_string(), "b".to_string());
let cfg = hocon::parse_with_env("a = ${S13C_CACHE2_X[]}\nb = ${S13C_CACHE2_X}", &env)
.expect("parse_with_env");
let a = cfg.get_list("a").expect("a should be list");
let texts: Vec<String> = a
.iter()
.map(|v| match v {
HoconValue::Scalar(sv) => sv.raw.clone(),
_ => panic!("expected scalar element in a"),
})
.collect();
assert_eq!(texts, vec!["a", "b"]);
assert_eq!(cfg.get_string("b").unwrap(), "scalar-val");
}
#[test]
fn s13c_cache_disambiguation_quoted_brackets_then_list() {
let mut env = HashMap::new();
env.insert("X[]".to_string(), "literal-bracket-key".to_string());
env.insert("X_0".to_string(), "first".to_string());
env.insert("X_1".to_string(), "second".to_string());
let cfg = hocon::parse_with_env("a = ${\"X[]\"}\nb = ${X[]}", &env).expect("parse_with_env");
assert_eq!(cfg.get_string("a").unwrap(), "literal-bracket-key");
let b = cfg
.get_list("b")
.expect("b should be list, not the cached scalar");
let texts: Vec<String> = b
.iter()
.map(|v| match v {
HoconValue::Scalar(sv) => sv.raw.clone(),
_ => panic!("expected scalar element in b"),
})
.collect();
assert_eq!(texts, vec!["first", "second"]);
}
#[test]
fn s13c_cache_disambiguation_list_then_quoted_brackets() {
let mut env = HashMap::new();
env.insert("X[]".to_string(), "literal-bracket-key".to_string());
env.insert("X_0".to_string(), "first".to_string());
env.insert("X_1".to_string(), "second".to_string());
let cfg = hocon::parse_with_env("a = ${X[]}\nb = ${\"X[]\"}", &env).expect("parse_with_env");
let a = cfg.get_list("a").expect("a should be list");
let texts: Vec<String> = a
.iter()
.map(|v| match v {
HoconValue::Scalar(sv) => sv.raw.clone(),
_ => panic!("expected scalar element in a"),
})
.collect();
assert_eq!(texts, vec!["first", "second"]);
assert_eq!(
cfg.get_string("b").unwrap(),
"literal-bracket-key",
"b should be scalar, not the cached list"
);
}
#[test]
fn s13c_lex_trailing_dot_before_suffix_errors() {
let env = HashMap::new();
let err = hocon::parse_with_env("x = ${A.[]}", &env);
assert!(
matches!(err, Err(hocon::HoconError::Parse(_))),
"expected HoconError::Parse for ${{A.[]}}, got {:?}",
err
);
}
#[test]
fn s13c_lex_trailing_dot_space_before_suffix_errors() {
let env = HashMap::new();
let err = hocon::parse_with_env("x = ${A . []}", &env);
assert!(
matches!(err, Err(hocon::HoconError::Parse(_))),
"expected HoconError::Parse for ${{A . []}}, got {:?}",
err
);
}
#[test]
fn s13c_lex_nbsp_before_suffix_errors() {
let env = HashMap::new();
let err = hocon::parse_with_env("x = ${A\u{00A0}[]}", &env);
assert!(
matches!(err, Err(hocon::HoconError::Parse(_))),
"expected HoconError::Parse for ${{A\\u00A0[]}} (NBSP), got {:?}",
err
);
}
#[test]
fn s13c_lex_cr_before_suffix_errors() {
let env = HashMap::new();
let err = hocon::parse_with_env("x = ${A\r[]}", &env);
assert!(
matches!(err, Err(hocon::HoconError::Parse(_))),
"expected HoconError::Parse for ${{A\\r[]}} (CR), got {:?}",
err
);
}
#[test]
fn s13c_lex_zs_em_space_before_suffix_errors() {
let env = HashMap::new();
let err = hocon::parse_with_env("x = ${A\u{2003}[]}", &env);
assert!(
matches!(err, Err(hocon::HoconError::Parse(_))),
"expected HoconError::Parse for ${{A\\u2003[]}} (em-space), got {:?}",
err
);
}
#[test]
fn s13c_lex_e7_ascii_space_tab_before_suffix_ok() {
let mut env = HashMap::new();
env.insert("S13C_E7_OK_0".to_string(), "v".to_string());
let cfg = hocon::parse_with_env("x = ${S13C_E7_OK []}", &env)
.expect("space before [] should be accepted");
assert!(cfg.has("x"));
let cfg2 = hocon::parse_with_env("x = ${S13C_E7_OK\t[]}", &env)
.expect("tab before [] should be accepted");
assert!(cfg2.has("x"));
}
#[test]
fn s13c_lex_quoted_segment_with_suffix_ok() {
let mut env = HashMap::new();
env.insert("a_0".to_string(), "v".to_string());
let cfg = hocon::parse_with_env(r#"x = ${"a"[]}"#, &env)
.expect("quoted segment + suffix should be accepted");
let list = cfg.get_list("x").expect("x should be list");
assert_eq!(list.len(), 1);
match &list[0] {
HoconValue::Scalar(sv) => assert_eq!(sv.raw, "v"),
_ => panic!("expected scalar element"),
}
}
#[test]
fn s13c_ev12c_e6_cross_source_include_config_wins() {
let mut env = HashMap::new();
env.insert("S13C_EV12C_X_0".to_string(), "env-val".to_string());
let cfg = hocon::parse_file_with_env(fixture_path("ev12c-include-config-defined-wins"), &env)
.expect("ev12c: cross-source resolution should succeed");
let got = config_to_json(&cfg);
let expected = load_expected_json("ev12c-include-config-defined-wins");
assert_eq!(
got,
expected,
"ev12c: cross-source config-defined wins mismatch (env-list candidate present — \
a failure here likely means listSuffix ran before S14c.2)\n \
got: {}\n expected: {}",
serde_json::to_string_pretty(&got).unwrap(),
serde_json::to_string_pretty(&expected).unwrap(),
);
}