use std::collections::BTreeMap; use std::env; use std::fs; use std::path::{Path, PathBuf}; use clap::ValueEnum; use color_eyre::eyre::{Result, bail}; use serde::{Deserialize, Serialize}; pub const DEFAULT_CONFIG_FILE: &str = "omnigraph.yaml"; pub fn graph_resource_id_for_selection( selected_graph: Option<&str>, normalized_uri: &str, ) -> String { selected_graph.unwrap_or(normalized_uri).to_string() } #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct ProjectConfig { pub name: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct TargetConfig { pub uri: String, pub bearer_token_env: Option, /// Per-graph Cedar policy file (MR-668). In single-graph mode this /// field is unused — the top-level `policy.file` applies. In /// multi-graph mode, each `graphs..policy.file` governs that /// graph's HTTP-layer Cedar enforcement. #[serde(default)] pub policy: PolicySettings, /// Per-graph stored-query registry: an inline `name -> entry` /// map. Mirrors the per-graph `policy` shape — each /// `graphs..queries` declares that graph's stored queries. Absent /// (or empty) = no stored queries for the graph. v1 is inline-only; /// an external `queries.yaml` manifest indirection is a deferred /// convenience. #[serde(default)] pub queries: BTreeMap, } #[derive(Debug, Clone, Copy, Default, Eq, PartialEq, Serialize, Deserialize, ValueEnum)] #[serde(rename_all = "snake_case")] pub enum ReadOutputFormat { #[default] Table, Kv, Csv, Jsonl, Json, } #[derive(Debug, Clone, Copy, Default, Eq, PartialEq, Serialize, Deserialize, ValueEnum)] #[serde(rename_all = "snake_case")] pub enum TableCellLayout { #[default] Truncate, Wrap, } #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct CliDefaults { #[serde(rename = "graph")] pub graph: Option, pub branch: Option, pub output_format: Option, pub table_max_column_width: Option, pub table_cell_layout: Option, /// Default actor identity for CLI direct-engine writes (MR-722). /// Used when `policy.file` is configured and the operator hasn't /// passed `--as ` on the command line. With policy configured /// and neither this nor `--as` set, the engine-layer footgun guard /// fires (no silent bypass). pub actor: Option, } #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct ServerDefaults { #[serde(rename = "graph")] pub graph: Option, pub bind: Option, /// Server-level Cedar policy (MR-668). Governs management endpoints /// — currently `GET /graphs`; future runtime add/remove endpoints /// will plug in here too. In single-graph mode this is unused — the /// top-level `policy.file` covers the single graph. #[serde(default)] pub policy: PolicySettings, } #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct AuthDefaults { pub env_file: Option, } #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct QueryDefaults { #[serde(default)] pub roots: Vec, } #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct PolicySettings { pub file: Option, } /// One stored-query registry entry. The map **key** is the query's /// identity — it must equal the `query ` symbol declared inside /// the referenced `.gq` file (asserted when the registry loads). /// Renaming the key (or the symbol) is a breaking change to callers, by /// design. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct QueryEntry { /// Path to the `.gq` file (relative to the config's `base_dir`). The /// file may declare several queries; the registry selects the one /// whose symbol matches the map key. pub file: String, #[serde(default)] pub mcp: McpSettings, } /// MCP exposure for a stored query. A *deployment* concern (the same /// `.gq` may be exposed in one graph and hidden in another), so it lives /// in YAML rather than in the `.gq` source. **Default `expose: true`** — /// declaring a query in the manifest *is* the opt-in, so it appears in the /// MCP tool catalog (`GET /queries`) by default; set `expose: false` to /// keep a query HTTP/service-callable but hidden from the agent tool list. /// `expose` governs catalog membership only — it is **not** an /// authorization gate (invocation is gated by `invoke_query`), so a hidden /// query is still invocable by name with the right permission. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct McpSettings { #[serde(default = "mcp_expose_default")] pub expose: bool, pub tool_name: Option, } fn mcp_expose_default() -> bool { true } impl Default for McpSettings { fn default() -> Self { Self { expose: mcp_expose_default(), tool_name: None, } } } #[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum AliasCommand { /// Read alias (canonical: `query`). The legacy spelling `read` is /// kept as the variant name for back-compat with serialized configs /// and external SDK callers; `query` is accepted on the wire via the /// serde alias. #[serde(alias = "query")] Read, /// Mutation alias (canonical: `mutate`). The legacy spelling `change` /// is kept as the variant name for back-compat; `mutate` is accepted /// on the wire via the serde alias. #[serde(alias = "mutate")] Change, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AliasConfig { pub command: AliasCommand, pub query: String, pub name: Option, #[serde(default)] pub args: Vec, #[serde(rename = "graph")] pub graph: Option, pub branch: Option, pub format: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct OmnigraphConfig { #[serde(default)] pub project: ProjectConfig, #[serde(default, rename = "graphs")] pub graphs: BTreeMap, #[serde(default)] pub server: ServerDefaults, #[serde(default)] pub auth: AuthDefaults, #[serde(default)] pub cli: CliDefaults, #[serde(default)] pub query: QueryDefaults, #[serde(default)] pub aliases: BTreeMap, #[serde(default)] pub policy: PolicySettings, /// Top-level stored-query registry, used in single-graph /// mode — mirrors how the top-level `policy` applies to the single /// graph. In multi-graph mode this is unused; each graph's /// `graphs..queries` applies instead. #[serde(default)] pub queries: BTreeMap, #[serde(skip)] base_dir: PathBuf, } impl Default for OmnigraphConfig { fn default() -> Self { Self { project: ProjectConfig::default(), graphs: BTreeMap::new(), server: ServerDefaults::default(), auth: AuthDefaults::default(), cli: CliDefaults::default(), query: QueryDefaults::default(), aliases: BTreeMap::new(), policy: PolicySettings::default(), queries: BTreeMap::new(), base_dir: PathBuf::new(), } } } impl OmnigraphConfig { pub fn base_dir(&self) -> &Path { &self.base_dir } pub fn cli_branch(&self) -> &str { self.cli.branch.as_deref().unwrap_or("main") } pub fn cli_output_format(&self) -> ReadOutputFormat { self.cli.output_format.unwrap_or_default() } pub fn table_max_column_width(&self) -> usize { self.cli.table_max_column_width.unwrap_or(80) } pub fn table_cell_layout(&self) -> TableCellLayout { self.cli.table_cell_layout.unwrap_or_default() } pub fn cli_graph_name(&self) -> Option<&str> { self.cli.graph.as_deref() } pub fn server_graph_name(&self) -> Option<&str> { self.server.graph.as_deref() } pub fn server_bind(&self) -> &str { self.server.bind.as_deref().unwrap_or("127.0.0.1:8080") } pub fn resolve_target_name<'a>( &self, explicit_uri: Option<&str>, explicit_target: Option<&'a str>, default_target: Option<&'a str>, ) -> Option<&'a str> { explicit_target.or_else(|| { if explicit_uri.is_some() { None } else { default_target } }) } pub fn graph_bearer_token_env( &self, explicit_uri: Option<&str>, explicit_target: Option<&str>, default_target: Option<&str>, ) -> Option<&str> { let target_name = self.resolve_target_name(explicit_uri, explicit_target, default_target)?; self.graphs .get(target_name) .and_then(|target| target.bearer_token_env.as_deref()) } pub fn resolve_auth_env_file(&self) -> Option { self.auth .env_file .as_deref() .map(|path| self.resolve_config_path(path)) } pub fn resolve_policy_file(&self) -> Option { self.policy .file .as_deref() .map(|path| self.resolve_config_path(path)) } /// Resolve the per-graph policy file path for the named target, /// relative to the config file's `base_dir`. Returns `None` if the /// target is unknown or no per-graph `policy.file` is set. pub fn resolve_target_policy_file(&self, target_name: &str) -> Option { let target = self.graphs.get(target_name)?; target .policy .file .as_deref() .map(|path| self.resolve_config_path(path)) } /// The top-level stored-query registry entries (single-graph mode). pub fn query_entries(&self) -> &BTreeMap { &self.queries } /// The per-graph stored-query registry entries for a named target /// (multi-graph mode). Returns `None` if the target is unknown. pub fn target_query_entries( &self, target_name: &str, ) -> Option<&BTreeMap> { self.graphs.get(target_name).map(|target| &target.queries) } /// The stored-query registry entries that apply for a graph /// selection — the single definition of "which `queries:` block /// governs graph X", shared by server boot and the CLI so the two /// can't drift. A named graph present in `graphs:` uses its /// per-graph block; everything else (no selection, or a name that is /// not a known graph, e.g. a bare URI) falls back to the top-level /// block (single-graph mode). pub fn query_entries_for(&self, graph: Option<&str>) -> &BTreeMap { match graph { Some(name) if self.graphs.contains_key(name) => &self.graphs[name].queries, _ => &self.queries, } } /// The single CLI gate that turns a raw graph selection into a *validated* /// one — the fallible counterpart to the infallible /// [`OmnigraphConfig::query_entries_for`]. Both `queries` subcommands route /// their selection through here so neither can skip a check the other (or /// server boot) applies: /// * a known name passes through, but only after the same coherence check /// server boot enforces /// ([`OmnigraphConfig::ensure_top_level_blocks_honored`]) — a named graph /// with a populated top-level block is rejected; /// * an unknown name errors with the **same** message /// [`OmnigraphConfig::resolve_target_uri`] produces, so a command that /// opens no URI rejects an unknown `--target` exactly like the /// URI-resolving commands do; /// * an anonymous selection (`None`, e.g. a bare URI) stays anonymous, /// resolving to the top-level registry downstream (top-level honored). pub fn resolve_graph_selection<'a>(&self, graph: Option<&'a str>) -> Result> { match graph { Some(name) if self.graphs.contains_key(name) => { self.ensure_top_level_blocks_honored(Some(name))?; Ok(Some(name)) } Some(name) => bail!("graph '{}' not found in {}", name, DEFAULT_CONFIG_FILE), None => Ok(None), } } pub fn resolve_policy_tooling_graph_selection(&self) -> Result> { self.resolve_graph_selection(self.cli_graph_name().or_else(|| self.server_graph_name())) } /// The policy file that applies for a graph selection — the policy /// sibling of [`OmnigraphConfig::query_entries_for`], so policy and /// queries resolve by the same identity rule. A named graph in /// `graphs:` uses its per-graph `policy.file` with **no** top-level /// fallback (a named graph with no per-graph policy has no policy — /// that keeps the boot-time coherence check meaningful); anything else /// (no selection, or a bare URI) uses the top-level `policy.file`. pub fn resolve_policy_file_for(&self, graph: Option<&str>) -> Option { match graph { Some(name) if self.graphs.contains_key(name) => self.resolve_target_policy_file(name), _ => self.resolve_policy_file(), } } /// Names of any top-level config blocks (`policy.file`, `queries:`) /// that are populated. Used by the boot-time coherence check: when a /// **named** graph is served (single-mode by name, or multi-mode), /// the top-level blocks are not honored, so a populated one is a /// configuration error rather than a silent no-op. pub fn populated_top_level_blocks(&self) -> Vec<&'static str> { let mut blocks = Vec::new(); if self.policy.file.is_some() { blocks.push("policy.file"); } if !self.queries.is_empty() { blocks.push("queries"); } blocks } /// A named graph uses its own `graphs.` block, so a populated /// top-level block would be silently ignored — a config error. The single /// definition of that rule, shared by server boot and the CLI selection /// gate ([`OmnigraphConfig::resolve_graph_selection`]) so the two can't /// drift. An anonymous selection (`None`, e.g. a bare URI) legitimately /// honors the top-level blocks, so it is never rejected here. pub fn ensure_top_level_blocks_honored(&self, selected: Option<&str>) -> Result<()> { if let Some(name) = selected { let unhonored = self.populated_top_level_blocks(); if !unhonored.is_empty() { bail!( "named graph '{name}' uses its own `graphs.{name}.…` block, but top-level {} \ {} set and would be ignored. Move it to `graphs.{name}` (e.g. \ `graphs.{name}.policy.file`, `graphs.{name}.queries`).", unhonored.join(" and "), if unhonored.len() == 1 { "is" } else { "are" }, ); } } Ok(()) } /// Resolve a stored-query `.gq` file path (from a registry entry), /// relative to the config's `base_dir`. Mirrors policy-file /// resolution; the registry loader calls this to turn each entry's /// `file:` value into an absolute path. pub fn resolve_query_file(&self, value: &str) -> PathBuf { self.resolve_config_path(value) } /// Resolve the server-level policy file path (used by management /// endpoints). Returns `None` if `server.policy.file` is not set. pub fn resolve_server_policy_file(&self) -> Option { self.server .policy .file .as_deref() .map(|path| self.resolve_config_path(path)) } /// Resolve a raw config-supplied URI (which may be relative) to its /// absolute form. URIs containing `://` are passed through as-is; /// relative paths are joined with the config file's `base_dir`. pub fn resolve_uri_value(&self, value: &str) -> String { self.resolve_config_uri(value) } pub fn resolve_policy_tests_file(&self) -> Option { let policy_file = self.resolve_policy_file()?; Some(policy_file.with_file_name("policy.tests.yaml")) } pub fn alias(&self, name: &str) -> Result<&AliasConfig> { self.aliases .get(name) .ok_or_else(|| color_eyre::eyre::eyre!("alias '{}' not found", name)) } pub fn resolve_target_uri( &self, explicit_uri: Option, explicit_target: Option<&str>, default_target: Option<&str>, ) -> Result { if let Some(uri) = explicit_uri { return Ok(uri); } let target_name = explicit_target.or(default_target).ok_or_else(|| { color_eyre::eyre::eyre!("URI must be provided via , --target, or config") })?; let target = self.graphs.get(target_name).ok_or_else(|| { color_eyre::eyre::eyre!( "graph '{}' not found in {}", target_name, DEFAULT_CONFIG_FILE ) })?; Ok(self.resolve_config_uri(&target.uri)) } pub fn resolve_query_path(&self, query: &Path) -> Result { if query.is_absolute() { return Ok(query.to_path_buf()); } let direct = self.base_dir.join(query); if direct.exists() { return Ok(direct); } for root in &self.query.roots { let candidate = self.base_dir.join(root).join(query); if candidate.exists() { return Ok(candidate); } } bail!("query file '{}' not found", query.display()); } fn resolve_config_uri(&self, value: &str) -> String { if value.contains("://") { return value.to_string(); } let path = Path::new(value); if path.is_absolute() { value.to_string() } else { self.base_dir.join(path).to_string_lossy().to_string() } } fn resolve_config_path(&self, value: &str) -> PathBuf { let path = Path::new(value); if path.is_absolute() { path.to_path_buf() } else { self.base_dir.join(path) } } } pub fn default_config_path() -> PathBuf { PathBuf::from(DEFAULT_CONFIG_FILE) } pub fn load_config(config_path: Option<&PathBuf>) -> Result { load_config_in(&env::current_dir()?, config_path) } fn load_config_in(cwd: &Path, config_path: Option<&PathBuf>) -> Result { let explicit_path = config_path.cloned(); let config_path = explicit_path.or_else(|| { let default_path = cwd.join(DEFAULT_CONFIG_FILE); default_path.exists().then_some(default_path) }); let mut config = if let Some(path) = &config_path { let text = fs::read_to_string(path)?; check_config_version(&text)?; serde_yaml::from_str::(&text)? } else { OmnigraphConfig::default() }; config.base_dir = if let Some(path) = config_path { absolute_base_dir(cwd, &path)? } else { cwd.to_path_buf() }; Ok(config) } /// Read the optional top-level `version:` discriminator and reject versions /// this build does not understand. The typed v1 schema (`deny_unknown_fields`, /// the `GraphLocator`) lands in a following change; today both no-version /// (legacy) and `version: 1` parse via the same lenient struct, so this is the /// forward-compat gate that lets v1 tighten without breaking legacy files. fn check_config_version(text: &str) -> Result<()> { let value: serde_yaml::Value = serde_yaml::from_str(text)?; if let Some(version) = value.get("version").and_then(serde_yaml::Value::as_u64) { if version != 1 { bail!( "unsupported config version {version}; this build supports version 1 \ (omit `version:` for the legacy schema)" ); } } Ok(()) } fn absolute_base_dir(cwd: &Path, path: &Path) -> Result { let path = if path.is_absolute() { path.to_path_buf() } else { cwd.join(path) }; Ok(path .parent() .map(Path::to_path_buf) .unwrap_or_else(|| cwd.to_path_buf())) } #[cfg(test)] mod tests { use std::fs; use std::path::{Path, PathBuf}; use tempfile::tempdir; use super::{ ReadOutputFormat, TableCellLayout, graph_resource_id_for_selection, load_config_in, }; #[test] fn load_config_reads_yaml_defaults_from_current_dir() { let temp = tempdir().unwrap(); fs::write( temp.path().join("omnigraph.yaml"), r#" graphs: local: uri: ./demo.omni bearer_token_env: DEMO_TOKEN auth: env_file: .env.omni cli: graph: local branch: main output_format: kv table_max_column_width: 40 table_cell_layout: wrap policy: {} "#, ) .unwrap(); let config = load_config_in(temp.path(), None).unwrap(); assert_eq!(config.cli_graph_name(), Some("local")); assert_eq!(config.cli_branch(), "main"); assert_eq!(config.cli_output_format(), ReadOutputFormat::Kv); assert_eq!(config.table_max_column_width(), 40); assert_eq!(config.table_cell_layout(), TableCellLayout::Wrap); assert_eq!( config.graph_bearer_token_env(None, None, config.cli_graph_name()), Some("DEMO_TOKEN") ); assert_eq!( config.resolve_auth_env_file().unwrap(), temp.path().join(".env.omni") ); assert_eq!( PathBuf::from( config .resolve_target_uri(None, None, config.cli_graph_name()) .unwrap() ), temp.path().join("./demo.omni") ); } #[test] fn load_config_does_not_walk_parent_directories() { let temp = tempdir().unwrap(); let child = temp.path().join("child"); fs::create_dir_all(&child).unwrap(); fs::write( temp.path().join("omnigraph.yaml"), "graphs:\n local:\n uri: ./demo.omni\n", ) .unwrap(); let config = load_config_in(&child, None).unwrap(); assert!(config.graphs.is_empty()); } #[test] fn graph_resource_id_for_selection_uses_name_or_anonymous_uri() { assert_eq!( graph_resource_id_for_selection(Some("local"), "/tmp/graph.omni"), "local" ); assert_eq!( graph_resource_id_for_selection(None, "/tmp/graph.omni"), "/tmp/graph.omni" ); } #[test] fn resolve_graph_selection_validates_membership_and_coherence() { let temp = tempdir().unwrap(); fs::write( temp.path().join("omnigraph.yaml"), "graphs:\n local:\n uri: ./demo.omni\n", ) .unwrap(); let config = load_config_in(temp.path(), None).unwrap(); // A known graph passes through unchanged. assert_eq!(config.resolve_graph_selection(Some("local")).unwrap(), Some("local")); // An anonymous selection stays anonymous (→ top-level registry downstream). assert_eq!(config.resolve_graph_selection(None).unwrap(), None); // An unknown name errors, naming the graph (matching resolve_target_uri). let err = config.resolve_graph_selection(Some("ghost")).unwrap_err().to_string(); assert!( err.contains("ghost") && err.contains("not found"), "unknown graph must error naming it: {err}" ); // Coherence: a named graph plus a populated top-level block is the // config server boot refuses, so the gate rejects it too (shared rule // via ensure_top_level_blocks_honored). An anonymous selection still // passes — top-level is honored when no graph is named. let temp2 = tempdir().unwrap(); fs::write( temp2.path().join("omnigraph.yaml"), "graphs:\n local:\n uri: ./demo.omni\npolicy:\n file: ./top.yaml\n", ) .unwrap(); let incoherent = load_config_in(temp2.path(), None).unwrap(); let err = incoherent .resolve_graph_selection(Some("local")) .unwrap_err() .to_string(); assert!( err.contains("local") && err.contains("policy.file"), "named graph + populated top-level block must be rejected, naming both: {err}" ); assert_eq!( incoherent.resolve_graph_selection(None).unwrap(), None, "anonymous selection still honors top-level" ); } #[test] fn policy_tooling_graph_selection_prefers_cli_then_server_and_validates() { let temp = tempdir().unwrap(); fs::write( temp.path().join("omnigraph.yaml"), "graphs:\n local:\n uri: ./local.omni\n prod:\n uri: ./prod.omni\n\ server:\n graph: local\ncli:\n graph: prod\n", ) .unwrap(); let config = load_config_in(temp.path(), None).unwrap(); assert_eq!( config.resolve_policy_tooling_graph_selection().unwrap(), Some("prod") ); let temp = tempdir().unwrap(); fs::write( temp.path().join("omnigraph.yaml"), "graphs:\n local:\n uri: ./local.omni\nserver:\n graph: local\n", ) .unwrap(); let config = load_config_in(temp.path(), None).unwrap(); assert_eq!( config.resolve_policy_tooling_graph_selection().unwrap(), Some("local") ); let temp = tempdir().unwrap(); fs::write(temp.path().join("omnigraph.yaml"), "policy: {}\n").unwrap(); let config = load_config_in(temp.path(), None).unwrap(); assert_eq!(config.resolve_policy_tooling_graph_selection().unwrap(), None); let temp = tempdir().unwrap(); fs::write( temp.path().join("omnigraph.yaml"), "graphs:\n local:\n uri: ./local.omni\nserver:\n graph: ghost\n", ) .unwrap(); let config = load_config_in(temp.path(), None).unwrap(); let err = config .resolve_policy_tooling_graph_selection() .unwrap_err() .to_string(); assert!( err.contains("ghost") && err.contains("not found"), "unknown server.graph must use graph-selection validation: {err}" ); } #[test] fn resolve_query_path_searches_config_roots() { let temp = tempdir().unwrap(); fs::create_dir_all(temp.path().join("queries")).unwrap(); fs::write( temp.path().join("omnigraph.yaml"), "query:\n roots:\n - queries\npolicy: {}\n", ) .unwrap(); fs::write( temp.path().join("queries").join("test.gq"), "query q { return {} }", ) .unwrap(); let config = load_config_in(temp.path(), None).unwrap(); let resolved = config.resolve_query_path(Path::new("test.gq")).unwrap(); assert_eq!(resolved, temp.path().join("queries").join("test.gq")); } #[test] fn resolve_query_path_prefers_config_base_dir_over_ambient_cwd() { let workspace = tempdir().unwrap(); let config_dir = workspace.path().join("config"); let ambient_dir = workspace.path().join("ambient"); fs::create_dir_all(&config_dir).unwrap(); fs::create_dir_all(&ambient_dir).unwrap(); fs::write(config_dir.join("omnigraph.yaml"), "policy: {}\n").unwrap(); fs::write(config_dir.join("local.gq"), "query local { return {} }").unwrap(); fs::write(ambient_dir.join("local.gq"), "query ambient { return {} }").unwrap(); let config = load_config_in(&ambient_dir, Some(&config_dir.join("omnigraph.yaml"))).unwrap(); let resolved = config.resolve_query_path(Path::new("local.gq")).unwrap(); assert_eq!(resolved, config_dir.join("local.gq")); } #[test] fn queries_block_round_trips_inline_and_per_graph() { let temp = tempdir().unwrap(); fs::write( temp.path().join("omnigraph.yaml"), r#" graphs: prod: uri: s3://bucket/prod queries: find_user: file: ./queries/find_user.gq mcp: expose: true tool_name: lookup_user internal_audit: file: ./queries/audit.gq queries: single_mode_q: file: ./q.gq "#, ) .unwrap(); let config = load_config_in(temp.path(), None).unwrap(); // Per-graph registry (multi-graph mode). let prod = config.target_query_entries("prod").unwrap(); assert_eq!(prod.len(), 2); let find_user = &prod["find_user"]; assert_eq!(find_user.file, "./queries/find_user.gq"); assert!(find_user.mcp.expose); assert_eq!(find_user.mcp.tool_name.as_deref(), Some("lookup_user")); // Default exposure is true (the manifest entry is the opt-in); tool_name absent. let audit = &prod["internal_audit"]; assert!(audit.mcp.expose); assert!(audit.mcp.tool_name.is_none()); // Top-level registry (single-graph mode). assert_eq!(config.query_entries().len(), 1); // The shared selector resolves the same blocks the server boot // and the CLI use: a known graph → its per-graph block; no // selection or an unknown name → the top-level block (the latter // pins the behavior of the CLI's now-deleted fallback arm). assert_eq!(config.query_entries_for(Some("prod")).len(), 2); assert_eq!(config.query_entries_for(None).len(), 1); assert_eq!(config.query_entries_for(Some("nonexistent")).len(), 1); // Path resolution joins against base_dir, like policy files. assert_eq!( config.resolve_query_file(&find_user.file), temp.path().join("./queries/find_user.gq") ); } #[test] fn resolve_policy_file_for_follows_identity() { let temp = tempdir().unwrap(); fs::write( temp.path().join("omnigraph.yaml"), "policy:\n file: ./top.yaml\ngraphs:\n prod:\n uri: s3://b/prod\n \ policy:\n file: ./prod.yaml\n bare:\n uri: s3://b/bare\n", ) .unwrap(); let config = load_config_in(temp.path(), None).unwrap(); // Named graph with its own policy → per-graph (not top-level). assert!( config .resolve_policy_file_for(Some("prod")) .unwrap() .ends_with("prod.yaml") ); // Named graph with NO per-graph policy → None (no top-level fallback; // load-bearing for the boot coherence check). assert!(config.resolve_policy_file_for(Some("bare")).is_none()); // Anonymous (bare URI) or an unknown name → top-level. assert!( config .resolve_policy_file_for(None) .unwrap() .ends_with("top.yaml") ); assert!( config .resolve_policy_file_for(Some("nope")) .unwrap() .ends_with("top.yaml") ); } #[test] fn queries_block_absent_yields_empty_registry() { let temp = tempdir().unwrap(); fs::write( temp.path().join("omnigraph.yaml"), "graphs:\n local:\n uri: ./demo.omni\n", ) .unwrap(); let config = load_config_in(temp.path(), None).unwrap(); // Additive: no `queries:` anywhere → empty registries everywhere. assert!(config.query_entries().is_empty()); assert!( config .target_query_entries("local") .unwrap() .is_empty() ); } #[test] fn policy_block_accepts_non_empty_mapping() { let temp = tempdir().unwrap(); fs::write( temp.path().join("omnigraph.yaml"), "policy:\n file: ./policy.yaml\n", ) .unwrap(); let config = load_config_in(temp.path(), None).unwrap(); assert_eq!( config.resolve_policy_file().unwrap(), temp.path().join("policy.yaml") ); } #[test] fn scoped_auth_env_ignores_default_target_when_uri_is_explicit() { let temp = tempdir().unwrap(); fs::write( temp.path().join("omnigraph.yaml"), r#" graphs: demo: uri: https://example.com bearer_token_env: DEMO_TOKEN cli: graph: demo "#, ) .unwrap(); let config = load_config_in(temp.path(), None).unwrap(); assert_eq!( config.graph_bearer_token_env( Some("https://override.example.com"), None, config.cli_graph_name() ), None ); assert_eq!( config.graph_bearer_token_env( Some("https://override.example.com"), Some("demo"), config.cli_graph_name() ), Some("DEMO_TOKEN") ); } #[test] fn version_one_parses_like_legacy() { let temp = tempdir().unwrap(); fs::write( temp.path().join("omnigraph.yaml"), "version: 1\ngraphs:\n local:\n uri: ./demo.omni\ncli:\n graph: local\n", ) .unwrap(); let config = load_config_in(temp.path(), None).unwrap(); assert_eq!(config.cli_graph_name(), Some("local")); assert_eq!( PathBuf::from( config .resolve_target_uri(None, None, config.cli_graph_name()) .unwrap() ), temp.path().join("./demo.omni") ); } #[test] fn unsupported_config_version_errors() { let temp = tempdir().unwrap(); fs::write( temp.path().join("omnigraph.yaml"), "version: 2\ngraphs:\n local:\n uri: ./demo.omni\n", ) .unwrap(); let err = load_config_in(temp.path(), None).unwrap_err().to_string(); assert!( err.contains("unsupported config version 2"), "config version > 1 must be rejected: {err}" ); } }