From 2bf3e45d088efeebfad1afec2c66031f8b620835 Mon Sep 17 00:00:00 2001 From: Ragnor Comerford Date: Fri, 5 Jun 2026 11:41:32 +0200 Subject: [PATCH] feat(cli): `omnigraph config view` (merged config / origins / locator) Add config view [--resolved] [--show-origin] [--json] []: prints the merged layered config (null/empty values pruned for readability), or with --show-origin the layer each field came from (sorted, deterministic), or with --resolved the typed GraphLocator (embedded vs remote). Suppress the always-empty legacy project:/server: blocks on serialize. --- crates/omnigraph-cli/src/main.rs | 188 ++++++++++++++++++++++++++++- crates/omnigraph-cli/tests/cli.rs | 93 ++++++++++++++ crates/omnigraph-config/src/lib.rs | 7 +- 3 files changed, 284 insertions(+), 4 deletions(-) diff --git a/crates/omnigraph-cli/src/main.rs b/crates/omnigraph-cli/src/main.rs index e2f1199..e08c117 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, ReadOutputFormat, graph_resource_id_for_selection, - load_layered_config, + AliasCommand, GraphLocator, OmnigraphConfig, Provenance, ReadOutputFormat, + graph_resource_id_for_selection, load_layered_config, }; use omnigraph_policy::{ PolicyAction, PolicyDecision, PolicyEngine, PolicyRequest, PolicyTestConfig, @@ -313,6 +313,11 @@ enum Command { #[command(subcommand)] command: GraphsCommand, }, + /// Inspect the merged configuration (global + project layers). + Config { + #[command(subcommand)] + command: ConfigCommand, + }, } /// Operations on the graph registry of a multi-graph server (MR-668). @@ -512,6 +517,27 @@ enum PolicyCommand { }, } +#[derive(Debug, Subcommand)] +enum ConfigCommand { + /// Print the merged configuration and, optionally, where each value came from. + View { + /// Project config file (defaults to ./omnigraph.yaml). + #[arg(long)] + config: Option, + /// Resolve the named graph (or `defaults.graph`) to its typed locator. + #[arg(long)] + resolved: bool, + /// Annotate each value with the layer (global/project) it came from. + #[arg(long = "show-origin")] + show_origin: bool, + /// Emit JSON instead of YAML. + #[arg(long)] + json: bool, + /// Graph to resolve with `--resolved` (defaults to `defaults.graph`). + graph: Option, + }, +} + #[derive(Debug, Subcommand)] enum QueriesCommand { /// Type-check the stored-query registry against the live schema. @@ -787,6 +813,147 @@ fn load_cli_config(config_path: Option<&PathBuf>) -> Result { Ok(loaded.config) } +/// `config view`: print the merged config, its per-field origin, or a resolved +/// graph locator. Reads the layered config directly (it needs the provenance the +/// CLI's normal path drops). +fn config_view( + config_path: Option<&PathBuf>, + resolved: bool, + show_origin: bool, + json: bool, + graph: Option<&str>, +) -> Result<()> { + let loaded = load_layered_config(config_path)?; + for warning in &loaded.warnings { + eprintln!("warning: {warning}"); + } + + if resolved { + let locator = loaded.config.resolve_graph(None, graph)?; + return print_resolved_locator(&locator, json); + } + if show_origin { + return print_config_origins(&loaded.provenance, json); + } + // Prune null/empty values so the dump shows only what is actually set. + let mut value = serde_yaml::to_value(&loaded.config)?; + prune_empty(&mut value); + if json { + println!("{}", serde_json::to_string_pretty(&value)?); + } else { + print!("{}", serde_yaml::to_string(&value)?); + } + Ok(()) +} + +/// Recursively drop `null` values and empty maps/sequences from a config dump so +/// `config view` shows only fields that are actually set. +fn prune_empty(value: &mut serde_yaml::Value) { + match value { + serde_yaml::Value::Mapping(map) => { + for (_, child) in map.iter_mut() { + prune_empty(child); + } + map.retain(|_, child| !is_empty_value(child)); + } + serde_yaml::Value::Sequence(seq) => { + for child in seq.iter_mut() { + prune_empty(child); + } + } + _ => {} + } +} + +fn is_empty_value(value: &serde_yaml::Value) -> bool { + match value { + serde_yaml::Value::Null => true, + serde_yaml::Value::Mapping(map) => map.is_empty(), + serde_yaml::Value::Sequence(seq) => seq.is_empty(), + _ => false, + } +} + +/// Print each honored field's origin layer, sorted (deterministic). +fn print_config_origins(provenance: &Provenance, json: bool) -> Result<()> { + if json { + let origins: std::collections::BTreeMap<&str, &str> = provenance + .iter() + .map(|(field, layer)| (field.as_str(), layer.label())) + .collect(); + print_json(&origins) + } else { + for (field, layer) in provenance.iter() { + println!("{field} ({})", layer.label()); + } + Ok(()) + } +} + +/// Print a resolved [`GraphLocator`] (embedded vs remote) in human or JSON form. +fn print_resolved_locator(locator: &GraphLocator, json: bool) -> Result<()> { + match locator { + GraphLocator::Embedded { + uri, + branch, + snapshot, + graph_id, + .. + } => { + if json { + print_json(&serde_json::json!({ + "kind": "embedded", + "uri": uri, + "graph_id": graph_id, + "branch": branch, + "snapshot": snapshot, + })) + } else { + println!("embedded"); + println!(" uri: {uri}"); + println!(" graph_id: {graph_id}"); + if let Some(branch) = branch { + println!(" branch: {branch}"); + } + if let Some(snapshot) = snapshot { + println!(" snapshot: {snapshot}"); + } + Ok(()) + } + } + GraphLocator::Remote { + endpoint, + server, + graph_id, + branch, + snapshot, + } => { + if json { + print_json(&serde_json::json!({ + "kind": "remote", + "endpoint": endpoint, + "server": server, + "graph_id": graph_id, + "branch": branch, + "snapshot": snapshot, + })) + } else { + println!("remote"); + println!(" endpoint: {endpoint}"); + println!(" server: {server}"); + println!(" graph_id: {graph_id}"); + if let Some(branch) = branch { + println!(" branch: {branch}"); + } + if let Some(snapshot) = snapshot { + println!(" snapshot: {snapshot}"); + } + Ok(()) + } + } + } +} + #[derive(Debug, Clone)] struct ResolvedCliGraph { uri: String, @@ -3016,6 +3183,23 @@ async fn main() -> Result<()> { print_policy_explain(&decision, &actor, &request); } }, + Command::Config { command } => match command { + ConfigCommand::View { + config, + resolved, + show_origin, + json, + graph, + } => { + config_view( + config.as_ref(), + resolved, + show_origin, + json, + graph.as_deref(), + )?; + } + }, Command::Optimize { uri, target, diff --git a/crates/omnigraph-cli/tests/cli.rs b/crates/omnigraph-cli/tests/cli.rs index 5b52b97..e43890e 100644 --- a/crates/omnigraph-cli/tests/cli.rs +++ b/crates/omnigraph-cli/tests/cli.rs @@ -274,6 +274,99 @@ fn cli_resolves_graph_from_global_config_with_no_project_file() { ); } +#[test] +fn config_view_prints_merged_config_as_yaml() { + let temp = tempdir().unwrap(); + let config = temp.path().join("omnigraph.yaml"); + write_config( + &config, + "version: 1\ngraphs:\n local:\n storage: ./l.omni\n\ + defaults:\n graph: local\n output_format: kv\n", + ); + let output = output_success(cli().arg("config").arg("view").arg("--config").arg(&config)); + let stdout = stdout_string(&output); + assert!( + stdout.contains("defaults:") && stdout.contains("local"), + "merged config should serialize defaults + graph: {stdout}" + ); + // The always-empty legacy `project:`/`server:` blocks are suppressed. + assert!( + !stdout.contains("project:") && !stdout.contains("server:"), + "legacy noise blocks must not appear: {stdout}" + ); +} + +#[test] +fn config_view_show_origin_labels_each_layer() { + let global_dir = tempdir().unwrap(); + let global_file = global_dir.path().join("config.yaml"); + write_config( + &global_file, + "version: 1\nservers:\n prod:\n endpoint: https://prod\ndefaults:\n output_format: kv\n", + ); + let project_dir = tempdir().unwrap(); + let project = project_dir.path().join("omnigraph.yaml"); + write_config( + &project, + "version: 1\ndefaults:\n graph: local\ngraphs:\n local:\n storage: ./l.omni\n", + ); + + let output = output_success( + cli() + .env("OMNIGRAPH_CONFIG", &global_file) + .arg("config") + .arg("view") + .arg("--show-origin") + .arg("--config") + .arg(&project), + ); + let stdout = stdout_string(&output); + assert!(stdout.contains("servers.prod (global)"), "{stdout}"); + assert!( + stdout.contains("defaults.output_format (global)"), + "{stdout}" + ); + assert!(stdout.contains("defaults.graph (project)"), "{stdout}"); +} + +#[test] +fn config_view_resolved_prints_embedded_and_remote_locators() { + let temp = tempdir().unwrap(); + let config = temp.path().join("omnigraph.yaml"); + write_config( + &config, + "version: 1\nservers:\n prod:\n endpoint: https://prod.example\n\ + graphs:\n local:\n storage: ./l.omni\n staging:\n server: prod\n graph_id: prod\n", + ); + + let embedded = parse_stdout_json(&output_success( + cli() + .arg("config") + .arg("view") + .arg("--resolved") + .arg("--json") + .arg("--config") + .arg(&config) + .arg("local"), + )); + assert_eq!(embedded["kind"], "embedded"); + assert!(embedded["uri"].as_str().unwrap().ends_with("l.omni")); + + let remote = parse_stdout_json(&output_success( + cli() + .arg("config") + .arg("view") + .arg("--resolved") + .arg("--json") + .arg("--config") + .arg(&config) + .arg("staging"), + )); + assert_eq!(remote["kind"], "remote"); + assert_eq!(remote["endpoint"], "https://prod.example"); + assert_eq!(remote["graph_id"], "prod"); +} + #[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 d526511..8700d33 100644 --- a/crates/omnigraph-config/src/lib.rs +++ b/crates/omnigraph-config/src/lib.rs @@ -390,7 +390,9 @@ pub struct OmnigraphConfig { /// `load_config_in`, which also rejects unsupported versions. #[serde(default)] pub version: Option, - #[serde(default)] + // Legacy, no consumer and empty after load โ€” parsed for back-compat but never + // emitted by `config view` (`skip_serializing`). + #[serde(default, skip_serializing)] pub project: ProjectConfig, /// Named remote servers (endpoints) referenced by remote graph entries /// (`graphs..server`) and `server/graph_id` addressing โ€” RFC-002 ยง1. @@ -406,7 +408,8 @@ pub struct OmnigraphConfig { pub serve: Serve, /// Legacy spelling of `serve:` (no `version:`); rejected under v1, folded /// into `serve` at load. Do not read directly โ€” use the `serve_*` accessors. - #[serde(default)] + /// Empty after load, so never emitted by `config view` (`skip_serializing`). + #[serde(default, skip_serializing)] pub server: ServerDefaults, #[serde(default)] pub auth: AuthDefaults,