polyspec_crudui_validator/compose/loader.rs
1//! File loader for `$ref` resolution.
2//!
3//! `$ref` loads external YAML files. The
4//! compose engine never touches the filesystem directly — it goes through the
5//! `FileLoader` trait, so the shared fixtures supply a virtual in-memory file set
6//! (the spec graph is the input; no disk needed). The only implementation today
7//! is `MemoryLoader`; a disk + YAML backed loader is not implemented yet — when
8//! added it plugs into the same trait without touching the engine.
9//!
10//! Path normalization:
11//! - absolute (`/…`) paths pass through unchanged
12//! - relative paths get the basepath prefix (`basepath + '/' + path`)
13//!
14//! The loader receives the ALREADY-normalized absolute key, so cycle detection
15//! and the fixture map key on one canonical identifier.
16
17use std::collections::HashMap;
18
19use serde_json::{Map, Value};
20
21use super::errors::{ComposeErrorCode, ComposeLoadError, ComposeResult};
22
23/// A loaded YAML document (a parsed object tree). Always an object map at the top.
24pub type LoadedDoc = Map<String, Value>;
25
26/// Loads a normalized file key to its parsed document. `load` returns
27/// `ComposeLoadError(REF_FILE_NOT_FOUND)` when the key is absent — the engine
28/// relies on that exact code (an unresolved `$ref` is a load error, never
29/// `valid:true`).
30pub trait FileLoader {
31 /// Resolve `path` against `basepath` to the canonical key the map uses.
32 fn normalize(&self, path: &str, basepath: &str) -> String;
33 /// Load the parsed document at the canonical key, or return NOT_FOUND.
34 fn load(&self, key: &str) -> ComposeResult<LoadedDoc>;
35}
36
37/// In-memory loader over a fixed `{ key: doc }` map. Shared fixtures pass a file
38/// set here; the engine resolves `$ref` against it with no disk access. Keys are
39/// the normalized identifiers (`normalize` output).
40pub struct MemoryLoader {
41 files: HashMap<String, LoadedDoc>,
42}
43
44impl MemoryLoader {
45 /// Build a loader from a `{ key: doc }` map. Each `doc` must be a JSON object
46 /// (a parsed YAML document); a non-object entry is dropped (it can never be a
47 /// valid file body), matching the fixture contract where every file is a map.
48 pub fn new(files: Map<String, Value>) -> Self {
49 let mut map = HashMap::new();
50 for (key, doc) in files {
51 if let Value::Object(obj) = doc {
52 map.insert(key, obj);
53 }
54 }
55 MemoryLoader { files: map }
56 }
57}
58
59impl FileLoader for MemoryLoader {
60 fn normalize(&self, path: &str, basepath: &str) -> String {
61 // Absolute path: pass through. Relative: prefix basepath.
62 if path.starts_with('/') {
63 return path.to_string();
64 }
65 if !basepath.is_empty() {
66 return format!("{}/{}", basepath, path);
67 }
68 path.to_string()
69 }
70
71 fn load(&self, key: &str) -> ComposeResult<LoadedDoc> {
72 match self.files.get(key) {
73 // Defensive clone so resolution never mutates the source file set.
74 Some(doc) => Ok(doc.clone()),
75 None => Err(ComposeLoadError::with_trace(
76 ComposeErrorCode::RefFileNotFound,
77 format!("$ref file not found: {}", key),
78 vec![key.to_string()],
79 )),
80 }
81 }
82}