feat(config,cli): omnigraph use active-context graph selection

Add omnigraph use <graph>: validate the graph resolves in the merged config (loud
fail otherwise), then write ~/.omnigraph/state/active.yaml ({graph}). The layered
loader reads it as the State layer between Global and Project, so a bare command
targets the active graph — Project still overrides State, State overrides Global.
The State layer is synthetic (sets defaults.graph only) and raises no version/legacy
warnings.
This commit is contained in:
Ragnor Comerford 2026-06-05 11:48:28 +02:00
parent 2bf3e45d08
commit 67a07cfec3
No known key found for this signature in database
3 changed files with 176 additions and 7 deletions

View file

@ -26,8 +26,8 @@ use omnigraph_compiler::{
json_params_to_param_map, lint_query_file,
};
use omnigraph_config::{
AliasCommand, GraphLocator, OmnigraphConfig, Provenance, ReadOutputFormat,
graph_resource_id_for_selection, load_layered_config,
ActiveContext, AliasCommand, GraphLocator, OmnigraphConfig, Provenance, ReadOutputFormat,
graph_resource_id_for_selection, load_layered_config, write_active_context,
};
use omnigraph_policy::{
PolicyAction, PolicyDecision, PolicyEngine, PolicyRequest, PolicyTestConfig,
@ -318,6 +318,14 @@ enum Command {
#[command(subcommand)]
command: ConfigCommand,
},
/// Set the active graph (writes `~/.omnigraph/state/active.yaml`).
Use {
/// Graph to make active (must resolve in the merged config).
graph: String,
/// Project config file (defaults to ./omnigraph.yaml).
#[arg(long)]
config: Option<PathBuf>,
},
}
/// Operations on the graph registry of a multi-graph server (MR-668).
@ -3200,6 +3208,17 @@ async fn main() -> Result<()> {
)?;
}
},
Command::Use { graph, config } => {
// Validate the graph resolves against the current merged config before
// making it the active default (loud fail on an unknown graph).
let resolved = load_cli_config(config.as_ref())?;
resolved.resolve_graph(None, Some(&graph))?;
write_active_context(&ActiveContext {
graph: graph.clone(),
server: None,
})?;
println!("active graph set to '{graph}'");
}
Command::Optimize {
uri,
target,

View file

@ -367,6 +367,52 @@ fn config_view_resolved_prints_embedded_and_remote_locators() {
assert_eq!(remote["graph_id"], "prod");
}
#[test]
fn use_sets_active_graph_targeted_by_bare_commands() {
let graph_dir = tempdir().unwrap();
let graph = graph_path(graph_dir.path());
init_graph(&graph);
// A custom global home that both defines the graph and receives the state file.
let home_dir = tempdir().unwrap();
write_config(
&home_dir.path().join("config.yaml"),
&format!(
"version: 1\ngraphs:\n g:\n storage: {}\n",
yaml_string(&graph.to_string_lossy())
),
);
let empty_cwd = tempdir().unwrap();
// `use` of an unknown graph fails loudly.
output_failure(
cli()
.env("OMNIGRAPH_HOME", home_dir.path())
.current_dir(empty_cwd.path())
.arg("use")
.arg("nonexistent"),
);
// `use g` records the active context.
output_success(
cli()
.env("OMNIGRAPH_HOME", home_dir.path())
.current_dir(empty_cwd.path())
.arg("use")
.arg("g"),
);
// A bare command (no --graph) now targets the active graph.
let output = output_success(
cli()
.env("OMNIGRAPH_HOME", home_dir.path())
.current_dir(empty_cwd.path())
.arg("snapshot")
.arg("--json"),
);
assert!(parse_stdout_json(&output).is_object());
}
#[test]
fn schema_plan_json_reports_supported_additive_change() {
let temp = tempdir().unwrap();

View file

@ -5,7 +5,7 @@ use std::fs;
use std::path::{Path, PathBuf};
use clap::ValueEnum;
use color_eyre::eyre::{Result, bail};
use color_eyre::eyre::{Result, bail, eyre};
use serde::{Deserialize, Serialize};
mod merge;
@ -1061,6 +1061,51 @@ pub fn global_config_file() -> Option<PathBuf> {
global_config_file_from(|key| env::var_os(key), dirs::home_dir())
}
/// The active context selected by `omnigraph use` — RFC-002 §5. A thin pointer to
/// the default graph (and optionally its server), written to
/// `<global>/state/active.yaml` and read as the `State` layer (between global and
/// project) so a bare command targets the active graph.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ActiveContext {
pub graph: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub server: Option<String>,
}
/// Path of the active-context state file (`<global>/state/active.yaml`).
pub fn active_context_file() -> Option<PathBuf> {
global_config_dir().map(|dir| dir.join("state").join("active.yaml"))
}
/// Write the active context to `<global>/state/active.yaml`, creating the
/// `state/` directory. Errors if no global dir resolves (set `OMNIGRAPH_HOME`).
pub fn write_active_context(context: &ActiveContext) -> Result<()> {
let path = active_context_file()
.ok_or_else(|| eyre!("cannot locate the global config dir; set OMNIGRAPH_HOME or $HOME"))?;
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
fs::write(&path, serde_yaml::to_string(context)?)?;
Ok(())
}
/// Build the synthetic `State` layer config from an active-context file, if it
/// exists: a thin config whose only effect is setting `defaults.graph` (and, when
/// present, the default server). Marked not-loaded-from-file so it raises no
/// version/legacy warnings.
fn load_state_layer(path: &Path) -> Result<Option<OmnigraphConfig>> {
if !path.exists() {
return Ok(None);
}
let context: ActiveContext = serde_yaml::from_str(&fs::read_to_string(path)?)?;
let mut config = OmnigraphConfig::default();
config.defaults.graph = Some(context.graph);
if let Some(parent) = path.parent() {
config.base_dir = parent.to_path_buf();
}
Ok(Some(config))
}
pub fn load_config(config_path: Option<&PathBuf>) -> Result<OmnigraphConfig> {
load_config_in(&env::current_dir()?, config_path)
}
@ -1100,9 +1145,11 @@ pub struct LayeredConfig {
pub fn load_layered_config(project_config_path: Option<&PathBuf>) -> Result<LayeredConfig> {
let cwd = env::current_dir()?;
let global = global_config_file();
let active = active_context_file();
load_layered_config_in(
&cwd,
global.as_deref(),
active.as_deref(),
project_config_path.map(PathBuf::as_path),
)
}
@ -1116,6 +1163,7 @@ pub fn load_layered_config(project_config_path: Option<&PathBuf>) -> Result<Laye
pub fn load_layered_config_in(
cwd: &Path,
global_file: Option<&Path>,
active_file: Option<&Path>,
project_config_path: Option<&Path>,
) -> Result<LayeredConfig> {
let mut layers: Vec<LoadedLayer> = Vec::new();
@ -1130,8 +1178,15 @@ pub fn load_layered_config_in(
}
}
// The active-context State layer (from `omnigraph use`) slots here, between
// Global and Project, in a later change.
// Active-context State layer (from `omnigraph use`): between Global and Project.
if let Some(active) = active_file {
if let Some(config) = load_state_layer(active)? {
layers.push(LoadedLayer {
layer: Layer::State,
config,
});
}
}
let project_path = project_config_path.map(Path::to_path_buf).or_else(|| {
let default_path = cwd.join(DEFAULT_CONFIG_FILE);
@ -1406,7 +1461,8 @@ query:
)
.unwrap();
let layered = load_layered_config_in(project_dir.path(), Some(&global_file), None).unwrap();
let layered =
load_layered_config_in(project_dir.path(), Some(&global_file), None, 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);
@ -1438,12 +1494,60 @@ query:
.unwrap();
let empty_cwd = tempdir().unwrap();
let layered = load_layered_config_in(empty_cwd.path(), Some(&global_file), None).unwrap();
let layered =
load_layered_config_in(empty_cwd.path(), Some(&global_file), None, 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 state_layer_overrides_global_but_not_project() {
let global_dir = tempdir().unwrap();
let global_file = global_dir.path().join("config.yaml");
fs::write(
&global_file,
"version: 1\ndefaults:\n graph: from_global\n",
)
.unwrap();
let state_dir = tempdir().unwrap();
let active = state_dir.path().join("active.yaml");
fs::write(&active, "graph: from_state\n").unwrap();
let project_dir = tempdir().unwrap();
fs::write(
project_dir.path().join("omnigraph.yaml"),
"version: 1\ndefaults:\n graph: from_project\n",
)
.unwrap();
// Project > State > Global.
let with_project =
load_layered_config_in(project_dir.path(), Some(&global_file), Some(&active), None)
.unwrap();
assert_eq!(
with_project.config.default_graph_name(),
Some("from_project")
);
assert_eq!(
with_project.provenance.origin("defaults.graph"),
Some(Layer::Project)
);
// No project file (empty cwd) ⇒ State > Global.
let empty_cwd = tempdir().unwrap();
let without_project =
load_layered_config_in(empty_cwd.path(), Some(&global_file), Some(&active), None)
.unwrap();
assert_eq!(
without_project.config.default_graph_name(),
Some("from_state")
);
assert_eq!(
without_project.provenance.origin("defaults.graph"),
Some(Layer::State)
);
}
#[test]
fn load_config_reads_yaml_defaults_from_current_dir() {
let temp = tempdir().unwrap();