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