Skip to main content

rskit_config/strict/
document.rs

1use std::path::{Path, PathBuf};
2use std::sync::Arc;
3
4use rskit_codec::{Codec, TomlCodec, Value};
5use rskit_errors::{AppError, AppResult};
6use serde::de::DeserializeOwned;
7
8use super::merge::IncludeMerge;
9
10/// Maximum size of a single configuration file (1 MiB).
11const MAX_CONFIG_BYTES: u64 = 1024 * 1024;
12
13/// Strict, layered document loader.
14///
15/// Loads a canonical config file, optionally merging in include files,
16/// and deserializes into a typed schema while honoring `#[serde(deny_unknown_fields)]`.
17/// Unlike the [`crate::ConfigLoader`] pipeline (built on the `config` crate's value tree),
18/// this path decodes through [`rskit_codec`] into the canonical [`Value`] tree
19/// and deserializes from it, so serde's unknown-field rejection fires
20/// and dynamic-keyed sections can be retained verbatim as [`crate::strict::RawValue`] for downstream parsing.
21///
22/// The on-disk format is pluggable via [`Codec`]: TOML is the built-in default ([`StrictLoader::new`]);
23/// any other codec (JSON, …) drops in through [`StrictLoader::with_codec`].
24///
25/// Include files are treated as defaults: the canonical file's values win over includes,
26/// and later includes win over earlier ones.
27/// Array-of-tables sections registered as identity-keyed (see [`IncludeMerge`]) are concatenated
28/// and hard-error on duplicate identity.
29#[derive(Debug)]
30pub struct StrictLoader {
31    path: PathBuf,
32    includes: Vec<PathBuf>,
33    merge: IncludeMerge,
34    codec: Arc<dyn Codec>,
35}
36
37impl StrictLoader {
38    /// Create a loader for the canonical file at `path`, decoded as TOML.
39    pub fn new(path: impl Into<PathBuf>) -> Self {
40        Self {
41            path: path.into(),
42            includes: Vec::new(),
43            merge: IncludeMerge::new(),
44            codec: Arc::new(TomlCodec),
45        }
46    }
47
48    /// Add an include file merged beneath the canonical file (a default source).
49    #[must_use]
50    pub fn with_include(mut self, path: impl Into<PathBuf>) -> Self {
51        self.includes.push(path.into());
52        self
53    }
54
55    /// Add multiple include files, in increasing precedence order.
56    #[must_use]
57    pub fn with_includes<I, P>(mut self, paths: I) -> Self
58    where
59        I: IntoIterator<Item = P>,
60        P: Into<PathBuf>,
61    {
62        self.includes.extend(paths.into_iter().map(Into::into));
63        self
64    }
65
66    /// Set the identity-aware include-merge configuration.
67    #[must_use]
68    pub fn with_merge(mut self, merge: IncludeMerge) -> Self {
69        self.merge = merge;
70        self
71    }
72
73    /// Set the [`Codec`] used to decode the canonical file and every include.
74    ///
75    /// Defaults to [`TomlCodec`]. Use this to load a strict JSON document, or any user-supplied format,
76    /// without changing the loader.
77    #[must_use]
78    pub fn with_codec(mut self, codec: Arc<dyn Codec>) -> Self {
79        self.codec = codec;
80        self
81    }
82
83    /// Load, merge includes, and deserialize into `T`.
84    ///
85    /// Honors `#[serde(deny_unknown_fields)]`: an unknown key is a hard error.
86    pub fn load<T>(&self) -> AppResult<T>
87    where
88        T: DeserializeOwned,
89    {
90        let value = self.load_raw()?;
91        T::deserialize(value).map_err(|error| {
92            AppError::invalid_input(
93                "config",
94                format!("failed to parse '{}': {error}", self.path.display()),
95            )
96        })
97    }
98
99    /// Load and merge includes into a single raw value tree (no typed schema).
100    ///
101    /// Validates identity-keyed sections but performs no schema typing, so callers can inspect
102    /// or hand off dynamic-keyed subtrees verbatim.
103    pub fn load_raw(&self) -> AppResult<Value> {
104        let canonical = self.read(&self.path)?;
105        self.assemble(self.includes.iter().map(PathBuf::as_path), canonical)
106    }
107
108    /// Load and deserialize into `T`, resolving include paths from the canonical document itself.
109    ///
110    /// Reads the canonical file exactly once,
111    /// passes its decoded value tree to `resolve` to obtain the include list (for configs that declare their own includes, e.g. `[toven].include = [...]`),
112    /// then merges those includes beneath the canonical document.
113    /// Any statically-registered [`with_includes`](Self::with_includes) are ignored by this entry point.
114    ///
115    /// Honors `#[serde(deny_unknown_fields)]`.
116    pub fn load_resolving_includes<T, F>(&self, resolve: F) -> AppResult<T>
117    where
118        T: DeserializeOwned,
119        F: FnOnce(&Value) -> AppResult<Vec<PathBuf>>,
120    {
121        let value = self.load_raw_resolving_includes(resolve)?;
122        T::deserialize(value).map_err(|error| {
123            AppError::invalid_input(
124                "config",
125                format!("failed to parse '{}': {error}", self.path.display()),
126            )
127        })
128    }
129
130    /// Raw counterpart of [`load_resolving_includes`](Self::load_resolving_includes).
131    ///
132    /// Reads the canonical file once, derives the include list from it via `resolve`,
133    /// and merges the includes beneath the canonical document.
134    pub fn load_raw_resolving_includes<F>(&self, resolve: F) -> AppResult<Value>
135    where
136        F: FnOnce(&Value) -> AppResult<Vec<PathBuf>>,
137    {
138        let canonical = self.read(&self.path)?;
139        let includes = resolve(&canonical)?;
140        self.assemble(includes.iter().map(PathBuf::as_path), canonical)
141    }
142
143    /// Merge `includes` beneath an already-decoded `canonical` document.
144    ///
145    /// Includes are applied in increasing precedence (later wins over earlier),
146    /// then the canonical document is merged on top so it wins every collision.
147    fn assemble<'a>(
148        &self,
149        includes: impl Iterator<Item = &'a Path>,
150        canonical: Value,
151    ) -> AppResult<Value> {
152        let mut document = Value::Object(serde_json::Map::new());
153        for include in includes {
154            let overlay = self.read(include)?;
155            document = self.merge.merge(document, overlay)?;
156        }
157        document = self.merge.merge(document, canonical)?;
158        self.merge.validate(&document)?;
159        Ok(document)
160    }
161
162    /// Read and decode a single file into the canonical [`Value`] tree.
163    fn read(&self, path: &Path) -> AppResult<Value> {
164        let text = rskit_fs::sync_io::file::read_string_bounded(path, MAX_CONFIG_BYTES)?;
165        self.codec.decode_value(&text).map_err(|error| {
166            AppError::invalid_input("config", format!("failed to parse '{}'", path.display()))
167                .with_cause(error)
168        })
169    }
170}
171
172/// Load a single strict file into `T` with no includes (decoded as TOML).
173pub fn load_strict<T>(path: impl Into<PathBuf>) -> AppResult<T>
174where
175    T: DeserializeOwned,
176{
177    StrictLoader::new(path).load()
178}
179
180#[cfg(test)]
181mod tests {
182    use super::*;
183    use crate::strict::{IdentityKey, RawValue};
184    use serde::Deserialize;
185    use std::collections::BTreeMap;
186
187    #[derive(Debug, Deserialize)]
188    #[serde(deny_unknown_fields)]
189    struct Doc {
190        name: String,
191        #[serde(default)]
192        ecosystems: BTreeMap<String, RawValue>,
193    }
194
195    fn write(dir: &Path, name: &str, body: &str) -> PathBuf {
196        let path = dir.join(name);
197        std::fs::write(&path, body).unwrap();
198        path
199    }
200
201    #[test]
202    fn load_strict_rejects_unknown_top_level_key() {
203        let dir = tempfile::tempdir().unwrap();
204        let path = write(dir.path(), "c.toml", "name = \"toven\"\nextra = true\n");
205
206        let err = load_strict::<Doc>(&path).unwrap_err();
207
208        assert!(err.to_string().contains("unknown field"));
209    }
210
211    #[test]
212    fn load_retains_dynamic_keyed_subtree_verbatim() {
213        let dir = tempfile::tempdir().unwrap();
214        let path = write(
215            dir.path(),
216            "c.toml",
217            "name = \"toven\"\n[ecosystems.rust]\nedition = 2024\nfeatures = [\"a\"]\n",
218        );
219
220        let doc: Doc = load_strict(&path).unwrap();
221
222        let rust = doc.ecosystems.get("rust").unwrap();
223        assert_eq!(rust.get("edition").unwrap().as_i64(), Some(2024));
224        assert_eq!(rust.get("features").unwrap().as_array().unwrap().len(), 1);
225    }
226
227    #[test]
228    fn includes_are_defaults_canonical_wins() {
229        let dir = tempfile::tempdir().unwrap();
230        let base = write(dir.path(), "base.toml", "name = \"base\"\n");
231        let main = write(dir.path(), "c.toml", "name = \"main\"\n");
232
233        let doc: Doc = StrictLoader::new(&main).with_include(&base).load().unwrap();
234
235        assert_eq!(doc.name, "main");
236    }
237
238    #[test]
239    fn resolving_includes_reads_canonical_once_and_merges_beneath() {
240        let dir = tempfile::tempdir().unwrap();
241        write(dir.path(), "extra.toml", "name = \"included\"\n");
242        let main = write(
243            dir.path(),
244            "c.toml",
245            "name = \"main\"\n[ecosystems.rust]\nedition = 2024\n",
246        );
247
248        let doc: Doc = StrictLoader::new(&main)
249            .load_resolving_includes(|_canonical| Ok(vec![dir.path().join("extra.toml")]))
250            .unwrap();
251
252        // Canonical wins the scalar; the included default merges beneath.
253        assert_eq!(doc.name, "main");
254        assert!(doc.ecosystems.contains_key("rust"));
255    }
256
257    #[test]
258    fn include_merge_rejects_duplicate_identity_across_files() {
259        #[derive(Debug, Deserialize)]
260        struct Groups {
261            #[serde(default)]
262            #[allow(dead_code)]
263            groups: Vec<Group>,
264        }
265        #[derive(Debug, Deserialize)]
266        struct Group {
267            #[allow(dead_code)]
268            name: String,
269        }
270
271        let dir = tempfile::tempdir().unwrap();
272        let base = write(dir.path(), "base.toml", "[[groups]]\nname = \"dup\"\n");
273        let main = write(dir.path(), "c.toml", "[[groups]]\nname = \"dup\"\n");
274
275        let err = StrictLoader::new(&main)
276            .with_include(&base)
277            .with_merge(IncludeMerge::new().with_identity("groups", IdentityKey::new("name")))
278            .load::<Groups>()
279            .unwrap_err();
280
281        assert!(err.to_string().contains("duplicate"));
282    }
283
284    #[test]
285    fn invalid_toml_surfaces_typed_error() {
286        let dir = tempfile::tempdir().unwrap();
287        let path = write(dir.path(), "c.toml", "name = \n");
288
289        let err = load_strict::<Doc>(&path).unwrap_err();
290
291        assert!(err.to_string().contains("failed to parse"));
292    }
293}