Skip to main content

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    /// The resolved include target path is not valid UTF-8.
125    NonUtf8Path,
126}
127
128/// Error type returned by user-provided include resolvers.
129#[derive(Debug)]
130#[non_exhaustive]
131pub enum IncludeResolveError {
132    Io(std::io::Error),
133    Message(String),
134    SizeLimitExceeded(usize, usize),
135    FileInclude(Box<ResolveProblem>),
136}
137
138impl From<std::io::Error> for IncludeResolveError {
139    fn from(value: std::io::Error) -> Self {
140        Self::Io(value)
141    }
142}
143
144/// A request passed to the include resolver to resolve an include directive.
145#[non_exhaustive]
146#[derive(Debug)]
147pub struct IncludeRequest<'a> {
148    /// The include specification (e.g. the path or URL).
149    pub spec: &'a str,
150    /// The name of the file or source currently being parsed (top of the include stack).
151    pub from_name: &'a str,
152    /// The canonical identity of the source currently being parsed, or None for the root parser.
153    pub from_id: Option<&'a str>,
154    /// The full chain of inclusions leading to this request, with the current file at the end.
155    pub stack: Vec<String>,
156    /// Remaining decoded byte quota available for additional reader-backed input, if configured.
157    pub size_remaining: Option<usize>,
158    /// The location in the source file where the include was requested.
159    pub location: crate::Location,
160}
161
162impl<'a> IncludeRequest<'a> {
163    /// Construct a root-level include request.
164    ///
165    /// This is primarily useful when testing a resolver directly. Parser-created requests
166    /// populate the same fields and may include parent-source and budget metadata.
167    #[must_use]
168    pub fn new(spec: &'a str, from_name: &'a str, location: crate::Location) -> Self {
169        Self {
170            spec,
171            from_name,
172            from_id: None,
173            stack: Vec::new(),
174            size_remaining: None,
175            location,
176        }
177    }
178
179    /// Set the canonical identity of the source containing the include.
180    #[must_use]
181    pub fn with_from_id(mut self, from_id: &'a str) -> Self {
182        self.from_id = Some(from_id);
183        self
184    }
185
186    /// Set the include chain leading to this request.
187    #[must_use]
188    pub fn with_stack(mut self, stack: Vec<String>) -> Self {
189        self.stack = stack;
190        self
191    }
192
193    /// Set the remaining reader-input byte budget.
194    #[must_use]
195    pub fn with_size_remaining(mut self, size_remaining: usize) -> Self {
196        self.size_remaining = Some(size_remaining);
197        self
198    }
199}
200
201/// Callback used to resolve `!include` directives during parsing.
202///
203/// The resolver receives an [`IncludeRequest`] describing what was requested, from which
204/// source it originated, and where in the source file the directive was encountered. It must
205/// either return a [`ResolvedInclude`] with a stable `id`, human-friendly `name`, and the
206/// replacement [`InputSource`], or fail with [`IncludeResolveError`].
207///
208/// The `id` should uniquely identify the underlying resource after any normalization you need
209/// (for example, a canonical filesystem path or a normalized URL). `serde-saphyr` uses this
210/// identifier for include-stack tracking and cycle detection. The `name` is intended for error
211/// messages and can be more user-friendly.
212///
213/// Resolvers may return:
214/// - [`InputSource::Text`] for ordinary in-memory YAML,
215/// - [`InputSource::AnchoredText`] when the include should behave as if a specific anchor was
216///   the first parsed node, or
217/// - [`InputSource::Reader`] when content should be streamed from an owned reader.
218///
219/// A resolver is invoked lazily, when a `!include` tag is encountered. Because the type is
220/// `FnMut`, the callback may keep state such as caches, metrics, or a virtual file map.
221///
222/// ```rust
223/// # #[cfg(feature = "include")]
224/// # {
225/// use serde::Deserialize;
226/// use serde_saphyr::{
227///     from_str_with_options, options, IncludeRequest, IncludeResolveError, InputSource,
228///     ResolvedInclude,
229/// };
230///
231/// #[derive(Debug, Deserialize, PartialEq)]
232/// struct Config {
233///     users: Vec<User>,
234/// }
235///
236/// #[derive(Debug, Deserialize, PartialEq)]
237/// struct User {
238///     name: String,
239/// }
240///
241/// let root_yaml = "users: !include virtual://users.yaml\n";
242/// let users_yaml = "- name: Alice\n- name: Bob\n";
243///
244/// let options = options! {}.with_include_resolver(|req: IncludeRequest<'_>| {
245///     assert_eq!(req.spec, "virtual://users.yaml");
246///     assert_eq!(req.from_name, "<input>");
247///
248///     if req.spec == "virtual://users.yaml" {
249///         Ok(ResolvedInclude::new(
250///             req.spec,
251///             "virtual users",
252///             InputSource::from_string(users_yaml.to_owned()),
253///         ))
254///     } else {
255///         Err(IncludeResolveError::Message(format!("unknown include: {}", req.spec)))
256///     }
257/// });
258///
259/// let config: Config = from_str_with_options(root_yaml, options).unwrap();
260/// assert_eq!(config.users.len(), 2);
261/// assert_eq!(config.users[0].name, "Alice");
262/// # }
263/// ```
264pub type IncludeResolver<'a> =
265    dyn FnMut(IncludeRequest<'_>) -> Result<ResolvedInclude, IncludeResolveError> + 'a;
266
267impl InputSource {
268    #[inline]
269    #[must_use]
270    pub fn from_string(s: String) -> Self {
271        Self::Text(s)
272    }
273
274    #[inline]
275    #[must_use]
276    pub fn from_reader<R>(r: R) -> Self
277    where
278        R: Read + 'static,
279    {
280        Self::Reader(Box::new(r))
281    }
282}
283
284#[cfg(test)]
285mod tests {
286    use super::InputSource;
287
288    #[test]
289    fn debug_formats_each_input_source_variant() {
290        let text = InputSource::from_string("hello".to_owned());
291        assert_eq!(format!("{text:?}"), "Text(\"hello\")");
292
293        let anchored = InputSource::AnchoredText {
294            text: "body: true\n".to_owned(),
295            anchor: "defaults".to_owned(),
296        };
297        assert_eq!(
298            format!("{anchored:?}"),
299            "AnchoredText { anchor: \"defaults\", text: \"body: true\\n\" }"
300        );
301
302        let reader = InputSource::from_reader(std::io::Cursor::new(b"stream".to_vec()));
303        assert_eq!(format!("{reader:?}"), "Reader(..)");
304    }
305}