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};
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    /// Default actor identity for CLI direct-engine writes (MR-722).
50    /// Used when `policy.file` is configured and the operator hasn't
51    /// passed `--as <actor>` on the command line. With policy configured
52    /// and neither this nor `--as` set, the engine-layer footgun guard
53    /// fires (no silent bypass).
54    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}