#![cfg(feature = "include")]
#![allow(missing_docs)]
#![allow(clippy::unwrap_used)]
use noyalib::include::{IncludeRequest, IncludeResolver, InputSource};
use noyalib::{from_str_with_config, ParserConfig, Result, Value};
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
fn mem_resolver(files: HashMap<&'static str, &'static str>) -> IncludeResolver {
let files: HashMap<String, String> = files
.into_iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect();
IncludeResolver::new(move |req: IncludeRequest<'_>| -> Result<InputSource> {
let (path, _frag) = noyalib::include::split_fragment(req.spec);
match files.get(path) {
Some(b) => Ok(InputSource::new(path, b.clone())),
None => Err(noyalib::Error::Custom(format!(
"test mem resolver: missing `{path}`"
))),
}
})
}
#[test]
fn basic_include_substitutes_document_root() {
let mut files = HashMap::new();
let _ = files.insert("frag.yaml", "name: alpha\nversion: 1\n");
let cfg = ParserConfig::new().include_resolver(mem_resolver(files));
let yaml = "service: !include frag.yaml\n";
let v: Value = from_str_with_config(yaml, &cfg).unwrap();
assert_eq!(v["service"]["name"].as_str(), Some("alpha"));
assert_eq!(v["service"]["version"].as_i64(), Some(1));
}
#[test]
fn nested_include_resolves_recursively() {
let mut files = HashMap::new();
let _ = files.insert("inner.yaml", "v: 99\n");
let _ = files.insert("outer.yaml", "inner: !include inner.yaml\n");
let cfg = ParserConfig::new().include_resolver(mem_resolver(files));
let yaml = "wrap: !include outer.yaml\n";
let v: Value = from_str_with_config(yaml, &cfg).unwrap();
assert_eq!(v["wrap"]["inner"]["v"].as_i64(), Some(99));
}
#[test]
fn fragment_anchor_narrows_to_named_key() {
let mut files = HashMap::new();
let _ = files.insert(
"defs.yaml",
"users:\n admin: { role: root }\n guest: { role: anon }\n",
);
let cfg = ParserConfig::new().include_resolver(mem_resolver(files));
let yaml = "u: !include defs.yaml#users\n";
let v: Value = from_str_with_config(yaml, &cfg).unwrap();
assert_eq!(v["u"]["admin"]["role"].as_str(), Some("root"));
assert_eq!(v["u"]["guest"]["role"].as_str(), Some("anon"));
}
#[test]
fn fragment_anchor_missing_key_errors_clearly() {
let mut files = HashMap::new();
let _ = files.insert("defs.yaml", "users:\n admin: 1\n");
let cfg = ParserConfig::new().include_resolver(mem_resolver(files));
let yaml = "u: !include defs.yaml#missing\n";
let res: Result<Value> = from_str_with_config(yaml, &cfg);
let err = res.unwrap_err();
assert!(err.to_string().contains("fragment"), "{err}");
assert!(err.to_string().contains("missing"), "{err}");
}
#[test]
fn cycle_detection_aborts_with_clear_error() {
let mut files = HashMap::new();
let _ = files.insert("a.yaml", "next: !include b.yaml\n");
let _ = files.insert("b.yaml", "next: !include a.yaml\n");
let cfg = ParserConfig::new().include_resolver(mem_resolver(files));
let yaml = "root: !include a.yaml\n";
let res: Result<Value> = from_str_with_config(yaml, &cfg);
let err = res.unwrap_err();
assert!(err.to_string().contains("cycle"), "{err}");
}
#[test]
fn max_include_depth_caps_recursion() {
let resolver = IncludeResolver::new(|_req: IncludeRequest<'_>| -> Result<InputSource> {
Ok(InputSource::new("infinite", "deeper: !include infinite\n"))
});
let cfg = ParserConfig::new()
.include_resolver(resolver)
.max_include_depth(5);
let yaml = "root: !include start\n";
let res: Result<Value> = from_str_with_config(yaml, &cfg);
assert!(res.is_err(), "max-depth must abort: {res:?}");
}
#[test]
fn no_resolver_set_means_no_walk() {
let cfg = ParserConfig::new();
let yaml = "left_alone: !include frag.yaml\n";
let v: Value = from_str_with_config(yaml, &cfg).unwrap();
let tag_str = v["left_alone"].as_tagged().map(|t| t.tag().as_str());
assert_eq!(tag_str, Some("!include"));
}
#[test]
fn resolver_errors_propagate() {
let resolver = IncludeResolver::new(|_req: IncludeRequest<'_>| -> Result<InputSource> {
Err(noyalib::Error::Custom("synthetic resolver failure".into()))
});
let cfg = ParserConfig::new().include_resolver(resolver);
let yaml = "v: !include anything\n";
let res: Result<Value> = from_str_with_config(yaml, &cfg);
let err = res.unwrap_err();
assert!(err.to_string().contains("synthetic"), "{err}");
}
#[test]
fn non_string_spec_errors() {
let resolver = IncludeResolver::new(|_req: IncludeRequest<'_>| -> Result<InputSource> {
Ok(InputSource::new("noop", "k: v\n"))
});
let cfg = ParserConfig::new().include_resolver(resolver);
let yaml = "bad: !include\n not: a-scalar\n";
let res: Result<Value> = from_str_with_config(yaml, &cfg);
assert!(res.is_err());
}
#[test]
fn typed_target_sees_substituted_value() {
use serde::Deserialize;
#[derive(Debug, Deserialize)]
struct Server {
host: String,
port: u16,
}
let mut files = HashMap::new();
let _ = files.insert("server.yaml", "host: db.local\nport: 5432\n");
let cfg = ParserConfig::new().include_resolver(mem_resolver(files));
let yaml = "server: !include server.yaml\n";
#[derive(Debug, Deserialize)]
struct Root {
server: Server,
}
let root: Root = from_str_with_config(yaml, &cfg).unwrap();
assert_eq!(root.server.host, "db.local");
assert_eq!(root.server.port, 5432);
}
#[cfg(feature = "include_fs")]
mod safe_file {
use super::*;
use noyalib::include::{SafeFileResolver, SymlinkPolicy};
fn temp_dir(name: &str) -> std::path::PathBuf {
let d = std::env::temp_dir().join(format!("noyalib-include-{name}"));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
d
}
#[test]
fn file_resolver_loads_basic_path() {
let dir = temp_dir("basic");
std::fs::write(dir.join("a.yaml"), "hello: world\n").unwrap();
let cfg = ParserConfig::new().include_resolver(SafeFileResolver::new(&dir).into_resolver());
let v: Value = from_str_with_config("inc: !include a.yaml\n", &cfg).unwrap();
assert_eq!(v["inc"]["hello"].as_str(), Some("world"));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn path_traversal_outside_root_errors() {
let dir = temp_dir("traversal");
std::fs::write(dir.join("ok.yaml"), "k: v\n").unwrap();
let cfg = ParserConfig::new().include_resolver(SafeFileResolver::new(&dir).into_resolver());
let res: Result<Value> = from_str_with_config("x: !include ../../etc/hosts\n", &cfg);
assert!(res.is_err(), "must reject path-traversal");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn missing_file_errors() {
let dir = temp_dir("missing");
let cfg = ParserConfig::new().include_resolver(SafeFileResolver::new(&dir).into_resolver());
let res: Result<Value> = from_str_with_config("x: !include nope.yaml\n", &cfg);
assert!(res.is_err(), "missing file must error");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn reject_symlink_policy_blocks_symlinks() {
let dir = temp_dir("symlink");
std::fs::write(dir.join("real.yaml"), "v: 1\n").unwrap();
#[cfg(unix)]
std::os::unix::fs::symlink(dir.join("real.yaml"), dir.join("link.yaml")).unwrap();
#[cfg(not(unix))]
return;
#[cfg(unix)]
{
let resolver = SafeFileResolver::new(&dir)
.symlink_policy(SymlinkPolicy::Reject)
.into_resolver();
let cfg = ParserConfig::new().include_resolver(resolver);
let res: Result<Value> = from_str_with_config("x: !include link.yaml\n", &cfg);
assert!(
res.is_err(),
"SymlinkPolicy::Reject must block symlinked includes"
);
let _ = std::fs::remove_dir_all(&dir);
}
}
#[test]
fn symlink_policy_default_is_follow_within_root() {
assert_eq!(SymlinkPolicy::default(), SymlinkPolicy::FollowWithinRoot);
}
#[test]
fn debug_impl_renders() {
let r = SafeFileResolver::new("/srv/configs");
let s = format!("{r:?}");
assert!(s.contains("SafeFileResolver"));
}
#[test]
fn split_fragment_round_trip() {
use noyalib::include::split_fragment;
assert_eq!(split_fragment("a.yaml#anchor"), ("a.yaml", Some("anchor")));
assert_eq!(split_fragment("a.yaml"), ("a.yaml", None));
assert_eq!(split_fragment(""), ("", None));
assert_eq!(split_fragment("#anchor"), ("", Some("anchor")));
}
}
#[test]
fn fragment_on_non_mapping_document_errors() {
let mut files = HashMap::new();
let _ = files.insert("scalar.yaml", "42\n");
let cfg = ParserConfig::new().include_resolver(mem_resolver(files));
let res: Result<Value> = from_str_with_config("v: !include scalar.yaml#k\n", &cfg);
let err = res.unwrap_err();
assert!(err.to_string().contains("mapping"), "{err}");
}
#[test]
fn include_inside_sequence_is_resolved() {
let mut files = HashMap::new();
let _ = files.insert("item.yaml", "name: alpha\n");
let cfg = ParserConfig::new().include_resolver(mem_resolver(files));
let yaml = "items:\n - !include item.yaml\n - !include item.yaml\n";
let v: Value = from_str_with_config(yaml, &cfg).unwrap();
let seq = v["items"].as_sequence().unwrap();
assert_eq!(seq.len(), 2);
assert_eq!(seq[0]["name"].as_str(), Some("alpha"));
}
#[test]
fn non_include_tagged_values_pass_through() {
let resolver = IncludeResolver::new(|_req: IncludeRequest<'_>| -> Result<InputSource> {
unreachable!("non-!include tag must not invoke the resolver")
});
let cfg = ParserConfig::new().include_resolver(resolver);
let yaml = "v: !custom 42\n";
let v: Value = from_str_with_config(yaml, &cfg).unwrap();
let tag = v["v"].as_tagged().unwrap();
assert_eq!(tag.tag().as_str(), "!custom");
}
#[test]
fn input_source_constructor_and_clone() {
let src = InputSource::new("test.yaml", "k: v\n");
assert_eq!(src.name, "test.yaml");
assert_eq!(src.bytes, "k: v\n");
let cloned = src.clone();
assert_eq!(cloned.name, src.name);
}
#[test]
fn include_request_debug_renders_via_resolver_invocation() {
use std::sync::Mutex;
let captured: Arc<Mutex<Option<String>>> = Arc::new(Mutex::new(None));
let captured_clone = Arc::clone(&captured);
let resolver = IncludeResolver::new(move |req: IncludeRequest<'_>| -> Result<InputSource> {
*captured_clone.lock().unwrap() = Some(format!("{req:?}"));
Ok(InputSource::new(req.spec, "ok: 1\n"))
});
let cfg = ParserConfig::new().include_resolver(resolver);
let _: Value = from_str_with_config("x: !include some_spec.yaml\n", &cfg).unwrap();
let dbg = captured.lock().unwrap().clone().unwrap();
assert!(dbg.contains("IncludeRequest"));
assert!(dbg.contains("some_spec.yaml"));
}
#[test]
fn resolver_debug_renders() {
let r = IncludeResolver::new(|_| Ok(InputSource::new("n", "v: 1\n")));
let s = format!("{r:?}");
assert!(s.contains("IncludeResolver"));
}
#[test]
fn resolver_observes_increasing_depth() {
let depths: Arc<Mutex<Vec<usize>>> = Arc::new(Mutex::new(Vec::new()));
let depths_clone = Arc::clone(&depths);
let resolver = IncludeResolver::new(move |req: IncludeRequest<'_>| -> Result<InputSource> {
depths_clone.lock().unwrap().push(req.depth);
match req.spec {
"a.yaml" => Ok(InputSource::new("a", "next: !include b.yaml\n")),
"b.yaml" => Ok(InputSource::new("b", "leaf: 7\n")),
_ => unreachable!(),
}
});
let cfg = ParserConfig::new().include_resolver(resolver);
let v: Value = from_str_with_config("r: !include a.yaml\n", &cfg).unwrap();
assert_eq!(v["r"]["next"]["leaf"].as_i64(), Some(7));
let observed = depths.lock().unwrap().clone();
assert_eq!(observed, vec![0, 1]);
}