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};
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    /// Per-graph Cedar policy file (MR-668). In single-graph mode this
22    /// field is unused — the top-level `policy.file` applies. In
23    /// multi-graph mode, each `graphs.<id>.policy.file` governs that
24    /// graph's HTTP-layer Cedar enforcement.
25    #[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    /// Default actor identity for CLI direct-engine writes (MR-722).
57    /// Used when `policy.file` is configured and the operator hasn't
58    /// passed `--as <actor>` on the command line. With policy configured
59    /// and neither this nor `--as` set, the engine-layer footgun guard
60    /// fires (no silent bypass).
61    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    /// Server-level Cedar policy (MR-668). Governs management endpoints
70    /// — currently `GET /graphs`; future runtime add/remove endpoints
71    /// will plug in here too. In single-graph mode this is unused — the
72    /// top-level `policy.file` covers the single graph.
73    #[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    /// Read alias (canonical: `query`). The legacy spelling `read` is
97    /// kept as the variant name for back-compat with serialized configs
98    /// and external SDK callers; `query` is accepted on the wire via the
99    /// serde alias.
100    #[serde(alias = "query")]
101    Read,
102    /// Mutation alias (canonical: `mutate`). The legacy spelling `change`
103    /// is kept as the variant name for back-compat; `mutate` is accepted
104    /// on the wire via the serde alias.
105    #[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    /// Resolve the per-graph policy file path for the named target,
236    /// relative to the config file's `base_dir`. Returns `None` if the
237    /// target is unknown or no per-graph `policy.file` is set.
238    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    /// Resolve the server-level policy file path (used by management
248    /// endpoints). Returns `None` if `server.policy.file` is not set.
249    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    /// Resolve a raw config-supplied URI (which may be relative) to its
258    /// absolute form. URIs containing `://` are passed through as-is;
259    /// relative paths are joined with the config file's `base_dir`.
260    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}