Skip to main content

nap_core/
query.rs

1//! Subtree query engine for NAP manifests.
2//!
3//! Supports dot-notation path traversal into YAML/JSON manifest values:
4//!
5//! ```text
6//! "appearances.audienceVotes"  → manifest["appearances"]["audienceVotes"]
7//! "representations.reference_image.hash" → the SHA-256 hash string
8//! "references.appears_in" → array of scene URIs
9//! ```
10//!
11//! This is a first-class resolver capability — not a bolted-on feature.
12//! It enables:
13//! - AI systems to retrieve 500 tokens instead of 40,000
14//! - Applications to fetch 10 KB instead of 5 MB
15//! - CLI queries: `nap resolve nap://starwars/character/luke#references.appears_in`
16
17use serde_yaml::Value;
18
19use crate::error::NapError;
20
21/// Query engine for extracting subtrees from manifest YAML values.
22pub struct ManifestQuery;
23
24impl ManifestQuery {
25    /// Traverse a dot-separated path into a YAML value tree.
26    ///
27    /// # Arguments
28    /// - `root` — The YAML value to traverse (typically a serialized Manifest).
29    /// - `path` — Dot-separated path. e.g., `"references.appears_in"`.
30    ///
31    /// # Returns
32    /// The subtree at the given path, or an error if any segment is missing.
33    ///
34    /// # Examples
35    /// ```
36    /// use serde_yaml::Value;
37    /// use nap_core::query::ManifestQuery;
38    ///
39    /// let yaml = "
40    /// name: Luke Skywalker
41    /// properties:
42    ///   homeworld: tatooine
43    ///   species: human
44    /// ";
45    /// let root: Value = serde_yaml::from_str(yaml).unwrap();
46    /// let result = ManifestQuery::query(&root, "properties.homeworld", "test").unwrap();
47    /// assert_eq!(result, Value::String("tatooine".to_string()));
48    /// ```
49    pub fn query(root: &Value, path: &str, manifest_id: &str) -> Result<Value, NapError> {
50        if path.is_empty() {
51            return Err(NapError::InvalidQueryPath(
52                "query path cannot be empty".to_string(),
53            ));
54        }
55
56        let segments: Vec<&str> = path.split('.').collect();
57        let mut current = root;
58
59        for (depth, segment) in segments.iter().enumerate() {
60            if segment.is_empty() {
61                return Err(NapError::InvalidQueryPath(format!(
62                    "empty segment at position {depth} in path '{path}'"
63                )));
64            }
65
66            current = match current {
67                Value::Mapping(map) => {
68                    let key = Value::String(segment.to_string());
69                    map.get(&key).ok_or_else(|| {
70                        let traversed = segments[..depth].join(".");
71                        let available_keys: Vec<String> = map
72                            .keys()
73                            .filter_map(|k| k.as_str().map(String::from))
74                            .collect();
75                        tracing::debug!(
76                            manifest_id = manifest_id,
77                            path = path,
78                            segment = segment,
79                            traversed = traversed,
80                            available_keys = ?available_keys,
81                            "query segment not found in mapping"
82                        );
83                        NapError::QueryPathNotFound {
84                            path: path.to_string(),
85                            manifest_id: manifest_id.to_string(),
86                        }
87                    })?
88                }
89                Value::Sequence(seq) => {
90                    // Allow integer index access into sequences
91                    let index: usize =
92                        segment.parse().map_err(|_| NapError::QueryPathNotFound {
93                            path: path.to_string(),
94                            manifest_id: manifest_id.to_string(),
95                        })?;
96                    seq.get(index).ok_or_else(|| NapError::QueryPathNotFound {
97                        path: path.to_string(),
98                        manifest_id: manifest_id.to_string(),
99                    })?
100                }
101                _ => {
102                    return Err(NapError::QueryPathNotFound {
103                        path: path.to_string(),
104                        manifest_id: manifest_id.to_string(),
105                    });
106                }
107            };
108        }
109
110        Ok(current.clone())
111    }
112
113    /// List available keys at a given path (for introspection / tab-completion).
114    pub fn list_keys(root: &Value, path: &str, manifest_id: &str) -> Result<Vec<String>, NapError> {
115        let target = if path.is_empty() {
116            root.clone()
117        } else {
118            Self::query(root, path, manifest_id)?
119        };
120
121        match target {
122            Value::Mapping(map) => Ok(map
123                .keys()
124                .filter_map(|k| k.as_str().map(String::from))
125                .collect()),
126            Value::Sequence(seq) => Ok((0..seq.len()).map(|i| i.to_string()).collect()),
127            _ => Ok(vec![]),
128        }
129    }
130}
131
132#[cfg(test)]
133mod tests {
134    use super::*;
135
136    fn test_manifest() -> Value {
137        let yaml = r#"
138id: "nap://starwars/character/lukeskywalker"
139name: "Luke Skywalker"
140entity_type: character
141version: 17
142properties:
143  homeworld: "nap://starwars/location/tatooine"
144  species: human
145  affiliation: rebel_alliance
146representations:
147  reference_image:
148    hash: "sha256:abc123"
149    format: png
150references:
151  appears_in:
152    - "nap://starwars/scene/cantina"
153    - "nap://starwars/scene/trench-run"
154  relationships:
155    - target: "nap://starwars/character/darthvader"
156      type: parent
157"#;
158        serde_yaml::from_str(yaml).unwrap()
159    }
160
161    #[test]
162    fn test_query_simple_property() {
163        let root = test_manifest();
164        let result = ManifestQuery::query(&root, "properties.species", "test").unwrap();
165        assert_eq!(result, Value::String("human".to_string()));
166    }
167
168    #[test]
169    fn test_query_nested_representation() {
170        let root = test_manifest();
171        let result =
172            ManifestQuery::query(&root, "representations.reference_image.hash", "test").unwrap();
173        assert_eq!(result, Value::String("sha256:abc123".to_string()));
174    }
175
176    #[test]
177    fn test_query_array() {
178        let root = test_manifest();
179        let result = ManifestQuery::query(&root, "references.appears_in", "test").unwrap();
180        match result {
181            Value::Sequence(seq) => assert_eq!(seq.len(), 2),
182            _ => panic!("expected sequence"),
183        }
184    }
185
186    #[test]
187    fn test_query_array_index() {
188        let root = test_manifest();
189        let result = ManifestQuery::query(&root, "references.appears_in.0", "test").unwrap();
190        assert_eq!(
191            result,
192            Value::String("nap://starwars/scene/cantina".to_string())
193        );
194    }
195
196    #[test]
197    fn test_query_not_found() {
198        let root = test_manifest();
199        let result = ManifestQuery::query(&root, "properties.nonexistent", "test");
200        assert!(result.is_err());
201    }
202
203    #[test]
204    fn test_query_empty_path() {
205        let root = test_manifest();
206        let result = ManifestQuery::query(&root, "", "test");
207        assert!(result.is_err());
208    }
209
210    #[test]
211    fn test_list_keys_root() {
212        let root = test_manifest();
213        let keys = ManifestQuery::list_keys(&root, "", "test").unwrap();
214        assert!(keys.contains(&"properties".to_string()));
215        assert!(keys.contains(&"representations".to_string()));
216    }
217
218    #[test]
219    fn test_list_keys_nested() {
220        let root = test_manifest();
221        let keys = ManifestQuery::list_keys(&root, "properties", "test").unwrap();
222        assert!(keys.contains(&"species".to_string()));
223        assert!(keys.contains(&"homeworld".to_string()));
224    }
225}