use hocon::parse;
use std::collections::HashMap;
fn test_tmp_dir(name: &str) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!("hocon_test_{}", name));
let _ = std::fs::create_dir_all(&dir);
dir
}
#[test]
fn parse_simple_config() {
let config = parse("host = \"localhost\"\nport = 8080").unwrap();
assert_eq!(config.get_string("host").unwrap(), "localhost");
assert_eq!(config.get_i64("port").unwrap(), 8080);
}
#[test]
fn parse_nested_config() {
let config = parse(
r#"
server {
host = "localhost"
port = 8080
}
"#,
)
.unwrap();
assert_eq!(config.get_string("server.host").unwrap(), "localhost");
assert_eq!(config.get_i64("server.port").unwrap(), 8080);
}
#[test]
fn parse_with_substitutions() {
let config = parse(
r#"
host = "localhost"
url = "http://"${host}":8080"
"#,
)
.unwrap();
assert_eq!(config.get_string("url").unwrap(), "http://localhost:8080");
}
#[test]
fn parse_with_env_fallback() {
let config = hocon::parse_with_env("port = 50051\nport = ${?GRPC_PORT}", &{
let mut m = HashMap::new();
m.insert("GRPC_PORT".into(), "9090".into());
m
})
.unwrap();
assert_eq!(config.get_string("port").unwrap(), "9090");
}
#[test]
fn parse_with_optional_substitution_fallback() {
let config =
hocon::parse_with_env("port = 50051\nport = ${?GRPC_PORT}", &HashMap::new()).unwrap();
assert_eq!(config.get_i64("port").unwrap(), 50051);
}
#[test]
fn parse_with_deep_merge() {
let config = parse(
r#"
server { host = "a" }
server { port = 8080 }
"#,
)
.unwrap();
assert_eq!(config.get_string("server.host").unwrap(), "a");
assert_eq!(config.get_i64("server.port").unwrap(), 8080);
}
#[test]
fn parse_with_arrays() {
let config = parse("list = [1, 2, 3]").unwrap();
let list = config.get_list("list").unwrap();
assert_eq!(list.len(), 3);
}
#[test]
fn parse_with_plus_equals() {
let config = parse("list = [1, 2]\nlist += 3").unwrap();
let list = config.get_list("list").unwrap();
assert_eq!(list.len(), 3);
}
#[test]
fn parse_with_comments() {
let config = parse(
r#"
# this is a comment
host = "localhost" // inline comment
port = 8080
"#,
)
.unwrap();
assert_eq!(config.get_string("host").unwrap(), "localhost");
assert_eq!(config.get_i64("port").unwrap(), 8080);
}
#[test]
fn parse_with_triple_quoted_string() {
let config = parse(
r#"
msg = """
hello
world"""
"#,
)
.unwrap();
assert_eq!(config.get_string("msg").unwrap(), "hello\nworld");
}
#[test]
fn parse_bool_coercion() {
let config = parse(
r#"
a = true
b = "false"
c = "yes"
d = "OFF"
"#,
)
.unwrap();
assert!(config.get_bool("a").unwrap());
assert!(!config.get_bool("b").unwrap());
assert!(config.get_bool("c").unwrap());
assert!(!config.get_bool("d").unwrap());
}
#[test]
fn parse_with_fallback() {
let c1 = parse("host = \"prod\"").unwrap();
let c2 = parse("host = \"dev\"\nport = 8080").unwrap();
let merged = c1.with_fallback(&c2);
assert_eq!(merged.get_string("host").unwrap(), "prod");
assert_eq!(merged.get_i64("port").unwrap(), 8080);
}
#[test]
fn parse_dot_notation() {
let config = parse("a.b.c = 1").unwrap();
assert_eq!(config.get_i64("a.b.c").unwrap(), 1);
}
#[test]
fn parse_self_referential_substitution() {
let config = parse("path = \"/usr\"\npath = ${path}:/extra").unwrap();
let path = config.get_string("path").unwrap();
assert!(path.contains("/usr"));
assert!(path.contains("/extra"));
}
#[test]
fn test_braced_root_object_concat() {
let cfg = hocon::parse("{ a = 1 } { b = 2 }").unwrap();
assert_eq!(cfg.get_i64("a").unwrap(), 1);
assert_eq!(cfg.get_i64("b").unwrap(), 2);
}
#[test]
fn test_braced_root_with_trailing_fields() {
let cfg = hocon::parse("{ a = 1 }\nb = 2").unwrap();
assert_eq!(cfg.get_i64("a").unwrap(), 1);
assert_eq!(cfg.get_i64("b").unwrap(), 2);
}
#[test]
fn test_trailing_comments_after_braced_root_ok() {
let result = hocon::parse("{ a = 1 } // comment");
assert!(result.is_ok(), "trailing comments should be accepted");
let result2 = hocon::parse("{ a = 1 } # comment");
assert!(result2.is_ok(), "trailing # comments should be accepted");
}
#[test]
fn test_quoted_path_lookup() {
let cfg = hocon::parse(r#""a.b" = 1"#).unwrap();
assert!(cfg.has(r#""a.b""#));
assert_eq!(cfg.get_i64(r#""a.b""#).unwrap(), 1);
}
#[test]
fn test_nested_quoted_path_lookup() {
let cfg = hocon::parse(r#"server { "web.api" { port = 8080 } }"#).unwrap();
assert_eq!(cfg.get_i64(r#"server."web.api".port"#).unwrap(), 8080);
}
#[test]
fn test_parse_bytes_fractional() {
let cfg = hocon::parse("size = 0.5M").unwrap();
let bytes = cfg.get_bytes("size").unwrap();
assert_eq!(bytes, 524_288); }
#[test]
fn test_parse_bytes_fractional_binary() {
let cfg = hocon::parse("size = 1.5MiB").unwrap();
let bytes = cfg.get_bytes("size").unwrap();
assert_eq!(bytes, 1_572_864);
}
#[test]
fn test_duration_missing_units() {
let tests = vec![
("dur = 1 milli", "dur", 1_000_000u128),
("dur = 2000 micros", "dur", 2_000_000u128),
("dur = 500 nano", "dur", 500u128),
("dur = 500 nanos", "dur", 500u128),
("dur = 1 nanosecond", "dur", 1u128),
("dur = 1 microsecond", "dur", 1_000u128),
("dur = 1 millis", "dur", 1_000_000u128),
("dur = 1 millisecond", "dur", 1_000_000u128),
("dur = 1w", "dur", 604_800_000_000_000u128),
];
for (input, path, expected_nanos) in tests {
let cfg = hocon::parse(input).unwrap();
let dur = cfg.get_duration(path).unwrap();
assert_eq!(
dur.as_nanos(),
expected_nanos,
"failed for input: {}",
input
);
}
}
#[test]
fn test_get_string_coerces_int() {
let cfg = hocon::parse("port = 8080").unwrap();
assert_eq!(cfg.get_string("port").unwrap(), "8080");
}
#[test]
fn test_get_string_coerces_float() {
let cfg = hocon::parse("ratio = 3.14").unwrap();
assert_eq!(cfg.get_string("ratio").unwrap(), "3.14");
}
#[test]
fn test_get_string_coerces_bool() {
let cfg = hocon::parse("enabled = true").unwrap();
assert_eq!(cfg.get_string("enabled").unwrap(), "true");
}
#[test]
fn test_get_string_on_null_errors() {
let cfg = hocon::parse("val = null").unwrap();
assert!(cfg.get_string("val").is_err());
}
#[test]
fn test_object_concat_deep_merge() {
let cfg = hocon::parse(r#"a = {x: {y: 1}} {x: {z: 2}}"#).unwrap();
assert_eq!(cfg.get_i64("a.x.y").unwrap(), 1);
assert_eq!(cfg.get_i64("a.x.z").unwrap(), 2);
}
#[test]
fn test_object_concat_deep_merge_multiple() {
let cfg = hocon::parse(r#"a = {nested: {a: 1}} {nested: {b: 2}} {nested: {c: 3}}"#).unwrap();
assert_eq!(cfg.get_i64("a.nested.a").unwrap(), 1);
assert_eq!(cfg.get_i64("a.nested.b").unwrap(), 2);
assert_eq!(cfg.get_i64("a.nested.c").unwrap(), 3);
}
#[test]
fn test_stray_brace_after_root() {
assert!(hocon::parse("{ a = 1 } }").is_err());
}
#[test]
fn test_parse_bytes_overflow_returns_none() {
let cfg = hocon::parse("size = 99999999999999999.0TiB").unwrap();
assert!(cfg.get_bytes("size").is_err());
}
#[test]
fn test_unterminated_quoted_path_fallback() {
let cfg = hocon::parse("a = 1").unwrap();
assert!(cfg.get_i64(r#""unterminated"#).is_err());
}
#[test]
fn test_include_required_file_form() {
let dir = test_tmp_dir("required_file_form");
let conf = dir.join("base.conf");
std::fs::write(&conf, "x = 1").unwrap();
let path_str = conf.display().to_string().replace('\\', "\\\\");
let input = format!(r#"include required(file("{}"))"#, path_str);
let cfg = hocon::parse(&input).unwrap();
assert_eq!(cfg.get_i64("x").unwrap(), 1);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn test_include_required_space_file_form() {
let dir = test_tmp_dir("required_space_file_form");
let conf = dir.join("spaced.conf");
std::fs::write(&conf, "y = 42").unwrap();
let path_str = conf.display().to_string().replace('\\', "\\\\");
let input = format!(r#"include required (file("{}"))"#, path_str);
let cfg = hocon::parse(&input).unwrap();
assert_eq!(cfg.get_i64("y").unwrap(), 42);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn test_include_required_missing_file_errors() {
let result = hocon::parse(r#"include required("nonexistent.conf")"#);
assert!(
result.is_err(),
"required include of missing file should error"
);
}
#[test]
fn test_include_required_file_form_missing_errors() {
let result = hocon::parse(r#"include required(file("nonexistent.conf"))"#);
assert!(
result.is_err(),
"required include with file() form of missing file should error"
);
}
#[test]
fn test_include_required_existing_file_ok() {
let dir = test_tmp_dir("required_existing");
let conf = dir.join("required_base.conf");
std::fs::write(&conf, "req_key = 42\n").unwrap();
let path_str = conf.display().to_string().replace('\\', "/");
let content = format!("include required(\"{}\")\nextra = 1", path_str);
let result = hocon::parse(&content);
assert!(
result.is_ok(),
"required include of existing file should succeed: {:?}",
result.err()
);
let cfg = result.unwrap();
assert_eq!(cfg.get_i64("req_key").unwrap(), 42);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn test_include_optional_missing_file_ok() {
let result = hocon::parse("include \"nonexistent.conf\"\na = 1");
assert!(
result.is_ok(),
"optional include of missing file should succeed"
);
let cfg = result.unwrap();
assert_eq!(cfg.get_i64("a").unwrap(), 1);
}
#[test]
fn test_include_probing_propagates_parse_error() {
let dir = test_tmp_dir("probing_parse_error");
let broken_path = dir.join("broken.conf");
std::fs::write(&broken_path, "{ invalid = }").unwrap();
let stem = dir.join("broken");
let path_str = stem.display().to_string().replace('\\', "/");
let input = format!(r#"include "{}""#, path_str);
let result = hocon::parse(&input);
assert!(
result.is_err(),
"parse error in included file should propagate"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn test_include_url_not_supported() {
let result = hocon::parse(r#"include url("http://example.com/config")"#);
assert!(result.is_err(), "include url(...) should return an error");
}
#[test]
fn test_include_classpath_not_supported() {
let result = hocon::parse(r#"include classpath("reference.conf")"#);
assert!(
result.is_err(),
"include classpath(...) should return an error"
);
}
#[test]
fn test_unknown_escape_sequence_error() {
let result = hocon::parse(r#"key = "hello\qworld""#);
assert!(result.is_err(), "unknown escape \\q should error");
}
#[test]
fn test_unknown_escape_a_error() {
let result = hocon::parse(r#"key = "\a""#);
assert!(result.is_err(), "unknown escape \\a should error");
}
#[test]
fn test_config_debug() {
let cfg = hocon::parse("a = 1").unwrap();
let debug_str = format!("{:?}", cfg);
assert!(!debug_str.is_empty(), "Debug output should not be empty");
}
#[test]
fn test_config_clone() {
let cfg = hocon::parse("a = 1").unwrap();
let cloned = cfg.clone();
assert_eq!(cloned.get_i64("a").unwrap(), 1);
}
#[test]
fn test_config_partial_eq() {
let cfg1 = hocon::parse("a = 1").unwrap();
let cfg2 = hocon::parse("a = 1").unwrap();
assert_eq!(cfg1, cfg2);
}
#[test]
fn unquoted_forbids_spec_special_chars() {
let specials = ['?', '!', '@', '*', '&', '^', '\\'];
for ch in &specials {
let input = format!("key = foo{}bar", ch);
assert!(
hocon::parse(&input).is_err(),
"char '{}' should be rejected in unquoted strings, but parsed successfully",
ch,
);
}
}
#[test]
fn parse_error_is_hocon_error_parse_variant() {
let result = hocon::parse("{ unterminated");
assert!(result.is_err());
let err = result.unwrap_err();
match err {
hocon::HoconError::Parse(pe) => {
assert!(
pe.line > 0 && pe.col > 0,
"should have position info (line and col)"
);
}
other => panic!("expected HoconError::Parse, got {:?}", other),
}
}
#[test]
fn resolve_error_is_hocon_error_resolve_variant() {
let result = hocon::parse("a = ${missing.required.key}");
assert!(result.is_err());
let err = result.unwrap_err();
match err {
hocon::HoconError::Resolve(re) => {
assert!(!re.path.is_empty(), "should have substitution path");
}
other => panic!("expected HoconError::Resolve, got {:?}", other),
}
}
#[test]
fn io_error_is_hocon_error_io_variant() {
let mut path = std::env::temp_dir();
path.push(format!(
"hocon_test_nonexistent_{}.conf",
std::process::id()
));
let result = hocon::parse_file(&path);
assert!(result.is_err());
let err = result.unwrap_err();
match err {
hocon::HoconError::Io(io_err) => {
assert_eq!(io_err.kind(), std::io::ErrorKind::NotFound);
}
other => panic!("expected HoconError::Io, got {:?}", other),
}
}
#[test]
fn test_unterminated_triple_quoted_string_errors() {
let result = hocon::parse(r#"a = """unterminated"#);
assert!(
result.is_err(),
"expected error for unterminated triple-quoted string"
);
}
#[test]
fn s2_3_comment_markers_in_quoted_values_are_literal() {
let cfg = parse(r#"url = "http://example.com""#).unwrap();
assert_eq!(cfg.get_string("url").unwrap(), "http://example.com");
let cfg = parse("note = \"# not a comment\"").unwrap();
assert_eq!(cfg.get_string("note").unwrap(), "# not a comment");
}
#[test]
fn s5_2_single_trailing_comma_in_array_allowed() {
let cfg = parse("list = [1, 2, 3,]").unwrap();
let items = cfg.get_list("list").unwrap();
assert_eq!(
items.len(),
3,
"trailing comma must not produce an extra element"
);
}
#[test]
fn s5_2_single_trailing_comma_in_object_allowed() {
let cfg = parse("{ a = 1, b = 2, }").unwrap();
assert_eq!(cfg.get_i64("a").unwrap(), 1);
assert_eq!(cfg.get_i64("b").unwrap(), 2);
}
#[test]
fn s5_3_two_trailing_commas_in_array_rejected() {
assert!(
parse("list = [1, 2, 3,,]").is_err(),
"two trailing commas in array must be a parse error per HOCON L160"
);
}
#[test]
fn s5_3_two_trailing_commas_in_object_rejected() {
assert!(
parse("{ a = 1, b = 2,, }").is_err(),
"two trailing commas in object must be a parse error per HOCON L160"
);
}
#[test]
fn s5_4_leading_comma_in_array_rejected() {
assert!(
parse("list = [,1, 2, 3]").is_err(),
"leading comma in array must be a parse error per HOCON L161"
);
}
#[test]
fn s5_4_leading_comma_in_object_rejected() {
assert!(
parse("{ , a = 1 }").is_err(),
"leading comma in object must be rejected per HOCON L161"
);
}
#[test]
fn s5_5_two_consecutive_commas_in_array_rejected() {
assert!(
parse("list = [1,, 2, 3]").is_err(),
"two consecutive commas in array must be a parse error per HOCON L162"
);
}
#[test]
fn s5_6_two_consecutive_commas_between_object_fields_rejected() {
assert!(
parse("{ a = 1,, b = 2 }").is_err(),
"consecutive commas between object fields must be rejected per HOCON L163"
);
}
#[test]
fn nested_include_resolves_substitutions_in_scope() {
let path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("tests/testdata/hocon/test10.conf");
let config = hocon::parse_file(&path).unwrap_or_else(|e| panic!("parse_file failed: {}", e));
assert_eq!(config.get_i64("bar.nested.y").unwrap(), 5);
assert_eq!(config.get_i64("bar.nested.b").unwrap(), 5);
assert_eq!(config.get_i64("bar.nested.a.c").unwrap(), 3);
assert_eq!(config.get_i64("bar.nested.a.q").unwrap(), 10);
}
#[test]
fn s3_2_root_bare_string_rejected() {
assert!(
parse("\"just a string\"").is_err(),
"bare string at root (no enclosing object or array) must be a parse error per HOCON L131"
);
assert!(
parse("42").is_err(),
"bare number at root must be a parse error per HOCON L131"
);
}
#[test]
fn s10_4_array_object_concat_is_error() {
assert!(
matches!(parse("a = [1,2] {b:3}"), Err(hocon::HoconError::Resolve(_))),
"array+object concat must raise ResolveError per HOCON L385"
);
assert!(
matches!(parse("a = {b:3} [1,2]"), Err(hocon::HoconError::Resolve(_))),
"object+array concat must raise ResolveError per HOCON L385"
);
}
#[test]
fn s10_4_subst_obj_plus_array_is_error() {
assert!(
matches!(
parse("obj = { b: 2 }\na = [1] ${obj}"),
Err(hocon::HoconError::Resolve(_))
),
"array + subst-resolved-object must raise ResolveError per HOCON L385/L387"
);
assert!(
matches!(
parse("arr = [1]\na = ${arr} { b: 2 }"),
Err(hocon::HoconError::Resolve(_))
),
"subst-resolved-array + object must raise ResolveError per HOCON L385/L387"
);
}
#[test]
fn s10_4_numeric_obj_concat_still_works() {
let cfg = parse("obj = {\"0\":\"x\",\"1\":\"y\"}\na = [1] ${obj}")
.expect("numeric-keyed object concat must still succeed (S15)");
let items = cfg.get_list("a").expect("a must be a list");
assert_eq!(items.len(), 3, "a must have 3 elements: [1, x, y]");
}
#[test]
fn s10_4_empty_edge_array_plus_empty_object_is_error() {
assert!(
matches!(parse("a = [1] {}"), Err(hocon::HoconError::Resolve(_))),
"array+empty-object must raise ResolveError (S15.4: empty object not converted)"
);
assert!(
matches!(parse("a = [] {b:1}"), Err(hocon::HoconError::Resolve(_))),
"empty-array+object must raise ResolveError per HOCON L385"
);
}
#[test]
fn s10_7_concat_does_not_span_newline() {
let cfg = parse("a = foo bar").unwrap();
assert_eq!(
cfg.get_string("a").unwrap(),
"foo bar",
"same-line unquoted string concat must produce 'foo bar'"
);
let cfg2 = parse("a = foo\nb = 1").unwrap();
assert_eq!(
cfg2.get_string("a").unwrap(),
"foo",
"value concat must not span a newline per HOCON L335"
);
}
#[test]
fn s10_8_quoted_key_with_space_allowed() {
let cfg = parse("\"foo bar\" = 42").unwrap();
assert_eq!(
cfg.get_i64("foo bar").unwrap(),
42,
"quoted key containing a space must be accepted per HOCON L317"
);
}
#[test]
fn s10_8_unquoted_space_key_basic() {
let cfg = parse("foo bar = 1").expect("parse must succeed per HOCON L317/L556");
assert_eq!(
cfg.get_i64("foo bar")
.expect("key 'foo bar' must exist per HOCON L317/L556"),
1,
"unquoted space-concat key must produce key 'foo bar' per HOCON L556"
);
}
#[test]
fn s10_8_unquoted_space_key_spec_three_token() {
let cfg = parse("a b c : 42").expect("parse must succeed per HOCON L317/L556");
assert_eq!(
cfg.get_i64("a b c")
.expect("key 'a b c' must exist per HOCON L556"),
42,
"three-token space-concat key must produce key 'a b c' per HOCON L556"
);
}
#[test]
fn s10_8_space_concat_after_dotted_path() {
let cfg = parse("a.b c = 1").unwrap();
assert_eq!(cfg.get_i64("a.\"b c\"").unwrap(), 1);
}
#[test]
fn s10_8_space_concat_dotted_tail() {
let cfg = parse("a b.c = 1").unwrap();
assert_eq!(cfg.get_i64("\"a b\".c").unwrap(), 1);
}
#[test]
fn s10_8_quoted_then_unquoted_space_concat() {
let cfg = parse("\"foo bar\" baz = 1").unwrap();
assert_eq!(cfg.get_i64("foo bar baz").unwrap(), 1);
}
#[test]
fn s10_8_inline_object_shorthand() {
let cfg = parse("a b { x = 1 }").unwrap();
assert_eq!(cfg.get_i64("\"a b\".x").unwrap(), 1);
}
#[test]
fn s10_8_leading_dot_after_whitespace_stays_separator() {
let cfg = parse("a .b = 1").unwrap();
assert_eq!(cfg.get_i64("\"a \".b").unwrap(), 1);
}
#[test]
fn s10_8_leading_dot_after_whitespace_multi_segment_tail() {
let cfg = parse("a .b.c = 1").unwrap();
assert_eq!(cfg.get_i64("\"a \".b.c").unwrap(), 1);
}
#[test]
fn s10_8_leading_dot_after_quoted_then_whitespace() {
let cfg = parse("\"a\" .b = 1").unwrap();
assert_eq!(cfg.get_i64("\"a \".b").unwrap(), 1);
}
#[test]
fn s10_8_dotted_path_then_whitespace_then_dot() {
let cfg = parse("a.b .c = 1").unwrap();
assert_eq!(cfg.get_i64("a.\"b \".c").unwrap(), 1);
}
#[test]
fn s10_8_quoted_dotted_path_then_whitespace_then_dot() {
let cfg = parse("\"a.b\" .c = 1").unwrap();
assert_eq!(cfg.get_i64("\"a.b \".c").unwrap(), 1);
}
#[test]
fn s10_8_tab_between_key_tokens_preserved_verbatim() {
let cfg = parse("a\tb = 1").unwrap();
assert_eq!(cfg.get_i64("\"a\tb\"").unwrap(), 1);
}
#[test]
fn e13_dot_ws_dot_makes_ws_its_own_segment() {
let cfg = parse("a. .b = 1").unwrap();
assert_eq!(cfg.get_i64("a.\" \".b").unwrap(), 1);
}
#[test]
fn e13_trailing_dot_after_quoted_segment_rejects() {
assert!(
matches!(parse("\"a\". = 1"), Err(hocon::HoconError::Parse(_))),
r#""a". = 1 must reject with ParseError per E13 trailing-dot guard"#
);
assert!(
matches!(parse("\"a\".\"b\". = 1"), Err(hocon::HoconError::Parse(_))),
r#""a"."b". = 1 must reject with ParseError per E13 trailing-dot guard"#
);
assert!(
matches!(parse("a.\"b\". = 1"), Err(hocon::HoconError::Parse(_))),
r#"a."b". = 1 must reject with ParseError per E13 trailing-dot guard"#
);
}
#[test]
fn e13_trailing_dot_error_position_points_at_dot() {
use hocon::HoconError;
let unquoted_err = match parse("foo. = 1") {
Err(HoconError::Parse(e)) => e,
other => panic!("expected ParseError, got {:?}", other),
};
assert_eq!(unquoted_err.line, 1, "unquoted trailing-dot: line");
assert_eq!(
unquoted_err.col, 4,
"unquoted trailing-dot: col (dot at col 4 of `foo.`)"
);
let standalone_err = match parse("\"a\". = 1") {
Err(HoconError::Parse(e)) => e,
other => panic!("expected ParseError, got {:?}", other),
};
assert_eq!(standalone_err.line, 1, "standalone-dot: line");
assert_eq!(
standalone_err.col, 4,
"standalone-dot: col (dot at col 4 after `\"a\"`)"
);
let multiline_err = match parse("a = 1\nbar. = 2") {
Err(HoconError::Parse(e)) => e,
other => panic!("expected ParseError, got {:?}", other),
};
assert_eq!(multiline_err.line, 2, "multi-line trailing-dot: line");
assert_eq!(
multiline_err.col, 4,
"multi-line trailing-dot: col (dot at col 4 of `bar.`)"
);
}
#[test]
fn s10_13_scalar_object_concat_is_error() {
assert!(
matches!(parse("a = hello {b:1}"), Err(hocon::HoconError::Resolve(_))),
"scalar+object in string concat must raise ResolveError per HOCON L373"
);
assert!(
matches!(parse("a = {b:1} hello"), Err(hocon::HoconError::Resolve(_))),
"object+scalar in string concat must raise ResolveError per HOCON L373"
);
}
#[test]
fn s10_13_scalar_array_concat_is_error() {
assert!(
matches!(parse("a = hello [1,2]"), Err(hocon::HoconError::Resolve(_))),
"scalar+array in string concat must raise ResolveError per HOCON L373"
);
assert!(
matches!(parse("a = [1,2] hello"), Err(hocon::HoconError::Resolve(_))),
"array+scalar in string concat must raise ResolveError per HOCON L373"
);
}
#[test]
fn s10_13_subst_resolved_array_plus_scalar_is_error() {
assert!(
matches!(
parse("arr = [1]\na = x ${arr}"),
Err(hocon::HoconError::Resolve(_))
),
"scalar + subst-resolved-array must raise ResolveError per HOCON L373/L387"
);
}
#[test]
fn s10_13_subst_resolved_object_plus_scalar_is_error() {
assert!(
matches!(
parse("obj = { b: 1 }\na = x ${obj}"),
Err(hocon::HoconError::Resolve(_))
),
"scalar + subst-resolved-object must raise ResolveError per HOCON L373/L387"
);
}
#[test]
fn s10_4_optional_missing_mid_concat_is_error() {
assert!(
matches!(
parse("a = [1] ${?missing} { b: 2 }"),
Err(hocon::HoconError::Resolve(_))
),
"optional-omission must not shield type-mismatch between neighbours (S10.4)"
);
}
#[test]
fn s10_4_optional_missing_only_piece_ok() {
let cfg = parse("a = [1] ${?missing}")
.expect("optional omission of trailing piece must leave a=[1] with no error");
let items = cfg.get_list("a").expect("a must be a list");
assert_eq!(items.len(), 1, "a must have 1 element");
}
#[test]
fn s10_14_whitespace_around_obj_subst_ignored() {
let cfg = parse("b = {x:1}\na = ${b} ").unwrap();
assert_eq!(
cfg.get_i64("a.x").unwrap(),
1,
"whitespace around obj substitution must be ignored per HOCON L440"
);
}
#[test]
fn s10_14_whitespace_around_arr_subst_ignored() {
let cfg = parse("b = [1,2,3]\na = ${b} ").unwrap();
assert_eq!(
cfg.get_list("a").unwrap().len(),
3,
"whitespace around array substitution must be ignored per HOCON L440"
);
}
#[test]
fn s10_19_subst_obj_concat_literal_array_is_error() {
assert!(
matches!(
parse("b = {x:1}\na = ${b} [1,2]"),
Err(hocon::HoconError::Resolve(_))
),
"subst-resolved object + literal array must raise ResolveError per HOCON L385-389"
);
}
#[test]
fn s10_19_subst_arr_concat_literal_obj_is_error() {
assert!(
matches!(
parse("b = [1,2]\na = ${b} {x:1}"),
Err(hocon::HoconError::Resolve(_))
),
"subst-resolved array + literal object must raise ResolveError per HOCON L385-389"
);
}
#[test]
fn s11_4_numeric_dot_unquoted_path() {
let cfg = parse("10.0foo = 42").unwrap();
assert_eq!(
cfg.keys(),
vec!["10"],
"top-level key must be \"10\" (not flat \"10.0foo\") per HOCON L496"
);
assert_eq!(
cfg.get_i64("10.0foo").unwrap(),
42,
"value must be reachable via the nested path 10.0foo"
);
}
#[test]
fn s11_5_unquoted_dot_numeric_path() {
let cfg = parse("foo10.0 = 42").unwrap();
assert_eq!(
cfg.keys(),
vec!["foo10"],
"top-level key must be \"foo10\" (not flat \"foo10.0\") per HOCON L498"
);
assert_eq!(
cfg.get_i64("foo10.0").unwrap(),
42,
"value must be reachable via the nested path foo10.0"
);
}
#[test]
fn s11_8_path_expression_stringifies_boolean() {
let cfg = parse("true = 42").unwrap();
assert_eq!(
cfg.get_i64("true").unwrap(),
42,
"boolean `true` used as a path key must be stringified to \"true\" per HOCON L504"
);
}
#[test]
fn s11_8_path_expression_stringifies_number() {
let cfg = parse("3 = 42").unwrap();
assert_eq!(
cfg.get_i64("3").unwrap(),
42,
"number `3` used as a path key must be stringified to \"3\" per HOCON L504"
);
}
#[test]
fn s11_9_subst_in_key_rejected() {
assert!(
parse("${a} = 42").is_err(),
"substitution at start of key path must be rejected per HOCON L479"
);
assert!(
parse("a.${b} = 42").is_err(),
"substitution inside dotted key path must be rejected per HOCON L479"
);
}
#[test]
fn s12_5_include_as_key_spec() {
for input in &[
"include.foo = 42",
"include : 1",
"include += [1]",
"include { x = 1 }",
] {
assert!(
parse(input).is_err(),
"unquoted `include` at start of a key path must be a parse error per HOCON L570 (input: {})",
input
);
}
assert!(
parse(r#""include" = 1"#).is_ok(),
"quoted include must succeed"
);
assert!(
parse("foo.include = 1").is_ok(),
"non-initial include must succeed"
);
}
#[test]
fn s13b_2_plus_eq_on_non_array_errors() {
assert!(
parse("a = 42\na += 1").is_err(),
"+= on numeric prior value must be a resolve-time error per HOCON L732"
);
assert!(
parse("a = \"str\"\na += 1").is_err(),
"+= on string prior value must be a resolve-time error per HOCON L732"
);
}
#[test]
fn s13b_2_plus_eq_on_object_errors() {
assert!(
parse("a = { x = 1 }\na += 1").is_err(),
"+= on object prior value must be a resolve-time error per HOCON L732"
);
}
#[test]
fn s13b_2_plus_eq_under_allow_unresolved_defers_on_unresolved_prior() {
use hocon::{parse_string_with_options, ParseOptions, ResolveOptions};
let cfg = parse_string_with_options(
"x = ${missing}\nx += 1",
ParseOptions::defaults().with_resolve_substitutions(false),
)
.expect("parse should succeed without resolution");
let resolved = cfg
.resolve(
ResolveOptions::defaults()
.with_allow_unresolved(true)
.with_use_system_environment(false),
)
.expect("allow_unresolved must defer, not error");
assert!(
!resolved.is_resolved(),
"result must remain unresolved (x's prior was unresolved)"
);
assert!(
resolved.get_string("x").is_err(),
"getter on unresolved path must error (NotResolved)"
);
}
#[test]
fn s13_3_space_before_question_differs_from_optional() {
let optional = hocon::parse_with_env("x = ${?foo}", &std::collections::HashMap::new())
.expect("${?foo} should parse");
assert!(
optional.get("x").is_none(),
"optional substitution with undefined var must drop the field"
);
let spaced = hocon::parse_with_env(r#"x = ${ ?foo}"#, &std::collections::HashMap::new());
assert!(
spaced.is_err(),
"space-before-? form must not silently act as optional substitution; expected parse or resolve error"
);
let mut env = std::collections::HashMap::new();
env.insert("foo".to_string(), "x".to_string());
let spaced_defined = hocon::parse_with_env(r#"x = ${ ?foo}"#, &env);
assert!(
spaced_defined.is_err(),
"space-before-? form must still error when the path is defined; got Ok value"
);
}
#[test]
fn s13_5_no_subst_in_quoted_string() {
let cfg = parse(r#"x = "${foo}""#).expect("parse failed");
assert_eq!(
cfg.get_string("x").unwrap(),
"${foo}",
"substitution syntax inside a quoted string must be treated as literal text"
);
}
#[test]
fn s13_9_null_blocks_env_var_lookup_pin() {
let mut env = std::collections::HashMap::new();
env.insert("HOME".to_string(), "/x/y".to_string());
let cfg = hocon::parse_with_env("HOME = null\nresult = ${?HOME}", &env)
.expect("parse should succeed");
let v = cfg.get("result").expect("[pin] result must be present");
match v {
hocon::HoconValue::Scalar(s) => assert_eq!(
s.value_type,
hocon::ScalarType::Null,
"[pin] result must be the explicit null scalar — env value must not leak"
),
other => panic!("[pin] result must be a null scalar, got {:?}", other),
}
}
#[test]
#[ignore = "spec violation: null in config must block env fallback per HOCON L618, see #74"]
fn s13_9_null_blocks_env_var_lookup_spec() {
let mut env = std::collections::HashMap::new();
env.insert("HOME".to_string(), "/x/y".to_string());
let cfg = hocon::parse_with_env("HOME = null\nresult = ${?HOME}", &env)
.expect("parse should succeed");
assert!(
cfg.get("result").is_none(),
"null in config must block env var fallback; result must be absent per HOCON L618"
);
}
#[test]
fn s13_13_optional_undefined_in_string_concat_is_empty() {
let cfg = hocon::parse_with_env(
r#"x = "pre"${?missing}"post""#,
&std::collections::HashMap::new(),
)
.expect("parse failed");
assert_eq!(
cfg.get_string("x").unwrap(),
"prepost",
"optional undefined substitution in string concat must contribute empty string"
);
}
#[test]
fn s13_14_optional_undefined_in_array_concat_spec() {
let cfg = hocon::parse_with_env("x = [1] ${?missing} [2]", &std::collections::HashMap::new())
.expect("parse failed");
let items = cfg.get_list("x").unwrap();
assert_eq!(
items.len(),
2,
"array concat must collapse to exactly two elements"
);
let val = |v: &hocon::HoconValue| match v {
hocon::HoconValue::Scalar(s) => Some((s.raw.clone(), s.value_type)),
_ => None,
};
assert_eq!(
val(&items[0]),
Some(("1".to_string(), hocon::ScalarType::Number)),
"items[0] must be numeric 1"
);
assert_eq!(
val(&items[1]),
Some(("2".to_string(), hocon::ScalarType::Number)),
"items[1] must be numeric 2"
);
}
#[test]
fn s13_14_optional_undefined_in_object_concat() {
let cfg = hocon::parse_with_env(
"x = {a:1} ${?missing} {b:2}",
&std::collections::HashMap::new(),
)
.expect("parse failed");
let sub = cfg.get_config("x").expect("x must be an object");
assert_eq!(sub.get_i64("a").unwrap(), 1);
assert_eq!(sub.get_i64("b").unwrap(), 2);
}
#[test]
fn s13_16_substitution_in_key_is_rejected() {
assert!(
parse("${foo} = 1").is_err(),
"substitution in key position must be a parse error per HOCON L644"
);
}
#[test]
fn s13a_13_optional_self_ref_concat_with_no_prior_spec() {
let cfg = hocon::parse_with_env("a = ${?a}foo", &std::collections::HashMap::new())
.expect("parse failed");
assert_eq!(
cfg.get_string("a").unwrap(),
"foo",
"with no prior value, the self-referencing optional subst is undefined; result must be \"foo\""
);
}
#[test]
fn s14a_6_include_in_dotted_key_is_literal() {
let cfg = parse("x.include = 1").expect("parse failed");
assert_eq!(
cfg.get_i64("x.include").unwrap(),
1,
"unquoted `include` that is not at the start of a key must be treated as literal"
);
}
#[test]
fn s14a_8_no_concatenation_on_include_arg() {
assert!(
parse(r#"include "a.conf" "b.conf""#).is_err(),
"multiple strings after `include` must be a parse error per HOCON L957"
);
}
#[test]
fn s14a_9_no_substitution_in_include_arg() {
assert!(
parse("include ${path}").is_err(),
"substitution as include argument must be a parse error per HOCON L959"
);
}
#[test]
fn s14b_1_array_root_include_is_error() {
let dir = test_tmp_dir("s14b1_array_root");
let arr_file = dir.join("arr.conf");
std::fs::write(&arr_file, "[1, 2, 3]").unwrap();
let path_str = arr_file.display().to_string().replace('\\', "/");
let input = format!(r#"include "{}""#, path_str);
let result = hocon::parse(&input);
assert!(
result.is_err(),
"including a file whose root is an array must produce an error per HOCON L993"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn s15_1_num_indexed_obj_to_array_spec() {
let cfg = hocon::parse_with_env(r#"v = {"0":"a","1":"b"}"#, &HashMap::new()).unwrap();
let items = cfg
.get_list("v")
.expect("numeric-keyed object must be convertible to array per HOCON L1191");
assert_eq!(items.len(), 2, "converted array must have 2 elements");
match &items[0] {
hocon::HoconValue::Scalar(sv) => assert_eq!(sv.raw, "a", "first element must be \"a\""),
other => panic!("expected Scalar, got {:?}", other),
}
match &items[1] {
hocon::HoconValue::Scalar(sv) => assert_eq!(sv.raw, "b", "second element must be \"b\""),
other => panic!("expected Scalar, got {:?}", other),
}
}
#[test]
fn s15_2_conversion_is_lazy_spec() {
let cfg = hocon::parse_with_env(r#"v = {"0":"a","1":"b"}"#, &HashMap::new()).unwrap();
assert!(
cfg.get_config("v").is_ok(),
"get_config must still succeed before conversion is triggered"
);
assert!(
cfg.get_list("v").is_ok(),
"get_list must trigger lazy conversion of numeric-keyed object to array"
);
}
#[test]
fn s15_3_conversion_in_concatenation_spec() {
let cfg = hocon::parse_with_env(
r#"obj = {"0":"x","1":"y"}
arr = [a] ${obj}"#,
&HashMap::new(),
)
.unwrap();
let items = cfg.get_list("arr").expect("concat produces an array");
assert_eq!(
items.len(),
3,
"expected ['a','x','y'] after conversion, got {:?}",
items
);
let raws: Vec<&str> = items
.iter()
.map(|v| match v {
hocon::HoconValue::Scalar(s) => s.raw.as_str(),
_ => panic!("expected scalar after conversion, got {:?}", v),
})
.collect();
assert_eq!(raws, vec!["a", "x", "y"]);
}
#[test]
fn s15_4_empty_object_not_converted() {
let cfg = hocon::parse_with_env(r#"v = {}"#, &HashMap::new()).unwrap();
assert!(
cfg.get_list("v").is_err(),
"empty object must not be converted to array per HOCON L1212"
);
}
#[test]
fn s15_5_non_integer_keys_ignored_spec() {
let cfg = hocon::parse_with_env(r#"v = {"0":"a","foo":"b","1":"c"}"#, &HashMap::new()).unwrap();
let items = cfg
.get_list("v")
.expect("mixed-key object must convert, ignoring non-integer keys per HOCON L1214");
assert_eq!(
items.len(),
2,
"only integer-keyed entries remain: [\"a\",\"c\"]"
);
}
#[test]
fn s15_6_missing_indices_compacted_spec() {
let cfg = hocon::parse_with_env(r#"v = {"0":"a","2":"c"}"#, &HashMap::new()).unwrap();
let items = cfg
.get_list("v")
.expect("sparse numeric-keyed object must convert to compacted array per HOCON L1216");
assert_eq!(
items.len(),
2,
"gaps eliminated: keys 0+2 → array of 2 elements"
);
}
#[test]
fn s15_7_sorted_by_key_value_spec() {
let cfg = hocon::parse_with_env(r#"v = {"2":"c","0":"a"}"#, &HashMap::new()).unwrap();
let items = cfg.get_list("v").expect(
"out-of-order numeric-keyed object must convert sorted by integer key per HOCON L1216",
);
assert_eq!(items.len(), 2, "must produce 2-element array");
match &items[0] {
hocon::HoconValue::Scalar(sv) => {
assert_eq!(sv.raw, "a", "first element must be key-0's value")
}
other => panic!("expected Scalar, got {:?}", other),
}
match &items[1] {
hocon::HoconValue::Scalar(sv) => {
assert_eq!(sv.raw, "c", "second element must be key-2's value")
}
other => panic!("expected Scalar, got {:?}", other),
}
}
#[test]
fn s17_5_null_string_stored_as_string_not_null() {
let cfg = hocon::parse_with_env(r#"v = "null""#, &HashMap::new()).unwrap();
match cfg.get("v") {
Some(hocon::HoconValue::Scalar(sv)) => {
assert_eq!(sv.raw, "null");
assert_eq!(
sv.value_type,
hocon::ScalarType::String,
"quoted \"null\" must be stored as String scalar, not Null"
);
}
other => panic!("expected Scalar, got {:?}", other),
}
}
#[test]
fn s17_6_null_to_numeric_and_bool_errors() {
let cfg = hocon::parse_with_env(r#"v = null"#, &HashMap::new()).unwrap();
assert!(
cfg.get_i64("v").is_err(),
"null → i64 must error per HOCON L1252"
);
assert!(
cfg.get_bool("v").is_err(),
"null → bool must error per HOCON L1252"
);
}
#[test]
fn s17_6_null_to_string_errors() {
let cfg = hocon::parse_with_env(r#"v = null"#, &HashMap::new()).unwrap();
assert!(
cfg.get_string("v").is_err(),
"null → string must return an error per HOCON L1252"
);
}
#[test]
fn s17_8_array_to_other_type_errors() {
let cfg = hocon::parse_with_env(r#"v = [1,2,3]"#, &HashMap::new()).unwrap();
assert!(
cfg.get_string("v").is_err(),
"array → string must error per HOCON L1255"
);
assert!(
cfg.get_i64("v").is_err(),
"array → i64 must error per HOCON L1255"
);
assert!(
cfg.get_bool("v").is_err(),
"array → bool must error per HOCON L1255"
);
assert!(
cfg.get_list("v").is_ok(),
"get_list on an array must succeed"
);
}
#[test]
fn s13a_13_external_reference_to_self_ref_field() {
let cfg = hocon::parse_with_env("a = ${?a}foo\nb = ${a}", &std::collections::HashMap::new())
.expect("parse failed");
assert_eq!(cfg.get_string("a").unwrap(), "foo");
assert_eq!(cfg.get_string("b").unwrap(), "foo");
}
#[test]
fn dotted_quoted_key_no_cache_collision_with_nested_path() {
let input = r#"a { b = "nested" }
"a.b" = "literal"
c = ${a.b}
"#;
let cfg =
hocon::parse_with_env(input, &std::collections::HashMap::new()).expect("parse failed");
assert_eq!(
cfg.get_string("a.b").unwrap(),
"nested",
"two-segment path a.b must resolve to \"nested\""
);
assert_eq!(
cfg.get_string("c").unwrap(),
"nested",
"c = ${{a.b}} must return \"nested\" (no cache collision with \"a.b\" quoted key)"
);
}
#[test]
fn should_fold_nested_gate_non_obj_overwrite() {
let input = "o.a = \"x\"\no.a = ${?o.a}bar\no = ${?o}";
let cfg =
hocon::parse_with_env(input, &std::collections::HashMap::new()).expect("parse failed");
assert_eq!(
cfg.get_string("o.a").unwrap(),
"xbar",
"o.a must be \"xbar\" (non-Obj overwrite must fold nested self-refs in prior)"
);
}