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<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TargetConfig {
pub uri: String,
pub bearer_token_env: Option<String>,
#[serde(default)]
pub policy: PolicySettings,
#[serde(default)]
pub queries: BTreeMap<String, QueryEntry>,
}
#[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<String>,
pub branch: Option<String>,
pub output_format: Option<ReadOutputFormat>,
pub table_max_column_width: Option<usize>,
pub table_cell_layout: Option<TableCellLayout>,
pub actor: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ServerDefaults {
#[serde(rename = "graph")]
pub graph: Option<String>,
pub bind: Option<String>,
#[serde(default)]
pub policy: PolicySettings,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct AuthDefaults {
pub env_file: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct QueryDefaults {
#[serde(default)]
pub roots: Vec<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct PolicySettings {
pub file: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct QueryEntry {
pub file: String,
#[serde(default)]
pub mcp: McpSettings,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct McpSettings {
#[serde(default = "mcp_expose_default")]
pub expose: bool,
pub tool_name: Option<String>,
}
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 {
#[serde(alias = "query")]
Read,
#[serde(alias = "mutate")]
Change,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AliasConfig {
pub command: AliasCommand,
pub query: String,
pub name: Option<String>,
#[serde(default)]
pub args: Vec<String>,
#[serde(rename = "graph")]
pub graph: Option<String>,
pub branch: Option<String>,
pub format: Option<ReadOutputFormat>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OmnigraphConfig {
#[serde(default)]
pub project: ProjectConfig,
#[serde(default, rename = "graphs")]
pub graphs: BTreeMap<String, TargetConfig>,
#[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<String, AliasConfig>,
#[serde(default)]
pub policy: PolicySettings,
#[serde(default)]
pub queries: BTreeMap<String, QueryEntry>,
#[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<PathBuf> {
self.auth
.env_file
.as_deref()
.map(|path| self.resolve_config_path(path))
}
pub fn resolve_policy_file(&self) -> Option<PathBuf> {
self.policy
.file
.as_deref()
.map(|path| self.resolve_config_path(path))
}
pub fn resolve_target_policy_file(&self, target_name: &str) -> Option<PathBuf> {
let target = self.graphs.get(target_name)?;
target
.policy
.file
.as_deref()
.map(|path| self.resolve_config_path(path))
}
pub fn query_entries(&self) -> &BTreeMap<String, QueryEntry> {
&self.queries
}
pub fn target_query_entries(
&self,
target_name: &str,
) -> Option<&BTreeMap<String, QueryEntry>> {
self.graphs.get(target_name).map(|target| &target.queries)
}
pub fn query_entries_for(&self, graph: Option<&str>) -> &BTreeMap<String, QueryEntry> {
match graph {
Some(name) if self.graphs.contains_key(name) => &self.graphs[name].queries,
_ => &self.queries,
}
}
pub fn resolve_graph_selection<'a>(&self, graph: Option<&'a str>) -> Result<Option<&'a str>> {
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<Option<&str>> {
self.resolve_graph_selection(self.cli_graph_name().or_else(|| self.server_graph_name()))
}
pub fn resolve_policy_file_for(&self, graph: Option<&str>) -> Option<PathBuf> {
match graph {
Some(name) if self.graphs.contains_key(name) => self.resolve_target_policy_file(name),
_ => self.resolve_policy_file(),
}
}
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
}
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(())
}
pub fn resolve_query_file(&self, value: &str) -> PathBuf {
self.resolve_config_path(value)
}
pub fn resolve_server_policy_file(&self) -> Option<PathBuf> {
self.server
.policy
.file
.as_deref()
.map(|path| self.resolve_config_path(path))
}
pub fn resolve_uri_value(&self, value: &str) -> String {
self.resolve_config_uri(value)
}
pub fn resolve_policy_tests_file(&self) -> Option<PathBuf> {
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<String>,
explicit_target: Option<&str>,
default_target: Option<&str>,
) -> Result<String> {
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 <URI>, --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<PathBuf> {
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<OmnigraphConfig> {
load_config_in(&env::current_dir()?, config_path)
}
fn load_config_in(cwd: &Path, config_path: Option<&PathBuf>) -> Result<OmnigraphConfig> {
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 {
serde_yaml::from_str::<OmnigraphConfig>(&fs::read_to_string(path)?)?
} else {
OmnigraphConfig::default()
};
config.base_dir = if let Some(path) = config_path {
absolute_base_dir(cwd, &path)?
} else {
cwd.to_path_buf()
};
Ok(config)
}
fn absolute_base_dir(cwd: &Path, path: &Path) -> Result<PathBuf> {
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();
assert_eq!(config.resolve_graph_selection(Some("local")).unwrap(), Some("local"));
assert_eq!(config.resolve_graph_selection(None).unwrap(), None);
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}"
);
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();
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"));
let audit = &prod["internal_audit"];
assert!(audit.mcp.expose);
assert!(audit.mcp.tool_name.is_none());
assert_eq!(config.query_entries().len(), 1);
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);
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();
assert!(
config
.resolve_policy_file_for(Some("prod"))
.unwrap()
.ends_with("prod.yaml")
);
assert!(config.resolve_policy_file_for(Some("bare")).is_none());
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();
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")
);
}
}