Skip to main content

omnigraph_server/
queries.rs

1//! Stored-query registry.
2//!
3//! A server-side registry of named, parameter-typed `.gq` queries that
4//! operators declare in `omnigraph.yaml` (per-graph, or top-level in
5//! single mode) and the server loads at startup. Each entry is parsed
6//! and its identity asserted here (`load`); type-checking against the
7//! live schema happens separately (a `check` pass) so the loader stays
8//! callable without an open engine (the CLI's offline `queries check`).
9//!
10//! Identity is the query **name**: the manifest key must equal the
11//! `query <name>` symbol declared in the referenced `.gq` file. The two
12//! are asserted equal at load — one name, two places that must agree.
13//! Renaming either is a breaking change to callers, by design.
14
15use std::collections::BTreeMap;
16use std::fs;
17use std::sync::Arc;
18
19use omnigraph_compiler::catalog::Catalog;
20use omnigraph_compiler::query::ast::QueryDecl;
21use omnigraph_compiler::query::parser::parse_query;
22use omnigraph_compiler::query::typecheck::typecheck_query_decl;
23use omnigraph_compiler::types::{PropType, ScalarType};
24
25use crate::config::{OmnigraphConfig, QueryEntry};
26
27/// One loaded stored query. `source` is the full `.gq` file text — the
28/// invocation handler hands it to `run_query` / `run_mutate` verbatim,
29/// which reuse the same parse/IR/exec path as the inline routes (no
30/// parallel implementation).
31#[derive(Debug, Clone)]
32pub struct StoredQuery {
33    /// Identity: manifest key == `query <name>` symbol.
34    pub name: String,
35    /// Full `.gq` source text the query was selected from.
36    pub source: Arc<str>,
37    /// Parsed declaration (params, mutations, description, …).
38    pub decl: QueryDecl,
39    /// Whether this query is listed in the MCP tool catalog (`GET /queries`).
40    /// Default `true` (the manifest entry is the opt-in); `expose: false`
41    /// keeps it HTTP/service-callable but hidden from the agent tool list.
42    /// Catalog membership only — not an authorization gate.
43    pub expose: bool,
44    /// Optional MCP tool-name override; defaults to `name`.
45    pub tool_name: Option<String>,
46}
47
48impl StoredQuery {
49    /// `true` if the selected declaration contains insert/update/delete
50    /// statements — drives read-vs-mutate routing at invocation time.
51    pub fn is_mutation(&self) -> bool {
52        !self.decl.mutations.is_empty()
53    }
54
55    /// The MCP tool name this query is catalogued under: the explicit
56    /// `tool_name` override, else the query `name`. The catalog key —
57    /// enforced unique across exposed queries at load. Server-side
58    /// consumers (the uniqueness check, the future catalog projection) read
59    /// this; the CLI `queries list` resolves the same rule on its own DTO.
60    pub fn effective_tool_name(&self) -> &str {
61        self.tool_name.as_deref().unwrap_or(&self.name)
62    }
63}
64
65/// A loaded, identity-checked stored-query registry for one graph.
66#[derive(Debug, Clone, Default)]
67pub struct QueryRegistry {
68    by_name: BTreeMap<String, StoredQuery>,
69}
70
71/// In-memory registry entry before file I/O. Used by [`QueryRegistry::load`]
72/// (after reading each `.gq` from disk) and directly by tests.
73#[derive(Debug, Clone)]
74pub struct RegistrySpec {
75    pub name: String,
76    pub source: String,
77    pub expose: bool,
78    pub tool_name: Option<String>,
79}
80
81/// A single registry load failure. Collected (not fail-fast) so a bad
82/// `omnigraph.yaml` surfaces every broken entry at once, matching the
83/// bad-policy-YAML posture.
84#[derive(Debug, Clone)]
85pub struct LoadError {
86    /// The offending query name, when the failure is entry-scoped.
87    pub query: Option<String>,
88    pub message: String,
89}
90
91impl std::fmt::Display for LoadError {
92    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
93        match &self.query {
94            Some(name) => write!(f, "stored query '{name}': {}", self.message),
95            None => write!(f, "stored query registry: {}", self.message),
96        }
97    }
98}
99
100impl QueryRegistry {
101    /// Build a registry from in-memory specs: parse each source, select
102    /// the declaration whose symbol equals the manifest key, and assert
103    /// they agree. Collects every failure. No schema type-checking here
104    /// — that is [`check`].
105    pub fn from_specs(specs: Vec<RegistrySpec>) -> Result<Self, Vec<LoadError>> {
106        let mut by_name = BTreeMap::new();
107        let mut errors = Vec::new();
108
109        for spec in specs {
110            match parse_query(&spec.source) {
111                Ok(file) => {
112                    match file.queries.into_iter().find(|q| q.name == spec.name) {
113                        Some(decl) => {
114                            by_name.insert(
115                                spec.name.clone(),
116                                StoredQuery {
117                                    name: spec.name,
118                                    source: Arc::from(spec.source),
119                                    decl,
120                                    expose: spec.expose,
121                                    tool_name: spec.tool_name,
122                                },
123                            );
124                        }
125                        None => errors.push(LoadError {
126                            query: Some(spec.name.clone()),
127                            message: format!(
128                                "no `query {}` declaration found in its `.gq` file \
129                                 (the registry key must match the query symbol)",
130                                spec.name
131                            ),
132                        }),
133                    }
134                }
135                Err(err) => errors.push(LoadError {
136                    query: Some(spec.name),
137                    message: format!("parse error: {err}"),
138                }),
139            }
140        }
141
142        // Exposed queries are catalogued under their effective tool name;
143        // two claiming one name is an MCP-namespace collision. Refuse it at
144        // load (collected, not fail-fast), naming the loser and the winner.
145        // Iterating the `BTreeMap` makes the winner deterministic (the
146        // lexicographically-first query name; config is a map, so YAML
147        // declaration order isn't preserved anyway) and the error order
148        // stable. Scoped to a block so these borrows of `by_name` end
149        // before it is moved into `Self`.
150        {
151            let mut claimed: BTreeMap<&str, &str> = BTreeMap::new();
152            for query in by_name.values().filter(|q| q.expose) {
153                let tool = query.effective_tool_name();
154                if let Some(winner) = claimed.insert(tool, &query.name) {
155                    errors.push(LoadError {
156                        query: Some(query.name.clone()),
157                        message: format!(
158                            "MCP tool name '{tool}' already claimed by exposed query '{winner}'"
159                        ),
160                    });
161                }
162            }
163        }
164
165        if errors.is_empty() {
166            Ok(Self { by_name })
167        } else {
168            Err(errors)
169        }
170    }
171
172    /// Read each registry entry's `.gq` file from disk and build the
173    /// registry. `entries` is either the top-level `queries` map (single
174    /// mode) or a graph's `queries` map (multi mode); `config` resolves
175    /// each entry's relative `file:` path against `base_dir`.
176    pub fn load(
177        config: &OmnigraphConfig,
178        entries: &BTreeMap<String, QueryEntry>,
179    ) -> Result<Self, Vec<LoadError>> {
180        let mut specs = Vec::with_capacity(entries.len());
181        let mut errors = Vec::new();
182        for (name, entry) in entries {
183            let path = config.resolve_query_file(&entry.file);
184            match fs::read_to_string(&path) {
185                Ok(source) => specs.push(RegistrySpec {
186                    name: name.clone(),
187                    source,
188                    expose: entry.mcp.expose,
189                    tool_name: entry.mcp.tool_name.clone(),
190                }),
191                Err(err) => errors.push(LoadError {
192                    query: Some(name.clone()),
193                    message: format!("cannot read '{}': {err}", path.display()),
194                }),
195            }
196        }
197
198        // Parse/identity/uniqueness-check the readable specs even when some
199        // files failed to read, so every broken entry (I/O, parse, identity,
200        // tool-name collision) surfaces in one pass rather than one per
201        // restart. I/O errors come first (in `entries` key order), then the
202        // spec errors. A non-empty `errors` always fails the load.
203        match Self::from_specs(specs) {
204            Ok(registry) if errors.is_empty() => Ok(registry),
205            Ok(_) => Err(errors),
206            Err(spec_errors) => {
207                errors.extend(spec_errors);
208                Err(errors)
209            }
210        }
211    }
212
213    pub fn lookup(&self, name: &str) -> Option<&StoredQuery> {
214        self.by_name.get(name)
215    }
216
217    pub fn iter(&self) -> impl Iterator<Item = &StoredQuery> {
218        self.by_name.values()
219    }
220
221    pub fn is_empty(&self) -> bool {
222        self.by_name.is_empty()
223    }
224
225    pub fn len(&self) -> usize {
226        self.by_name.len()
227    }
228}
229
230/// A stored query that fails to type-check against the live schema —
231/// e.g. it references a node/edge type or property that was renamed or
232/// removed by a migration. Breakages **block server boot** (same posture
233/// as bad policy YAML), surfacing schema drift at the deploy boundary
234/// rather than silently at invocation time.
235#[derive(Debug, Clone)]
236pub struct Breakage {
237    pub query: String,
238    pub message: String,
239}
240
241/// A non-blocking advisory found during validation. Logged at boot;
242/// never blocks startup. Currently: an MCP-exposed query that declares a
243/// parameter an agent cannot realistically supply.
244#[derive(Debug, Clone)]
245pub struct Warning {
246    pub query: String,
247    pub message: String,
248}
249
250/// Outcome of validating a registry against a schema. Breakages are
251/// fatal (boot refuses); warnings are advisory.
252#[derive(Debug, Clone, Default)]
253pub struct CheckReport {
254    pub breakages: Vec<Breakage>,
255    pub warnings: Vec<Warning>,
256}
257
258impl CheckReport {
259    pub fn has_breakages(&self) -> bool {
260        !self.breakages.is_empty()
261    }
262
263    pub fn is_clean(&self) -> bool {
264        self.breakages.is_empty() && self.warnings.is_empty()
265    }
266}
267
268/// Validate a loaded registry against the live schema.
269///
270/// Pure over `(registry, catalog)` — takes an already-parsed registry and
271/// a catalog, so it is callable both at server boot (with the engine's
272/// `catalog()`) and offline from the CLI (`omnigraph queries check`),
273/// without coupling to server config or an open engine connection.
274///
275/// Every query is type-checked via the same `typecheck_query_decl` the
276/// engine runs for inline queries — no parallel implementation. Failures
277/// are **collected, not fail-fast**, so an operator sees every broken
278/// query in one pass.
279///
280/// Advisory lint (warn, never block): an `mcp.expose: true` query that
281/// declares a `Vector(N)` parameter. An LLM cannot supply a raw embedding
282/// vector; such a query should take a `String` parameter and let the
283/// engine embed it server-side at query time. Service-to-service callers
284/// may legitimately pass vectors, so this warns rather than rejects.
285pub fn check(registry: &QueryRegistry, catalog: &Catalog) -> CheckReport {
286    let mut report = CheckReport::default();
287    for query in registry.iter() {
288        if let Err(err) = typecheck_query_decl(catalog, &query.decl) {
289            report.breakages.push(Breakage {
290                query: query.name.clone(),
291                message: err.to_string(),
292            });
293        }
294        if query.expose {
295            for param in &query.decl.params {
296                // Resolve to the structured type via the compiler's own
297                // resolver rather than string-matching `Vector(` — one
298                // canonical definition of "is a vector", so this lint can't
299                // drift from how the parser/type system spells the type.
300                let is_vector = PropType::from_param_type_name(&param.type_name, param.nullable)
301                    .is_some_and(|pt| matches!(pt.scalar, ScalarType::Vector(_)));
302                if is_vector {
303                    report.warnings.push(Warning {
304                        query: query.name.clone(),
305                        message: format!(
306                            "MCP-exposed query declares a `{}` parameter `${}` that agents \
307                             cannot supply; use a `String` parameter for server-side embedding",
308                            param.type_name, param.name
309                        ),
310                    });
311                }
312            }
313        }
314    }
315    report
316}
317
318/// Format every breakage in a registry check report into a multi-line
319/// operator-facing message, naming each offending query.
320pub fn format_check_breakages(label: &str, report: &CheckReport) -> String {
321    let joined = report
322        .breakages
323        .iter()
324        .map(|b| format!("query '{}': {}", b.query, b.message))
325        .collect::<Vec<_>>()
326        .join("\n  ");
327    format!(
328        "graph '{label}': {} stored quer{} failed the schema check:\n  {joined}",
329        report.breakages.len(),
330        if report.breakages.len() == 1 {
331            "y"
332        } else {
333            "ies"
334        }
335    )
336}
337
338#[cfg(test)]
339mod tests {
340    use super::*;
341
342    fn spec(name: &str, source: &str, expose: bool) -> RegistrySpec {
343        RegistrySpec {
344            name: name.to_string(),
345            source: source.to_string(),
346            expose,
347            tool_name: None,
348        }
349    }
350
351    fn spec_tool(name: &str, source: &str, expose: bool, tool_name: &str) -> RegistrySpec {
352        RegistrySpec {
353            name: name.to_string(),
354            source: source.to_string(),
355            expose,
356            tool_name: Some(tool_name.to_string()),
357        }
358    }
359
360    #[test]
361    fn key_equal_symbol_loads() {
362        let reg = QueryRegistry::from_specs(vec![spec(
363            "find_user",
364            "query find_user($id: String) { match { $u: User } return { $u.name } }",
365            true,
366        )])
367        .unwrap();
368        let q = reg.lookup("find_user").unwrap();
369        assert_eq!(q.name, "find_user");
370        assert!(q.expose);
371        assert_eq!(q.decl.params.len(), 1);
372        assert!(!q.is_mutation());
373        // No override → the effective tool name is the query name.
374        assert_eq!(q.effective_tool_name(), "find_user");
375
376        // An explicit override is what the catalog keys on.
377        let with_tool = QueryRegistry::from_specs(vec![spec_tool(
378            "find_user",
379            "query find_user($id: String) { match { $u: User } return { $u.name } }",
380            true,
381            "lookup_user",
382        )])
383        .unwrap();
384        assert_eq!(
385            with_tool.lookup("find_user").unwrap().effective_tool_name(),
386            "lookup_user"
387        );
388    }
389
390    #[test]
391    fn key_mismatch_is_an_identity_error() {
392        let errors = QueryRegistry::from_specs(vec![spec(
393            "find_user",
394            // symbol is `lookup`, key is `find_user` — must be rejected.
395            "query lookup($id: String) { match { $u: User } return { $u.name } }",
396            false,
397        )])
398        .unwrap_err();
399        assert_eq!(errors.len(), 1);
400        assert_eq!(errors[0].query.as_deref(), Some("find_user"));
401        assert!(errors[0].message.contains("must match the query symbol"));
402    }
403
404    #[test]
405    fn multi_query_file_selects_the_matching_symbol() {
406        let source = "query a($x: I64) { match { $u: User } return { $u.name } }\n\
407                      query b($y: String) { match { $u: User } return { $u.name } }";
408        let reg = QueryRegistry::from_specs(vec![spec("b", source, false)]).unwrap();
409        let q = reg.lookup("b").unwrap();
410        assert_eq!(q.name, "b");
411        assert_eq!(q.decl.params[0].name, "y");
412        assert!(reg.lookup("a").is_none(), "only the selected symbol is registered");
413    }
414
415    #[test]
416    fn duplicate_exposed_tool_name_is_a_load_error() {
417        // Two MCP-exposed queries claiming one tool name is an ambiguity in
418        // the catalog key space — refused at load, naming both queries and
419        // the contested tool.
420        let errors = QueryRegistry::from_specs(vec![
421            spec_tool("a", "query a() { match { $u: User } return { $u.name } }", true, "dup"),
422            spec_tool("b", "query b() { match { $u: User } return { $u.name } }", true, "dup"),
423        ])
424        .unwrap_err();
425        assert_eq!(errors.len(), 1);
426        let msg = errors[0].to_string();
427        assert!(msg.contains("'dup'"), "names the contested tool: {msg}");
428        assert!(msg.contains("'a'"), "names the winning query: {msg}");
429        assert!(msg.contains("'b'"), "names the losing query: {msg}");
430    }
431
432    #[test]
433    fn duplicate_tool_name_among_unexposed_is_allowed() {
434        // Unexposed queries have no MCP tool, so a shared effective tool
435        // name is inert — must not error (pins the exposed-only scope).
436        let reg = QueryRegistry::from_specs(vec![
437            spec_tool("a", "query a() { match { $u: User } return { $u.name } }", false, "dup"),
438            spec_tool("b", "query b() { match { $u: User } return { $u.name } }", false, "dup"),
439        ])
440        .unwrap();
441        assert_eq!(reg.len(), 2);
442    }
443
444    #[test]
445    fn parse_error_surfaces_per_entry() {
446        let errors =
447            QueryRegistry::from_specs(vec![spec("broken", "query broken( {{ not valid", false)])
448                .unwrap_err();
449        assert_eq!(errors[0].query.as_deref(), Some("broken"));
450        assert!(errors[0].message.contains("parse error"));
451    }
452
453    #[test]
454    fn errors_collect_rather_than_fail_fast() {
455        let errors = QueryRegistry::from_specs(vec![
456            spec("good", "query good() { match { $u: User } return { $u.name } }", false),
457            spec("mismatch", "query other() { match { $u: User } return { $u.name } }", false),
458            spec("broken", "query broken(", false),
459        ])
460        .unwrap_err();
461        // `good` loads cleanly; only the mismatch and the parse error are
462        // reported, and both surface in one pass (not fail-fast).
463        assert_eq!(errors.len(), 2);
464    }
465
466    #[test]
467    fn mutation_body_classifies_as_mutation() {
468        let reg = QueryRegistry::from_specs(vec![spec(
469            "add_user",
470            "query add_user($name: String) { insert User { name: $name } }",
471            false,
472        )])
473        .unwrap();
474        assert!(reg.lookup("add_user").unwrap().is_mutation());
475    }
476
477    // --- check(registry, catalog) ---
478
479    use omnigraph_compiler::catalog::build_catalog;
480    use omnigraph_compiler::schema::parser::parse_schema;
481
482    fn test_catalog() -> Catalog {
483        let schema = parse_schema(
484            r#"
485node User {
486name: String
487age: I32?
488embedding: Vector(4)
489}
490"#,
491        )
492        .unwrap();
493        build_catalog(&schema).unwrap()
494    }
495
496    #[test]
497    fn check_passes_for_valid_query() {
498        let reg = QueryRegistry::from_specs(vec![spec(
499            "find_user",
500            "query find_user($name: String) { match { $u: User { name: $name } } return { $u.age } }",
501            false,
502        )])
503        .unwrap();
504        let report = check(&reg, &test_catalog());
505        assert!(report.is_clean(), "unexpected: {:?}", report);
506    }
507
508    #[test]
509    fn check_reports_unknown_type_as_breakage() {
510        let reg = QueryRegistry::from_specs(vec![spec(
511            "ghost",
512            // `Widget` is not in the schema.
513            "query ghost() { match { $w: Widget } return { $w.name } }",
514            false,
515        )])
516        .unwrap();
517        let report = check(&reg, &test_catalog());
518        assert!(report.has_breakages());
519        assert_eq!(report.breakages[0].query, "ghost");
520    }
521
522    #[test]
523    fn check_reports_unknown_property_as_breakage() {
524        let reg = QueryRegistry::from_specs(vec![spec(
525            "bad_prop",
526            // `User` exists but has no `nickname`.
527            "query bad_prop() { match { $u: User } return { $u.nickname } }",
528            false,
529        )])
530        .unwrap();
531        let report = check(&reg, &test_catalog());
532        assert!(report.has_breakages());
533        assert_eq!(report.breakages[0].query, "bad_prop");
534    }
535
536    #[test]
537    fn check_collects_every_breakage_not_fail_fast() {
538        let reg = QueryRegistry::from_specs(vec![
539            spec("a", "query a() { match { $w: Widget } return { $w.x } }", false),
540            spec("b", "query b() { match { $g: Gadget } return { $g.y } }", false),
541            spec(
542                "ok",
543                "query ok() { match { $u: User } return { $u.name } }",
544                false,
545            ),
546        ])
547        .unwrap();
548        let report = check(&reg, &test_catalog());
549        assert_eq!(report.breakages.len(), 2, "both bad queries reported: {:?}", report);
550    }
551
552    #[test]
553    fn vector_param_on_exposed_query_warns() {
554        let reg = QueryRegistry::from_specs(vec![spec(
555            "vec_search",
556            "query vec_search($q: Vector(4)) { match { $u: User } return { $u.name } \
557             order { nearest($u.embedding, $q) } limit 3 }",
558            true, // mcp.expose
559        )])
560        .unwrap();
561        let report = check(&reg, &test_catalog());
562        assert!(!report.has_breakages(), "valid query: {:?}", report);
563        assert_eq!(report.warnings.len(), 1);
564        assert_eq!(report.warnings[0].query, "vec_search");
565    }
566
567    #[test]
568    fn vector_param_on_unexposed_query_is_silent() {
569        let reg = QueryRegistry::from_specs(vec![spec(
570            "vec_search",
571            "query vec_search($q: Vector(4)) { match { $u: User } return { $u.name } \
572             order { nearest($u.embedding, $q) } limit 3 }",
573            false, // not exposed — vector param is fine for service-to-service callers
574        )])
575        .unwrap();
576        let report = check(&reg, &test_catalog());
577        assert!(report.is_clean(), "unexpected: {:?}", report);
578    }
579
580    #[test]
581    fn non_vector_param_on_exposed_query_does_not_warn() {
582        // The recommended `String` alternative on an exposed query does not
583        // resolve to a Vector, so the embedding advisory stays silent. Guards
584        // the structured type check against a false positive (and pins that
585        // only `Vector(_)` triggers the warning).
586        let reg = QueryRegistry::from_specs(vec![spec(
587            "search",
588            "query search($name: String) { match { $u: User { name: $name } } return { $u.name } }",
589            true,
590        )])
591        .unwrap();
592        let report = check(&reg, &test_catalog());
593        assert!(report.is_clean(), "no breakage or warning expected: {:?}", report);
594    }
595
596    // --- catalog projection (api::query_catalog_entry) ---
597
598    #[test]
599    fn catalog_entry_projects_every_param_kind() {
600        use crate::api::{self, ParamKind};
601        let reg = QueryRegistry::from_specs(vec![spec_tool(
602            "all_types",
603            "query all_types($s: String, $i: I32, $big: I64, $u: U64, $f: F64, $b: Bool, \
604             $d: Date, $dt: DateTime, $blob: Blob, $opt: String?, $list: [I32], $vec: Vector(4)) \
605             { match { $x: User } return { $x.name } }",
606            true,
607            "all",
608        )])
609        .unwrap();
610        let entry = api::query_catalog_entry(reg.lookup("all_types").unwrap());
611        assert_eq!(entry.name, "all_types");
612        assert_eq!(entry.tool_name, "all");
613        assert!(!entry.mutation);
614
615        let by: std::collections::HashMap<_, _> =
616            entry.params.iter().map(|p| (p.name.as_str(), p)).collect();
617        assert_eq!(by["s"].kind, ParamKind::String);
618        assert_eq!(by["i"].kind, ParamKind::Int);
619        assert_eq!(by["big"].kind, ParamKind::BigInt, "I64 → bigint (string on the wire)");
620        assert_eq!(by["u"].kind, ParamKind::BigInt, "U64 → bigint");
621        assert_eq!(by["f"].kind, ParamKind::Float);
622        assert_eq!(by["b"].kind, ParamKind::Bool);
623        assert_eq!(by["d"].kind, ParamKind::Date);
624        assert_eq!(by["dt"].kind, ParamKind::DateTime);
625        assert_eq!(by["blob"].kind, ParamKind::Blob);
626        assert!(!by["s"].nullable);
627        assert!(by["opt"].nullable, "String? → nullable");
628        assert_eq!(by["list"].kind, ParamKind::List);
629        assert_eq!(by["list"].item_kind, Some(ParamKind::Int), "[I32] → list of int");
630        assert_eq!(by["vec"].kind, ParamKind::Vector);
631        assert_eq!(by["vec"].vector_dim, Some(4));
632    }
633
634    #[test]
635    fn catalog_entry_flags_mutation_and_empty_params() {
636        use crate::api;
637        let reg = QueryRegistry::from_specs(vec![spec(
638            "add_user",
639            "query add_user($name: String) { insert User { name: $name } }",
640            true,
641        )])
642        .unwrap();
643        let entry = api::query_catalog_entry(reg.lookup("add_user").unwrap());
644        assert!(entry.mutation, "insert body → mutation flag");
645
646        let reg2 = QueryRegistry::from_specs(vec![spec(
647            "no_params",
648            "query no_params() { match { $u: User } return { $u.name } }",
649            true,
650        )])
651        .unwrap();
652        let entry2 = api::query_catalog_entry(reg2.lookup("no_params").unwrap());
653        assert!(entry2.params.is_empty(), "no declared params → empty list");
654    }
655
656    // --- load() error collection (file I/O + parse in one pass) ---
657
658    #[test]
659    fn load_collects_io_and_parse_errors_in_one_pass() {
660        use crate::config::load_config;
661        let temp = tempfile::tempdir().unwrap();
662        std::fs::write(
663            temp.path().join("good.gq"),
664            "query good() { match { $u: User } return { $u.name } }",
665        )
666        .unwrap();
667        std::fs::write(temp.path().join("broken.gq"), "query broken( {{ not valid").unwrap();
668        // `missing.gq` is deliberately not written (an I/O failure).
669        std::fs::write(
670            temp.path().join("omnigraph.yaml"),
671            "queries:\n  good:\n    file: ./good.gq\n  \
672             missing:\n    file: ./missing.gq\n  broken:\n    file: ./broken.gq\n",
673        )
674        .unwrap();
675        let config = load_config(Some(&temp.path().join("omnigraph.yaml"))).unwrap();
676
677        let errors = QueryRegistry::load(&config, config.query_entries()).unwrap_err();
678        let joined = errors.iter().map(|e| e.to_string()).collect::<Vec<_>>().join("\n");
679        // Both the missing file AND the parse error surface in one pass —
680        // the I/O failure must not mask the parse failure.
681        assert!(joined.contains("missing"), "I/O error must surface: {joined}");
682        assert!(
683            joined.contains("broken") && joined.contains("parse error"),
684            "the parse error in a readable file must surface in the same pass: {joined}"
685        );
686        assert!(!joined.contains("'good'"), "the valid entry is not an error: {joined}");
687    }
688}