//! RFC-011 Slice A scope resolution. //! //! Translates the new scope inputs (`--profile` / `--store` / operator-config //! `profiles`/`clusters`/`defaults`) into the SAME effective addressing tuple //! the existing `GraphClient` factories (`client.rs`) and the maintenance //! resolver (`helpers::resolve_storage_uri`) already consume. This is a //! translation layer that sits *in front* of those resolvers — it is purely //! additive: an explicit legacy address (`--uri`/`--target`/`--server`/ //! `--store`) wins and reproduces today's behavior exactly, so existing //! invocations are unaffected. //! //! The access path (served vs direct) is never chosen here; it falls out of the //! scope's binding × the verb's capability. The capability→scope check rejects //! mismatches (e.g. a server scope on a maintenance verb) only on the *new* //! resolution paths. use std::env; use color_eyre::Result; use color_eyre::eyre::{bail, eyre}; use crate::operator::{OperatorConfig, ScopeBinding}; use crate::planes::Capability; pub(crate) const PROFILE_ENV: &str = "OMNIGRAPH_PROFILE"; /// The effective addressing a command should use, in the terms the existing /// resolvers consume. Data/served verbs read `server`/`graph`/`uri`/`target`; /// maintenance verbs read `cluster`/`cluster_graph`. #[derive(Debug, Default, PartialEq, Eq)] pub(crate) struct ResolvedScope { pub(crate) server: Option, pub(crate) graph: Option, pub(crate) uri: Option, pub(crate) cluster: Option, pub(crate) cluster_graph: Option, } /// The raw addressing inputs for one command: the global scope flags plus the /// command's own positional URI. pub(crate) struct ScopeFlags<'a> { pub(crate) profile: Option<&'a str>, pub(crate) store: Option<&'a str>, pub(crate) server: Option<&'a str>, pub(crate) cluster: Option<&'a str>, pub(crate) graph: Option<&'a str>, pub(crate) uri: Option, } /// Resolve the scope for a command with `capability`. Precedence (RFC-011): /// 1. explicit primitive address (`uri`/`--server`/`--store`) → passthrough; /// 2. `--profile` / `OMNIGRAPH_PROFILE`; /// 3. flat `defaults.server` + `defaults.default_graph`; /// 4. nothing — downstream behaves as today. pub(crate) fn resolve_scope( op: &OperatorConfig, capability: Capability, flags: ScopeFlags<'_>, ) -> Result { // At most one explicit scope primitive may address a command — a positional // URI, `--store`, `--server`, or `--cluster` are mutually exclusive ways to // name the graph. Combining them is a contradiction, not a silent precedence. let primitives: Vec<&str> = [ flags.uri.as_deref().map(|_| "a positional URI"), flags.store.map(|_| "--store"), flags.server.map(|_| "--server"), flags.cluster.map(|_| "--cluster"), ] .into_iter() .flatten() .collect(); if primitives.len() > 1 { bail!( "{} are mutually exclusive — pick one way to address the graph", primitives.join(" and ") ); } // 1a. `--cluster` is the cluster scope primitive (maintenance): resolve its // root + select the graph with `--graph`. if let Some(cluster) = flags.cluster { return scope_from_binding( op, capability, ScopeBinding::Cluster(cluster.to_string()), flags.graph.map(str::to_string), "--cluster", ); } // 1b. Any other explicit address wins; reproduce today's behavior untouched. // `--store` is an explicit store URI — fold it into `uri`. if flags.uri.is_some() || flags.server.is_some() || flags.store.is_some() { // `--graph` selects within a multi-graph scope; a bare positional URI / // `--store` is already a single graph, so a stray `--graph` is an error // rather than a silently-dropped flag. if flags.graph.is_some() && flags.server.is_none() { bail!( "--graph selects a graph within a server or cluster scope; a positional \ URI / --store is already a single graph" ); } return Ok(ResolvedScope { server: flags.server.map(str::to_string), graph: flags.graph.map(str::to_string), uri: flags.store.map(str::to_string).or(flags.uri), ..Default::default() }); } // 2. A named profile (flag, else env). let profile_name = flags .profile .map(str::to_string) .or_else(|| env::var(PROFILE_ENV).ok().filter(|s| !s.is_empty())); if let Some(name) = profile_name { let profile = op.profile(&name).ok_or_else(|| { eyre!("unknown profile '{name}' (not defined under `profiles:` in operator config)") })?; let binding = profile.binding(&name)?; let graph = flags .graph .map(str::to_string) .or_else(|| profile.default_graph.clone()); return scope_from_binding(op, capability, binding, graph, &format!("profile '{name}'")); } // 3. Flat default server scope. if let Some(server) = op.default_server() { let graph = flags .graph .map(str::to_string) .or_else(|| op.default_graph().map(str::to_string)); return scope_from_binding( op, capability, ScopeBinding::Server(server.to_string()), graph, "operator defaults", ); } // 3b. Flat default store scope — the zero-flag local-dev default (RFC-011). // Mutually exclusive with `defaults.server` (enforced at config load). if let Some(store) = op.default_store() { return scope_from_binding( op, capability, ScopeBinding::Store(store.to_string()), flags.graph.map(str::to_string), "operator defaults", ); } // 4. Nothing resolved — leave the tuple empty; downstream falls through to // today's behavior (legacy `cli.graph` default or a no-address error). Ok(ResolvedScope::default()) } /// Map a resolved binding to the effective tuple, enforcing scope × capability /// capability (RFC-011): a server scope is served (data only); a cluster scope /// is privileged direct (maintenance/control only); a store scope is direct /// (either). fn scope_from_binding( op: &OperatorConfig, capability: Capability, binding: ScopeBinding, graph: Option, source: &str, ) -> Result { match binding { ScopeBinding::Server(server) => { if capability == Capability::Direct { bail!( "this command needs direct storage access, but {source} resolves a \ server scope; name storage explicitly with --store (or \ --cluster --graph for a managed graph)" ); } Ok(ResolvedScope { server: Some(server), graph, ..Default::default() }) } ScopeBinding::Cluster(cluster) => { if capability == Capability::Any { bail!( "{source} resolves a cluster scope, which is not valid for graph data \ commands; run data commands through a server, or use --store \ for ad-hoc direct access" ); } // A cluster value is a config name (resolved against `clusters:`) // or a literal root: an `s3://`/`file://` URI or a local cluster // directory. Only a configured name is rewritten; anything else is // passed through to the cluster-state resolver verbatim, so a bare // directory path keeps working as it did for per-command `--cluster`. let root = op .cluster_root(&cluster) .map(str::to_string) .unwrap_or(cluster); // A cluster holds many graphs; maintenance addresses one at a time. // When no `--graph`/`default_graph` is given, leave `cluster_graph` // empty and defer to the async storage-URI resolver (RFC-011 D7), // which enumerates the catalog: auto-use a sole graph, else error // and list the candidates. Ok(ResolvedScope { cluster: Some(root), cluster_graph: graph, ..Default::default() }) } ScopeBinding::Store(uri) => { if graph.is_some() { bail!( "--graph does not apply to a store scope ({source}): a store is already \ a single graph" ); } Ok(ResolvedScope { uri: Some(uri), ..Default::default() }) } } } #[cfg(test)] mod tests { use super::*; fn cfg(yaml: &str) -> OperatorConfig { serde_yaml::from_str(yaml).unwrap() } fn flags<'a>() -> ScopeFlags<'a> { ScopeFlags { profile: None, store: None, server: None, cluster: None, graph: None, uri: None, } } #[test] fn explicit_legacy_address_wins_unchanged() { let op = cfg("defaults:\n server: prod\nservers:\n prod:\n url: https://x\n"); // A positional URI given → profile/defaults are ignored entirely. let scope = resolve_scope( &op, Capability::Any, ScopeFlags { uri: Some("graph.omni".into()), ..flags() }, ) .unwrap(); assert_eq!(scope.uri.as_deref(), Some("graph.omni")); assert_eq!(scope.server, None); } #[test] fn store_flag_folds_into_uri_and_rejects_graph() { let op = OperatorConfig::default(); let scope = resolve_scope( &op, Capability::Any, ScopeFlags { store: Some("s3://b/g.omni"), ..flags() }, ) .unwrap(); assert_eq!(scope.uri.as_deref(), Some("s3://b/g.omni")); } #[test] fn scope_primitives_are_mutually_exclusive() { let op = OperatorConfig::default(); for flags in [ ScopeFlags { store: Some("s3://b/g.omni"), uri: Some("file://other.omni".into()), ..flags() }, ScopeFlags { store: Some("s3://b/g.omni"), server: Some("prod"), ..flags() }, ScopeFlags { cluster: Some("./brain"), uri: Some("file://other.omni".into()), ..flags() }, ScopeFlags { cluster: Some("./brain"), server: Some("prod"), ..flags() }, ] { let err = resolve_scope(&op, Capability::Direct, flags) .unwrap_err() .to_string(); assert!(err.contains("mutually exclusive"), "{err}"); } } #[test] fn cluster_flag_resolves_root_and_graph_for_maintenance() { let op = cfg("clusters:\n brain:\n root: s3://acme/brain\n"); let scope = resolve_scope( &op, Capability::Direct, ScopeFlags { cluster: Some("brain"), graph: Some("knowledge"), ..flags() }, ) .unwrap(); assert_eq!(scope.cluster.as_deref(), Some("s3://acme/brain")); assert_eq!(scope.cluster_graph.as_deref(), Some("knowledge")); } #[test] fn cluster_flag_accepts_a_literal_root_uri() { let op = OperatorConfig::default(); let scope = resolve_scope( &op, Capability::Direct, ScopeFlags { cluster: Some("s3://bucket/clusters/brain"), graph: Some("knowledge"), ..flags() }, ) .unwrap(); assert_eq!(scope.cluster.as_deref(), Some("s3://bucket/clusters/brain")); assert_eq!(scope.cluster_graph.as_deref(), Some("knowledge")); } #[test] fn cluster_scope_without_a_graph_defers_to_catalog_enumeration() { // RFC-011 D7: with no `--graph`/`default_graph`, resolution no longer // bails here — it resolves the cluster root and leaves `cluster_graph` // empty, deferring to the async storage-URI resolver (which enumerates // the catalog: auto-use a sole graph, else error listing candidates). let op = cfg("clusters:\n brain:\n root: s3://acme/brain\n"); let scope = resolve_scope( &op, Capability::Direct, ScopeFlags { cluster: Some("brain"), ..flags() }, ) .unwrap(); assert_eq!(scope.cluster.as_deref(), Some("s3://acme/brain")); assert_eq!(scope.cluster_graph, None); } #[test] fn graph_on_a_bare_store_or_uri_is_rejected() { let op = OperatorConfig::default(); for flags in [ ScopeFlags { uri: Some("graph.omni".into()), graph: Some("knowledge"), ..flags() }, ScopeFlags { store: Some("s3://b/g.omni"), graph: Some("knowledge"), ..flags() }, ] { let err = resolve_scope(&op, Capability::Any, flags) .unwrap_err() .to_string(); assert!(err.contains("already a single graph"), "{err}"); } } #[test] fn flat_default_store_drives_local_verbs() { // RFC-011: `defaults.store` is the zero-flag local default — no flags, // no profile → the store URI resolves as the (single-graph) store scope. let op = cfg("defaults:\n store: file:///tmp/dev.omni\n"); let scope = resolve_scope(&op, Capability::Any, flags()).unwrap(); assert_eq!(scope.uri.as_deref(), Some("file:///tmp/dev.omni")); assert_eq!(scope.server, None); } #[test] fn flat_default_store_rejects_graph() { // A store is already a single graph, so `--graph` against a default // store is a loud error. let op = cfg("defaults:\n store: file:///tmp/dev.omni\n"); let err = resolve_scope( &op, Capability::Any, ScopeFlags { graph: Some("knowledge"), ..flags() }, ) .unwrap_err() .to_string(); assert!(err.contains("does not apply to a store scope"), "{err}"); } #[test] fn flat_default_server_drives_data_verbs() { let op = cfg("defaults:\n server: prod\n default_graph: knowledge\nservers:\n prod:\n url: https://x\n"); let scope = resolve_scope(&op, Capability::Any, flags()).unwrap(); assert_eq!(scope.server.as_deref(), Some("prod")); assert_eq!(scope.graph.as_deref(), Some("knowledge")); } #[test] fn profile_server_scope_with_graph_override() { let op = cfg( "servers:\n staging:\n url: https://s\nprofiles:\n staging:\n server: staging\n default_graph: knowledge\n", ); let scope = resolve_scope( &op, Capability::Any, ScopeFlags { profile: Some("staging"), graph: Some("archive"), ..flags() }, ) .unwrap(); assert_eq!(scope.server.as_deref(), Some("staging")); assert_eq!(scope.graph.as_deref(), Some("archive")); // flag beats profile default } #[test] fn profile_cluster_scope_resolves_root_for_maintenance() { let op = cfg( "clusters:\n brain:\n root: s3://acme/brain\nprofiles:\n admin:\n cluster: brain\n default_graph: knowledge\n", ); let scope = resolve_scope( &op, Capability::Direct, ScopeFlags { profile: Some("admin"), ..flags() }, ) .unwrap(); assert_eq!(scope.cluster.as_deref(), Some("s3://acme/brain")); assert_eq!(scope.cluster_graph.as_deref(), Some("knowledge")); } #[test] fn profile_cluster_scope_with_graph_override() { // The deferral closed by this slice: a `--graph` flag overrides a // profile cluster's default_graph, exactly as it does for a server scope. let op = cfg( "clusters:\n brain:\n root: s3://acme/brain\nprofiles:\n admin:\n cluster: brain\n default_graph: knowledge\n", ); let scope = resolve_scope( &op, Capability::Direct, ScopeFlags { profile: Some("admin"), graph: Some("archive"), ..flags() }, ) .unwrap(); assert_eq!(scope.cluster.as_deref(), Some("s3://acme/brain")); assert_eq!(scope.cluster_graph.as_deref(), Some("archive")); // flag beats profile default } #[test] fn server_scope_on_maintenance_verb_errors() { let op = cfg("defaults:\n server: prod\nservers:\n prod:\n url: https://x\n"); let err = resolve_scope(&op, Capability::Direct, flags()).unwrap_err().to_string(); assert!(err.contains("direct storage access"), "{err}"); } #[test] fn cluster_scope_on_data_verb_errors() { let op = cfg( "clusters:\n brain:\n root: s3://acme/brain\nprofiles:\n admin:\n cluster: brain\n", ); let err = resolve_scope( &op, Capability::Any, ScopeFlags { profile: Some("admin"), ..flags() }, ) .unwrap_err() .to_string(); assert!(err.contains("not valid for graph data commands"), "{err}"); } #[test] fn unknown_profile_is_a_loud_error() { let op = OperatorConfig::default(); let err = resolve_scope( &op, Capability::Any, ScopeFlags { profile: Some("nope"), ..flags() }, ) .unwrap_err() .to_string(); assert!(err.contains("unknown profile 'nope'"), "{err}"); } #[test] fn no_address_resolves_empty_for_legacy_fallthrough() { let op = OperatorConfig::default(); let scope = resolve_scope(&op, Capability::Any, flags()).unwrap(); assert_eq!(scope, ResolvedScope::default()); } }