Skip to main content

metadata_gen/
io.rs

1// SPDX-FileCopyrightText: 2024 - 2026 metadata-gen contributors
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! Synchronous readers and files.
5//!
6//! The parser works on `&str`; these helpers do the reading. Any async
7//! runtime can use them from a blocking task, and the Tokio-native
8//! versions live in `metadata_gen::tokio` behind the `tokio` feature.
9
10use crate::error::MetadataError;
11use crate::{
12    extract_and_prepare_metadata, Keywords, MetaTagGroups, MetadataMap,
13};
14use std::io::Read;
15use std::path::Path;
16
17/// Reads everything from `reader` and runs [`extract_and_prepare_metadata`].
18///
19/// An empty (or whitespace-only) input yields empty metadata rather than
20/// an error, matching `metadata_gen::tokio::extract_from_file` (feature `tokio`).
21///
22/// # Errors
23///
24/// [`MetadataError::IoError`] when the read fails, otherwise whatever
25/// [`extract_and_prepare_metadata`] returns.
26///
27/// # Example
28///
29/// ```
30/// use metadata_gen::io::extract_from_reader;
31///
32/// let doc = b"---\ntitle: Reader\n---\nbody" as &[u8];
33/// let (metadata, _, _) = extract_from_reader(doc).unwrap();
34/// assert_eq!(metadata["title"], "Reader");
35/// ```
36pub fn extract_from_reader<R: Read>(
37    mut reader: R,
38) -> Result<(MetadataMap, Keywords, MetaTagGroups), MetadataError> {
39    let mut content = String::new();
40    reader.read_to_string(&mut content)?;
41    extract_from_content(&content)
42}
43
44/// Reads the file at `path` and runs [`extract_and_prepare_metadata`].
45///
46/// # Errors
47///
48/// As [`extract_from_reader`].
49///
50/// # Security
51///
52/// `path` is opened as given. Validate a path that comes from untrusted
53/// input before passing it, to rule out path traversal.
54///
55/// # Example
56///
57/// ```no_run
58/// use metadata_gen::io::extract_from_file;
59///
60/// let (metadata, keywords, tags) = extract_from_file("content/post.md").unwrap();
61/// # let _ = (metadata, keywords, tags);
62/// ```
63pub fn extract_from_file<P: AsRef<Path>>(
64    path: P,
65) -> Result<(MetadataMap, Keywords, MetaTagGroups), MetadataError> {
66    extract_from_reader(std::fs::File::open(path)?)
67}
68
69/// The shared tail of every file helper: empty input is empty output.
70pub(crate) fn extract_from_content(
71    content: &str,
72) -> Result<(MetadataMap, Keywords, MetaTagGroups), MetadataError> {
73    if content.trim().is_empty() {
74        return Ok((
75            MetadataMap::new(),
76            Vec::new(),
77            MetaTagGroups::default(),
78        ));
79    }
80    extract_and_prepare_metadata(content)
81}
82
83#[cfg(all(test, feature = "yaml"))]
84mod tests {
85    use super::*;
86
87    #[test]
88    fn reader_round_trip_matches_string_api() {
89        let doc = "---\ntitle: T\ndate: 2024-01-02\n---\nbody";
90        let via_reader = extract_from_reader(doc.as_bytes()).unwrap();
91        let direct = extract_and_prepare_metadata(doc).unwrap();
92        assert_eq!(via_reader.0, direct.0);
93        assert_eq!(via_reader.1, direct.1);
94        assert_eq!(via_reader.2, direct.2);
95    }
96
97    #[test]
98    fn empty_reader_is_empty_metadata() {
99        let (map, keywords, tags) =
100            extract_from_reader(b"  \n" as &[u8]).unwrap();
101        assert!(map.is_empty());
102        assert!(keywords.is_empty());
103        assert_eq!(tags, MetaTagGroups::default());
104    }
105
106    #[test]
107    fn invalid_utf8_is_an_io_error() {
108        let err = extract_from_reader(&[0xff, 0xfe][..]).unwrap_err();
109        assert!(matches!(err, MetadataError::IoError(_)));
110    }
111
112    #[cfg_attr(
113        miri,
114        ignore = "touches the filesystem; Miri isolation forbids it"
115    )]
116    #[test]
117    fn missing_file_is_an_io_error() {
118        let err =
119            extract_from_file("/definitely/not/here.md").unwrap_err();
120        assert!(matches!(err, MetadataError::IoError(_)));
121    }
122}