rskit_config/strict/
document.rs1use 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
10const MAX_CONFIG_BYTES: u64 = 1024 * 1024;
12
13#[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 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 #[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 #[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 #[must_use]
68 pub fn with_merge(mut self, merge: IncludeMerge) -> Self {
69 self.merge = merge;
70 self
71 }
72
73 #[must_use]
78 pub fn with_codec(mut self, codec: Arc<dyn Codec>) -> Self {
79 self.codec = codec;
80 self
81 }
82
83 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 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 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 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 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 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
172pub 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 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}