feat(config,cli): global-first layered config load

Add load_layered_config: load the global ~/.omnigraph/config.yaml layer under the
project ./omnigraph.yaml (or --config) layer and merge them, returning the merged
config, its provenance, and per-layer deprecation warnings. The CLI's load_cli_config
now uses it — so a graph/server/defaults defined only in the global config is usable
from any directory with no project file. The server stays single-layer (a deployment
manifest must not pick up ambient $HOME state).

Global and cwd-default files are optional (absent = no layer); an explicit project
--config still errors if missing. base_dir stays the highest loaded layer's config
dir so relative ad-hoc --query paths resolve as before. The CLI test harness pins
OMNIGRAPH_HOME to an empty temp dir so tests never read the developer's real global
config.
This commit is contained in:
Ragnor Comerford 2026-06-05 11:31:10 +02:00
parent d3ebc29c05
commit 059fbe4c4a
No known key found for this signature in database
4 changed files with 212 additions and 9 deletions

View file

@ -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<OmnigraphConfig> {
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)]

View file

@ -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();

View file

@ -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<TempDir> = 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('\'', "''"))
}