serde_saphyr/de/input_source.rs
1use std::io::Read;
2
3/// Owned input that can be fed into the YAML parser.
4///
5/// This is primarily used by the include resolver: it can return either fully-owned
6/// in-memory text, or a fully-owned streaming reader.
7#[non_exhaustive]
8pub enum InputSource {
9 /// Owned text.
10 Text(String),
11 /// Owned YAML text together with the name of an anchor to extract from it.
12 ///
13 /// This is mainly intended for resolvers that support include specs such as
14 /// `path/to/file.yaml#anchor_name`. In that case, the resolver still receives the full
15 /// include spec via [`IncludeRequest::spec`], splits the file part from the fragment itself,
16 /// reads the target document, and returns:
17 ///
18 /// ```rust
19 /// # use serde_saphyr::InputSource;
20 /// let source = InputSource::AnchoredText {
21 /// text: "defaults: &defaults\n enabled: true\nfeature: *defaults\n".to_owned(),
22 /// anchor: "defaults".to_owned(),
23 /// };
24 /// ```
25 ///
26 /// During parsing, `serde-saphyr` will parse `text`, find the node tagged with `&defaults`,
27 /// and replay only that anchored node as the included value. Conceptually, this makes:
28 ///
29 /// ```yaml
30 /// settings: !include config.yaml#defaults
31 /// ```
32 ///
33 /// behave as if `settings` directly contained the YAML node anchored as `&defaults` inside
34 /// `config.yaml`.
35 ///
36 /// Use [`InputSource::Text`] when the whole document should be included, and use
37 /// [`InputSource::AnchoredText`] only when you want the include to resolve to a specific
38 /// anchored fragment.
39 AnchoredText { text: String, anchor: String },
40 /// Owned reader (streaming).
41 Reader(Box<dyn Read + 'static>),
42}
43
44impl std::fmt::Debug for InputSource {
45 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
46 match self {
47 Self::Text(text) => f.debug_tuple("Text").field(text).finish(),
48 Self::AnchoredText { text, anchor } => f
49 .debug_struct("AnchoredText")
50 .field("anchor", anchor)
51 .field("text", text)
52 .finish(),
53 Self::Reader(_) => f.write_str("Reader(..)"),
54 }
55 }
56}
57
58/// A resolved include containing the source identity and the content.
59#[non_exhaustive]
60#[derive(Debug)]
61pub struct ResolvedInclude {
62 /// The canonical identity of the included source, used for cycle detection and absolute paths.
63 pub id: String,
64 /// The display name of the included source, used for error messages.
65 pub name: String,
66 /// The actual content to parse.
67 pub source: InputSource,
68}
69
70impl ResolvedInclude {
71 /// Construct a resolved include returned by an [`IncludeResolver`].
72 #[must_use]
73 pub fn new(id: impl Into<String>, name: impl Into<String>, source: InputSource) -> Self {
74 Self {
75 id: id.into(),
76 name: name.into(),
77 source,
78 }
79 }
80}
81
82/// Specific problems encountered during file include resolution.
83#[derive(Debug)]
84#[non_exhaustive]
85pub enum ResolveProblem {
86 /// Failed to canonicalize the include target path.
87 ResolveFailed {
88 spec: String,
89 base_dir: String,
90 err: std::io::Error,
91 },
92 /// The include target is not a regular file.
93 TargetNotRegularFile { target: String },
94 /// The include target resolves to the configured root file itself (cyclic include).
95 TargetIsRootFile { spec: String },
96 /// The parent include id was not an absolute canonical path.
97 ParentIdNotAbsoluteCanonical { parent_id: String },
98 /// Failed to resolve the parent include source.
99 ParentResolveFailed {
100 parent_id: String,
101 from_name: String,
102 err: std::io::Error,
103 },
104 /// The parent include is not a regular file.
105 ParentNotRegularFile { parent: String },
106 /// The parent include does not have a parent directory.
107 ParentHasNoDirectory { parent: String },
108 /// The include resolves outside the configured root directory.
109 ResolvesOutsideRoot { spec: String, root: String },
110 /// The include traverses a symlink, which is disabled by policy.
111 TraversesSymlink { spec: String },
112 /// Absolute include paths are not allowed.
113 AbsolutePathNotAllowed { spec: String },
114 /// The include path is empty.
115 EmptyPath,
116 /// The include target does not have a valid YAML extension (.yml or .yaml).
117 InvalidExtension { spec: String },
118 /// The include target is a hidden file (starts with a dot).
119 HiddenFile { spec: String },
120 /// The include fragment is empty.
121 EmptyFragment,
122 /// The include fragment contains a '#' character.
123 FragmentContainsHash { spec: String },
124}
125
126/// Error type returned by user-provided include resolvers.
127#[derive(Debug)]
128#[non_exhaustive]
129pub enum IncludeResolveError {
130 Io(std::io::Error),
131 Message(String),
132 SizeLimitExceeded(usize, usize),
133 FileInclude(Box<ResolveProblem>),
134}
135
136impl From<std::io::Error> for IncludeResolveError {
137 fn from(value: std::io::Error) -> Self {
138 Self::Io(value)
139 }
140}
141
142/// A request passed to the include resolver to resolve an include directive.
143#[non_exhaustive]
144#[derive(Debug)]
145pub struct IncludeRequest<'a> {
146 /// The include specification (e.g. the path or URL).
147 pub spec: &'a str,
148 /// The name of the file or source currently being parsed (top of the include stack).
149 pub from_name: &'a str,
150 /// The canonical identity of the source currently being parsed, or None for the root parser.
151 pub from_id: Option<&'a str>,
152 /// The full chain of inclusions leading to this request, with the current file at the end.
153 pub stack: Vec<String>,
154 /// Remaining decoded byte quota available for additional reader-backed input, if configured.
155 pub size_remaining: Option<usize>,
156 /// The location in the source file where the include was requested.
157 pub location: crate::Location,
158}
159
160impl<'a> IncludeRequest<'a> {
161 /// Construct a root-level include request.
162 ///
163 /// This is primarily useful when testing a resolver directly. Parser-created requests
164 /// populate the same fields and may include parent-source and budget metadata.
165 #[must_use]
166 pub fn new(spec: &'a str, from_name: &'a str, location: crate::Location) -> Self {
167 Self {
168 spec,
169 from_name,
170 from_id: None,
171 stack: Vec::new(),
172 size_remaining: None,
173 location,
174 }
175 }
176
177 /// Set the canonical identity of the source containing the include.
178 #[must_use]
179 pub fn with_from_id(mut self, from_id: &'a str) -> Self {
180 self.from_id = Some(from_id);
181 self
182 }
183
184 /// Set the include chain leading to this request.
185 #[must_use]
186 pub fn with_stack(mut self, stack: Vec<String>) -> Self {
187 self.stack = stack;
188 self
189 }
190
191 /// Set the remaining reader-input byte budget.
192 #[must_use]
193 pub fn with_size_remaining(mut self, size_remaining: usize) -> Self {
194 self.size_remaining = Some(size_remaining);
195 self
196 }
197}
198
199/// Callback used to resolve `!include` directives during parsing.
200///
201/// The resolver receives an [`IncludeRequest`] describing what was requested, from which
202/// source it originated, and where in the source file the directive was encountered. It must
203/// either return a [`ResolvedInclude`] with a stable `id`, human-friendly `name`, and the
204/// replacement [`InputSource`], or fail with [`IncludeResolveError`].
205///
206/// The `id` should uniquely identify the underlying resource after any normalization you need
207/// (for example, a canonical filesystem path or a normalized URL). `serde-saphyr` uses this
208/// identifier for include-stack tracking and cycle detection. The `name` is intended for error
209/// messages and can be more user-friendly.
210///
211/// Resolvers may return:
212/// - [`InputSource::Text`] for ordinary in-memory YAML,
213/// - [`InputSource::AnchoredText`] when the include should behave as if a specific anchor was
214/// the first parsed node, or
215/// - [`InputSource::Reader`] when content should be streamed from an owned reader.
216///
217/// A resolver is invoked lazily, when a `!include` tag is encountered. Because the type is
218/// `FnMut`, the callback may keep state such as caches, metrics, or a virtual file map.
219///
220/// ```rust
221/// # #[cfg(feature = "include")]
222/// # {
223/// use serde::Deserialize;
224/// use serde_saphyr::{
225/// from_str_with_options, options, IncludeRequest, IncludeResolveError, InputSource,
226/// ResolvedInclude,
227/// };
228///
229/// #[derive(Debug, Deserialize, PartialEq)]
230/// struct Config {
231/// users: Vec<User>,
232/// }
233///
234/// #[derive(Debug, Deserialize, PartialEq)]
235/// struct User {
236/// name: String,
237/// }
238///
239/// let root_yaml = "users: !include virtual://users.yaml\n";
240/// let users_yaml = "- name: Alice\n- name: Bob\n";
241///
242/// let options = options! {}.with_include_resolver(|req: IncludeRequest<'_>| {
243/// assert_eq!(req.spec, "virtual://users.yaml");
244/// assert_eq!(req.from_name, "<input>");
245///
246/// if req.spec == "virtual://users.yaml" {
247/// Ok(ResolvedInclude::new(
248/// req.spec,
249/// "virtual users",
250/// InputSource::from_string(users_yaml.to_owned()),
251/// ))
252/// } else {
253/// Err(IncludeResolveError::Message(format!("unknown include: {}", req.spec)))
254/// }
255/// });
256///
257/// let config: Config = from_str_with_options(root_yaml, options).unwrap();
258/// assert_eq!(config.users.len(), 2);
259/// assert_eq!(config.users[0].name, "Alice");
260/// # }
261/// ```
262pub type IncludeResolver<'a> =
263 dyn FnMut(IncludeRequest<'_>) -> Result<ResolvedInclude, IncludeResolveError> + 'a;
264
265impl InputSource {
266 #[inline]
267 #[must_use]
268 pub fn from_string(s: String) -> Self {
269 Self::Text(s)
270 }
271
272 #[inline]
273 #[must_use]
274 pub fn from_reader<R>(r: R) -> Self
275 where
276 R: Read + 'static,
277 {
278 Self::Reader(Box::new(r))
279 }
280}
281
282#[cfg(test)]
283mod tests {
284 use super::InputSource;
285
286 #[test]
287 fn debug_formats_each_input_source_variant() {
288 let text = InputSource::from_string("hello".to_owned());
289 assert_eq!(format!("{text:?}"), "Text(\"hello\")");
290
291 let anchored = InputSource::AnchoredText {
292 text: "body: true\n".to_owned(),
293 anchor: "defaults".to_owned(),
294 };
295 assert_eq!(
296 format!("{anchored:?}"),
297 "AnchoredText { anchor: \"defaults\", text: \"body: true\\n\" }"
298 );
299
300 let reader = InputSource::from_reader(std::io::Cursor::new(b"stream".to_vec()));
301 assert_eq!(format!("{reader:?}"), "Reader(..)");
302 }
303}