Skip to main content

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}