feat(cli)!: unify graph selection under --graph; --cluster is a global scope; remove --cluster-graph (#241)

RFC-011: --graph is the single graph selector across server and cluster scopes; --cluster becomes a global scope primitive; --cluster-graph removed. Maintenance dispatch unified through resolve_scope. Wrong-address guard validates each scope flag against the verb it can consume.
This commit is contained in:
Andrew Altshuler 2026-06-15 14:30:58 +03:00 committed by GitHub
parent c3d7639377
commit a09045028f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 398 additions and 205 deletions

View file

@ -42,6 +42,7 @@ 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>,
}
@ -56,17 +57,49 @@ pub(crate) fn resolve_scope(
capability: Capability,
flags: ScopeFlags<'_>,
) -> Result<ResolvedScope> {
// `--store` is its own way to address a graph; combining it with a positional
// URI or `--server` is a contradiction, not a silent precedence.
if flags.store.is_some() && (flags.uri.is_some() || flags.server.is_some()) {
// 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!(
"--store is exclusive with a positional URI and --server — pick one way to \
address the graph"
"{} are mutually exclusive — pick one way to address the graph",
primitives.join(" and ")
);
}
// 1. Any explicit address wins; reproduce today's behavior untouched.
// `--store` is an explicit store URI — fold it into `uri`.
// 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),
@ -128,8 +161,8 @@ fn scope_from_binding(
if capability == Capability::Direct {
bail!(
"this command needs direct storage access, but {source} resolves a \
server scope; name storage explicitly with --store <uri> (or a \
--cluster/--cluster-graph for a managed graph)"
server scope; name storage explicitly with --store <uri> (or \
--cluster <dir> --graph <id> for a managed graph)"
);
}
Ok(ResolvedScope {
@ -146,21 +179,25 @@ fn scope_from_binding(
direct access"
);
}
// A cluster binding is a config name (resolved against `clusters:`)
// or a literal root URI.
let root = if let Some(root) = op.cluster_root(&cluster) {
root.to_string()
} else if cluster.contains("://") {
cluster
} else {
// 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.
let Some(graph) = graph else {
bail!(
"unknown cluster '{cluster}' ({source}); define it under `clusters:` \
in operator config, or use a literal root URI"
"{source} resolves a cluster scope; pass --graph <id> to select which \
graph to maintain"
);
};
Ok(ResolvedScope {
cluster: Some(root),
cluster_graph: graph,
cluster_graph: Some(graph),
..Default::default()
})
}
@ -192,6 +229,7 @@ mod tests {
profile: None,
store: None,
server: None,
cluster: None,
graph: None,
uri: None,
}
@ -230,7 +268,7 @@ mod tests {
}
#[test]
fn store_is_exclusive_with_positional_uri_and_server() {
fn scope_primitives_are_mutually_exclusive() {
let op = OperatorConfig::default();
for flags in [
ScopeFlags {
@ -243,9 +281,93 @@ mod tests {
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::Any, flags).unwrap_err().to_string();
assert!(err.contains("--store is exclusive"), "{err}");
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_is_a_loud_error() {
let op = cfg("clusters:\n brain:\n root: s3://acme/brain\n");
let err = resolve_scope(
&op,
Capability::Direct,
ScopeFlags {
cluster: Some("brain"),
..flags()
},
)
.unwrap_err()
.to_string();
assert!(err.contains("--graph <id>"), "{err}");
}
#[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}");
}
}
@ -294,6 +416,27 @@ mod tests {
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");