//! Declared CLI "planes" (RFC-010 Slice 1). //! //! Every subcommand belongs to exactly one plane. This classification is the //! single source of truth the wrong-plane guard consumes — and that later //! RFC-010 slices (the capability surface, plane-grouped help) will consume //! too. The `command_plane` match is **exhaustive on purpose**: adding a //! `Command` variant is a compile error until its plane is declared, so the //! surface cannot silently drift from the command set. //! //! See [docs/dev/rfc-010-cli-planes-restructure.md]. use color_eyre::Result; use color_eyre::eyre::bail; use crate::cli::{Cli, Command, QueriesCommand, SchemaCommand}; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum Plane { /// Runs against a graph, embedded **or** via `--server` (the `GraphClient` /// axis). The only plane on which the data-plane addressing flags /// (`--server`/`--graph`) apply. Data, /// Direct storage access; no server. Maintenance + local-only inspection /// that must work with the server down. Storage, /// Operates on a cluster directory, not a graph URI. Control, /// Touches no graph at all — session / config / local tooling. Session, } impl std::fmt::Display for Plane { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.write_str(match self { Plane::Data => "data", Plane::Storage => "storage", Plane::Control => "control", Plane::Session => "session", }) } } /// What a command *needs*, in the user-facing vocabulary (RFC-011). This is the /// language CLI errors and `--help` speak; `Plane` stays the internal classifier /// (`Capability` is derived from it, so the two cannot drift). /// /// - `any` — graph-scoped data; served via a server scope, or direct against a /// store scope. Accepts `--server`/`--graph`. /// - `served` — requires a server. Accepts `--server`/`--graph`. /// - `direct` — storage-native; opens storage directly, never through a server. /// - `control` — operates on a cluster (control plane). /// - `local` — addresses no graph at all. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum Capability { Any, Served, Direct, Control, Local, } impl Capability { /// A human phrase for error messages (`` `optimize` is a {…} command ``). pub(crate) fn describe(self) -> &'static str { match self { Capability::Any => "data", Capability::Served => "served", Capability::Direct => "direct (storage-native)", Capability::Control => "cluster control", Capability::Local => "local", } } /// `--server`/`--graph` are served-graph addressing: they apply only to the /// capabilities that reach a graph through a server. fn accepts_server_addressing(self) -> bool { matches!(self, Capability::Any | Capability::Served) } } /// The capability a subcommand needs, derived from its `Plane` (the exhaustive /// classifier) plus the one Data→Served refinement: `graphs` is remote-only. /// /// This reflects *current enforced behavior*, so messages stay truthful: /// `queries list` is `Local` (reads config today) and `queries validate` is /// `Direct` (opens a graph directly today). Both converge to the RFC end-state /// (served / control) only when later slices re-route them. pub(crate) fn command_capability(cmd: &Command) -> Capability { if let Command::Graphs { .. } = cmd { return Capability::Served; } match command_plane(cmd) { Plane::Data => Capability::Any, Plane::Storage => Capability::Direct, Plane::Control => Capability::Control, Plane::Session => Capability::Local, } } /// The plane a subcommand belongs to. Exhaustive — a new `Command` variant /// will not compile until classified. Descends into the nested enums where /// the plane differs per subcommand (`schema plan` is storage while `schema /// show`/`apply` are data; `queries validate` opens the graph while `queries /// list` only reads config). pub(crate) fn command_plane(cmd: &Command) -> Plane { match cmd { Command::Query { .. } | Command::Mutate { .. } | Command::Load { .. } | Command::Ingest { .. } | Command::Branch { .. } | Command::Snapshot { .. } | Command::Export { .. } | Command::Commit { .. } | Command::Graphs { .. } => Plane::Data, Command::Schema { command: SchemaCommand::Show { .. } | SchemaCommand::Apply { .. }, } => Plane::Data, Command::Schema { command: SchemaCommand::Plan { .. }, } => Plane::Storage, Command::Queries { command: QueriesCommand::Validate { .. }, } => Plane::Storage, Command::Queries { command: QueriesCommand::List { .. }, } => Plane::Session, Command::Init { .. } | Command::Optimize { .. } | Command::Repair { .. } | Command::Cleanup { .. } | Command::Lint { .. } => Plane::Storage, Command::Cluster { .. } => Plane::Control, Command::Policy { .. } | Command::Embed(_) | Command::Login { .. } | Command::Logout { .. } | Command::Config { .. } | Command::Version => Plane::Session, } } /// User-facing label for a subcommand (descends one level for the nested /// families so messages read `schema plan`, `queries validate`, etc.). pub(crate) fn command_label(cmd: &Command) -> &'static str { match cmd { Command::Version => "version", Command::Login { .. } => "login", Command::Logout { .. } => "logout", Command::Config { .. } => "config", Command::Embed(_) => "embed", Command::Init { .. } => "init", Command::Load { .. } => "load", Command::Ingest { .. } => "ingest", Command::Branch { .. } => "branch", Command::Schema { command } => match command { SchemaCommand::Plan { .. } => "schema plan", SchemaCommand::Apply { .. } => "schema apply", SchemaCommand::Show { .. } => "schema show", }, Command::Lint { .. } => "lint", Command::Queries { command } => match command { QueriesCommand::Validate { .. } => "queries validate", QueriesCommand::List { .. } => "queries list", }, Command::Snapshot { .. } => "snapshot", Command::Export { .. } => "export", Command::Commit { .. } => "commit", Command::Query { .. } => "query", Command::Mutate { .. } => "mutate", Command::Policy { .. } => "policy", Command::Optimize { .. } => "optimize", Command::Repair { .. } => "repair", Command::Cleanup { .. } => "cleanup", Command::Cluster { .. } => "cluster", Command::Graphs { .. } => "graphs", } } /// The verbs that address an existing graph through a cluster scope /// (`--cluster --graph `): the storage-maintenance commands. /// `init` is storage-plane too but *creates* a graph (cluster graphs are born /// from `cluster apply`, not `init`), and `schema plan` / `lint` take a /// positional URI — none consume cluster addressing, so the guard rejects /// `--cluster`/`--graph` on them rather than silently dropping the flag. pub(crate) fn accepts_cluster_addressing(cmd: &Command) -> bool { matches!( cmd, Command::Optimize { .. } | Command::Repair { .. } | Command::Cleanup { .. } ) } /// Reject a scope-addressing flag (`--server`/`--cluster`/`--graph`) on a verb /// that cannot consume it, rather than silently dropping it (the old behavior: /// e.g. `optimize --server prod` dropped `--server` and failed later with an /// unrelated message). Each flag has a distinct valid surface: /// - `--server` → served-graph scopes (`any`/`served`); /// - `--cluster` → the cluster-maintenance verbs (optimize/repair/cleanup); /// - `--graph` → any multi-graph scope: a served scope *or* a cluster one. /// RFC-010 Slice 1, generalized for RFC-011 cluster addressing. pub(crate) fn guard_addressing(cli: &Cli) -> Result<()> { if cli.server.is_none() && cli.cluster.is_none() && cli.graph.is_none() { return Ok(()); } let capability = command_capability(&cli.command); let label = command_label(&cli.command); let cluster_ok = accepts_cluster_addressing(&cli.command); if cli.server.is_some() && !capability.accepts_server_addressing() { bail!( "`{label}` is a {} command; --server addresses a served graph and does not apply.{}", capability.describe(), remediation(capability, &cli.command), ); } if cli.cluster.is_some() && !cluster_ok { bail!( "`{label}` is a {} command; --cluster addresses a cluster-managed graph for \ maintenance (optimize/repair/cleanup) and does not apply.{}", capability.describe(), remediation(capability, &cli.command), ); } if cli.graph.is_some() && !(capability.accepts_server_addressing() || cluster_ok) { bail!( "`{label}` is a {} command; --graph selects a graph within a server or cluster \ scope and does not apply.{}", capability.describe(), remediation(capability, &cli.command), ); } Ok(()) } /// The "what to do instead" tail for a wrong-address error, by capability. /// Includes its own leading space when non-empty so the caller appends it /// directly — an empty tail (the served-addressing capabilities, which only /// reach this fn for a misplaced `--cluster`/`--graph`) leaves no trailing space. fn remediation(capability: Capability, cmd: &Command) -> &'static str { match capability { Capability::Direct => match cmd { Command::Init { .. } => " Pass a storage URI.", Command::Optimize { .. } | Command::Repair { .. } | Command::Cleanup { .. } => { " Pass a storage URI, or --cluster --graph ." } _ => " Pass a storage URI.", }, Capability::Control => " It operates on a cluster (pass --config ).", Capability::Local => " It does not address a graph.", Capability::Any | Capability::Served => "", } } #[cfg(test)] mod tests { use clap::Parser; use super::*; #[test] fn server_addressing_allowed_exactly_on_any_and_served() { // The behavior-preservation contract: `--server`/`--graph` apply to the // served-graph capabilities (`any`, `served`) and nothing else. This is // the old "Data plane only" allow set, re-expressed — graphs (the one // Data→Served verb) was already allowed. assert!(Capability::Any.accepts_server_addressing()); assert!(Capability::Served.accepts_server_addressing()); assert!(!Capability::Direct.accepts_server_addressing()); assert!(!Capability::Control.accepts_server_addressing()); assert!(!Capability::Local.accepts_server_addressing()); } #[test] fn command_capability_classifies_representative_verbs() { let cap = |args: &[&str]| { command_capability(&Cli::try_parse_from(args).unwrap().command) }; // The one Data→Served refinement — if the `graphs` guard were deleted, // every other assertion here would still pass. assert_eq!(cap(&["omnigraph", "graphs", "list"]), Capability::Served); assert_eq!(cap(&["omnigraph", "optimize", "graph.omni"]), Capability::Direct); assert_eq!(cap(&["omnigraph", "schema", "plan", "--schema", "s.pg", "graph.omni"]), Capability::Direct); assert_eq!(cap(&["omnigraph", "cluster", "status", "--config", "."]), Capability::Control); assert_eq!(cap(&["omnigraph", "version"]), Capability::Local); assert_eq!(cap(&["omnigraph", "queries", "list"]), Capability::Local); } #[test] fn every_capability_describes_distinctly() { let phrases = [ Capability::Any.describe(), Capability::Served.describe(), Capability::Direct.describe(), Capability::Control.describe(), Capability::Local.describe(), ]; for (i, a) in phrases.iter().enumerate() { assert!(!a.is_empty()); for b in &phrases[i + 1..] { assert_ne!(a, b); } } } }