From 67a07cfec38441197a0c0a96e3c39c6108ee3779 Mon Sep 17 00:00:00 2001 From: Ragnor Comerford Date: Fri, 5 Jun 2026 11:48:28 +0200 Subject: [PATCH] feat(config,cli): `omnigraph use` active-context graph selection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add omnigraph use : 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. --- crates/omnigraph-cli/src/main.rs | 23 +++++- crates/omnigraph-cli/tests/cli.rs | 46 ++++++++++++ crates/omnigraph-config/src/lib.rs | 114 +++++++++++++++++++++++++++-- 3 files changed, 176 insertions(+), 7 deletions(-) diff --git a/crates/omnigraph-cli/src/main.rs b/crates/omnigraph-cli/src/main.rs index e08c117..2dfc902 100644 --- a/crates/omnigraph-cli/src/main.rs +++ b/crates/omnigraph-cli/src/main.rs @@ -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, + }, } /// 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, diff --git a/crates/omnigraph-cli/tests/cli.rs b/crates/omnigraph-cli/tests/cli.rs index e43890e..0a7c2de 100644 --- a/crates/omnigraph-cli/tests/cli.rs +++ b/crates/omnigraph-cli/tests/cli.rs @@ -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(); diff --git a/crates/omnigraph-config/src/lib.rs b/crates/omnigraph-config/src/lib.rs index 8700d33..31b1f2d 100644 --- a/crates/omnigraph-config/src/lib.rs +++ b/crates/omnigraph-config/src/lib.rs @@ -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 { 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 +/// `/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, +} + +/// Path of the active-context state file (`/state/active.yaml`). +pub fn active_context_file() -> Option { + global_config_dir().map(|dir| dir.join("state").join("active.yaml")) +} + +/// Write the active context to `/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> { + 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 { 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 { 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, + active_file: Option<&Path>, project_config_path: Option<&Path>, ) -> Result { let mut layers: Vec = 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();