Skip to main content

omnigraph_server/
config.rs

1use std::collections::BTreeMap;
2use std::env;
3use std::fs;
4use std::path::{Path, PathBuf};
5
6use clap::ValueEnum;
7use color_eyre::eyre::{Result, bail};
8use serde::{Deserialize, Serialize};
9
10pub const DEFAULT_CONFIG_FILE: &str = "omnigraph.yaml";
11
12pub fn graph_resource_id_for_selection(
13    selected_graph: Option<&str>,
14    normalized_uri: &str,
15) -> String {
16    selected_graph.unwrap_or(normalized_uri).to_string()
17}
18
19#[derive(Debug, Clone, Default, Serialize, Deserialize)]
20pub struct ProjectConfig {
21    pub name: Option<String>,
22}
23
24#[derive(Debug, Clone, Serialize, Deserialize)]
25pub struct TargetConfig {
26    pub uri: String,
27    pub bearer_token_env: Option<String>,
28    /// Per-graph Cedar policy file (MR-668). In single-graph mode this
29    /// field is unused — the top-level `policy.file` applies. In
30    /// multi-graph mode, each `graphs.<id>.policy.file` governs that
31    /// graph's HTTP-layer Cedar enforcement.
32    #[serde(default)]
33    pub policy: PolicySettings,
34    /// Per-graph stored-query registry: an inline `name -> entry`
35    /// map. Mirrors the per-graph `policy` shape — each
36    /// `graphs.<id>.queries` declares that graph's stored queries. Absent
37    /// (or empty) = no stored queries for the graph. v1 is inline-only;
38    /// an external `queries.yaml` manifest indirection is a deferred
39    /// convenience.
40    #[serde(default)]
41    pub queries: BTreeMap<String, QueryEntry>,
42}
43
44#[derive(Debug, Clone, Copy, Default, Eq, PartialEq, Serialize, Deserialize, ValueEnum)]
45#[serde(rename_all = "snake_case")]
46pub enum ReadOutputFormat {
47    #[default]
48    Table,
49    Kv,
50    Csv,
51    Jsonl,
52    Json,
53}
54
55#[derive(Debug, Clone, Copy, Default, Eq, PartialEq, Serialize, Deserialize, ValueEnum)]
56#[serde(rename_all = "snake_case")]
57pub enum TableCellLayout {
58    #[default]
59    Truncate,
60    Wrap,
61}
62
63#[derive(Debug, Clone, Default, Serialize, Deserialize)]
64pub struct CliDefaults {
65    #[serde(rename = "graph")]
66    pub graph: Option<String>,
67    pub branch: Option<String>,
68    pub output_format: Option<ReadOutputFormat>,
69    pub table_max_column_width: Option<usize>,
70    pub table_cell_layout: Option<TableCellLayout>,
71    /// Default actor identity for CLI direct-engine writes (MR-722).
72    /// Used when `policy.file` is configured and the operator hasn't
73    /// passed `--as <actor>` on the command line. With policy configured
74    /// and neither this nor `--as` set, the engine-layer footgun guard
75    /// fires (no silent bypass).
76    pub actor: Option<String>,
77}
78
79#[derive(Debug, Clone, Default, Serialize, Deserialize)]
80pub struct ServerDefaults {
81    #[serde(rename = "graph")]
82    pub graph: Option<String>,
83    pub bind: Option<String>,
84    /// Server-level Cedar policy (MR-668). Governs management endpoints
85    /// — currently `GET /graphs`; future runtime add/remove endpoints
86    /// will plug in here too. In single-graph mode this is unused — the
87    /// top-level `policy.file` covers the single graph.
88    #[serde(default)]
89    pub policy: PolicySettings,
90}
91
92#[derive(Debug, Clone, Default, Serialize, Deserialize)]
93pub struct AuthDefaults {
94    pub env_file: Option<String>,
95}
96
97#[derive(Debug, Clone, Default, Serialize, Deserialize)]
98pub struct QueryDefaults {
99    #[serde(default)]
100    pub roots: Vec<String>,
101}
102
103#[derive(Debug, Clone, Default, Serialize, Deserialize)]
104pub struct PolicySettings {
105    pub file: Option<String>,
106}
107
108/// One stored-query registry entry. The map **key** is the query's
109/// identity — it must equal the `query <name>` symbol declared inside
110/// the referenced `.gq` file (asserted when the registry loads).
111/// Renaming the key (or the symbol) is a breaking change to callers, by
112/// design.
113#[derive(Debug, Clone, Serialize, Deserialize)]
114pub struct QueryEntry {
115    /// Path to the `.gq` file (relative to the config's `base_dir`). The
116    /// file may declare several queries; the registry selects the one
117    /// whose symbol matches the map key.
118    pub file: String,
119    #[serde(default)]
120    pub mcp: McpSettings,
121}
122
123/// MCP exposure for a stored query. A *deployment* concern (the same
124/// `.gq` may be exposed in one graph and hidden in another), so it lives
125/// in YAML rather than in the `.gq` source. **Default `expose: true`** —
126/// declaring a query in the manifest *is* the opt-in, so it appears in the
127/// MCP tool catalog (`GET /queries`) by default; set `expose: false` to
128/// keep a query HTTP/service-callable but hidden from the agent tool list.
129/// `expose` governs catalog membership only — it is **not** an
130/// authorization gate (invocation is gated by `invoke_query`), so a hidden
131/// query is still invocable by name with the right permission.
132#[derive(Debug, Clone, Serialize, Deserialize)]
133pub struct McpSettings {
134    #[serde(default = "mcp_expose_default")]
135    pub expose: bool,
136    pub tool_name: Option<String>,
137}
138
139fn mcp_expose_default() -> bool {
140    true
141}
142
143impl Default for McpSettings {
144    fn default() -> Self {
145        Self {
146            expose: mcp_expose_default(),
147            tool_name: None,
148        }
149    }
150}
151
152#[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize, Deserialize)]
153#[serde(rename_all = "snake_case")]
154pub enum AliasCommand {
155    /// Read alias (canonical: `query`). The legacy spelling `read` is
156    /// kept as the variant name for back-compat with serialized configs
157    /// and external SDK callers; `query` is accepted on the wire via the
158    /// serde alias.
159    #[serde(alias = "query")]
160    Read,
161    /// Mutation alias (canonical: `mutate`). The legacy spelling `change`
162    /// is kept as the variant name for back-compat; `mutate` is accepted
163    /// on the wire via the serde alias.
164    #[serde(alias = "mutate")]
165    Change,
166}
167
168#[derive(Debug, Clone, Serialize, Deserialize)]
169pub struct AliasConfig {
170    pub command: AliasCommand,
171    pub query: String,
172    pub name: Option<String>,
173    #[serde(default)]
174    pub args: Vec<String>,
175    #[serde(rename = "graph")]
176    pub graph: Option<String>,
177    pub branch: Option<String>,
178    pub format: Option<ReadOutputFormat>,
179}
180
181#[derive(Debug, Clone, Serialize, Deserialize)]
182pub struct OmnigraphConfig {
183    #[serde(default)]
184    pub project: ProjectConfig,
185    #[serde(default, rename = "graphs")]
186    pub graphs: BTreeMap<String, TargetConfig>,
187    #[serde(default)]
188    pub server: ServerDefaults,
189    #[serde(default)]
190    pub auth: AuthDefaults,
191    #[serde(default)]
192    pub cli: CliDefaults,
193    #[serde(default)]
194    pub query: QueryDefaults,
195    #[serde(default)]
196    pub aliases: BTreeMap<String, AliasConfig>,
197    #[serde(default)]
198    pub policy: PolicySettings,
199    /// Top-level stored-query registry, used in single-graph
200    /// mode — mirrors how the top-level `policy` applies to the single
201    /// graph. In multi-graph mode this is unused; each graph's
202    /// `graphs.<id>.queries` applies instead.
203    #[serde(default)]
204    pub queries: BTreeMap<String, QueryEntry>,
205    #[serde(skip)]
206    base_dir: PathBuf,
207}
208
209impl Default for OmnigraphConfig {
210    fn default() -> Self {
211        Self {
212            project: ProjectConfig::default(),
213            graphs: BTreeMap::new(),
214            server: ServerDefaults::default(),
215            auth: AuthDefaults::default(),
216            cli: CliDefaults::default(),
217            query: QueryDefaults::default(),
218            aliases: BTreeMap::new(),
219            policy: PolicySettings::default(),
220            queries: BTreeMap::new(),
221            base_dir: PathBuf::new(),
222        }
223    }
224}
225
226impl OmnigraphConfig {
227    pub fn base_dir(&self) -> &Path {
228        &self.base_dir
229    }
230
231    pub fn cli_branch(&self) -> &str {
232        self.cli.branch.as_deref().unwrap_or("main")
233    }
234
235    pub fn cli_output_format(&self) -> ReadOutputFormat {
236        self.cli.output_format.unwrap_or_default()
237    }
238
239    pub fn table_max_column_width(&self) -> usize {
240        self.cli.table_max_column_width.unwrap_or(80)
241    }
242
243    pub fn table_cell_layout(&self) -> TableCellLayout {
244        self.cli.table_cell_layout.unwrap_or_default()
245    }
246
247    pub fn cli_graph_name(&self) -> Option<&str> {
248        self.cli.graph.as_deref()
249    }
250
251    pub fn server_graph_name(&self) -> Option<&str> {
252        self.server.graph.as_deref()
253    }
254
255    pub fn server_bind(&self) -> &str {
256        self.server.bind.as_deref().unwrap_or("127.0.0.1:8080")
257    }
258
259    pub fn resolve_target_name<'a>(
260        &self,
261        explicit_uri: Option<&str>,
262        explicit_target: Option<&'a str>,
263        default_target: Option<&'a str>,
264    ) -> Option<&'a str> {
265        explicit_target.or_else(|| {
266            if explicit_uri.is_some() {
267                None
268            } else {
269                default_target
270            }
271        })
272    }
273
274    pub fn graph_bearer_token_env(
275        &self,
276        explicit_uri: Option<&str>,
277        explicit_target: Option<&str>,
278        default_target: Option<&str>,
279    ) -> Option<&str> {
280        let target_name =
281            self.resolve_target_name(explicit_uri, explicit_target, default_target)?;
282        self.graphs
283            .get(target_name)
284            .and_then(|target| target.bearer_token_env.as_deref())
285    }
286
287    pub fn resolve_auth_env_file(&self) -> Option<PathBuf> {
288        self.auth
289            .env_file
290            .as_deref()
291            .map(|path| self.resolve_config_path(path))
292    }
293
294    pub fn resolve_policy_file(&self) -> Option<PathBuf> {
295        self.policy
296            .file
297            .as_deref()
298            .map(|path| self.resolve_config_path(path))
299    }
300
301    /// Resolve the per-graph policy file path for the named target,
302    /// relative to the config file's `base_dir`. Returns `None` if the
303    /// target is unknown or no per-graph `policy.file` is set.
304    pub fn resolve_target_policy_file(&self, target_name: &str) -> Option<PathBuf> {
305        let target = self.graphs.get(target_name)?;
306        target
307            .policy
308            .file
309            .as_deref()
310            .map(|path| self.resolve_config_path(path))
311    }
312
313    /// The top-level stored-query registry entries (single-graph mode).
314    pub fn query_entries(&self) -> &BTreeMap<String, QueryEntry> {
315        &self.queries
316    }
317
318    /// The per-graph stored-query registry entries for a named target
319    /// (multi-graph mode). Returns `None` if the target is unknown.
320    pub fn target_query_entries(
321        &self,
322        target_name: &str,
323    ) -> Option<&BTreeMap<String, QueryEntry>> {
324        self.graphs.get(target_name).map(|target| &target.queries)
325    }
326
327    /// The stored-query registry entries that apply for a graph
328    /// selection — the single definition of "which `queries:` block
329    /// governs graph X", shared by server boot and the CLI so the two
330    /// can't drift. A named graph present in `graphs:` uses its
331    /// per-graph block; everything else (no selection, or a name that is
332    /// not a known graph, e.g. a bare URI) falls back to the top-level
333    /// block (single-graph mode).
334    pub fn query_entries_for(&self, graph: Option<&str>) -> &BTreeMap<String, QueryEntry> {
335        match graph {
336            Some(name) if self.graphs.contains_key(name) => &self.graphs[name].queries,
337            _ => &self.queries,
338        }
339    }
340
341    /// The single CLI gate that turns a raw graph selection into a *validated*
342    /// one — the fallible counterpart to the infallible
343    /// [`OmnigraphConfig::query_entries_for`]. Both `queries` subcommands route
344    /// their selection through here so neither can skip a check the other (or
345    /// server boot) applies:
346    /// * a known name passes through, but only after the same coherence check
347    ///   server boot enforces
348    ///   ([`OmnigraphConfig::ensure_top_level_blocks_honored`]) — a named graph
349    ///   with a populated top-level block is rejected;
350    /// * an unknown name errors with the **same** message
351    ///   [`OmnigraphConfig::resolve_target_uri`] produces, so a command that
352    ///   opens no URI rejects an unknown `--target` exactly like the
353    ///   URI-resolving commands do;
354    /// * an anonymous selection (`None`, e.g. a bare URI) stays anonymous,
355    ///   resolving to the top-level registry downstream (top-level honored).
356    pub fn resolve_graph_selection<'a>(&self, graph: Option<&'a str>) -> Result<Option<&'a str>> {
357        match graph {
358            Some(name) if self.graphs.contains_key(name) => {
359                self.ensure_top_level_blocks_honored(Some(name))?;
360                Ok(Some(name))
361            }
362            Some(name) => bail!("graph '{}' not found in {}", name, DEFAULT_CONFIG_FILE),
363            None => Ok(None),
364        }
365    }
366
367    pub fn resolve_policy_tooling_graph_selection(&self) -> Result<Option<&str>> {
368        self.resolve_graph_selection(self.cli_graph_name().or_else(|| self.server_graph_name()))
369    }
370
371    /// The policy file that applies for a graph selection — the policy
372    /// sibling of [`OmnigraphConfig::query_entries_for`], so policy and
373    /// queries resolve by the same identity rule. A named graph in
374    /// `graphs:` uses its per-graph `policy.file` with **no** top-level
375    /// fallback (a named graph with no per-graph policy has no policy —
376    /// that keeps the boot-time coherence check meaningful); anything else
377    /// (no selection, or a bare URI) uses the top-level `policy.file`.
378    pub fn resolve_policy_file_for(&self, graph: Option<&str>) -> Option<PathBuf> {
379        match graph {
380            Some(name) if self.graphs.contains_key(name) => self.resolve_target_policy_file(name),
381            _ => self.resolve_policy_file(),
382        }
383    }
384
385    /// Names of any top-level config blocks (`policy.file`, `queries:`)
386    /// that are populated. Used by the boot-time coherence check: when a
387    /// **named** graph is served (single-mode by name, or multi-mode),
388    /// the top-level blocks are not honored, so a populated one is a
389    /// configuration error rather than a silent no-op.
390    pub fn populated_top_level_blocks(&self) -> Vec<&'static str> {
391        let mut blocks = Vec::new();
392        if self.policy.file.is_some() {
393            blocks.push("policy.file");
394        }
395        if !self.queries.is_empty() {
396            blocks.push("queries");
397        }
398        blocks
399    }
400
401    /// A named graph uses its own `graphs.<name>` block, so a populated
402    /// top-level block would be silently ignored — a config error. The single
403    /// definition of that rule, shared by server boot and the CLI selection
404    /// gate ([`OmnigraphConfig::resolve_graph_selection`]) so the two can't
405    /// drift. An anonymous selection (`None`, e.g. a bare URI) legitimately
406    /// honors the top-level blocks, so it is never rejected here.
407    pub fn ensure_top_level_blocks_honored(&self, selected: Option<&str>) -> Result<()> {
408        if let Some(name) = selected {
409            let unhonored = self.populated_top_level_blocks();
410            if !unhonored.is_empty() {
411                bail!(
412                    "named graph '{name}' uses its own `graphs.{name}.…` block, but top-level {} \
413                     {} set and would be ignored. Move it to `graphs.{name}` (e.g. \
414                     `graphs.{name}.policy.file`, `graphs.{name}.queries`).",
415                    unhonored.join(" and "),
416                    if unhonored.len() == 1 { "is" } else { "are" },
417                );
418            }
419        }
420        Ok(())
421    }
422
423    /// Resolve a stored-query `.gq` file path (from a registry entry),
424    /// relative to the config's `base_dir`. Mirrors policy-file
425    /// resolution; the registry loader calls this to turn each entry's
426    /// `file:` value into an absolute path.
427    pub fn resolve_query_file(&self, value: &str) -> PathBuf {
428        self.resolve_config_path(value)
429    }
430
431    /// Resolve the server-level policy file path (used by management
432    /// endpoints). Returns `None` if `server.policy.file` is not set.
433    pub fn resolve_server_policy_file(&self) -> Option<PathBuf> {
434        self.server
435            .policy
436            .file
437            .as_deref()
438            .map(|path| self.resolve_config_path(path))
439    }
440
441    /// Resolve a raw config-supplied URI (which may be relative) to its
442    /// absolute form. URIs containing `://` are passed through as-is;
443    /// relative paths are joined with the config file's `base_dir`.
444    pub fn resolve_uri_value(&self, value: &str) -> String {
445        self.resolve_config_uri(value)
446    }
447
448    pub fn resolve_policy_tests_file(&self) -> Option<PathBuf> {
449        let policy_file = self.resolve_policy_file()?;
450        Some(policy_file.with_file_name("policy.tests.yaml"))
451    }
452
453    pub fn alias(&self, name: &str) -> Result<&AliasConfig> {
454        self.aliases
455            .get(name)
456            .ok_or_else(|| color_eyre::eyre::eyre!("alias '{}' not found", name))
457    }
458
459    pub fn resolve_target_uri(
460        &self,
461        explicit_uri: Option<String>,
462        explicit_target: Option<&str>,
463        default_target: Option<&str>,
464    ) -> Result<String> {
465        if let Some(uri) = explicit_uri {
466            return Ok(uri);
467        }
468
469        let target_name = explicit_target.or(default_target).ok_or_else(|| {
470            color_eyre::eyre::eyre!("URI must be provided via <URI>, --target, or config")
471        })?;
472        let target = self.graphs.get(target_name).ok_or_else(|| {
473            color_eyre::eyre::eyre!(
474                "graph '{}' not found in {}",
475                target_name,
476                DEFAULT_CONFIG_FILE
477            )
478        })?;
479        Ok(self.resolve_config_uri(&target.uri))
480    }
481
482    pub fn resolve_query_path(&self, query: &Path) -> Result<PathBuf> {
483        if query.is_absolute() {
484            return Ok(query.to_path_buf());
485        }
486
487        let direct = self.base_dir.join(query);
488        if direct.exists() {
489            return Ok(direct);
490        }
491
492        for root in &self.query.roots {
493            let candidate = self.base_dir.join(root).join(query);
494            if candidate.exists() {
495                return Ok(candidate);
496            }
497        }
498
499        bail!("query file '{}' not found", query.display());
500    }
501
502    fn resolve_config_uri(&self, value: &str) -> String {
503        if value.contains("://") {
504            return value.to_string();
505        }
506
507        let path = Path::new(value);
508        if path.is_absolute() {
509            value.to_string()
510        } else {
511            self.base_dir.join(path).to_string_lossy().to_string()
512        }
513    }
514
515    fn resolve_config_path(&self, value: &str) -> PathBuf {
516        let path = Path::new(value);
517        if path.is_absolute() {
518            path.to_path_buf()
519        } else {
520            self.base_dir.join(path)
521        }
522    }
523}
524
525pub fn default_config_path() -> PathBuf {
526    PathBuf::from(DEFAULT_CONFIG_FILE)
527}
528
529pub fn load_config(config_path: Option<&PathBuf>) -> Result<OmnigraphConfig> {
530    load_config_in(&env::current_dir()?, config_path)
531}
532
533fn load_config_in(cwd: &Path, config_path: Option<&PathBuf>) -> Result<OmnigraphConfig> {
534    let explicit_path = config_path.cloned();
535    let config_path = explicit_path.or_else(|| {
536        let default_path = cwd.join(DEFAULT_CONFIG_FILE);
537        default_path.exists().then_some(default_path)
538    });
539
540    let mut config = if let Some(path) = &config_path {
541        serde_yaml::from_str::<OmnigraphConfig>(&fs::read_to_string(path)?)?
542    } else {
543        OmnigraphConfig::default()
544    };
545
546    config.base_dir = if let Some(path) = config_path {
547        absolute_base_dir(cwd, &path)?
548    } else {
549        cwd.to_path_buf()
550    };
551
552    Ok(config)
553}
554
555fn absolute_base_dir(cwd: &Path, path: &Path) -> Result<PathBuf> {
556    let path = if path.is_absolute() {
557        path.to_path_buf()
558    } else {
559        cwd.join(path)
560    };
561    Ok(path
562        .parent()
563        .map(Path::to_path_buf)
564        .unwrap_or_else(|| cwd.to_path_buf()))
565}
566
567#[cfg(test)]
568mod tests {
569    use std::fs;
570    use std::path::{Path, PathBuf};
571
572    use tempfile::tempdir;
573
574    use super::{
575        ReadOutputFormat, TableCellLayout, graph_resource_id_for_selection, load_config_in,
576    };
577
578    #[test]
579    fn load_config_reads_yaml_defaults_from_current_dir() {
580        let temp = tempdir().unwrap();
581        fs::write(
582            temp.path().join("omnigraph.yaml"),
583            r#"
584graphs:
585  local:
586    uri: ./demo.omni
587    bearer_token_env: DEMO_TOKEN
588auth:
589  env_file: .env.omni
590cli:
591  graph: local
592  branch: main
593  output_format: kv
594  table_max_column_width: 40
595  table_cell_layout: wrap
596policy: {}
597"#,
598        )
599        .unwrap();
600
601        let config = load_config_in(temp.path(), None).unwrap();
602        assert_eq!(config.cli_graph_name(), Some("local"));
603        assert_eq!(config.cli_branch(), "main");
604        assert_eq!(config.cli_output_format(), ReadOutputFormat::Kv);
605        assert_eq!(config.table_max_column_width(), 40);
606        assert_eq!(config.table_cell_layout(), TableCellLayout::Wrap);
607        assert_eq!(
608            config.graph_bearer_token_env(None, None, config.cli_graph_name()),
609            Some("DEMO_TOKEN")
610        );
611        assert_eq!(
612            config.resolve_auth_env_file().unwrap(),
613            temp.path().join(".env.omni")
614        );
615        assert_eq!(
616            PathBuf::from(
617                config
618                    .resolve_target_uri(None, None, config.cli_graph_name())
619                    .unwrap()
620            ),
621            temp.path().join("./demo.omni")
622        );
623    }
624
625    #[test]
626    fn load_config_does_not_walk_parent_directories() {
627        let temp = tempdir().unwrap();
628        let child = temp.path().join("child");
629        fs::create_dir_all(&child).unwrap();
630        fs::write(
631            temp.path().join("omnigraph.yaml"),
632            "graphs:\n  local:\n    uri: ./demo.omni\n",
633        )
634        .unwrap();
635
636        let config = load_config_in(&child, None).unwrap();
637        assert!(config.graphs.is_empty());
638    }
639
640    #[test]
641    fn graph_resource_id_for_selection_uses_name_or_anonymous_uri() {
642        assert_eq!(
643            graph_resource_id_for_selection(Some("local"), "/tmp/graph.omni"),
644            "local"
645        );
646        assert_eq!(
647            graph_resource_id_for_selection(None, "/tmp/graph.omni"),
648            "/tmp/graph.omni"
649        );
650    }
651
652    #[test]
653    fn resolve_graph_selection_validates_membership_and_coherence() {
654        let temp = tempdir().unwrap();
655        fs::write(
656            temp.path().join("omnigraph.yaml"),
657            "graphs:\n  local:\n    uri: ./demo.omni\n",
658        )
659        .unwrap();
660        let config = load_config_in(temp.path(), None).unwrap();
661
662        // A known graph passes through unchanged.
663        assert_eq!(config.resolve_graph_selection(Some("local")).unwrap(), Some("local"));
664        // An anonymous selection stays anonymous (→ top-level registry downstream).
665        assert_eq!(config.resolve_graph_selection(None).unwrap(), None);
666        // An unknown name errors, naming the graph (matching resolve_target_uri).
667        let err = config.resolve_graph_selection(Some("ghost")).unwrap_err().to_string();
668        assert!(
669            err.contains("ghost") && err.contains("not found"),
670            "unknown graph must error naming it: {err}"
671        );
672
673        // Coherence: a named graph plus a populated top-level block is the
674        // config server boot refuses, so the gate rejects it too (shared rule
675        // via ensure_top_level_blocks_honored). An anonymous selection still
676        // passes — top-level is honored when no graph is named.
677        let temp2 = tempdir().unwrap();
678        fs::write(
679            temp2.path().join("omnigraph.yaml"),
680            "graphs:\n  local:\n    uri: ./demo.omni\npolicy:\n  file: ./top.yaml\n",
681        )
682        .unwrap();
683        let incoherent = load_config_in(temp2.path(), None).unwrap();
684        let err = incoherent
685            .resolve_graph_selection(Some("local"))
686            .unwrap_err()
687            .to_string();
688        assert!(
689            err.contains("local") && err.contains("policy.file"),
690            "named graph + populated top-level block must be rejected, naming both: {err}"
691        );
692        assert_eq!(
693            incoherent.resolve_graph_selection(None).unwrap(),
694            None,
695            "anonymous selection still honors top-level"
696        );
697    }
698
699    #[test]
700    fn policy_tooling_graph_selection_prefers_cli_then_server_and_validates() {
701        let temp = tempdir().unwrap();
702        fs::write(
703            temp.path().join("omnigraph.yaml"),
704            "graphs:\n  local:\n    uri: ./local.omni\n  prod:\n    uri: ./prod.omni\n\
705             server:\n  graph: local\ncli:\n  graph: prod\n",
706        )
707        .unwrap();
708        let config = load_config_in(temp.path(), None).unwrap();
709        assert_eq!(
710            config.resolve_policy_tooling_graph_selection().unwrap(),
711            Some("prod")
712        );
713
714        let temp = tempdir().unwrap();
715        fs::write(
716            temp.path().join("omnigraph.yaml"),
717            "graphs:\n  local:\n    uri: ./local.omni\nserver:\n  graph: local\n",
718        )
719        .unwrap();
720        let config = load_config_in(temp.path(), None).unwrap();
721        assert_eq!(
722            config.resolve_policy_tooling_graph_selection().unwrap(),
723            Some("local")
724        );
725
726        let temp = tempdir().unwrap();
727        fs::write(temp.path().join("omnigraph.yaml"), "policy: {}\n").unwrap();
728        let config = load_config_in(temp.path(), None).unwrap();
729        assert_eq!(config.resolve_policy_tooling_graph_selection().unwrap(), None);
730
731        let temp = tempdir().unwrap();
732        fs::write(
733            temp.path().join("omnigraph.yaml"),
734            "graphs:\n  local:\n    uri: ./local.omni\nserver:\n  graph: ghost\n",
735        )
736        .unwrap();
737        let config = load_config_in(temp.path(), None).unwrap();
738        let err = config
739            .resolve_policy_tooling_graph_selection()
740            .unwrap_err()
741            .to_string();
742        assert!(
743            err.contains("ghost") && err.contains("not found"),
744            "unknown server.graph must use graph-selection validation: {err}"
745        );
746    }
747
748    #[test]
749    fn resolve_query_path_searches_config_roots() {
750        let temp = tempdir().unwrap();
751        fs::create_dir_all(temp.path().join("queries")).unwrap();
752        fs::write(
753            temp.path().join("omnigraph.yaml"),
754            "query:\n  roots:\n    - queries\npolicy: {}\n",
755        )
756        .unwrap();
757        fs::write(
758            temp.path().join("queries").join("test.gq"),
759            "query q { return {} }",
760        )
761        .unwrap();
762
763        let config = load_config_in(temp.path(), None).unwrap();
764        let resolved = config.resolve_query_path(Path::new("test.gq")).unwrap();
765        assert_eq!(resolved, temp.path().join("queries").join("test.gq"));
766    }
767
768    #[test]
769    fn resolve_query_path_prefers_config_base_dir_over_ambient_cwd() {
770        let workspace = tempdir().unwrap();
771        let config_dir = workspace.path().join("config");
772        let ambient_dir = workspace.path().join("ambient");
773        fs::create_dir_all(&config_dir).unwrap();
774        fs::create_dir_all(&ambient_dir).unwrap();
775        fs::write(config_dir.join("omnigraph.yaml"), "policy: {}\n").unwrap();
776        fs::write(config_dir.join("local.gq"), "query local { return {} }").unwrap();
777        fs::write(ambient_dir.join("local.gq"), "query ambient { return {} }").unwrap();
778
779        let config =
780            load_config_in(&ambient_dir, Some(&config_dir.join("omnigraph.yaml"))).unwrap();
781        let resolved = config.resolve_query_path(Path::new("local.gq")).unwrap();
782
783        assert_eq!(resolved, config_dir.join("local.gq"));
784    }
785
786    #[test]
787    fn queries_block_round_trips_inline_and_per_graph() {
788        let temp = tempdir().unwrap();
789        fs::write(
790            temp.path().join("omnigraph.yaml"),
791            r#"
792graphs:
793  prod:
794    uri: s3://bucket/prod
795    queries:
796      find_user:
797        file: ./queries/find_user.gq
798        mcp:
799          expose: true
800          tool_name: lookup_user
801      internal_audit:
802        file: ./queries/audit.gq
803queries:
804  single_mode_q:
805    file: ./q.gq
806"#,
807        )
808        .unwrap();
809
810        let config = load_config_in(temp.path(), None).unwrap();
811
812        // Per-graph registry (multi-graph mode).
813        let prod = config.target_query_entries("prod").unwrap();
814        assert_eq!(prod.len(), 2);
815        let find_user = &prod["find_user"];
816        assert_eq!(find_user.file, "./queries/find_user.gq");
817        assert!(find_user.mcp.expose);
818        assert_eq!(find_user.mcp.tool_name.as_deref(), Some("lookup_user"));
819        // Default exposure is true (the manifest entry is the opt-in); tool_name absent.
820        let audit = &prod["internal_audit"];
821        assert!(audit.mcp.expose);
822        assert!(audit.mcp.tool_name.is_none());
823
824        // Top-level registry (single-graph mode).
825        assert_eq!(config.query_entries().len(), 1);
826
827        // The shared selector resolves the same blocks the server boot
828        // and the CLI use: a known graph → its per-graph block; no
829        // selection or an unknown name → the top-level block (the latter
830        // pins the behavior of the CLI's now-deleted fallback arm).
831        assert_eq!(config.query_entries_for(Some("prod")).len(), 2);
832        assert_eq!(config.query_entries_for(None).len(), 1);
833        assert_eq!(config.query_entries_for(Some("nonexistent")).len(), 1);
834
835        // Path resolution joins against base_dir, like policy files.
836        assert_eq!(
837            config.resolve_query_file(&find_user.file),
838            temp.path().join("./queries/find_user.gq")
839        );
840    }
841
842    #[test]
843    fn resolve_policy_file_for_follows_identity() {
844        let temp = tempdir().unwrap();
845        fs::write(
846            temp.path().join("omnigraph.yaml"),
847            "policy:\n  file: ./top.yaml\ngraphs:\n  prod:\n    uri: s3://b/prod\n    \
848             policy:\n      file: ./prod.yaml\n  bare:\n    uri: s3://b/bare\n",
849        )
850        .unwrap();
851        let config = load_config_in(temp.path(), None).unwrap();
852
853        // Named graph with its own policy → per-graph (not top-level).
854        assert!(
855            config
856                .resolve_policy_file_for(Some("prod"))
857                .unwrap()
858                .ends_with("prod.yaml")
859        );
860        // Named graph with NO per-graph policy → None (no top-level fallback;
861        // load-bearing for the boot coherence check).
862        assert!(config.resolve_policy_file_for(Some("bare")).is_none());
863        // Anonymous (bare URI) or an unknown name → top-level.
864        assert!(
865            config
866                .resolve_policy_file_for(None)
867                .unwrap()
868                .ends_with("top.yaml")
869        );
870        assert!(
871            config
872                .resolve_policy_file_for(Some("nope"))
873                .unwrap()
874                .ends_with("top.yaml")
875        );
876    }
877
878    #[test]
879    fn queries_block_absent_yields_empty_registry() {
880        let temp = tempdir().unwrap();
881        fs::write(
882            temp.path().join("omnigraph.yaml"),
883            "graphs:\n  local:\n    uri: ./demo.omni\n",
884        )
885        .unwrap();
886
887        let config = load_config_in(temp.path(), None).unwrap();
888        // Additive: no `queries:` anywhere → empty registries everywhere.
889        assert!(config.query_entries().is_empty());
890        assert!(
891            config
892                .target_query_entries("local")
893                .unwrap()
894                .is_empty()
895        );
896    }
897
898    #[test]
899    fn policy_block_accepts_non_empty_mapping() {
900        let temp = tempdir().unwrap();
901        fs::write(
902            temp.path().join("omnigraph.yaml"),
903            "policy:\n  file: ./policy.yaml\n",
904        )
905        .unwrap();
906
907        let config = load_config_in(temp.path(), None).unwrap();
908        assert_eq!(
909            config.resolve_policy_file().unwrap(),
910            temp.path().join("policy.yaml")
911        );
912    }
913
914    #[test]
915    fn scoped_auth_env_ignores_default_target_when_uri_is_explicit() {
916        let temp = tempdir().unwrap();
917        fs::write(
918            temp.path().join("omnigraph.yaml"),
919            r#"
920graphs:
921  demo:
922    uri: https://example.com
923    bearer_token_env: DEMO_TOKEN
924cli:
925  graph: demo
926"#,
927        )
928        .unwrap();
929
930        let config = load_config_in(temp.path(), None).unwrap();
931        assert_eq!(
932            config.graph_bearer_token_env(
933                Some("https://override.example.com"),
934                None,
935                config.cli_graph_name()
936            ),
937            None
938        );
939        assert_eq!(
940            config.graph_bearer_token_env(
941                Some("https://override.example.com"),
942                Some("demo"),
943                config.cli_graph_name()
944            ),
945            Some("DEMO_TOKEN")
946        );
947    }
948}