Skip to main content

soothfast_spec/probe/
coverage.rs

1//! Every field the spec declares for a response, as population paths.
2//!
3//! The population lock only protects fields that have been observed at
4//! least once — a field declared in the schema but never wired to data is
5//! invisible to it. This walk enumerates the response schema's field
6//! paths in the same dotted/`[]` notation as [`super::population`], so
7//! declared-but-never-populated fields can be diffed, locked, and gated.
8
9use std::collections::BTreeSet;
10
11use serde_json::Value;
12
13/// Enumerate the field paths `schema` declares, resolving `$ref`s through
14/// the spec's `components/schemas`. `allOf` branches all apply and are
15/// merged; `oneOf`/`anyOf` children are conditional on which branch the
16/// value takes, so nothing below a union counts as declared. Recursive
17/// schemas stop at the repeated `$ref`; an open schema (`{}`) declares
18/// nothing below its own path.
19pub fn declared_paths(schema: &Value, spec: &Value) -> BTreeSet<String> {
20    let mut paths = BTreeSet::new();
21    walk(schema, spec, String::new(), &mut Vec::new(), &mut paths);
22    paths
23}
24
25fn walk(
26    schema: &Value,
27    spec: &Value,
28    path: String,
29    ref_stack: &mut Vec<String>,
30    out: &mut BTreeSet<String>,
31) {
32    let Some(schema_obj) = schema.as_object() else {
33        return;
34    };
35    if let Some(reference) = schema_obj.get("$ref").and_then(Value::as_str) {
36        if ref_stack.iter().any(|r| r == reference) {
37            return;
38        }
39        let Some(resolved) = super::shape::resolve(spec, reference) else {
40            return;
41        };
42        ref_stack.push(reference.to_string());
43        walk(resolved, spec, path, ref_stack, out);
44        ref_stack.pop();
45        return;
46    }
47    if schema_obj.contains_key("oneOf") || schema_obj.contains_key("anyOf") {
48        return;
49    }
50    if let Some(branches) = schema_obj.get("allOf").and_then(Value::as_array) {
51        for branch in branches {
52            walk(branch, spec, path.clone(), ref_stack, out);
53        }
54        return;
55    }
56    if let Some(properties) = schema_obj.get("properties").and_then(Value::as_object) {
57        for (key, child) in properties {
58            let child_path = if path.is_empty() {
59                key.clone()
60            } else {
61                format!("{path}.{key}")
62            };
63            out.insert(child_path.clone());
64            walk(child, spec, child_path, ref_stack, out);
65        }
66    }
67    if let Some(items) = schema_obj.get("items") {
68        walk(items, spec, format!("{path}[]"), ref_stack, out);
69    }
70}
71
72#[cfg(test)]
73mod tests {
74    use super::*;
75    use serde_json::json;
76
77    #[test]
78    fn properties_arrays_and_refs_enumerate_like_population_paths() {
79        let spec = json!({
80            "components": {"schemas": {"Money": {
81                "type": "object",
82                "properties": {"raw": {"type": "number"}, "fmt": {"type": "string"}},
83            }}}
84        });
85        let schema = json!({
86            "type": "object",
87            "properties": {
88                "symbol": {"type": "string"},
89                "price": {"$ref": "#/components/schemas/Money"},
90                "candles": {"type": "array", "items": {
91                    "type": "object",
92                    "properties": {"close": {"type": "number"}},
93                }},
94            },
95        });
96        let paths = declared_paths(&schema, &spec);
97        let expected: BTreeSet<String> = [
98            "symbol",
99            "price",
100            "price.raw",
101            "price.fmt",
102            "candles",
103            "candles[].close",
104        ]
105        .into_iter()
106        .map(String::from)
107        .collect();
108        assert_eq!(paths, expected);
109    }
110
111    #[test]
112    fn open_and_recursive_schemas_terminate() {
113        let spec = json!({
114            "components": {"schemas": {"Node": {
115                "type": "object",
116                "properties": {
117                    "value": {"type": "number"},
118                    "next": {"$ref": "#/components/schemas/Node"},
119                },
120            }}}
121        });
122        let schema = json!({
123            "type": "object",
124            "properties": {"open": {}, "tree": {"$ref": "#/components/schemas/Node"}},
125        });
126        let paths = declared_paths(&schema, &spec);
127        assert!(paths.contains("open"));
128        assert!(paths.contains("tree.value"));
129        assert!(paths.contains("tree.next"));
130        assert!(!paths.contains("tree.next.value"));
131    }
132
133    #[test]
134    fn union_children_are_conditional_but_all_of_is_conjunctive() {
135        let spec = json!({});
136        let union = json!({
137            "oneOf": [
138                {"type": "number"},
139                {"type": "object", "properties": {"raw": {"type": "number"}}},
140            ],
141        });
142        assert!(declared_paths(&union, &spec).is_empty());
143        let conjunction = json!({
144            "allOf": [
145                {"type": "object", "properties": {"a": {"type": "number"}}},
146                {"type": "object", "properties": {"b": {"type": "number"}}},
147            ],
148        });
149        let paths = declared_paths(&conjunction, &spec);
150        assert!(paths.contains("a"));
151        assert!(paths.contains("b"));
152    }
153}