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
12#[derive(Debug, Clone, Default, Serialize, Deserialize)]
13pub struct ProjectConfig {
14 pub name: Option<String>,
15}
16
17#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct TargetConfig {
19 pub uri: String,
20 pub bearer_token_env: Option<String>,
21 #[serde(default)]
26 pub policy: PolicySettings,
27}
28
29#[derive(Debug, Clone, Copy, Default, Eq, PartialEq, Serialize, Deserialize, ValueEnum)]
30#[serde(rename_all = "snake_case")]
31pub enum ReadOutputFormat {
32 #[default]
33 Table,
34 Kv,
35 Csv,
36 Jsonl,
37 Json,
38}
39
40#[derive(Debug, Clone, Copy, Default, Eq, PartialEq, Serialize, Deserialize, ValueEnum)]
41#[serde(rename_all = "snake_case")]
42pub enum TableCellLayout {
43 #[default]
44 Truncate,
45 Wrap,
46}
47
48#[derive(Debug, Clone, Default, Serialize, Deserialize)]
49pub struct CliDefaults {
50 #[serde(rename = "graph")]
51 pub graph: Option<String>,
52 pub branch: Option<String>,
53 pub output_format: Option<ReadOutputFormat>,
54 pub table_max_column_width: Option<usize>,
55 pub table_cell_layout: Option<TableCellLayout>,
56 pub actor: Option<String>,
62}
63
64#[derive(Debug, Clone, Default, Serialize, Deserialize)]
65pub struct ServerDefaults {
66 #[serde(rename = "graph")]
67 pub graph: Option<String>,
68 pub bind: Option<String>,
69 #[serde(default)]
74 pub policy: PolicySettings,
75}
76
77#[derive(Debug, Clone, Default, Serialize, Deserialize)]
78pub struct AuthDefaults {
79 pub env_file: Option<String>,
80}
81
82#[derive(Debug, Clone, Default, Serialize, Deserialize)]
83pub struct QueryDefaults {
84 #[serde(default)]
85 pub roots: Vec<String>,
86}
87
88#[derive(Debug, Clone, Default, Serialize, Deserialize)]
89pub struct PolicySettings {
90 pub file: Option<String>,
91}
92
93#[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize, Deserialize)]
94#[serde(rename_all = "snake_case")]
95pub enum AliasCommand {
96 #[serde(alias = "query")]
101 Read,
102 #[serde(alias = "mutate")]
106 Change,
107}
108
109#[derive(Debug, Clone, Serialize, Deserialize)]
110pub struct AliasConfig {
111 pub command: AliasCommand,
112 pub query: String,
113 pub name: Option<String>,
114 #[serde(default)]
115 pub args: Vec<String>,
116 #[serde(rename = "graph")]
117 pub graph: Option<String>,
118 pub branch: Option<String>,
119 pub format: Option<ReadOutputFormat>,
120}
121
122#[derive(Debug, Clone, Serialize, Deserialize)]
123pub struct OmnigraphConfig {
124 #[serde(default)]
125 pub project: ProjectConfig,
126 #[serde(default, rename = "graphs")]
127 pub graphs: BTreeMap<String, TargetConfig>,
128 #[serde(default)]
129 pub server: ServerDefaults,
130 #[serde(default)]
131 pub auth: AuthDefaults,
132 #[serde(default)]
133 pub cli: CliDefaults,
134 #[serde(default)]
135 pub query: QueryDefaults,
136 #[serde(default)]
137 pub aliases: BTreeMap<String, AliasConfig>,
138 #[serde(default)]
139 pub policy: PolicySettings,
140 #[serde(skip)]
141 base_dir: PathBuf,
142}
143
144impl Default for OmnigraphConfig {
145 fn default() -> Self {
146 Self {
147 project: ProjectConfig::default(),
148 graphs: BTreeMap::new(),
149 server: ServerDefaults::default(),
150 auth: AuthDefaults::default(),
151 cli: CliDefaults::default(),
152 query: QueryDefaults::default(),
153 aliases: BTreeMap::new(),
154 policy: PolicySettings::default(),
155 base_dir: PathBuf::new(),
156 }
157 }
158}
159
160impl OmnigraphConfig {
161 pub fn base_dir(&self) -> &Path {
162 &self.base_dir
163 }
164
165 pub fn cli_branch(&self) -> &str {
166 self.cli.branch.as_deref().unwrap_or("main")
167 }
168
169 pub fn cli_output_format(&self) -> ReadOutputFormat {
170 self.cli.output_format.unwrap_or_default()
171 }
172
173 pub fn table_max_column_width(&self) -> usize {
174 self.cli.table_max_column_width.unwrap_or(80)
175 }
176
177 pub fn table_cell_layout(&self) -> TableCellLayout {
178 self.cli.table_cell_layout.unwrap_or_default()
179 }
180
181 pub fn cli_graph_name(&self) -> Option<&str> {
182 self.cli.graph.as_deref()
183 }
184
185 pub fn server_graph_name(&self) -> Option<&str> {
186 self.server.graph.as_deref()
187 }
188
189 pub fn server_bind(&self) -> &str {
190 self.server.bind.as_deref().unwrap_or("127.0.0.1:8080")
191 }
192
193 pub fn resolve_target_name<'a>(
194 &self,
195 explicit_uri: Option<&str>,
196 explicit_target: Option<&'a str>,
197 default_target: Option<&'a str>,
198 ) -> Option<&'a str> {
199 explicit_target.or_else(|| {
200 if explicit_uri.is_some() {
201 None
202 } else {
203 default_target
204 }
205 })
206 }
207
208 pub fn graph_bearer_token_env(
209 &self,
210 explicit_uri: Option<&str>,
211 explicit_target: Option<&str>,
212 default_target: Option<&str>,
213 ) -> Option<&str> {
214 let target_name =
215 self.resolve_target_name(explicit_uri, explicit_target, default_target)?;
216 self.graphs
217 .get(target_name)
218 .and_then(|target| target.bearer_token_env.as_deref())
219 }
220
221 pub fn resolve_auth_env_file(&self) -> Option<PathBuf> {
222 self.auth
223 .env_file
224 .as_deref()
225 .map(|path| self.resolve_config_path(path))
226 }
227
228 pub fn resolve_policy_file(&self) -> Option<PathBuf> {
229 self.policy
230 .file
231 .as_deref()
232 .map(|path| self.resolve_config_path(path))
233 }
234
235 pub fn resolve_target_policy_file(&self, target_name: &str) -> Option<PathBuf> {
239 let target = self.graphs.get(target_name)?;
240 target
241 .policy
242 .file
243 .as_deref()
244 .map(|path| self.resolve_config_path(path))
245 }
246
247 pub fn resolve_server_policy_file(&self) -> Option<PathBuf> {
250 self.server
251 .policy
252 .file
253 .as_deref()
254 .map(|path| self.resolve_config_path(path))
255 }
256
257 pub fn resolve_uri_value(&self, value: &str) -> String {
261 self.resolve_config_uri(value)
262 }
263
264 pub fn resolve_policy_tests_file(&self) -> Option<PathBuf> {
265 let policy_file = self.resolve_policy_file()?;
266 Some(policy_file.with_file_name("policy.tests.yaml"))
267 }
268
269 pub fn alias(&self, name: &str) -> Result<&AliasConfig> {
270 self.aliases
271 .get(name)
272 .ok_or_else(|| color_eyre::eyre::eyre!("alias '{}' not found", name))
273 }
274
275 pub fn resolve_target_uri(
276 &self,
277 explicit_uri: Option<String>,
278 explicit_target: Option<&str>,
279 default_target: Option<&str>,
280 ) -> Result<String> {
281 if let Some(uri) = explicit_uri {
282 return Ok(uri);
283 }
284
285 let target_name = explicit_target.or(default_target).ok_or_else(|| {
286 color_eyre::eyre::eyre!("URI must be provided via <URI>, --target, or config")
287 })?;
288 let target = self.graphs.get(target_name).ok_or_else(|| {
289 color_eyre::eyre::eyre!(
290 "graph '{}' not found in {}",
291 target_name,
292 DEFAULT_CONFIG_FILE
293 )
294 })?;
295 Ok(self.resolve_config_uri(&target.uri))
296 }
297
298 pub fn resolve_query_path(&self, query: &Path) -> Result<PathBuf> {
299 if query.is_absolute() {
300 return Ok(query.to_path_buf());
301 }
302
303 let direct = self.base_dir.join(query);
304 if direct.exists() {
305 return Ok(direct);
306 }
307
308 for root in &self.query.roots {
309 let candidate = self.base_dir.join(root).join(query);
310 if candidate.exists() {
311 return Ok(candidate);
312 }
313 }
314
315 bail!("query file '{}' not found", query.display());
316 }
317
318 fn resolve_config_uri(&self, value: &str) -> String {
319 if value.contains("://") {
320 return value.to_string();
321 }
322
323 let path = Path::new(value);
324 if path.is_absolute() {
325 value.to_string()
326 } else {
327 self.base_dir.join(path).to_string_lossy().to_string()
328 }
329 }
330
331 fn resolve_config_path(&self, value: &str) -> PathBuf {
332 let path = Path::new(value);
333 if path.is_absolute() {
334 path.to_path_buf()
335 } else {
336 self.base_dir.join(path)
337 }
338 }
339}
340
341pub fn default_config_path() -> PathBuf {
342 PathBuf::from(DEFAULT_CONFIG_FILE)
343}
344
345pub fn load_config(config_path: Option<&PathBuf>) -> Result<OmnigraphConfig> {
346 load_config_in(&env::current_dir()?, config_path)
347}
348
349fn load_config_in(cwd: &Path, config_path: Option<&PathBuf>) -> Result<OmnigraphConfig> {
350 let explicit_path = config_path.cloned();
351 let config_path = explicit_path.or_else(|| {
352 let default_path = cwd.join(DEFAULT_CONFIG_FILE);
353 default_path.exists().then_some(default_path)
354 });
355
356 let mut config = if let Some(path) = &config_path {
357 serde_yaml::from_str::<OmnigraphConfig>(&fs::read_to_string(path)?)?
358 } else {
359 OmnigraphConfig::default()
360 };
361
362 config.base_dir = if let Some(path) = config_path {
363 absolute_base_dir(cwd, &path)?
364 } else {
365 cwd.to_path_buf()
366 };
367
368 Ok(config)
369}
370
371fn absolute_base_dir(cwd: &Path, path: &Path) -> Result<PathBuf> {
372 let path = if path.is_absolute() {
373 path.to_path_buf()
374 } else {
375 cwd.join(path)
376 };
377 Ok(path
378 .parent()
379 .map(Path::to_path_buf)
380 .unwrap_or_else(|| cwd.to_path_buf()))
381}
382
383#[cfg(test)]
384mod tests {
385 use std::fs;
386 use std::path::{Path, PathBuf};
387
388 use tempfile::tempdir;
389
390 use super::{ReadOutputFormat, TableCellLayout, load_config_in};
391
392 #[test]
393 fn load_config_reads_yaml_defaults_from_current_dir() {
394 let temp = tempdir().unwrap();
395 fs::write(
396 temp.path().join("omnigraph.yaml"),
397 r#"
398graphs:
399 local:
400 uri: ./demo.omni
401 bearer_token_env: DEMO_TOKEN
402auth:
403 env_file: .env.omni
404cli:
405 graph: local
406 branch: main
407 output_format: kv
408 table_max_column_width: 40
409 table_cell_layout: wrap
410policy: {}
411"#,
412 )
413 .unwrap();
414
415 let config = load_config_in(temp.path(), None).unwrap();
416 assert_eq!(config.cli_graph_name(), Some("local"));
417 assert_eq!(config.cli_branch(), "main");
418 assert_eq!(config.cli_output_format(), ReadOutputFormat::Kv);
419 assert_eq!(config.table_max_column_width(), 40);
420 assert_eq!(config.table_cell_layout(), TableCellLayout::Wrap);
421 assert_eq!(
422 config.graph_bearer_token_env(None, None, config.cli_graph_name()),
423 Some("DEMO_TOKEN")
424 );
425 assert_eq!(
426 config.resolve_auth_env_file().unwrap(),
427 temp.path().join(".env.omni")
428 );
429 assert_eq!(
430 PathBuf::from(
431 config
432 .resolve_target_uri(None, None, config.cli_graph_name())
433 .unwrap()
434 ),
435 temp.path().join("./demo.omni")
436 );
437 }
438
439 #[test]
440 fn load_config_does_not_walk_parent_directories() {
441 let temp = tempdir().unwrap();
442 let child = temp.path().join("child");
443 fs::create_dir_all(&child).unwrap();
444 fs::write(
445 temp.path().join("omnigraph.yaml"),
446 "graphs:\n local:\n uri: ./demo.omni\n",
447 )
448 .unwrap();
449
450 let config = load_config_in(&child, None).unwrap();
451 assert!(config.graphs.is_empty());
452 }
453
454 #[test]
455 fn resolve_query_path_searches_config_roots() {
456 let temp = tempdir().unwrap();
457 fs::create_dir_all(temp.path().join("queries")).unwrap();
458 fs::write(
459 temp.path().join("omnigraph.yaml"),
460 "query:\n roots:\n - queries\npolicy: {}\n",
461 )
462 .unwrap();
463 fs::write(
464 temp.path().join("queries").join("test.gq"),
465 "query q { return {} }",
466 )
467 .unwrap();
468
469 let config = load_config_in(temp.path(), None).unwrap();
470 let resolved = config.resolve_query_path(Path::new("test.gq")).unwrap();
471 assert_eq!(resolved, temp.path().join("queries").join("test.gq"));
472 }
473
474 #[test]
475 fn resolve_query_path_prefers_config_base_dir_over_ambient_cwd() {
476 let workspace = tempdir().unwrap();
477 let config_dir = workspace.path().join("config");
478 let ambient_dir = workspace.path().join("ambient");
479 fs::create_dir_all(&config_dir).unwrap();
480 fs::create_dir_all(&ambient_dir).unwrap();
481 fs::write(config_dir.join("omnigraph.yaml"), "policy: {}\n").unwrap();
482 fs::write(config_dir.join("local.gq"), "query local { return {} }").unwrap();
483 fs::write(ambient_dir.join("local.gq"), "query ambient { return {} }").unwrap();
484
485 let config =
486 load_config_in(&ambient_dir, Some(&config_dir.join("omnigraph.yaml"))).unwrap();
487 let resolved = config.resolve_query_path(Path::new("local.gq")).unwrap();
488
489 assert_eq!(resolved, config_dir.join("local.gq"));
490 }
491
492 #[test]
493 fn policy_block_accepts_non_empty_mapping() {
494 let temp = tempdir().unwrap();
495 fs::write(
496 temp.path().join("omnigraph.yaml"),
497 "policy:\n file: ./policy.yaml\n",
498 )
499 .unwrap();
500
501 let config = load_config_in(temp.path(), None).unwrap();
502 assert_eq!(
503 config.resolve_policy_file().unwrap(),
504 temp.path().join("policy.yaml")
505 );
506 }
507
508 #[test]
509 fn scoped_auth_env_ignores_default_target_when_uri_is_explicit() {
510 let temp = tempdir().unwrap();
511 fs::write(
512 temp.path().join("omnigraph.yaml"),
513 r#"
514graphs:
515 demo:
516 uri: https://example.com
517 bearer_token_env: DEMO_TOKEN
518cli:
519 graph: demo
520"#,
521 )
522 .unwrap();
523
524 let config = load_config_in(temp.path(), None).unwrap();
525 assert_eq!(
526 config.graph_bearer_token_env(
527 Some("https://override.example.com"),
528 None,
529 config.cli_graph_name()
530 ),
531 None
532 );
533 assert_eq!(
534 config.graph_bearer_token_env(
535 Some("https://override.example.com"),
536 Some("demo"),
537 config.cli_graph_name()
538 ),
539 Some("DEMO_TOKEN")
540 );
541 }
542}