diff --git a/crates/omnigraph-cli/src/main.rs b/crates/omnigraph-cli/src/main.rs index c537d36..e2f1199 100644 --- a/crates/omnigraph-cli/src/main.rs +++ b/crates/omnigraph-cli/src/main.rs @@ -27,7 +27,7 @@ use omnigraph_compiler::{ }; use omnigraph_config::{ AliasCommand, GraphLocator, OmnigraphConfig, ReadOutputFormat, graph_resource_id_for_selection, - load_config, + load_layered_config, }; use omnigraph_policy::{ PolicyAction, PolicyDecision, PolicyEngine, PolicyRequest, PolicyTestConfig, @@ -775,14 +775,16 @@ fn load_env_file_into_process(path: &Path) -> Result<()> { } fn load_cli_config(config_path: Option<&PathBuf>) -> Result { - let config = load_config(config_path)?; - for warning in config.deprecation_warnings() { + // Global-first layered load (global `~/.omnigraph/config.yaml` under the + // project `./omnigraph.yaml`); RFC-002 §4. Warnings are collected per layer. + let loaded = load_layered_config(config_path)?; + for warning in &loaded.warnings { eprintln!("warning: {warning}"); } - if let Some(path) = config.resolve_auth_env_file() { + if let Some(path) = loaded.config.resolve_auth_env_file() { load_env_file_into_process(&path)?; } - Ok(config) + Ok(loaded.config) } #[derive(Debug, Clone)] diff --git a/crates/omnigraph-cli/tests/cli.rs b/crates/omnigraph-cli/tests/cli.rs index 82bd298..5b52b97 100644 --- a/crates/omnigraph-cli/tests/cli.rs +++ b/crates/omnigraph-cli/tests/cli.rs @@ -239,6 +239,41 @@ fn init_creates_graph_successfully_on_missing_local_directory() { ); } +#[test] +fn cli_resolves_graph_from_global_config_with_no_project_file() { + // Global-first (RFC-002 §4): a graph defined only in the global config is + // usable from a working directory that has no `omnigraph.yaml`. + let graph_dir = tempdir().unwrap(); + let graph = graph_path(graph_dir.path()); + init_graph(&graph); + + let global_dir = tempdir().unwrap(); + let global_file = global_dir.path().join("config.yaml"); + write_config( + &global_file, + &format!( + "version: 1\ngraphs:\n g:\n storage: {}\ndefaults:\n graph: g\n", + yaml_string(&graph.to_string_lossy()) + ), + ); + + let empty_cwd = tempdir().unwrap(); + let output = output_success( + cli() + .current_dir(empty_cwd.path()) + .env("OMNIGRAPH_CONFIG", &global_file) + .arg("snapshot") + .arg("--graph") + .arg("g") + .arg("--json"), + ); + let json = parse_stdout_json(&output); + assert!( + json.is_object(), + "snapshot --json should print an object resolved from the global config: {json}" + ); +} + #[test] fn schema_plan_json_reports_supported_additive_change() { let temp = tempdir().unwrap(); diff --git a/crates/omnigraph-cli/tests/support/mod.rs b/crates/omnigraph-cli/tests/support/mod.rs index 45694ea..9c4d029 100644 --- a/crates/omnigraph-cli/tests/support/mod.rs +++ b/crates/omnigraph-cli/tests/support/mod.rs @@ -4,6 +4,7 @@ use std::fs; use std::net::TcpListener; use std::path::{Path, PathBuf}; use std::process::{Child, Command as StdCommand, Output, Stdio}; +use std::sync::OnceLock; use std::thread::sleep; use std::time::Duration; @@ -12,12 +13,26 @@ use reqwest::blocking::Client; use serde_json::Value; use tempfile::{TempDir, tempdir}; +/// A process-wide empty temp dir used as `OMNIGRAPH_HOME` for every spawned CLI, +/// so tests never pick up the developer's real `~/.omnigraph/config.yaml` (the +/// global config layer). Tests exercising global-first set `OMNIGRAPH_CONFIG` / +/// `OMNIGRAPH_HOME` explicitly to override this isolation. +fn isolated_omnigraph_home() -> &'static Path { + static HOME: OnceLock = OnceLock::new(); + HOME.get_or_init(|| tempdir().expect("isolated OMNIGRAPH_HOME")) + .path() +} + pub fn cli() -> Command { - Command::cargo_bin("omnigraph").unwrap() + let mut command = Command::cargo_bin("omnigraph").unwrap(); + command.env("OMNIGRAPH_HOME", isolated_omnigraph_home()); + command } pub fn cli_process() -> StdCommand { - StdCommand::new(assert_cmd::cargo::cargo_bin("omnigraph")) + let mut command = StdCommand::new(assert_cmd::cargo::cargo_bin("omnigraph")); + command.env("OMNIGRAPH_HOME", isolated_omnigraph_home()); + command } fn server_process() -> StdCommand { @@ -112,7 +127,7 @@ pub fn write_file(path: &Path, source: &str) { fs::write(path, source).unwrap(); } -fn yaml_string(value: &str) -> String { +pub fn yaml_string(value: &str) -> String { format!("'{}'", value.replace('\'', "''")) } diff --git a/crates/omnigraph-config/src/lib.rs b/crates/omnigraph-config/src/lib.rs index 3026539..d526511 100644 --- a/crates/omnigraph-config/src/lib.rs +++ b/crates/omnigraph-config/src/lib.rs @@ -35,6 +35,18 @@ pub enum Layer { Project, } +impl Layer { + /// Short human label for messages and `config view --show-origin`. + pub fn label(self) -> &'static str { + match self { + Layer::Default => "default", + Layer::Global => "global", + Layer::State => "state", + Layer::Project => "project", + } + } +} + /// Per-field origin of a merged config — a dotted field path (`defaults.graph`, /// `graphs.prod`) to the layer that supplied the winning value. Populated by the /// merge engine and consumed by `config view --show-origin`; the rest of the @@ -1069,6 +1081,90 @@ fn load_config_in(cwd: &Path, config_path: Option<&PathBuf>) -> Result, +} + +/// Load and merge the global (`~/.omnigraph/config.yaml`) layer under the project +/// (`./omnigraph.yaml` or an explicit `--config`) layer — RFC-002 §4 global-first. +/// The global layer is optional; the project layer follows [`load_config`]'s rules +/// (an explicit path errors if missing, the cwd default is optional). +pub fn load_layered_config(project_config_path: Option<&PathBuf>) -> Result { + let cwd = env::current_dir()?; + let global = global_config_file(); + load_layered_config_in( + &cwd, + global.as_deref(), + project_config_path.map(PathBuf::as_path), + ) +} + +/// Hermetic core of [`load_layered_config`]: the caller injects `cwd` and the +/// global file path. A missing global or cwd-default project file is simply "no +/// layer"; an explicit project path that is missing still errors via +/// [`load_single_layer`]. The merged `base_dir` is the highest loaded layer's +/// config dir (so a relative ad-hoc `--query` still resolves against the config's +/// directory, as before), falling back to `cwd` only when no config file loaded. +pub fn load_layered_config_in( + cwd: &Path, + global_file: Option<&Path>, + project_config_path: Option<&Path>, +) -> Result { + let mut layers: Vec = Vec::new(); + + if let Some(global) = global_file { + if global.exists() { + let config = load_single_layer(cwd, global)?; + layers.push(LoadedLayer { + layer: Layer::Global, + config, + }); + } + } + + // The active-context State layer (from `omnigraph use`) slots here, between + // Global and Project, in a later change. + + let project_path = project_config_path.map(Path::to_path_buf).or_else(|| { + let default_path = cwd.join(DEFAULT_CONFIG_FILE); + default_path.exists().then_some(default_path) + }); + if let Some(path) = project_path { + let config = load_single_layer(cwd, &path)?; + layers.push(LoadedLayer { + layer: Layer::Project, + config, + }); + } + + let warnings: Vec = layers + .iter() + .flat_map(|loaded| { + let label = loaded.layer.label(); + loaded + .config + .deprecation_warnings() + .into_iter() + .map(move |warning| format!("{label}: {warning}")) + }) + .collect(); + + let (mut config, provenance) = merge_layers(layers); + if config.base_dir.as_os_str().is_empty() { + config.base_dir = cwd.to_path_buf(); + } + Ok(LayeredConfig { + config, + provenance, + warnings, + }) +} + /// Load and fully process one config layer from `path` — version-gating, the /// legacy-key scan, `base_dir`, and `normalize_*`. The result is a self-contained /// layer. Errors if `path` is missing/unreadable: callers own the "is this file @@ -1190,7 +1286,7 @@ mod tests { use super::{ GraphLocator, Layer, ReadOutputFormat, TableCellLayout, global_config_file_from, - graph_resource_id_for_selection, load_config_in, + graph_resource_id_for_selection, load_config_in, load_layered_config_in, }; #[test] @@ -1290,6 +1386,61 @@ query: ); } + #[test] + fn load_layered_config_merges_global_under_project() { + let global_dir = tempdir().unwrap(); + let global_file = global_dir.path().join("config.yaml"); + fs::write( + &global_file, + "version: 1\nservers:\n prod:\n endpoint: https://prod\n\ + defaults:\n output_format: kv\n graph: shared\n", + ) + .unwrap(); + let project_dir = tempdir().unwrap(); + fs::write( + project_dir.path().join("omnigraph.yaml"), + "version: 1\ndefaults:\n graph: local\ngraphs:\n local:\n storage: ./l.omni\n", + ) + .unwrap(); + + let layered = load_layered_config_in(project_dir.path(), Some(&global_file), None).unwrap(); + // Global-only field inherited; per-leaf project value overrides the global. + assert!(layered.config.servers.contains_key("prod")); + assert_eq!(layered.config.default_output_format(), ReadOutputFormat::Kv); + assert_eq!(layered.config.default_graph_name(), Some("local")); + assert_eq!( + layered.provenance.origin("servers.prod"), + Some(Layer::Global) + ); + assert_eq!( + layered.provenance.origin("defaults.output_format"), + Some(Layer::Global) + ); + assert_eq!( + layered.provenance.origin("defaults.graph"), + Some(Layer::Project) + ); + } + + #[test] + fn load_layered_config_is_global_first_with_no_project_file() { + // The headline posture: a global config alone is fully honored from a + // working directory that has no `omnigraph.yaml` (RFC-002 §4). + let global_dir = tempdir().unwrap(); + let global_file = global_dir.path().join("config.yaml"); + fs::write( + &global_file, + "version: 1\ngraphs:\n personal:\n storage: ./p.omni\ndefaults:\n graph: personal\n", + ) + .unwrap(); + let empty_cwd = tempdir().unwrap(); + + let layered = load_layered_config_in(empty_cwd.path(), Some(&global_file), None).unwrap(); + assert_eq!(layered.config.default_graph_name(), Some("personal")); + assert!(layered.config.graphs.contains_key("personal")); + assert!(layered.warnings.is_empty(), "clean v1 layers must not warn"); + } + #[test] fn load_config_reads_yaml_defaults_from_current_dir() { let temp = tempdir().unwrap();