mirror of
https://github.com/ModernRelay/omnigraph.git
synced 2026-06-09 01:35:18 +02:00
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:
parent
d3ebc29c05
commit
059fbe4c4a
4 changed files with 212 additions and 9 deletions
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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('\'', "''"))
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<Omnigraph
|
|||
}
|
||||
}
|
||||
|
||||
/// The outcome of a layered config load: the merged config, its per-field
|
||||
/// [`Provenance`], and the per-layer deprecation warnings (each labelled with its
|
||||
/// layer) collected before merge.
|
||||
pub struct LayeredConfig {
|
||||
pub config: OmnigraphConfig,
|
||||
pub provenance: Provenance,
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
|
||||
/// 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<LayeredConfig> {
|
||||
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<LayeredConfig> {
|
||||
let mut layers: Vec<LoadedLayer> = 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<String> = 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();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue