1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
// SPDX-License-Identifier: MIT OR Apache-2.0
// Copyright (c) 2026 Noyalib. All rights reserved.
//! Coverage for `de.rs`'s `!include` recursion error edges and
//! `include.rs`'s filesystem read-error path.
//!
//! These close gaps the existing `include_directive.rs` suite leaves
//! open because its assertions only check `is_err()` without pinning
//! the *variant* — e.g. `max_include_depth_caps_recursion` reuses the
//! spec `"infinite"`, so it trips the **cycle** guard and never reaches
//! the **depth** guard (`de.rs` line 607). Each test here asserts the
//! specific failure so the intended region is actually exercised.
#![cfg(feature = "include")]
#![allow(missing_docs)]
#![allow(clippy::unwrap_used)]
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use noyalib::include::{IncludeRequest, IncludeResolver, InputSource};
use noyalib::{Error, ErrorKind, ParserConfig, Result, Value, from_str_with_config};
/// A depth-blowup resolver that hands back a **distinct** spec on
/// every call, so the per-walk `visited` cycle guard never fires and
/// resolution can only be stopped by the depth cap. This is what makes
/// the recursion reach `de.rs`'s `RecursionLimitExceeded` branch (607)
/// rather than the cycle branch (620) the existing test hits.
fn ever_deeper_resolver() -> IncludeResolver {
let counter = Arc::new(AtomicUsize::new(0));
IncludeResolver::new(move |_req: IncludeRequest<'_>| -> Result<InputSource> {
let n = counter.fetch_add(1, Ordering::Relaxed);
// Each level references a freshly-named child spec.
Ok(InputSource::new(
"gen",
format!("deeper: !include level_{}\n", n + 1),
))
})
}
#[test]
fn depth_cap_yields_recursion_limit_not_cycle() {
let cfg = ParserConfig::new()
.include_resolver(ever_deeper_resolver())
.max_include_depth(4);
let res: Result<Value> = from_str_with_config("root: !include level_0\n", &cfg);
let err = res.unwrap_err();
// The distinguishing assertion: this must be the *depth* guard,
// not the cycle guard. Cycle errors are `Error::Custom`
// (ErrorKind::Other); the depth guard is `RecursionLimitExceeded`
// (ErrorKind::Budget).
assert!(
matches!(err, Error::RecursionLimitExceeded { .. }),
"expected RecursionLimitExceeded, got {err:?}"
);
assert_eq!(err.kind(), ErrorKind::Budget);
}
#[test]
fn error_propagates_through_non_include_tagged_wrapper() {
// A `!custom`-tagged node whose *inner* value contains an
// `!include` that fails. The walker takes the non-`!include`
// Tagged branch (`de.rs` line 672), recurses into the wrapped
// mapping, and the failing include's error must propagate back up
// through the `?` at line 682.
let resolver = IncludeResolver::new(|req: IncludeRequest<'_>| -> Result<InputSource> {
Err(Error::Custom(format!("boom for `{}`", req.spec)))
});
let cfg = ParserConfig::new().include_resolver(resolver);
let yaml = "wrapped: !custom\n inner: !include child.yaml\n";
let res: Result<Value> = from_str_with_config(yaml, &cfg);
let err = res.unwrap_err();
assert!(
err.to_string().contains("boom for `child.yaml`"),
"inner include error must surface through the tagged wrapper: {err}"
);
}
#[test]
fn error_propagates_through_sequence_element() {
// The OK sequence path is covered elsewhere; this pins the
// Err-propagation edge of the sequence arm (`de.rs` line 696):
// a sequence element that is a failing `!include`.
let resolver = IncludeResolver::new(|req: IncludeRequest<'_>| -> Result<InputSource> {
Err(Error::Custom(format!("seq boom for `{}`", req.spec)))
});
let cfg = ParserConfig::new().include_resolver(resolver);
let yaml = "items:\n - ok\n - !include broken.yaml\n";
let res: Result<Value> = from_str_with_config(yaml, &cfg);
let err = res.unwrap_err();
assert!(
err.to_string().contains("seq boom for `broken.yaml`"),
"failing include inside a sequence must propagate: {err}"
);
}
/// The read-error path in `include.rs` (`SafeFileResolver`): a path
/// that canonicalises successfully but cannot be read as a file —
/// i.e. it is a **directory**. Exercises lines 289-294 (`fs::read_to_string`
/// error closure), which the "missing file" tests never reach because
/// canonicalisation fails first for a non-existent path.
#[cfg(feature = "include_fs")]
#[test]
fn including_a_directory_surfaces_read_error() {
use noyalib::include::SafeFileResolver;
let root = std::env::temp_dir().join("noyalib-cov-de-include-dir");
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(&root).unwrap();
// `sub` is a directory *inside* root — canonicalises fine, but is
// not a readable file.
std::fs::create_dir_all(root.join("sub")).unwrap();
let cfg = ParserConfig::new().include_resolver(SafeFileResolver::new(&root).into_resolver());
let res: Result<Value> = from_str_with_config("x: !include sub\n", &cfg);
let err = res.unwrap_err();
assert!(
err.to_string().contains("cannot read"),
"reading a directory must yield the resolver read-error: {err}"
);
let _ = std::fs::remove_dir_all(&root);
}