Skip to main content

clayers_spec/
query.rs

1use std::path::Path;
2
3use clayers_xml::query::{QueryMode as XmlQueryMode, QueryResult as XmlQueryResult};
4
5/// Result of an `XPath` query.
6#[derive(Debug)]
7pub enum QueryResult {
8    /// Node count (--count mode).
9    Count(usize),
10    /// Text content (--text mode).
11    Text(Vec<String>),
12    /// Raw XML output (default mode).
13    Xml(Vec<String>),
14}
15
16/// Execute an `XPath` query against a spec's combined document.
17///
18/// Assembles a combined document from the spec files, registers all
19/// clayers namespace prefixes, and evaluates the `XPath` expression.
20///
21/// # Errors
22///
23/// Returns an error if the spec cannot be assembled or the `XPath` is invalid.
24pub fn execute_query(
25    spec_dir: &Path,
26    xpath_expr: &str,
27    mode: QueryMode,
28) -> Result<QueryResult, crate::Error> {
29    let index_files = crate::discovery::find_index_files(spec_dir)?;
30    if index_files.is_empty() {
31        return Err(crate::Error::Discovery("no specs found".into()));
32    }
33
34    let mut all_file_paths = Vec::new();
35    for index_path in &index_files {
36        let file_paths = crate::discovery::discover_spec_files(index_path)?;
37        all_file_paths.extend(file_paths);
38    }
39
40    let combined_xml = crate::assembly::assemble_combined_string(&all_file_paths)?;
41
42    let xml_mode = match mode {
43        QueryMode::Count => XmlQueryMode::Count,
44        QueryMode::Text => XmlQueryMode::Text,
45        QueryMode::Xml => XmlQueryMode::Xml,
46    };
47
48    let result = clayers_xml::query::evaluate_xpath(
49        &combined_xml,
50        xpath_expr,
51        xml_mode,
52        &[],
53    )?;
54
55    Ok(match result {
56        XmlQueryResult::Count(n) => QueryResult::Count(n),
57        XmlQueryResult::Text(t) => QueryResult::Text(t),
58        XmlQueryResult::Xml(x) => QueryResult::Xml(x),
59    })
60}
61
62/// Query output mode.
63#[derive(Debug, Clone, Copy)]
64pub enum QueryMode {
65    Count,
66    Text,
67    Xml,
68}
69
70#[cfg(test)]
71mod tests {
72    use super::*;
73    use crate::namespace;
74    use std::path::PathBuf;
75
76    fn spec_dir() -> PathBuf {
77        PathBuf::from(env!("CARGO_MANIFEST_DIR"))
78            .join("../../clayers/clayers")
79            .canonicalize()
80            .expect("clayers/clayers/ not found")
81    }
82
83    #[test]
84    fn query_count_terms() {
85        let result =
86            execute_query(&spec_dir(), "//trm:term", QueryMode::Count).expect("query failed");
87        if let QueryResult::Count(count) = result {
88            assert!(count >= 15, "expected 15+ terms, got {count}");
89        } else {
90            panic!("expected Count result");
91        }
92    }
93
94    #[test]
95    fn query_count_depends_on_relations() {
96        let result = execute_query(
97            &spec_dir(),
98            "//rel:relation[@type=\"depends-on\"]",
99            QueryMode::Count,
100        )
101        .expect("query failed");
102        if let QueryResult::Count(count) = result {
103            assert!(count >= 20, "expected 20+ depends-on, got {count}");
104        } else {
105            panic!("expected Count result");
106        }
107    }
108
109    #[test]
110    fn query_text_term_definition() {
111        let result = execute_query(
112            &spec_dir(),
113            "//trm:term[@id=\"term-layer\"]/trm:definition",
114            QueryMode::Text,
115        )
116        .expect("query failed");
117        if let QueryResult::Text(texts) = result {
118            assert!(!texts.is_empty(), "should find term-layer definition");
119            let text = &texts[0];
120            assert!(
121                text.len() > 10,
122                "definition should have meaningful text: {text}"
123            );
124            assert!(!text.contains("<trm:"), "text should not contain XML tags");
125        } else {
126            panic!("expected Text result");
127        }
128    }
129
130    #[test]
131    fn query_xml_output() {
132        let result = execute_query(
133            &spec_dir(),
134            "//trm:term[@id=\"term-layer\"]",
135            QueryMode::Xml,
136        )
137        .expect("query failed");
138        if let QueryResult::Xml(xmls) = result {
139            assert!(!xmls.is_empty(), "should find term-layer");
140            let xml = &xmls[0];
141            assert!(xml.contains('<'), "should contain XML");
142            let old_urn = ["living", "spec"].concat();
143            assert!(!xml.contains(&old_urn), "should not contain old URN");
144        } else {
145            panic!("expected Xml result");
146        }
147    }
148
149    #[test]
150    fn all_namespace_prefixes_resolve() {
151        let prefixes = [
152            "pr", "trm", "org", "rel", "art", "llm", "rev", "spec", "cmb", "idx", "dec", "src",
153            "pln",
154        ];
155        for prefix in prefixes {
156            assert!(
157                namespace::uri_for(prefix).is_some(),
158                "prefix {prefix} should resolve to a URI"
159            );
160        }
161    }
162
163    // --- XPath 3.1 feature tests ---
164
165    #[test]
166    fn query_absolute_path_terms() {
167        // The combined document has <cmb:spec> as root, not <spec:clayers>.
168        let result = execute_query(
169            &spec_dir(),
170            "/cmb:spec/trm:term/trm:name",
171            QueryMode::Text,
172        )
173        .expect("absolute path query failed");
174        if let QueryResult::Text(texts) = result {
175            assert!(!texts.is_empty(), "should find term names via absolute path");
176        } else {
177            panic!("expected Text result");
178        }
179    }
180
181    #[test]
182    fn query_count_function() {
183        let result = execute_query(
184            &spec_dir(),
185            "count(//trm:term)",
186            QueryMode::Text,
187        )
188        .expect("count() query failed");
189        if let QueryResult::Text(texts) = result {
190            assert_eq!(texts.len(), 1, "count() should return one value");
191            let count: f64 = texts[0].parse().expect("count should be numeric");
192            assert!(count >= 15.0, "expected 15+ terms, got {count}");
193        } else {
194            panic!("expected Text result");
195        }
196    }
197
198    #[test]
199    fn query_starts_with_predicate() {
200        let result = execute_query(
201            &spec_dir(),
202            "//trm:term[starts-with(@id, 'term-')]",
203            QueryMode::Count,
204        )
205        .expect("starts-with query failed");
206        if let QueryResult::Count(count) = result {
207            assert!(count >= 15, "expected 15+ terms with 'term-' prefix, got {count}");
208        } else {
209            panic!("expected Count result");
210        }
211    }
212
213    #[test]
214    fn query_string_length_function() {
215        let result = execute_query(
216            &spec_dir(),
217            "string-length(//trm:term[@id='term-layer']/trm:name)",
218            QueryMode::Text,
219        )
220        .expect("string-length query failed");
221        if let QueryResult::Text(texts) = result {
222            assert_eq!(texts.len(), 1, "should return one value");
223            let len: f64 = texts[0].parse().expect("should be numeric");
224            assert!(len > 0.0, "term name should have non-zero length");
225        } else {
226            panic!("expected Text result");
227        }
228    }
229
230    #[test]
231    fn query_positional_predicate() {
232        let result = execute_query(
233            &spec_dir(),
234            "(//trm:term)[1]/trm:name",
235            QueryMode::Text,
236        )
237        .expect("positional predicate query failed");
238        if let QueryResult::Text(texts) = result {
239            assert_eq!(texts.len(), 1, "should return exactly 1 term name");
240        } else {
241            panic!("expected Text result");
242        }
243    }
244}