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";
#[derive(Debug, Default, PartialEq, Eq)]
pub(crate) struct ResolvedScope {
pub(crate) server: Option<String>,
pub(crate) graph: Option<String>,
pub(crate) uri: Option<String>,
pub(crate) cluster: Option<String>,
pub(crate) cluster_graph: Option<String>,
}
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<String>,
}
pub(crate) fn resolve_scope(
op: &OperatorConfig,
capability: Capability,
flags: ScopeFlags<'_>,
) -> Result<ResolvedScope> {
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 ")
);
}
if let Some(cluster) = flags.cluster {
return scope_from_binding(
op,
capability,
ScopeBinding::Cluster(cluster.to_string()),
flags.graph.map(str::to_string),
"--cluster",
);
}
if flags.uri.is_some() || flags.server.is_some() || flags.store.is_some() {
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()
});
}
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}'"));
}
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",
);
}
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",
);
}
Ok(ResolvedScope::default())
}
fn scope_from_binding(
op: &OperatorConfig,
capability: Capability,
binding: ScopeBinding,
graph: Option<String>,
source: &str,
) -> Result<ResolvedScope> {
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 <uri> (or \
--cluster <dir> --graph <id> 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 <uri> \
for ad-hoc direct access"
);
}
let root = op
.cluster_root(&cluster)
.map(str::to_string)
.unwrap_or(cluster);
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");
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() {
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() {
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() {
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")); }
#[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() {
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")); }
#[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());
}
}