diff --git a/crates/omnigraph-cli/src/cli.rs b/crates/omnigraph-cli/src/cli.rs new file mode 100644 index 0000000..6b59559 --- /dev/null +++ b/crates/omnigraph-cli/src/cli.rs @@ -0,0 +1,650 @@ +//! The clap surface: every command, subcommand, and argument struct +//! (moved verbatim from main.rs in the modularization). + +use super::*; + +pub(crate) const DEFAULT_BEARER_TOKEN_ENV: &str = "OMNIGRAPH_BEARER_TOKEN"; + +#[derive(Debug, Parser)] +#[command(name = "omnigraph")] +#[command(about = "Omnigraph graph database CLI")] +#[command(version = env!("CARGO_PKG_VERSION"), disable_version_flag = true)] +pub(crate) struct Cli { + /// Actor identity for direct-engine writes (MR-722). Overrides + /// `cli.actor` from `omnigraph.yaml`. When the configured policy + /// is in effect, Cedar evaluates this actor against the requested + /// action and scope; with policy configured but neither this flag + /// nor `cli.actor` set, the engine-layer footgun guard fires and + /// the write is denied (no silent bypass). Has no effect on remote + /// HTTP writes — those resolve their actor server-side from the + /// bearer token. + #[arg(long = "as", global = true, value_name = "ACTOR")] + pub(crate) as_actor: Option, + + #[command(subcommand)] + pub(crate) command: Command, +} + +#[derive(Debug, Subcommand)] +pub(crate) enum Command { + /// Print the CLI version + Version, + /// Generate, clean, or refresh explicit seed embeddings + Embed(EmbedArgs), + /// Initialize a new graph from a schema + Init { + #[arg(long)] + schema: PathBuf, + /// Graph URI (local path or s3://) + uri: String, + /// Overwrite existing schema artifacts at the URI. Without + /// this flag, init refuses to touch a URI that already holds + /// `_schema.pg`, `_schema.ir.json`, or `__schema_state.json` + /// — closes the re-init footgun (MR-668 follow-up). With the + /// flag, the operator opts in to destructive semantics. + #[arg(long)] + force: bool, + }, + /// Load data into a graph (local or remote) + Load { + /// Graph URI + uri: Option, + #[arg(long)] + target: Option, + #[arg(long)] + config: Option, + #[arg(long)] + data: PathBuf, + /// Target branch (defaults to main). Without --from it must exist. + #[arg(long)] + branch: Option, + /// Base branch to fork --branch from when it doesn't exist yet. + /// Without this flag a missing branch is an error, never a fork. + #[arg(long)] + from: Option, + /// How existing rows are handled: overwrite | append | merge. + /// Required — overwrite is destructive, so there is no default. + #[arg(long)] + mode: CliLoadMode, + #[arg(long)] + json: bool, + }, + /// Deprecated alias of `load --from ` (defaults: --mode merge, --from main) + Ingest { + /// Graph URI + uri: Option, + #[arg(long)] + target: Option, + #[arg(long)] + config: Option, + #[arg(long)] + data: PathBuf, + #[arg(long)] + branch: Option, + #[arg(long)] + from: Option, + #[arg(long, default_value = "merge")] + mode: CliLoadMode, + #[arg(long)] + json: bool, + }, + /// Branch operations + Branch { + #[command(subcommand)] + command: BranchCommand, + }, + /// Schema planning operations + Schema { + #[command(subcommand)] + command: SchemaCommand, + }, + /// Validate queries against a schema (offline) or repo (repo-backed). + /// + /// Canonical name is `lint` (matches the `omnigraph_compiler::lint` + /// module and the `OG-XXX-NNN` lint-code vocabulary). Replaces the + /// deprecated `omnigraph query lint` / `omnigraph query check` / + /// `omnigraph check` invocations — each is kept as an argv-level + /// shim that prints a one-line stderr warning and rewrites to + /// `omnigraph lint`. Aliases are deliberately *not* exposed via + /// clap's `visible_alias` because that would advertise two + /// equivalent canonical names, which agents emit interchangeably + /// (see MR-981). + Lint { + /// Graph URI + uri: Option, + #[arg(long)] + target: Option, + #[arg(long)] + config: Option, + #[arg(long)] + query: PathBuf, + #[arg(long)] + schema: Option, + #[arg(long)] + json: bool, + }, + /// Operate on the server-side stored-query registry (`queries:`). + Queries { + #[command(subcommand)] + command: QueriesCommand, + }, + /// Show graph snapshot + Snapshot { + /// Graph URI + uri: Option, + #[arg(long)] + target: Option, + #[arg(long)] + config: Option, + #[arg(long)] + branch: Option, + #[arg(long)] + json: bool, + }, + /// Export a full graph snapshot as JSONL + Export { + /// Graph URI + uri: Option, + #[arg(long)] + target: Option, + #[arg(long)] + config: Option, + #[arg(long)] + branch: Option, + #[arg(long, hide = true)] + jsonl: bool, + #[arg(long = "type")] + type_names: Vec, + #[arg(long = "table")] + table_keys: Vec, + }, + /// Commit history operations + Commit { + #[command(subcommand)] + command: CommitCommand, + }, + /// Execute a read query against a branch or snapshot. + /// + /// Canonical read endpoint. The previous name `omnigraph read` is + /// kept as a visible alias and prints a one-line deprecation warning + /// when used. Pairs with `omnigraph mutate` on the write side. + #[command(visible_alias = "read")] + Query { + /// Graph URI + #[arg(long)] + uri: Option, + #[arg(hide = true)] + legacy_uri: Option, + #[arg(long)] + target: Option, + #[arg(long)] + config: Option, + #[arg(long, conflicts_with_all = ["query", "query_string"])] + alias: Option, + #[arg(long, conflicts_with_all = ["alias", "query_string"])] + query: Option, + /// Inline GQ source — alternative to `--query ` and `--alias `. + #[arg(short = 'e', long = "query-string", value_name = "GQ", conflicts_with_all = ["query", "alias"])] + query_string: Option, + #[arg(long)] + name: Option, + #[command(flatten)] + params: ParamsArgs, + #[arg(long, conflicts_with = "snapshot")] + branch: Option, + #[arg(long, conflicts_with = "branch")] + snapshot: Option, + #[arg(long, conflicts_with = "json")] + format: Option, + #[arg(long, conflicts_with = "format")] + json: bool, + #[arg()] + alias_args: Vec, + }, + /// Execute a graph mutation query against a branch. + /// + /// Canonical mutation endpoint. The previous name `omnigraph change` + /// is kept as a visible alias and prints a one-line deprecation + /// warning when used. Pairs with `omnigraph query` on the read side. + #[command(visible_alias = "change")] + Mutate { + /// Graph URI + #[arg(long)] + uri: Option, + #[arg(hide = true)] + legacy_uri: Option, + #[arg(long)] + target: Option, + #[arg(long)] + config: Option, + #[arg(long, conflicts_with_all = ["query", "query_string"])] + alias: Option, + #[arg(long, conflicts_with_all = ["alias", "query_string"])] + query: Option, + /// Inline GQ source — alternative to `--query ` and `--alias `. + #[arg(short = 'e', long = "query-string", value_name = "GQ", conflicts_with_all = ["query", "alias"])] + query_string: Option, + #[arg(long)] + name: Option, + #[command(flatten)] + params: ParamsArgs, + #[arg(long)] + branch: Option, + #[arg(long)] + json: bool, + #[arg()] + alias_args: Vec, + }, + /// Policy administration and diagnostics + Policy { + #[command(subcommand)] + command: PolicyCommand, + }, + /// Compact small Lance fragments in every table of the graph + Optimize { + /// Graph URI + uri: Option, + #[arg(long)] + target: Option, + #[arg(long)] + config: Option, + #[arg(long)] + json: bool, + }, + /// Classify and explicitly repair manifest/head drift + Repair { + /// Graph URI + uri: Option, + #[arg(long)] + target: Option, + #[arg(long)] + config: Option, + /// Publish verified maintenance drift. Without this flag, repair only + /// previews what it would do. + #[arg(long)] + confirm: bool, + /// Also publish suspicious or unverifiable drift. Requires + /// `--confirm`; use only after operator review. + #[arg(long, requires = "confirm")] + force: bool, + #[arg(long)] + json: bool, + }, + /// Remove old Lance versions from every table of the graph (destructive) + Cleanup { + /// Graph URI + uri: Option, + #[arg(long)] + target: Option, + #[arg(long)] + config: Option, + /// Number of recent versions to keep per table. Either `--keep` or + /// `--older-than` (or both) must be set. + #[arg(long)] + keep: Option, + /// Only remove versions older than this duration. Accepts Go-style + /// durations: `7d`, `24h`, `90m`. At least one of --keep / --older-than. + #[arg(long)] + older_than: Option, + /// Required to actually run; without it, prints what would be removed + #[arg(long)] + confirm: bool, + #[arg(long)] + json: bool, + }, + /// Validate and plan read-only cluster configuration. + Cluster { + #[command(subcommand)] + command: ClusterCommand, + }, + /// Manage graphs on a multi-graph server (MR-668) + Graphs { + #[command(subcommand)] + command: GraphsCommand, + }, +} + +#[derive(Debug, Subcommand)] +pub(crate) enum ClusterCommand { + /// Validate cluster.yaml and referenced schemas, queries, and policy files. + Validate { + /// Cluster config directory containing cluster.yaml. + #[arg(long, default_value = ".")] + config: PathBuf, + /// Emit JSON instead of human text. + #[arg(long)] + json: bool, + }, + /// Produce a read-only plan by diffing cluster.yaml against __cluster/state.json. + Plan { + /// Cluster config directory containing cluster.yaml. + #[arg(long, default_value = ".")] + config: PathBuf, + /// Emit JSON instead of human text. + #[arg(long)] + json: bool, + }, + /// Apply the config-only (query/policy) subset of the plan to the local + /// cluster catalog. Graph/schema changes are deferred to a later stage. + Apply { + /// Cluster config directory containing cluster.yaml. + #[arg(long, default_value = ".")] + config: PathBuf, + /// Emit JSON instead of human text. + #[arg(long)] + json: bool, + }, + /// Record a digest-bound approval for a gated (irreversible) change, + /// e.g. a graph delete. Requires the global --as actor. + Approve { + /// Typed resource address of the gated change (e.g. graph.scratch). + resource: String, + /// Cluster config directory containing cluster.yaml. + #[arg(long, default_value = ".")] + config: PathBuf, + /// Emit JSON instead of human text. + #[arg(long)] + json: bool, + }, + /// Read the local JSON state ledger without scanning live graph resources. + Status { + /// Cluster config directory containing cluster.yaml. + #[arg(long, default_value = ".")] + config: PathBuf, + /// Emit JSON instead of human text. + #[arg(long)] + json: bool, + }, + /// Refresh existing local JSON state from declared graph observations. + Refresh { + /// Cluster config directory containing cluster.yaml. + #[arg(long, default_value = ".")] + config: PathBuf, + /// Emit JSON instead of human text. + #[arg(long)] + json: bool, + }, + /// Import initial local JSON state from declared graph observations. + Import { + /// Cluster config directory containing cluster.yaml. + #[arg(long, default_value = ".")] + config: PathBuf, + /// Emit JSON instead of human text. + #[arg(long)] + json: bool, + }, + /// Remove a held local JSON state lock after operator confirmation. + ForceUnlock { + /// Exact lock id from cluster status or a state_lock_held diagnostic. + lock_id: String, + /// Cluster config directory containing cluster.yaml. + #[arg(long, default_value = ".")] + config: PathBuf, + /// Emit JSON instead of human text. + #[arg(long)] + json: bool, + }, +} + +/// Operations on the graph registry of a multi-graph server (MR-668). +/// +/// All operations target a remote multi-graph server URL (http:// or +/// https://). Local-URI invocations return a clear error. To add or +/// remove graphs, operators edit `omnigraph.yaml` directly and restart +/// the server — runtime mutation is not exposed in v0.6.0. +#[derive(Debug, Subcommand)] +pub(crate) enum GraphsCommand { + /// List every graph registered with the multi-graph server. + List { + /// Remote server URL (e.g. `https://server.example.com`). + #[arg(long)] + uri: Option, + #[arg(long)] + target: Option, + #[arg(long)] + config: Option, + #[arg(long)] + json: bool, + }, +} + +#[derive(Debug, Subcommand)] +pub(crate) enum BranchCommand { + /// Create a new branch + Create { + /// Graph URI + #[arg(long)] + uri: Option, + #[arg(long)] + target: Option, + #[arg(long)] + config: Option, + #[arg(long)] + from: Option, + name: String, + #[arg(long)] + json: bool, + }, + /// List branches + List { + /// Graph URI + #[arg(long)] + uri: Option, + #[arg(long)] + target: Option, + #[arg(long)] + config: Option, + #[arg(long)] + json: bool, + }, + /// Delete a branch + Delete { + /// Graph URI + #[arg(long)] + uri: Option, + #[arg(long)] + target: Option, + #[arg(long)] + config: Option, + name: String, + #[arg(long)] + json: bool, + }, + /// Merge a source branch into a target branch + Merge { + /// Graph URI + #[arg(long)] + uri: Option, + #[arg(long)] + target: Option, + #[arg(long)] + config: Option, + source: String, + #[arg(long)] + into: Option, + #[arg(long)] + json: bool, + }, +} + +#[derive(Debug, Subcommand)] +pub(crate) enum SchemaCommand { + /// Plan a schema migration against the accepted persisted schema + Plan { + /// Graph URI + uri: Option, + #[arg(long)] + target: Option, + #[arg(long)] + config: Option, + #[arg(long)] + schema: PathBuf, + #[arg(long)] + json: bool, + /// Show the plan as it would execute with `--allow-data-loss`. + /// Promotes every `DropMode::Soft` step to `DropMode::Hard` + /// so the plan output reflects the destructive intent. + #[arg(long, default_value_t = false)] + allow_data_loss: bool, + }, + /// Apply a supported schema migration + Apply { + /// Graph URI + uri: Option, + #[arg(long)] + target: Option, + #[arg(long)] + config: Option, + #[arg(long)] + schema: PathBuf, + #[arg(long)] + json: bool, + /// Allow destructive (data-loss) schema changes. + /// + /// Without this flag, drops are "soft": the column or table + /// is removed from the current manifest version but prior + /// versions are retained, so `snapshot_at_version(pre_drop)` + /// can still read the dropped data until `omnigraph cleanup` + /// runs. With this flag, drops are "hard": `cleanup_old_versions` + /// runs on the affected datasets immediately after the apply, + /// making the prior data unreachable. + #[arg(long, default_value_t = false)] + allow_data_loss: bool, + }, + /// Show the current accepted schema source + #[command(alias = "get")] + Show { + /// Graph URI + uri: Option, + #[arg(long)] + target: Option, + #[arg(long)] + config: Option, + #[arg(long)] + json: bool, + }, +} + +#[derive(Debug, Subcommand)] + +pub(crate) enum CommitCommand { + /// List graph commits + List { + /// Graph URI + uri: Option, + #[arg(long)] + target: Option, + #[arg(long)] + config: Option, + #[arg(long)] + branch: Option, + #[arg(long)] + json: bool, + }, + /// Show a graph commit + Show { + /// Graph URI + #[arg(long)] + uri: Option, + #[arg(long)] + target: Option, + #[arg(long)] + config: Option, + commit_id: String, + #[arg(long)] + json: bool, + }, +} + +#[derive(Debug, Subcommand)] +pub(crate) enum PolicyCommand { + /// Validate policy YAML and compiled Cedar policy state + Validate { + #[arg(long)] + config: Option, + }, + /// Run declarative policy tests from policy.tests.yaml + Test { + #[arg(long)] + config: Option, + }, + /// Explain one policy decision locally + Explain { + #[arg(long)] + config: Option, + #[arg(long)] + actor: String, + #[arg(long)] + action: PolicyAction, + #[arg(long)] + branch: Option, + #[arg(long = "target-branch")] + target_branch: Option, + }, +} + +#[derive(Debug, Subcommand)] +pub(crate) enum QueriesCommand { + /// Type-check the stored-query registry against the live schema. + /// + /// Distinct from `omnigraph lint` (which lints one `.gq` file): + /// this validates the whole `queries:` registry — opening the graph + /// to read its schema and confirming every stored query still + /// type-checks. Exits non-zero on any breakage. + Validate { + /// Graph URI + uri: Option, + #[arg(long)] + target: Option, + #[arg(long)] + config: Option, + #[arg(long)] + json: bool, + }, + /// List the registered stored queries (name, MCP exposure, params). + List { + #[arg(long)] + target: Option, + #[arg(long)] + config: Option, + #[arg(long)] + json: bool, + }, +} + +#[derive(Debug, Args, Clone)] +pub(crate) struct ParamsArgs { + #[arg(long, conflicts_with = "params_file")] + pub(crate) params: Option, + #[arg(long, conflicts_with = "params")] + pub(crate) params_file: Option, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, ValueEnum)] +#[serde(rename_all = "snake_case")] +pub(crate) enum CliLoadMode { + Overwrite, + Append, + Merge, +} + +impl From for LoadMode { + fn from(value: CliLoadMode) -> Self { + match value { + CliLoadMode::Overwrite => LoadMode::Overwrite, + CliLoadMode::Append => LoadMode::Append, + CliLoadMode::Merge => LoadMode::Merge, + } + } +} + +impl CliLoadMode { + pub(crate) fn as_str(self) -> &'static str { + match self { + CliLoadMode::Overwrite => "overwrite", + CliLoadMode::Append => "append", + CliLoadMode::Merge => "merge", + } + } +} + diff --git a/crates/omnigraph-cli/src/helpers.rs b/crates/omnigraph-cli/src/helpers.rs new file mode 100644 index 0000000..be356a9 --- /dev/null +++ b/crates/omnigraph-cli/src/helpers.rs @@ -0,0 +1,1085 @@ +//! Resolution helpers: config/actor/graph/branch/query resolution, +//! remote HTTP, env/token handling, scaffolding (moved verbatim from +//! main.rs in the modularization). + +use super::*; + +pub(crate) fn ensure_local_graph_parent(uri: &str) -> Result<()> { + if !uri.contains("://") { + fs::create_dir_all(uri)?; + } + Ok(()) +} + +pub(crate) fn is_remote_uri(uri: &str) -> bool { + uri.starts_with("http://") || uri.starts_with("https://") +} + +pub(crate) fn remote_url(base: &str, path: &str) -> String { + format!("{}{}", base.trim_end_matches('/'), path) +} + +pub(crate) fn remote_branch_url(base: &str, branch: &str) -> Result { + let mut url = reqwest::Url::parse(&format!("{}/", base.trim_end_matches('/')))?; + url.path_segments_mut() + .map_err(|_| color_eyre::eyre::eyre!("invalid remote base url"))? + .extend(["branches", branch]); + Ok(url.to_string()) +} + +pub(crate) fn normalize_bearer_token(value: Option) -> Option { + value + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) +} + +pub(crate) fn bearer_token_from_env(var_name: &str) -> Option { + normalize_bearer_token(std::env::var(var_name).ok()) +} + +pub(crate) fn parse_env_assignment(line: &str) -> Option<(String, String)> { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + return None; + } + + let line = line.strip_prefix("export ").unwrap_or(line).trim(); + let (name, value) = line.split_once('=')?; + let name = name.trim(); + if name.is_empty() { + return None; + } + + let value = value.trim(); + let value = if value.len() >= 2 + && ((value.starts_with('"') && value.ends_with('"')) + || (value.starts_with('\'') && value.ends_with('\''))) + { + &value[1..value.len() - 1] + } else { + value + }; + + Some((name.to_string(), value.to_string())) +} + +pub(crate) fn bearer_token_from_env_file(path: &Path, var_name: &str) -> Result> { + if !path.exists() { + return Ok(None); + } + + for line in fs::read_to_string(path)?.lines() { + let Some((name, value)) = parse_env_assignment(line) else { + continue; + }; + if name == var_name { + return Ok(normalize_bearer_token(Some(value))); + } + } + + Ok(None) +} + +pub(crate) fn load_env_file_into_process(path: &Path) -> Result<()> { + if !path.exists() { + return Ok(()); + } + + for line in fs::read_to_string(path)?.lines() { + let Some((name, value)) = parse_env_assignment(line) else { + continue; + }; + if std::env::var_os(&name).is_none() { + unsafe { + std::env::set_var(name, value); + } + } + } + + Ok(()) +} + +pub(crate) fn load_cli_config(config_path: Option<&PathBuf>) -> Result { + let config = load_config(config_path)?; + if let Some(path) = config.resolve_auth_env_file() { + load_env_file_into_process(&path)?; + } + Ok(config) +} + +#[derive(Debug, Clone)] +pub(crate) struct ResolvedCliGraph { + pub(crate) uri: String, + pub(crate) selected: Option, + pub(crate) graph_id: String, + pub(crate) policy_file: Option, + pub(crate) is_remote: bool, +} + +impl ResolvedCliGraph { + pub(crate) fn selected(&self) -> Option<&str> { + self.selected.as_deref() + } +} + +pub(crate) struct ResolvedPolicyContext { + pub(crate) policy_file: PathBuf, + pub(crate) graph_id: String, +} + +pub(crate) fn resolve_policy_context(config: &OmnigraphConfig) -> Result { + let selected = config.resolve_policy_tooling_graph_selection()?; + let policy_file = config.resolve_policy_file_for(selected).ok_or_else(|| { + color_eyre::eyre::eyre!( + "policy.file or graphs..policy.file must be set in omnigraph.yaml" + ) + })?; + let graph_id = match selected { + Some(name) => graph_resource_id_for_selection(Some(name), ""), + None => graph_resource_id_for_selection(None, "default"), + }; + Ok(ResolvedPolicyContext { + policy_file, + graph_id, + }) +} + +pub(crate) fn resolve_policy_engine(context: &ResolvedPolicyContext) -> Result { + PolicyEngine::load_graph(&context.policy_file, &context.graph_id) +} + +pub(crate) fn resolve_policy_engine_for_graph(graph: &ResolvedCliGraph) -> Result { + let policy_file = graph.policy_file.as_ref().ok_or_else(|| { + color_eyre::eyre::eyre!( + "policy.file or graphs..policy.file must be set in omnigraph.yaml" + ) + })?; + PolicyEngine::load_graph(policy_file, &graph.graph_id) +} + +pub(crate) async fn open_local_db_with_policy(graph: &ResolvedCliGraph) -> Result { + let db = Omnigraph::open(&graph.uri).await?; + if graph.policy_file.is_some() { + let engine = Arc::new(resolve_policy_engine_for_graph(graph)?); + Ok(db.with_policy(engine as Arc)) + } else { + Ok(db) + } +} + +pub(crate) fn resolve_cluster_actor(cli_as: Option<&str>) -> Result> { + if let Some(actor) = cli_as { + return Ok(Some(actor.to_string())); + } + let config = load_config(None).wrap_err( + "resolving the default actor from the per-operator omnigraph.yaml (pass --as to skip this lookup)", + )?; + Ok(config.cli.actor.clone()) +} + +pub(crate) fn resolve_cli_actor<'a>(cli_as: Option<&'a str>, config: &'a OmnigraphConfig) -> Option<&'a str> { + cli_as.or(config.cli.actor.as_deref()) +} + +pub(crate) fn resolve_policy_tests_path(context: &ResolvedPolicyContext) -> PathBuf { + context.policy_file.with_file_name("policy.tests.yaml") +} + +pub(crate) fn normalize_policy_graph_uri(uri: &str) -> Result { + if is_remote_uri(uri) { + Ok(uri.trim_end_matches('/').to_string()) + } else { + Ok(normalize_root_uri(uri)?) + } +} + +pub(crate) fn resolve_remote_bearer_token( + config: &OmnigraphConfig, + explicit_uri: Option<&str>, + explicit_target: Option<&str>, +) -> Result> { + let scoped_env = + config.graph_bearer_token_env(explicit_uri, explicit_target, config.cli_graph_name()); + let mut env_names = Vec::new(); + if let Some(name) = scoped_env { + env_names.push(name.to_string()); + } + if env_names + .iter() + .all(|name| name != DEFAULT_BEARER_TOKEN_ENV) + { + env_names.push(DEFAULT_BEARER_TOKEN_ENV.to_string()); + } + + let env_file = config.resolve_auth_env_file(); + for env_name in env_names { + if let Some(token) = bearer_token_from_env(&env_name) { + return Ok(Some(token)); + } + if let Some(path) = env_file.as_ref() { + if let Some(token) = bearer_token_from_env_file(path, &env_name)? { + return Ok(Some(token)); + } + } + } + + Ok(None) +} + +pub(crate) fn build_http_client() -> Result { + Ok(reqwest::Client::new()) +} + +pub(crate) fn apply_bearer_token( + request: reqwest::RequestBuilder, + token: Option<&str>, +) -> reqwest::RequestBuilder { + if let Some(token) = token { + request.header(AUTHORIZATION, format!("Bearer {}", token)) + } else { + request + } +} + +pub(crate) async fn remote_json( + client: &reqwest::Client, + method: Method, + url: String, + body: Option, + bearer_token: Option<&str>, +) -> Result { + let request = apply_bearer_token(client.request(method, url), bearer_token); + let request = if let Some(body) = body { + request.json(&body) + } else { + request + }; + let response = request.send().await?; + let status = response.status(); + let text = response.text().await?; + if !status.is_success() { + if let Ok(error) = serde_json::from_str::(&text) { + bail!(error.error); + } + bail!("server returned {}: {}", status, text); + } + Ok(serde_json::from_str(&text)?) +} + +pub(crate) fn resolve_uri( + config: &OmnigraphConfig, + cli_uri: Option, + cli_target: Option<&str>, +) -> Result { + config.resolve_target_uri(cli_uri, cli_target, config.cli_graph_name()) +} + +pub(crate) fn resolve_cli_graph( + config: &OmnigraphConfig, + cli_uri: Option, + cli_target: Option<&str>, +) -> Result { + let selected = if cli_uri.is_some() { + None + } else { + cli_target + .map(str::to_string) + .or_else(|| config.cli_graph_name().map(str::to_string)) + }; + config.resolve_graph_selection(selected.as_deref())?; + let uri = resolve_uri(config, cli_uri, cli_target)?; + let normalized_uri = normalize_policy_graph_uri(&uri)?; + let graph_id = graph_resource_id_for_selection(selected.as_deref(), &normalized_uri); + Ok(ResolvedCliGraph { + graph_id, + is_remote: is_remote_uri(&uri), + policy_file: config.resolve_policy_file_for(selected.as_deref()), + selected, + uri, + }) +} + +pub(crate) fn resolve_local_graph( + config: &OmnigraphConfig, + cli_uri: Option, + cli_target: Option<&str>, + operation: &str, +) -> Result { + let graph = resolve_cli_graph(config, cli_uri, cli_target)?; + if graph.is_remote { + bail!( + "{} is only supported against local graph URIs in this milestone", + operation + ); + } + Ok(graph) +} + +pub(crate) fn parse_duration_arg(s: &str) -> Result { + let s = s.trim(); + if s.is_empty() { + bail!("duration is empty"); + } + let (num_part, unit) = match s + .char_indices() + .rev() + .find(|(_, c)| c.is_ascii_alphabetic()) + { + Some((i, _)) => ( + &s[..i + 1 - s[i..].chars().next().unwrap().len_utf8()], + &s[i..], + ), + None => (s, ""), + }; + let n: u64 = num_part + .parse() + .map_err(|e| color_eyre::eyre::eyre!("invalid duration '{}': {}", s, e))?; + let secs = match unit { + "" | "s" => n, + "m" => n * 60, + "h" => n * 60 * 60, + "d" => n * 60 * 60 * 24, + "w" => n * 60 * 60 * 24 * 7, + _ => bail!("unknown duration unit '{}'. Supported: s, m, h, d, w", unit), + }; + Ok(std::time::Duration::from_secs(secs)) +} + +pub(crate) fn resolve_local_uri( + config: &OmnigraphConfig, + cli_uri: Option, + cli_target: Option<&str>, + operation: &str, +) -> Result { + Ok(resolve_local_graph(config, cli_uri, cli_target, operation)?.uri) +} + +pub(crate) fn resolve_branch( + config: &OmnigraphConfig, + cli_branch: Option, + alias_branch: Option, + default_branch: &str, +) -> String { + cli_branch + .or(alias_branch) + .or_else(|| config.cli.branch.clone()) + .unwrap_or_else(|| default_branch.to_string()) +} + +pub(crate) fn resolve_read_target( + config: &OmnigraphConfig, + cli_branch: Option, + cli_snapshot: Option, + alias_branch: Option, +) -> Result { + if cli_branch.is_some() && cli_snapshot.is_some() { + bail!("read target may specify branch or snapshot, not both"); + } + Ok(read_target_from_cli( + cli_branch + .or(alias_branch) + .or_else(|| config.cli.branch.clone()), + cli_snapshot, + )) +} + +pub(crate) fn resolve_query_path( + config: &OmnigraphConfig, + explicit_query: Option<&PathBuf>, + alias_query: Option<&str>, +) -> Result { + explicit_query + .map(PathBuf::from) + .or_else(|| alias_query.map(PathBuf::from)) + .ok_or_else(|| { + color_eyre::eyre::eyre!( + "exactly one of --query, --query-string, or --alias must be provided" + ) + }) + .and_then(|query_path| config.resolve_query_path(&query_path)) +} + +pub(crate) fn resolve_query_source( + config: &OmnigraphConfig, + explicit_query: Option<&PathBuf>, + inline_query: Option<&str>, + alias_query: Option<&str>, +) -> Result { + if let Some(inline) = inline_query { + if inline.trim().is_empty() { + bail!("--query-string must not be empty"); + } + return Ok(inline.to_string()); + } + Ok(fs::read_to_string(resolve_query_path( + config, + explicit_query, + alias_query, + )?)?) +} + +pub(crate) fn parse_alias_value(value: &str) -> Value { + serde_json::from_str(value).unwrap_or_else(|_| Value::String(value.to_string())) +} + +pub(crate) fn merged_params_json( + alias_name: Option<&str>, + alias_arg_names: &[String], + alias_arg_values: &[String], + explicit: Option, +) -> Result> { + if alias_arg_values.len() > alias_arg_names.len() { + let alias = alias_name.unwrap_or(""); + bail!( + "alias '{}' expects at most {} args but got {}", + alias, + alias_arg_names.len(), + alias_arg_values.len() + ); + } + + let mut merged = serde_json::Map::new(); + for (arg_name, arg_value) in alias_arg_names.iter().zip(alias_arg_values.iter()) { + merged.insert(arg_name.clone(), parse_alias_value(arg_value)); + } + + match explicit { + Some(Value::Object(object)) => { + for (key, value) in object { + merged.insert(key, value); + } + } + Some(_) => bail!("params JSON must be an object"), + None => {} + } + + if merged.is_empty() { + Ok(None) + } else { + Ok(Some(Value::Object(merged))) + } +} + +pub(crate) fn resolve_read_format( + config: &OmnigraphConfig, + cli_format: Option, + json: bool, + alias_format: Option, +) -> ReadOutputFormat { + if json { + ReadOutputFormat::Json + } else { + cli_format + .or(alias_format) + .unwrap_or_else(|| config.cli_output_format()) + } +} + +pub(crate) fn resolve_alias<'a>( + config: &'a OmnigraphConfig, + alias_name: Option<&'a str>, + expected: AliasCommand, +) -> Result> { + let Some(alias_name) = alias_name else { + return Ok(None); + }; + let alias = config.alias(alias_name)?; + if alias.command != expected { + bail!( + "alias '{}' is a {:?} alias, not a {:?} alias", + alias_name, + alias.command, + expected + ); + } + Ok(Some((alias_name, alias))) +} + +pub(crate) fn normalize_legacy_alias_uri( + uri: Option, + target_available: bool, + alias_name: Option<&str>, + mut alias_args: Vec, +) -> (Option, Vec) { + let Some(candidate) = uri else { + return (None, alias_args); + }; + + if alias_name.is_some() && target_available { + alias_args.insert(0, candidate); + return (None, alias_args); + } + + (Some(candidate), alias_args) +} + +pub(crate) fn scaffold_config_if_missing(uri: &str) -> Result<()> { + let path = inferred_config_path(uri)?; + if path.exists() { + return Ok(()); + } + + fs::write( + path, + format!( + "\ +project: + name: Omnigraph Project + +graphs: + local: + uri: {} + # bearer_token_env: OMNIGRAPH_BEARER_TOKEN + +server: + graph: local + bind: 127.0.0.1:8080 + +cli: + graph: local + branch: main + output_format: table + table_max_column_width: 80 + table_cell_layout: truncate + +query: + roots: + - queries + - . + +aliases: + # owner: + # command: read + # query: context.gq + # name: decision_owner + # args: [slug] + # graph: local + # branch: main + # format: kv + # + # attach_trace: + # command: change + # query: mutations.gq + # name: attach_trace + # args: [decision_slug, trace_slug] + # graph: local + # branch: main + +# auth: +# env_file: ./.env.omni +# +# policy: +# file: ./policy.yaml +", + yaml_string(uri), + ), + )?; + Ok(()) +} + +pub(crate) fn inferred_config_path(uri: &str) -> Result { + if uri.contains("://") { + return Ok(omnigraph_server::config::default_config_path()); + } + + let path = Path::new(uri); + let base = if path.is_absolute() { + path.parent() + .map(Path::to_path_buf) + .unwrap_or(std::env::current_dir()?) + } else { + std::env::current_dir()?.join(path.parent().unwrap_or_else(|| Path::new("."))) + }; + Ok(base.join(omnigraph_server::config::DEFAULT_CONFIG_FILE)) +} + +pub(crate) fn read_target_from_cli(branch: Option, snapshot: Option) -> ReadTarget { + if let Some(snapshot) = snapshot { + ReadTarget::snapshot(SnapshotId::new(snapshot)) + } else { + ReadTarget::branch(branch.unwrap_or_else(|| "main".to_string())) + } +} + +pub(crate) fn load_params_json(params: &ParamsArgs) -> Result> { + match (¶ms.params, ¶ms.params_file) { + (Some(inline), None) => Ok(Some(serde_json::from_str(inline)?)), + (None, Some(path)) => Ok(Some(serde_json::from_str(&fs::read_to_string(path)?)?)), + (None, None) => Ok(None), + (Some(_), Some(_)) => bail!("only one of --params or --params-file may be provided"), + } +} + +pub(crate) fn select_named_query( + query_source: &str, + requested_name: Option<&str>, +) -> Result<(String, Vec)> { + let parsed = parse_query(query_source)?; + let query = if let Some(name) = requested_name { + parsed + .queries + .into_iter() + .find(|query| query.name == name) + .ok_or_else(|| color_eyre::eyre::eyre!("query '{}' not found", name))? + } else if parsed.queries.len() == 1 { + parsed.queries.into_iter().next().unwrap() + } else { + bail!("query file contains multiple queries; pass --name"); + }; + + Ok((query.name, query.params)) +} + +pub(crate) fn query_params_from_json( + query_params: &[omnigraph_compiler::query::ast::Param], + params_json: Option<&Value>, +) -> Result { + json_params_to_param_map(params_json, query_params, JsonParamMode::Standard) + .map_err(|err| color_eyre::eyre::eyre!(err.to_string())) +} + +pub(crate) async fn execute_query_lint( + config: &OmnigraphConfig, + cli_uri: Option, + cli_target: Option<&str>, + schema_path: Option<&PathBuf>, + query_path: &PathBuf, +) -> Result { + let resolved_query_path = resolve_query_path(config, Some(query_path), None)?; + let query_source = fs::read_to_string(&resolved_query_path)?; + let query_path = resolved_query_path.to_string_lossy().into_owned(); + + if let Some(schema_path) = schema_path { + let schema_source = fs::read_to_string(schema_path)?; + let schema = + parse_schema(&schema_source).map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?; + let catalog = + build_catalog(&schema).map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?; + return Ok(lint_query_file( + &catalog, + &query_source, + query_path, + QueryLintSchemaSource::file(schema_path.to_string_lossy().into_owned()), + )); + } + + let has_graph_target = + cli_uri.is_some() || cli_target.is_some() || config.cli_graph_name().is_some(); + if !has_graph_target { + bail!("query lint requires --schema or a resolvable graph target"); + } + + let uri = resolve_local_uri(config, cli_uri, cli_target, "query lint")?; + let db = Omnigraph::open(&uri).await?; + Ok(lint_query_file( + &db.catalog(), + &query_source, + query_path, + QueryLintSchemaSource::graph(uri), + )) +} + +pub(crate) fn resolve_selected_graph( + config: &OmnigraphConfig, + cli_uri: Option, + cli_target: Option<&str>, + operation: &str, +) -> Result<(String, Option)> { + let graph = resolve_local_graph(config, cli_uri, cli_target, operation)?; + Ok((graph.uri, graph.selected)) +} + +pub(crate) fn load_registry_or_report( + config: &OmnigraphConfig, + selected: Option<&str>, +) -> Result { + QueryRegistry::load(config, config.query_entries_for(selected)).map_err(|errors| { + color_eyre::eyre::eyre!( + "stored-query registry failed to load:\n {}", + errors + .iter() + .map(|e| e.to_string()) + .collect::>() + .join("\n ") + ) + }) +} + +pub(crate) fn graph_query_registry_names(config: &OmnigraphConfig) -> Vec<&str> { + config + .graphs + .iter() + .filter_map(|(name, graph)| (!graph.queries.is_empty()).then_some(name.as_str())) + .collect() +} + +pub(crate) fn resolve_registry_selection_for_list( + config: &OmnigraphConfig, + target: Option<&str>, +) -> Result> { + let selected = target + .map(str::to_string) + .or_else(|| config.cli_graph_name().map(str::to_string)); + if let Some(name) = selected.as_deref() { + config.resolve_graph_selection(Some(name))?; + return Ok(selected); + } + + if !config.query_entries().is_empty() { + return Ok(None); + } + + let graph_names = graph_query_registry_names(config); + if graph_names.is_empty() { + return Ok(None); + } + + bail!( + "stored-query registries are configured for graph{} {} but no graph was selected. Pass `--target {}` or set `cli.graph`.", + if graph_names.len() == 1 { "" } else { "s" }, + graph_names.join(", "), + graph_names[0], + ) +} + +pub(crate) fn validate_registry_for_catalog( + registry: &QueryRegistry, + catalog: &omnigraph_compiler::catalog::Catalog, + label: &str, +) -> omnigraph::error::Result<()> { + let report = check(registry, catalog); + if report.has_breakages() { + return Err(omnigraph::error::OmniError::manifest( + format_check_breakages(label, &report), + )); + } + Ok(()) +} + +pub(crate) async fn execute_queries_validate( + uri: Option, + target: Option, + config_path: Option<&PathBuf>, + json: bool, +) -> Result<()> { + let config = load_cli_config(config_path)?; + // One selection drives both the schema URI and the registry, so a + // positional URI and a `--target` can't validate different graphs. + let (uri, selected) = + resolve_selected_graph(&config, uri, target.as_deref(), "queries validate")?; + let registry = load_registry_or_report(&config, selected.as_deref())?; + let db = Omnigraph::open(&uri).await?; + let report = check(®istry, &db.catalog()); + + let output = QueriesValidateOutput { + ok: !report.has_breakages(), + breakages: report + .breakages + .iter() + .map(|b| QueriesIssue { + query: b.query.clone(), + message: b.message.clone(), + }) + .collect(), + warnings: report + .warnings + .iter() + .map(|w| QueriesIssue { + query: w.query.clone(), + message: w.message.clone(), + }) + .collect(), + }; + + if json { + print_json(&output)?; + } else { + if output.breakages.is_empty() { + println!( + "OK {} stored quer{} type-check against the schema", + registry.len(), + if registry.len() == 1 { "y" } else { "ies" } + ); + } + for issue in &output.breakages { + println!("ERROR query '{}': {}", issue.query, issue.message); + } + for issue in &output.warnings { + println!("WARN query '{}': {}", issue.query, issue.message); + } + } + + if report.has_breakages() { + io::stdout().flush()?; + std::process::exit(1); + } + Ok(()) +} + +pub(crate) fn execute_queries_list( + target: Option, + config_path: Option<&PathBuf>, + json: bool, +) -> Result<()> { + let config = load_cli_config(config_path)?; + let selected = resolve_registry_selection_for_list(&config, target.as_deref())?; + let registry = load_registry_or_report(&config, selected.as_deref())?; + + let output = QueriesListOutput { + queries: registry + .iter() + .map(|q| QueriesListItem { + name: q.name.clone(), + mcp_expose: q.expose, + tool_name: q.tool_name.clone(), + mutation: q.is_mutation(), + params: q + .decl + .params + .iter() + .map(|p| QueriesParam { + name: p.name.clone(), + type_name: p.type_name.clone(), + nullable: p.nullable, + }) + .collect(), + }) + .collect(), + }; + + if json { + print_json(&output)?; + } else if output.queries.is_empty() { + println!("(no stored queries registered)"); + } else { + for q in &output.queries { + let kind = if q.mutation { "mutation" } else { "read" }; + let params = q + .params + .iter() + .map(|p| { + format!( + "${}: {}{}", + p.name, + p.type_name, + if p.nullable { "?" } else { "" } + ) + }) + .collect::>() + .join(", "); + let mcp = if q.mcp_expose { + format!(" [mcp: {}]", q.tool_name.as_deref().unwrap_or(&q.name)) + } else { + String::new() + }; + println!("{kind} {}({params}){mcp}", q.name); + } + } + Ok(()) +} + +pub(crate) async fn execute_read( + uri: &str, + query_source: &str, + query_name: Option<&str>, + target: ReadTarget, + params_json: Option<&Value>, +) -> Result { + let (selected_name, query_params) = select_named_query(query_source, query_name)?; + let params = query_params_from_json(&query_params, params_json)?; + let db = Omnigraph::open(uri).await?; + let result = db + .query(target.clone(), query_source, &selected_name, ¶ms) + .await?; + Ok(read_output(selected_name, &target, result)) +} + +pub(crate) async fn execute_read_remote( + client: &reqwest::Client, + uri: &str, + query_source: &str, + query_name: Option<&str>, + target: ReadTarget, + params_json: Option<&Value>, + bearer_token: Option<&str>, +) -> Result { + let (branch, snapshot) = match &target { + ReadTarget::Branch(branch) => (Some(branch.clone()), None), + ReadTarget::Snapshot(snapshot) => (None, Some(snapshot.as_str().to_string())), + }; + remote_json( + client, + Method::POST, + remote_url(uri, "/read"), + Some(serde_json::to_value(ReadRequest { + query_source: query_source.to_string(), + query_name: query_name.map(ToOwned::to_owned), + params: params_json.cloned(), + branch, + snapshot, + })?), + bearer_token, + ) + .await +} + +pub(crate) async fn execute_change( + graph: &ResolvedCliGraph, + query_source: &str, + query_name: Option<&str>, + branch: &str, + params_json: Option<&Value>, + config: &OmnigraphConfig, + cli_as_actor: Option<&str>, +) -> Result { + let (selected_name, query_params) = select_named_query(query_source, query_name)?; + let params = query_params_from_json(&query_params, params_json)?; + let db = open_local_db_with_policy(graph).await?; + let actor = resolve_cli_actor(cli_as_actor, config); + let result = db + .mutate_as(branch, query_source, &selected_name, ¶ms, actor) + .await?; + Ok(ChangeOutput { + branch: branch.to_string(), + query_name: selected_name, + affected_nodes: result.affected_nodes, + affected_edges: result.affected_edges, + actor_id: actor.map(String::from), + }) +} + +pub(crate) fn legacy_change_request_body( + query_source: &str, + query_name: Option<&str>, + branch: &str, + params_json: Option<&Value>, +) -> Value { + let mut body = serde_json::json!({ + "query_source": query_source, + "branch": branch, + }); + if let Some(name) = query_name { + body["query_name"] = Value::String(name.to_string()); + } + if let Some(params) = params_json { + body["params"] = params.clone(); + } + body +} + +pub(crate) async fn execute_change_remote( + client: &reqwest::Client, + uri: &str, + query_source: &str, + query_name: Option<&str>, + branch: &str, + params_json: Option<&Value>, + bearer_token: Option<&str>, +) -> Result { + remote_json( + client, + Method::POST, + remote_url(uri, "/change"), + Some(legacy_change_request_body( + query_source, + query_name, + branch, + params_json, + )), + bearer_token, + ) + .await +} + +pub(crate) async fn execute_export_to_writer( + uri: &str, + branch: &str, + type_names: &[String], + table_keys: &[String], + writer: &mut W, +) -> Result<()> { + let db = Omnigraph::open(uri).await?; + db.export_jsonl_to_writer(branch, type_names, table_keys, writer) + .await?; + writer.flush()?; + Ok(()) +} + +pub(crate) async fn execute_export_remote_to_writer( + client: &reqwest::Client, + uri: &str, + branch: &str, + type_names: &[String], + table_keys: &[String], + bearer_token: Option<&str>, + writer: &mut W, +) -> Result<()> { + let request = apply_bearer_token( + client.request(Method::POST, remote_url(uri, "/export")), + bearer_token, + ) + .json(&ExportRequest { + branch: Some(branch.to_string()), + type_names: type_names.to_vec(), + table_keys: table_keys.to_vec(), + }); + let mut response = request.send().await?; + let status = response.status(); + if !status.is_success() { + let text = response.text().await?; + if let Ok(error) = serde_json::from_str::(&text) { + bail!(error.error); + } + bail!("server returned {}: {}", status, text); + } + + while let Some(chunk) = response.chunk().await? { + writer.write_all(&chunk)?; + } + writer.flush()?; + Ok(()) +} + +pub(crate) fn rewrite_deprecated_argv(args: Vec) -> Vec { + if args.len() >= 3 { + let sub = args[1].to_str(); + let sub2 = args[2].to_str(); + if sub == Some("query") && matches!(sub2, Some("lint") | Some("check")) { + let suffix = sub2.unwrap(); + eprintln!( + "warning: `omnigraph query {suffix}` is deprecated; use `omnigraph lint` instead" + ); + // Drop the leading `query` token AND normalize `check` -> `lint`. + // `check` is no longer a clap visible_alias (MR-981 §6), so the + // rewritten argv must reach the canonical `lint` subcommand + // directly. Result for `omnigraph query check --query foo.gq`: + // `omnigraph lint --query foo.gq`. + let mut out = Vec::with_capacity(args.len() - 1); + out.push(args[0].clone()); + out.push(OsString::from("lint")); + out.extend(args[3..].iter().cloned()); + return out; + } + } + if let Some(sub) = args.get(1).and_then(|s| s.to_str()) { + match sub { + "read" => { + eprintln!("warning: `omnigraph read` is deprecated; use `omnigraph query` instead") + } + "change" => eprintln!( + "warning: `omnigraph change` is deprecated; use `omnigraph mutate` instead" + ), + "check" => { + eprintln!("warning: `omnigraph check` is deprecated; use `omnigraph lint` instead"); + // Rewrite the top-level subcommand to `lint`; pass through the rest. + let mut out = Vec::with_capacity(args.len()); + out.push(args[0].clone()); + out.push(OsString::from("lint")); + out.extend(args[2..].iter().cloned()); + return out; + } + _ => {} + } + } + args +} diff --git a/crates/omnigraph-cli/src/main.rs b/crates/omnigraph-cli/src/main.rs index e9cff0c..0d6ce03 100644 --- a/crates/omnigraph-cli/src/main.rs +++ b/crates/omnigraph-cli/src/main.rs @@ -47,2613 +47,12 @@ mod read_format; use embed::{EmbedArgs, EmbedOutput, execute_embed}; use read_format::{ReadRenderOptions, render_read}; -const DEFAULT_BEARER_TOKEN_ENV: &str = "OMNIGRAPH_BEARER_TOKEN"; - -#[derive(Debug, Parser)] -#[command(name = "omnigraph")] -#[command(about = "Omnigraph graph database CLI")] -#[command(version = env!("CARGO_PKG_VERSION"), disable_version_flag = true)] -struct Cli { - /// Actor identity for direct-engine writes (MR-722). Overrides - /// `cli.actor` from `omnigraph.yaml`. When the configured policy - /// is in effect, Cedar evaluates this actor against the requested - /// action and scope; with policy configured but neither this flag - /// nor `cli.actor` set, the engine-layer footgun guard fires and - /// the write is denied (no silent bypass). Has no effect on remote - /// HTTP writes — those resolve their actor server-side from the - /// bearer token. - #[arg(long = "as", global = true, value_name = "ACTOR")] - as_actor: Option, - - #[command(subcommand)] - command: Command, -} - -#[derive(Debug, Subcommand)] -enum Command { - /// Print the CLI version - Version, - /// Generate, clean, or refresh explicit seed embeddings - Embed(EmbedArgs), - /// Initialize a new graph from a schema - Init { - #[arg(long)] - schema: PathBuf, - /// Graph URI (local path or s3://) - uri: String, - /// Overwrite existing schema artifacts at the URI. Without - /// this flag, init refuses to touch a URI that already holds - /// `_schema.pg`, `_schema.ir.json`, or `__schema_state.json` - /// — closes the re-init footgun (MR-668 follow-up). With the - /// flag, the operator opts in to destructive semantics. - #[arg(long)] - force: bool, - }, - /// Load data into a graph (local or remote) - Load { - /// Graph URI - uri: Option, - #[arg(long)] - target: Option, - #[arg(long)] - config: Option, - #[arg(long)] - data: PathBuf, - /// Target branch (defaults to main). Without --from it must exist. - #[arg(long)] - branch: Option, - /// Base branch to fork --branch from when it doesn't exist yet. - /// Without this flag a missing branch is an error, never a fork. - #[arg(long)] - from: Option, - /// How existing rows are handled: overwrite | append | merge. - /// Required — overwrite is destructive, so there is no default. - #[arg(long)] - mode: CliLoadMode, - #[arg(long)] - json: bool, - }, - /// Deprecated alias of `load --from ` (defaults: --mode merge, --from main) - Ingest { - /// Graph URI - uri: Option, - #[arg(long)] - target: Option, - #[arg(long)] - config: Option, - #[arg(long)] - data: PathBuf, - #[arg(long)] - branch: Option, - #[arg(long)] - from: Option, - #[arg(long, default_value = "merge")] - mode: CliLoadMode, - #[arg(long)] - json: bool, - }, - /// Branch operations - Branch { - #[command(subcommand)] - command: BranchCommand, - }, - /// Schema planning operations - Schema { - #[command(subcommand)] - command: SchemaCommand, - }, - /// Validate queries against a schema (offline) or repo (repo-backed). - /// - /// Canonical name is `lint` (matches the `omnigraph_compiler::lint` - /// module and the `OG-XXX-NNN` lint-code vocabulary). Replaces the - /// deprecated `omnigraph query lint` / `omnigraph query check` / - /// `omnigraph check` invocations — each is kept as an argv-level - /// shim that prints a one-line stderr warning and rewrites to - /// `omnigraph lint`. Aliases are deliberately *not* exposed via - /// clap's `visible_alias` because that would advertise two - /// equivalent canonical names, which agents emit interchangeably - /// (see MR-981). - Lint { - /// Graph URI - uri: Option, - #[arg(long)] - target: Option, - #[arg(long)] - config: Option, - #[arg(long)] - query: PathBuf, - #[arg(long)] - schema: Option, - #[arg(long)] - json: bool, - }, - /// Operate on the server-side stored-query registry (`queries:`). - Queries { - #[command(subcommand)] - command: QueriesCommand, - }, - /// Show graph snapshot - Snapshot { - /// Graph URI - uri: Option, - #[arg(long)] - target: Option, - #[arg(long)] - config: Option, - #[arg(long)] - branch: Option, - #[arg(long)] - json: bool, - }, - /// Export a full graph snapshot as JSONL - Export { - /// Graph URI - uri: Option, - #[arg(long)] - target: Option, - #[arg(long)] - config: Option, - #[arg(long)] - branch: Option, - #[arg(long, hide = true)] - jsonl: bool, - #[arg(long = "type")] - type_names: Vec, - #[arg(long = "table")] - table_keys: Vec, - }, - /// Commit history operations - Commit { - #[command(subcommand)] - command: CommitCommand, - }, - /// Execute a read query against a branch or snapshot. - /// - /// Canonical read endpoint. The previous name `omnigraph read` is - /// kept as a visible alias and prints a one-line deprecation warning - /// when used. Pairs with `omnigraph mutate` on the write side. - #[command(visible_alias = "read")] - Query { - /// Graph URI - #[arg(long)] - uri: Option, - #[arg(hide = true)] - legacy_uri: Option, - #[arg(long)] - target: Option, - #[arg(long)] - config: Option, - #[arg(long, conflicts_with_all = ["query", "query_string"])] - alias: Option, - #[arg(long, conflicts_with_all = ["alias", "query_string"])] - query: Option, - /// Inline GQ source — alternative to `--query ` and `--alias `. - #[arg(short = 'e', long = "query-string", value_name = "GQ", conflicts_with_all = ["query", "alias"])] - query_string: Option, - #[arg(long)] - name: Option, - #[command(flatten)] - params: ParamsArgs, - #[arg(long, conflicts_with = "snapshot")] - branch: Option, - #[arg(long, conflicts_with = "branch")] - snapshot: Option, - #[arg(long, conflicts_with = "json")] - format: Option, - #[arg(long, conflicts_with = "format")] - json: bool, - #[arg()] - alias_args: Vec, - }, - /// Execute a graph mutation query against a branch. - /// - /// Canonical mutation endpoint. The previous name `omnigraph change` - /// is kept as a visible alias and prints a one-line deprecation - /// warning when used. Pairs with `omnigraph query` on the read side. - #[command(visible_alias = "change")] - Mutate { - /// Graph URI - #[arg(long)] - uri: Option, - #[arg(hide = true)] - legacy_uri: Option, - #[arg(long)] - target: Option, - #[arg(long)] - config: Option, - #[arg(long, conflicts_with_all = ["query", "query_string"])] - alias: Option, - #[arg(long, conflicts_with_all = ["alias", "query_string"])] - query: Option, - /// Inline GQ source — alternative to `--query ` and `--alias `. - #[arg(short = 'e', long = "query-string", value_name = "GQ", conflicts_with_all = ["query", "alias"])] - query_string: Option, - #[arg(long)] - name: Option, - #[command(flatten)] - params: ParamsArgs, - #[arg(long)] - branch: Option, - #[arg(long)] - json: bool, - #[arg()] - alias_args: Vec, - }, - /// Policy administration and diagnostics - Policy { - #[command(subcommand)] - command: PolicyCommand, - }, - /// Compact small Lance fragments in every table of the graph - Optimize { - /// Graph URI - uri: Option, - #[arg(long)] - target: Option, - #[arg(long)] - config: Option, - #[arg(long)] - json: bool, - }, - /// Classify and explicitly repair manifest/head drift - Repair { - /// Graph URI - uri: Option, - #[arg(long)] - target: Option, - #[arg(long)] - config: Option, - /// Publish verified maintenance drift. Without this flag, repair only - /// previews what it would do. - #[arg(long)] - confirm: bool, - /// Also publish suspicious or unverifiable drift. Requires - /// `--confirm`; use only after operator review. - #[arg(long, requires = "confirm")] - force: bool, - #[arg(long)] - json: bool, - }, - /// Remove old Lance versions from every table of the graph (destructive) - Cleanup { - /// Graph URI - uri: Option, - #[arg(long)] - target: Option, - #[arg(long)] - config: Option, - /// Number of recent versions to keep per table. Either `--keep` or - /// `--older-than` (or both) must be set. - #[arg(long)] - keep: Option, - /// Only remove versions older than this duration. Accepts Go-style - /// durations: `7d`, `24h`, `90m`. At least one of --keep / --older-than. - #[arg(long)] - older_than: Option, - /// Required to actually run; without it, prints what would be removed - #[arg(long)] - confirm: bool, - #[arg(long)] - json: bool, - }, - /// Validate and plan read-only cluster configuration. - Cluster { - #[command(subcommand)] - command: ClusterCommand, - }, - /// Manage graphs on a multi-graph server (MR-668) - Graphs { - #[command(subcommand)] - command: GraphsCommand, - }, -} - -#[derive(Debug, Subcommand)] -enum ClusterCommand { - /// Validate cluster.yaml and referenced schemas, queries, and policy files. - Validate { - /// Cluster config directory containing cluster.yaml. - #[arg(long, default_value = ".")] - config: PathBuf, - /// Emit JSON instead of human text. - #[arg(long)] - json: bool, - }, - /// Produce a read-only plan by diffing cluster.yaml against __cluster/state.json. - Plan { - /// Cluster config directory containing cluster.yaml. - #[arg(long, default_value = ".")] - config: PathBuf, - /// Emit JSON instead of human text. - #[arg(long)] - json: bool, - }, - /// Apply the config-only (query/policy) subset of the plan to the local - /// cluster catalog. Graph/schema changes are deferred to a later stage. - Apply { - /// Cluster config directory containing cluster.yaml. - #[arg(long, default_value = ".")] - config: PathBuf, - /// Emit JSON instead of human text. - #[arg(long)] - json: bool, - }, - /// Record a digest-bound approval for a gated (irreversible) change, - /// e.g. a graph delete. Requires the global --as actor. - Approve { - /// Typed resource address of the gated change (e.g. graph.scratch). - resource: String, - /// Cluster config directory containing cluster.yaml. - #[arg(long, default_value = ".")] - config: PathBuf, - /// Emit JSON instead of human text. - #[arg(long)] - json: bool, - }, - /// Read the local JSON state ledger without scanning live graph resources. - Status { - /// Cluster config directory containing cluster.yaml. - #[arg(long, default_value = ".")] - config: PathBuf, - /// Emit JSON instead of human text. - #[arg(long)] - json: bool, - }, - /// Refresh existing local JSON state from declared graph observations. - Refresh { - /// Cluster config directory containing cluster.yaml. - #[arg(long, default_value = ".")] - config: PathBuf, - /// Emit JSON instead of human text. - #[arg(long)] - json: bool, - }, - /// Import initial local JSON state from declared graph observations. - Import { - /// Cluster config directory containing cluster.yaml. - #[arg(long, default_value = ".")] - config: PathBuf, - /// Emit JSON instead of human text. - #[arg(long)] - json: bool, - }, - /// Remove a held local JSON state lock after operator confirmation. - ForceUnlock { - /// Exact lock id from cluster status or a state_lock_held diagnostic. - lock_id: String, - /// Cluster config directory containing cluster.yaml. - #[arg(long, default_value = ".")] - config: PathBuf, - /// Emit JSON instead of human text. - #[arg(long)] - json: bool, - }, -} - -/// Operations on the graph registry of a multi-graph server (MR-668). -/// -/// All operations target a remote multi-graph server URL (http:// or -/// https://). Local-URI invocations return a clear error. To add or -/// remove graphs, operators edit `omnigraph.yaml` directly and restart -/// the server — runtime mutation is not exposed in v0.6.0. -#[derive(Debug, Subcommand)] -enum GraphsCommand { - /// List every graph registered with the multi-graph server. - List { - /// Remote server URL (e.g. `https://server.example.com`). - #[arg(long)] - uri: Option, - #[arg(long)] - target: Option, - #[arg(long)] - config: Option, - #[arg(long)] - json: bool, - }, -} - -#[derive(Debug, Subcommand)] -enum BranchCommand { - /// Create a new branch - Create { - /// Graph URI - #[arg(long)] - uri: Option, - #[arg(long)] - target: Option, - #[arg(long)] - config: Option, - #[arg(long)] - from: Option, - name: String, - #[arg(long)] - json: bool, - }, - /// List branches - List { - /// Graph URI - #[arg(long)] - uri: Option, - #[arg(long)] - target: Option, - #[arg(long)] - config: Option, - #[arg(long)] - json: bool, - }, - /// Delete a branch - Delete { - /// Graph URI - #[arg(long)] - uri: Option, - #[arg(long)] - target: Option, - #[arg(long)] - config: Option, - name: String, - #[arg(long)] - json: bool, - }, - /// Merge a source branch into a target branch - Merge { - /// Graph URI - #[arg(long)] - uri: Option, - #[arg(long)] - target: Option, - #[arg(long)] - config: Option, - source: String, - #[arg(long)] - into: Option, - #[arg(long)] - json: bool, - }, -} - -#[derive(Debug, Subcommand)] -enum SchemaCommand { - /// Plan a schema migration against the accepted persisted schema - Plan { - /// Graph URI - uri: Option, - #[arg(long)] - target: Option, - #[arg(long)] - config: Option, - #[arg(long)] - schema: PathBuf, - #[arg(long)] - json: bool, - /// Show the plan as it would execute with `--allow-data-loss`. - /// Promotes every `DropMode::Soft` step to `DropMode::Hard` - /// so the plan output reflects the destructive intent. - #[arg(long, default_value_t = false)] - allow_data_loss: bool, - }, - /// Apply a supported schema migration - Apply { - /// Graph URI - uri: Option, - #[arg(long)] - target: Option, - #[arg(long)] - config: Option, - #[arg(long)] - schema: PathBuf, - #[arg(long)] - json: bool, - /// Allow destructive (data-loss) schema changes. - /// - /// Without this flag, drops are "soft": the column or table - /// is removed from the current manifest version but prior - /// versions are retained, so `snapshot_at_version(pre_drop)` - /// can still read the dropped data until `omnigraph cleanup` - /// runs. With this flag, drops are "hard": `cleanup_old_versions` - /// runs on the affected datasets immediately after the apply, - /// making the prior data unreachable. - #[arg(long, default_value_t = false)] - allow_data_loss: bool, - }, - /// Show the current accepted schema source - #[command(alias = "get")] - Show { - /// Graph URI - uri: Option, - #[arg(long)] - target: Option, - #[arg(long)] - config: Option, - #[arg(long)] - json: bool, - }, -} - -#[derive(Debug, Subcommand)] - -enum CommitCommand { - /// List graph commits - List { - /// Graph URI - uri: Option, - #[arg(long)] - target: Option, - #[arg(long)] - config: Option, - #[arg(long)] - branch: Option, - #[arg(long)] - json: bool, - }, - /// Show a graph commit - Show { - /// Graph URI - #[arg(long)] - uri: Option, - #[arg(long)] - target: Option, - #[arg(long)] - config: Option, - commit_id: String, - #[arg(long)] - json: bool, - }, -} - -#[derive(Debug, Subcommand)] -enum PolicyCommand { - /// Validate policy YAML and compiled Cedar policy state - Validate { - #[arg(long)] - config: Option, - }, - /// Run declarative policy tests from policy.tests.yaml - Test { - #[arg(long)] - config: Option, - }, - /// Explain one policy decision locally - Explain { - #[arg(long)] - config: Option, - #[arg(long)] - actor: String, - #[arg(long)] - action: PolicyAction, - #[arg(long)] - branch: Option, - #[arg(long = "target-branch")] - target_branch: Option, - }, -} - -#[derive(Debug, Subcommand)] -enum QueriesCommand { - /// Type-check the stored-query registry against the live schema. - /// - /// Distinct from `omnigraph lint` (which lints one `.gq` file): - /// this validates the whole `queries:` registry — opening the graph - /// to read its schema and confirming every stored query still - /// type-checks. Exits non-zero on any breakage. - Validate { - /// Graph URI - uri: Option, - #[arg(long)] - target: Option, - #[arg(long)] - config: Option, - #[arg(long)] - json: bool, - }, - /// List the registered stored queries (name, MCP exposure, params). - List { - #[arg(long)] - target: Option, - #[arg(long)] - config: Option, - #[arg(long)] - json: bool, - }, -} - -#[derive(Debug, Args, Clone)] -struct ParamsArgs { - #[arg(long, conflicts_with = "params_file")] - params: Option, - #[arg(long, conflicts_with = "params")] - params_file: Option, -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, ValueEnum)] -#[serde(rename_all = "snake_case")] -enum CliLoadMode { - Overwrite, - Append, - Merge, -} - -impl From for LoadMode { - fn from(value: CliLoadMode) -> Self { - match value { - CliLoadMode::Overwrite => LoadMode::Overwrite, - CliLoadMode::Append => LoadMode::Append, - CliLoadMode::Merge => LoadMode::Merge, - } - } -} - -impl CliLoadMode { - fn as_str(self) -> &'static str { - match self { - CliLoadMode::Overwrite => "overwrite", - CliLoadMode::Append => "append", - CliLoadMode::Merge => "merge", - } - } -} - -#[derive(Debug, Serialize)] -struct LoadOutput { - uri: String, - branch: String, - mode: &'static str, - /// Present only when `--from` was given; echoes the requested base. - #[serde(skip_serializing_if = "Option::is_none")] - base_branch: Option, - branch_created: bool, - nodes_loaded: usize, - edges_loaded: usize, - node_types_loaded: usize, - edge_types_loaded: usize, -} - -/// Map a remote `/ingest` response onto the CLI's load output. Table keys -/// carry `node:`/`edge:` prefixes, so the per-kind sums are derivable -/// client-side without the catalog. -fn load_output_from_tables( - uri: &str, - branch: &str, - mode: CliLoadMode, - output: &IngestOutput, -) -> LoadOutput { - let mut nodes_loaded = 0; - let mut edges_loaded = 0; - let mut node_types_loaded = 0; - let mut edge_types_loaded = 0; - for table in &output.tables { - if table.table_key.starts_with("node:") { - nodes_loaded += table.rows_loaded; - node_types_loaded += 1; - } else if table.table_key.starts_with("edge:") { - edges_loaded += table.rows_loaded; - edge_types_loaded += 1; - } - } - LoadOutput { - uri: uri.to_string(), - branch: branch.to_string(), - mode: mode.as_str(), - base_branch: output.base_branch.clone(), - branch_created: output.branch_created, - nodes_loaded, - edges_loaded, - node_types_loaded, - edge_types_loaded, - } -} - -#[derive(Debug, Serialize)] -struct SchemaPlanOutput<'a> { - uri: &'a str, - supported: bool, - step_count: usize, - steps: &'a [SchemaMigrationStep], -} - -fn print_schema_apply_human(output: &SchemaApplyOutput) { - println!("schema apply for {}", output.uri); - println!("supported: {}", if output.supported { "yes" } else { "no" }); - println!("applied: {}", if output.applied { "yes" } else { "no" }); - println!("manifest_version: {}", output.manifest_version); - if output.steps.is_empty() { - println!("no schema changes"); - return; - } - for step in &output.steps { - println!("- {}", render_schema_plan_step(step)); - } -} - -fn query_kind_label(kind: QueryLintQueryKind) -> &'static str { - match kind { - QueryLintQueryKind::Read => "read", - QueryLintQueryKind::Mutation => "mutation", - } -} - -fn severity_label(severity: QueryLintSeverity) -> &'static str { - match severity { - QueryLintSeverity::Error => "ERROR", - QueryLintSeverity::Warning => "WARN ", - QueryLintSeverity::Info => "INFO ", - } -} - -fn print_query_lint_human(output: &QueryLintOutput) { - for result in &output.results { - match result.status { - QueryLintStatus::Ok => { - println!( - "OK query `{}` ({})", - result.name, - query_kind_label(result.kind) - ); - } - QueryLintStatus::Error => { - println!( - "ERROR query `{}`: {}", - result.name, - result.error.as_deref().unwrap_or("unknown error") - ); - } - } - - for warning in &result.warnings { - println!("WARN query `{}`: {}", result.name, warning); - } - } - - for finding in &output.findings { - println!("{} {}", severity_label(finding.severity), finding.message); - } - - println!( - "INFO Lint complete: {} queries processed ({} error(s), {} warning(s), {} info item(s))", - output.queries_processed, output.errors, output.warnings, output.infos - ); -} - -fn finish_query_lint(output: &QueryLintOutput, json: bool) -> Result<()> { - if json { - print_json(output)?; - } else { - print_query_lint_human(output); - } - - if output.status == QueryLintStatus::Error { - io::stdout().flush()?; - std::process::exit(1); - } - - Ok(()) -} - -fn ensure_local_graph_parent(uri: &str) -> Result<()> { - if !uri.contains("://") { - fs::create_dir_all(uri)?; - } - Ok(()) -} - -fn print_json(value: &T) -> Result<()> { - println!("{}", serde_json::to_string_pretty(value)?); - Ok(()) -} - -fn print_cluster_validate_human(output: &ValidateOutput) { - if output.ok { - println!( - "cluster config valid: {} resource(s), {} dependency edge(s)", - output.resources.len(), - output.dependencies.len() - ); - } else { - println!("cluster config invalid"); - } - print_cluster_diagnostics(&output.diagnostics); -} - -fn print_cluster_plan_human(output: &PlanOutput) { - if output.ok { - println!( - "cluster plan: {} change(s), {} approval gate(s)", - output.changes.len(), - output.approvals_required.len() - ); - for change in &output.changes { - let bindings = if change.binding_change { " [bindings]" } else { "" }; - println!(" {:?} {}{bindings}", change.operation, change.resource); - if let Some(migration) = &change.migration { - if !migration.supported { - println!(" migration UNSUPPORTED:"); - } - for step in &migration.steps { - println!( - " {}", - serde_json::to_string(step).unwrap_or_else(|_| format!("{step:?}")) - ); - } - } - } - if output.changes.is_empty() { - println!(" no changes"); - } - } else { - println!("cluster plan failed"); - } - print_cluster_diagnostics(&output.diagnostics); -} - -fn print_cluster_apply_human(output: &ApplyOutput) { - if output.ok { - println!( - "cluster apply: {} applied, {} deferred/blocked", - output.applied_count, output.deferred_count - ); - } else { - println!("cluster apply failed"); - } - // The change list prints on failure too: an operator debugging a partial - // apply (payload or state-write error) needs to see what was attempted. - print_cluster_apply_changes(&output.changes); - if output.ok { - let state = &output.state_observations; - println!( - " state: revision {}, converged: {}, written: {}", - state.state_revision, output.converged, output.state_written - ); - println!(" note: cluster-booted servers (--cluster) serve this on their next restart; omnigraph.yaml deployments are unaffected"); - } - print_cluster_diagnostics(&output.diagnostics); -} - -fn print_cluster_apply_changes(changes: &[omnigraph_cluster::PlanChange]) { - for change in changes { - let bindings = if change.binding_change { " [bindings]" } else { "" }; - match (&change.disposition, change.reason.as_deref()) { - (Some(disposition), Some(reason)) => println!( - " {:?} {}{bindings} [{disposition:?}: {reason}]", - change.operation, change.resource - ), - (Some(disposition), None) => println!( - " {:?} {}{bindings} [{disposition:?}]", - change.operation, change.resource - ), - _ => println!(" {:?} {}{bindings}", change.operation, change.resource), - } - } - if changes.is_empty() { - println!(" no changes"); - } -} - -fn print_cluster_status_human(output: &StatusOutput) { - if output.ok { - let state = &output.state_observations; - if state.state_found { - println!( - "cluster state: revision {}, {} resource(s)", - state.state_revision, state.resource_count - ); - if let Some(digest) = state.applied_config_digest.as_deref() { - println!(" applied config: {digest}"); - } - if state.locked { - println!(" lock: held{}", cluster_lock_summary(state)); - } else { - println!(" lock: not held"); - } - } else { - println!("cluster state missing"); - } - } else { - println!("cluster status failed"); - } - print_cluster_diagnostics(&output.diagnostics); -} - -fn print_cluster_state_sync_human(output: &StateSyncOutput) { - let operation = match output.operation { - omnigraph_cluster::StateSyncOperation::Refresh => "refresh", - omnigraph_cluster::StateSyncOperation::Import => "import", - }; - if output.ok { - let state = &output.state_observations; - println!( - "cluster {operation}: revision {}, {} resource(s)", - state.state_revision, state.resource_count - ); - if let Some(cas) = state.state_cas.as_deref() { - println!(" state_cas: {cas}"); - } - if state.locked { - println!(" lock: acquired{}", cluster_lock_summary(state)); - } else { - println!(" lock: not acquired"); - } - } else { - println!("cluster {operation} failed"); - } - print_cluster_diagnostics(&output.diagnostics); -} - -fn print_cluster_force_unlock_human(output: &ForceUnlockOutput) { - if output.ok { - if output.lock_removed { - println!( - "cluster force-unlock: removed lock{}", - cluster_lock_summary(&output.state_observations) - ); - } else { - println!("cluster force-unlock: no lock removed"); - } - } else { - println!("cluster force-unlock failed"); - if output.state_observations.locked { - println!( - " lock: held{}", - cluster_lock_summary(&output.state_observations) - ); - } - } - print_cluster_diagnostics(&output.diagnostics); -} - -fn cluster_lock_summary(state: &omnigraph_cluster::StateObservations) -> String { - let Some(lock_id) = state.lock_id.as_deref() else { - return String::new(); - }; - let mut parts = vec![format!("id={lock_id}")]; - if let Some(operation) = state.lock_operation.as_deref() { - parts.push(format!("operation={operation}")); - } - if let Some(pid) = state.lock_pid { - parts.push(format!("pid={pid}")); - } - if let Some(created_at) = state.lock_created_at.as_deref() { - parts.push(format!("created_at={created_at}")); - } - if let Some(age_seconds) = state.lock_age_seconds { - parts.push(format!("age_seconds={age_seconds}")); - } - format!(" ({})", parts.join(", ")) -} - -fn print_cluster_diagnostics(diagnostics: &[omnigraph_cluster::Diagnostic]) { - for diagnostic in diagnostics { - let label = match diagnostic.severity { - DiagnosticSeverity::Error => "ERROR", - DiagnosticSeverity::Warning => "WARN ", - }; - println!( - "{label} {} {}: {}", - diagnostic.code, diagnostic.path, diagnostic.message - ); - } -} - -fn finish_cluster_validate(output: &ValidateOutput, json: bool) -> Result<()> { - if json { - print_json(output)?; - } else { - print_cluster_validate_human(output); - } - if !output.ok { - io::stdout().flush()?; - std::process::exit(1); - } - Ok(()) -} - -fn finish_cluster_plan(output: &PlanOutput, json: bool) -> Result<()> { - if json { - print_json(output)?; - } else { - print_cluster_plan_human(output); - } - if !output.ok { - io::stdout().flush()?; - std::process::exit(1); - } - Ok(()) -} - -fn finish_cluster_apply(output: &ApplyOutput, json: bool) -> Result<()> { - if json { - print_json(output)?; - } else { - print_cluster_apply_human(output); - } - if !output.ok { - io::stdout().flush()?; - std::process::exit(1); - } - Ok(()) -} - -fn finish_cluster_approve(output: &ApproveOutput, json: bool) -> Result<()> { - if json { - print_json(output)?; - } else if output.ok { - println!( - "cluster approve: {} {} approved by {} (approval {})", - output - .operation - .as_ref() - .map(|operation| format!("{operation:?}").to_lowercase()) - .unwrap_or_default(), - output.resource.as_deref().unwrap_or("?"), - output.approved_by.as_deref().unwrap_or("?"), - output.approval_id.as_deref().unwrap_or("?"), - ); - print_cluster_diagnostics(&output.diagnostics); - } else { - println!("cluster approve failed"); - print_cluster_diagnostics(&output.diagnostics); - } - if !output.ok { - io::stdout().flush()?; - std::process::exit(1); - } - Ok(()) -} - -fn finish_cluster_status(output: &StatusOutput, json: bool) -> Result<()> { - if json { - print_json(output)?; - } else { - print_cluster_status_human(output); - } - if !output.ok { - io::stdout().flush()?; - std::process::exit(1); - } - Ok(()) -} - -fn finish_cluster_state_sync(output: &StateSyncOutput, json: bool) -> Result<()> { - if json { - print_json(output)?; - } else { - print_cluster_state_sync_human(output); - } - if !output.ok { - io::stdout().flush()?; - std::process::exit(1); - } - Ok(()) -} - -fn finish_cluster_force_unlock(output: &ForceUnlockOutput, json: bool) -> Result<()> { - if json { - print_json(output)?; - } else { - print_cluster_force_unlock_human(output); - } - if !output.ok { - io::stdout().flush()?; - std::process::exit(1); - } - Ok(()) -} - -fn is_remote_uri(uri: &str) -> bool { - uri.starts_with("http://") || uri.starts_with("https://") -} - -fn remote_url(base: &str, path: &str) -> String { - format!("{}{}", base.trim_end_matches('/'), path) -} - -fn remote_branch_url(base: &str, branch: &str) -> Result { - let mut url = reqwest::Url::parse(&format!("{}/", base.trim_end_matches('/')))?; - url.path_segments_mut() - .map_err(|_| color_eyre::eyre::eyre!("invalid remote base url"))? - .extend(["branches", branch]); - Ok(url.to_string()) -} - -fn normalize_bearer_token(value: Option) -> Option { - value - .map(|value| value.trim().to_string()) - .filter(|value| !value.is_empty()) -} - -fn bearer_token_from_env(var_name: &str) -> Option { - normalize_bearer_token(std::env::var(var_name).ok()) -} - -fn parse_env_assignment(line: &str) -> Option<(String, String)> { - let line = line.trim(); - if line.is_empty() || line.starts_with('#') { - return None; - } - - let line = line.strip_prefix("export ").unwrap_or(line).trim(); - let (name, value) = line.split_once('=')?; - let name = name.trim(); - if name.is_empty() { - return None; - } - - let value = value.trim(); - let value = if value.len() >= 2 - && ((value.starts_with('"') && value.ends_with('"')) - || (value.starts_with('\'') && value.ends_with('\''))) - { - &value[1..value.len() - 1] - } else { - value - }; - - Some((name.to_string(), value.to_string())) -} - -fn bearer_token_from_env_file(path: &Path, var_name: &str) -> Result> { - if !path.exists() { - return Ok(None); - } - - for line in fs::read_to_string(path)?.lines() { - let Some((name, value)) = parse_env_assignment(line) else { - continue; - }; - if name == var_name { - return Ok(normalize_bearer_token(Some(value))); - } - } - - Ok(None) -} - -fn load_env_file_into_process(path: &Path) -> Result<()> { - if !path.exists() { - return Ok(()); - } - - for line in fs::read_to_string(path)?.lines() { - let Some((name, value)) = parse_env_assignment(line) else { - continue; - }; - if std::env::var_os(&name).is_none() { - unsafe { - std::env::set_var(name, value); - } - } - } - - Ok(()) -} - -fn load_cli_config(config_path: Option<&PathBuf>) -> Result { - let config = load_config(config_path)?; - if let Some(path) = config.resolve_auth_env_file() { - load_env_file_into_process(&path)?; - } - Ok(config) -} - -#[derive(Debug, Clone)] -struct ResolvedCliGraph { - uri: String, - selected: Option, - graph_id: String, - policy_file: Option, - is_remote: bool, -} - -impl ResolvedCliGraph { - fn selected(&self) -> Option<&str> { - self.selected.as_deref() - } -} - -struct ResolvedPolicyContext { - policy_file: PathBuf, - graph_id: String, -} - -fn resolve_policy_context(config: &OmnigraphConfig) -> Result { - let selected = config.resolve_policy_tooling_graph_selection()?; - let policy_file = config.resolve_policy_file_for(selected).ok_or_else(|| { - color_eyre::eyre::eyre!( - "policy.file or graphs..policy.file must be set in omnigraph.yaml" - ) - })?; - let graph_id = match selected { - Some(name) => graph_resource_id_for_selection(Some(name), ""), - None => graph_resource_id_for_selection(None, "default"), - }; - Ok(ResolvedPolicyContext { - policy_file, - graph_id, - }) -} - -fn resolve_policy_engine(context: &ResolvedPolicyContext) -> Result { - PolicyEngine::load_graph(&context.policy_file, &context.graph_id) -} - -fn resolve_policy_engine_for_graph(graph: &ResolvedCliGraph) -> Result { - let policy_file = graph.policy_file.as_ref().ok_or_else(|| { - color_eyre::eyre::eyre!( - "policy.file or graphs..policy.file must be set in omnigraph.yaml" - ) - })?; - PolicyEngine::load_graph(policy_file, &graph.graph_id) -} - -/// Open a local graph and install the policy resolved for the same graph -/// identity that produced the URI. A named graph uses -/// `graphs..policy.file`; an explicit positional URI is anonymous and -/// uses the legacy top-level `policy.file`. -async fn open_local_db_with_policy(graph: &ResolvedCliGraph) -> Result { - let db = Omnigraph::open(&graph.uri).await?; - if graph.policy_file.is_some() { - let engine = Arc::new(resolve_policy_engine_for_graph(graph)?); - Ok(db.with_policy(engine as Arc)) - } else { - Ok(db) - } -} - -/// Actor resolution for cluster operations. Cluster FACTS stay unlayered -/// (cluster.yaml only), but the operator's identity is a per-operator fact — -/// the per-operator config's permanent job. An explicit --as never touches -/// any config (containers and CI stay config-free); without it, the standard -/// cwd omnigraph.yaml search supplies `cli.actor`, and a malformed config -/// fails loudly rather than silently dropping attribution. Deliberately -/// `load_config`, NOT `load_cli_config`: the latter also loads -/// `auth.env_file` into the process env — a second thing, violating the -/// documented "exactly one thing" contract. -fn resolve_cluster_actor(cli_as: Option<&str>) -> Result> { - if let Some(actor) = cli_as { - return Ok(Some(actor.to_string())); - } - let config = load_config(None).wrap_err( - "resolving the default actor from the per-operator omnigraph.yaml (pass --as to skip this lookup)", - )?; - Ok(config.cli.actor.clone()) -} - -/// Resolve the CLI's effective actor identity for engine-layer policy -/// (MR-722). Precedence: `--as ` (top-level flag) overrides -/// `cli.actor` from `omnigraph.yaml`; both unset returns `None`. When -/// policy is configured and this returns `None`, the engine-layer -/// footgun guard intentionally denies — silent bypass via "I forgot the -/// actor" is what the guard prevents. -fn resolve_cli_actor<'a>(cli_as: Option<&'a str>, config: &'a OmnigraphConfig) -> Option<&'a str> { - cli_as.or(config.cli.actor.as_deref()) -} - -fn resolve_policy_tests_path(context: &ResolvedPolicyContext) -> PathBuf { - context.policy_file.with_file_name("policy.tests.yaml") -} - -fn normalize_policy_graph_uri(uri: &str) -> Result { - if is_remote_uri(uri) { - Ok(uri.trim_end_matches('/').to_string()) - } else { - Ok(normalize_root_uri(uri)?) - } -} - -fn resolve_remote_bearer_token( - config: &OmnigraphConfig, - explicit_uri: Option<&str>, - explicit_target: Option<&str>, -) -> Result> { - let scoped_env = - config.graph_bearer_token_env(explicit_uri, explicit_target, config.cli_graph_name()); - let mut env_names = Vec::new(); - if let Some(name) = scoped_env { - env_names.push(name.to_string()); - } - if env_names - .iter() - .all(|name| name != DEFAULT_BEARER_TOKEN_ENV) - { - env_names.push(DEFAULT_BEARER_TOKEN_ENV.to_string()); - } - - let env_file = config.resolve_auth_env_file(); - for env_name in env_names { - if let Some(token) = bearer_token_from_env(&env_name) { - return Ok(Some(token)); - } - if let Some(path) = env_file.as_ref() { - if let Some(token) = bearer_token_from_env_file(path, &env_name)? { - return Ok(Some(token)); - } - } - } - - Ok(None) -} - -fn build_http_client() -> Result { - Ok(reqwest::Client::new()) -} - -fn apply_bearer_token( - request: reqwest::RequestBuilder, - token: Option<&str>, -) -> reqwest::RequestBuilder { - if let Some(token) = token { - request.header(AUTHORIZATION, format!("Bearer {}", token)) - } else { - request - } -} - -async fn remote_json( - client: &reqwest::Client, - method: Method, - url: String, - body: Option, - bearer_token: Option<&str>, -) -> Result { - let request = apply_bearer_token(client.request(method, url), bearer_token); - let request = if let Some(body) = body { - request.json(&body) - } else { - request - }; - let response = request.send().await?; - let status = response.status(); - let text = response.text().await?; - if !status.is_success() { - if let Ok(error) = serde_json::from_str::(&text) { - bail!(error.error); - } - bail!("server returned {}: {}", status, text); - } - Ok(serde_json::from_str(&text)?) -} - -fn resolve_uri( - config: &OmnigraphConfig, - cli_uri: Option, - cli_target: Option<&str>, -) -> Result { - config.resolve_target_uri(cli_uri, cli_target, config.cli_graph_name()) -} - -fn resolve_cli_graph( - config: &OmnigraphConfig, - cli_uri: Option, - cli_target: Option<&str>, -) -> Result { - let selected = if cli_uri.is_some() { - None - } else { - cli_target - .map(str::to_string) - .or_else(|| config.cli_graph_name().map(str::to_string)) - }; - config.resolve_graph_selection(selected.as_deref())?; - let uri = resolve_uri(config, cli_uri, cli_target)?; - let normalized_uri = normalize_policy_graph_uri(&uri)?; - let graph_id = graph_resource_id_for_selection(selected.as_deref(), &normalized_uri); - Ok(ResolvedCliGraph { - graph_id, - is_remote: is_remote_uri(&uri), - policy_file: config.resolve_policy_file_for(selected.as_deref()), - selected, - uri, - }) -} - -fn resolve_local_graph( - config: &OmnigraphConfig, - cli_uri: Option, - cli_target: Option<&str>, - operation: &str, -) -> Result { - let graph = resolve_cli_graph(config, cli_uri, cli_target)?; - if graph.is_remote { - bail!( - "{} is only supported against local graph URIs in this milestone", - operation - ); - } - Ok(graph) -} - -/// Parse a Go-style compact duration: `7d`, `24h`, `30m`, `90s`, or a plain -/// integer as seconds. Used by the `cleanup --older-than` flag. -fn parse_duration_arg(s: &str) -> Result { - let s = s.trim(); - if s.is_empty() { - bail!("duration is empty"); - } - let (num_part, unit) = match s - .char_indices() - .rev() - .find(|(_, c)| c.is_ascii_alphabetic()) - { - Some((i, _)) => ( - &s[..i + 1 - s[i..].chars().next().unwrap().len_utf8()], - &s[i..], - ), - None => (s, ""), - }; - let n: u64 = num_part - .parse() - .map_err(|e| color_eyre::eyre::eyre!("invalid duration '{}': {}", s, e))?; - let secs = match unit { - "" | "s" => n, - "m" => n * 60, - "h" => n * 60 * 60, - "d" => n * 60 * 60 * 24, - "w" => n * 60 * 60 * 24 * 7, - _ => bail!("unknown duration unit '{}'. Supported: s, m, h, d, w", unit), - }; - Ok(std::time::Duration::from_secs(secs)) -} - -fn resolve_local_uri( - config: &OmnigraphConfig, - cli_uri: Option, - cli_target: Option<&str>, - operation: &str, -) -> Result { - Ok(resolve_local_graph(config, cli_uri, cli_target, operation)?.uri) -} - -fn resolve_branch( - config: &OmnigraphConfig, - cli_branch: Option, - alias_branch: Option, - default_branch: &str, -) -> String { - cli_branch - .or(alias_branch) - .or_else(|| config.cli.branch.clone()) - .unwrap_or_else(|| default_branch.to_string()) -} - -fn resolve_read_target( - config: &OmnigraphConfig, - cli_branch: Option, - cli_snapshot: Option, - alias_branch: Option, -) -> Result { - if cli_branch.is_some() && cli_snapshot.is_some() { - bail!("read target may specify branch or snapshot, not both"); - } - Ok(read_target_from_cli( - cli_branch - .or(alias_branch) - .or_else(|| config.cli.branch.clone()), - cli_snapshot, - )) -} - -fn resolve_query_path( - config: &OmnigraphConfig, - explicit_query: Option<&PathBuf>, - alias_query: Option<&str>, -) -> Result { - explicit_query - .map(PathBuf::from) - .or_else(|| alias_query.map(PathBuf::from)) - .ok_or_else(|| { - color_eyre::eyre::eyre!( - "exactly one of --query, --query-string, or --alias must be provided" - ) - }) - .and_then(|query_path| config.resolve_query_path(&query_path)) -} - -fn resolve_query_source( - config: &OmnigraphConfig, - explicit_query: Option<&PathBuf>, - inline_query: Option<&str>, - alias_query: Option<&str>, -) -> Result { - if let Some(inline) = inline_query { - if inline.trim().is_empty() { - bail!("--query-string must not be empty"); - } - return Ok(inline.to_string()); - } - Ok(fs::read_to_string(resolve_query_path( - config, - explicit_query, - alias_query, - )?)?) -} - -fn parse_alias_value(value: &str) -> Value { - serde_json::from_str(value).unwrap_or_else(|_| Value::String(value.to_string())) -} - -fn merged_params_json( - alias_name: Option<&str>, - alias_arg_names: &[String], - alias_arg_values: &[String], - explicit: Option, -) -> Result> { - if alias_arg_values.len() > alias_arg_names.len() { - let alias = alias_name.unwrap_or(""); - bail!( - "alias '{}' expects at most {} args but got {}", - alias, - alias_arg_names.len(), - alias_arg_values.len() - ); - } - - let mut merged = serde_json::Map::new(); - for (arg_name, arg_value) in alias_arg_names.iter().zip(alias_arg_values.iter()) { - merged.insert(arg_name.clone(), parse_alias_value(arg_value)); - } - - match explicit { - Some(Value::Object(object)) => { - for (key, value) in object { - merged.insert(key, value); - } - } - Some(_) => bail!("params JSON must be an object"), - None => {} - } - - if merged.is_empty() { - Ok(None) - } else { - Ok(Some(Value::Object(merged))) - } -} - -fn print_load_human(payload: &LoadOutput) { - println!( - "loaded {} on branch {} with {}: {} nodes across {} node types, {} edges across {} edge types", - payload.uri, - payload.branch, - payload.mode, - payload.nodes_loaded, - payload.node_types_loaded, - payload.edges_loaded, - payload.edge_types_loaded - ); - if payload.branch_created { - if let Some(base) = &payload.base_branch { - println!("branch {} created from {}", payload.branch, base); - } - } -} - -fn print_ingest_human(output: &IngestOutput) { - println!( - "ingested {} into branch {} from {} with {} ({})", - output.uri, - output.branch, - output.base_branch.as_deref().unwrap_or("main"), - output.mode.as_str(), - if output.branch_created { - "branch created" - } else { - "branch exists" - } - ); - for table in &output.tables { - println!("{} rows_loaded={}", table.table_key, table.rows_loaded); - } - if let Some(actor_id) = &output.actor_id { - println!("actor_id: {}", actor_id); - } -} - -fn print_schema_plan_human(uri: &str, plan: &SchemaMigrationPlan) { - println!("schema plan for {}", uri); - println!("supported: {}", if plan.supported { "yes" } else { "no" }); - if plan.steps.is_empty() { - println!("no schema changes"); - return; - } - for step in &plan.steps { - println!("- {}", render_schema_plan_step(step)); - } -} - -fn render_schema_plan_step(step: &SchemaMigrationStep) -> String { - match step { - SchemaMigrationStep::AddType { type_kind, name } => { - format!("add {} type '{}'", schema_type_kind_label(*type_kind), name) - } - SchemaMigrationStep::RenameType { - type_kind, - from, - to, - } => format!( - "rename {} type '{}' -> '{}'", - schema_type_kind_label(*type_kind), - from, - to - ), - SchemaMigrationStep::AddProperty { - type_kind, - type_name, - property_name, - property_type, - } => format!( - "add property '{}.{}' ({}) on {} '{}'", - type_name, - property_name, - render_prop_type(property_type), - schema_type_kind_label(*type_kind), - type_name - ), - SchemaMigrationStep::RenameProperty { - type_kind, - type_name, - from, - to, - } => format!( - "rename property '{}.{}' -> '{}.{}' on {} '{}'", - type_name, - from, - type_name, - to, - schema_type_kind_label(*type_kind), - type_name - ), - SchemaMigrationStep::AddConstraint { - type_kind, - type_name, - constraint, - } => format!( - "add constraint {} on {} '{}'", - render_constraint(constraint), - schema_type_kind_label(*type_kind), - type_name - ), - SchemaMigrationStep::UpdateTypeMetadata { - type_kind, - name, - annotations, - } => format!( - "update metadata on {} '{}' ({})", - schema_type_kind_label(*type_kind), - name, - render_annotations(annotations) - ), - SchemaMigrationStep::UpdatePropertyMetadata { - type_kind, - type_name, - property_name, - annotations, - } => format!( - "update metadata on property '{}.{}' of {} '{}' ({})", - type_name, - property_name, - schema_type_kind_label(*type_kind), - type_name, - render_annotations(annotations) - ), - SchemaMigrationStep::DropType { - type_kind, - name, - mode, - } => format!( - "drop {} type '{}' ({} mode)", - schema_type_kind_label(*type_kind), - name, - drop_mode_label(*mode), - ), - SchemaMigrationStep::DropProperty { - type_kind, - type_name, - property_name, - mode, - } => format!( - "drop property '{}.{}' of {} '{}' ({} mode)", - type_name, - property_name, - schema_type_kind_label(*type_kind), - type_name, - drop_mode_label(*mode), - ), - SchemaMigrationStep::UnsupportedChange { entity, reason, .. } => { - // When a schema-lint code is attached, render code + tier - // so operators see at-a-glance the kind of risk (destructive - // / validated / safe) — not just the rule identifier. - // Reach the diagnostic via the `diagnostic()` helper so the - // CLI doesn't need to know how the lookup works. - match step.diagnostic() { - Some(diag) => format!( - "unsupported change on {} [{}, {}]: {}", - entity, - diag.code, - schema_lint_tier_label(diag.tier), - reason, - ), - None => format!("unsupported change on {}: {}", entity, reason), - } - } - } -} - -fn schema_type_kind_label(kind: omnigraph_compiler::SchemaTypeKind) -> &'static str { - match kind { - omnigraph_compiler::SchemaTypeKind::Interface => "interface", - omnigraph_compiler::SchemaTypeKind::Node => "node", - omnigraph_compiler::SchemaTypeKind::Edge => "edge", - } -} - -fn schema_lint_tier_label(tier: omnigraph_compiler::SafetyTier) -> &'static str { - match tier { - omnigraph_compiler::SafetyTier::Safe => "safe", - omnigraph_compiler::SafetyTier::Validated => "validated", - omnigraph_compiler::SafetyTier::Destructive => "destructive", - } -} - -fn drop_mode_label(mode: omnigraph_compiler::DropMode) -> &'static str { - match mode { - omnigraph_compiler::DropMode::Soft => "soft", - omnigraph_compiler::DropMode::Hard => "hard", - } -} - -fn render_prop_type(prop_type: &omnigraph_compiler::PropType) -> String { - let base = if let Some(values) = &prop_type.enum_values { - format!("Enum({})", values.join("|")) - } else { - prop_type.scalar.to_string() - }; - let base = if prop_type.list { - format!("[{}]", base) - } else { - base - }; - if prop_type.nullable { - format!("{}?", base) - } else { - base - } -} - -fn render_constraint(constraint: &omnigraph_compiler::schema::ast::Constraint) -> String { - match constraint { - omnigraph_compiler::schema::ast::Constraint::Key(columns) => { - format!("@key({})", columns.join(", ")) - } - omnigraph_compiler::schema::ast::Constraint::Unique(columns) => { - format!("@unique({})", columns.join(", ")) - } - omnigraph_compiler::schema::ast::Constraint::Index(columns) => { - format!("@index({})", columns.join(", ")) - } - omnigraph_compiler::schema::ast::Constraint::Range { property, min, max } => { - format!("@range({}, {:?}, {:?})", property, min, max) - } - omnigraph_compiler::schema::ast::Constraint::Check { property, pattern } => { - format!("@check({}, {:?})", property, pattern) - } - } -} - -fn render_annotations(annotations: &[omnigraph_compiler::schema::ast::Annotation]) -> String { - annotations - .iter() - .map(|annotation| match &annotation.value { - Some(value) => format!("@{}({})", annotation.name, value), - None => format!("@{}", annotation.name), - }) - .collect::>() - .join(", ") -} - -fn print_embed_human(output: &EmbedOutput) { - println!( - "embedded {} rows (selected {}, cleaned {}) from {} -> {} [{} {}d]", - output.embedded_rows, - output.selected_rows, - output.cleaned_rows, - output.input, - output.output, - output.mode, - output.dimension - ); -} - -fn print_snapshot_human(branch: &str, manifest_version: u64, entries: &[SnapshotTableOutput]) { - println!("branch: {}", branch); - println!("manifest_version: {}", manifest_version); - for entry in entries { - println!( - "{} v{} branch={} rows={}", - entry.table_key, - entry.table_version, - entry.table_branch.as_deref().unwrap_or("main"), - entry.row_count - ); - } -} - -fn print_read_output( - output: &ReadOutput, - format: ReadOutputFormat, - config: &OmnigraphConfig, -) -> Result<()> { - println!( - "{}", - render_read( - output, - format, - &ReadRenderOptions { - max_column_width: config.table_max_column_width(), - cell_layout: config.table_cell_layout(), - }, - )? - ); - Ok(()) -} - -fn print_change_human(output: &ChangeOutput) { - println!( - "changed {} via {}: {} nodes, {} edges", - output.branch, output.query_name, output.affected_nodes, output.affected_edges - ); - if let Some(actor_id) = &output.actor_id { - println!("actor_id: {}", actor_id); - } -} - -fn print_commit_list_human(commits: &[CommitOutput]) { - for commit in commits { - let branch = commit.manifest_branch.as_deref().unwrap_or("main"); - println!( - "{} branch={} version={}{}", - commit.graph_commit_id, - branch, - commit.manifest_version, - commit - .actor_id - .as_deref() - .map(|actor| format!(" actor={}", actor)) - .unwrap_or_default() - ); - } -} - -fn print_commit_human(commit: &CommitOutput) { - println!("graph_commit_id: {}", commit.graph_commit_id); - println!( - "manifest_branch: {}", - commit.manifest_branch.as_deref().unwrap_or("main") - ); - println!("manifest_version: {}", commit.manifest_version); - if let Some(parent_commit_id) = &commit.parent_commit_id { - println!("parent_commit_id: {}", parent_commit_id); - } - if let Some(merged_parent_commit_id) = &commit.merged_parent_commit_id { - println!("merged_parent_commit_id: {}", merged_parent_commit_id); - } - if let Some(actor_id) = &commit.actor_id { - println!("actor_id: {}", actor_id); - } - println!("created_at: {}", commit.created_at); -} - -fn print_policy_explain(decision: &PolicyDecision, actor_id: &str, request: &PolicyRequest) { - println!( - "decision: {}", - if decision.allowed { "allow" } else { "deny" } - ); - println!("actor: {}", actor_id); - println!("action: {}", request.action); - if let Some(branch) = &request.branch { - println!("branch: {}", branch); - } - if let Some(target_branch) = &request.target_branch { - println!("target_branch: {}", target_branch); - } - if let Some(rule_id) = &decision.matched_rule_id { - println!("matched_rule: {}", rule_id); - } - println!("message: {}", decision.message); -} - -fn resolve_read_format( - config: &OmnigraphConfig, - cli_format: Option, - json: bool, - alias_format: Option, -) -> ReadOutputFormat { - if json { - ReadOutputFormat::Json - } else { - cli_format - .or(alias_format) - .unwrap_or_else(|| config.cli_output_format()) - } -} - -fn resolve_alias<'a>( - config: &'a OmnigraphConfig, - alias_name: Option<&'a str>, - expected: AliasCommand, -) -> Result> { - let Some(alias_name) = alias_name else { - return Ok(None); - }; - let alias = config.alias(alias_name)?; - if alias.command != expected { - bail!( - "alias '{}' is a {:?} alias, not a {:?} alias", - alias_name, - alias.command, - expected - ); - } - Ok(Some((alias_name, alias))) -} - -fn normalize_legacy_alias_uri( - uri: Option, - target_available: bool, - alias_name: Option<&str>, - mut alias_args: Vec, -) -> (Option, Vec) { - let Some(candidate) = uri else { - return (None, alias_args); - }; - - if alias_name.is_some() && target_available { - alias_args.insert(0, candidate); - return (None, alias_args); - } - - (Some(candidate), alias_args) -} - -fn scaffold_config_if_missing(uri: &str) -> Result<()> { - let path = inferred_config_path(uri)?; - if path.exists() { - return Ok(()); - } - - fs::write( - path, - format!( - "\ -project: - name: Omnigraph Project - -graphs: - local: - uri: {} - # bearer_token_env: OMNIGRAPH_BEARER_TOKEN - -server: - graph: local - bind: 127.0.0.1:8080 - -cli: - graph: local - branch: main - output_format: table - table_max_column_width: 80 - table_cell_layout: truncate - -query: - roots: - - queries - - . - -aliases: - # owner: - # command: read - # query: context.gq - # name: decision_owner - # args: [slug] - # graph: local - # branch: main - # format: kv - # - # attach_trace: - # command: change - # query: mutations.gq - # name: attach_trace - # args: [decision_slug, trace_slug] - # graph: local - # branch: main - -# auth: -# env_file: ./.env.omni -# -# policy: -# file: ./policy.yaml -", - yaml_string(uri), - ), - )?; - Ok(()) -} - -fn yaml_string(value: &str) -> String { - format!("'{}'", value.replace('\'', "''")) -} - -fn inferred_config_path(uri: &str) -> Result { - if uri.contains("://") { - return Ok(omnigraph_server::config::default_config_path()); - } - - let path = Path::new(uri); - let base = if path.is_absolute() { - path.parent() - .map(Path::to_path_buf) - .unwrap_or(std::env::current_dir()?) - } else { - std::env::current_dir()?.join(path.parent().unwrap_or_else(|| Path::new("."))) - }; - Ok(base.join(omnigraph_server::config::DEFAULT_CONFIG_FILE)) -} - -fn read_target_from_cli(branch: Option, snapshot: Option) -> ReadTarget { - if let Some(snapshot) = snapshot { - ReadTarget::snapshot(SnapshotId::new(snapshot)) - } else { - ReadTarget::branch(branch.unwrap_or_else(|| "main".to_string())) - } -} - -fn load_params_json(params: &ParamsArgs) -> Result> { - match (¶ms.params, ¶ms.params_file) { - (Some(inline), None) => Ok(Some(serde_json::from_str(inline)?)), - (None, Some(path)) => Ok(Some(serde_json::from_str(&fs::read_to_string(path)?)?)), - (None, None) => Ok(None), - (Some(_), Some(_)) => bail!("only one of --params or --params-file may be provided"), - } -} - -fn select_named_query( - query_source: &str, - requested_name: Option<&str>, -) -> Result<(String, Vec)> { - let parsed = parse_query(query_source)?; - let query = if let Some(name) = requested_name { - parsed - .queries - .into_iter() - .find(|query| query.name == name) - .ok_or_else(|| color_eyre::eyre::eyre!("query '{}' not found", name))? - } else if parsed.queries.len() == 1 { - parsed.queries.into_iter().next().unwrap() - } else { - bail!("query file contains multiple queries; pass --name"); - }; - - Ok((query.name, query.params)) -} - -fn query_params_from_json( - query_params: &[omnigraph_compiler::query::ast::Param], - params_json: Option<&Value>, -) -> Result { - json_params_to_param_map(params_json, query_params, JsonParamMode::Standard) - .map_err(|err| color_eyre::eyre::eyre!(err.to_string())) -} - -async fn execute_query_lint( - config: &OmnigraphConfig, - cli_uri: Option, - cli_target: Option<&str>, - schema_path: Option<&PathBuf>, - query_path: &PathBuf, -) -> Result { - let resolved_query_path = resolve_query_path(config, Some(query_path), None)?; - let query_source = fs::read_to_string(&resolved_query_path)?; - let query_path = resolved_query_path.to_string_lossy().into_owned(); - - if let Some(schema_path) = schema_path { - let schema_source = fs::read_to_string(schema_path)?; - let schema = - parse_schema(&schema_source).map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?; - let catalog = - build_catalog(&schema).map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?; - return Ok(lint_query_file( - &catalog, - &query_source, - query_path, - QueryLintSchemaSource::file(schema_path.to_string_lossy().into_owned()), - )); - } - - let has_graph_target = - cli_uri.is_some() || cli_target.is_some() || config.cli_graph_name().is_some(); - if !has_graph_target { - bail!("query lint requires --schema or a resolvable graph target"); - } - - let uri = resolve_local_uri(config, cli_uri, cli_target, "query lint")?; - let db = Omnigraph::open(&uri).await?; - Ok(lint_query_file( - &db.catalog(), - &query_source, - query_path, - QueryLintSchemaSource::graph(uri), - )) -} - -#[derive(serde::Serialize)] -struct QueriesIssue { - query: String, - message: String, -} - -#[derive(serde::Serialize)] -struct QueriesValidateOutput { - ok: bool, - breakages: Vec, - warnings: Vec, -} - -#[derive(serde::Serialize)] -struct QueriesParam { - name: String, - #[serde(rename = "type")] - type_name: String, - nullable: bool, -} - -#[derive(serde::Serialize)] -struct QueriesListItem { - name: String, - mcp_expose: bool, - tool_name: Option, - mutation: bool, - params: Vec, -} - -#[derive(serde::Serialize)] -struct QueriesListOutput { - queries: Vec, -} - -/// Resolve the selected graph to `(local URI, registry selection)` from one -/// precedence, so a command's schema and its stored-query registry can never -/// come from different graphs. A **positional URI is anonymous** (top-level -/// registry, ignoring the configured default graph); otherwise `--target` -/// or the configured `cli.graph` names the graph (its per-graph block). -/// Mirrors the server's single-mode identity rule. -fn resolve_selected_graph( - config: &OmnigraphConfig, - cli_uri: Option, - cli_target: Option<&str>, - operation: &str, -) -> Result<(String, Option)> { - let graph = resolve_local_graph(config, cli_uri, cli_target, operation)?; - Ok((graph.uri, graph.selected)) -} - -/// Load the stored-query registry for an already-resolved graph selection -/// (`None` = anonymous → top-level; `Some(name)` = that graph's block). -fn load_registry_or_report( - config: &OmnigraphConfig, - selected: Option<&str>, -) -> Result { - QueryRegistry::load(config, config.query_entries_for(selected)).map_err(|errors| { - color_eyre::eyre::eyre!( - "stored-query registry failed to load:\n {}", - errors - .iter() - .map(|e| e.to_string()) - .collect::>() - .join("\n ") - ) - }) -} - -fn graph_query_registry_names(config: &OmnigraphConfig) -> Vec<&str> { - config - .graphs - .iter() - .filter_map(|(name, graph)| (!graph.queries.is_empty()).then_some(name.as_str())) - .collect() -} - -fn resolve_registry_selection_for_list( - config: &OmnigraphConfig, - target: Option<&str>, -) -> Result> { - let selected = target - .map(str::to_string) - .or_else(|| config.cli_graph_name().map(str::to_string)); - if let Some(name) = selected.as_deref() { - config.resolve_graph_selection(Some(name))?; - return Ok(selected); - } - - if !config.query_entries().is_empty() { - return Ok(None); - } - - let graph_names = graph_query_registry_names(config); - if graph_names.is_empty() { - return Ok(None); - } - - bail!( - "stored-query registries are configured for graph{} {} but no graph was selected. Pass `--target {}` or set `cli.graph`.", - if graph_names.len() == 1 { "" } else { "s" }, - graph_names.join(", "), - graph_names[0], - ) -} - -fn validate_registry_for_catalog( - registry: &QueryRegistry, - catalog: &omnigraph_compiler::catalog::Catalog, - label: &str, -) -> omnigraph::error::Result<()> { - let report = check(registry, catalog); - if report.has_breakages() { - return Err(omnigraph::error::OmniError::manifest( - format_check_breakages(label, &report), - )); - } - Ok(()) -} - -async fn execute_queries_validate( - uri: Option, - target: Option, - config_path: Option<&PathBuf>, - json: bool, -) -> Result<()> { - let config = load_cli_config(config_path)?; - // One selection drives both the schema URI and the registry, so a - // positional URI and a `--target` can't validate different graphs. - let (uri, selected) = - resolve_selected_graph(&config, uri, target.as_deref(), "queries validate")?; - let registry = load_registry_or_report(&config, selected.as_deref())?; - let db = Omnigraph::open(&uri).await?; - let report = check(®istry, &db.catalog()); - - let output = QueriesValidateOutput { - ok: !report.has_breakages(), - breakages: report - .breakages - .iter() - .map(|b| QueriesIssue { - query: b.query.clone(), - message: b.message.clone(), - }) - .collect(), - warnings: report - .warnings - .iter() - .map(|w| QueriesIssue { - query: w.query.clone(), - message: w.message.clone(), - }) - .collect(), - }; - - if json { - print_json(&output)?; - } else { - if output.breakages.is_empty() { - println!( - "OK {} stored quer{} type-check against the schema", - registry.len(), - if registry.len() == 1 { "y" } else { "ies" } - ); - } - for issue in &output.breakages { - println!("ERROR query '{}': {}", issue.query, issue.message); - } - for issue in &output.warnings { - println!("WARN query '{}': {}", issue.query, issue.message); - } - } - - if report.has_breakages() { - io::stdout().flush()?; - std::process::exit(1); - } - Ok(()) -} - -fn execute_queries_list( - target: Option, - config_path: Option<&PathBuf>, - json: bool, -) -> Result<()> { - let config = load_cli_config(config_path)?; - let selected = resolve_registry_selection_for_list(&config, target.as_deref())?; - let registry = load_registry_or_report(&config, selected.as_deref())?; - - let output = QueriesListOutput { - queries: registry - .iter() - .map(|q| QueriesListItem { - name: q.name.clone(), - mcp_expose: q.expose, - tool_name: q.tool_name.clone(), - mutation: q.is_mutation(), - params: q - .decl - .params - .iter() - .map(|p| QueriesParam { - name: p.name.clone(), - type_name: p.type_name.clone(), - nullable: p.nullable, - }) - .collect(), - }) - .collect(), - }; - - if json { - print_json(&output)?; - } else if output.queries.is_empty() { - println!("(no stored queries registered)"); - } else { - for q in &output.queries { - let kind = if q.mutation { "mutation" } else { "read" }; - let params = q - .params - .iter() - .map(|p| { - format!( - "${}: {}{}", - p.name, - p.type_name, - if p.nullable { "?" } else { "" } - ) - }) - .collect::>() - .join(", "); - let mcp = if q.mcp_expose { - format!(" [mcp: {}]", q.tool_name.as_deref().unwrap_or(&q.name)) - } else { - String::new() - }; - println!("{kind} {}({params}){mcp}", q.name); - } - } - Ok(()) -} - -async fn execute_read( - uri: &str, - query_source: &str, - query_name: Option<&str>, - target: ReadTarget, - params_json: Option<&Value>, -) -> Result { - let (selected_name, query_params) = select_named_query(query_source, query_name)?; - let params = query_params_from_json(&query_params, params_json)?; - let db = Omnigraph::open(uri).await?; - let result = db - .query(target.clone(), query_source, &selected_name, ¶ms) - .await?; - Ok(read_output(selected_name, &target, result)) -} - -async fn execute_read_remote( - client: &reqwest::Client, - uri: &str, - query_source: &str, - query_name: Option<&str>, - target: ReadTarget, - params_json: Option<&Value>, - bearer_token: Option<&str>, -) -> Result { - let (branch, snapshot) = match &target { - ReadTarget::Branch(branch) => (Some(branch.clone()), None), - ReadTarget::Snapshot(snapshot) => (None, Some(snapshot.as_str().to_string())), - }; - remote_json( - client, - Method::POST, - remote_url(uri, "/read"), - Some(serde_json::to_value(ReadRequest { - query_source: query_source.to_string(), - query_name: query_name.map(ToOwned::to_owned), - params: params_json.cloned(), - branch, - snapshot, - })?), - bearer_token, - ) - .await -} - -async fn execute_change( - graph: &ResolvedCliGraph, - query_source: &str, - query_name: Option<&str>, - branch: &str, - params_json: Option<&Value>, - config: &OmnigraphConfig, - cli_as_actor: Option<&str>, -) -> Result { - let (selected_name, query_params) = select_named_query(query_source, query_name)?; - let params = query_params_from_json(&query_params, params_json)?; - let db = open_local_db_with_policy(graph).await?; - let actor = resolve_cli_actor(cli_as_actor, config); - let result = db - .mutate_as(branch, query_source, &selected_name, ¶ms, actor) - .await?; - Ok(ChangeOutput { - branch: branch.to_string(), - query_name: selected_name, - affected_nodes: result.affected_nodes, - affected_edges: result.affected_edges, - actor_id: actor.map(String::from), - }) -} - -/// Build the JSON body for `POST /change` using the legacy wire shape. -/// -/// `ChangeRequest`'s Rust field names are now `query` / `name` (the canonical -/// wire shape going forward), but old `omnigraph-server` builds still require -/// the legacy `query_source` / `query_name` keys on `/change`. Hand-rolling -/// the JSON with the legacy names keeps a newer CLI talking to an older -/// server intact -- the same byte-stability contract we apply to -/// `execute_read_remote` against `/read`. -fn legacy_change_request_body( - query_source: &str, - query_name: Option<&str>, - branch: &str, - params_json: Option<&Value>, -) -> Value { - let mut body = serde_json::json!({ - "query_source": query_source, - "branch": branch, - }); - if let Some(name) = query_name { - body["query_name"] = Value::String(name.to_string()); - } - if let Some(params) = params_json { - body["params"] = params.clone(); - } - body -} - -async fn execute_change_remote( - client: &reqwest::Client, - uri: &str, - query_source: &str, - query_name: Option<&str>, - branch: &str, - params_json: Option<&Value>, - bearer_token: Option<&str>, -) -> Result { - remote_json( - client, - Method::POST, - remote_url(uri, "/change"), - Some(legacy_change_request_body( - query_source, - query_name, - branch, - params_json, - )), - bearer_token, - ) - .await -} - -async fn execute_export_to_writer( - uri: &str, - branch: &str, - type_names: &[String], - table_keys: &[String], - writer: &mut W, -) -> Result<()> { - let db = Omnigraph::open(uri).await?; - db.export_jsonl_to_writer(branch, type_names, table_keys, writer) - .await?; - writer.flush()?; - Ok(()) -} - -async fn execute_export_remote_to_writer( - client: &reqwest::Client, - uri: &str, - branch: &str, - type_names: &[String], - table_keys: &[String], - bearer_token: Option<&str>, - writer: &mut W, -) -> Result<()> { - let request = apply_bearer_token( - client.request(Method::POST, remote_url(uri, "/export")), - bearer_token, - ) - .json(&ExportRequest { - branch: Some(branch.to_string()), - type_names: type_names.to_vec(), - table_keys: table_keys.to_vec(), - }); - let mut response = request.send().await?; - let status = response.status(); - if !status.is_success() { - let text = response.text().await?; - if let Ok(error) = serde_json::from_str::(&text) { - bail!(error.error); - } - bail!("server returned {}: {}", status, text); - } - - while let Some(chunk) = response.chunk().await? { - writer.write_all(&chunk)?; - } - writer.flush()?; - Ok(()) -} - -/// Rewrite deprecated CLI invocations into their canonical form. -/// -/// The current rename pass moves four subcommands: -/// - `omnigraph read` -> `omnigraph query` (clap `visible_alias` handles parsing; we warn) -/// - `omnigraph change` -> `omnigraph mutate` (clap `visible_alias` handles parsing; we warn) -/// - `omnigraph check` -> `omnigraph lint` (rewrite required; no visible_alias by design) -/// - `omnigraph query lint` -> `omnigraph lint` (rewrite required; `query` is now the read-runner) -/// - `omnigraph query check` -> `omnigraph lint` (rewrite required) -/// -/// `check` is *not* a clap visible_alias on `lint` even though they're -/// semantically equivalent. Visible aliases create two canonical names -/// that agents emit interchangeably depending on training-data drift -/// (see MR-981 §6 for the policy). The argv-shim + stderr warning -/// pattern preserves back-compat for human users while pointing every -/// caller at the single canonical name in `--help`. -/// -/// Returns the (possibly rewritten) argv that clap should parse. -fn rewrite_deprecated_argv(args: Vec) -> Vec { - if args.len() >= 3 { - let sub = args[1].to_str(); - let sub2 = args[2].to_str(); - if sub == Some("query") && matches!(sub2, Some("lint") | Some("check")) { - let suffix = sub2.unwrap(); - eprintln!( - "warning: `omnigraph query {suffix}` is deprecated; use `omnigraph lint` instead" - ); - // Drop the leading `query` token AND normalize `check` -> `lint`. - // `check` is no longer a clap visible_alias (MR-981 §6), so the - // rewritten argv must reach the canonical `lint` subcommand - // directly. Result for `omnigraph query check --query foo.gq`: - // `omnigraph lint --query foo.gq`. - let mut out = Vec::with_capacity(args.len() - 1); - out.push(args[0].clone()); - out.push(OsString::from("lint")); - out.extend(args[3..].iter().cloned()); - return out; - } - } - if let Some(sub) = args.get(1).and_then(|s| s.to_str()) { - match sub { - "read" => { - eprintln!("warning: `omnigraph read` is deprecated; use `omnigraph query` instead") - } - "change" => eprintln!( - "warning: `omnigraph change` is deprecated; use `omnigraph mutate` instead" - ), - "check" => { - eprintln!("warning: `omnigraph check` is deprecated; use `omnigraph lint` instead"); - // Rewrite the top-level subcommand to `lint`; pass through the rest. - let mut out = Vec::with_capacity(args.len()); - out.push(args[0].clone()); - out.push(OsString::from("lint")); - out.extend(args[2..].iter().cloned()); - return out; - } - _ => {} - } - } - args -} +mod cli; +mod helpers; +mod output; +use cli::*; +use helpers::*; +use output::*; #[tokio::main] async fn main() -> Result<()> { @@ -3779,419 +1178,7 @@ async fn main() -> Result<()> { Ok(()) } + #[cfg(test)] -mod tests { - use std::fs; - - use super::{ - DEFAULT_BEARER_TOKEN_ENV, apply_bearer_token, bearer_token_from_env_file, - legacy_change_request_body, load_cli_config, load_env_file_into_process, - normalize_bearer_token, parse_env_assignment, resolve_cli_graph, resolve_policy_context, - resolve_remote_bearer_token, - }; - use omnigraph_server::load_config; - use reqwest::header::AUTHORIZATION; - use serde_json::json; - use tempfile::tempdir; - - #[test] - fn legacy_change_request_body_uses_legacy_field_names() { - // `execute_change_remote` hits `POST /change`, which old - // `omnigraph-server` builds deserialize as `ChangeRequest` with - // **required** `query_source` and optional `query_name` keys. - // Newer servers accept both spellings via serde alias, but a - // newer CLI must still emit the legacy keys on the wire so it - // can talk to an old server during a rolling upgrade. - let body = legacy_change_request_body( - "query insert_person($n: String) { insert Person { name: $n } }", - Some("insert_person"), - "main", - Some(&json!({ "n": "Alice" })), - ); - assert_eq!( - body["query_source"].as_str(), - Some("query insert_person($n: String) { insert Person { name: $n } }"), - ); - assert_eq!(body["query_name"].as_str(), Some("insert_person")); - assert_eq!(body["branch"].as_str(), Some("main")); - assert_eq!(body["params"]["n"].as_str(), Some("Alice")); - // Crucially, the **new** field names must NOT appear -- old - // servers would silently treat them as unknown fields and then - // fail on missing required `query_source`. - assert!( - body.get("query").is_none(), - "legacy /change body must not carry the renamed `query` key; got {body}" - ); - assert!( - body.get("name").is_none(), - "legacy /change body must not carry the renamed `name` key; got {body}" - ); - } - - #[test] - fn legacy_change_request_body_omits_optional_fields_when_unset() { - let body = legacy_change_request_body( - "query find() { match { $p: Person } return { $p.name } }", - None, - "main", - None, - ); - assert_eq!(body["branch"].as_str(), Some("main")); - assert!(body.get("query_name").is_none()); - assert!(body.get("params").is_none()); - } - - #[test] - fn apply_bearer_token_adds_header_when_configured() { - let client = reqwest::Client::new(); - let request = apply_bearer_token(client.get("http://example.com"), Some("demo-token")) - .build() - .unwrap(); - assert_eq!( - request - .headers() - .get(AUTHORIZATION) - .and_then(|value| value.to_str().ok()), - Some("Bearer demo-token") - ); - } - - #[test] - fn apply_bearer_token_leaves_request_unchanged_when_not_configured() { - let client = reqwest::Client::new(); - let request = apply_bearer_token(client.get("http://example.com"), None) - .build() - .unwrap(); - assert!(request.headers().get(AUTHORIZATION).is_none()); - } - - #[test] - fn normalize_bearer_token_trims_and_filters_blank_values() { - assert_eq!(normalize_bearer_token(None), None); - assert_eq!(normalize_bearer_token(Some(" ".to_string())), None); - assert_eq!( - normalize_bearer_token(Some(" demo-token ".to_string())).as_deref(), - Some("demo-token") - ); - } - - #[test] - fn parse_env_assignment_supports_plain_and_exported_values() { - assert_eq!( - parse_env_assignment("DEMO_TOKEN=demo-token"), - Some(("DEMO_TOKEN".to_string(), "demo-token".to_string())) - ); - assert_eq!( - parse_env_assignment("export DEMO_TOKEN=\"quoted-token\""), - Some(("DEMO_TOKEN".to_string(), "quoted-token".to_string())) - ); - assert_eq!(parse_env_assignment("# comment"), None); - assert_eq!(parse_env_assignment(" "), None); - } - - #[test] - fn bearer_token_from_env_file_reads_named_value() { - let temp = tempdir().unwrap(); - let env_file = temp.path().join(".env.omni"); - fs::write( - &env_file, - "FIRST=ignore\nexport DEMO_TOKEN=\" demo-token \"\n", - ) - .unwrap(); - - assert_eq!( - bearer_token_from_env_file(&env_file, "DEMO_TOKEN") - .unwrap() - .as_deref(), - Some("demo-token") - ); - assert_eq!( - bearer_token_from_env_file(&env_file, "MISSING").unwrap(), - None - ); - } - - #[test] - fn load_env_file_into_process_sets_missing_values_without_overriding_existing_ones() { - let temp = tempdir().unwrap(); - let env_file = temp.path().join(".env.omni"); - fs::write( - &env_file, - "AUTOLOAD_ONLY=from-file\nAUTOLOAD_PRESET=from-file\n", - ) - .unwrap(); - - let missing_key = "AUTOLOAD_ONLY"; - let preset_key = "AUTOLOAD_PRESET"; - let previous_missing = std::env::var_os(missing_key); - let previous_preset = std::env::var_os(preset_key); - - unsafe { - std::env::remove_var(missing_key); - std::env::set_var(preset_key, "from-env"); - } - - load_env_file_into_process(&env_file).unwrap(); - - assert_eq!(std::env::var(missing_key).unwrap(), "from-file"); - assert_eq!(std::env::var(preset_key).unwrap(), "from-env"); - - unsafe { - if let Some(value) = previous_missing { - std::env::set_var(missing_key, value); - } else { - std::env::remove_var(missing_key); - } - - if let Some(value) = previous_preset { - std::env::set_var(preset_key, value); - } else { - std::env::remove_var(preset_key); - } - } - } - - #[test] - fn resolve_remote_bearer_token_uses_scoped_env_file_with_global_fallback() { - let temp = tempdir().unwrap(); - fs::write( - temp.path().join("omnigraph.yaml"), - r#" -graphs: - demo: - uri: https://example.com - bearer_token_env: DEMO_TOKEN -auth: - env_file: .env.omni -cli: - graph: demo -"#, - ) - .unwrap(); - fs::write( - temp.path().join(".env.omni"), - "DEMO_TOKEN=scoped-token\nOMNIGRAPH_BEARER_TOKEN=global-token\n", - ) - .unwrap(); - - let previous = std::env::var_os(DEFAULT_BEARER_TOKEN_ENV); - unsafe { - std::env::remove_var(DEFAULT_BEARER_TOKEN_ENV); - } - - let config_path = temp.path().join("omnigraph.yaml"); - let config = load_config(Some(&config_path)).unwrap(); - - assert_eq!( - resolve_remote_bearer_token(&config, None, Some("demo")) - .unwrap() - .as_deref(), - Some("scoped-token") - ); - assert_eq!( - resolve_remote_bearer_token(&config, Some("https://override.example.com"), None) - .unwrap() - .as_deref(), - Some("global-token") - ); - - unsafe { - if let Some(value) = previous { - std::env::set_var(DEFAULT_BEARER_TOKEN_ENV, value); - } else { - std::env::remove_var(DEFAULT_BEARER_TOKEN_ENV); - } - } - } - - #[test] - fn load_cli_config_autoloads_env_file_into_process() { - let temp = tempdir().unwrap(); - fs::write( - temp.path().join("omnigraph.yaml"), - r#" -auth: - env_file: .env.omni -graphs: - demo: - uri: s3://bucket/prefix -"#, - ) - .unwrap(); - fs::write( - temp.path().join(".env.omni"), - "AUTOLOAD_FROM_CONFIG=loaded\n", - ) - .unwrap(); - - let key = "AUTOLOAD_FROM_CONFIG"; - let previous = std::env::var_os(key); - unsafe { - std::env::remove_var(key); - } - - let config_path = temp.path().join("omnigraph.yaml"); - let config = load_cli_config(Some(&config_path)).unwrap(); - - assert_eq!( - config.resolve_target_uri(None, Some("demo"), None).unwrap(), - "s3://bucket/prefix" - ); - assert_eq!(std::env::var(key).unwrap(), "loaded"); - - unsafe { - if let Some(value) = previous { - std::env::set_var(key, value); - } else { - std::env::remove_var(key); - } - } - } - - #[test] - fn graph_identity_resolve_policy_context_named_cli_graph_uses_graph_key_not_project_name_or_uri() - { - let temp = tempdir().unwrap(); - let config_path = temp.path().join("omnigraph.yaml"); - fs::write( - &config_path, - r#" -project: - name: misleading-project -graphs: - local: - uri: /tmp/local-policy-graph.omni - policy: - file: ./policy.yaml -cli: - graph: local -"#, - ) - .unwrap(); - - let config = load_config(Some(&config_path)).unwrap(); - let context = resolve_policy_context(&config).unwrap(); - assert_eq!(context.graph_id, "local"); - } - - #[test] - fn graph_identity_resolve_policy_context_server_graph_uses_graph_key_when_cli_graph_absent() { - let temp = tempdir().unwrap(); - let config_path = temp.path().join("omnigraph.yaml"); - fs::write( - &config_path, - r#" -project: - name: misleading-project -graphs: - local: - uri: /tmp/local-policy-graph.omni - policy: - file: ./server-policy.yaml -server: - graph: local -"#, - ) - .unwrap(); - - let config = load_config(Some(&config_path)).unwrap(); - let context = resolve_policy_context(&config).unwrap(); - assert_eq!(context.graph_id, "local"); - assert!(context.policy_file.ends_with("server-policy.yaml")); - } - - #[test] - fn graph_identity_resolve_policy_context_anonymous_uses_top_level_default_identity() { - let temp = tempdir().unwrap(); - let config_path = temp.path().join("omnigraph.yaml"); - fs::write( - &config_path, - r#" -project: - name: misleading-project -graphs: - local: - uri: /tmp/local-policy-graph.omni -policy: - file: ./top-policy.yaml -"#, - ) - .unwrap(); - - let config = load_config(Some(&config_path)).unwrap(); - let context = resolve_policy_context(&config).unwrap(); - assert_eq!(context.graph_id, "default"); - assert!(context.policy_file.ends_with("top-policy.yaml")); - } - - #[test] - fn graph_identity_resolve_cli_graph_named_target_uses_graph_key_not_project_name_or_uri() { - let temp = tempdir().unwrap(); - let config_path = temp.path().join("omnigraph.yaml"); - fs::write( - &config_path, - r#" -project: - name: misleading-project -graphs: - prod: - uri: s3://bucket/prod-graph/ - policy: - file: ./prod-policy.yaml -"#, - ) - .unwrap(); - - let config = load_config(Some(&config_path)).unwrap(); - let graph = resolve_cli_graph(&config, None, Some("prod")).unwrap(); - assert_eq!(graph.selected(), Some("prod")); - assert_eq!(graph.graph_id, "prod"); - assert_eq!(graph.uri, "s3://bucket/prod-graph/"); - } - - #[test] - fn graph_identity_resolve_cli_graph_positional_uri_uses_anonymous_normalized_uri() { - let temp = tempdir().unwrap(); - let config_path = temp.path().join("omnigraph.yaml"); - fs::write( - &config_path, - r#" -project: - name: misleading-project -graphs: - local: - uri: /tmp/configured-graph.omni - policy: - file: ./policy.yaml -cli: - graph: local -"#, - ) - .unwrap(); - - let config = load_config(Some(&config_path)).unwrap(); - let local_graph_path = temp.path().join("explicit-graph.omni"); - let local_graph = resolve_cli_graph( - &config, - Some(format!("file://{}", local_graph_path.display())), - None, - ) - .unwrap(); - assert_eq!(local_graph.selected(), None); - assert_eq!( - local_graph.graph_id, - local_graph_path.to_string_lossy().as_ref() - ); - assert_eq!(local_graph.policy_file, None); - - let s3_graph = resolve_cli_graph( - &config, - Some("s3://bucket/anonymous-graph/".to_string()), - None, - ) - .unwrap(); - assert_eq!(s3_graph.selected(), None); - assert_eq!(s3_graph.graph_id, "s3://bucket/anonymous-graph"); - assert_eq!(s3_graph.policy_file, None); - } -} +#[path = "main_tests.rs"] +mod tests; diff --git a/crates/omnigraph-cli/src/main_tests.rs b/crates/omnigraph-cli/src/main_tests.rs new file mode 100644 index 0000000..0bbb593 --- /dev/null +++ b/crates/omnigraph-cli/src/main_tests.rs @@ -0,0 +1,416 @@ +//! In-source test suite for the CLI binary (moved verbatim from +//! main.rs; `use super::*` resolves through the #[path] declaration). + + use std::fs; + + use super::{ + DEFAULT_BEARER_TOKEN_ENV, apply_bearer_token, bearer_token_from_env_file, + legacy_change_request_body, load_cli_config, load_env_file_into_process, + normalize_bearer_token, parse_env_assignment, resolve_cli_graph, resolve_policy_context, + resolve_remote_bearer_token, + }; + use omnigraph_server::load_config; + use reqwest::header::AUTHORIZATION; + use serde_json::json; + use tempfile::tempdir; + + #[test] + fn legacy_change_request_body_uses_legacy_field_names() { + // `execute_change_remote` hits `POST /change`, which old + // `omnigraph-server` builds deserialize as `ChangeRequest` with + // **required** `query_source` and optional `query_name` keys. + // Newer servers accept both spellings via serde alias, but a + // newer CLI must still emit the legacy keys on the wire so it + // can talk to an old server during a rolling upgrade. + let body = legacy_change_request_body( + "query insert_person($n: String) { insert Person { name: $n } }", + Some("insert_person"), + "main", + Some(&json!({ "n": "Alice" })), + ); + assert_eq!( + body["query_source"].as_str(), + Some("query insert_person($n: String) { insert Person { name: $n } }"), + ); + assert_eq!(body["query_name"].as_str(), Some("insert_person")); + assert_eq!(body["branch"].as_str(), Some("main")); + assert_eq!(body["params"]["n"].as_str(), Some("Alice")); + // Crucially, the **new** field names must NOT appear -- old + // servers would silently treat them as unknown fields and then + // fail on missing required `query_source`. + assert!( + body.get("query").is_none(), + "legacy /change body must not carry the renamed `query` key; got {body}" + ); + assert!( + body.get("name").is_none(), + "legacy /change body must not carry the renamed `name` key; got {body}" + ); + } + + #[test] + fn legacy_change_request_body_omits_optional_fields_when_unset() { + let body = legacy_change_request_body( + "query find() { match { $p: Person } return { $p.name } }", + None, + "main", + None, + ); + assert_eq!(body["branch"].as_str(), Some("main")); + assert!(body.get("query_name").is_none()); + assert!(body.get("params").is_none()); + } + + #[test] + fn apply_bearer_token_adds_header_when_configured() { + let client = reqwest::Client::new(); + let request = apply_bearer_token(client.get("http://example.com"), Some("demo-token")) + .build() + .unwrap(); + assert_eq!( + request + .headers() + .get(AUTHORIZATION) + .and_then(|value| value.to_str().ok()), + Some("Bearer demo-token") + ); + } + + #[test] + fn apply_bearer_token_leaves_request_unchanged_when_not_configured() { + let client = reqwest::Client::new(); + let request = apply_bearer_token(client.get("http://example.com"), None) + .build() + .unwrap(); + assert!(request.headers().get(AUTHORIZATION).is_none()); + } + + #[test] + fn normalize_bearer_token_trims_and_filters_blank_values() { + assert_eq!(normalize_bearer_token(None), None); + assert_eq!(normalize_bearer_token(Some(" ".to_string())), None); + assert_eq!( + normalize_bearer_token(Some(" demo-token ".to_string())).as_deref(), + Some("demo-token") + ); + } + + #[test] + fn parse_env_assignment_supports_plain_and_exported_values() { + assert_eq!( + parse_env_assignment("DEMO_TOKEN=demo-token"), + Some(("DEMO_TOKEN".to_string(), "demo-token".to_string())) + ); + assert_eq!( + parse_env_assignment("export DEMO_TOKEN=\"quoted-token\""), + Some(("DEMO_TOKEN".to_string(), "quoted-token".to_string())) + ); + assert_eq!(parse_env_assignment("# comment"), None); + assert_eq!(parse_env_assignment(" "), None); + } + + #[test] + fn bearer_token_from_env_file_reads_named_value() { + let temp = tempdir().unwrap(); + let env_file = temp.path().join(".env.omni"); + fs::write( + &env_file, + "FIRST=ignore\nexport DEMO_TOKEN=\" demo-token \"\n", + ) + .unwrap(); + + assert_eq!( + bearer_token_from_env_file(&env_file, "DEMO_TOKEN") + .unwrap() + .as_deref(), + Some("demo-token") + ); + assert_eq!( + bearer_token_from_env_file(&env_file, "MISSING").unwrap(), + None + ); + } + + #[test] + fn load_env_file_into_process_sets_missing_values_without_overriding_existing_ones() { + let temp = tempdir().unwrap(); + let env_file = temp.path().join(".env.omni"); + fs::write( + &env_file, + "AUTOLOAD_ONLY=from-file\nAUTOLOAD_PRESET=from-file\n", + ) + .unwrap(); + + let missing_key = "AUTOLOAD_ONLY"; + let preset_key = "AUTOLOAD_PRESET"; + let previous_missing = std::env::var_os(missing_key); + let previous_preset = std::env::var_os(preset_key); + + unsafe { + std::env::remove_var(missing_key); + std::env::set_var(preset_key, "from-env"); + } + + load_env_file_into_process(&env_file).unwrap(); + + assert_eq!(std::env::var(missing_key).unwrap(), "from-file"); + assert_eq!(std::env::var(preset_key).unwrap(), "from-env"); + + unsafe { + if let Some(value) = previous_missing { + std::env::set_var(missing_key, value); + } else { + std::env::remove_var(missing_key); + } + + if let Some(value) = previous_preset { + std::env::set_var(preset_key, value); + } else { + std::env::remove_var(preset_key); + } + } + } + + #[test] + fn resolve_remote_bearer_token_uses_scoped_env_file_with_global_fallback() { + let temp = tempdir().unwrap(); + fs::write( + temp.path().join("omnigraph.yaml"), + r#" +graphs: + demo: + uri: https://example.com + bearer_token_env: DEMO_TOKEN +auth: + env_file: .env.omni +cli: + graph: demo +"#, + ) + .unwrap(); + fs::write( + temp.path().join(".env.omni"), + "DEMO_TOKEN=scoped-token\nOMNIGRAPH_BEARER_TOKEN=global-token\n", + ) + .unwrap(); + + let previous = std::env::var_os(DEFAULT_BEARER_TOKEN_ENV); + unsafe { + std::env::remove_var(DEFAULT_BEARER_TOKEN_ENV); + } + + let config_path = temp.path().join("omnigraph.yaml"); + let config = load_config(Some(&config_path)).unwrap(); + + assert_eq!( + resolve_remote_bearer_token(&config, None, Some("demo")) + .unwrap() + .as_deref(), + Some("scoped-token") + ); + assert_eq!( + resolve_remote_bearer_token(&config, Some("https://override.example.com"), None) + .unwrap() + .as_deref(), + Some("global-token") + ); + + unsafe { + if let Some(value) = previous { + std::env::set_var(DEFAULT_BEARER_TOKEN_ENV, value); + } else { + std::env::remove_var(DEFAULT_BEARER_TOKEN_ENV); + } + } + } + + #[test] + fn load_cli_config_autoloads_env_file_into_process() { + let temp = tempdir().unwrap(); + fs::write( + temp.path().join("omnigraph.yaml"), + r#" +auth: + env_file: .env.omni +graphs: + demo: + uri: s3://bucket/prefix +"#, + ) + .unwrap(); + fs::write( + temp.path().join(".env.omni"), + "AUTOLOAD_FROM_CONFIG=loaded\n", + ) + .unwrap(); + + let key = "AUTOLOAD_FROM_CONFIG"; + let previous = std::env::var_os(key); + unsafe { + std::env::remove_var(key); + } + + let config_path = temp.path().join("omnigraph.yaml"); + let config = load_cli_config(Some(&config_path)).unwrap(); + + assert_eq!( + config.resolve_target_uri(None, Some("demo"), None).unwrap(), + "s3://bucket/prefix" + ); + assert_eq!(std::env::var(key).unwrap(), "loaded"); + + unsafe { + if let Some(value) = previous { + std::env::set_var(key, value); + } else { + std::env::remove_var(key); + } + } + } + + #[test] + fn graph_identity_resolve_policy_context_named_cli_graph_uses_graph_key_not_project_name_or_uri() + { + let temp = tempdir().unwrap(); + let config_path = temp.path().join("omnigraph.yaml"); + fs::write( + &config_path, + r#" +project: + name: misleading-project +graphs: + local: + uri: /tmp/local-policy-graph.omni + policy: + file: ./policy.yaml +cli: + graph: local +"#, + ) + .unwrap(); + + let config = load_config(Some(&config_path)).unwrap(); + let context = resolve_policy_context(&config).unwrap(); + assert_eq!(context.graph_id, "local"); + } + + #[test] + fn graph_identity_resolve_policy_context_server_graph_uses_graph_key_when_cli_graph_absent() { + let temp = tempdir().unwrap(); + let config_path = temp.path().join("omnigraph.yaml"); + fs::write( + &config_path, + r#" +project: + name: misleading-project +graphs: + local: + uri: /tmp/local-policy-graph.omni + policy: + file: ./server-policy.yaml +server: + graph: local +"#, + ) + .unwrap(); + + let config = load_config(Some(&config_path)).unwrap(); + let context = resolve_policy_context(&config).unwrap(); + assert_eq!(context.graph_id, "local"); + assert!(context.policy_file.ends_with("server-policy.yaml")); + } + + #[test] + fn graph_identity_resolve_policy_context_anonymous_uses_top_level_default_identity() { + let temp = tempdir().unwrap(); + let config_path = temp.path().join("omnigraph.yaml"); + fs::write( + &config_path, + r#" +project: + name: misleading-project +graphs: + local: + uri: /tmp/local-policy-graph.omni +policy: + file: ./top-policy.yaml +"#, + ) + .unwrap(); + + let config = load_config(Some(&config_path)).unwrap(); + let context = resolve_policy_context(&config).unwrap(); + assert_eq!(context.graph_id, "default"); + assert!(context.policy_file.ends_with("top-policy.yaml")); + } + + #[test] + fn graph_identity_resolve_cli_graph_named_target_uses_graph_key_not_project_name_or_uri() { + let temp = tempdir().unwrap(); + let config_path = temp.path().join("omnigraph.yaml"); + fs::write( + &config_path, + r#" +project: + name: misleading-project +graphs: + prod: + uri: s3://bucket/prod-graph/ + policy: + file: ./prod-policy.yaml +"#, + ) + .unwrap(); + + let config = load_config(Some(&config_path)).unwrap(); + let graph = resolve_cli_graph(&config, None, Some("prod")).unwrap(); + assert_eq!(graph.selected(), Some("prod")); + assert_eq!(graph.graph_id, "prod"); + assert_eq!(graph.uri, "s3://bucket/prod-graph/"); + } + + #[test] + fn graph_identity_resolve_cli_graph_positional_uri_uses_anonymous_normalized_uri() { + let temp = tempdir().unwrap(); + let config_path = temp.path().join("omnigraph.yaml"); + fs::write( + &config_path, + r#" +project: + name: misleading-project +graphs: + local: + uri: /tmp/configured-graph.omni + policy: + file: ./policy.yaml +cli: + graph: local +"#, + ) + .unwrap(); + + let config = load_config(Some(&config_path)).unwrap(); + let local_graph_path = temp.path().join("explicit-graph.omni"); + let local_graph = resolve_cli_graph( + &config, + Some(format!("file://{}", local_graph_path.display())), + None, + ) + .unwrap(); + assert_eq!(local_graph.selected(), None); + assert_eq!( + local_graph.graph_id, + local_graph_path.to_string_lossy().as_ref() + ); + assert_eq!(local_graph.policy_file, None); + + let s3_graph = resolve_cli_graph( + &config, + Some("s3://bucket/anonymous-graph/".to_string()), + None, + ) + .unwrap(); + assert_eq!(s3_graph.selected(), None); + assert_eq!(s3_graph.graph_id, "s3://bucket/anonymous-graph"); + assert_eq!(s3_graph.policy_file, None); + } diff --git a/crates/omnigraph-cli/src/output.rs b/crates/omnigraph-cli/src/output.rs new file mode 100644 index 0000000..f77e50f --- /dev/null +++ b/crates/omnigraph-cli/src/output.rs @@ -0,0 +1,830 @@ +//! Human/JSON output formatting for every command (moved verbatim from +//! main.rs in the modularization). + +use super::*; + +#[derive(Debug, Serialize)] +pub(crate) struct LoadOutput { + pub(crate) uri: String, + pub(crate) branch: String, + pub(crate) mode: &'static str, + /// Present only when `--from` was given; echoes the requested base. + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) base_branch: Option, + pub(crate) branch_created: bool, + pub(crate) nodes_loaded: usize, + pub(crate) edges_loaded: usize, + pub(crate) node_types_loaded: usize, + pub(crate) edge_types_loaded: usize, +} + +pub(crate) fn load_output_from_tables( + uri: &str, + branch: &str, + mode: CliLoadMode, + output: &IngestOutput, +) -> LoadOutput { + let mut nodes_loaded = 0; + let mut edges_loaded = 0; + let mut node_types_loaded = 0; + let mut edge_types_loaded = 0; + for table in &output.tables { + if table.table_key.starts_with("node:") { + nodes_loaded += table.rows_loaded; + node_types_loaded += 1; + } else if table.table_key.starts_with("edge:") { + edges_loaded += table.rows_loaded; + edge_types_loaded += 1; + } + } + LoadOutput { + uri: uri.to_string(), + branch: branch.to_string(), + mode: mode.as_str(), + base_branch: output.base_branch.clone(), + branch_created: output.branch_created, + nodes_loaded, + edges_loaded, + node_types_loaded, + edge_types_loaded, + } +} + +#[derive(Debug, Serialize)] +pub(crate) struct SchemaPlanOutput<'a> { + pub(crate) uri: &'a str, + pub(crate) supported: bool, + pub(crate) step_count: usize, + pub(crate) steps: &'a [SchemaMigrationStep], +} + +pub(crate) fn print_schema_apply_human(output: &SchemaApplyOutput) { + println!("schema apply for {}", output.uri); + println!("supported: {}", if output.supported { "yes" } else { "no" }); + println!("applied: {}", if output.applied { "yes" } else { "no" }); + println!("manifest_version: {}", output.manifest_version); + if output.steps.is_empty() { + println!("no schema changes"); + return; + } + for step in &output.steps { + println!("- {}", render_schema_plan_step(step)); + } +} + +pub(crate) fn query_kind_label(kind: QueryLintQueryKind) -> &'static str { + match kind { + QueryLintQueryKind::Read => "read", + QueryLintQueryKind::Mutation => "mutation", + } +} + +pub(crate) fn severity_label(severity: QueryLintSeverity) -> &'static str { + match severity { + QueryLintSeverity::Error => "ERROR", + QueryLintSeverity::Warning => "WARN ", + QueryLintSeverity::Info => "INFO ", + } +} + +pub(crate) fn print_query_lint_human(output: &QueryLintOutput) { + for result in &output.results { + match result.status { + QueryLintStatus::Ok => { + println!( + "OK query `{}` ({})", + result.name, + query_kind_label(result.kind) + ); + } + QueryLintStatus::Error => { + println!( + "ERROR query `{}`: {}", + result.name, + result.error.as_deref().unwrap_or("unknown error") + ); + } + } + + for warning in &result.warnings { + println!("WARN query `{}`: {}", result.name, warning); + } + } + + for finding in &output.findings { + println!("{} {}", severity_label(finding.severity), finding.message); + } + + println!( + "INFO Lint complete: {} queries processed ({} error(s), {} warning(s), {} info item(s))", + output.queries_processed, output.errors, output.warnings, output.infos + ); +} + +pub(crate) fn finish_query_lint(output: &QueryLintOutput, json: bool) -> Result<()> { + if json { + print_json(output)?; + } else { + print_query_lint_human(output); + } + + if output.status == QueryLintStatus::Error { + io::stdout().flush()?; + std::process::exit(1); + } + + Ok(()) +} + +pub(crate) fn print_json(value: &T) -> Result<()> { + println!("{}", serde_json::to_string_pretty(value)?); + Ok(()) +} + +pub(crate) fn print_cluster_validate_human(output: &ValidateOutput) { + if output.ok { + println!( + "cluster config valid: {} resource(s), {} dependency edge(s)", + output.resources.len(), + output.dependencies.len() + ); + } else { + println!("cluster config invalid"); + } + print_cluster_diagnostics(&output.diagnostics); +} + +pub(crate) fn print_cluster_plan_human(output: &PlanOutput) { + if output.ok { + println!( + "cluster plan: {} change(s), {} approval gate(s)", + output.changes.len(), + output.approvals_required.len() + ); + for change in &output.changes { + let bindings = if change.binding_change { " [bindings]" } else { "" }; + println!(" {:?} {}{bindings}", change.operation, change.resource); + if let Some(migration) = &change.migration { + if !migration.supported { + println!(" migration UNSUPPORTED:"); + } + for step in &migration.steps { + println!( + " {}", + serde_json::to_string(step).unwrap_or_else(|_| format!("{step:?}")) + ); + } + } + } + if output.changes.is_empty() { + println!(" no changes"); + } + } else { + println!("cluster plan failed"); + } + print_cluster_diagnostics(&output.diagnostics); +} + +pub(crate) fn print_cluster_apply_human(output: &ApplyOutput) { + if output.ok { + println!( + "cluster apply: {} applied, {} deferred/blocked", + output.applied_count, output.deferred_count + ); + } else { + println!("cluster apply failed"); + } + // The change list prints on failure too: an operator debugging a partial + // apply (payload or state-write error) needs to see what was attempted. + print_cluster_apply_changes(&output.changes); + if output.ok { + let state = &output.state_observations; + println!( + " state: revision {}, converged: {}, written: {}", + state.state_revision, output.converged, output.state_written + ); + println!(" note: cluster-booted servers (--cluster) serve this on their next restart; omnigraph.yaml deployments are unaffected"); + } + print_cluster_diagnostics(&output.diagnostics); +} + +pub(crate) fn print_cluster_apply_changes(changes: &[omnigraph_cluster::PlanChange]) { + for change in changes { + let bindings = if change.binding_change { " [bindings]" } else { "" }; + match (&change.disposition, change.reason.as_deref()) { + (Some(disposition), Some(reason)) => println!( + " {:?} {}{bindings} [{disposition:?}: {reason}]", + change.operation, change.resource + ), + (Some(disposition), None) => println!( + " {:?} {}{bindings} [{disposition:?}]", + change.operation, change.resource + ), + _ => println!(" {:?} {}{bindings}", change.operation, change.resource), + } + } + if changes.is_empty() { + println!(" no changes"); + } +} + +pub(crate) fn print_cluster_status_human(output: &StatusOutput) { + if output.ok { + let state = &output.state_observations; + if state.state_found { + println!( + "cluster state: revision {}, {} resource(s)", + state.state_revision, state.resource_count + ); + if let Some(digest) = state.applied_config_digest.as_deref() { + println!(" applied config: {digest}"); + } + if state.locked { + println!(" lock: held{}", cluster_lock_summary(state)); + } else { + println!(" lock: not held"); + } + } else { + println!("cluster state missing"); + } + } else { + println!("cluster status failed"); + } + print_cluster_diagnostics(&output.diagnostics); +} + +pub(crate) fn print_cluster_state_sync_human(output: &StateSyncOutput) { + let operation = match output.operation { + omnigraph_cluster::StateSyncOperation::Refresh => "refresh", + omnigraph_cluster::StateSyncOperation::Import => "import", + }; + if output.ok { + let state = &output.state_observations; + println!( + "cluster {operation}: revision {}, {} resource(s)", + state.state_revision, state.resource_count + ); + if let Some(cas) = state.state_cas.as_deref() { + println!(" state_cas: {cas}"); + } + if state.locked { + println!(" lock: acquired{}", cluster_lock_summary(state)); + } else { + println!(" lock: not acquired"); + } + } else { + println!("cluster {operation} failed"); + } + print_cluster_diagnostics(&output.diagnostics); +} + +pub(crate) fn print_cluster_force_unlock_human(output: &ForceUnlockOutput) { + if output.ok { + if output.lock_removed { + println!( + "cluster force-unlock: removed lock{}", + cluster_lock_summary(&output.state_observations) + ); + } else { + println!("cluster force-unlock: no lock removed"); + } + } else { + println!("cluster force-unlock failed"); + if output.state_observations.locked { + println!( + " lock: held{}", + cluster_lock_summary(&output.state_observations) + ); + } + } + print_cluster_diagnostics(&output.diagnostics); +} + +pub(crate) fn cluster_lock_summary(state: &omnigraph_cluster::StateObservations) -> String { + let Some(lock_id) = state.lock_id.as_deref() else { + return String::new(); + }; + let mut parts = vec![format!("id={lock_id}")]; + if let Some(operation) = state.lock_operation.as_deref() { + parts.push(format!("operation={operation}")); + } + if let Some(pid) = state.lock_pid { + parts.push(format!("pid={pid}")); + } + if let Some(created_at) = state.lock_created_at.as_deref() { + parts.push(format!("created_at={created_at}")); + } + if let Some(age_seconds) = state.lock_age_seconds { + parts.push(format!("age_seconds={age_seconds}")); + } + format!(" ({})", parts.join(", ")) +} + +pub(crate) fn print_cluster_diagnostics(diagnostics: &[omnigraph_cluster::Diagnostic]) { + for diagnostic in diagnostics { + let label = match diagnostic.severity { + DiagnosticSeverity::Error => "ERROR", + DiagnosticSeverity::Warning => "WARN ", + }; + println!( + "{label} {} {}: {}", + diagnostic.code, diagnostic.path, diagnostic.message + ); + } +} + +pub(crate) fn finish_cluster_validate(output: &ValidateOutput, json: bool) -> Result<()> { + if json { + print_json(output)?; + } else { + print_cluster_validate_human(output); + } + if !output.ok { + io::stdout().flush()?; + std::process::exit(1); + } + Ok(()) +} + +pub(crate) fn finish_cluster_plan(output: &PlanOutput, json: bool) -> Result<()> { + if json { + print_json(output)?; + } else { + print_cluster_plan_human(output); + } + if !output.ok { + io::stdout().flush()?; + std::process::exit(1); + } + Ok(()) +} + +pub(crate) fn finish_cluster_apply(output: &ApplyOutput, json: bool) -> Result<()> { + if json { + print_json(output)?; + } else { + print_cluster_apply_human(output); + } + if !output.ok { + io::stdout().flush()?; + std::process::exit(1); + } + Ok(()) +} + +pub(crate) fn finish_cluster_approve(output: &ApproveOutput, json: bool) -> Result<()> { + if json { + print_json(output)?; + } else if output.ok { + println!( + "cluster approve: {} {} approved by {} (approval {})", + output + .operation + .as_ref() + .map(|operation| format!("{operation:?}").to_lowercase()) + .unwrap_or_default(), + output.resource.as_deref().unwrap_or("?"), + output.approved_by.as_deref().unwrap_or("?"), + output.approval_id.as_deref().unwrap_or("?"), + ); + print_cluster_diagnostics(&output.diagnostics); + } else { + println!("cluster approve failed"); + print_cluster_diagnostics(&output.diagnostics); + } + if !output.ok { + io::stdout().flush()?; + std::process::exit(1); + } + Ok(()) +} + +pub(crate) fn finish_cluster_status(output: &StatusOutput, json: bool) -> Result<()> { + if json { + print_json(output)?; + } else { + print_cluster_status_human(output); + } + if !output.ok { + io::stdout().flush()?; + std::process::exit(1); + } + Ok(()) +} + +pub(crate) fn finish_cluster_state_sync(output: &StateSyncOutput, json: bool) -> Result<()> { + if json { + print_json(output)?; + } else { + print_cluster_state_sync_human(output); + } + if !output.ok { + io::stdout().flush()?; + std::process::exit(1); + } + Ok(()) +} + +pub(crate) fn finish_cluster_force_unlock(output: &ForceUnlockOutput, json: bool) -> Result<()> { + if json { + print_json(output)?; + } else { + print_cluster_force_unlock_human(output); + } + if !output.ok { + io::stdout().flush()?; + std::process::exit(1); + } + Ok(()) +} + +pub(crate) fn print_load_human(payload: &LoadOutput) { + println!( + "loaded {} on branch {} with {}: {} nodes across {} node types, {} edges across {} edge types", + payload.uri, + payload.branch, + payload.mode, + payload.nodes_loaded, + payload.node_types_loaded, + payload.edges_loaded, + payload.edge_types_loaded + ); + if payload.branch_created { + if let Some(base) = &payload.base_branch { + println!("branch {} created from {}", payload.branch, base); + } + } +} + +pub(crate) fn print_ingest_human(output: &IngestOutput) { + println!( + "ingested {} into branch {} from {} with {} ({})", + output.uri, + output.branch, + output.base_branch.as_deref().unwrap_or("main"), + output.mode.as_str(), + if output.branch_created { + "branch created" + } else { + "branch exists" + } + ); + for table in &output.tables { + println!("{} rows_loaded={}", table.table_key, table.rows_loaded); + } + if let Some(actor_id) = &output.actor_id { + println!("actor_id: {}", actor_id); + } +} + +pub(crate) fn print_schema_plan_human(uri: &str, plan: &SchemaMigrationPlan) { + println!("schema plan for {}", uri); + println!("supported: {}", if plan.supported { "yes" } else { "no" }); + if plan.steps.is_empty() { + println!("no schema changes"); + return; + } + for step in &plan.steps { + println!("- {}", render_schema_plan_step(step)); + } +} + +pub(crate) fn render_schema_plan_step(step: &SchemaMigrationStep) -> String { + match step { + SchemaMigrationStep::AddType { type_kind, name } => { + format!("add {} type '{}'", schema_type_kind_label(*type_kind), name) + } + SchemaMigrationStep::RenameType { + type_kind, + from, + to, + } => format!( + "rename {} type '{}' -> '{}'", + schema_type_kind_label(*type_kind), + from, + to + ), + SchemaMigrationStep::AddProperty { + type_kind, + type_name, + property_name, + property_type, + } => format!( + "add property '{}.{}' ({}) on {} '{}'", + type_name, + property_name, + render_prop_type(property_type), + schema_type_kind_label(*type_kind), + type_name + ), + SchemaMigrationStep::RenameProperty { + type_kind, + type_name, + from, + to, + } => format!( + "rename property '{}.{}' -> '{}.{}' on {} '{}'", + type_name, + from, + type_name, + to, + schema_type_kind_label(*type_kind), + type_name + ), + SchemaMigrationStep::AddConstraint { + type_kind, + type_name, + constraint, + } => format!( + "add constraint {} on {} '{}'", + render_constraint(constraint), + schema_type_kind_label(*type_kind), + type_name + ), + SchemaMigrationStep::UpdateTypeMetadata { + type_kind, + name, + annotations, + } => format!( + "update metadata on {} '{}' ({})", + schema_type_kind_label(*type_kind), + name, + render_annotations(annotations) + ), + SchemaMigrationStep::UpdatePropertyMetadata { + type_kind, + type_name, + property_name, + annotations, + } => format!( + "update metadata on property '{}.{}' of {} '{}' ({})", + type_name, + property_name, + schema_type_kind_label(*type_kind), + type_name, + render_annotations(annotations) + ), + SchemaMigrationStep::DropType { + type_kind, + name, + mode, + } => format!( + "drop {} type '{}' ({} mode)", + schema_type_kind_label(*type_kind), + name, + drop_mode_label(*mode), + ), + SchemaMigrationStep::DropProperty { + type_kind, + type_name, + property_name, + mode, + } => format!( + "drop property '{}.{}' of {} '{}' ({} mode)", + type_name, + property_name, + schema_type_kind_label(*type_kind), + type_name, + drop_mode_label(*mode), + ), + SchemaMigrationStep::UnsupportedChange { entity, reason, .. } => { + // When a schema-lint code is attached, render code + tier + // so operators see at-a-glance the kind of risk (destructive + // / validated / safe) — not just the rule identifier. + // Reach the diagnostic via the `diagnostic()` helper so the + // CLI doesn't need to know how the lookup works. + match step.diagnostic() { + Some(diag) => format!( + "unsupported change on {} [{}, {}]: {}", + entity, + diag.code, + schema_lint_tier_label(diag.tier), + reason, + ), + None => format!("unsupported change on {}: {}", entity, reason), + } + } + } +} + +pub(crate) fn schema_type_kind_label(kind: omnigraph_compiler::SchemaTypeKind) -> &'static str { + match kind { + omnigraph_compiler::SchemaTypeKind::Interface => "interface", + omnigraph_compiler::SchemaTypeKind::Node => "node", + omnigraph_compiler::SchemaTypeKind::Edge => "edge", + } +} + +pub(crate) fn schema_lint_tier_label(tier: omnigraph_compiler::SafetyTier) -> &'static str { + match tier { + omnigraph_compiler::SafetyTier::Safe => "safe", + omnigraph_compiler::SafetyTier::Validated => "validated", + omnigraph_compiler::SafetyTier::Destructive => "destructive", + } +} + +pub(crate) fn drop_mode_label(mode: omnigraph_compiler::DropMode) -> &'static str { + match mode { + omnigraph_compiler::DropMode::Soft => "soft", + omnigraph_compiler::DropMode::Hard => "hard", + } +} + +pub(crate) fn render_prop_type(prop_type: &omnigraph_compiler::PropType) -> String { + let base = if let Some(values) = &prop_type.enum_values { + format!("Enum({})", values.join("|")) + } else { + prop_type.scalar.to_string() + }; + let base = if prop_type.list { + format!("[{}]", base) + } else { + base + }; + if prop_type.nullable { + format!("{}?", base) + } else { + base + } +} + +pub(crate) fn render_constraint(constraint: &omnigraph_compiler::schema::ast::Constraint) -> String { + match constraint { + omnigraph_compiler::schema::ast::Constraint::Key(columns) => { + format!("@key({})", columns.join(", ")) + } + omnigraph_compiler::schema::ast::Constraint::Unique(columns) => { + format!("@unique({})", columns.join(", ")) + } + omnigraph_compiler::schema::ast::Constraint::Index(columns) => { + format!("@index({})", columns.join(", ")) + } + omnigraph_compiler::schema::ast::Constraint::Range { property, min, max } => { + format!("@range({}, {:?}, {:?})", property, min, max) + } + omnigraph_compiler::schema::ast::Constraint::Check { property, pattern } => { + format!("@check({}, {:?})", property, pattern) + } + } +} + +pub(crate) fn render_annotations(annotations: &[omnigraph_compiler::schema::ast::Annotation]) -> String { + annotations + .iter() + .map(|annotation| match &annotation.value { + Some(value) => format!("@{}({})", annotation.name, value), + None => format!("@{}", annotation.name), + }) + .collect::>() + .join(", ") +} + +pub(crate) fn print_embed_human(output: &EmbedOutput) { + println!( + "embedded {} rows (selected {}, cleaned {}) from {} -> {} [{} {}d]", + output.embedded_rows, + output.selected_rows, + output.cleaned_rows, + output.input, + output.output, + output.mode, + output.dimension + ); +} + +pub(crate) fn print_snapshot_human(branch: &str, manifest_version: u64, entries: &[SnapshotTableOutput]) { + println!("branch: {}", branch); + println!("manifest_version: {}", manifest_version); + for entry in entries { + println!( + "{} v{} branch={} rows={}", + entry.table_key, + entry.table_version, + entry.table_branch.as_deref().unwrap_or("main"), + entry.row_count + ); + } +} + +pub(crate) fn print_read_output( + output: &ReadOutput, + format: ReadOutputFormat, + config: &OmnigraphConfig, +) -> Result<()> { + println!( + "{}", + render_read( + output, + format, + &ReadRenderOptions { + max_column_width: config.table_max_column_width(), + cell_layout: config.table_cell_layout(), + }, + )? + ); + Ok(()) +} + +pub(crate) fn print_change_human(output: &ChangeOutput) { + println!( + "changed {} via {}: {} nodes, {} edges", + output.branch, output.query_name, output.affected_nodes, output.affected_edges + ); + if let Some(actor_id) = &output.actor_id { + println!("actor_id: {}", actor_id); + } +} + +pub(crate) fn print_commit_list_human(commits: &[CommitOutput]) { + for commit in commits { + let branch = commit.manifest_branch.as_deref().unwrap_or("main"); + println!( + "{} branch={} version={}{}", + commit.graph_commit_id, + branch, + commit.manifest_version, + commit + .actor_id + .as_deref() + .map(|actor| format!(" actor={}", actor)) + .unwrap_or_default() + ); + } +} + +pub(crate) fn print_commit_human(commit: &CommitOutput) { + println!("graph_commit_id: {}", commit.graph_commit_id); + println!( + "manifest_branch: {}", + commit.manifest_branch.as_deref().unwrap_or("main") + ); + println!("manifest_version: {}", commit.manifest_version); + if let Some(parent_commit_id) = &commit.parent_commit_id { + println!("parent_commit_id: {}", parent_commit_id); + } + if let Some(merged_parent_commit_id) = &commit.merged_parent_commit_id { + println!("merged_parent_commit_id: {}", merged_parent_commit_id); + } + if let Some(actor_id) = &commit.actor_id { + println!("actor_id: {}", actor_id); + } + println!("created_at: {}", commit.created_at); +} + +pub(crate) fn print_policy_explain(decision: &PolicyDecision, actor_id: &str, request: &PolicyRequest) { + println!( + "decision: {}", + if decision.allowed { "allow" } else { "deny" } + ); + println!("actor: {}", actor_id); + println!("action: {}", request.action); + if let Some(branch) = &request.branch { + println!("branch: {}", branch); + } + if let Some(target_branch) = &request.target_branch { + println!("target_branch: {}", target_branch); + } + if let Some(rule_id) = &decision.matched_rule_id { + println!("matched_rule: {}", rule_id); + } + println!("message: {}", decision.message); +} + +pub(crate) fn yaml_string(value: &str) -> String { + format!("'{}'", value.replace('\'', "''")) +} + +#[derive(serde::Serialize)] +pub(crate) struct QueriesIssue { + pub(crate) query: String, + pub(crate) message: String, +} + +#[derive(serde::Serialize)] +pub(crate) struct QueriesValidateOutput { + pub(crate) ok: bool, + pub(crate) breakages: Vec, + pub(crate) warnings: Vec, +} + +#[derive(serde::Serialize)] +pub(crate) struct QueriesParam { + pub(crate) name: String, + #[serde(rename = "type")] + pub(crate) type_name: String, + pub(crate) nullable: bool, +} + +#[derive(serde::Serialize)] +pub(crate) struct QueriesListItem { + pub(crate) name: String, + pub(crate) mcp_expose: bool, + pub(crate) tool_name: Option, + pub(crate) mutation: bool, + pub(crate) params: Vec, +} + +#[derive(serde::Serialize)] +pub(crate) struct QueriesListOutput { + pub(crate) queries: Vec, +} diff --git a/crates/omnigraph-cli/tests/cli.rs b/crates/omnigraph-cli/tests/cli.rs deleted file mode 100644 index 3f21b8a..0000000 --- a/crates/omnigraph-cli/tests/cli.rs +++ /dev/null @@ -1,4548 +0,0 @@ -use std::fs; - -use lance::Dataset; -use lance::index::DatasetIndexExt; -use omnigraph::db::{Omnigraph, ReadTarget}; -use serde_json::Value; -use tempfile::tempdir; - -mod support; - -use support::*; - -const POLICY_YAML: &str = r#" -version: 1 -groups: - team: [act-andrew, act-bruno] - admins: [act-andrew] -protected_branches: [main] -rules: - - id: team-read - allow: - actors: { group: team } - actions: [read] - branch_scope: any - - id: team-write - allow: - actors: { group: team } - actions: [change] - branch_scope: unprotected - - id: admins-promote - allow: - actors: { group: admins } - actions: [branch_merge] - target_branch_scope: protected -"#; - -const POLICY_TESTS_YAML: &str = r#" -version: 1 -cases: - - id: allow-feature-write - actor: act-andrew - action: change - branch: feature - expect: allow - - id: deny-main-write - actor: act-bruno - action: change - branch: main - expect: deny -"#; - -fn manifest_dataset_version(graph: &std::path::Path) -> u64 { - tokio::runtime::Runtime::new().unwrap().block_on(async { - Omnigraph::open(graph.to_string_lossy().as_ref()) - .await - .unwrap() - .snapshot_of(ReadTarget::branch("main")) - .await - .unwrap() - .version() - }) -} - -fn forge_person_delete_drift(graph: &std::path::Path) -> (u64, u64) { - tokio::runtime::Runtime::new().unwrap().block_on(async { - let uri = graph.to_string_lossy(); - let db = Omnigraph::open(uri.as_ref()).await.unwrap(); - let snap = db - .snapshot_of(ReadTarget::branch("main")) - .await - .unwrap(); - let entry = snap.entry("node:Person").unwrap(); - let full_path = format!("{}/{}", uri.trim_end_matches('/'), entry.table_path); - let mut ds = Dataset::open(&full_path).await.unwrap(); - let deleted = ds.delete("name = 'Alice'").await.unwrap(); - assert_eq!(deleted.num_deleted_rows, 1); - let head = deleted.new_dataset.version().version; - assert!(head > entry.table_version); - (entry.table_version, head) - }) -} - -fn write_policy_config_fixture(root: &std::path::Path) -> (std::path::PathBuf, std::path::PathBuf) { - let config = root.join("omnigraph.yaml"); - let policy = root.join("policy.yaml"); - fs::write( - &config, - r#" -project: - name: policy-test-graph -policy: - file: ./policy.yaml -"#, - ) - .unwrap(); - fs::write(&policy, POLICY_YAML).unwrap(); - fs::write(root.join("policy.tests.yaml"), POLICY_TESTS_YAML).unwrap(); - (config, policy) -} - -fn write_cluster_config_fixture(root: &std::path::Path) { - fs::write( - root.join("people.pg"), - r#" -node Person { - name: String @key - age: I32? -} -"#, - ) - .unwrap(); - fs::write( - root.join("people.gq"), - r#" -query find_person($name: String) { - match { $p: Person { name: $name } } - return { $p.name, $p.age } -} -"#, - ) - .unwrap(); - fs::write(root.join("base.policy.yaml"), "rules: []\n").unwrap(); - fs::write( - root.join("cluster.yaml"), - r#" -version: 1 -metadata: - name: company-brain -state: - backend: cluster - lock: true -graphs: - knowledge: - schema: ./people.pg - queries: - find_person: - file: ./people.gq -policies: - base: - file: ./base.policy.yaml - applies_to: [knowledge] -"#, - ) - .unwrap(); -} - -fn init_cluster_derived_graph(root: &std::path::Path) { - init_named_cluster_graph(root, "knowledge", "people.pg"); -} - -fn init_named_cluster_graph(root: &std::path::Path, graph_id: &str, schema_file: &str) { - let graph_dir = root.join("graphs"); - fs::create_dir_all(&graph_dir).unwrap(); - output_success( - cli() - .arg("init") - .arg("--schema") - .arg(root.join(schema_file)) - .arg(graph_dir.join(format!("{graph_id}.omni"))), - ); -} - -fn write_cluster_lock(root: &std::path::Path, lock_id: &str, operation: &str) { - let state_dir = root.join("__cluster"); - fs::create_dir_all(&state_dir).unwrap(); - fs::write( - state_dir.join("lock.json"), - format!( - r#"{{"version":1,"lock_id":"{lock_id}","operation":"{operation}","created_at":"1970-01-01T00:00:00Z","pid":123}}"# - ), - ) - .unwrap(); -} - -#[test] -fn version_command_prints_current_cli_version() { - let output = output_success(cli().arg("version")); - let stdout = stdout_string(&output); - - assert_eq!( - stdout.trim(), - format!("omnigraph {}", env!("CARGO_PKG_VERSION")) - ); -} - -#[test] -fn cluster_validate_config_success() { - let temp = tempdir().unwrap(); - write_cluster_config_fixture(temp.path()); - - let output = output_success( - cli() - .arg("cluster") - .arg("validate") - .arg("--config") - .arg(temp.path()), - ); - let stdout = stdout_string(&output); - assert!(stdout.contains("cluster config valid"), "{stdout}"); -} - -#[test] -fn cluster_validate_json_is_stable() { - let temp = tempdir().unwrap(); - write_cluster_config_fixture(temp.path()); - - let json = parse_stdout_json(&output_success( - cli() - .arg("cluster") - .arg("validate") - .arg("--config") - .arg(temp.path()) - .arg("--json"), - )); - assert_eq!(json["ok"], true); - assert!(json["resource_digests"]["graph.knowledge"].is_string()); - assert!(json["resource_digests"]["query.knowledge.find_person"].is_string()); - assert_eq!(json["dependencies"][0]["from"], "policy.base"); - assert_eq!(json["dependencies"][0]["to"], "graph.knowledge"); -} - -#[test] -fn cluster_plan_json_reads_inferred_local_state() { - let temp = tempdir().unwrap(); - write_cluster_config_fixture(temp.path()); - let state_dir = temp.path().join("__cluster"); - fs::create_dir_all(&state_dir).unwrap(); - fs::write( - state_dir.join("state.json"), - r#" -{ - "version": 1, - "applied_revision": { - "config_digest": "old", - "resources": { - "graph.knowledge": { "digest": "old-graph" }, - "policy.old": { "digest": "old-policy" } - } - } -} -"#, - ) - .unwrap(); - - let json = parse_stdout_json(&output_success( - cli() - .arg("cluster") - .arg("plan") - .arg("--config") - .arg(temp.path()) - .arg("--json"), - )); - assert_eq!(json["ok"], true); - assert_eq!(json["state_observations"]["state_found"], true); - assert!( - json["changes"] - .as_array() - .unwrap() - .iter() - .any(|change| change["resource"] == "policy.old" && change["operation"] == "delete"), - "plan should read state and delete stale resources: {json}" - ); -} - -#[test] -fn cluster_status_json_reports_missing_state() { - let temp = tempdir().unwrap(); - write_cluster_config_fixture(temp.path()); - - let json = parse_stdout_json(&output_success( - cli() - .arg("cluster") - .arg("status") - .arg("--config") - .arg(temp.path()) - .arg("--json"), - )); - assert_eq!(json["ok"], true); - assert_eq!(json["state_observations"]["state_found"], false); - assert!( - json["diagnostics"] - .as_array() - .unwrap() - .iter() - .any(|diagnostic| diagnostic["code"] == "state_missing"), - "missing state should be a warning diagnostic: {json}" - ); -} - -#[test] -fn cluster_status_json_reports_lock_metadata() { - let temp = tempdir().unwrap(); - write_cluster_config_fixture(temp.path()); - write_cluster_lock(temp.path(), "held-lock", "refresh"); - - let json = parse_stdout_json(&output_success( - cli() - .arg("cluster") - .arg("status") - .arg("--config") - .arg(temp.path()) - .arg("--json"), - )); - assert_eq!(json["ok"], true); - assert_eq!(json["state_observations"]["locked"], true); - assert_eq!(json["state_observations"]["lock_id"], "held-lock"); - assert_eq!(json["state_observations"]["lock_operation"], "refresh"); - assert_eq!(json["state_observations"]["lock_pid"], 123); - assert_eq!( - json["state_observations"]["lock_created_at"], - "1970-01-01T00:00:00Z" - ); - assert!(json["state_observations"]["lock_age_seconds"].is_number()); -} - -#[test] -fn cluster_status_json_reports_extended_state() { - let temp = tempdir().unwrap(); - write_cluster_config_fixture(temp.path()); - let state_dir = temp.path().join("__cluster"); - fs::create_dir_all(&state_dir).unwrap(); - fs::write( - state_dir.join("state.json"), - r#" -{ - "version": 1, - "state_revision": 5, - "applied_revision": { - "config_digest": "applied", - "resources": { - "graph.knowledge": { "digest": "graph-digest" } - } - }, - "resource_statuses": { - "graph.knowledge": { "status": "applied", "conditions": ["healthy"] } - }, - "approval_records": {}, - "recovery_records": {}, - "observations": {} -} -"#, - ) - .unwrap(); - - let json = parse_stdout_json(&output_success( - cli() - .arg("cluster") - .arg("status") - .arg("--config") - .arg(temp.path()) - .arg("--json"), - )); - assert_eq!(json["ok"], true); - assert_eq!(json["state_observations"]["state_revision"], 5); - assert!( - json["state_observations"]["state_cas"] - .as_str() - .unwrap() - .starts_with("sha256:") - ); - assert_eq!(json["resource_digests"]["graph.knowledge"], "graph-digest"); - assert_eq!( - json["resource_statuses"]["graph.knowledge"]["status"], - "applied" - ); -} - -#[test] -fn cluster_plan_json_includes_state_cas_revision_and_lock_observation() { - let temp = tempdir().unwrap(); - write_cluster_config_fixture(temp.path()); - let state_dir = temp.path().join("__cluster"); - fs::create_dir_all(&state_dir).unwrap(); - fs::write( - state_dir.join("state.json"), - r#" -{ - "version": 1, - "state_revision": 9, - "applied_revision": { - "config_digest": "old", - "resources": { - "graph.knowledge": { "digest": "old-graph" } - } - } -} -"#, - ) - .unwrap(); - - let json = parse_stdout_json(&output_success( - cli() - .arg("cluster") - .arg("plan") - .arg("--config") - .arg(temp.path()) - .arg("--json"), - )); - assert_eq!(json["ok"], true); - assert_eq!(json["state_observations"]["state_revision"], 9); - assert!( - json["state_observations"]["state_cas"] - .as_str() - .unwrap() - .starts_with("sha256:") - ); - assert_eq!(json["state_observations"]["locked"], false); - assert_eq!(json["state_observations"]["lock_acquired"], true); - assert!(json["state_observations"]["acquired_lock_id"].is_string()); - assert!(!state_dir.join("lock.json").exists()); -} - -#[test] -fn cluster_plan_locked_state_exits_nonzero() { - let temp = tempdir().unwrap(); - write_cluster_config_fixture(temp.path()); - write_cluster_lock(temp.path(), "held-lock", "plan"); - - let output = output_failure( - cli() - .arg("cluster") - .arg("plan") - .arg("--config") - .arg(temp.path()) - .arg("--json"), - ); - let json = parse_stdout_json(&output); - assert_eq!(json["ok"], false); - assert_eq!(json["state_observations"]["locked"], true); - assert_eq!(json["state_observations"]["lock_acquired"], false); - assert_eq!(json["state_observations"]["lock_id"], "held-lock"); - assert_eq!(json["state_observations"]["lock_operation"], "plan"); - assert_eq!(json["state_observations"]["lock_pid"], 123); - assert_eq!( - json["state_observations"]["lock_created_at"], - "1970-01-01T00:00:00Z" - ); - assert!(json["state_observations"]["lock_age_seconds"].is_number()); - assert!( - json["diagnostics"] - .as_array() - .unwrap() - .iter() - .any(|diagnostic| diagnostic["code"] == "state_lock_held" - && diagnostic["message"] - .as_str() - .unwrap() - .contains("force-unlock held-lock")), - "locked state should produce a useful diagnostic: {json}" - ); -} - -#[test] -fn cluster_force_unlock_json_removes_lock() { - let temp = tempdir().unwrap(); - write_cluster_config_fixture(temp.path()); - write_cluster_lock(temp.path(), "held-lock", "plan"); - - let json = parse_stdout_json(&output_success( - cli() - .arg("cluster") - .arg("force-unlock") - .arg("held-lock") - .arg("--config") - .arg(temp.path()) - .arg("--json"), - )); - assert_eq!(json["ok"], true); - assert_eq!(json["lock_removed"], true); - assert_eq!(json["state_observations"]["lock_id"], "held-lock"); - assert_eq!(json["state_observations"]["lock_operation"], "plan"); - assert!(!temp.path().join("__cluster/lock.json").exists()); -} - -#[test] -fn cluster_force_unlock_wrong_id_exits_nonzero() { - let temp = tempdir().unwrap(); - write_cluster_config_fixture(temp.path()); - write_cluster_lock(temp.path(), "held-lock", "plan"); - - let json = parse_stdout_json(&output_failure( - cli() - .arg("cluster") - .arg("force-unlock") - .arg("other-lock") - .arg("--config") - .arg(temp.path()) - .arg("--json"), - )); - assert_eq!(json["ok"], false); - assert_eq!(json["lock_removed"], false); - assert!( - json["diagnostics"] - .as_array() - .unwrap() - .iter() - .any(|diagnostic| diagnostic["code"] == "state_lock_id_mismatch") - ); - assert!(temp.path().join("__cluster/lock.json").exists()); -} - -#[test] -fn cluster_locked_plan_then_force_unlock_then_plan_succeeds() { - let temp = tempdir().unwrap(); - write_cluster_config_fixture(temp.path()); - write_cluster_lock(temp.path(), "held-lock", "plan"); - - let locked = parse_stdout_json(&output_failure( - cli() - .arg("cluster") - .arg("plan") - .arg("--config") - .arg(temp.path()) - .arg("--json"), - )); - assert_eq!(locked["ok"], false); - assert_eq!(locked["state_observations"]["lock_id"], "held-lock"); - - let unlocked = parse_stdout_json(&output_success( - cli() - .arg("cluster") - .arg("force-unlock") - .arg("held-lock") - .arg("--config") - .arg(temp.path()) - .arg("--json"), - )); - assert_eq!(unlocked["lock_removed"], true); - - let planned = parse_stdout_json(&output_success( - cli() - .arg("cluster") - .arg("plan") - .arg("--config") - .arg(temp.path()) - .arg("--json"), - )); - assert_eq!(planned["ok"], true); -} - -#[test] -fn cluster_import_json_bootstraps_missing_state() { - let temp = tempdir().unwrap(); - write_cluster_config_fixture(temp.path()); - init_cluster_derived_graph(temp.path()); - - let json = parse_stdout_json(&output_success( - cli() - .arg("cluster") - .arg("import") - .arg("--config") - .arg(temp.path()) - .arg("--json"), - )); - assert_eq!(json["ok"], true); - assert_eq!(json["operation"], "import"); - assert_eq!(json["state_observations"]["state_revision"], 1); - assert!( - json["state_observations"]["state_cas"] - .as_str() - .unwrap() - .starts_with("sha256:") - ); - assert_eq!(json["state_observations"]["locked"], false); - assert_eq!(json["state_observations"]["lock_acquired"], true); - assert!(json["state_observations"]["acquired_lock_id"].is_string()); - assert!(json["observations"]["graph.knowledge"]["manifest_version"].is_number()); - assert_eq!( - json["resource_statuses"]["graph.knowledge"]["status"], - "applied" - ); - assert!(temp.path().join("__cluster/state.json").exists()); - assert!(!temp.path().join("__cluster/lock.json").exists()); -} - -#[test] -fn cluster_refresh_json_updates_revision_cas_and_removes_lock() { - let temp = tempdir().unwrap(); - write_cluster_config_fixture(temp.path()); - init_cluster_derived_graph(temp.path()); - let state_dir = temp.path().join("__cluster"); - fs::create_dir_all(&state_dir).unwrap(); - fs::write( - state_dir.join("state.json"), - r#" -{ - "version": 1, - "state_revision": 2, - "applied_revision": { "resources": {} } -} -"#, - ) - .unwrap(); - - let json = parse_stdout_json(&output_success( - cli() - .arg("cluster") - .arg("refresh") - .arg("--config") - .arg(temp.path()) - .arg("--json"), - )); - assert_eq!(json["ok"], true); - assert_eq!(json["operation"], "refresh"); - assert_eq!(json["state_observations"]["state_revision"], 3); - assert!( - json["state_observations"]["state_cas"] - .as_str() - .unwrap() - .starts_with("sha256:") - ); - assert_eq!(json["state_observations"]["locked"], false); - assert_eq!(json["state_observations"]["lock_acquired"], true); - assert!(json["state_observations"]["acquired_lock_id"].is_string()); - assert!(!state_dir.join("lock.json").exists()); -} - -#[test] -fn cluster_refresh_missing_state_exits_nonzero() { - let temp = tempdir().unwrap(); - write_cluster_config_fixture(temp.path()); - - let output = output_failure( - cli() - .arg("cluster") - .arg("refresh") - .arg("--config") - .arg(temp.path()) - .arg("--json"), - ); - let json = parse_stdout_json(&output); - assert_eq!(json["ok"], false); - assert!( - json["diagnostics"] - .as_array() - .unwrap() - .iter() - .any(|diagnostic| diagnostic["code"] == "state_missing"), - "missing state should produce a useful diagnostic: {json}" - ); -} - -#[test] -fn cluster_import_existing_state_exits_nonzero() { - let temp = tempdir().unwrap(); - write_cluster_config_fixture(temp.path()); - let state_dir = temp.path().join("__cluster"); - fs::create_dir_all(&state_dir).unwrap(); - fs::write( - state_dir.join("state.json"), - r#"{"version":1,"applied_revision":{"resources":{}}}"#, - ) - .unwrap(); - - let output = output_failure( - cli() - .arg("cluster") - .arg("import") - .arg("--config") - .arg(temp.path()) - .arg("--json"), - ); - let json = parse_stdout_json(&output); - assert_eq!(json["ok"], false); - assert!( - json["diagnostics"] - .as_array() - .unwrap() - .iter() - .any(|diagnostic| diagnostic["code"] == "state_already_exists"), - "existing state should produce a useful diagnostic: {json}" - ); -} - -#[test] -fn cluster_refresh_and_import_locked_state_exit_nonzero() { - let temp = tempdir().unwrap(); - write_cluster_config_fixture(temp.path()); - let state_dir = temp.path().join("__cluster"); - fs::create_dir_all(&state_dir).unwrap(); - fs::write( - state_dir.join("state.json"), - r#"{"version":1,"applied_revision":{"resources":{}}}"#, - ) - .unwrap(); - fs::write( - state_dir.join("lock.json"), - r#"{"version":1,"lock_id":"held-lock","operation":"refresh","created_at":"2026-06-08T00:00:00Z","pid":123}"#, - ) - .unwrap(); - - let refresh = parse_stdout_json(&output_failure( - cli() - .arg("cluster") - .arg("refresh") - .arg("--config") - .arg(temp.path()) - .arg("--json"), - )); - assert_eq!(refresh["state_observations"]["locked"], true); - assert_eq!(refresh["state_observations"]["lock_id"], "held-lock"); - assert_eq!(refresh["state_observations"]["lock_acquired"], false); - assert!( - refresh["diagnostics"] - .as_array() - .unwrap() - .iter() - .any(|diagnostic| diagnostic["code"] == "state_lock_held") - ); - - let temp = tempdir().unwrap(); - write_cluster_config_fixture(temp.path()); - let state_dir = temp.path().join("__cluster"); - fs::create_dir_all(&state_dir).unwrap(); - fs::write( - state_dir.join("lock.json"), - r#"{"version":1,"lock_id":"held-lock","operation":"import","created_at":"2026-06-08T00:00:00Z","pid":123}"#, - ) - .unwrap(); - - let imported = parse_stdout_json(&output_failure( - cli() - .arg("cluster") - .arg("import") - .arg("--config") - .arg(temp.path()) - .arg("--json"), - )); - assert_eq!(imported["state_observations"]["locked"], true); - assert_eq!(imported["state_observations"]["lock_id"], "held-lock"); - assert_eq!(imported["state_observations"]["lock_acquired"], false); - assert!( - imported["diagnostics"] - .as_array() - .unwrap() - .iter() - .any(|diagnostic| diagnostic["code"] == "state_lock_held") - ); -} - -#[test] -fn cluster_validate_invalid_config_exits_nonzero() { - let temp = tempdir().unwrap(); - fs::write( - temp.path().join("cluster.yaml"), - "version: 1\ngraphs: {}\npipelines: {}\n", - ) - .unwrap(); - - let output = output_failure( - cli() - .arg("cluster") - .arg("validate") - .arg("--config") - .arg(temp.path()), - ); - let stdout = stdout_string(&output); - assert!(stdout.contains("future_phase_field"), "{stdout}"); -} - -/// Seed an applyable state: schema digest borrowed from `cluster validate`, -/// graph entry present (composite recomputed by apply), queries/policies -/// pending. -fn write_cluster_applyable_state(root: &std::path::Path) -> serde_json::Value { - let validate = parse_stdout_json(&output_success( - cli() - .arg("cluster") - .arg("validate") - .arg("--config") - .arg(root) - .arg("--json"), - )); - let schema_digest = validate["resource_digests"]["schema.knowledge"] - .as_str() - .unwrap() - .to_string(); - let state_dir = root.join("__cluster"); - fs::create_dir_all(&state_dir).unwrap(); - fs::write( - state_dir.join("state.json"), - format!( - r#"{{ - "version": 1, - "state_revision": 1, - "applied_revision": {{ - "resources": {{ - "graph.knowledge": {{ "digest": "seed" }}, - "schema.knowledge": {{ "digest": "{schema_digest}" }} - }} - }} -}} -"# - ), - ) - .unwrap(); - validate -} - -#[test] -fn cluster_apply_json_applies_query_and_policy() { - let temp = tempdir().unwrap(); - write_cluster_config_fixture(temp.path()); - let validate = write_cluster_applyable_state(temp.path()); - - let json = parse_stdout_json(&output_success( - cli() - .arg("cluster") - .arg("apply") - .arg("--config") - .arg(temp.path()) - .arg("--json"), - )); - assert_eq!(json["ok"], true, "{json}"); - assert_eq!(json["applied_count"], 2, "{json}"); - assert_eq!(json["converged"], true, "{json}"); - assert_eq!(json["state_written"], true, "{json}"); - assert_eq!( - json["resource_statuses"]["query.knowledge.find_person"]["status"], - "applied" - ); - - let query_digest = validate["resource_digests"]["query.knowledge.find_person"] - .as_str() - .unwrap(); - let payload = temp - .path() - .join("__cluster/resources/query/knowledge/find_person") - .join(format!("{query_digest}.gq")); - assert!(payload.exists(), "missing payload {}", payload.display()); - - let state: serde_json::Value = serde_json::from_str( - &fs::read_to_string(temp.path().join("__cluster/state.json")).unwrap(), - ) - .unwrap(); - assert_eq!(state["state_revision"], 2); - assert_eq!( - state["applied_revision"]["resources"]["query.knowledge.find_person"]["digest"], - *query_digest - ); -} - -#[test] -fn cluster_apply_missing_state_exits_nonzero() { - let temp = tempdir().unwrap(); - write_cluster_config_fixture(temp.path()); - - let output = output_failure( - cli() - .arg("cluster") - .arg("apply") - .arg("--config") - .arg(temp.path()) - .arg("--json"), - ); - let json = parse_stdout_json(&output); - assert_eq!(json["ok"], false); - assert!( - json["diagnostics"] - .as_array() - .unwrap() - .iter() - .any(|diagnostic| diagnostic["code"] == "state_missing"), - "{json}" - ); - assert!(!temp.path().join("__cluster/resources").exists()); -} - -#[test] -fn cluster_apply_locked_exits_nonzero() { - let temp = tempdir().unwrap(); - write_cluster_config_fixture(temp.path()); - write_cluster_applyable_state(temp.path()); - write_cluster_lock(temp.path(), "held-lock", "plan"); - - let output = output_failure( - cli() - .arg("cluster") - .arg("apply") - .arg("--config") - .arg(temp.path()) - .arg("--json"), - ); - let json = parse_stdout_json(&output); - assert_eq!(json["ok"], false); - assert!( - json["diagnostics"] - .as_array() - .unwrap() - .iter() - .any(|diagnostic| diagnostic["code"] == "state_lock_held"), - "{json}" - ); - assert!(temp.path().join("__cluster/lock.json").exists()); - assert!(!temp.path().join("__cluster/resources").exists()); -} - -fn cluster_json(root: &std::path::Path, command: &str) -> serde_json::Value { - parse_stdout_json(&output_success( - cli() - .arg("cluster") - .arg(command) - .arg("--config") - .arg(root) - .arg("--json"), - )) -} - -/// End-to-end lifecycle against a REAL derived graph: import observes the live -/// graph, plan/apply converge the query+policy catalog, status reports it, -/// refresh re-observes without un-converging, and a query edit round-trips. -/// This is the composition test — every step passes individually elsewhere; -/// this catches the seams (e.g. refresh and apply recomputing the graph -/// composite digest differently would silently re-open the plan forever). -#[test] -fn cluster_e2e_lifecycle_import_apply_status_refresh_converges() { - let temp = tempdir().unwrap(); - write_cluster_config_fixture(temp.path()); - init_cluster_derived_graph(temp.path()); - - let import = cluster_json(temp.path(), "import"); - assert_eq!(import["ok"], true, "{import}"); - assert_eq!(import["state_observations"]["state_revision"], 1); - - let plan = cluster_json(temp.path(), "plan"); - let changes = plan["changes"].as_array().unwrap(); - assert_eq!(changes.len(), 3, "{plan}"); - let disposition_of = |resource: &str| { - changes - .iter() - .find(|change| change["resource"] == resource) - .unwrap_or_else(|| panic!("missing change for {resource}: {plan}"))["disposition"] - .clone() - }; - assert_eq!(disposition_of("graph.knowledge"), "derived"); - assert_eq!(disposition_of("query.knowledge.find_person"), "applied"); - assert_eq!(disposition_of("policy.base"), "applied"); - - let apply = cluster_json(temp.path(), "apply"); - assert_eq!(apply["ok"], true, "{apply}"); - assert_eq!(apply["applied_count"], 2, "{apply}"); - assert_eq!(apply["converged"], true, "{apply}"); - - let status = cluster_json(temp.path(), "status"); - assert_eq!( - status["resource_statuses"]["query.knowledge.find_person"]["status"], - "applied" - ); - assert_eq!(status["resource_statuses"]["policy.base"]["status"], "applied"); - assert!( - status["state_observations"]["applied_config_digest"].is_string(), - "converged apply must record the applied config digest: {status}" - ); - - // Refresh re-observes the live graph; it must not undo apply's work. - let refresh = cluster_json(temp.path(), "refresh"); - assert_eq!(refresh["ok"], true, "{refresh}"); - let replan = cluster_json(temp.path(), "plan"); - assert!( - replan["changes"].as_array().unwrap().is_empty(), - "refresh after a converged apply must not re-open the plan: {replan}" - ); - - // A query edit round-trips: plan update -> apply -> converged again. - fs::write( - temp.path().join("people.gq"), - r#" -query find_person($name: String) { - match { $p: Person { name: $name } } - return { $p.name } -} -"#, - ) - .unwrap(); - let apply_edit = cluster_json(temp.path(), "apply"); - assert_eq!(apply_edit["applied_count"], 1, "{apply_edit}"); - assert_eq!(apply_edit["converged"], true, "{apply_edit}"); - - let final_apply = cluster_json(temp.path(), "apply"); - assert_eq!(final_apply["state_written"], false, "{final_apply}"); - assert!(final_apply["changes"].as_array().unwrap().is_empty()); -} - -/// The operator workflow across the Stage 3A boundary: a schema change is -/// deferred by cluster apply, executed by `omnigraph schema apply` against -/// the graph, picked up by `cluster refresh`, and the next apply re-converges. -#[test] -fn cluster_e2e_schema_change_applied_by_cluster() { - let temp = tempdir().unwrap(); - write_cluster_config_fixture(temp.path()); - init_cluster_derived_graph(temp.path()); - let import = cluster_json(temp.path(), "import"); - assert_eq!(import["ok"], true, "{import}"); - let apply = cluster_json(temp.path(), "apply"); - assert_eq!(apply["converged"], true, "{apply}"); - - // Additive schema change: Stage 4B applies it from the cluster — no - // manual schema apply, no refresh round-trip. - fs::write( - temp.path().join("people.pg"), - r#" -node Person { - name: String @key - age: I32? - bio: String? -} -"#, - ) - .unwrap(); - - // Plan previews the real migration steps (RFC-004 §D7). - let plan = cluster_json(temp.path(), "plan"); - let schema_change = change_for(&plan, "schema.knowledge"); - assert_eq!(schema_change["disposition"], "applied", "{plan}"); - let migration = &schema_change["migration"]; - assert_eq!(migration["supported"], true, "{plan}"); - assert!( - migration["steps"] - .as_array() - .unwrap() - .iter() - .any(|step| step["kind"] == "add_property"), - "{plan}" - ); - - let evolve = cluster_json(temp.path(), "apply"); - assert_eq!(evolve["ok"], true, "{evolve}"); - assert_eq!(evolve["converged"], true, "{evolve}"); - assert_eq!(change_for(&evolve, "schema.knowledge")["disposition"], "applied"); - - // The live graph carries the new schema; the plan is empty. - let schema_show = output_success( - cli() - .arg("schema") - .arg("show") - .arg(temp.path().join("graphs/knowledge.omni")), - ); - assert!(stdout_string(&schema_show).contains("bio"), "live schema updated"); - let replan = cluster_json(temp.path(), "plan"); - assert!( - replan["changes"].as_array().unwrap().is_empty(), - "one cluster apply converges a schema change: {replan}" - ); -} - -/// Lock-recovery composition: a held lock refuses apply, force-unlock clears -/// it, and the retried apply converges. -#[test] -fn cluster_e2e_force_unlock_unblocks_apply() { - let temp = tempdir().unwrap(); - write_cluster_config_fixture(temp.path()); - write_cluster_applyable_state(temp.path()); - write_cluster_lock(temp.path(), "stuck-lock", "apply"); - - let refused = parse_stdout_json(&output_failure( - cli() - .arg("cluster") - .arg("apply") - .arg("--config") - .arg(temp.path()) - .arg("--json"), - )); - assert_eq!(refused["ok"], false); - - let unlocked = parse_stdout_json(&output_success( - cli() - .arg("cluster") - .arg("force-unlock") - .arg("stuck-lock") - .arg("--config") - .arg(temp.path()) - .arg("--json"), - )); - assert_eq!(unlocked["lock_removed"], true, "{unlocked}"); - - let retried = cluster_json(temp.path(), "apply"); - assert_eq!(retried["ok"], true, "{retried}"); - assert_eq!(retried["converged"], true, "{retried}"); -} - -/// Two-graph fixture: `knowledge` (people) + `engineering` (services), a -/// policy spanning both graphs, and a cluster-scoped policy with no graph -/// dependencies. -fn write_multi_graph_cluster_fixture(root: &std::path::Path) { - write_cluster_config_fixture(root); - fs::write( - root.join("services.pg"), - r#" -node Service { - name: String @key -} -"#, - ) - .unwrap(); - fs::write( - root.join("services.gq"), - r#" -query find_service($name: String) { - match { $s: Service { name: $name } } - return { $s.name } -} -"#, - ) - .unwrap(); - fs::write(root.join("cluster_wide.policy.yaml"), "rules: []\n").unwrap(); - fs::write(root.join("shared.policy.yaml"), "rules: []\n").unwrap(); - fs::write( - root.join("cluster.yaml"), - r#" -version: 1 -metadata: - name: company-brain -state: - backend: cluster - lock: true -graphs: - knowledge: - schema: ./people.pg - queries: - find_person: - file: ./people.gq - engineering: - schema: ./services.pg - queries: - find_service: - file: ./services.gq -policies: - shared: - file: ./shared.policy.yaml - applies_to: [knowledge, engineering] - cluster_wide: - file: ./cluster_wide.policy.yaml - applies_to: [cluster] -"#, - ) - .unwrap(); -} - -fn change_for<'j>(json: &'j serde_json::Value, resource: &str) -> &'j serde_json::Value { - json["changes"] - .as_array() - .unwrap() - .iter() - .find(|change| change["resource"] == resource) - .unwrap_or_else(|| panic!("missing change for {resource}: {json}")) -} - -/// The spec's resilience claim — "state is reconstructable from the -/// self-describing cluster" — proven end to end: lose the ledger, re-import -/// from the live graph, re-apply, and converge onto the same content-addressed -/// catalog blobs. -#[test] -fn cluster_e2e_lost_state_reimport_recovers_catalog() { - let temp = tempdir().unwrap(); - write_cluster_config_fixture(temp.path()); - init_cluster_derived_graph(temp.path()); - let import = cluster_json(temp.path(), "import"); - assert_eq!(import["ok"], true, "{import}"); - let apply = cluster_json(temp.path(), "apply"); - assert_eq!(apply["converged"], true, "{apply}"); - - let query_digest = change_for(&apply, "query.knowledge.find_person")["after_digest"] - .as_str() - .unwrap() - .to_string(); - let blob = temp - .path() - .join("__cluster/resources/query/knowledge/find_person") - .join(format!("{query_digest}.gq")); - let blob_content = fs::read_to_string(&blob).unwrap(); - - // Disaster: the state ledger is lost. - fs::remove_file(temp.path().join("__cluster/state.json")).unwrap(); - - let reimport = cluster_json(temp.path(), "import"); - assert_eq!(reimport["ok"], true, "{reimport}"); - assert_eq!(reimport["state_observations"]["state_revision"], 1); - // Import observes graph/schema only; query/policy digests are not invented. - assert!( - reimport["resource_digests"] - .get("query.knowledge.find_person") - .is_none(), - "{reimport}" - ); - - let plan = cluster_json(temp.path(), "plan"); - assert_eq!( - change_for(&plan, "query.knowledge.find_person")["disposition"], - "applied" - ); - assert_eq!(change_for(&plan, "policy.base")["disposition"], "applied"); - - let reapply = cluster_json(temp.path(), "apply"); - assert_eq!(reapply["ok"], true, "{reapply}"); - assert_eq!(reapply["converged"], true, "{reapply}"); - assert!( - reapply["state_observations"]["applied_config_digest"].is_string(), - "{reapply}" - ); - // The catalog blob was reused, not rewritten with different content. - assert_eq!(fs::read_to_string(&blob).unwrap(), blob_content); - - let replan = cluster_json(temp.path(), "plan"); - assert!(replan["changes"].as_array().unwrap().is_empty(), "{replan}"); -} - -/// The Sarah/Bob violation made visible: a schema change applied directly to -/// the graph (no config change) must surface as drift through refresh, status, -/// and plan — and apply must never silently "correct" it. -#[test] -fn cluster_e2e_out_of_band_schema_drift_then_apply_converges_it() { - let temp = tempdir().unwrap(); - write_cluster_config_fixture(temp.path()); - init_cluster_derived_graph(temp.path()); - let import = cluster_json(temp.path(), "import"); - assert_eq!(import["ok"], true, "{import}"); - let apply = cluster_json(temp.path(), "apply"); - assert_eq!(apply["converged"], true, "{apply}"); - - // Out-of-band: the live graph evolves, cluster.yaml stays put. - fs::write( - temp.path().join("people_v2.pg"), - r#" -node Person { - name: String @key - age: I32? - bio: String? -} -"#, - ) - .unwrap(); - output_success( - cli() - .arg("schema") - .arg("apply") - .arg(temp.path().join("graphs/knowledge.omni")) - .arg("--schema") - .arg(temp.path().join("people_v2.pg")) - .arg("--json"), - ); - - // Drift is visible... - let refresh = cluster_json(temp.path(), "refresh"); - assert_eq!( - refresh["resource_statuses"]["schema.knowledge"]["status"], - "drifted" - ); - // ...the plan proposes converging back to desired, with a migration - // preview (a soft drop of the out-of-band field)... - let plan = cluster_json(temp.path(), "plan"); - let schema_change = change_for(&plan, "schema.knowledge"); - assert_eq!(schema_change["disposition"], "applied", "{plan}"); - assert!( - schema_change["migration"]["steps"] - .as_array() - .unwrap() - .iter() - .any(|step| step["kind"] == "drop_property" && step["mode"] == "soft"), - "{plan}" - ); - // ...and apply converges the live schema back (axiom 8: drift correction - // is gated like any change; a soft migration is the recoverable tier). - let converge = cluster_json(temp.path(), "apply"); - assert_eq!(converge["ok"], true, "{converge}"); - assert_eq!(converge["converged"], true, "{converge}"); - let schema_show = output_success( - cli() - .arg("schema") - .arg("show") - .arg(temp.path().join("graphs/knowledge.omni")), - ); - assert!( - !stdout_string(&schema_show).contains("bio"), - "out-of-band field soft-dropped back to desired" - ); - let replan = cluster_json(temp.path(), "plan"); - assert!(replan["changes"].as_array().unwrap().is_empty(), "{replan}"); -} - -/// Disaster input fails closed: a destroyed graph root drifts the ledger, -/// the plan proposes deferred creates, and apply moves nothing. -#[test] -fn cluster_e2e_graph_root_destruction_drifts_then_apply_recreates_empty_graph() { - let temp = tempdir().unwrap(); - write_cluster_config_fixture(temp.path()); - init_cluster_derived_graph(temp.path()); - let import = cluster_json(temp.path(), "import"); - assert_eq!(import["ok"], true, "{import}"); - let apply = cluster_json(temp.path(), "apply"); - assert_eq!(apply["converged"], true, "{apply}"); - let query_digest = change_for(&apply, "query.knowledge.find_person")["after_digest"] - .as_str() - .unwrap() - .to_string(); - - fs::remove_dir_all(temp.path().join("graphs/knowledge.omni")).unwrap(); - - // Missing root is drift, not an error. - let refresh = cluster_json(temp.path(), "refresh"); - assert_eq!(refresh["ok"], true, "{refresh}"); - assert_eq!( - refresh["resource_statuses"]["graph.knowledge"]["status"], - "drifted" - ); - assert!( - refresh["resource_statuses"]["graph.knowledge"]["conditions"] - .as_array() - .unwrap() - .iter() - .any(|condition| condition == "graph_missing"), - "{refresh}" - ); - // Graph/schema digests removed; query/policy digests preserved. - assert!(refresh["resource_digests"].get("graph.knowledge").is_none()); - assert!(refresh["resource_digests"].get("schema.knowledge").is_none()); - assert!( - refresh["resource_digests"] - .get("query.knowledge.find_person") - .is_some(), - "{refresh}" - ); - - let plan = cluster_json(temp.path(), "plan"); - assert_eq!(change_for(&plan, "graph.knowledge")["operation"], "create"); - // Stage 4A: the re-create is executable and the plan says so — nothing - // hidden about converging a destroyed root back to an EMPTY graph (the - // data was already lost; this is declarative convergence, RFC-004 §D1). - assert_eq!(change_for(&plan, "graph.knowledge")["disposition"], "applied"); - assert_eq!(change_for(&plan, "schema.knowledge")["disposition"], "applied"); - // Converged-then-destroyed: query/policy are already in state at the - // desired digests, so they are not changes at all. - assert_eq!(plan["changes"].as_array().unwrap().len(), 2, "{plan}"); - - let recreate = cluster_json(temp.path(), "apply"); - assert_eq!(recreate["ok"], true, "{recreate}"); - assert_eq!(recreate["converged"], true, "{recreate}"); - // The empty graph is back on disk; catalog state survived throughout. - assert!(temp.path().join("graphs/knowledge.omni").exists()); - let state: serde_json::Value = serde_json::from_str( - &fs::read_to_string(temp.path().join("__cluster/state.json")).unwrap(), - ) - .unwrap(); - assert_eq!( - state["applied_revision"]["resources"]["query.knowledge.find_person"]["digest"], - query_digest - ); - assert!( - temp.path() - .join("__cluster/resources/query/knowledge/find_person") - .join(format!("{query_digest}.gq")) - .exists() - ); -} - -/// The disposition matrix as a system under Stage 4A: a fresh multi-graph -/// config converges in ONE apply (both graphs created, spanning and -/// cluster-scoped policies applied), and a later mixed run — schema update -/// (deferred), its dependent query (blocked), an independent query update -/// (applied), its composite (derived) — shows all four dispositions at once -/// before the graph-plane schema apply closes the loop. -#[test] -fn cluster_e2e_multi_graph_mixed_dispositions_then_approve_and_converge() { - let temp = tempdir().unwrap(); - write_multi_graph_cluster_fixture(temp.path()); - // No manual init: Stage 4A creates both graphs. - - let import = cluster_json(temp.path(), "import"); - assert_eq!(import["ok"], true, "{import}"); - - let apply = cluster_json(temp.path(), "apply"); - assert_eq!(apply["ok"], true, "{apply}"); - assert_eq!(apply["converged"], true, "{apply}"); - assert_eq!(change_for(&apply, "graph.knowledge")["disposition"], "applied"); - assert_eq!( - change_for(&apply, "graph.engineering")["disposition"], - "applied" - ); - assert_eq!( - change_for(&apply, "query.engineering.find_service")["disposition"], - "applied" - ); - // The graph-spanning and cluster-scoped policies ride the same run. - assert_eq!(change_for(&apply, "policy.shared")["disposition"], "applied"); - assert_eq!( - change_for(&apply, "policy.cluster_wide")["disposition"], - "applied" - ); - assert!(temp.path().join("graphs/knowledge.omni").exists()); - assert!(temp.path().join("graphs/engineering.omni").exists()); - - // Mixed run: a graph REMOVAL (4C territory — deferred) gates its query - // delete (blocked), while a knowledge query update is independent - // (applied) and re-derives its composite. All four dispositions at once. - fs::write( - temp.path().join("cluster.yaml"), - r#" -version: 1 -metadata: - name: company-brain -state: - backend: cluster - lock: true -graphs: - knowledge: - schema: ./people.pg - queries: - find_person: - file: ./people.gq -policies: - shared: - file: ./shared.policy.yaml - applies_to: [knowledge] - cluster_wide: - file: ./cluster_wide.policy.yaml - applies_to: [cluster] -"#, - ) - .unwrap(); - fs::write( - temp.path().join("people.gq"), - "\nquery find_person($name: String) {\n match { $p: Person { name: $name } }\n return { $p.name }\n}\n", - ) - .unwrap(); - - let mixed = cluster_json(temp.path(), "apply"); - assert_eq!(mixed["ok"], true, "{mixed}"); - assert_eq!(mixed["converged"], false, "{mixed}"); - // Stage 4C: deletes are gated on a digest-bound approval, one gate per - // subtree (the graph-level approval carries schema + queries). - assert_eq!( - change_for(&mixed, "graph.engineering")["disposition"], - "blocked" - ); - assert_eq!( - change_for(&mixed, "graph.engineering")["reason"], - "approval_required" - ); - assert_eq!( - change_for(&mixed, "schema.engineering")["reason"], - "approval_required" - ); - assert_eq!( - change_for(&mixed, "query.engineering.find_service")["reason"], - "approval_required" - ); - let gate_plan = cluster_json(temp.path(), "plan"); - let gates = gate_plan["approvals_required"].as_array().unwrap(); - assert_eq!(gates.len(), 1, "{gate_plan}"); - assert_eq!(gates[0]["resource"], "graph.engineering"); - assert_eq!(gates[0]["satisfied"], false); - assert_eq!( - change_for(&mixed, "query.knowledge.find_person")["disposition"], - "applied" - ); - // 5A: policy.shared's applies_to narrowed with an unchanged file digest - // — now a first-class binding change, applied in the same run. - assert_eq!(change_for(&mixed, "policy.shared")["binding_change"], true); - assert_eq!(change_for(&mixed, "policy.shared")["disposition"], "applied"); - assert_eq!( - change_for(&mixed, "graph.knowledge")["disposition"], - "derived" - ); - // Deterministic ordering: changes sorted by resource address. - let order: Vec<&str> = mixed["changes"] - .as_array() - .unwrap() - .iter() - .map(|change| change["resource"].as_str().unwrap()) - .collect(); - let mut sorted = order.clone(); - sorted.sort_unstable(); - assert_eq!(order, sorted, "{mixed}"); - // The conclusion: an apply without approval stays blocked; the approved - // delete converges the cluster, tombstoning the removed graph. - let still_blocked = cluster_json(temp.path(), "apply"); - assert_eq!(still_blocked["converged"], false, "{still_blocked}"); - - let approve = parse_stdout_json(&output_success( - cli() - .arg("--as") - .arg("andrew") - .arg("cluster") - .arg("approve") - .arg("graph.engineering") - .arg("--config") - .arg(temp.path()) - .arg("--json"), - )); - assert_eq!(approve["ok"], true, "{approve}"); - assert_eq!(approve["approved_by"], "andrew"); - - let converge = cluster_json(temp.path(), "apply"); - assert_eq!(converge["ok"], true, "{converge}"); - assert_eq!(converge["converged"], true, "{converge}"); - assert!(!temp.path().join("graphs/engineering.omni").exists()); - - let status = cluster_json(temp.path(), "status"); - assert_eq!(status["observations"]["graph.engineering"]["kind"], "tombstone"); - let final_plan = cluster_json(temp.path(), "plan"); - assert!( - final_plan["changes"].as_array().unwrap().is_empty(), - "{final_plan}" - ); -} - -/// An approval without an approver is meaningless: approve requires --as. -#[test] -fn cluster_e2e_approve_requires_actor() { - let temp = tempdir().unwrap(); - write_cluster_config_fixture(temp.path()); - - let output = output_failure( - cli() - .arg("cluster") - .arg("approve") - .arg("graph.knowledge") - .arg("--config") - .arg(temp.path()), - ); - let stderr = String::from_utf8_lossy(&output.stderr); - assert!(stderr.contains("--as"), "{stderr}"); -} - -/// Stage 4A headline: a declared graph is created by `cluster apply` itself — -/// no manual `omnigraph init` anywhere in the flow. -#[test] -fn cluster_e2e_declared_graph_created_by_apply() { - let temp = tempdir().unwrap(); - write_cluster_config_fixture(temp.path()); - - let import = cluster_json(temp.path(), "import"); - assert_eq!(import["ok"], true, "{import}"); - - let apply = cluster_json(temp.path(), "apply"); - assert_eq!(apply["ok"], true, "{apply}"); - assert_eq!(apply["converged"], true, "{apply}"); - assert_eq!(change_for(&apply, "graph.knowledge")["disposition"], "applied"); - assert!(temp.path().join("graphs/knowledge.omni").exists()); - - // The created graph is a real graph: the per-graph CLI can open it. - let snapshot = output_success( - cli() - .arg("snapshot") - .arg(temp.path().join("graphs/knowledge.omni")), - ); - assert!(!stdout_string(&snapshot).is_empty()); - - let plan = cluster_json(temp.path(), "plan"); - assert!(plan["changes"].as_array().unwrap().is_empty(), "{plan}"); - let status = cluster_json(temp.path(), "status"); - assert_eq!( - status["resource_statuses"]["graph.knowledge"]["status"], - "applied" - ); -} - -/// Catalog payload drift self-heals across the command surface: status warns -/// read-only, refresh persists the drift and drops the digest, apply -/// republishes the blob, status comes back clean. -#[test] -fn cluster_e2e_payload_drift_self_heals() { - let temp = tempdir().unwrap(); - write_cluster_config_fixture(temp.path()); - init_cluster_derived_graph(temp.path()); - let import = cluster_json(temp.path(), "import"); - assert_eq!(import["ok"], true, "{import}"); - let apply = cluster_json(temp.path(), "apply"); - assert_eq!(apply["converged"], true, "{apply}"); - - let query_digest = change_for(&apply, "query.knowledge.find_person")["after_digest"] - .as_str() - .unwrap() - .to_string(); - let blob = temp - .path() - .join("__cluster/resources/query/knowledge/find_person") - .join(format!("{query_digest}.gq")); - fs::remove_file(&blob).unwrap(); - - let status = cluster_json(temp.path(), "status"); - assert_eq!(status["ok"], true, "{status}"); - assert!( - status["diagnostics"] - .as_array() - .unwrap() - .iter() - .any(|diagnostic| diagnostic["code"] == "catalog_payload_missing"), - "{status}" - ); - - let refresh = cluster_json(temp.path(), "refresh"); - assert_eq!(refresh["ok"], true, "{refresh}"); - assert_eq!( - refresh["resource_statuses"]["query.knowledge.find_person"]["status"], - "drifted" - ); - - let heal = cluster_json(temp.path(), "apply"); - assert_eq!(heal["ok"], true, "{heal}"); - assert_eq!(heal["converged"], true, "{heal}"); - assert!(blob.exists(), "blob republished"); - - let clean = cluster_json(temp.path(), "status"); - assert!( - !clean["diagnostics"] - .as_array() - .unwrap() - .iter() - .any(|diagnostic| { - diagnostic["code"] - .as_str() - .is_some_and(|code| code.starts_with("catalog_payload")) - }), - "{clean}" - ); -} - -#[test] -fn short_version_flag_prints_current_cli_version() { - let output = output_success(cli().arg("-v")); - let stdout = stdout_string(&output); - - assert_eq!( - stdout.trim(), - format!("omnigraph {}", env!("CARGO_PKG_VERSION")) - ); -} - -#[test] -fn long_version_flag_prints_current_cli_version() { - let output = output_success(cli().arg("--version")); - let stdout = stdout_string(&output); - - assert_eq!( - stdout.trim(), - format!("omnigraph {}", env!("CARGO_PKG_VERSION")) - ); -} - -#[test] -fn embed_seed_fills_missing_and_preserves_existing_vectors_by_default() { - let temp = tempdir().unwrap(); - let seed = write_seed_fixture(temp.path()); - - let output = output_success( - cli() - .env("OMNIGRAPH_EMBEDDINGS_MOCK", "1") - .arg("embed") - .arg("--seed") - .arg(&seed) - .arg("--json"), - ); - let payload: Value = serde_json::from_slice(&output.stdout).unwrap(); - assert_eq!(payload["mode"], "fill_missing"); - assert_eq!(payload["embedded_rows"], 1); - assert_eq!(payload["selected_rows"], 2); - - let embedded = read_embedded_rows(temp.path().join("build/seed.embedded.jsonl")); - assert_eq!( - embedded[0]["data"]["embedding"].as_array().unwrap().len(), - 4 - ); - assert_eq!( - embedded[1]["data"]["embedding"], - serde_json::json!([0.1, 0.2]) - ); -} - -#[test] -fn embed_clean_removes_selected_embeddings() { - let temp = tempdir().unwrap(); - let seed = write_seed_fixture(temp.path()); - - let output = output_success( - cli() - .arg("embed") - .arg("--seed") - .arg(&seed) - .arg("--clean") - .arg("--select") - .arg("Decision:slug=dec-beta") - .arg("--json"), - ); - let payload: Value = serde_json::from_slice(&output.stdout).unwrap(); - assert_eq!(payload["mode"], "clean"); - assert_eq!(payload["cleaned_rows"], 1); - - let embedded = read_embedded_rows(temp.path().join("build/seed.embedded.jsonl")); - assert!(embedded[0]["data"].get("embedding").is_none()); - assert!(embedded[1]["data"].get("embedding").is_none()); -} - -#[test] -fn embed_select_reembeds_only_matching_rows() { - let temp = tempdir().unwrap(); - let seed = write_seed_fixture(temp.path()); - - let output = output_success( - cli() - .env("OMNIGRAPH_EMBEDDINGS_MOCK", "1") - .arg("embed") - .arg("--seed") - .arg(&seed) - .arg("--select") - .arg("Decision:slug=dec-beta") - .arg("--json"), - ); - let payload: Value = serde_json::from_slice(&output.stdout).unwrap(); - assert_eq!(payload["mode"], "reembed_selected"); - assert_eq!(payload["embedded_rows"], 1); - assert_eq!(payload["selected_rows"], 1); - - let embedded = read_embedded_rows(temp.path().join("build/seed.embedded.jsonl")); - assert!(embedded[0]["data"].get("embedding").is_none()); - assert_ne!( - embedded[1]["data"]["embedding"], - serde_json::json!([0.1, 0.2]) - ); - assert_eq!( - embedded[1]["data"]["embedding"].as_array().unwrap().len(), - 4 - ); -} - -#[test] -fn embed_seed_preserves_non_entity_rows() { - let temp = tempdir().unwrap(); - let seed = write_seed_fixture_with_edge(temp.path()); - - let output = output_success( - cli() - .env("OMNIGRAPH_EMBEDDINGS_MOCK", "1") - .arg("embed") - .arg("--seed") - .arg(&seed) - .arg("--json"), - ); - let payload: Value = serde_json::from_slice(&output.stdout).unwrap(); - assert_eq!(payload["rows"], 3); - assert_eq!(payload["embedded_rows"], 1); - - let embedded = read_embedded_rows(temp.path().join("build/seed.embedded.jsonl")); - assert_eq!(embedded.len(), 3); - assert_eq!(embedded[2]["edge"], "Triggered"); - assert_eq!(embedded[2]["from"], "sig-alpha"); - assert_eq!(embedded[2]["to"], "dec-alpha"); -} - -#[test] -fn init_creates_graph_successfully_on_missing_local_directory() { - let temp = tempdir().unwrap(); - let graph = graph_path(temp.path()); - let schema = fixture("test.pg"); - - let output = output_success(cli().arg("init").arg("--schema").arg(&schema).arg(&graph)); - let stdout = stdout_string(&output); - - assert!(stdout.contains("initialized")); - assert!(graph.join("_schema.pg").exists()); - assert!(graph.join("__manifest").exists()); - assert!(temp.path().join("omnigraph.yaml").exists()); -} - -#[test] -fn repair_json_reports_noop_on_clean_graph() { - let temp = tempdir().unwrap(); - let graph = graph_path(temp.path()); - init_graph(&graph); - load_fixture(&graph); - - let output = output_success(cli().arg("repair").arg("--json").arg(&graph)); - let payload: Value = serde_json::from_slice(&output.stdout).unwrap(); - - assert_eq!(payload["confirm"], false); - assert_eq!(payload["force"], false); - assert_eq!(payload["manifest_version"], Value::Null); - let tables = payload["tables"].as_array().unwrap(); - assert_eq!(tables.len(), 4); - assert!(tables.iter().all(|table| { - table["classification"] == "no_drift" && table["action"] == "no_op" - })); -} - -#[test] -fn repair_confirm_json_refuses_suspicious_drift_with_nonzero_exit_then_force_succeeds() { - let temp = tempdir().unwrap(); - let graph = graph_path(temp.path()); - init_graph(&graph); - load_fixture(&graph); - let graph_manifest_before = manifest_dataset_version(&graph); - let (table_manifest_before, table_head_before) = forge_person_delete_drift(&graph); - - let refused = output_failure( - cli() - .arg("repair") - .arg("--confirm") - .arg("--json") - .arg(&graph), - ); - let refused_payload: Value = serde_json::from_slice(&refused.stdout).unwrap(); - assert_eq!(refused_payload["manifest_version"], Value::Null); - let person = refused_payload["tables"] - .as_array() - .unwrap() - .iter() - .find(|table| table["table_key"] == "node:Person") - .unwrap(); - assert_eq!(person["classification"], "suspicious"); - assert_eq!(person["action"], "refused"); - assert!( - String::from_utf8_lossy(&refused.stderr).contains("repair refused"), - "stderr should explain the non-zero exit; got: {}", - String::from_utf8_lossy(&refused.stderr) - ); - assert_eq!(manifest_dataset_version(&graph), graph_manifest_before); - - let forced = output_success( - cli() - .arg("repair") - .arg("--force") - .arg("--confirm") - .arg("--json") - .arg(&graph), - ); - let forced_payload: Value = serde_json::from_slice(&forced.stdout).unwrap(); - let forced_manifest = forced_payload["manifest_version"].as_u64().unwrap(); - assert!(forced_manifest > graph_manifest_before); - let person = forced_payload["tables"] - .as_array() - .unwrap() - .iter() - .find(|table| table["table_key"] == "node:Person") - .unwrap(); - assert_eq!(person["classification"], "suspicious"); - assert_eq!(person["action"], "forced"); - assert_eq!(person["manifest_version"], table_manifest_before); - assert_eq!(person["lance_head_version"], table_head_before); - assert_eq!(manifest_dataset_version(&graph), forced_manifest); -} - -#[test] -fn schema_plan_json_reports_supported_additive_change() { - let temp = tempdir().unwrap(); - let graph = graph_path(temp.path()); - let schema_path = temp.path().join("next.pg"); - init_graph(&graph); - - let next_schema = fs::read_to_string(fixture("test.pg")).unwrap().replace( - " age: I32?\n}", - " age: I32?\n nickname: String?\n}", - ); - fs::write(&schema_path, next_schema).unwrap(); - - let output = output_success( - cli() - .arg("schema") - .arg("plan") - .arg("--schema") - .arg(&schema_path) - .arg("--json") - .arg(&graph), - ); - let payload: Value = serde_json::from_slice(&output.stdout).unwrap(); - - assert_eq!(payload["supported"], true); - assert_eq!(payload["step_count"], 1); - assert_eq!(payload["steps"][0]["kind"], "add_property"); - assert_eq!(payload["steps"][0]["type_kind"], "node"); - assert_eq!(payload["steps"][0]["type_name"], "Person"); - assert_eq!(payload["steps"][0]["property_name"], "nickname"); -} - -#[test] -fn schema_plan_json_reports_unsupported_type_change() { - let temp = tempdir().unwrap(); - let graph = graph_path(temp.path()); - let schema_path = temp.path().join("breaking.pg"); - init_graph(&graph); - - let breaking_schema = fs::read_to_string(fixture("test.pg")) - .unwrap() - .replace("age: I32?", "age: I64?"); - fs::write(&schema_path, breaking_schema).unwrap(); - - let output = output_success( - cli() - .arg("schema") - .arg("plan") - .arg("--schema") - .arg(&schema_path) - .arg("--json") - .arg(&graph), - ); - let payload: Value = serde_json::from_slice(&output.stdout).unwrap(); - - assert_eq!(payload["supported"], false); - assert!(payload["steps"].as_array().unwrap().iter().any(|step| { - step["kind"] == "unsupported_change" - && step["entity"] - .as_str() - .unwrap_or_default() - .contains("Person.age") - })); -} - -#[test] -fn schema_apply_json_applies_supported_migration() { - let temp = tempdir().unwrap(); - let graph = graph_path(temp.path()); - let schema_path = temp.path().join("next.pg"); - init_graph(&graph); - - let next_schema = fs::read_to_string(fixture("test.pg")).unwrap().replace( - " age: I32?\n}", - " age: I32?\n nickname: String?\n}", - ); - fs::write(&schema_path, next_schema).unwrap(); - - let output = output_success( - cli() - .arg("schema") - .arg("apply") - .arg("--schema") - .arg(&schema_path) - .arg("--json") - .arg(&graph), - ); - let payload: Value = serde_json::from_slice(&output.stdout).unwrap(); - - assert_eq!(payload["supported"], true); - assert_eq!(payload["applied"], true); - assert_eq!(payload["step_count"], 1); - - let db = tokio::runtime::Runtime::new() - .unwrap() - .block_on(Omnigraph::open(graph.to_string_lossy().as_ref())) - .unwrap(); - assert!( - db.catalog().node_types["Person"] - .properties - .contains_key("nickname") - ); -} - -#[test] -fn schema_apply_human_reports_noop() { - let temp = tempdir().unwrap(); - let graph = graph_path(temp.path()); - let schema_path = fixture("test.pg"); - init_graph(&graph); - - let output = output_success( - cli() - .arg("schema") - .arg("apply") - .arg("--schema") - .arg(&schema_path) - .arg(&graph), - ); - let stdout = stdout_string(&output); - - assert!(stdout.contains("applied: no")); - assert!(stdout.contains("no schema changes")); -} - -#[test] -fn schema_apply_json_renames_type_and_updates_snapshot() { - let temp = tempdir().unwrap(); - let graph = graph_path(temp.path()); - let schema_path = temp.path().join("rename.pg"); - init_graph(&graph); - - let renamed_schema = fs::read_to_string(fixture("test.pg")) - .unwrap() - .replace("node Person {\n", "node Human @rename_from(\"Person\") {\n") - .replace("edge Knows: Person -> Person", "edge Knows: Human -> Human") - .replace( - "edge WorksAt: Person -> Company", - "edge WorksAt: Human -> Company", - ); - fs::write(&schema_path, renamed_schema).unwrap(); - - let output = output_success( - cli() - .arg("schema") - .arg("apply") - .arg("--schema") - .arg(&schema_path) - .arg("--json") - .arg(&graph), - ); - let payload: Value = serde_json::from_slice(&output.stdout).unwrap(); - assert_eq!(payload["applied"], true); - - let db = tokio::runtime::Runtime::new() - .unwrap() - .block_on(Omnigraph::open(graph.to_string_lossy().as_ref())) - .unwrap(); - let snapshot = tokio::runtime::Runtime::new() - .unwrap() - .block_on(db.snapshot_of(ReadTarget::branch("main"))) - .unwrap(); - assert!(snapshot.entry("node:Human").is_some()); - assert!(snapshot.entry("node:Person").is_none()); -} - -#[test] -fn schema_apply_json_renames_property_and_updates_catalog() { - let temp = tempdir().unwrap(); - let graph = graph_path(temp.path()); - let schema_path = temp.path().join("rename-property.pg"); - init_graph(&graph); - - let renamed_schema = fs::read_to_string(fixture("test.pg")) - .unwrap() - .replace("age: I32?", "years: I32? @rename_from(\"age\")"); - fs::write(&schema_path, renamed_schema).unwrap(); - - let output = output_success( - cli() - .arg("schema") - .arg("apply") - .arg("--schema") - .arg(&schema_path) - .arg("--json") - .arg(&graph), - ); - let payload: Value = serde_json::from_slice(&output.stdout).unwrap(); - assert_eq!(payload["applied"], true); - - let db = tokio::runtime::Runtime::new() - .unwrap() - .block_on(Omnigraph::open(graph.to_string_lossy().as_ref())) - .unwrap(); - let person = &db.catalog().node_types["Person"]; - assert!(person.properties.contains_key("years")); - assert!(!person.properties.contains_key("age")); -} - -#[test] -fn schema_apply_json_adds_index_for_existing_property() { - let temp = tempdir().unwrap(); - let graph = graph_path(temp.path()); - let schema_path = temp.path().join("index.pg"); - init_graph(&graph); - - let before_index_count = tokio::runtime::Runtime::new().unwrap().block_on(async { - let db = Omnigraph::open(graph.to_string_lossy().as_ref()) - .await - .unwrap(); - let snapshot = db.snapshot_of(ReadTarget::branch("main")).await.unwrap(); - let dataset = snapshot.open("node:Person").await.unwrap(); - dataset.load_indices().await.unwrap().len() - }); - - let indexed_schema = fs::read_to_string(fixture("test.pg")) - .unwrap() - .replace("name: String @key", "name: String @key @index"); - fs::write(&schema_path, indexed_schema).unwrap(); - - let output = output_success( - cli() - .arg("schema") - .arg("apply") - .arg("--schema") - .arg(&schema_path) - .arg("--json") - .arg(&graph), - ); - let payload: Value = serde_json::from_slice(&output.stdout).unwrap(); - assert_eq!(payload["applied"], true); - - let after_index_count = tokio::runtime::Runtime::new().unwrap().block_on(async { - let db = Omnigraph::open(graph.to_string_lossy().as_ref()) - .await - .unwrap(); - let snapshot = db.snapshot_of(ReadTarget::branch("main")).await.unwrap(); - let dataset = snapshot.open("node:Person").await.unwrap(); - dataset.load_indices().await.unwrap().len() - }); - assert!(after_index_count > before_index_count); -} - -#[test] -fn schema_apply_rejects_unsupported_plan() { - let temp = tempdir().unwrap(); - let graph = graph_path(temp.path()); - let schema_path = temp.path().join("breaking.pg"); - init_graph(&graph); - - let breaking_schema = fs::read_to_string(fixture("test.pg")) - .unwrap() - .replace("age: I32?", "age: I64?"); - fs::write(&schema_path, breaking_schema).unwrap(); - - let output = output_failure( - cli() - .arg("schema") - .arg("apply") - .arg("--schema") - .arg(&schema_path) - .arg(&graph), - ); - let stderr = String::from_utf8_lossy(&output.stderr); - assert!(stderr.contains("changing property type")); -} - -#[test] -fn schema_apply_rejects_when_non_main_branch_exists() { - let temp = tempdir().unwrap(); - let graph = graph_path(temp.path()); - let schema_path = temp.path().join("next.pg"); - init_graph(&graph); - output_success( - cli() - .arg("branch") - .arg("create") - .arg("--from") - .arg("main") - .arg("--uri") - .arg(&graph) - .arg("feature"), - ); - - let next_schema = fs::read_to_string(fixture("test.pg")).unwrap().replace( - " age: I32?\n}", - " age: I32?\n nickname: String?\n}", - ); - fs::write(&schema_path, next_schema).unwrap(); - - let output = output_failure( - cli() - .arg("schema") - .arg("apply") - .arg("--schema") - .arg(&schema_path) - .arg(&graph), - ); - let stderr = String::from_utf8_lossy(&output.stderr); - assert!(stderr.contains("schema apply requires a graph with only main")); -} - -#[test] -fn query_lint_json_with_schema_reports_warnings() { - let temp = tempdir().unwrap(); - let schema_path = temp.path().join("schema.pg"); - let query_path = temp.path().join("queries.gq"); - write_file( - &schema_path, - r#" -node Policy { - slug: String @key - name: String? - effectiveTo: DateTime? -} -"#, - ); - write_query_file( - &query_path, - r#" -query update_policy($slug: String, $name: String) { - update Policy set { name: $name } where slug = $slug -} -"#, - ); - - let output = output_success( - cli() - .arg("query") - .arg("lint") - .arg("--query") - .arg(&query_path) - .arg("--schema") - .arg(&schema_path) - .arg("--json"), - ); - let payload: Value = serde_json::from_slice(&output.stdout).unwrap(); - - assert_eq!(payload["status"], "ok"); - assert_eq!(payload["schema_source"]["kind"], "file"); - assert_eq!(payload["queries_processed"], 1); - assert_eq!(payload["warnings"], 1); - assert_eq!(payload["findings"][0]["code"], "L201"); - assert_eq!( - payload["findings"][0]["message"], - "Policy.effectiveTo exists in schema but no update query sets it" - ); -} - -#[test] -fn query_check_alias_matches_lint_output() { - let temp = tempdir().unwrap(); - let schema_path = temp.path().join("schema.pg"); - let query_path = temp.path().join("queries.gq"); - write_file( - &schema_path, - r#" -node Person { - name: String -} -"#, - ); - write_query_file( - &query_path, - r#" -query list_people() { - match { $p: Person } - return { $p.name } -} -"#, - ); - - let lint_output = output_success( - cli() - .arg("query") - .arg("lint") - .arg("--query") - .arg(&query_path) - .arg("--schema") - .arg(&schema_path) - .arg("--json"), - ); - let check_output = output_success( - cli() - .arg("query") - .arg("check") - .arg("--query") - .arg(&query_path) - .arg("--schema") - .arg(&schema_path) - .arg("--json"), - ); - - assert_eq!(stdout_string(&lint_output), stdout_string(&check_output)); -} - -/// `omnigraph lint` is the canonical top-level lint command after the -/// query/mutate rename. `omnigraph query lint` and `omnigraph query check` -/// are kept as deprecated argv shims (warning + rewrite). All three must -/// produce identical stdout output. -#[test] -fn lint_top_level_matches_deprecated_query_lint_output() { - let temp = tempdir().unwrap(); - let schema_path = temp.path().join("schema.pg"); - let query_path = temp.path().join("queries.gq"); - write_file( - &schema_path, - r#" -node Person { - name: String -} -"#, - ); - write_query_file( - &query_path, - r#" -query list_people() { - match { $p: Person } - return { $p.name } -} -"#, - ); - - let canonical = output_success( - cli() - .arg("lint") - .arg("--query") - .arg(&query_path) - .arg("--schema") - .arg(&schema_path) - .arg("--json"), - ); - let deprecated_lint = output_success( - cli() - .arg("query") - .arg("lint") - .arg("--query") - .arg(&query_path) - .arg("--schema") - .arg(&schema_path) - .arg("--json"), - ); - let deprecated_check = output_success( - cli() - .arg("query") - .arg("check") - .arg("--query") - .arg(&query_path) - .arg("--schema") - .arg(&schema_path) - .arg("--json"), - ); - - assert_eq!(stdout_string(&canonical), stdout_string(&deprecated_lint)); - assert_eq!(stdout_string(&canonical), stdout_string(&deprecated_check)); - - // Canonical form must NOT emit the deprecation warning. - let canonical_stderr = String::from_utf8(canonical.stderr).unwrap(); - assert!( - !canonical_stderr.contains("deprecated"), - "`omnigraph lint` is canonical and must not warn; got stderr: {canonical_stderr}" - ); - - // Deprecated forms MUST emit the one-line warning, pointing at the - // new top-level `omnigraph lint`. - let lint_stderr = String::from_utf8(deprecated_lint.stderr).unwrap(); - assert!( - lint_stderr.contains("`omnigraph query lint` is deprecated") - && lint_stderr.contains("`omnigraph lint`"), - "expected deprecation warning pointing at `omnigraph lint`; got: {lint_stderr}" - ); - let check_stderr = String::from_utf8(deprecated_check.stderr).unwrap(); - assert!( - check_stderr.contains("`omnigraph query check` is deprecated") - && check_stderr.contains("`omnigraph lint`"), - "expected deprecation warning pointing at `omnigraph lint`; got: {check_stderr}" - ); -} - -/// Bare `omnigraph check` is NOT a clap `visible_alias` on `lint` (MR-981 §6: -/// visible aliases give agents two canonical names to emit interchangeably). -/// It's an argv-level shim: rewrites to `omnigraph lint`, prints a one-line -/// stderr deprecation warning, and produces identical stdout to the canonical -/// invocation. Cargo/Go users typing `check` keep working; help text shows -/// only `lint`. -#[test] -fn deprecated_check_top_level_rewrites_to_lint() { - let temp = tempdir().unwrap(); - let schema_path = temp.path().join("schema.pg"); - let query_path = temp.path().join("queries.gq"); - write_file( - &schema_path, - r#" -node Person { - name: String -} -"#, - ); - write_query_file( - &query_path, - r#" -query list_people() { - match { $p: Person } - return { $p.name } -} -"#, - ); - - let canonical = output_success( - cli() - .arg("lint") - .arg("--query") - .arg(&query_path) - .arg("--schema") - .arg(&schema_path) - .arg("--json"), - ); - let deprecated_check = output_success( - cli() - .arg("check") - .arg("--query") - .arg(&query_path) - .arg("--schema") - .arg(&schema_path) - .arg("--json"), - ); - - assert_eq!(stdout_string(&canonical), stdout_string(&deprecated_check)); - - let check_stderr = String::from_utf8(deprecated_check.stderr).unwrap(); - assert!( - check_stderr.contains("`omnigraph check` is deprecated") - && check_stderr.contains("`omnigraph lint`"), - "expected `omnigraph check` deprecation warning pointing at `omnigraph lint`; got: {check_stderr}" - ); - - // `check` must NOT appear in the canonical `omnigraph --help` output — - // agents copy the surface from help text and would otherwise emit both - // names interchangeably. - let help = cli().arg("--help").output().unwrap(); - let stdout = String::from_utf8(help.stdout).unwrap(); - let check_aliased = stdout - .lines() - .any(|line| line.trim_start().starts_with("lint") && line.contains("check")); - assert!( - !check_aliased, - "`check` must not be advertised as a visible alias of `lint`; help output: {stdout}" - ); -} - -/// `omnigraph read` and `omnigraph change` are kept as visible clap -/// aliases for the new canonical `query` / `mutate` subcommands, plus an -/// argv-level deprecation warning. The warning is emitted to stderr; the -/// command otherwise behaves identically to the canonical form. -#[test] -fn deprecated_read_and_change_subcommands_emit_warnings() { - // Both subcommands require `--query`/`--query-string`/`--alias`, so - // invoking them with no args will exit non-zero. That's fine -- - // we only care that the deprecation warning is printed before the - // argument-required error. - let output = cli().arg("read").output().unwrap(); - let stderr = String::from_utf8(output.stderr).unwrap(); - assert!( - stderr.contains("`omnigraph read` is deprecated") && stderr.contains("`omnigraph query`"), - "expected `omnigraph read` deprecation warning; got: {stderr}" - ); - - let output = cli().arg("change").output().unwrap(); - let stderr = String::from_utf8(output.stderr).unwrap(); - assert!( - stderr.contains("`omnigraph change` is deprecated") - && stderr.contains("`omnigraph mutate`"), - "expected `omnigraph change` deprecation warning; got: {stderr}" - ); - - // Sanity check the inverse: the canonical names must NOT print the - // deprecation banner. - let output = cli().arg("query").arg("--help").output().unwrap(); - let stderr = String::from_utf8(output.stderr).unwrap(); - assert!( - !stderr.contains("deprecated"), - "`omnigraph query` is canonical and must not warn; got: {stderr}" - ); - let output = cli().arg("mutate").arg("--help").output().unwrap(); - let stderr = String::from_utf8(output.stderr).unwrap(); - assert!( - !stderr.contains("deprecated"), - "`omnigraph mutate` is canonical and must not warn; got: {stderr}" - ); -} - -#[test] -fn query_lint_can_use_local_graph_via_positional_uri() { - let temp = tempdir().unwrap(); - let graph = graph_path(temp.path()); - let query_path = temp.path().join("queries.gq"); - init_graph(&graph); - write_query_file( - &query_path, - r#" -query list_people() { - match { $p: Person } - return { $p.name } -} -"#, - ); - - let output = output_success( - cli() - .arg("query") - .arg("lint") - .arg("--query") - .arg(&query_path) - .arg("--json") - .arg(&graph), - ); - let payload: Value = serde_json::from_slice(&output.stdout).unwrap(); - - assert_eq!(payload["status"], "ok"); - assert_eq!(payload["schema_source"]["kind"], "graph"); - assert_eq!( - payload["schema_source"]["uri"].as_str(), - Some(graph.to_string_lossy().as_ref()) - ); -} - -#[test] -fn query_lint_can_resolve_graph_and_query_from_config() { - let temp = tempdir().unwrap(); - let graph = graph_path(temp.path()); - let config_path = temp.path().join("omnigraph.yaml"); - init_graph(&graph); - write_query_file( - &temp.path().join("queries.gq"), - r#" -query list_people() { - match { $p: Person } - return { $p.name } -} -"#, - ); - write_config(&config_path, &local_yaml_config(&graph)); - - let output = output_success( - cli() - .arg("query") - .arg("lint") - .arg("--query") - .arg("queries.gq") - .arg("--config") - .arg(&config_path) - .arg("--json"), - ); - let payload: Value = serde_json::from_slice(&output.stdout).unwrap(); - - assert_eq!(payload["status"], "ok"); - assert_eq!(payload["schema_source"]["kind"], "graph"); - assert_eq!( - payload["schema_source"]["uri"].as_str(), - Some(graph.to_string_lossy().as_ref()) - ); -} - -#[test] -fn query_lint_rejects_http_targets_without_schema() { - let temp = tempdir().unwrap(); - let query_path = temp.path().join("queries.gq"); - write_query_file( - &query_path, - r#" -query list_people() { - match { $p: Person } - return { $p.name } -} -"#, - ); - - let output = output_failure( - cli() - .arg("query") - .arg("lint") - .arg("--query") - .arg(&query_path) - .arg("http://127.0.0.1:8080"), - ); - let stderr = String::from_utf8_lossy(&output.stderr); - assert!( - stderr.contains("query lint is only supported against local graph URIs in this milestone") - ); -} - -#[test] -fn query_lint_requires_schema_or_resolvable_graph_target() { - let temp = tempdir().unwrap(); - let query_path = temp.path().join("queries.gq"); - write_query_file( - &query_path, - r#" -query list_people() { - match { $p: Person } - return { $p.name } -} -"#, - ); - - let output = output_failure( - cli() - .arg("query") - .arg("lint") - .arg("--query") - .arg(&query_path), - ); - let stderr = String::from_utf8_lossy(&output.stderr); - assert!( - stderr.contains("query lint requires --schema or a resolvable graph target") - ); -} - -#[test] -fn query_lint_human_output_reports_warnings() { - let temp = tempdir().unwrap(); - let schema_path = temp.path().join("schema.pg"); - let query_path = temp.path().join("queries.gq"); - write_file( - &schema_path, - r#" -node Policy { - slug: String @key - name: String? - effectiveTo: DateTime? -} -"#, - ); - write_query_file( - &query_path, - r#" -query update_policy($slug: String, $name: String) { - update Policy set { name: $name } where slug = $slug -} -"#, - ); - - let output = output_success( - cli() - .arg("query") - .arg("lint") - .arg("--query") - .arg(&query_path) - .arg("--schema") - .arg(&schema_path), - ); - let stdout = stdout_string(&output); - - assert!(stdout.contains("OK query `update_policy` (mutation)")); - assert!( - stdout.contains("WARN Policy.effectiveTo exists in schema but no update query sets it") - ); - assert!(stdout.contains( - "INFO Lint complete: 1 queries processed (0 error(s), 1 warning(s), 0 info item(s))" - )); -} - -#[test] -fn query_lint_human_output_reports_strict_validation_errors() { - let temp = tempdir().unwrap(); - let schema_path = temp.path().join("schema.pg"); - let query_path = temp.path().join("queries.gq"); - write_file( - &schema_path, - r#" -node Policy { - slug: String @key - name: String? -} -"#, - ); - write_query_file( - &query_path, - r#" -query bad_update($slug: String) { - update Policy set { priority_level: "high" } where slug = $slug -} -"#, - ); - - let output = output_failure( - cli() - .arg("query") - .arg("lint") - .arg("--query") - .arg(&query_path) - .arg("--schema") - .arg(&schema_path), - ); - let stdout = stdout_string(&output); - - assert!(stdout.contains("ERROR query `bad_update`:")); - assert!(stdout.contains("Policy")); - assert!(stdout.contains( - "INFO Lint complete: 1 queries processed (1 error(s), 0 warning(s), 0 info item(s))" - )); -} - -#[test] -fn load_json_outputs_summary_for_main_branch() { - let temp = tempdir().unwrap(); - let graph = graph_path(temp.path()); - init_graph(&graph); - let data = fixture("test.jsonl"); - - let output = output_success( - cli() - .arg("load") - .arg("--mode") - .arg("overwrite") - .arg("--data") - .arg(&data) - .arg("--json") - .arg(&graph), - ); - let payload: Value = serde_json::from_slice(&output.stdout).unwrap(); - - assert_eq!(payload["branch"], "main"); - assert_eq!(payload["mode"], "overwrite"); - assert_eq!(payload["nodes_loaded"], 6); - assert_eq!(payload["edges_loaded"], 5); - assert_eq!(payload["node_types_loaded"], 2); - assert_eq!(payload["edge_types_loaded"], 2); -} - -#[test] -fn load_into_feature_branch_with_merge_mode_succeeds() { - let temp = tempdir().unwrap(); - let graph = graph_path(temp.path()); - init_graph(&graph); - load_fixture(&graph); - - output_success( - cli() - .arg("branch") - .arg("create") - .arg("--uri") - .arg(&graph) - .arg("--from") - .arg("main") - .arg("feature"), - ); - - let feature_data = temp.path().join("feature.jsonl"); - write_jsonl( - &feature_data, - r#"{"type":"Person","data":{"name":"Alice","age":31}}"#, - ); - - let output = output_success( - cli() - .arg("load") - .arg("--data") - .arg(&feature_data) - .arg("--branch") - .arg("feature") - .arg("--mode") - .arg("merge") - .arg(&graph), - ); - let stdout = stdout_string(&output); - - assert!(stdout.contains("branch feature")); - assert!(stdout.contains("with merge")); - assert!(stdout.contains("1 nodes across 1 node types")); -} - -#[test] -fn read_json_outputs_rows_for_named_query() { - let temp = tempdir().unwrap(); - let graph = graph_path(temp.path()); - init_graph(&graph); - load_fixture(&graph); - let queries = fixture("test.gq"); - - let output = output_success( - cli() - .arg("read") - .arg(&graph) - .arg("--query") - .arg(&queries) - .arg("--name") - .arg("get_person") - .arg("--params") - .arg(r#"{"name":"Alice"}"#) - .arg("--json"), - ); - let payload: Value = serde_json::from_slice(&output.stdout).unwrap(); - - assert_eq!(payload["query_name"], "get_person"); - assert_eq!(payload["target"]["branch"], "main"); - assert_eq!(payload["row_count"], 1); - assert_eq!(payload["rows"][0]["p.name"], "Alice"); -} - -#[test] -fn export_jsonl_outputs_source_rows_for_selected_branch_and_type() { - let temp = tempdir().unwrap(); - let graph = graph_path(temp.path()); - init_graph(&graph); - load_fixture(&graph); - - output_success( - cli() - .arg("branch") - .arg("create") - .arg("--uri") - .arg(&graph) - .arg("--from") - .arg("main") - .arg("feature"), - ); - - let feature_data = temp.path().join("feature-export.jsonl"); - write_jsonl( - &feature_data, - r#"{"type":"Person","data":{"name":"Eve","age":29}}"#, - ); - output_success( - cli() - .arg("load") - .arg("--data") - .arg(&feature_data) - .arg("--branch") - .arg("feature") - .arg("--mode") - .arg("append") - .arg(&graph), - ); - - let output = output_success( - cli() - .arg("export") - .arg(&graph) - .arg("--branch") - .arg("feature") - .arg("--type") - .arg("Person") - .arg("--jsonl"), - ); - let rows = stdout_string(&output) - .lines() - .map(|line| serde_json::from_str::(line).unwrap()) - .collect::>(); - - assert_eq!(rows.len(), 5); - assert!(rows.iter().all(|row| row["type"] == "Person")); - assert!(rows.iter().all(|row| row.get("edge").is_none())); - assert!( - rows.iter() - .any(|row| row["data"]["name"].as_str() == Some("Eve")) - ); -} - -#[test] -fn policy_validate_accepts_valid_policy_file() { - let temp = tempdir().unwrap(); - let (config, _) = write_policy_config_fixture(temp.path()); - - let output = output_success( - cli() - .arg("policy") - .arg("validate") - .arg("--config") - .arg(&config), - ); - let stdout = stdout_string(&output); - - assert!(stdout.contains("policy valid:")); - assert!(stdout.contains("policy.yaml")); - assert!(stdout.contains("[2 actors]")); -} - -#[test] -fn policy_validate_fails_for_invalid_policy_file() { - let temp = tempdir().unwrap(); - let config = temp.path().join("omnigraph.yaml"); - let policy = temp.path().join("policy.yaml"); - fs::write( - &config, - r#" -project: - name: policy-test-graph -policy: - file: ./policy.yaml -"#, - ) - .unwrap(); - fs::write( - &policy, - r#" -version: 1 -groups: - team: [act-andrew] -rules: - - id: duplicate - allow: - actors: { group: team } - actions: [read] - branch_scope: any - - id: duplicate - allow: - actors: { group: team } - actions: [export] - branch_scope: any -"#, - ) - .unwrap(); - - let output = output_failure( - cli() - .arg("policy") - .arg("validate") - .arg("--config") - .arg(&config), - ); - let stderr = String::from_utf8(output.stderr).unwrap(); - assert!(stderr.contains("duplicate policy rule id")); -} - -#[test] -fn policy_test_runs_declarative_cases() { - let temp = tempdir().unwrap(); - let (config, _) = write_policy_config_fixture(temp.path()); - - let output = output_success(cli().arg("policy").arg("test").arg("--config").arg(&config)); - let stdout = stdout_string(&output); - - assert!(stdout.contains("policy tests passed: 2 cases")); -} - -#[test] -fn policy_explain_reports_decision_and_matched_rule() { - let temp = tempdir().unwrap(); - let (config, _) = write_policy_config_fixture(temp.path()); - - let allow = output_success( - cli() - .arg("policy") - .arg("explain") - .arg("--config") - .arg(&config) - .arg("--actor") - .arg("act-andrew") - .arg("--action") - .arg("change") - .arg("--branch") - .arg("feature"), - ); - let allow_stdout = stdout_string(&allow); - assert!(allow_stdout.contains("decision: allow")); - assert!(allow_stdout.contains("matched_rule: team-write")); - - let deny = output_success( - cli() - .arg("policy") - .arg("explain") - .arg("--config") - .arg(&config) - .arg("--actor") - .arg("act-bruno") - .arg("--action") - .arg("change") - .arg("--branch") - .arg("main"), - ); - let deny_stdout = stdout_string(&deny); - assert!(deny_stdout.contains("decision: deny")); - assert!(deny_stdout.contains("message: policy denied action 'change' on branch 'main'")); -} - -#[test] -fn read_can_resolve_uri_from_config() { - let temp = tempdir().unwrap(); - let graph = graph_path(temp.path()); - let config = temp.path().join("omnigraph.yaml"); - init_graph(&graph); - load_fixture(&graph); - write_config(&config, &local_yaml_config(&graph)); - - let output = output_success( - cli() - .arg("read") - .arg("--config") - .arg(&config) - .arg("--query") - .arg(fixture("test.gq")) - .arg("--name") - .arg("get_person") - .arg("--params") - .arg(r#"{"name":"Alice"}"#) - .arg("--json"), - ); - let payload: Value = serde_json::from_slice(&output.stdout).unwrap(); - assert_eq!(payload["row_count"], 1); -} - -#[test] -fn read_alias_from_yaml_config_runs_with_kv_output() { - let temp = tempdir().unwrap(); - let graph = graph_path(temp.path()); - let config = temp.path().join("omnigraph.yaml"); - let query = temp.path().join("aliases.gq"); - init_graph(&graph); - load_fixture(&graph); - write_query_file( - &query, - &std::fs::read_to_string(fixture("test.gq")).unwrap(), - ); - write_config( - &config, - &format!( - "{}aliases:\n owner:\n command: read\n query: aliases.gq\n name: get_person\n args: [name]\n format: kv\n", - local_yaml_config(&graph) - ), - ); - - let output = output_success( - cli() - .arg("read") - .arg("--config") - .arg(&config) - .arg("--alias") - .arg("owner") - .arg("Alice"), - ); - let stdout = stdout_string(&output); - - assert!(stdout.contains("row 1")); - assert!(stdout.contains("p.name: Alice")); -} - -#[test] -fn read_alias_uses_alias_target_without_cli_default_and_accepts_url_like_arg() { - let temp = tempdir().unwrap(); - let graph = graph_path(temp.path()); - let config = temp.path().join("omnigraph.yaml"); - let query = temp.path().join("aliases.gq"); - let data = temp.path().join("url-like.jsonl"); - init_graph(&graph); - write_jsonl( - &data, - r#"{"type":"Person","data":{"name":"https://example.com","age":30}}"#, - ); - output_success( - cli() - .arg("load") - .arg("--mode") - .arg("overwrite") - .arg("--data") - .arg(&data) - .arg(&graph), - ); - write_query_file( - &query, - &std::fs::read_to_string(fixture("test.gq")).unwrap(), - ); - write_config( - &config, - &format!( - "graphs:\n local:\n uri: '{}'\nquery:\n roots:\n - .\npolicy: {{}}\naliases:\n owner:\n command: read\n query: aliases.gq\n name: get_person\n args: [name]\n graph: local\n format: kv\n", - graph.to_string_lossy() - ), - ); - - let output = output_success( - cli() - .arg("read") - .arg("--config") - .arg(&config) - .arg("--alias") - .arg("owner") - .arg("https://example.com"), - ); - let stdout = stdout_string(&output); - - assert!(stdout.contains("row 1")); - assert!(stdout.contains("p.name: https://example.com")); -} - -#[test] -fn change_alias_from_yaml_config_persists_changes() { - let temp = tempdir().unwrap(); - let graph = graph_path(temp.path()); - let config = temp.path().join("omnigraph.yaml"); - let query = temp.path().join("mutations.gq"); - init_graph(&graph); - load_fixture(&graph); - write_query_file( - &query, - r#" -query insert_person($name: String, $age: I32) { - insert Person { name: $name, age: $age } -} -"#, - ); - write_config( - &config, - &format!( - "{}aliases:\n add_person:\n command: change\n query: mutations.gq\n name: insert_person\n args: [name, age]\n", - local_yaml_config(&graph) - ), - ); - - let output = output_success( - cli() - .arg("change") - .arg("--config") - .arg(&config) - .arg("--alias") - .arg("add_person") - .arg("Eve") - .arg("29") - .arg("--json"), - ); - let payload: Value = serde_json::from_slice(&output.stdout).unwrap(); - assert_eq!(payload["affected_nodes"], 1); - - let verify = output_success( - cli() - .arg("read") - .arg(&graph) - .arg("--query") - .arg(fixture("test.gq")) - .arg("--name") - .arg("get_person") - .arg("--params") - .arg(r#"{"name":"Eve"}"#) - .arg("--json"), - ); - let verify_payload: Value = serde_json::from_slice(&verify.stdout).unwrap(); - assert_eq!(verify_payload["row_count"], 1); -} - -#[test] -fn read_csv_format_outputs_header_and_row_values() { - let temp = tempdir().unwrap(); - let graph = graph_path(temp.path()); - init_graph(&graph); - load_fixture(&graph); - - let output = output_success( - cli() - .arg("read") - .arg(&graph) - .arg("--query") - .arg(fixture("test.gq")) - .arg("--name") - .arg("get_person") - .arg("--params") - .arg(r#"{"name":"Alice"}"#) - .arg("--format") - .arg("csv"), - ); - let stdout = stdout_string(&output); - - assert!(stdout.lines().next().unwrap().contains("p.name")); - assert!(stdout.contains("Alice")); -} - -#[test] -fn read_jsonl_format_outputs_metadata_header_first() { - let temp = tempdir().unwrap(); - let graph = graph_path(temp.path()); - init_graph(&graph); - load_fixture(&graph); - - let output = output_success( - cli() - .arg("read") - .arg(&graph) - .arg("--query") - .arg(fixture("test.gq")) - .arg("--name") - .arg("get_person") - .arg("--params") - .arg(r#"{"name":"Alice"}"#) - .arg("--format") - .arg("jsonl"), - ); - let stdout = stdout_string(&output); - let mut lines = stdout.lines(); - assert!(lines.next().unwrap().contains("\"kind\":\"metadata\"")); - assert!(lines.next().unwrap().contains("\"p.name\":\"Alice\"")); -} - -#[test] -fn change_json_outputs_affected_counts_and_persists() { - let temp = tempdir().unwrap(); - let graph = graph_path(temp.path()); - init_graph(&graph); - load_fixture(&graph); - let mutation_file = temp.path().join("mutations.gq"); - write_query_file( - &mutation_file, - r#" -query insert_person($name: String, $age: I32) { - insert Person { name: $name, age: $age } -} -"#, - ); - - let output = output_success( - cli() - .arg("change") - .arg(&graph) - .arg("--query") - .arg(&mutation_file) - .arg("--params") - .arg(r#"{"name":"Eve","age":29}"#) - .arg("--json"), - ); - let payload: Value = serde_json::from_slice(&output.stdout).unwrap(); - assert_eq!(payload["branch"], "main"); - assert_eq!(payload["query_name"], "insert_person"); - assert_eq!(payload["affected_nodes"], 1); - assert_eq!(payload["affected_edges"], 0); - - let verify = output_success( - cli() - .arg("read") - .arg(&graph) - .arg("--query") - .arg(fixture("test.gq")) - .arg("--name") - .arg("get_person") - .arg("--params") - .arg(r#"{"name":"Eve"}"#) - .arg("--json"), - ); - let verify_payload: Value = serde_json::from_slice(&verify.stdout).unwrap(); - assert_eq!(verify_payload["row_count"], 1); - assert_eq!(verify_payload["rows"][0]["p.name"], "Eve"); -} - -#[test] -fn change_can_resolve_uri_and_branch_from_config() { - let temp = tempdir().unwrap(); - let graph = graph_path(temp.path()); - let config = temp.path().join("omnigraph.yaml"); - init_graph(&graph); - load_fixture(&graph); - write_config(&config, &local_yaml_config(&graph)); - let mutation_file = temp.path().join("config-mutations.gq"); - write_query_file( - &mutation_file, - r#" -query insert_person($name: String, $age: I32) { - insert Person { name: $name, age: $age } -} -"#, - ); - - let output = output_success( - cli() - .arg("change") - .arg("--config") - .arg(&config) - .arg("--query") - .arg(&mutation_file) - .arg("--params") - .arg(r#"{"name":"Mia","age":30}"#) - .arg("--json"), - ); - let payload: Value = serde_json::from_slice(&output.stdout).unwrap(); - assert_eq!(payload["branch"], "main"); - assert_eq!(payload["affected_nodes"], 1); -} - -#[test] -fn read_requires_name_for_multi_query_files() { - let temp = tempdir().unwrap(); - let graph = graph_path(temp.path()); - init_graph(&graph); - load_fixture(&graph); - - let output = output_failure( - cli() - .arg("read") - .arg(&graph) - .arg("--query") - .arg(fixture("test.gq")), - ); - let stderr = String::from_utf8(output.stderr).unwrap(); - assert!(stderr.contains("multiple queries")); -} - -#[test] -fn read_supports_inline_query_string() { - let temp = tempdir().unwrap(); - let repo = graph_path(temp.path()); - init_graph(&repo); - load_fixture(&repo); - - let output = output_success( - cli() - .arg("read") - .arg(&repo) - .arg("-e") - .arg("query find($name: String) { match { $p: Person { name: $name } } return { $p.name, $p.age } }") - .arg("--params") - .arg(r#"{"name":"Alice"}"#) - .arg("--json"), - ); - let payload: Value = serde_json::from_slice(&output.stdout).unwrap(); - assert_eq!(payload["query_name"], "find"); - assert_eq!(payload["row_count"], 1); - assert_eq!(payload["rows"][0]["p.name"], "Alice"); -} - -#[test] -fn change_supports_inline_query_string() { - let temp = tempdir().unwrap(); - let repo = graph_path(temp.path()); - init_graph(&repo); - load_fixture(&repo); - - let output = output_success( - cli() - .arg("change") - .arg(&repo) - .arg("--query-string") - .arg("query add($name: String, $age: I32) { insert Person { name: $name, age: $age } }") - .arg("--params") - .arg(r#"{"name":"Inline","age":42}"#) - .arg("--json"), - ); - let payload: Value = serde_json::from_slice(&output.stdout).unwrap(); - assert_eq!(payload["query_name"], "add"); - assert_eq!(payload["affected_nodes"], 1); - - let verify = output_success( - cli() - .arg("read") - .arg(&repo) - .arg("-e") - .arg("query find($name: String) { match { $p: Person { name: $name } } return { $p.name } }") - .arg("--params") - .arg(r#"{"name":"Inline"}"#) - .arg("--json"), - ); - let verify_payload: Value = serde_json::from_slice(&verify.stdout).unwrap(); - assert_eq!(verify_payload["row_count"], 1); -} - -#[test] -fn read_rejects_query_string_combined_with_query() { - let temp = tempdir().unwrap(); - let repo = graph_path(temp.path()); - init_graph(&repo); - load_fixture(&repo); - - let output = output_failure( - cli() - .arg("read") - .arg(&repo) - .arg("--query") - .arg(fixture("test.gq")) - .arg("-e") - .arg("query whatever() { match { $p: Person } return { $p.name } }"), - ); - let stderr = String::from_utf8(output.stderr).unwrap(); - assert!( - stderr.contains("cannot be used") || stderr.contains("conflict"), - "expected clap conflict error, got: {stderr}" - ); -} - -#[test] -fn read_rejects_empty_query_string() { - let temp = tempdir().unwrap(); - let repo = graph_path(temp.path()); - init_graph(&repo); - load_fixture(&repo); - - let output = output_failure(cli().arg("read").arg(&repo).arg("-e").arg("")); - let stderr = String::from_utf8(output.stderr).unwrap(); - assert!( - stderr.contains("must not be empty"), - "expected empty-string rejection, got: {stderr}" - ); -} - -#[test] -fn branch_create_json_outputs_source_and_name() { - let temp = tempdir().unwrap(); - let graph = graph_path(temp.path()); - init_graph(&graph); - - let output = output_success( - cli() - .arg("branch") - .arg("create") - .arg("--uri") - .arg(&graph) - .arg("--from") - .arg("main") - .arg("feature") - .arg("--json"), - ); - let payload: Value = serde_json::from_slice(&output.stdout).unwrap(); - - assert_eq!(payload["from"], "main"); - assert_eq!(payload["name"], "feature"); - assert_eq!(payload["uri"], graph.to_string_lossy().as_ref()); -} - -#[test] -fn branch_list_outputs_sorted_branches() { - let temp = tempdir().unwrap(); - let graph = graph_path(temp.path()); - init_graph(&graph); - - output_success( - cli() - .arg("branch") - .arg("create") - .arg("--uri") - .arg(&graph) - .arg("--from") - .arg("main") - .arg("zeta"), - ); - output_success( - cli() - .arg("branch") - .arg("create") - .arg("--uri") - .arg(&graph) - .arg("--from") - .arg("main") - .arg("alpha"), - ); - - let output = output_success(cli().arg("branch").arg("list").arg("--uri").arg(&graph)); - let stdout = stdout_string(&output); - let lines = stdout - .lines() - .map(str::trim) - .filter(|line| !line.is_empty()) - .collect::>(); - - assert_eq!(lines, vec!["alpha", "main", "zeta"]); -} - -#[test] -fn branch_delete_json_outputs_name_and_removes_branch() { - let temp = tempdir().unwrap(); - let graph = graph_path(temp.path()); - init_graph(&graph); - - output_success( - cli() - .arg("branch") - .arg("create") - .arg("--uri") - .arg(&graph) - .arg("--from") - .arg("main") - .arg("feature"), - ); - - let output = output_success( - cli() - .arg("branch") - .arg("delete") - .arg("--uri") - .arg(&graph) - .arg("feature") - .arg("--json"), - ); - let payload: Value = serde_json::from_slice(&output.stdout).unwrap(); - assert_eq!(payload["name"], "feature"); - assert_eq!(payload["uri"], graph.to_string_lossy().as_ref()); - - let listed = output_success(cli().arg("branch").arg("list").arg("--uri").arg(&graph)); - let stdout = stdout_string(&listed); - let lines = stdout - .lines() - .map(str::trim) - .filter(|line| !line.is_empty()) - .collect::>(); - assert_eq!(lines, vec!["main"]); -} - -#[test] -fn branch_delete_rejects_main() { - let temp = tempdir().unwrap(); - let graph = graph_path(temp.path()); - init_graph(&graph); - - let output = output_failure( - cli() - .arg("branch") - .arg("delete") - .arg("--uri") - .arg(&graph) - .arg("main"), - ); - let stderr = String::from_utf8(output.stderr).unwrap(); - assert!(stderr.contains("cannot delete branch 'main'")); -} - -#[test] -fn branch_merge_defaults_target_to_main() { - let temp = tempdir().unwrap(); - let graph = graph_path(temp.path()); - init_graph(&graph); - load_fixture(&graph); - - output_success( - cli() - .arg("branch") - .arg("create") - .arg("--uri") - .arg(&graph) - .arg("--from") - .arg("main") - .arg("feature"), - ); - - let feature_data = temp.path().join("feature.jsonl"); - write_jsonl( - &feature_data, - r#"{"type":"Person","data":{"name":"Eve","age":29}}"#, - ); - output_success( - cli() - .arg("load") - .arg("--data") - .arg(&feature_data) - .arg("--branch") - .arg("feature") - .arg("--mode") - .arg("append") - .arg(&graph), - ); - - let merge_output = output_success( - cli() - .arg("branch") - .arg("merge") - .arg("--uri") - .arg(&graph) - .arg("feature") - .arg("--json"), - ); - let merge_payload: Value = serde_json::from_slice(&merge_output.stdout).unwrap(); - assert_eq!(merge_payload["source"], "feature"); - assert_eq!(merge_payload["target"], "main"); - assert_eq!(merge_payload["outcome"], "fast_forward"); - - let snapshot_output = output_success( - cli() - .arg("snapshot") - .arg(&graph) - .arg("--branch") - .arg("main") - .arg("--json"), - ); - let snapshot: Value = serde_json::from_slice(&snapshot_output.stdout).unwrap(); - let person_row_count = snapshot["tables"] - .as_array() - .unwrap() - .iter() - .find(|table| table["table_key"] == "node:Person") - .unwrap()["row_count"] - .as_u64() - .unwrap(); - assert_eq!(person_row_count, 5); -} - -#[test] -fn branch_merge_supports_explicit_target() { - let temp = tempdir().unwrap(); - let graph = graph_path(temp.path()); - init_graph(&graph); - load_fixture(&graph); - - output_success( - cli() - .arg("branch") - .arg("create") - .arg("--uri") - .arg(&graph) - .arg("--from") - .arg("main") - .arg("feature"), - ); - output_success( - cli() - .arg("branch") - .arg("create") - .arg("--uri") - .arg(&graph) - .arg("--from") - .arg("main") - .arg("experiment"), - ); - - let feature_data = temp.path().join("feature-explicit.jsonl"); - write_jsonl( - &feature_data, - r#"{"type":"Person","data":{"name":"Frank","age":41}}"#, - ); - output_success( - cli() - .arg("load") - .arg("--data") - .arg(&feature_data) - .arg("--branch") - .arg("feature") - .arg("--mode") - .arg("append") - .arg(&graph), - ); - - let merge_output = output_success( - cli() - .arg("branch") - .arg("merge") - .arg("--uri") - .arg(&graph) - .arg("feature") - .arg("--into") - .arg("experiment") - .arg("--json"), - ); - let merge_payload: Value = serde_json::from_slice(&merge_output.stdout).unwrap(); - assert_eq!(merge_payload["target"], "experiment"); - assert_eq!(merge_payload["outcome"], "fast_forward"); -} - -#[test] -fn snapshot_json_returns_manifest_version_and_tables() { - let temp = tempdir().unwrap(); - let graph = graph_path(temp.path()); - init_graph(&graph); - load_fixture(&graph); - - let output = output_success(cli().arg("snapshot").arg(&graph).arg("--json")); - let payload: Value = serde_json::from_slice(&output.stdout).unwrap(); - - assert_eq!(payload["branch"], "main"); - assert_eq!( - payload["manifest_version"].as_u64().unwrap(), - manifest_dataset_version(&graph) - ); - assert!(payload["tables"].as_array().unwrap().len() >= 4); -} - -fn write_seed_fixture(root: &std::path::Path) -> std::path::PathBuf { - fs::create_dir_all(root.join("data")).unwrap(); - fs::create_dir_all(root.join("build")).unwrap(); - let raw_seed = root.join("data/seed.jsonl"); - let seed = root.join("seed.yaml"); - - fs::write( - &raw_seed, - concat!( - "{\"type\":\"Decision\",\"data\":{\"slug\":\"dec-alpha\",\"intent\":\"Alpha ship\"}}\n", - "{\"type\":\"Decision\",\"data\":{\"slug\":\"dec-beta\",\"intent\":\"Beta ship\",\"embedding\":[0.1,0.2]}}\n" - ), - ) - .unwrap(); - - fs::write( - &seed, - concat!( - "graph:\n", - " slug: mr-context-graph\n", - "sources:\n", - " raw_seed: ./data/seed.jsonl\n", - "artifacts:\n", - " embedded_seed: ./build/seed.embedded.jsonl\n", - "embeddings:\n", - " model: gemini-embedding-2-preview\n", - " dimension: 4\n", - " types:\n", - " Decision:\n", - " target: embedding\n", - " fields: [slug, intent]\n" - ), - ) - .unwrap(); - - seed -} - -fn write_seed_fixture_with_edge(root: &std::path::Path) -> std::path::PathBuf { - let seed = write_seed_fixture(root); - let raw_seed = root.join("data/seed.jsonl"); - fs::write( - &raw_seed, - concat!( - "{\"type\":\"Decision\",\"data\":{\"slug\":\"dec-alpha\",\"intent\":\"Alpha ship\"}}\n", - "{\"type\":\"Decision\",\"data\":{\"slug\":\"dec-beta\",\"intent\":\"Beta ship\",\"embedding\":[0.1,0.2]}}\n", - "{\"edge\":\"Triggered\",\"from\":\"sig-alpha\",\"to\":\"dec-alpha\"}\n" - ), - ) - .unwrap(); - seed -} - -fn read_embedded_rows(path: std::path::PathBuf) -> Vec { - fs::read_to_string(path) - .unwrap() - .lines() - .filter(|line| !line.trim().is_empty()) - .map(|line| serde_json::from_str(line).unwrap()) - .collect() -} - -#[test] -fn snapshot_can_resolve_uri_from_config() { - let temp = tempdir().unwrap(); - let graph = graph_path(temp.path()); - let config = temp.path().join("omnigraph.yaml"); - init_graph(&graph); - load_fixture(&graph); - write_config(&config, &local_yaml_config(&graph)); - - let output = output_success( - cli() - .arg("snapshot") - .arg("--config") - .arg(&config) - .arg("--json"), - ); - let payload: Value = serde_json::from_slice(&output.stdout).unwrap(); - assert_eq!(payload["branch"], "main"); -} - -#[test] -fn snapshot_human_output_includes_branch_and_table_summaries() { - let temp = tempdir().unwrap(); - let graph = graph_path(temp.path()); - init_graph(&graph); - load_fixture(&graph); - - let output = output_success(cli().arg("snapshot").arg(&graph)); - let stdout = stdout_string(&output); - - assert!(stdout.contains("branch: main")); - assert!(stdout.contains("manifest_version:")); - assert!(stdout.contains("node:Person v")); - assert!(stdout.contains("edge:Knows v")); -} - -#[test] -fn commit_show_accepts_long_uri_flag() { - let temp = tempdir().unwrap(); - let graph = graph_path(temp.path()); - init_graph(&graph); - load_fixture(&graph); - - let list = output_success(cli().arg("commit").arg("list").arg(&graph).arg("--json")); - let list_payload: Value = serde_json::from_slice(&list.stdout).unwrap(); - let commit_id = list_payload["commits"][0]["graph_commit_id"] - .as_str() - .unwrap() - .to_string(); - - let output = output_success( - cli() - .arg("commit") - .arg("show") - .arg("--uri") - .arg(&graph) - .arg(&commit_id) - .arg("--json"), - ); - let payload: Value = serde_json::from_slice(&output.stdout).unwrap(); - - assert_eq!(payload["graph_commit_id"], commit_id); - assert!(payload["manifest_version"].as_u64().unwrap() >= 1); -} - -#[test] -fn cli_fails_for_missing_graph() { - let temp = tempdir().unwrap(); - let graph = graph_path(temp.path()); - - let output = output_failure(cli().arg("snapshot").arg(&graph)); - let stderr = String::from_utf8(output.stderr).unwrap(); - assert!( - stderr.contains("_schema.pg") - || stderr.contains("No such file") - || stderr.contains("not found") - ); -} - -#[test] -fn cli_fails_for_missing_schema_or_data_file() { - let temp = tempdir().unwrap(); - let graph = graph_path(temp.path()); - let missing_schema = temp.path().join("missing.pg"); - let missing_data = temp.path().join("missing.jsonl"); - - let init_output = output_failure( - cli() - .arg("init") - .arg("--schema") - .arg(&missing_schema) - .arg(&graph), - ); - assert!( - String::from_utf8(init_output.stderr) - .unwrap() - .contains("No such file") - ); - - init_graph(&graph); - let load_output = output_failure( - cli() - .arg("load") - .arg("--mode") - .arg("overwrite") - .arg("--data") - .arg(&missing_data) - .arg(&graph), - ); - assert!( - String::from_utf8(load_output.stderr) - .unwrap() - .contains("No such file") - ); -} - -#[test] -fn cli_fails_for_invalid_merge_requests() { - let temp = tempdir().unwrap(); - let graph = graph_path(temp.path()); - init_graph(&graph); - load_fixture(&graph); - - let missing_branch = output_failure( - cli() - .arg("branch") - .arg("merge") - .arg("--uri") - .arg(&graph) - .arg("missing"), - ); - let missing_branch_stderr = String::from_utf8(missing_branch.stderr).unwrap(); - assert!( - missing_branch_stderr.contains("missing") - || missing_branch_stderr.contains("head commit") - || missing_branch_stderr.contains("not found") - ); - - let same_branch = output_failure( - cli() - .arg("branch") - .arg("merge") - .arg("--uri") - .arg(&graph) - .arg("main") - .arg("--into") - .arg("main"), - ); - assert!( - String::from_utf8(same_branch.stderr) - .unwrap() - .contains("distinct source and target") - ); -} - -// `omnigraph run list/show/publish/abort` subcommands removed -// alongside the run state machine. Direct-to-target writes leave nothing -// for these CLIs to manage. Audit history is now visible via -// `omnigraph commit list` reading the commit graph. - -// ─── MR-694 PR B: --allow-data-loss flag end-to-end ────────────────────── -// -// The schema-lint chassis v1.2 (PR #100) shipped the `--allow-data-loss` -// flag at the CLI layer; the SDK suite verifies promotion to Hard mode -// via `apply_schema_with_options(.., SchemaApplyOptions { allow_data_loss })`. -// These CLI tests close the integration gap so a future change that -// drops the flag wiring in `main.rs` turns red. - -#[test] -fn schema_apply_allow_data_loss_flag_promotes_drops_to_hard() { - let temp = tempdir().unwrap(); - let graph = graph_path(temp.path()); - let schema_path = temp.path().join("drop-age.pg"); - init_graph(&graph); - - // Drop the nullable `age` column. - let next_schema = fs::read_to_string(fixture("test.pg")) - .unwrap() - .replace(" age: I32?\n", ""); - fs::write(&schema_path, next_schema).unwrap(); - - let output = output_success( - cli() - .arg("schema") - .arg("apply") - .arg("--schema") - .arg(&schema_path) - .arg("--allow-data-loss") - .arg("--json") - .arg(&graph), - ); - let payload: Value = serde_json::from_slice(&output.stdout).unwrap(); - assert_eq!(payload["applied"], true); - - let drop_step = payload["steps"] - .as_array() - .unwrap() - .iter() - .find(|s| s["kind"] == "drop_property") - .expect("plan should include a drop_property step"); - assert_eq!( - drop_step["mode"], "hard", - "--allow-data-loss should promote Soft → Hard; full step: {drop_step}", - ); -} - -#[test] -fn schema_apply_without_allow_data_loss_keeps_soft_drops() { - // Symmetric to the above: same schema change without the flag → - // drops stay Soft. Pins default semantics against accidental Hard - // promotion if a future refactor changes the option threading. - let temp = tempdir().unwrap(); - let graph = graph_path(temp.path()); - let schema_path = temp.path().join("drop-age-soft.pg"); - init_graph(&graph); - - let next_schema = fs::read_to_string(fixture("test.pg")) - .unwrap() - .replace(" age: I32?\n", ""); - fs::write(&schema_path, next_schema).unwrap(); - - let output = output_success( - cli() - .arg("schema") - .arg("apply") - .arg("--schema") - .arg(&schema_path) - .arg("--json") - .arg(&graph), - ); - let payload: Value = serde_json::from_slice(&output.stdout).unwrap(); - assert_eq!(payload["applied"], true); - - let drop_step = payload["steps"] - .as_array() - .unwrap() - .iter() - .find(|s| s["kind"] == "drop_property") - .expect("plan should include a drop_property step"); - assert_eq!( - drop_step["mode"], "soft", - "no flag should leave drops Soft; full step: {drop_step}", - ); -} - -#[test] -fn schema_plan_parity_cli_and_sdk() { - // Same .pg through `Omnigraph::plan_schema_with_options` (SDK) and - // `omnigraph schema plan --json` (CLI). Asserts the steps array is - // byte-identical after JSON round-trip. HTTP doesn't expose a - // separate /schema/plan route — that side of parity is covered by - // the HTTP soft/hard drop tests, which exercise apply with - // identical fixtures. - let temp = tempdir().unwrap(); - let graph = graph_path(temp.path()); - init_graph(&graph); - let schema_path = temp.path().join("plan-parity.pg"); - let next_schema = fs::read_to_string(fixture("test.pg")).unwrap().replace( - " age: I32?\n}", - " age: I32?\n nickname: String?\n}", - ); - fs::write(&schema_path, &next_schema).unwrap(); - - // CLI side. - let cli_output = output_success( - cli() - .arg("schema") - .arg("plan") - .arg("--schema") - .arg(&schema_path) - .arg("--json") - .arg(&graph), - ); - let cli_payload: Value = serde_json::from_slice(&cli_output.stdout).unwrap(); - - // SDK side: open graph, call plan_schema. - let plan = tokio::runtime::Runtime::new().unwrap().block_on(async { - let db = Omnigraph::open(graph.to_string_lossy().as_ref()) - .await - .unwrap(); - db.plan_schema(&next_schema).await.unwrap() - }); - let sdk_steps = serde_json::to_value(&plan.steps).unwrap(); - - assert_eq!( - cli_payload["steps"], sdk_steps, - "CLI plan steps must match SDK plan steps for identical input", - ); - assert_eq!(cli_payload["supported"], plan.supported); -} - -// ─── MR-668 PR 8 — omnigraph graphs subcommand ───────────────────────────── - -/// `omnigraph graphs --help` lists only the read-only `list` -/// subcommand. Runtime add (`create`) and remove (`delete`) are -/// deferred — operators add/remove graphs by editing `omnigraph.yaml` -/// and restarting. This test pins the deferral against accidental -/// re-introduction. -#[test] -fn graphs_subcommand_help_lists_list_only() { - let output = output_success(cli().arg("graphs").arg("--help")); - let stdout = stdout_string(&output); - assert!( - stdout.contains("list"), - "expected `list` subcommand in help output:\n{stdout}" - ); - let lowered = stdout.to_lowercase(); - assert!( - !lowered.contains("create a new graph"), - "graph create should not be in v0.6.0 help; got:\n{stdout}" - ); - assert!( - !lowered.contains("delete a graph"), - "graph delete should not be in v0.6.0 help; got:\n{stdout}" - ); -} - -/// `omnigraph graphs list` against a local URI errors with a clear -/// message — the CLI only operates against remote multi-graph servers. -#[test] -fn graphs_list_against_local_uri_errors_with_remote_only_message() { - let output = output_failure( - cli() - .arg("graphs") - .arg("list") - .arg("--uri") - .arg("/tmp/local"), - ); - let stderr = String::from_utf8_lossy(&output.stderr).into_owned(); - assert!( - stderr.contains("remote multi-graph server URL"), - "expected 'remote multi-graph server URL' rejection in stderr; got:\n{stderr}" - ); -} - -fn queries_test_config(graph_uri: &str, entry: &str, gq_file: &str) -> String { - format!( - "graphs:\n local:\n uri: '{}'\n queries:\n {entry}:\n file: ./{gq_file}\n\ - cli:\n graph: local\npolicy: {{}}\n", - graph_uri.replace('\'', "''") - ) -} - -#[test] -fn queries_validate_exits_zero_on_clean_registry() { - let graph = SystemGraph::loaded(); - graph.write_query( - "find_person.gq", - "query find_person($name: String) { match { $p: Person { name: $name } } return { $p.age } }", - ); - let config = graph.write_config( - "omnigraph.yaml", - &queries_test_config( - &graph.path().to_string_lossy(), - "find_person", - "find_person.gq", - ), - ); - let output = output_success( - cli() - .arg("queries") - .arg("validate") - .arg("--config") - .arg(&config), - ); - let stdout = stdout_string(&output); - assert!(stdout.contains("OK"), "stdout:\n{stdout}"); -} - -#[test] -fn queries_validate_exits_nonzero_on_type_broken_query() { - let graph = SystemGraph::loaded(); - // `Widget` is not in the fixture schema. - graph.write_query( - "ghost.gq", - "query ghost() { match { $w: Widget } return { $w.name } }", - ); - let config = graph.write_config( - "omnigraph.yaml", - &queries_test_config(&graph.path().to_string_lossy(), "ghost", "ghost.gq"), - ); - let output = output_failure( - cli() - .arg("queries") - .arg("validate") - .arg("--config") - .arg(&config), - ); - let stdout = stdout_string(&output); - assert!( - stdout.contains("ghost"), - "validation should name the broken query; stdout:\n{stdout}" - ); -} - -#[test] -fn queries_list_prints_registered_query() { - let graph = SystemGraph::loaded(); - graph.write_query( - "find_person.gq", - "query find_person($name: String) { match { $p: Person { name: $name } } return { $p.age } }", - ); - // Exposed with an explicit tool name so the list shows the MCP suffix. - let config = graph.write_config( - "omnigraph.yaml", - &format!( - concat!( - "graphs:\n", - " local:\n", - " uri: '{}'\n", - " queries:\n", - " find_person:\n", - " file: ./find_person.gq\n", - " mcp: {{ expose: true, tool_name: lookup_person }}\n", - "cli:\n", - " graph: local\n", - "policy: {{}}\n", - ), - graph.path().to_string_lossy().replace('\'', "''") - ), - ); - let output = output_success( - cli() - .arg("queries") - .arg("list") - .arg("--config") - .arg(&config), - ); - let stdout = stdout_string(&output); - assert!(stdout.contains("find_person"), "stdout:\n{stdout}"); - assert!( - stdout.contains("$name: String"), - "list should show typed params; stdout:\n{stdout}" - ); - assert!( - stdout.contains("[mcp: lookup_person]"), - "list should show the MCP tool name for exposed queries; stdout:\n{stdout}" - ); -} - -#[test] -fn queries_list_requires_graph_selection_for_per_graph_only_registries() { - let graph = SystemGraph::loaded(); - graph.write_query( - "find_person.gq", - "query find_person($name: String) { match { $p: Person { name: $name } } return { $p.age } }", - ); - let config = graph.write_config( - "omnigraph.yaml", - &format!( - concat!( - "graphs:\n", - " local:\n", - " uri: '{}'\n", - " queries:\n", - " find_person:\n", - " file: ./find_person.gq\n", - "policy: {{}}\n", - ), - graph.path().to_string_lossy().replace('\'', "''") - ), - ); - - let output = output_failure( - cli() - .arg("queries") - .arg("list") - .arg("--config") - .arg(&config), - ); - let stderr = String::from_utf8_lossy(&output.stderr); - assert!( - stderr.contains("local") && stderr.contains("--target local"), - "error must name the graph and give a concrete selection hint; stderr:\n{stderr}" - ); -} - -#[test] -fn queries_list_without_graph_selection_lists_top_level_registry() { - let graph = SystemGraph::loaded(); - graph.write_query( - "top_find.gq", - "query top_find($name: String) { match { $p: Person { name: $name } } return { $p.age } }", - ); - let config = graph.write_config( - "omnigraph.yaml", - concat!( - "queries:\n", - " top_find:\n", - " file: ./top_find.gq\n", - "policy: {}\n", - ), - ); - - let output = output_success( - cli() - .arg("queries") - .arg("list") - .arg("--config") - .arg(&config), - ); - let stdout = stdout_string(&output); - assert!(stdout.contains("top_find"), "stdout:\n{stdout}"); -} - -#[test] -fn queries_list_unknown_target_errors() { - // `queries list` opens no graph URI, so unknown-graph validation can't ride - // along on URI resolution the way it does for every other command. An - // unknown `--target` must still error (naming the graph) instead of - // silently falling back to the top-level registry and showing the wrong - // (or empty) catalog. - let graph = SystemGraph::loaded(); - graph.write_query( - "find_person.gq", - "query find_person($name: String) { match { $p: Person { name: $name } } return { $p.age } }", - ); - let config = graph.write_config( - "omnigraph.yaml", - &queries_test_config( - &graph.path().to_string_lossy(), - "find_person", - "find_person.gq", - ), - ); - let output = output_failure( - cli() - .arg("queries") - .arg("list") - .arg("--target") - .arg("nonexistent") - .arg("--config") - .arg(&config), - ); - let stderr = String::from_utf8_lossy(&output.stderr); - assert!( - stderr.contains("nonexistent"), - "error must name the unknown graph; stderr:\n{stderr}" - ); -} - -#[test] -fn queries_commands_reject_named_graph_with_populated_top_level_block() { - // A named graph (here via `cli.graph`) uses its own `graphs.` block, - // so a populated top-level `queries:` block would be silently ignored — a - // config the server REFUSES to boot. `queries validate`/`list` must reject - // it too (matching boot) instead of validating/listing the per-graph block - // and giving a false green. - let graph = SystemGraph::loaded(); - graph.write_query( - "find_person.gq", - "query find_person($name: String) { match { $p: Person { name: $name } } return { $p.age } }", - ); - let config = graph.write_config( - "omnigraph.yaml", - &format!( - concat!( - "graphs:\n", - " local:\n", - " uri: '{}'\n", - " queries:\n", - " find_person:\n", - " file: ./find_person.gq\n", - "cli:\n", - " graph: local\n", - "queries:\n", // populated top-level block: the coherence violation - " legacy:\n", - " file: ./legacy.gq\n", - "policy: {{}}\n", - ), - graph.path().to_string_lossy().replace('\'', "''") - ), - ); - // Both resolve `local` from cli.graph (no positional URI), so both must - // error and name the graph + the ignored block — like server boot does. - for sub in ["validate", "list"] { - let output = output_failure(cli().arg("queries").arg(sub).arg("--config").arg(&config)); - let stderr = String::from_utf8_lossy(&output.stderr); - assert!( - stderr.contains("local") && stderr.contains("queries"), - "`queries {sub}` must reject a named graph with a populated top-level block; stderr:\n{stderr}" - ); - } -} - -#[test] -fn queries_validate_exits_nonzero_on_duplicate_tool_name() { - // Two exposed queries claiming one MCP tool name is a load-time - // collision — `queries validate` must fail (offline, before the engine - // opens) and name both queries plus the contested tool. - let graph = SystemGraph::loaded(); - graph.write_query( - "a.gq", - "query a() { match { $p: Person } return { $p.name } }", - ); - graph.write_query( - "b.gq", - "query b() { match { $p: Person } return { $p.name } }", - ); - let config = graph.write_config( - "omnigraph.yaml", - &format!( - concat!( - "graphs:\n", - " local:\n", - " uri: '{}'\n", - " queries:\n", - " a:\n", - " file: ./a.gq\n", - " mcp: {{ expose: true, tool_name: dup }}\n", - " b:\n", - " file: ./b.gq\n", - " mcp: {{ expose: true, tool_name: dup }}\n", - "cli:\n", - " graph: local\n", - "policy: {{}}\n", - ), - graph.path().to_string_lossy().replace('\'', "''") - ), - ); - let output = output_failure( - cli() - .arg("queries") - .arg("validate") - .arg("--config") - .arg(&config), - ); - let stderr = String::from_utf8_lossy(&output.stderr); - assert!( - stderr.contains("dup") && stderr.contains("'a'") && stderr.contains("'b'"), - "duplicate tool name should be reported naming both queries; stderr:\n{stderr}" - ); -} - -#[test] -fn queries_validate_positional_uri_ignores_default_graph() { - // A positional URI is anonymous → the schema AND the registry both come - // from top-level, even when `cli.graph` names a graph whose per-graph - // queries would fail. Pins that the URI and registry can't diverge. - let graph = SystemGraph::loaded(); - graph.write_query( - "clean.gq", - "query clean($name: String) { match { $p: Person { name: $name } } return { $p.age } }", - ); - // `Widget` is not in the fixture schema — the default graph's per-graph - // query would break validate if it were (wrongly) selected. - graph.write_query( - "broken.gq", - "query broken() { match { $w: Widget } return { $w.name } }", - ); - let config = graph.write_config( - "omnigraph.yaml", - concat!( - "cli:\n graph: prod\n", - "graphs:\n", - " prod:\n", - " uri: /nonexistent-prod.omni\n", - " queries:\n", - " broken:\n", - " file: ./broken.gq\n", - "queries:\n", - " clean:\n", - " file: ./clean.gq\n", - "policy: {}\n", - ), - ); - // Positional URI = the real loaded graph; selection is anonymous, so the - // CLEAN top-level registry validates (not prod's broken one). - let output = output_success( - cli() - .arg("queries") - .arg("validate") - .arg(graph.path()) - .arg("--config") - .arg(&config), - ); - let stdout = stdout_string(&output); - assert!( - stdout.contains("OK"), - "positional URI must validate the top-level registry, not the cli.graph default; stdout:\n{stdout}" - ); -} - -// ---- per-operator local config (omnigraph.yaml) vs the cluster surfaces ---- - -/// Cluster ops resolve operator identity per-operator: --as wins, and -/// without it the cwd omnigraph.yaml's `cli.actor` is the default. -#[test] -fn cluster_apply_uses_cli_actor_from_local_config() { - let temp = tempdir().unwrap(); - write_cluster_config_fixture(temp.path()); - fs::write( - temp.path().join("omnigraph.yaml"), - "cli:\n actor: act-local\n", - ) - .unwrap(); - // Phase 1: import once (setup, not under test). - let output = cli() - .current_dir(temp.path()) - .arg("cluster") - .arg("import") - .arg("--config") - .arg(temp.path()) - .output() - .unwrap(); - assert!(output.status.success(), "{output:?}"); - - // Phase 2: apply alone, capturing the echoed actor (idempotent re-runs). - let apply = |extra: &[&str]| { - let mut command = cli(); - command.current_dir(temp.path()); - for arg in extra { - command.arg(arg); - } - let output = command - .arg("cluster") - .arg("apply") - .arg("--config") - .arg(temp.path()) - .arg("--json") - .output() - .unwrap(); - let json: serde_json::Value = - serde_json::from_str(String::from_utf8_lossy(&output.stdout).trim()).unwrap(); - json["actor"].clone() - }; - assert_eq!(apply(&[]), "act-local", "cli.actor is the no-flag default"); - assert_eq!(apply(&["--as", "andrew"]), "andrew", "--as overrides cli.actor"); -} - -#[test] -fn cluster_approve_uses_cli_actor_fallback() { - let temp = tempdir().unwrap(); - write_cluster_config_fixture(temp.path()); - fs::write( - temp.path().join("omnigraph.yaml"), - "cli:\n actor: act-local\n", - ) - .unwrap(); - // Converge, then remove the graph so a gated delete is pending. - for command in ["import", "apply"] { - let output = cli() - .current_dir(temp.path()) - .arg("cluster") - .arg(command) - .arg("--config") - .arg(temp.path()) - .output() - .unwrap(); - assert!(output.status.success(), "cluster {command} failed"); - } - fs::write(temp.path().join("cluster.yaml"), "version: 1\ngraphs: {}\n").unwrap(); - - let output = cli() - .current_dir(temp.path()) - .arg("cluster") - .arg("approve") - .arg("graph.knowledge") - .arg("--config") - .arg(temp.path()) - .arg("--json") - .output() - .unwrap(); - assert!(output.status.success(), "{output:?}"); - let json: serde_json::Value = - serde_json::from_str(String::from_utf8_lossy(&output.stdout).trim()).unwrap(); - assert_eq!(json["approved_by"], "act-local"); - - // With neither flag nor config: refused with the actionable message. - let bare = tempdir().unwrap(); - write_cluster_config_fixture(bare.path()); - let output = output_failure( - cli() - .current_dir(bare.path()) - .arg("cluster") - .arg("approve") - .arg("graph.knowledge") - .arg("--config") - .arg(bare.path()), - ); - let stderr = String::from_utf8_lossy(&output.stderr); - assert!(stderr.contains("--as"), "{stderr}"); - assert!(stderr.contains("cli.actor"), "{stderr}"); -} - -/// A malformed omnigraph.yaml in the cwd must never break cluster commands; -/// it is read for exactly one thing (the actor default when --as is absent), -/// and only that path fails loudly. -#[test] -fn cluster_commands_ignore_malformed_local_config() { - let temp = tempdir().unwrap(); - write_cluster_config_fixture(temp.path()); - fs::write(temp.path().join("omnigraph.yaml"), "{{{{ not yaml").unwrap(); - - for command in ["validate", "plan", "status"] { - let output = cli() - .current_dir(temp.path()) - .arg("cluster") - .arg(command) - .arg("--config") - .arg(temp.path()) - .arg("--json") - .output() - .unwrap(); - assert!( - output.status.success() || command == "plan", // plan warns state-missing pre-import; still must not config-error - "cluster {command} affected by malformed omnigraph.yaml: {output:?}" - ); - assert!( - !String::from_utf8_lossy(&output.stderr).contains("omnigraph.yaml"), - "cluster {command} touched omnigraph.yaml" - ); - } - // import + apply with an explicit --as: the config is never loaded. - for (command, args) in [("import", vec![]), ("apply", vec!["--as", "andrew"])] { - let mut invocation = cli(); - invocation.current_dir(temp.path()); - for arg in &args { - invocation.arg(arg); - } - let output = invocation - .arg("cluster") - .arg(command) - .arg("--config") - .arg(temp.path()) - .output() - .unwrap(); - assert!( - output.status.success(), - "cluster {command} affected by malformed omnigraph.yaml: {}", - String::from_utf8_lossy(&output.stderr) - ); - } - // Only the no-flag actor lookup is allowed to fail, and loudly. - let output = output_failure( - cli() - .current_dir(temp.path()) - .arg("cluster") - .arg("apply") - .arg("--config") - .arg(temp.path()), - ); - let stderr = String::from_utf8_lossy(&output.stderr); - assert!( - stderr.contains("omnigraph.yaml") && stderr.contains("--as"), - "the actor-default config read must fail loudly and actionably: {stderr}" - ); -} - -/// A well-formed omnigraph.yaml with a CONFLICTING world view (different -/// graphs, server bind) leaks nothing into cluster outputs. -#[test] -fn cluster_commands_ignore_conflicting_local_config() { - let baseline = tempdir().unwrap(); - write_cluster_config_fixture(baseline.path()); - let with_config = tempdir().unwrap(); - write_cluster_config_fixture(with_config.path()); - fs::write( - with_config.path().join("omnigraph.yaml"), - r#" -server: - bind: 0.0.0.0:9999 -graphs: - phantom: - uri: ./phantom.omni -"#, - ) - .unwrap(); - - let validate = |dir: &std::path::Path| { - let output = cli() - .current_dir(dir) - .arg("cluster") - .arg("validate") - .arg("--config") - .arg(dir) - .arg("--json") - .output() - .unwrap(); - assert!(output.status.success(), "{output:?}"); - serde_json::from_str::(String::from_utf8_lossy(&output.stdout).trim()) - .unwrap() - }; - let (a, b) = (validate(baseline.path()), validate(with_config.path())); - // Compare the path-free invariants (paths embed each tempdir). - for key in ["ok", "diagnostics", "resource_digests", "dependencies"] { - assert_eq!(a[key], b[key], "conflicting omnigraph.yaml leaked into cluster validate ({key})"); - } - let leaked = b.to_string(); - assert!(!leaked.contains("phantom") && !leaked.contains("9999"), "{leaked}"); -} diff --git a/crates/omnigraph-cli/tests/cli_cluster.rs b/crates/omnigraph-cli/tests/cli_cluster.rs new file mode 100644 index 0000000..be7675a --- /dev/null +++ b/crates/omnigraph-cli/tests/cli_cluster.rs @@ -0,0 +1,884 @@ +//! Cluster command surface: validate/plan/apply/approve/status/sync/force-unlock. +//! Moved verbatim from tests/cli.rs in the modularization. + +use std::fs; + +use tempfile::tempdir; + +mod support; + +use support::*; + + +#[test] +fn cluster_validate_config_success() { + let temp = tempdir().unwrap(); + write_cluster_config_fixture(temp.path()); + + let output = output_success( + cli() + .arg("cluster") + .arg("validate") + .arg("--config") + .arg(temp.path()), + ); + let stdout = stdout_string(&output); + assert!(stdout.contains("cluster config valid"), "{stdout}"); +} + +#[test] +fn cluster_validate_json_is_stable() { + let temp = tempdir().unwrap(); + write_cluster_config_fixture(temp.path()); + + let json = parse_stdout_json(&output_success( + cli() + .arg("cluster") + .arg("validate") + .arg("--config") + .arg(temp.path()) + .arg("--json"), + )); + assert_eq!(json["ok"], true); + assert!(json["resource_digests"]["graph.knowledge"].is_string()); + assert!(json["resource_digests"]["query.knowledge.find_person"].is_string()); + assert_eq!(json["dependencies"][0]["from"], "policy.base"); + assert_eq!(json["dependencies"][0]["to"], "graph.knowledge"); +} + +#[test] +fn cluster_plan_json_reads_inferred_local_state() { + let temp = tempdir().unwrap(); + write_cluster_config_fixture(temp.path()); + let state_dir = temp.path().join("__cluster"); + fs::create_dir_all(&state_dir).unwrap(); + fs::write( + state_dir.join("state.json"), + r#" +{ + "version": 1, + "applied_revision": { + "config_digest": "old", + "resources": { + "graph.knowledge": { "digest": "old-graph" }, + "policy.old": { "digest": "old-policy" } + } + } +} +"#, + ) + .unwrap(); + + let json = parse_stdout_json(&output_success( + cli() + .arg("cluster") + .arg("plan") + .arg("--config") + .arg(temp.path()) + .arg("--json"), + )); + assert_eq!(json["ok"], true); + assert_eq!(json["state_observations"]["state_found"], true); + assert!( + json["changes"] + .as_array() + .unwrap() + .iter() + .any(|change| change["resource"] == "policy.old" && change["operation"] == "delete"), + "plan should read state and delete stale resources: {json}" + ); +} + +#[test] +fn cluster_status_json_reports_missing_state() { + let temp = tempdir().unwrap(); + write_cluster_config_fixture(temp.path()); + + let json = parse_stdout_json(&output_success( + cli() + .arg("cluster") + .arg("status") + .arg("--config") + .arg(temp.path()) + .arg("--json"), + )); + assert_eq!(json["ok"], true); + assert_eq!(json["state_observations"]["state_found"], false); + assert!( + json["diagnostics"] + .as_array() + .unwrap() + .iter() + .any(|diagnostic| diagnostic["code"] == "state_missing"), + "missing state should be a warning diagnostic: {json}" + ); +} + +#[test] +fn cluster_status_json_reports_lock_metadata() { + let temp = tempdir().unwrap(); + write_cluster_config_fixture(temp.path()); + write_cluster_lock(temp.path(), "held-lock", "refresh"); + + let json = parse_stdout_json(&output_success( + cli() + .arg("cluster") + .arg("status") + .arg("--config") + .arg(temp.path()) + .arg("--json"), + )); + assert_eq!(json["ok"], true); + assert_eq!(json["state_observations"]["locked"], true); + assert_eq!(json["state_observations"]["lock_id"], "held-lock"); + assert_eq!(json["state_observations"]["lock_operation"], "refresh"); + assert_eq!(json["state_observations"]["lock_pid"], 123); + assert_eq!( + json["state_observations"]["lock_created_at"], + "1970-01-01T00:00:00Z" + ); + assert!(json["state_observations"]["lock_age_seconds"].is_number()); +} + +#[test] +fn cluster_status_json_reports_extended_state() { + let temp = tempdir().unwrap(); + write_cluster_config_fixture(temp.path()); + let state_dir = temp.path().join("__cluster"); + fs::create_dir_all(&state_dir).unwrap(); + fs::write( + state_dir.join("state.json"), + r#" +{ + "version": 1, + "state_revision": 5, + "applied_revision": { + "config_digest": "applied", + "resources": { + "graph.knowledge": { "digest": "graph-digest" } + } + }, + "resource_statuses": { + "graph.knowledge": { "status": "applied", "conditions": ["healthy"] } + }, + "approval_records": {}, + "recovery_records": {}, + "observations": {} +} +"#, + ) + .unwrap(); + + let json = parse_stdout_json(&output_success( + cli() + .arg("cluster") + .arg("status") + .arg("--config") + .arg(temp.path()) + .arg("--json"), + )); + assert_eq!(json["ok"], true); + assert_eq!(json["state_observations"]["state_revision"], 5); + assert!( + json["state_observations"]["state_cas"] + .as_str() + .unwrap() + .starts_with("sha256:") + ); + assert_eq!(json["resource_digests"]["graph.knowledge"], "graph-digest"); + assert_eq!( + json["resource_statuses"]["graph.knowledge"]["status"], + "applied" + ); +} + +#[test] +fn cluster_plan_json_includes_state_cas_revision_and_lock_observation() { + let temp = tempdir().unwrap(); + write_cluster_config_fixture(temp.path()); + let state_dir = temp.path().join("__cluster"); + fs::create_dir_all(&state_dir).unwrap(); + fs::write( + state_dir.join("state.json"), + r#" +{ + "version": 1, + "state_revision": 9, + "applied_revision": { + "config_digest": "old", + "resources": { + "graph.knowledge": { "digest": "old-graph" } + } + } +} +"#, + ) + .unwrap(); + + let json = parse_stdout_json(&output_success( + cli() + .arg("cluster") + .arg("plan") + .arg("--config") + .arg(temp.path()) + .arg("--json"), + )); + assert_eq!(json["ok"], true); + assert_eq!(json["state_observations"]["state_revision"], 9); + assert!( + json["state_observations"]["state_cas"] + .as_str() + .unwrap() + .starts_with("sha256:") + ); + assert_eq!(json["state_observations"]["locked"], false); + assert_eq!(json["state_observations"]["lock_acquired"], true); + assert!(json["state_observations"]["acquired_lock_id"].is_string()); + assert!(!state_dir.join("lock.json").exists()); +} + +#[test] +fn cluster_plan_locked_state_exits_nonzero() { + let temp = tempdir().unwrap(); + write_cluster_config_fixture(temp.path()); + write_cluster_lock(temp.path(), "held-lock", "plan"); + + let output = output_failure( + cli() + .arg("cluster") + .arg("plan") + .arg("--config") + .arg(temp.path()) + .arg("--json"), + ); + let json = parse_stdout_json(&output); + assert_eq!(json["ok"], false); + assert_eq!(json["state_observations"]["locked"], true); + assert_eq!(json["state_observations"]["lock_acquired"], false); + assert_eq!(json["state_observations"]["lock_id"], "held-lock"); + assert_eq!(json["state_observations"]["lock_operation"], "plan"); + assert_eq!(json["state_observations"]["lock_pid"], 123); + assert_eq!( + json["state_observations"]["lock_created_at"], + "1970-01-01T00:00:00Z" + ); + assert!(json["state_observations"]["lock_age_seconds"].is_number()); + assert!( + json["diagnostics"] + .as_array() + .unwrap() + .iter() + .any(|diagnostic| diagnostic["code"] == "state_lock_held" + && diagnostic["message"] + .as_str() + .unwrap() + .contains("force-unlock held-lock")), + "locked state should produce a useful diagnostic: {json}" + ); +} + +#[test] +fn cluster_force_unlock_json_removes_lock() { + let temp = tempdir().unwrap(); + write_cluster_config_fixture(temp.path()); + write_cluster_lock(temp.path(), "held-lock", "plan"); + + let json = parse_stdout_json(&output_success( + cli() + .arg("cluster") + .arg("force-unlock") + .arg("held-lock") + .arg("--config") + .arg(temp.path()) + .arg("--json"), + )); + assert_eq!(json["ok"], true); + assert_eq!(json["lock_removed"], true); + assert_eq!(json["state_observations"]["lock_id"], "held-lock"); + assert_eq!(json["state_observations"]["lock_operation"], "plan"); + assert!(!temp.path().join("__cluster/lock.json").exists()); +} + +#[test] +fn cluster_force_unlock_wrong_id_exits_nonzero() { + let temp = tempdir().unwrap(); + write_cluster_config_fixture(temp.path()); + write_cluster_lock(temp.path(), "held-lock", "plan"); + + let json = parse_stdout_json(&output_failure( + cli() + .arg("cluster") + .arg("force-unlock") + .arg("other-lock") + .arg("--config") + .arg(temp.path()) + .arg("--json"), + )); + assert_eq!(json["ok"], false); + assert_eq!(json["lock_removed"], false); + assert!( + json["diagnostics"] + .as_array() + .unwrap() + .iter() + .any(|diagnostic| diagnostic["code"] == "state_lock_id_mismatch") + ); + assert!(temp.path().join("__cluster/lock.json").exists()); +} + +#[test] +fn cluster_locked_plan_then_force_unlock_then_plan_succeeds() { + let temp = tempdir().unwrap(); + write_cluster_config_fixture(temp.path()); + write_cluster_lock(temp.path(), "held-lock", "plan"); + + let locked = parse_stdout_json(&output_failure( + cli() + .arg("cluster") + .arg("plan") + .arg("--config") + .arg(temp.path()) + .arg("--json"), + )); + assert_eq!(locked["ok"], false); + assert_eq!(locked["state_observations"]["lock_id"], "held-lock"); + + let unlocked = parse_stdout_json(&output_success( + cli() + .arg("cluster") + .arg("force-unlock") + .arg("held-lock") + .arg("--config") + .arg(temp.path()) + .arg("--json"), + )); + assert_eq!(unlocked["lock_removed"], true); + + let planned = parse_stdout_json(&output_success( + cli() + .arg("cluster") + .arg("plan") + .arg("--config") + .arg(temp.path()) + .arg("--json"), + )); + assert_eq!(planned["ok"], true); +} + +#[test] +fn cluster_import_json_bootstraps_missing_state() { + let temp = tempdir().unwrap(); + write_cluster_config_fixture(temp.path()); + init_cluster_derived_graph(temp.path()); + + let json = parse_stdout_json(&output_success( + cli() + .arg("cluster") + .arg("import") + .arg("--config") + .arg(temp.path()) + .arg("--json"), + )); + assert_eq!(json["ok"], true); + assert_eq!(json["operation"], "import"); + assert_eq!(json["state_observations"]["state_revision"], 1); + assert!( + json["state_observations"]["state_cas"] + .as_str() + .unwrap() + .starts_with("sha256:") + ); + assert_eq!(json["state_observations"]["locked"], false); + assert_eq!(json["state_observations"]["lock_acquired"], true); + assert!(json["state_observations"]["acquired_lock_id"].is_string()); + assert!(json["observations"]["graph.knowledge"]["manifest_version"].is_number()); + assert_eq!( + json["resource_statuses"]["graph.knowledge"]["status"], + "applied" + ); + assert!(temp.path().join("__cluster/state.json").exists()); + assert!(!temp.path().join("__cluster/lock.json").exists()); +} + +#[test] +fn cluster_refresh_json_updates_revision_cas_and_removes_lock() { + let temp = tempdir().unwrap(); + write_cluster_config_fixture(temp.path()); + init_cluster_derived_graph(temp.path()); + let state_dir = temp.path().join("__cluster"); + fs::create_dir_all(&state_dir).unwrap(); + fs::write( + state_dir.join("state.json"), + r#" +{ + "version": 1, + "state_revision": 2, + "applied_revision": { "resources": {} } +} +"#, + ) + .unwrap(); + + let json = parse_stdout_json(&output_success( + cli() + .arg("cluster") + .arg("refresh") + .arg("--config") + .arg(temp.path()) + .arg("--json"), + )); + assert_eq!(json["ok"], true); + assert_eq!(json["operation"], "refresh"); + assert_eq!(json["state_observations"]["state_revision"], 3); + assert!( + json["state_observations"]["state_cas"] + .as_str() + .unwrap() + .starts_with("sha256:") + ); + assert_eq!(json["state_observations"]["locked"], false); + assert_eq!(json["state_observations"]["lock_acquired"], true); + assert!(json["state_observations"]["acquired_lock_id"].is_string()); + assert!(!state_dir.join("lock.json").exists()); +} + +#[test] +fn cluster_refresh_missing_state_exits_nonzero() { + let temp = tempdir().unwrap(); + write_cluster_config_fixture(temp.path()); + + let output = output_failure( + cli() + .arg("cluster") + .arg("refresh") + .arg("--config") + .arg(temp.path()) + .arg("--json"), + ); + let json = parse_stdout_json(&output); + assert_eq!(json["ok"], false); + assert!( + json["diagnostics"] + .as_array() + .unwrap() + .iter() + .any(|diagnostic| diagnostic["code"] == "state_missing"), + "missing state should produce a useful diagnostic: {json}" + ); +} + +#[test] +fn cluster_import_existing_state_exits_nonzero() { + let temp = tempdir().unwrap(); + write_cluster_config_fixture(temp.path()); + let state_dir = temp.path().join("__cluster"); + fs::create_dir_all(&state_dir).unwrap(); + fs::write( + state_dir.join("state.json"), + r#"{"version":1,"applied_revision":{"resources":{}}}"#, + ) + .unwrap(); + + let output = output_failure( + cli() + .arg("cluster") + .arg("import") + .arg("--config") + .arg(temp.path()) + .arg("--json"), + ); + let json = parse_stdout_json(&output); + assert_eq!(json["ok"], false); + assert!( + json["diagnostics"] + .as_array() + .unwrap() + .iter() + .any(|diagnostic| diagnostic["code"] == "state_already_exists"), + "existing state should produce a useful diagnostic: {json}" + ); +} + +#[test] +fn cluster_refresh_and_import_locked_state_exit_nonzero() { + let temp = tempdir().unwrap(); + write_cluster_config_fixture(temp.path()); + let state_dir = temp.path().join("__cluster"); + fs::create_dir_all(&state_dir).unwrap(); + fs::write( + state_dir.join("state.json"), + r#"{"version":1,"applied_revision":{"resources":{}}}"#, + ) + .unwrap(); + fs::write( + state_dir.join("lock.json"), + r#"{"version":1,"lock_id":"held-lock","operation":"refresh","created_at":"2026-06-08T00:00:00Z","pid":123}"#, + ) + .unwrap(); + + let refresh = parse_stdout_json(&output_failure( + cli() + .arg("cluster") + .arg("refresh") + .arg("--config") + .arg(temp.path()) + .arg("--json"), + )); + assert_eq!(refresh["state_observations"]["locked"], true); + assert_eq!(refresh["state_observations"]["lock_id"], "held-lock"); + assert_eq!(refresh["state_observations"]["lock_acquired"], false); + assert!( + refresh["diagnostics"] + .as_array() + .unwrap() + .iter() + .any(|diagnostic| diagnostic["code"] == "state_lock_held") + ); + + let temp = tempdir().unwrap(); + write_cluster_config_fixture(temp.path()); + let state_dir = temp.path().join("__cluster"); + fs::create_dir_all(&state_dir).unwrap(); + fs::write( + state_dir.join("lock.json"), + r#"{"version":1,"lock_id":"held-lock","operation":"import","created_at":"2026-06-08T00:00:00Z","pid":123}"#, + ) + .unwrap(); + + let imported = parse_stdout_json(&output_failure( + cli() + .arg("cluster") + .arg("import") + .arg("--config") + .arg(temp.path()) + .arg("--json"), + )); + assert_eq!(imported["state_observations"]["locked"], true); + assert_eq!(imported["state_observations"]["lock_id"], "held-lock"); + assert_eq!(imported["state_observations"]["lock_acquired"], false); + assert!( + imported["diagnostics"] + .as_array() + .unwrap() + .iter() + .any(|diagnostic| diagnostic["code"] == "state_lock_held") + ); +} + +#[test] +fn cluster_validate_invalid_config_exits_nonzero() { + let temp = tempdir().unwrap(); + fs::write( + temp.path().join("cluster.yaml"), + "version: 1\ngraphs: {}\npipelines: {}\n", + ) + .unwrap(); + + let output = output_failure( + cli() + .arg("cluster") + .arg("validate") + .arg("--config") + .arg(temp.path()), + ); + let stdout = stdout_string(&output); + assert!(stdout.contains("future_phase_field"), "{stdout}"); +} + +#[test] +fn cluster_apply_json_applies_query_and_policy() { + let temp = tempdir().unwrap(); + write_cluster_config_fixture(temp.path()); + let validate = write_cluster_applyable_state(temp.path()); + + let json = parse_stdout_json(&output_success( + cli() + .arg("cluster") + .arg("apply") + .arg("--config") + .arg(temp.path()) + .arg("--json"), + )); + assert_eq!(json["ok"], true, "{json}"); + assert_eq!(json["applied_count"], 2, "{json}"); + assert_eq!(json["converged"], true, "{json}"); + assert_eq!(json["state_written"], true, "{json}"); + assert_eq!( + json["resource_statuses"]["query.knowledge.find_person"]["status"], + "applied" + ); + + let query_digest = validate["resource_digests"]["query.knowledge.find_person"] + .as_str() + .unwrap(); + let payload = temp + .path() + .join("__cluster/resources/query/knowledge/find_person") + .join(format!("{query_digest}.gq")); + assert!(payload.exists(), "missing payload {}", payload.display()); + + let state: serde_json::Value = serde_json::from_str( + &fs::read_to_string(temp.path().join("__cluster/state.json")).unwrap(), + ) + .unwrap(); + assert_eq!(state["state_revision"], 2); + assert_eq!( + state["applied_revision"]["resources"]["query.knowledge.find_person"]["digest"], + *query_digest + ); +} + +#[test] +fn cluster_apply_missing_state_exits_nonzero() { + let temp = tempdir().unwrap(); + write_cluster_config_fixture(temp.path()); + + let output = output_failure( + cli() + .arg("cluster") + .arg("apply") + .arg("--config") + .arg(temp.path()) + .arg("--json"), + ); + let json = parse_stdout_json(&output); + assert_eq!(json["ok"], false); + assert!( + json["diagnostics"] + .as_array() + .unwrap() + .iter() + .any(|diagnostic| diagnostic["code"] == "state_missing"), + "{json}" + ); + assert!(!temp.path().join("__cluster/resources").exists()); +} + +#[test] +fn cluster_apply_locked_exits_nonzero() { + let temp = tempdir().unwrap(); + write_cluster_config_fixture(temp.path()); + write_cluster_applyable_state(temp.path()); + write_cluster_lock(temp.path(), "held-lock", "plan"); + + let output = output_failure( + cli() + .arg("cluster") + .arg("apply") + .arg("--config") + .arg(temp.path()) + .arg("--json"), + ); + let json = parse_stdout_json(&output); + assert_eq!(json["ok"], false); + assert!( + json["diagnostics"] + .as_array() + .unwrap() + .iter() + .any(|diagnostic| diagnostic["code"] == "state_lock_held"), + "{json}" + ); + assert!(temp.path().join("__cluster/lock.json").exists()); + assert!(!temp.path().join("__cluster/resources").exists()); +} + +#[test] +fn cluster_apply_uses_cli_actor_from_local_config() { + let temp = tempdir().unwrap(); + write_cluster_config_fixture(temp.path()); + fs::write( + temp.path().join("omnigraph.yaml"), + "cli:\n actor: act-local\n", + ) + .unwrap(); + // Phase 1: import once (setup, not under test). + let output = cli() + .current_dir(temp.path()) + .arg("cluster") + .arg("import") + .arg("--config") + .arg(temp.path()) + .output() + .unwrap(); + assert!(output.status.success(), "{output:?}"); + + // Phase 2: apply alone, capturing the echoed actor (idempotent re-runs). + let apply = |extra: &[&str]| { + let mut command = cli(); + command.current_dir(temp.path()); + for arg in extra { + command.arg(arg); + } + let output = command + .arg("cluster") + .arg("apply") + .arg("--config") + .arg(temp.path()) + .arg("--json") + .output() + .unwrap(); + let json: serde_json::Value = + serde_json::from_str(String::from_utf8_lossy(&output.stdout).trim()).unwrap(); + json["actor"].clone() + }; + assert_eq!(apply(&[]), "act-local", "cli.actor is the no-flag default"); + assert_eq!(apply(&["--as", "andrew"]), "andrew", "--as overrides cli.actor"); +} + +#[test] +fn cluster_approve_uses_cli_actor_fallback() { + let temp = tempdir().unwrap(); + write_cluster_config_fixture(temp.path()); + fs::write( + temp.path().join("omnigraph.yaml"), + "cli:\n actor: act-local\n", + ) + .unwrap(); + // Converge, then remove the graph so a gated delete is pending. + for command in ["import", "apply"] { + let output = cli() + .current_dir(temp.path()) + .arg("cluster") + .arg(command) + .arg("--config") + .arg(temp.path()) + .output() + .unwrap(); + assert!(output.status.success(), "cluster {command} failed"); + } + fs::write(temp.path().join("cluster.yaml"), "version: 1\ngraphs: {}\n").unwrap(); + + let output = cli() + .current_dir(temp.path()) + .arg("cluster") + .arg("approve") + .arg("graph.knowledge") + .arg("--config") + .arg(temp.path()) + .arg("--json") + .output() + .unwrap(); + assert!(output.status.success(), "{output:?}"); + let json: serde_json::Value = + serde_json::from_str(String::from_utf8_lossy(&output.stdout).trim()).unwrap(); + assert_eq!(json["approved_by"], "act-local"); + + // With neither flag nor config: refused with the actionable message. + let bare = tempdir().unwrap(); + write_cluster_config_fixture(bare.path()); + let output = output_failure( + cli() + .current_dir(bare.path()) + .arg("cluster") + .arg("approve") + .arg("graph.knowledge") + .arg("--config") + .arg(bare.path()), + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("--as"), "{stderr}"); + assert!(stderr.contains("cli.actor"), "{stderr}"); +} + +#[test] +fn cluster_commands_ignore_malformed_local_config() { + let temp = tempdir().unwrap(); + write_cluster_config_fixture(temp.path()); + fs::write(temp.path().join("omnigraph.yaml"), "{{{{ not yaml").unwrap(); + + for command in ["validate", "plan", "status"] { + let output = cli() + .current_dir(temp.path()) + .arg("cluster") + .arg(command) + .arg("--config") + .arg(temp.path()) + .arg("--json") + .output() + .unwrap(); + assert!( + output.status.success() || command == "plan", // plan warns state-missing pre-import; still must not config-error + "cluster {command} affected by malformed omnigraph.yaml: {output:?}" + ); + assert!( + !String::from_utf8_lossy(&output.stderr).contains("omnigraph.yaml"), + "cluster {command} touched omnigraph.yaml" + ); + } + // import + apply with an explicit --as: the config is never loaded. + for (command, args) in [("import", vec![]), ("apply", vec!["--as", "andrew"])] { + let mut invocation = cli(); + invocation.current_dir(temp.path()); + for arg in &args { + invocation.arg(arg); + } + let output = invocation + .arg("cluster") + .arg(command) + .arg("--config") + .arg(temp.path()) + .output() + .unwrap(); + assert!( + output.status.success(), + "cluster {command} affected by malformed omnigraph.yaml: {}", + String::from_utf8_lossy(&output.stderr) + ); + } + // Only the no-flag actor lookup is allowed to fail, and loudly. + let output = output_failure( + cli() + .current_dir(temp.path()) + .arg("cluster") + .arg("apply") + .arg("--config") + .arg(temp.path()), + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("omnigraph.yaml") && stderr.contains("--as"), + "the actor-default config read must fail loudly and actionably: {stderr}" + ); +} + +#[test] +fn cluster_commands_ignore_conflicting_local_config() { + let baseline = tempdir().unwrap(); + write_cluster_config_fixture(baseline.path()); + let with_config = tempdir().unwrap(); + write_cluster_config_fixture(with_config.path()); + fs::write( + with_config.path().join("omnigraph.yaml"), + r#" +server: + bind: 0.0.0.0:9999 +graphs: + phantom: + uri: ./phantom.omni +"#, + ) + .unwrap(); + + let validate = |dir: &std::path::Path| { + let output = cli() + .current_dir(dir) + .arg("cluster") + .arg("validate") + .arg("--config") + .arg(dir) + .arg("--json") + .output() + .unwrap(); + assert!(output.status.success(), "{output:?}"); + serde_json::from_str::(String::from_utf8_lossy(&output.stdout).trim()) + .unwrap() + }; + let (a, b) = (validate(baseline.path()), validate(with_config.path())); + // Compare the path-free invariants (paths embed each tempdir). + for key in ["ok", "diagnostics", "resource_digests", "dependencies"] { + assert_eq!(a[key], b[key], "conflicting omnigraph.yaml leaked into cluster validate ({key})"); + } + let leaked = b.to_string(); + assert!(!leaked.contains("phantom") && !leaked.contains("9999"), "{leaked}"); +} diff --git a/crates/omnigraph-cli/tests/cli_cluster_e2e.rs b/crates/omnigraph-cli/tests/cli_cluster_e2e.rs new file mode 100644 index 0000000..36b476a --- /dev/null +++ b/crates/omnigraph-cli/tests/cli_cluster_e2e.rs @@ -0,0 +1,621 @@ +//! Cluster lifecycle compositions over the spawned binary (recovery, drift, convergence). +//! Moved verbatim from tests/cli.rs in the modularization. + +use std::fs; + +use tempfile::tempdir; + +mod support; + +use support::*; + + +#[test] +fn cluster_e2e_lifecycle_import_apply_status_refresh_converges() { + let temp = tempdir().unwrap(); + write_cluster_config_fixture(temp.path()); + init_cluster_derived_graph(temp.path()); + + let import = cluster_json(temp.path(), "import"); + assert_eq!(import["ok"], true, "{import}"); + assert_eq!(import["state_observations"]["state_revision"], 1); + + let plan = cluster_json(temp.path(), "plan"); + let changes = plan["changes"].as_array().unwrap(); + assert_eq!(changes.len(), 3, "{plan}"); + let disposition_of = |resource: &str| { + changes + .iter() + .find(|change| change["resource"] == resource) + .unwrap_or_else(|| panic!("missing change for {resource}: {plan}"))["disposition"] + .clone() + }; + assert_eq!(disposition_of("graph.knowledge"), "derived"); + assert_eq!(disposition_of("query.knowledge.find_person"), "applied"); + assert_eq!(disposition_of("policy.base"), "applied"); + + let apply = cluster_json(temp.path(), "apply"); + assert_eq!(apply["ok"], true, "{apply}"); + assert_eq!(apply["applied_count"], 2, "{apply}"); + assert_eq!(apply["converged"], true, "{apply}"); + + let status = cluster_json(temp.path(), "status"); + assert_eq!( + status["resource_statuses"]["query.knowledge.find_person"]["status"], + "applied" + ); + assert_eq!(status["resource_statuses"]["policy.base"]["status"], "applied"); + assert!( + status["state_observations"]["applied_config_digest"].is_string(), + "converged apply must record the applied config digest: {status}" + ); + + // Refresh re-observes the live graph; it must not undo apply's work. + let refresh = cluster_json(temp.path(), "refresh"); + assert_eq!(refresh["ok"], true, "{refresh}"); + let replan = cluster_json(temp.path(), "plan"); + assert!( + replan["changes"].as_array().unwrap().is_empty(), + "refresh after a converged apply must not re-open the plan: {replan}" + ); + + // A query edit round-trips: plan update -> apply -> converged again. + fs::write( + temp.path().join("people.gq"), + r#" +query find_person($name: String) { + match { $p: Person { name: $name } } + return { $p.name } +} +"#, + ) + .unwrap(); + let apply_edit = cluster_json(temp.path(), "apply"); + assert_eq!(apply_edit["applied_count"], 1, "{apply_edit}"); + assert_eq!(apply_edit["converged"], true, "{apply_edit}"); + + let final_apply = cluster_json(temp.path(), "apply"); + assert_eq!(final_apply["state_written"], false, "{final_apply}"); + assert!(final_apply["changes"].as_array().unwrap().is_empty()); +} + +#[test] +fn cluster_e2e_schema_change_applied_by_cluster() { + let temp = tempdir().unwrap(); + write_cluster_config_fixture(temp.path()); + init_cluster_derived_graph(temp.path()); + let import = cluster_json(temp.path(), "import"); + assert_eq!(import["ok"], true, "{import}"); + let apply = cluster_json(temp.path(), "apply"); + assert_eq!(apply["converged"], true, "{apply}"); + + // Additive schema change: Stage 4B applies it from the cluster — no + // manual schema apply, no refresh round-trip. + fs::write( + temp.path().join("people.pg"), + r#" +node Person { + name: String @key + age: I32? + bio: String? +} +"#, + ) + .unwrap(); + + // Plan previews the real migration steps (RFC-004 §D7). + let plan = cluster_json(temp.path(), "plan"); + let schema_change = change_for(&plan, "schema.knowledge"); + assert_eq!(schema_change["disposition"], "applied", "{plan}"); + let migration = &schema_change["migration"]; + assert_eq!(migration["supported"], true, "{plan}"); + assert!( + migration["steps"] + .as_array() + .unwrap() + .iter() + .any(|step| step["kind"] == "add_property"), + "{plan}" + ); + + let evolve = cluster_json(temp.path(), "apply"); + assert_eq!(evolve["ok"], true, "{evolve}"); + assert_eq!(evolve["converged"], true, "{evolve}"); + assert_eq!(change_for(&evolve, "schema.knowledge")["disposition"], "applied"); + + // The live graph carries the new schema; the plan is empty. + let schema_show = output_success( + cli() + .arg("schema") + .arg("show") + .arg(temp.path().join("graphs/knowledge.omni")), + ); + assert!(stdout_string(&schema_show).contains("bio"), "live schema updated"); + let replan = cluster_json(temp.path(), "plan"); + assert!( + replan["changes"].as_array().unwrap().is_empty(), + "one cluster apply converges a schema change: {replan}" + ); +} + +#[test] +fn cluster_e2e_force_unlock_unblocks_apply() { + let temp = tempdir().unwrap(); + write_cluster_config_fixture(temp.path()); + write_cluster_applyable_state(temp.path()); + write_cluster_lock(temp.path(), "stuck-lock", "apply"); + + let refused = parse_stdout_json(&output_failure( + cli() + .arg("cluster") + .arg("apply") + .arg("--config") + .arg(temp.path()) + .arg("--json"), + )); + assert_eq!(refused["ok"], false); + + let unlocked = parse_stdout_json(&output_success( + cli() + .arg("cluster") + .arg("force-unlock") + .arg("stuck-lock") + .arg("--config") + .arg(temp.path()) + .arg("--json"), + )); + assert_eq!(unlocked["lock_removed"], true, "{unlocked}"); + + let retried = cluster_json(temp.path(), "apply"); + assert_eq!(retried["ok"], true, "{retried}"); + assert_eq!(retried["converged"], true, "{retried}"); +} + +#[test] +fn cluster_e2e_lost_state_reimport_recovers_catalog() { + let temp = tempdir().unwrap(); + write_cluster_config_fixture(temp.path()); + init_cluster_derived_graph(temp.path()); + let import = cluster_json(temp.path(), "import"); + assert_eq!(import["ok"], true, "{import}"); + let apply = cluster_json(temp.path(), "apply"); + assert_eq!(apply["converged"], true, "{apply}"); + + let query_digest = change_for(&apply, "query.knowledge.find_person")["after_digest"] + .as_str() + .unwrap() + .to_string(); + let blob = temp + .path() + .join("__cluster/resources/query/knowledge/find_person") + .join(format!("{query_digest}.gq")); + let blob_content = fs::read_to_string(&blob).unwrap(); + + // Disaster: the state ledger is lost. + fs::remove_file(temp.path().join("__cluster/state.json")).unwrap(); + + let reimport = cluster_json(temp.path(), "import"); + assert_eq!(reimport["ok"], true, "{reimport}"); + assert_eq!(reimport["state_observations"]["state_revision"], 1); + // Import observes graph/schema only; query/policy digests are not invented. + assert!( + reimport["resource_digests"] + .get("query.knowledge.find_person") + .is_none(), + "{reimport}" + ); + + let plan = cluster_json(temp.path(), "plan"); + assert_eq!( + change_for(&plan, "query.knowledge.find_person")["disposition"], + "applied" + ); + assert_eq!(change_for(&plan, "policy.base")["disposition"], "applied"); + + let reapply = cluster_json(temp.path(), "apply"); + assert_eq!(reapply["ok"], true, "{reapply}"); + assert_eq!(reapply["converged"], true, "{reapply}"); + assert!( + reapply["state_observations"]["applied_config_digest"].is_string(), + "{reapply}" + ); + // The catalog blob was reused, not rewritten with different content. + assert_eq!(fs::read_to_string(&blob).unwrap(), blob_content); + + let replan = cluster_json(temp.path(), "plan"); + assert!(replan["changes"].as_array().unwrap().is_empty(), "{replan}"); +} + +#[test] +fn cluster_e2e_out_of_band_schema_drift_then_apply_converges_it() { + let temp = tempdir().unwrap(); + write_cluster_config_fixture(temp.path()); + init_cluster_derived_graph(temp.path()); + let import = cluster_json(temp.path(), "import"); + assert_eq!(import["ok"], true, "{import}"); + let apply = cluster_json(temp.path(), "apply"); + assert_eq!(apply["converged"], true, "{apply}"); + + // Out-of-band: the live graph evolves, cluster.yaml stays put. + fs::write( + temp.path().join("people_v2.pg"), + r#" +node Person { + name: String @key + age: I32? + bio: String? +} +"#, + ) + .unwrap(); + output_success( + cli() + .arg("schema") + .arg("apply") + .arg(temp.path().join("graphs/knowledge.omni")) + .arg("--schema") + .arg(temp.path().join("people_v2.pg")) + .arg("--json"), + ); + + // Drift is visible... + let refresh = cluster_json(temp.path(), "refresh"); + assert_eq!( + refresh["resource_statuses"]["schema.knowledge"]["status"], + "drifted" + ); + // ...the plan proposes converging back to desired, with a migration + // preview (a soft drop of the out-of-band field)... + let plan = cluster_json(temp.path(), "plan"); + let schema_change = change_for(&plan, "schema.knowledge"); + assert_eq!(schema_change["disposition"], "applied", "{plan}"); + assert!( + schema_change["migration"]["steps"] + .as_array() + .unwrap() + .iter() + .any(|step| step["kind"] == "drop_property" && step["mode"] == "soft"), + "{plan}" + ); + // ...and apply converges the live schema back (axiom 8: drift correction + // is gated like any change; a soft migration is the recoverable tier). + let converge = cluster_json(temp.path(), "apply"); + assert_eq!(converge["ok"], true, "{converge}"); + assert_eq!(converge["converged"], true, "{converge}"); + let schema_show = output_success( + cli() + .arg("schema") + .arg("show") + .arg(temp.path().join("graphs/knowledge.omni")), + ); + assert!( + !stdout_string(&schema_show).contains("bio"), + "out-of-band field soft-dropped back to desired" + ); + let replan = cluster_json(temp.path(), "plan"); + assert!(replan["changes"].as_array().unwrap().is_empty(), "{replan}"); +} + +#[test] +fn cluster_e2e_graph_root_destruction_drifts_then_apply_recreates_empty_graph() { + let temp = tempdir().unwrap(); + write_cluster_config_fixture(temp.path()); + init_cluster_derived_graph(temp.path()); + let import = cluster_json(temp.path(), "import"); + assert_eq!(import["ok"], true, "{import}"); + let apply = cluster_json(temp.path(), "apply"); + assert_eq!(apply["converged"], true, "{apply}"); + let query_digest = change_for(&apply, "query.knowledge.find_person")["after_digest"] + .as_str() + .unwrap() + .to_string(); + + fs::remove_dir_all(temp.path().join("graphs/knowledge.omni")).unwrap(); + + // Missing root is drift, not an error. + let refresh = cluster_json(temp.path(), "refresh"); + assert_eq!(refresh["ok"], true, "{refresh}"); + assert_eq!( + refresh["resource_statuses"]["graph.knowledge"]["status"], + "drifted" + ); + assert!( + refresh["resource_statuses"]["graph.knowledge"]["conditions"] + .as_array() + .unwrap() + .iter() + .any(|condition| condition == "graph_missing"), + "{refresh}" + ); + // Graph/schema digests removed; query/policy digests preserved. + assert!(refresh["resource_digests"].get("graph.knowledge").is_none()); + assert!(refresh["resource_digests"].get("schema.knowledge").is_none()); + assert!( + refresh["resource_digests"] + .get("query.knowledge.find_person") + .is_some(), + "{refresh}" + ); + + let plan = cluster_json(temp.path(), "plan"); + assert_eq!(change_for(&plan, "graph.knowledge")["operation"], "create"); + // Stage 4A: the re-create is executable and the plan says so — nothing + // hidden about converging a destroyed root back to an EMPTY graph (the + // data was already lost; this is declarative convergence, RFC-004 §D1). + assert_eq!(change_for(&plan, "graph.knowledge")["disposition"], "applied"); + assert_eq!(change_for(&plan, "schema.knowledge")["disposition"], "applied"); + // Converged-then-destroyed: query/policy are already in state at the + // desired digests, so they are not changes at all. + assert_eq!(plan["changes"].as_array().unwrap().len(), 2, "{plan}"); + + let recreate = cluster_json(temp.path(), "apply"); + assert_eq!(recreate["ok"], true, "{recreate}"); + assert_eq!(recreate["converged"], true, "{recreate}"); + // The empty graph is back on disk; catalog state survived throughout. + assert!(temp.path().join("graphs/knowledge.omni").exists()); + let state: serde_json::Value = serde_json::from_str( + &fs::read_to_string(temp.path().join("__cluster/state.json")).unwrap(), + ) + .unwrap(); + assert_eq!( + state["applied_revision"]["resources"]["query.knowledge.find_person"]["digest"], + query_digest + ); + assert!( + temp.path() + .join("__cluster/resources/query/knowledge/find_person") + .join(format!("{query_digest}.gq")) + .exists() + ); +} + +#[test] +fn cluster_e2e_multi_graph_mixed_dispositions_then_approve_and_converge() { + let temp = tempdir().unwrap(); + write_multi_graph_cluster_fixture(temp.path()); + // No manual init: Stage 4A creates both graphs. + + let import = cluster_json(temp.path(), "import"); + assert_eq!(import["ok"], true, "{import}"); + + let apply = cluster_json(temp.path(), "apply"); + assert_eq!(apply["ok"], true, "{apply}"); + assert_eq!(apply["converged"], true, "{apply}"); + assert_eq!(change_for(&apply, "graph.knowledge")["disposition"], "applied"); + assert_eq!( + change_for(&apply, "graph.engineering")["disposition"], + "applied" + ); + assert_eq!( + change_for(&apply, "query.engineering.find_service")["disposition"], + "applied" + ); + // The graph-spanning and cluster-scoped policies ride the same run. + assert_eq!(change_for(&apply, "policy.shared")["disposition"], "applied"); + assert_eq!( + change_for(&apply, "policy.cluster_wide")["disposition"], + "applied" + ); + assert!(temp.path().join("graphs/knowledge.omni").exists()); + assert!(temp.path().join("graphs/engineering.omni").exists()); + + // Mixed run: a graph REMOVAL (4C territory — deferred) gates its query + // delete (blocked), while a knowledge query update is independent + // (applied) and re-derives its composite. All four dispositions at once. + fs::write( + temp.path().join("cluster.yaml"), + r#" +version: 1 +metadata: + name: company-brain +state: + backend: cluster + lock: true +graphs: + knowledge: + schema: ./people.pg + queries: + find_person: + file: ./people.gq +policies: + shared: + file: ./shared.policy.yaml + applies_to: [knowledge] + cluster_wide: + file: ./cluster_wide.policy.yaml + applies_to: [cluster] +"#, + ) + .unwrap(); + fs::write( + temp.path().join("people.gq"), + "\nquery find_person($name: String) {\n match { $p: Person { name: $name } }\n return { $p.name }\n}\n", + ) + .unwrap(); + + let mixed = cluster_json(temp.path(), "apply"); + assert_eq!(mixed["ok"], true, "{mixed}"); + assert_eq!(mixed["converged"], false, "{mixed}"); + // Stage 4C: deletes are gated on a digest-bound approval, one gate per + // subtree (the graph-level approval carries schema + queries). + assert_eq!( + change_for(&mixed, "graph.engineering")["disposition"], + "blocked" + ); + assert_eq!( + change_for(&mixed, "graph.engineering")["reason"], + "approval_required" + ); + assert_eq!( + change_for(&mixed, "schema.engineering")["reason"], + "approval_required" + ); + assert_eq!( + change_for(&mixed, "query.engineering.find_service")["reason"], + "approval_required" + ); + let gate_plan = cluster_json(temp.path(), "plan"); + let gates = gate_plan["approvals_required"].as_array().unwrap(); + assert_eq!(gates.len(), 1, "{gate_plan}"); + assert_eq!(gates[0]["resource"], "graph.engineering"); + assert_eq!(gates[0]["satisfied"], false); + assert_eq!( + change_for(&mixed, "query.knowledge.find_person")["disposition"], + "applied" + ); + // 5A: policy.shared's applies_to narrowed with an unchanged file digest + // — now a first-class binding change, applied in the same run. + assert_eq!(change_for(&mixed, "policy.shared")["binding_change"], true); + assert_eq!(change_for(&mixed, "policy.shared")["disposition"], "applied"); + assert_eq!( + change_for(&mixed, "graph.knowledge")["disposition"], + "derived" + ); + // Deterministic ordering: changes sorted by resource address. + let order: Vec<&str> = mixed["changes"] + .as_array() + .unwrap() + .iter() + .map(|change| change["resource"].as_str().unwrap()) + .collect(); + let mut sorted = order.clone(); + sorted.sort_unstable(); + assert_eq!(order, sorted, "{mixed}"); + // The conclusion: an apply without approval stays blocked; the approved + // delete converges the cluster, tombstoning the removed graph. + let still_blocked = cluster_json(temp.path(), "apply"); + assert_eq!(still_blocked["converged"], false, "{still_blocked}"); + + let approve = parse_stdout_json(&output_success( + cli() + .arg("--as") + .arg("andrew") + .arg("cluster") + .arg("approve") + .arg("graph.engineering") + .arg("--config") + .arg(temp.path()) + .arg("--json"), + )); + assert_eq!(approve["ok"], true, "{approve}"); + assert_eq!(approve["approved_by"], "andrew"); + + let converge = cluster_json(temp.path(), "apply"); + assert_eq!(converge["ok"], true, "{converge}"); + assert_eq!(converge["converged"], true, "{converge}"); + assert!(!temp.path().join("graphs/engineering.omni").exists()); + + let status = cluster_json(temp.path(), "status"); + assert_eq!(status["observations"]["graph.engineering"]["kind"], "tombstone"); + let final_plan = cluster_json(temp.path(), "plan"); + assert!( + final_plan["changes"].as_array().unwrap().is_empty(), + "{final_plan}" + ); +} + +#[test] +fn cluster_e2e_approve_requires_actor() { + let temp = tempdir().unwrap(); + write_cluster_config_fixture(temp.path()); + + let output = output_failure( + cli() + .arg("cluster") + .arg("approve") + .arg("graph.knowledge") + .arg("--config") + .arg(temp.path()), + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("--as"), "{stderr}"); +} + +#[test] +fn cluster_e2e_declared_graph_created_by_apply() { + let temp = tempdir().unwrap(); + write_cluster_config_fixture(temp.path()); + + let import = cluster_json(temp.path(), "import"); + assert_eq!(import["ok"], true, "{import}"); + + let apply = cluster_json(temp.path(), "apply"); + assert_eq!(apply["ok"], true, "{apply}"); + assert_eq!(apply["converged"], true, "{apply}"); + assert_eq!(change_for(&apply, "graph.knowledge")["disposition"], "applied"); + assert!(temp.path().join("graphs/knowledge.omni").exists()); + + // The created graph is a real graph: the per-graph CLI can open it. + let snapshot = output_success( + cli() + .arg("snapshot") + .arg(temp.path().join("graphs/knowledge.omni")), + ); + assert!(!stdout_string(&snapshot).is_empty()); + + let plan = cluster_json(temp.path(), "plan"); + assert!(plan["changes"].as_array().unwrap().is_empty(), "{plan}"); + let status = cluster_json(temp.path(), "status"); + assert_eq!( + status["resource_statuses"]["graph.knowledge"]["status"], + "applied" + ); +} + +#[test] +fn cluster_e2e_payload_drift_self_heals() { + let temp = tempdir().unwrap(); + write_cluster_config_fixture(temp.path()); + init_cluster_derived_graph(temp.path()); + let import = cluster_json(temp.path(), "import"); + assert_eq!(import["ok"], true, "{import}"); + let apply = cluster_json(temp.path(), "apply"); + assert_eq!(apply["converged"], true, "{apply}"); + + let query_digest = change_for(&apply, "query.knowledge.find_person")["after_digest"] + .as_str() + .unwrap() + .to_string(); + let blob = temp + .path() + .join("__cluster/resources/query/knowledge/find_person") + .join(format!("{query_digest}.gq")); + fs::remove_file(&blob).unwrap(); + + let status = cluster_json(temp.path(), "status"); + assert_eq!(status["ok"], true, "{status}"); + assert!( + status["diagnostics"] + .as_array() + .unwrap() + .iter() + .any(|diagnostic| diagnostic["code"] == "catalog_payload_missing"), + "{status}" + ); + + let refresh = cluster_json(temp.path(), "refresh"); + assert_eq!(refresh["ok"], true, "{refresh}"); + assert_eq!( + refresh["resource_statuses"]["query.knowledge.find_person"]["status"], + "drifted" + ); + + let heal = cluster_json(temp.path(), "apply"); + assert_eq!(heal["ok"], true, "{heal}"); + assert_eq!(heal["converged"], true, "{heal}"); + assert!(blob.exists(), "blob republished"); + + let clean = cluster_json(temp.path(), "status"); + assert!( + !clean["diagnostics"] + .as_array() + .unwrap() + .iter() + .any(|diagnostic| { + diagnostic["code"] + .as_str() + .is_some_and(|code| code.starts_with("catalog_payload")) + }), + "{clean}" + ); +} diff --git a/crates/omnigraph-cli/tests/cli_data.rs b/crates/omnigraph-cli/tests/cli_data.rs new file mode 100644 index 0000000..841bedf --- /dev/null +++ b/crates/omnigraph-cli/tests/cli_data.rs @@ -0,0 +1,1631 @@ +//! Data commands: load/read/change/branch/commit/export/snapshot/policy/embed/maintenance. +//! Moved verbatim from tests/cli.rs in the modularization. + +use std::fs; + +use serde_json::Value; +use tempfile::tempdir; + +mod support; + +use support::*; + + +#[test] +fn short_version_flag_prints_current_cli_version() { + let output = output_success(cli().arg("-v")); + let stdout = stdout_string(&output); + + assert_eq!( + stdout.trim(), + format!("omnigraph {}", env!("CARGO_PKG_VERSION")) + ); +} + +#[test] +fn long_version_flag_prints_current_cli_version() { + let output = output_success(cli().arg("--version")); + let stdout = stdout_string(&output); + + assert_eq!( + stdout.trim(), + format!("omnigraph {}", env!("CARGO_PKG_VERSION")) + ); +} + +#[test] +fn embed_seed_fills_missing_and_preserves_existing_vectors_by_default() { + let temp = tempdir().unwrap(); + let seed = write_seed_fixture(temp.path()); + + let output = output_success( + cli() + .env("OMNIGRAPH_EMBEDDINGS_MOCK", "1") + .arg("embed") + .arg("--seed") + .arg(&seed) + .arg("--json"), + ); + let payload: Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(payload["mode"], "fill_missing"); + assert_eq!(payload["embedded_rows"], 1); + assert_eq!(payload["selected_rows"], 2); + + let embedded = read_embedded_rows(temp.path().join("build/seed.embedded.jsonl")); + assert_eq!( + embedded[0]["data"]["embedding"].as_array().unwrap().len(), + 4 + ); + assert_eq!( + embedded[1]["data"]["embedding"], + serde_json::json!([0.1, 0.2]) + ); +} + +#[test] +fn embed_clean_removes_selected_embeddings() { + let temp = tempdir().unwrap(); + let seed = write_seed_fixture(temp.path()); + + let output = output_success( + cli() + .arg("embed") + .arg("--seed") + .arg(&seed) + .arg("--clean") + .arg("--select") + .arg("Decision:slug=dec-beta") + .arg("--json"), + ); + let payload: Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(payload["mode"], "clean"); + assert_eq!(payload["cleaned_rows"], 1); + + let embedded = read_embedded_rows(temp.path().join("build/seed.embedded.jsonl")); + assert!(embedded[0]["data"].get("embedding").is_none()); + assert!(embedded[1]["data"].get("embedding").is_none()); +} + +#[test] +fn embed_select_reembeds_only_matching_rows() { + let temp = tempdir().unwrap(); + let seed = write_seed_fixture(temp.path()); + + let output = output_success( + cli() + .env("OMNIGRAPH_EMBEDDINGS_MOCK", "1") + .arg("embed") + .arg("--seed") + .arg(&seed) + .arg("--select") + .arg("Decision:slug=dec-beta") + .arg("--json"), + ); + let payload: Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(payload["mode"], "reembed_selected"); + assert_eq!(payload["embedded_rows"], 1); + assert_eq!(payload["selected_rows"], 1); + + let embedded = read_embedded_rows(temp.path().join("build/seed.embedded.jsonl")); + assert!(embedded[0]["data"].get("embedding").is_none()); + assert_ne!( + embedded[1]["data"]["embedding"], + serde_json::json!([0.1, 0.2]) + ); + assert_eq!( + embedded[1]["data"]["embedding"].as_array().unwrap().len(), + 4 + ); +} + +#[test] +fn embed_seed_preserves_non_entity_rows() { + let temp = tempdir().unwrap(); + let seed = write_seed_fixture_with_edge(temp.path()); + + let output = output_success( + cli() + .env("OMNIGRAPH_EMBEDDINGS_MOCK", "1") + .arg("embed") + .arg("--seed") + .arg(&seed) + .arg("--json"), + ); + let payload: Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(payload["rows"], 3); + assert_eq!(payload["embedded_rows"], 1); + + let embedded = read_embedded_rows(temp.path().join("build/seed.embedded.jsonl")); + assert_eq!(embedded.len(), 3); + assert_eq!(embedded[2]["edge"], "Triggered"); + assert_eq!(embedded[2]["from"], "sig-alpha"); + assert_eq!(embedded[2]["to"], "dec-alpha"); +} + +#[test] +fn repair_json_reports_noop_on_clean_graph() { + let temp = tempdir().unwrap(); + let graph = graph_path(temp.path()); + init_graph(&graph); + load_fixture(&graph); + + let output = output_success(cli().arg("repair").arg("--json").arg(&graph)); + let payload: Value = serde_json::from_slice(&output.stdout).unwrap(); + + assert_eq!(payload["confirm"], false); + assert_eq!(payload["force"], false); + assert_eq!(payload["manifest_version"], Value::Null); + let tables = payload["tables"].as_array().unwrap(); + assert_eq!(tables.len(), 4); + assert!(tables.iter().all(|table| { + table["classification"] == "no_drift" && table["action"] == "no_op" + })); +} + +#[test] +fn repair_confirm_json_refuses_suspicious_drift_with_nonzero_exit_then_force_succeeds() { + let temp = tempdir().unwrap(); + let graph = graph_path(temp.path()); + init_graph(&graph); + load_fixture(&graph); + let graph_manifest_before = manifest_dataset_version(&graph); + let (table_manifest_before, table_head_before) = forge_person_delete_drift(&graph); + + let refused = output_failure( + cli() + .arg("repair") + .arg("--confirm") + .arg("--json") + .arg(&graph), + ); + let refused_payload: Value = serde_json::from_slice(&refused.stdout).unwrap(); + assert_eq!(refused_payload["manifest_version"], Value::Null); + let person = refused_payload["tables"] + .as_array() + .unwrap() + .iter() + .find(|table| table["table_key"] == "node:Person") + .unwrap(); + assert_eq!(person["classification"], "suspicious"); + assert_eq!(person["action"], "refused"); + assert!( + String::from_utf8_lossy(&refused.stderr).contains("repair refused"), + "stderr should explain the non-zero exit; got: {}", + String::from_utf8_lossy(&refused.stderr) + ); + assert_eq!(manifest_dataset_version(&graph), graph_manifest_before); + + let forced = output_success( + cli() + .arg("repair") + .arg("--force") + .arg("--confirm") + .arg("--json") + .arg(&graph), + ); + let forced_payload: Value = serde_json::from_slice(&forced.stdout).unwrap(); + let forced_manifest = forced_payload["manifest_version"].as_u64().unwrap(); + assert!(forced_manifest > graph_manifest_before); + let person = forced_payload["tables"] + .as_array() + .unwrap() + .iter() + .find(|table| table["table_key"] == "node:Person") + .unwrap(); + assert_eq!(person["classification"], "suspicious"); + assert_eq!(person["action"], "forced"); + assert_eq!(person["manifest_version"], table_manifest_before); + assert_eq!(person["lance_head_version"], table_head_before); + assert_eq!(manifest_dataset_version(&graph), forced_manifest); +} + +#[test] +fn query_lint_json_with_schema_reports_warnings() { + let temp = tempdir().unwrap(); + let schema_path = temp.path().join("schema.pg"); + let query_path = temp.path().join("queries.gq"); + write_file( + &schema_path, + r#" +node Policy { + slug: String @key + name: String? + effectiveTo: DateTime? +} +"#, + ); + write_query_file( + &query_path, + r#" +query update_policy($slug: String, $name: String) { + update Policy set { name: $name } where slug = $slug +} +"#, + ); + + let output = output_success( + cli() + .arg("query") + .arg("lint") + .arg("--query") + .arg(&query_path) + .arg("--schema") + .arg(&schema_path) + .arg("--json"), + ); + let payload: Value = serde_json::from_slice(&output.stdout).unwrap(); + + assert_eq!(payload["status"], "ok"); + assert_eq!(payload["schema_source"]["kind"], "file"); + assert_eq!(payload["queries_processed"], 1); + assert_eq!(payload["warnings"], 1); + assert_eq!(payload["findings"][0]["code"], "L201"); + assert_eq!( + payload["findings"][0]["message"], + "Policy.effectiveTo exists in schema but no update query sets it" + ); +} + +#[test] +fn lint_top_level_matches_deprecated_query_lint_output() { + let temp = tempdir().unwrap(); + let schema_path = temp.path().join("schema.pg"); + let query_path = temp.path().join("queries.gq"); + write_file( + &schema_path, + r#" +node Person { + name: String +} +"#, + ); + write_query_file( + &query_path, + r#" +query list_people() { + match { $p: Person } + return { $p.name } +} +"#, + ); + + let canonical = output_success( + cli() + .arg("lint") + .arg("--query") + .arg(&query_path) + .arg("--schema") + .arg(&schema_path) + .arg("--json"), + ); + let deprecated_lint = output_success( + cli() + .arg("query") + .arg("lint") + .arg("--query") + .arg(&query_path) + .arg("--schema") + .arg(&schema_path) + .arg("--json"), + ); + let deprecated_check = output_success( + cli() + .arg("query") + .arg("check") + .arg("--query") + .arg(&query_path) + .arg("--schema") + .arg(&schema_path) + .arg("--json"), + ); + + assert_eq!(stdout_string(&canonical), stdout_string(&deprecated_lint)); + assert_eq!(stdout_string(&canonical), stdout_string(&deprecated_check)); + + // Canonical form must NOT emit the deprecation warning. + let canonical_stderr = String::from_utf8(canonical.stderr).unwrap(); + assert!( + !canonical_stderr.contains("deprecated"), + "`omnigraph lint` is canonical and must not warn; got stderr: {canonical_stderr}" + ); + + // Deprecated forms MUST emit the one-line warning, pointing at the + // new top-level `omnigraph lint`. + let lint_stderr = String::from_utf8(deprecated_lint.stderr).unwrap(); + assert!( + lint_stderr.contains("`omnigraph query lint` is deprecated") + && lint_stderr.contains("`omnigraph lint`"), + "expected deprecation warning pointing at `omnigraph lint`; got: {lint_stderr}" + ); + let check_stderr = String::from_utf8(deprecated_check.stderr).unwrap(); + assert!( + check_stderr.contains("`omnigraph query check` is deprecated") + && check_stderr.contains("`omnigraph lint`"), + "expected deprecation warning pointing at `omnigraph lint`; got: {check_stderr}" + ); +} + +#[test] +fn deprecated_check_top_level_rewrites_to_lint() { + let temp = tempdir().unwrap(); + let schema_path = temp.path().join("schema.pg"); + let query_path = temp.path().join("queries.gq"); + write_file( + &schema_path, + r#" +node Person { + name: String +} +"#, + ); + write_query_file( + &query_path, + r#" +query list_people() { + match { $p: Person } + return { $p.name } +} +"#, + ); + + let canonical = output_success( + cli() + .arg("lint") + .arg("--query") + .arg(&query_path) + .arg("--schema") + .arg(&schema_path) + .arg("--json"), + ); + let deprecated_check = output_success( + cli() + .arg("check") + .arg("--query") + .arg(&query_path) + .arg("--schema") + .arg(&schema_path) + .arg("--json"), + ); + + assert_eq!(stdout_string(&canonical), stdout_string(&deprecated_check)); + + let check_stderr = String::from_utf8(deprecated_check.stderr).unwrap(); + assert!( + check_stderr.contains("`omnigraph check` is deprecated") + && check_stderr.contains("`omnigraph lint`"), + "expected `omnigraph check` deprecation warning pointing at `omnigraph lint`; got: {check_stderr}" + ); + + // `check` must NOT appear in the canonical `omnigraph --help` output — + // agents copy the surface from help text and would otherwise emit both + // names interchangeably. + let help = cli().arg("--help").output().unwrap(); + let stdout = String::from_utf8(help.stdout).unwrap(); + let check_aliased = stdout + .lines() + .any(|line| line.trim_start().starts_with("lint") && line.contains("check")); + assert!( + !check_aliased, + "`check` must not be advertised as a visible alias of `lint`; help output: {stdout}" + ); +} + +#[test] +fn deprecated_read_and_change_subcommands_emit_warnings() { + // Both subcommands require `--query`/`--query-string`/`--alias`, so + // invoking them with no args will exit non-zero. That's fine -- + // we only care that the deprecation warning is printed before the + // argument-required error. + let output = cli().arg("read").output().unwrap(); + let stderr = String::from_utf8(output.stderr).unwrap(); + assert!( + stderr.contains("`omnigraph read` is deprecated") && stderr.contains("`omnigraph query`"), + "expected `omnigraph read` deprecation warning; got: {stderr}" + ); + + let output = cli().arg("change").output().unwrap(); + let stderr = String::from_utf8(output.stderr).unwrap(); + assert!( + stderr.contains("`omnigraph change` is deprecated") + && stderr.contains("`omnigraph mutate`"), + "expected `omnigraph change` deprecation warning; got: {stderr}" + ); + + // Sanity check the inverse: the canonical names must NOT print the + // deprecation banner. + let output = cli().arg("query").arg("--help").output().unwrap(); + let stderr = String::from_utf8(output.stderr).unwrap(); + assert!( + !stderr.contains("deprecated"), + "`omnigraph query` is canonical and must not warn; got: {stderr}" + ); + let output = cli().arg("mutate").arg("--help").output().unwrap(); + let stderr = String::from_utf8(output.stderr).unwrap(); + assert!( + !stderr.contains("deprecated"), + "`omnigraph mutate` is canonical and must not warn; got: {stderr}" + ); +} + +#[test] +fn query_lint_can_use_local_graph_via_positional_uri() { + let temp = tempdir().unwrap(); + let graph = graph_path(temp.path()); + let query_path = temp.path().join("queries.gq"); + init_graph(&graph); + write_query_file( + &query_path, + r#" +query list_people() { + match { $p: Person } + return { $p.name } +} +"#, + ); + + let output = output_success( + cli() + .arg("query") + .arg("lint") + .arg("--query") + .arg(&query_path) + .arg("--json") + .arg(&graph), + ); + let payload: Value = serde_json::from_slice(&output.stdout).unwrap(); + + assert_eq!(payload["status"], "ok"); + assert_eq!(payload["schema_source"]["kind"], "graph"); + assert_eq!( + payload["schema_source"]["uri"].as_str(), + Some(graph.to_string_lossy().as_ref()) + ); +} + +#[test] +fn query_lint_can_resolve_graph_and_query_from_config() { + let temp = tempdir().unwrap(); + let graph = graph_path(temp.path()); + let config_path = temp.path().join("omnigraph.yaml"); + init_graph(&graph); + write_query_file( + &temp.path().join("queries.gq"), + r#" +query list_people() { + match { $p: Person } + return { $p.name } +} +"#, + ); + write_config(&config_path, &local_yaml_config(&graph)); + + let output = output_success( + cli() + .arg("query") + .arg("lint") + .arg("--query") + .arg("queries.gq") + .arg("--config") + .arg(&config_path) + .arg("--json"), + ); + let payload: Value = serde_json::from_slice(&output.stdout).unwrap(); + + assert_eq!(payload["status"], "ok"); + assert_eq!(payload["schema_source"]["kind"], "graph"); + assert_eq!( + payload["schema_source"]["uri"].as_str(), + Some(graph.to_string_lossy().as_ref()) + ); +} + +#[test] +fn query_lint_rejects_http_targets_without_schema() { + let temp = tempdir().unwrap(); + let query_path = temp.path().join("queries.gq"); + write_query_file( + &query_path, + r#" +query list_people() { + match { $p: Person } + return { $p.name } +} +"#, + ); + + let output = output_failure( + cli() + .arg("query") + .arg("lint") + .arg("--query") + .arg(&query_path) + .arg("http://127.0.0.1:8080"), + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("query lint is only supported against local graph URIs in this milestone") + ); +} + +#[test] +fn query_lint_requires_schema_or_resolvable_graph_target() { + let temp = tempdir().unwrap(); + let query_path = temp.path().join("queries.gq"); + write_query_file( + &query_path, + r#" +query list_people() { + match { $p: Person } + return { $p.name } +} +"#, + ); + + let output = output_failure( + cli() + .arg("query") + .arg("lint") + .arg("--query") + .arg(&query_path), + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("query lint requires --schema or a resolvable graph target") + ); +} + +#[test] +fn query_lint_human_output_reports_warnings() { + let temp = tempdir().unwrap(); + let schema_path = temp.path().join("schema.pg"); + let query_path = temp.path().join("queries.gq"); + write_file( + &schema_path, + r#" +node Policy { + slug: String @key + name: String? + effectiveTo: DateTime? +} +"#, + ); + write_query_file( + &query_path, + r#" +query update_policy($slug: String, $name: String) { + update Policy set { name: $name } where slug = $slug +} +"#, + ); + + let output = output_success( + cli() + .arg("query") + .arg("lint") + .arg("--query") + .arg(&query_path) + .arg("--schema") + .arg(&schema_path), + ); + let stdout = stdout_string(&output); + + assert!(stdout.contains("OK query `update_policy` (mutation)")); + assert!( + stdout.contains("WARN Policy.effectiveTo exists in schema but no update query sets it") + ); + assert!(stdout.contains( + "INFO Lint complete: 1 queries processed (0 error(s), 1 warning(s), 0 info item(s))" + )); +} + +#[test] +fn query_lint_human_output_reports_strict_validation_errors() { + let temp = tempdir().unwrap(); + let schema_path = temp.path().join("schema.pg"); + let query_path = temp.path().join("queries.gq"); + write_file( + &schema_path, + r#" +node Policy { + slug: String @key + name: String? +} +"#, + ); + write_query_file( + &query_path, + r#" +query bad_update($slug: String) { + update Policy set { priority_level: "high" } where slug = $slug +} +"#, + ); + + let output = output_failure( + cli() + .arg("query") + .arg("lint") + .arg("--query") + .arg(&query_path) + .arg("--schema") + .arg(&schema_path), + ); + let stdout = stdout_string(&output); + + assert!(stdout.contains("ERROR query `bad_update`:")); + assert!(stdout.contains("Policy")); + assert!(stdout.contains( + "INFO Lint complete: 1 queries processed (1 error(s), 0 warning(s), 0 info item(s))" + )); +} + +#[test] +fn load_json_outputs_summary_for_main_branch() { + let temp = tempdir().unwrap(); + let graph = graph_path(temp.path()); + init_graph(&graph); + let data = fixture("test.jsonl"); + + let output = output_success( + cli() + .arg("load") + .arg("--mode") + .arg("overwrite") + .arg("--data") + .arg(&data) + .arg("--json") + .arg(&graph), + ); + let payload: Value = serde_json::from_slice(&output.stdout).unwrap(); + + assert_eq!(payload["branch"], "main"); + assert_eq!(payload["mode"], "overwrite"); + assert_eq!(payload["nodes_loaded"], 6); + assert_eq!(payload["edges_loaded"], 5); + assert_eq!(payload["node_types_loaded"], 2); + assert_eq!(payload["edge_types_loaded"], 2); +} + +#[test] +fn load_into_feature_branch_with_merge_mode_succeeds() { + let temp = tempdir().unwrap(); + let graph = graph_path(temp.path()); + init_graph(&graph); + load_fixture(&graph); + + output_success( + cli() + .arg("branch") + .arg("create") + .arg("--uri") + .arg(&graph) + .arg("--from") + .arg("main") + .arg("feature"), + ); + + let feature_data = temp.path().join("feature.jsonl"); + write_jsonl( + &feature_data, + r#"{"type":"Person","data":{"name":"Alice","age":31}}"#, + ); + + let output = output_success( + cli() + .arg("load") + .arg("--data") + .arg(&feature_data) + .arg("--branch") + .arg("feature") + .arg("--mode") + .arg("merge") + .arg(&graph), + ); + let stdout = stdout_string(&output); + + assert!(stdout.contains("branch feature")); + assert!(stdout.contains("with merge")); + assert!(stdout.contains("1 nodes across 1 node types")); +} + +#[test] +fn read_json_outputs_rows_for_named_query() { + let temp = tempdir().unwrap(); + let graph = graph_path(temp.path()); + init_graph(&graph); + load_fixture(&graph); + let queries = fixture("test.gq"); + + let output = output_success( + cli() + .arg("read") + .arg(&graph) + .arg("--query") + .arg(&queries) + .arg("--name") + .arg("get_person") + .arg("--params") + .arg(r#"{"name":"Alice"}"#) + .arg("--json"), + ); + let payload: Value = serde_json::from_slice(&output.stdout).unwrap(); + + assert_eq!(payload["query_name"], "get_person"); + assert_eq!(payload["target"]["branch"], "main"); + assert_eq!(payload["row_count"], 1); + assert_eq!(payload["rows"][0]["p.name"], "Alice"); +} + +#[test] +fn export_jsonl_outputs_source_rows_for_selected_branch_and_type() { + let temp = tempdir().unwrap(); + let graph = graph_path(temp.path()); + init_graph(&graph); + load_fixture(&graph); + + output_success( + cli() + .arg("branch") + .arg("create") + .arg("--uri") + .arg(&graph) + .arg("--from") + .arg("main") + .arg("feature"), + ); + + let feature_data = temp.path().join("feature-export.jsonl"); + write_jsonl( + &feature_data, + r#"{"type":"Person","data":{"name":"Eve","age":29}}"#, + ); + output_success( + cli() + .arg("load") + .arg("--data") + .arg(&feature_data) + .arg("--branch") + .arg("feature") + .arg("--mode") + .arg("append") + .arg(&graph), + ); + + let output = output_success( + cli() + .arg("export") + .arg(&graph) + .arg("--branch") + .arg("feature") + .arg("--type") + .arg("Person") + .arg("--jsonl"), + ); + let rows = stdout_string(&output) + .lines() + .map(|line| serde_json::from_str::(line).unwrap()) + .collect::>(); + + assert_eq!(rows.len(), 5); + assert!(rows.iter().all(|row| row["type"] == "Person")); + assert!(rows.iter().all(|row| row.get("edge").is_none())); + assert!( + rows.iter() + .any(|row| row["data"]["name"].as_str() == Some("Eve")) + ); +} + +#[test] +fn policy_validate_accepts_valid_policy_file() { + let temp = tempdir().unwrap(); + let (config, _) = write_policy_config_fixture(temp.path()); + + let output = output_success( + cli() + .arg("policy") + .arg("validate") + .arg("--config") + .arg(&config), + ); + let stdout = stdout_string(&output); + + assert!(stdout.contains("policy valid:")); + assert!(stdout.contains("policy.yaml")); + assert!(stdout.contains("[2 actors]")); +} + +#[test] +fn policy_validate_fails_for_invalid_policy_file() { + let temp = tempdir().unwrap(); + let config = temp.path().join("omnigraph.yaml"); + let policy = temp.path().join("policy.yaml"); + fs::write( + &config, + r#" +project: + name: policy-test-graph +policy: + file: ./policy.yaml +"#, + ) + .unwrap(); + fs::write( + &policy, + r#" +version: 1 +groups: + team: [act-andrew] +rules: + - id: duplicate + allow: + actors: { group: team } + actions: [read] + branch_scope: any + - id: duplicate + allow: + actors: { group: team } + actions: [export] + branch_scope: any +"#, + ) + .unwrap(); + + let output = output_failure( + cli() + .arg("policy") + .arg("validate") + .arg("--config") + .arg(&config), + ); + let stderr = String::from_utf8(output.stderr).unwrap(); + assert!(stderr.contains("duplicate policy rule id")); +} + +#[test] +fn policy_test_runs_declarative_cases() { + let temp = tempdir().unwrap(); + let (config, _) = write_policy_config_fixture(temp.path()); + + let output = output_success(cli().arg("policy").arg("test").arg("--config").arg(&config)); + let stdout = stdout_string(&output); + + assert!(stdout.contains("policy tests passed: 2 cases")); +} + +#[test] +fn policy_explain_reports_decision_and_matched_rule() { + let temp = tempdir().unwrap(); + let (config, _) = write_policy_config_fixture(temp.path()); + + let allow = output_success( + cli() + .arg("policy") + .arg("explain") + .arg("--config") + .arg(&config) + .arg("--actor") + .arg("act-andrew") + .arg("--action") + .arg("change") + .arg("--branch") + .arg("feature"), + ); + let allow_stdout = stdout_string(&allow); + assert!(allow_stdout.contains("decision: allow")); + assert!(allow_stdout.contains("matched_rule: team-write")); + + let deny = output_success( + cli() + .arg("policy") + .arg("explain") + .arg("--config") + .arg(&config) + .arg("--actor") + .arg("act-bruno") + .arg("--action") + .arg("change") + .arg("--branch") + .arg("main"), + ); + let deny_stdout = stdout_string(&deny); + assert!(deny_stdout.contains("decision: deny")); + assert!(deny_stdout.contains("message: policy denied action 'change' on branch 'main'")); +} + +#[test] +fn read_can_resolve_uri_from_config() { + let temp = tempdir().unwrap(); + let graph = graph_path(temp.path()); + let config = temp.path().join("omnigraph.yaml"); + init_graph(&graph); + load_fixture(&graph); + write_config(&config, &local_yaml_config(&graph)); + + let output = output_success( + cli() + .arg("read") + .arg("--config") + .arg(&config) + .arg("--query") + .arg(fixture("test.gq")) + .arg("--name") + .arg("get_person") + .arg("--params") + .arg(r#"{"name":"Alice"}"#) + .arg("--json"), + ); + let payload: Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(payload["row_count"], 1); +} + +#[test] +fn read_csv_format_outputs_header_and_row_values() { + let temp = tempdir().unwrap(); + let graph = graph_path(temp.path()); + init_graph(&graph); + load_fixture(&graph); + + let output = output_success( + cli() + .arg("read") + .arg(&graph) + .arg("--query") + .arg(fixture("test.gq")) + .arg("--name") + .arg("get_person") + .arg("--params") + .arg(r#"{"name":"Alice"}"#) + .arg("--format") + .arg("csv"), + ); + let stdout = stdout_string(&output); + + assert!(stdout.lines().next().unwrap().contains("p.name")); + assert!(stdout.contains("Alice")); +} + +#[test] +fn read_jsonl_format_outputs_metadata_header_first() { + let temp = tempdir().unwrap(); + let graph = graph_path(temp.path()); + init_graph(&graph); + load_fixture(&graph); + + let output = output_success( + cli() + .arg("read") + .arg(&graph) + .arg("--query") + .arg(fixture("test.gq")) + .arg("--name") + .arg("get_person") + .arg("--params") + .arg(r#"{"name":"Alice"}"#) + .arg("--format") + .arg("jsonl"), + ); + let stdout = stdout_string(&output); + let mut lines = stdout.lines(); + assert!(lines.next().unwrap().contains("\"kind\":\"metadata\"")); + assert!(lines.next().unwrap().contains("\"p.name\":\"Alice\"")); +} + +#[test] +fn change_json_outputs_affected_counts_and_persists() { + let temp = tempdir().unwrap(); + let graph = graph_path(temp.path()); + init_graph(&graph); + load_fixture(&graph); + let mutation_file = temp.path().join("mutations.gq"); + write_query_file( + &mutation_file, + r#" +query insert_person($name: String, $age: I32) { + insert Person { name: $name, age: $age } +} +"#, + ); + + let output = output_success( + cli() + .arg("change") + .arg(&graph) + .arg("--query") + .arg(&mutation_file) + .arg("--params") + .arg(r#"{"name":"Eve","age":29}"#) + .arg("--json"), + ); + let payload: Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(payload["branch"], "main"); + assert_eq!(payload["query_name"], "insert_person"); + assert_eq!(payload["affected_nodes"], 1); + assert_eq!(payload["affected_edges"], 0); + + let verify = output_success( + cli() + .arg("read") + .arg(&graph) + .arg("--query") + .arg(fixture("test.gq")) + .arg("--name") + .arg("get_person") + .arg("--params") + .arg(r#"{"name":"Eve"}"#) + .arg("--json"), + ); + let verify_payload: Value = serde_json::from_slice(&verify.stdout).unwrap(); + assert_eq!(verify_payload["row_count"], 1); + assert_eq!(verify_payload["rows"][0]["p.name"], "Eve"); +} + +#[test] +fn change_can_resolve_uri_and_branch_from_config() { + let temp = tempdir().unwrap(); + let graph = graph_path(temp.path()); + let config = temp.path().join("omnigraph.yaml"); + init_graph(&graph); + load_fixture(&graph); + write_config(&config, &local_yaml_config(&graph)); + let mutation_file = temp.path().join("config-mutations.gq"); + write_query_file( + &mutation_file, + r#" +query insert_person($name: String, $age: I32) { + insert Person { name: $name, age: $age } +} +"#, + ); + + let output = output_success( + cli() + .arg("change") + .arg("--config") + .arg(&config) + .arg("--query") + .arg(&mutation_file) + .arg("--params") + .arg(r#"{"name":"Mia","age":30}"#) + .arg("--json"), + ); + let payload: Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(payload["branch"], "main"); + assert_eq!(payload["affected_nodes"], 1); +} + +#[test] +fn read_requires_name_for_multi_query_files() { + let temp = tempdir().unwrap(); + let graph = graph_path(temp.path()); + init_graph(&graph); + load_fixture(&graph); + + let output = output_failure( + cli() + .arg("read") + .arg(&graph) + .arg("--query") + .arg(fixture("test.gq")), + ); + let stderr = String::from_utf8(output.stderr).unwrap(); + assert!(stderr.contains("multiple queries")); +} + +#[test] +fn read_supports_inline_query_string() { + let temp = tempdir().unwrap(); + let repo = graph_path(temp.path()); + init_graph(&repo); + load_fixture(&repo); + + let output = output_success( + cli() + .arg("read") + .arg(&repo) + .arg("-e") + .arg("query find($name: String) { match { $p: Person { name: $name } } return { $p.name, $p.age } }") + .arg("--params") + .arg(r#"{"name":"Alice"}"#) + .arg("--json"), + ); + let payload: Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(payload["query_name"], "find"); + assert_eq!(payload["row_count"], 1); + assert_eq!(payload["rows"][0]["p.name"], "Alice"); +} + +#[test] +fn change_supports_inline_query_string() { + let temp = tempdir().unwrap(); + let repo = graph_path(temp.path()); + init_graph(&repo); + load_fixture(&repo); + + let output = output_success( + cli() + .arg("change") + .arg(&repo) + .arg("--query-string") + .arg("query add($name: String, $age: I32) { insert Person { name: $name, age: $age } }") + .arg("--params") + .arg(r#"{"name":"Inline","age":42}"#) + .arg("--json"), + ); + let payload: Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(payload["query_name"], "add"); + assert_eq!(payload["affected_nodes"], 1); + + let verify = output_success( + cli() + .arg("read") + .arg(&repo) + .arg("-e") + .arg("query find($name: String) { match { $p: Person { name: $name } } return { $p.name } }") + .arg("--params") + .arg(r#"{"name":"Inline"}"#) + .arg("--json"), + ); + let verify_payload: Value = serde_json::from_slice(&verify.stdout).unwrap(); + assert_eq!(verify_payload["row_count"], 1); +} + +#[test] +fn read_rejects_query_string_combined_with_query() { + let temp = tempdir().unwrap(); + let repo = graph_path(temp.path()); + init_graph(&repo); + load_fixture(&repo); + + let output = output_failure( + cli() + .arg("read") + .arg(&repo) + .arg("--query") + .arg(fixture("test.gq")) + .arg("-e") + .arg("query whatever() { match { $p: Person } return { $p.name } }"), + ); + let stderr = String::from_utf8(output.stderr).unwrap(); + assert!( + stderr.contains("cannot be used") || stderr.contains("conflict"), + "expected clap conflict error, got: {stderr}" + ); +} + +#[test] +fn read_rejects_empty_query_string() { + let temp = tempdir().unwrap(); + let repo = graph_path(temp.path()); + init_graph(&repo); + load_fixture(&repo); + + let output = output_failure(cli().arg("read").arg(&repo).arg("-e").arg("")); + let stderr = String::from_utf8(output.stderr).unwrap(); + assert!( + stderr.contains("must not be empty"), + "expected empty-string rejection, got: {stderr}" + ); +} + +#[test] +fn branch_create_json_outputs_source_and_name() { + let temp = tempdir().unwrap(); + let graph = graph_path(temp.path()); + init_graph(&graph); + + let output = output_success( + cli() + .arg("branch") + .arg("create") + .arg("--uri") + .arg(&graph) + .arg("--from") + .arg("main") + .arg("feature") + .arg("--json"), + ); + let payload: Value = serde_json::from_slice(&output.stdout).unwrap(); + + assert_eq!(payload["from"], "main"); + assert_eq!(payload["name"], "feature"); + assert_eq!(payload["uri"], graph.to_string_lossy().as_ref()); +} + +#[test] +fn branch_list_outputs_sorted_branches() { + let temp = tempdir().unwrap(); + let graph = graph_path(temp.path()); + init_graph(&graph); + + output_success( + cli() + .arg("branch") + .arg("create") + .arg("--uri") + .arg(&graph) + .arg("--from") + .arg("main") + .arg("zeta"), + ); + output_success( + cli() + .arg("branch") + .arg("create") + .arg("--uri") + .arg(&graph) + .arg("--from") + .arg("main") + .arg("alpha"), + ); + + let output = output_success(cli().arg("branch").arg("list").arg("--uri").arg(&graph)); + let stdout = stdout_string(&output); + let lines = stdout + .lines() + .map(str::trim) + .filter(|line| !line.is_empty()) + .collect::>(); + + assert_eq!(lines, vec!["alpha", "main", "zeta"]); +} + +#[test] +fn branch_delete_json_outputs_name_and_removes_branch() { + let temp = tempdir().unwrap(); + let graph = graph_path(temp.path()); + init_graph(&graph); + + output_success( + cli() + .arg("branch") + .arg("create") + .arg("--uri") + .arg(&graph) + .arg("--from") + .arg("main") + .arg("feature"), + ); + + let output = output_success( + cli() + .arg("branch") + .arg("delete") + .arg("--uri") + .arg(&graph) + .arg("feature") + .arg("--json"), + ); + let payload: Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(payload["name"], "feature"); + assert_eq!(payload["uri"], graph.to_string_lossy().as_ref()); + + let listed = output_success(cli().arg("branch").arg("list").arg("--uri").arg(&graph)); + let stdout = stdout_string(&listed); + let lines = stdout + .lines() + .map(str::trim) + .filter(|line| !line.is_empty()) + .collect::>(); + assert_eq!(lines, vec!["main"]); +} + +#[test] +fn branch_delete_rejects_main() { + let temp = tempdir().unwrap(); + let graph = graph_path(temp.path()); + init_graph(&graph); + + let output = output_failure( + cli() + .arg("branch") + .arg("delete") + .arg("--uri") + .arg(&graph) + .arg("main"), + ); + let stderr = String::from_utf8(output.stderr).unwrap(); + assert!(stderr.contains("cannot delete branch 'main'")); +} + +#[test] +fn branch_merge_defaults_target_to_main() { + let temp = tempdir().unwrap(); + let graph = graph_path(temp.path()); + init_graph(&graph); + load_fixture(&graph); + + output_success( + cli() + .arg("branch") + .arg("create") + .arg("--uri") + .arg(&graph) + .arg("--from") + .arg("main") + .arg("feature"), + ); + + let feature_data = temp.path().join("feature.jsonl"); + write_jsonl( + &feature_data, + r#"{"type":"Person","data":{"name":"Eve","age":29}}"#, + ); + output_success( + cli() + .arg("load") + .arg("--data") + .arg(&feature_data) + .arg("--branch") + .arg("feature") + .arg("--mode") + .arg("append") + .arg(&graph), + ); + + let merge_output = output_success( + cli() + .arg("branch") + .arg("merge") + .arg("--uri") + .arg(&graph) + .arg("feature") + .arg("--json"), + ); + let merge_payload: Value = serde_json::from_slice(&merge_output.stdout).unwrap(); + assert_eq!(merge_payload["source"], "feature"); + assert_eq!(merge_payload["target"], "main"); + assert_eq!(merge_payload["outcome"], "fast_forward"); + + let snapshot_output = output_success( + cli() + .arg("snapshot") + .arg(&graph) + .arg("--branch") + .arg("main") + .arg("--json"), + ); + let snapshot: Value = serde_json::from_slice(&snapshot_output.stdout).unwrap(); + let person_row_count = snapshot["tables"] + .as_array() + .unwrap() + .iter() + .find(|table| table["table_key"] == "node:Person") + .unwrap()["row_count"] + .as_u64() + .unwrap(); + assert_eq!(person_row_count, 5); +} + +#[test] +fn branch_merge_supports_explicit_target() { + let temp = tempdir().unwrap(); + let graph = graph_path(temp.path()); + init_graph(&graph); + load_fixture(&graph); + + output_success( + cli() + .arg("branch") + .arg("create") + .arg("--uri") + .arg(&graph) + .arg("--from") + .arg("main") + .arg("feature"), + ); + output_success( + cli() + .arg("branch") + .arg("create") + .arg("--uri") + .arg(&graph) + .arg("--from") + .arg("main") + .arg("experiment"), + ); + + let feature_data = temp.path().join("feature-explicit.jsonl"); + write_jsonl( + &feature_data, + r#"{"type":"Person","data":{"name":"Frank","age":41}}"#, + ); + output_success( + cli() + .arg("load") + .arg("--data") + .arg(&feature_data) + .arg("--branch") + .arg("feature") + .arg("--mode") + .arg("append") + .arg(&graph), + ); + + let merge_output = output_success( + cli() + .arg("branch") + .arg("merge") + .arg("--uri") + .arg(&graph) + .arg("feature") + .arg("--into") + .arg("experiment") + .arg("--json"), + ); + let merge_payload: Value = serde_json::from_slice(&merge_output.stdout).unwrap(); + assert_eq!(merge_payload["target"], "experiment"); + assert_eq!(merge_payload["outcome"], "fast_forward"); +} + +#[test] +fn snapshot_json_returns_manifest_version_and_tables() { + let temp = tempdir().unwrap(); + let graph = graph_path(temp.path()); + init_graph(&graph); + load_fixture(&graph); + + let output = output_success(cli().arg("snapshot").arg(&graph).arg("--json")); + let payload: Value = serde_json::from_slice(&output.stdout).unwrap(); + + assert_eq!(payload["branch"], "main"); + assert_eq!( + payload["manifest_version"].as_u64().unwrap(), + manifest_dataset_version(&graph) + ); + assert!(payload["tables"].as_array().unwrap().len() >= 4); +} + +#[test] +fn snapshot_can_resolve_uri_from_config() { + let temp = tempdir().unwrap(); + let graph = graph_path(temp.path()); + let config = temp.path().join("omnigraph.yaml"); + init_graph(&graph); + load_fixture(&graph); + write_config(&config, &local_yaml_config(&graph)); + + let output = output_success( + cli() + .arg("snapshot") + .arg("--config") + .arg(&config) + .arg("--json"), + ); + let payload: Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(payload["branch"], "main"); +} + +#[test] +fn snapshot_human_output_includes_branch_and_table_summaries() { + let temp = tempdir().unwrap(); + let graph = graph_path(temp.path()); + init_graph(&graph); + load_fixture(&graph); + + let output = output_success(cli().arg("snapshot").arg(&graph)); + let stdout = stdout_string(&output); + + assert!(stdout.contains("branch: main")); + assert!(stdout.contains("manifest_version:")); + assert!(stdout.contains("node:Person v")); + assert!(stdout.contains("edge:Knows v")); +} + +#[test] +fn commit_show_accepts_long_uri_flag() { + let temp = tempdir().unwrap(); + let graph = graph_path(temp.path()); + init_graph(&graph); + load_fixture(&graph); + + let list = output_success(cli().arg("commit").arg("list").arg(&graph).arg("--json")); + let list_payload: Value = serde_json::from_slice(&list.stdout).unwrap(); + let commit_id = list_payload["commits"][0]["graph_commit_id"] + .as_str() + .unwrap() + .to_string(); + + let output = output_success( + cli() + .arg("commit") + .arg("show") + .arg("--uri") + .arg(&graph) + .arg(&commit_id) + .arg("--json"), + ); + let payload: Value = serde_json::from_slice(&output.stdout).unwrap(); + + assert_eq!(payload["graph_commit_id"], commit_id); + assert!(payload["manifest_version"].as_u64().unwrap() >= 1); +} + +#[test] +fn cli_fails_for_missing_graph() { + let temp = tempdir().unwrap(); + let graph = graph_path(temp.path()); + + let output = output_failure(cli().arg("snapshot").arg(&graph)); + let stderr = String::from_utf8(output.stderr).unwrap(); + assert!( + stderr.contains("_schema.pg") + || stderr.contains("No such file") + || stderr.contains("not found") + ); +} + +#[test] +fn cli_fails_for_missing_schema_or_data_file() { + let temp = tempdir().unwrap(); + let graph = graph_path(temp.path()); + let missing_schema = temp.path().join("missing.pg"); + let missing_data = temp.path().join("missing.jsonl"); + + let init_output = output_failure( + cli() + .arg("init") + .arg("--schema") + .arg(&missing_schema) + .arg(&graph), + ); + assert!( + String::from_utf8(init_output.stderr) + .unwrap() + .contains("No such file") + ); + + init_graph(&graph); + let load_output = output_failure( + cli() + .arg("load") + .arg("--mode") + .arg("overwrite") + .arg("--data") + .arg(&missing_data) + .arg(&graph), + ); + assert!( + String::from_utf8(load_output.stderr) + .unwrap() + .contains("No such file") + ); +} + +#[test] +fn cli_fails_for_invalid_merge_requests() { + let temp = tempdir().unwrap(); + let graph = graph_path(temp.path()); + init_graph(&graph); + load_fixture(&graph); + + let missing_branch = output_failure( + cli() + .arg("branch") + .arg("merge") + .arg("--uri") + .arg(&graph) + .arg("missing"), + ); + let missing_branch_stderr = String::from_utf8(missing_branch.stderr).unwrap(); + assert!( + missing_branch_stderr.contains("missing") + || missing_branch_stderr.contains("head commit") + || missing_branch_stderr.contains("not found") + ); + + let same_branch = output_failure( + cli() + .arg("branch") + .arg("merge") + .arg("--uri") + .arg(&graph) + .arg("main") + .arg("--into") + .arg("main"), + ); + assert!( + String::from_utf8(same_branch.stderr) + .unwrap() + .contains("distinct source and target") + ); +} diff --git a/crates/omnigraph-cli/tests/cli_queries.rs b/crates/omnigraph-cli/tests/cli_queries.rs new file mode 100644 index 0000000..8a1e553 --- /dev/null +++ b/crates/omnigraph-cli/tests/cli_queries.rs @@ -0,0 +1,535 @@ +//! Stored-query commands and alias resolution. +//! Moved verbatim from tests/cli.rs in the modularization. + + +use serde_json::Value; +use tempfile::tempdir; + +mod support; + +use support::*; + + +#[test] +fn query_check_alias_matches_lint_output() { + let temp = tempdir().unwrap(); + let schema_path = temp.path().join("schema.pg"); + let query_path = temp.path().join("queries.gq"); + write_file( + &schema_path, + r#" +node Person { + name: String +} +"#, + ); + write_query_file( + &query_path, + r#" +query list_people() { + match { $p: Person } + return { $p.name } +} +"#, + ); + + let lint_output = output_success( + cli() + .arg("query") + .arg("lint") + .arg("--query") + .arg(&query_path) + .arg("--schema") + .arg(&schema_path) + .arg("--json"), + ); + let check_output = output_success( + cli() + .arg("query") + .arg("check") + .arg("--query") + .arg(&query_path) + .arg("--schema") + .arg(&schema_path) + .arg("--json"), + ); + + assert_eq!(stdout_string(&lint_output), stdout_string(&check_output)); +} + +#[test] +fn read_alias_from_yaml_config_runs_with_kv_output() { + let temp = tempdir().unwrap(); + let graph = graph_path(temp.path()); + let config = temp.path().join("omnigraph.yaml"); + let query = temp.path().join("aliases.gq"); + init_graph(&graph); + load_fixture(&graph); + write_query_file( + &query, + &std::fs::read_to_string(fixture("test.gq")).unwrap(), + ); + write_config( + &config, + &format!( + "{}aliases:\n owner:\n command: read\n query: aliases.gq\n name: get_person\n args: [name]\n format: kv\n", + local_yaml_config(&graph) + ), + ); + + let output = output_success( + cli() + .arg("read") + .arg("--config") + .arg(&config) + .arg("--alias") + .arg("owner") + .arg("Alice"), + ); + let stdout = stdout_string(&output); + + assert!(stdout.contains("row 1")); + assert!(stdout.contains("p.name: Alice")); +} + +#[test] +fn read_alias_uses_alias_target_without_cli_default_and_accepts_url_like_arg() { + let temp = tempdir().unwrap(); + let graph = graph_path(temp.path()); + let config = temp.path().join("omnigraph.yaml"); + let query = temp.path().join("aliases.gq"); + let data = temp.path().join("url-like.jsonl"); + init_graph(&graph); + write_jsonl( + &data, + r#"{"type":"Person","data":{"name":"https://example.com","age":30}}"#, + ); + output_success( + cli() + .arg("load") + .arg("--mode") + .arg("overwrite") + .arg("--data") + .arg(&data) + .arg(&graph), + ); + write_query_file( + &query, + &std::fs::read_to_string(fixture("test.gq")).unwrap(), + ); + write_config( + &config, + &format!( + "graphs:\n local:\n uri: '{}'\nquery:\n roots:\n - .\npolicy: {{}}\naliases:\n owner:\n command: read\n query: aliases.gq\n name: get_person\n args: [name]\n graph: local\n format: kv\n", + graph.to_string_lossy() + ), + ); + + let output = output_success( + cli() + .arg("read") + .arg("--config") + .arg(&config) + .arg("--alias") + .arg("owner") + .arg("https://example.com"), + ); + let stdout = stdout_string(&output); + + assert!(stdout.contains("row 1")); + assert!(stdout.contains("p.name: https://example.com")); +} + +#[test] +fn change_alias_from_yaml_config_persists_changes() { + let temp = tempdir().unwrap(); + let graph = graph_path(temp.path()); + let config = temp.path().join("omnigraph.yaml"); + let query = temp.path().join("mutations.gq"); + init_graph(&graph); + load_fixture(&graph); + write_query_file( + &query, + r#" +query insert_person($name: String, $age: I32) { + insert Person { name: $name, age: $age } +} +"#, + ); + write_config( + &config, + &format!( + "{}aliases:\n add_person:\n command: change\n query: mutations.gq\n name: insert_person\n args: [name, age]\n", + local_yaml_config(&graph) + ), + ); + + let output = output_success( + cli() + .arg("change") + .arg("--config") + .arg(&config) + .arg("--alias") + .arg("add_person") + .arg("Eve") + .arg("29") + .arg("--json"), + ); + let payload: Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(payload["affected_nodes"], 1); + + let verify = output_success( + cli() + .arg("read") + .arg(&graph) + .arg("--query") + .arg(fixture("test.gq")) + .arg("--name") + .arg("get_person") + .arg("--params") + .arg(r#"{"name":"Eve"}"#) + .arg("--json"), + ); + let verify_payload: Value = serde_json::from_slice(&verify.stdout).unwrap(); + assert_eq!(verify_payload["row_count"], 1); +} + +#[test] +fn queries_validate_exits_zero_on_clean_registry() { + let graph = SystemGraph::loaded(); + graph.write_query( + "find_person.gq", + "query find_person($name: String) { match { $p: Person { name: $name } } return { $p.age } }", + ); + let config = graph.write_config( + "omnigraph.yaml", + &queries_test_config( + &graph.path().to_string_lossy(), + "find_person", + "find_person.gq", + ), + ); + let output = output_success( + cli() + .arg("queries") + .arg("validate") + .arg("--config") + .arg(&config), + ); + let stdout = stdout_string(&output); + assert!(stdout.contains("OK"), "stdout:\n{stdout}"); +} + +#[test] +fn queries_validate_exits_nonzero_on_type_broken_query() { + let graph = SystemGraph::loaded(); + // `Widget` is not in the fixture schema. + graph.write_query( + "ghost.gq", + "query ghost() { match { $w: Widget } return { $w.name } }", + ); + let config = graph.write_config( + "omnigraph.yaml", + &queries_test_config(&graph.path().to_string_lossy(), "ghost", "ghost.gq"), + ); + let output = output_failure( + cli() + .arg("queries") + .arg("validate") + .arg("--config") + .arg(&config), + ); + let stdout = stdout_string(&output); + assert!( + stdout.contains("ghost"), + "validation should name the broken query; stdout:\n{stdout}" + ); +} + +#[test] +fn queries_list_prints_registered_query() { + let graph = SystemGraph::loaded(); + graph.write_query( + "find_person.gq", + "query find_person($name: String) { match { $p: Person { name: $name } } return { $p.age } }", + ); + // Exposed with an explicit tool name so the list shows the MCP suffix. + let config = graph.write_config( + "omnigraph.yaml", + &format!( + concat!( + "graphs:\n", + " local:\n", + " uri: '{}'\n", + " queries:\n", + " find_person:\n", + " file: ./find_person.gq\n", + " mcp: {{ expose: true, tool_name: lookup_person }}\n", + "cli:\n", + " graph: local\n", + "policy: {{}}\n", + ), + graph.path().to_string_lossy().replace('\'', "''") + ), + ); + let output = output_success( + cli() + .arg("queries") + .arg("list") + .arg("--config") + .arg(&config), + ); + let stdout = stdout_string(&output); + assert!(stdout.contains("find_person"), "stdout:\n{stdout}"); + assert!( + stdout.contains("$name: String"), + "list should show typed params; stdout:\n{stdout}" + ); + assert!( + stdout.contains("[mcp: lookup_person]"), + "list should show the MCP tool name for exposed queries; stdout:\n{stdout}" + ); +} + +#[test] +fn queries_list_requires_graph_selection_for_per_graph_only_registries() { + let graph = SystemGraph::loaded(); + graph.write_query( + "find_person.gq", + "query find_person($name: String) { match { $p: Person { name: $name } } return { $p.age } }", + ); + let config = graph.write_config( + "omnigraph.yaml", + &format!( + concat!( + "graphs:\n", + " local:\n", + " uri: '{}'\n", + " queries:\n", + " find_person:\n", + " file: ./find_person.gq\n", + "policy: {{}}\n", + ), + graph.path().to_string_lossy().replace('\'', "''") + ), + ); + + let output = output_failure( + cli() + .arg("queries") + .arg("list") + .arg("--config") + .arg(&config), + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("local") && stderr.contains("--target local"), + "error must name the graph and give a concrete selection hint; stderr:\n{stderr}" + ); +} + +#[test] +fn queries_list_without_graph_selection_lists_top_level_registry() { + let graph = SystemGraph::loaded(); + graph.write_query( + "top_find.gq", + "query top_find($name: String) { match { $p: Person { name: $name } } return { $p.age } }", + ); + let config = graph.write_config( + "omnigraph.yaml", + concat!( + "queries:\n", + " top_find:\n", + " file: ./top_find.gq\n", + "policy: {}\n", + ), + ); + + let output = output_success( + cli() + .arg("queries") + .arg("list") + .arg("--config") + .arg(&config), + ); + let stdout = stdout_string(&output); + assert!(stdout.contains("top_find"), "stdout:\n{stdout}"); +} + +#[test] +fn queries_list_unknown_target_errors() { + // `queries list` opens no graph URI, so unknown-graph validation can't ride + // along on URI resolution the way it does for every other command. An + // unknown `--target` must still error (naming the graph) instead of + // silently falling back to the top-level registry and showing the wrong + // (or empty) catalog. + let graph = SystemGraph::loaded(); + graph.write_query( + "find_person.gq", + "query find_person($name: String) { match { $p: Person { name: $name } } return { $p.age } }", + ); + let config = graph.write_config( + "omnigraph.yaml", + &queries_test_config( + &graph.path().to_string_lossy(), + "find_person", + "find_person.gq", + ), + ); + let output = output_failure( + cli() + .arg("queries") + .arg("list") + .arg("--target") + .arg("nonexistent") + .arg("--config") + .arg(&config), + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("nonexistent"), + "error must name the unknown graph; stderr:\n{stderr}" + ); +} + +#[test] +fn queries_commands_reject_named_graph_with_populated_top_level_block() { + // A named graph (here via `cli.graph`) uses its own `graphs.` block, + // so a populated top-level `queries:` block would be silently ignored — a + // config the server REFUSES to boot. `queries validate`/`list` must reject + // it too (matching boot) instead of validating/listing the per-graph block + // and giving a false green. + let graph = SystemGraph::loaded(); + graph.write_query( + "find_person.gq", + "query find_person($name: String) { match { $p: Person { name: $name } } return { $p.age } }", + ); + let config = graph.write_config( + "omnigraph.yaml", + &format!( + concat!( + "graphs:\n", + " local:\n", + " uri: '{}'\n", + " queries:\n", + " find_person:\n", + " file: ./find_person.gq\n", + "cli:\n", + " graph: local\n", + "queries:\n", // populated top-level block: the coherence violation + " legacy:\n", + " file: ./legacy.gq\n", + "policy: {{}}\n", + ), + graph.path().to_string_lossy().replace('\'', "''") + ), + ); + // Both resolve `local` from cli.graph (no positional URI), so both must + // error and name the graph + the ignored block — like server boot does. + for sub in ["validate", "list"] { + let output = output_failure(cli().arg("queries").arg(sub).arg("--config").arg(&config)); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("local") && stderr.contains("queries"), + "`queries {sub}` must reject a named graph with a populated top-level block; stderr:\n{stderr}" + ); + } +} + +#[test] +fn queries_validate_exits_nonzero_on_duplicate_tool_name() { + // Two exposed queries claiming one MCP tool name is a load-time + // collision — `queries validate` must fail (offline, before the engine + // opens) and name both queries plus the contested tool. + let graph = SystemGraph::loaded(); + graph.write_query( + "a.gq", + "query a() { match { $p: Person } return { $p.name } }", + ); + graph.write_query( + "b.gq", + "query b() { match { $p: Person } return { $p.name } }", + ); + let config = graph.write_config( + "omnigraph.yaml", + &format!( + concat!( + "graphs:\n", + " local:\n", + " uri: '{}'\n", + " queries:\n", + " a:\n", + " file: ./a.gq\n", + " mcp: {{ expose: true, tool_name: dup }}\n", + " b:\n", + " file: ./b.gq\n", + " mcp: {{ expose: true, tool_name: dup }}\n", + "cli:\n", + " graph: local\n", + "policy: {{}}\n", + ), + graph.path().to_string_lossy().replace('\'', "''") + ), + ); + let output = output_failure( + cli() + .arg("queries") + .arg("validate") + .arg("--config") + .arg(&config), + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("dup") && stderr.contains("'a'") && stderr.contains("'b'"), + "duplicate tool name should be reported naming both queries; stderr:\n{stderr}" + ); +} + +#[test] +fn queries_validate_positional_uri_ignores_default_graph() { + // A positional URI is anonymous → the schema AND the registry both come + // from top-level, even when `cli.graph` names a graph whose per-graph + // queries would fail. Pins that the URI and registry can't diverge. + let graph = SystemGraph::loaded(); + graph.write_query( + "clean.gq", + "query clean($name: String) { match { $p: Person { name: $name } } return { $p.age } }", + ); + // `Widget` is not in the fixture schema — the default graph's per-graph + // query would break validate if it were (wrongly) selected. + graph.write_query( + "broken.gq", + "query broken() { match { $w: Widget } return { $w.name } }", + ); + let config = graph.write_config( + "omnigraph.yaml", + concat!( + "cli:\n graph: prod\n", + "graphs:\n", + " prod:\n", + " uri: /nonexistent-prod.omni\n", + " queries:\n", + " broken:\n", + " file: ./broken.gq\n", + "queries:\n", + " clean:\n", + " file: ./clean.gq\n", + "policy: {}\n", + ), + ); + // Positional URI = the real loaded graph; selection is anonymous, so the + // CLEAN top-level registry validates (not prod's broken one). + let output = output_success( + cli() + .arg("queries") + .arg("validate") + .arg(graph.path()) + .arg("--config") + .arg(&config), + ); + let stdout = stdout_string(&output); + assert!( + stdout.contains("OK"), + "positional URI must validate the top-level registry, not the cli.graph default; stdout:\n{stdout}" + ); +} diff --git a/crates/omnigraph-cli/tests/cli_schema_config.rs b/crates/omnigraph-cli/tests/cli_schema_config.rs new file mode 100644 index 0000000..a0dac0a --- /dev/null +++ b/crates/omnigraph-cli/tests/cli_schema_config.rs @@ -0,0 +1,500 @@ +//! init/config scaffolding, schema plan/apply, graphs listing, version. +//! Moved verbatim from tests/cli.rs in the modularization. + +use std::fs; + +use lance::index::DatasetIndexExt; +use omnigraph::db::{Omnigraph, ReadTarget}; +use serde_json::Value; +use tempfile::tempdir; + +mod support; + +use support::*; + + +#[test] +fn version_command_prints_current_cli_version() { + let output = output_success(cli().arg("version")); + let stdout = stdout_string(&output); + + assert_eq!( + stdout.trim(), + format!("omnigraph {}", env!("CARGO_PKG_VERSION")) + ); +} + +#[test] +fn init_creates_graph_successfully_on_missing_local_directory() { + let temp = tempdir().unwrap(); + let graph = graph_path(temp.path()); + let schema = fixture("test.pg"); + + let output = output_success(cli().arg("init").arg("--schema").arg(&schema).arg(&graph)); + let stdout = stdout_string(&output); + + assert!(stdout.contains("initialized")); + assert!(graph.join("_schema.pg").exists()); + assert!(graph.join("__manifest").exists()); + assert!(temp.path().join("omnigraph.yaml").exists()); +} + +#[test] +fn schema_plan_json_reports_supported_additive_change() { + let temp = tempdir().unwrap(); + let graph = graph_path(temp.path()); + let schema_path = temp.path().join("next.pg"); + init_graph(&graph); + + let next_schema = fs::read_to_string(fixture("test.pg")).unwrap().replace( + " age: I32?\n}", + " age: I32?\n nickname: String?\n}", + ); + fs::write(&schema_path, next_schema).unwrap(); + + let output = output_success( + cli() + .arg("schema") + .arg("plan") + .arg("--schema") + .arg(&schema_path) + .arg("--json") + .arg(&graph), + ); + let payload: Value = serde_json::from_slice(&output.stdout).unwrap(); + + assert_eq!(payload["supported"], true); + assert_eq!(payload["step_count"], 1); + assert_eq!(payload["steps"][0]["kind"], "add_property"); + assert_eq!(payload["steps"][0]["type_kind"], "node"); + assert_eq!(payload["steps"][0]["type_name"], "Person"); + assert_eq!(payload["steps"][0]["property_name"], "nickname"); +} + +#[test] +fn schema_plan_json_reports_unsupported_type_change() { + let temp = tempdir().unwrap(); + let graph = graph_path(temp.path()); + let schema_path = temp.path().join("breaking.pg"); + init_graph(&graph); + + let breaking_schema = fs::read_to_string(fixture("test.pg")) + .unwrap() + .replace("age: I32?", "age: I64?"); + fs::write(&schema_path, breaking_schema).unwrap(); + + let output = output_success( + cli() + .arg("schema") + .arg("plan") + .arg("--schema") + .arg(&schema_path) + .arg("--json") + .arg(&graph), + ); + let payload: Value = serde_json::from_slice(&output.stdout).unwrap(); + + assert_eq!(payload["supported"], false); + assert!(payload["steps"].as_array().unwrap().iter().any(|step| { + step["kind"] == "unsupported_change" + && step["entity"] + .as_str() + .unwrap_or_default() + .contains("Person.age") + })); +} + +#[test] +fn schema_apply_json_applies_supported_migration() { + let temp = tempdir().unwrap(); + let graph = graph_path(temp.path()); + let schema_path = temp.path().join("next.pg"); + init_graph(&graph); + + let next_schema = fs::read_to_string(fixture("test.pg")).unwrap().replace( + " age: I32?\n}", + " age: I32?\n nickname: String?\n}", + ); + fs::write(&schema_path, next_schema).unwrap(); + + let output = output_success( + cli() + .arg("schema") + .arg("apply") + .arg("--schema") + .arg(&schema_path) + .arg("--json") + .arg(&graph), + ); + let payload: Value = serde_json::from_slice(&output.stdout).unwrap(); + + assert_eq!(payload["supported"], true); + assert_eq!(payload["applied"], true); + assert_eq!(payload["step_count"], 1); + + let db = tokio::runtime::Runtime::new() + .unwrap() + .block_on(Omnigraph::open(graph.to_string_lossy().as_ref())) + .unwrap(); + assert!( + db.catalog().node_types["Person"] + .properties + .contains_key("nickname") + ); +} + +#[test] +fn schema_apply_human_reports_noop() { + let temp = tempdir().unwrap(); + let graph = graph_path(temp.path()); + let schema_path = fixture("test.pg"); + init_graph(&graph); + + let output = output_success( + cli() + .arg("schema") + .arg("apply") + .arg("--schema") + .arg(&schema_path) + .arg(&graph), + ); + let stdout = stdout_string(&output); + + assert!(stdout.contains("applied: no")); + assert!(stdout.contains("no schema changes")); +} + +#[test] +fn schema_apply_json_renames_type_and_updates_snapshot() { + let temp = tempdir().unwrap(); + let graph = graph_path(temp.path()); + let schema_path = temp.path().join("rename.pg"); + init_graph(&graph); + + let renamed_schema = fs::read_to_string(fixture("test.pg")) + .unwrap() + .replace("node Person {\n", "node Human @rename_from(\"Person\") {\n") + .replace("edge Knows: Person -> Person", "edge Knows: Human -> Human") + .replace( + "edge WorksAt: Person -> Company", + "edge WorksAt: Human -> Company", + ); + fs::write(&schema_path, renamed_schema).unwrap(); + + let output = output_success( + cli() + .arg("schema") + .arg("apply") + .arg("--schema") + .arg(&schema_path) + .arg("--json") + .arg(&graph), + ); + let payload: Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(payload["applied"], true); + + let db = tokio::runtime::Runtime::new() + .unwrap() + .block_on(Omnigraph::open(graph.to_string_lossy().as_ref())) + .unwrap(); + let snapshot = tokio::runtime::Runtime::new() + .unwrap() + .block_on(db.snapshot_of(ReadTarget::branch("main"))) + .unwrap(); + assert!(snapshot.entry("node:Human").is_some()); + assert!(snapshot.entry("node:Person").is_none()); +} + +#[test] +fn schema_apply_json_renames_property_and_updates_catalog() { + let temp = tempdir().unwrap(); + let graph = graph_path(temp.path()); + let schema_path = temp.path().join("rename-property.pg"); + init_graph(&graph); + + let renamed_schema = fs::read_to_string(fixture("test.pg")) + .unwrap() + .replace("age: I32?", "years: I32? @rename_from(\"age\")"); + fs::write(&schema_path, renamed_schema).unwrap(); + + let output = output_success( + cli() + .arg("schema") + .arg("apply") + .arg("--schema") + .arg(&schema_path) + .arg("--json") + .arg(&graph), + ); + let payload: Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(payload["applied"], true); + + let db = tokio::runtime::Runtime::new() + .unwrap() + .block_on(Omnigraph::open(graph.to_string_lossy().as_ref())) + .unwrap(); + let person = &db.catalog().node_types["Person"]; + assert!(person.properties.contains_key("years")); + assert!(!person.properties.contains_key("age")); +} + +#[test] +fn schema_apply_json_adds_index_for_existing_property() { + let temp = tempdir().unwrap(); + let graph = graph_path(temp.path()); + let schema_path = temp.path().join("index.pg"); + init_graph(&graph); + + let before_index_count = tokio::runtime::Runtime::new().unwrap().block_on(async { + let db = Omnigraph::open(graph.to_string_lossy().as_ref()) + .await + .unwrap(); + let snapshot = db.snapshot_of(ReadTarget::branch("main")).await.unwrap(); + let dataset = snapshot.open("node:Person").await.unwrap(); + dataset.load_indices().await.unwrap().len() + }); + + let indexed_schema = fs::read_to_string(fixture("test.pg")) + .unwrap() + .replace("name: String @key", "name: String @key @index"); + fs::write(&schema_path, indexed_schema).unwrap(); + + let output = output_success( + cli() + .arg("schema") + .arg("apply") + .arg("--schema") + .arg(&schema_path) + .arg("--json") + .arg(&graph), + ); + let payload: Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(payload["applied"], true); + + let after_index_count = tokio::runtime::Runtime::new().unwrap().block_on(async { + let db = Omnigraph::open(graph.to_string_lossy().as_ref()) + .await + .unwrap(); + let snapshot = db.snapshot_of(ReadTarget::branch("main")).await.unwrap(); + let dataset = snapshot.open("node:Person").await.unwrap(); + dataset.load_indices().await.unwrap().len() + }); + assert!(after_index_count > before_index_count); +} + +#[test] +fn schema_apply_rejects_unsupported_plan() { + let temp = tempdir().unwrap(); + let graph = graph_path(temp.path()); + let schema_path = temp.path().join("breaking.pg"); + init_graph(&graph); + + let breaking_schema = fs::read_to_string(fixture("test.pg")) + .unwrap() + .replace("age: I32?", "age: I64?"); + fs::write(&schema_path, breaking_schema).unwrap(); + + let output = output_failure( + cli() + .arg("schema") + .arg("apply") + .arg("--schema") + .arg(&schema_path) + .arg(&graph), + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("changing property type")); +} + +#[test] +fn schema_apply_rejects_when_non_main_branch_exists() { + let temp = tempdir().unwrap(); + let graph = graph_path(temp.path()); + let schema_path = temp.path().join("next.pg"); + init_graph(&graph); + output_success( + cli() + .arg("branch") + .arg("create") + .arg("--from") + .arg("main") + .arg("--uri") + .arg(&graph) + .arg("feature"), + ); + + let next_schema = fs::read_to_string(fixture("test.pg")).unwrap().replace( + " age: I32?\n}", + " age: I32?\n nickname: String?\n}", + ); + fs::write(&schema_path, next_schema).unwrap(); + + let output = output_failure( + cli() + .arg("schema") + .arg("apply") + .arg("--schema") + .arg(&schema_path) + .arg(&graph), + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("schema apply requires a graph with only main")); +} + +#[test] +fn schema_apply_allow_data_loss_flag_promotes_drops_to_hard() { + let temp = tempdir().unwrap(); + let graph = graph_path(temp.path()); + let schema_path = temp.path().join("drop-age.pg"); + init_graph(&graph); + + // Drop the nullable `age` column. + let next_schema = fs::read_to_string(fixture("test.pg")) + .unwrap() + .replace(" age: I32?\n", ""); + fs::write(&schema_path, next_schema).unwrap(); + + let output = output_success( + cli() + .arg("schema") + .arg("apply") + .arg("--schema") + .arg(&schema_path) + .arg("--allow-data-loss") + .arg("--json") + .arg(&graph), + ); + let payload: Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(payload["applied"], true); + + let drop_step = payload["steps"] + .as_array() + .unwrap() + .iter() + .find(|s| s["kind"] == "drop_property") + .expect("plan should include a drop_property step"); + assert_eq!( + drop_step["mode"], "hard", + "--allow-data-loss should promote Soft → Hard; full step: {drop_step}", + ); +} + +#[test] +fn schema_apply_without_allow_data_loss_keeps_soft_drops() { + // Symmetric to the above: same schema change without the flag → + // drops stay Soft. Pins default semantics against accidental Hard + // promotion if a future refactor changes the option threading. + let temp = tempdir().unwrap(); + let graph = graph_path(temp.path()); + let schema_path = temp.path().join("drop-age-soft.pg"); + init_graph(&graph); + + let next_schema = fs::read_to_string(fixture("test.pg")) + .unwrap() + .replace(" age: I32?\n", ""); + fs::write(&schema_path, next_schema).unwrap(); + + let output = output_success( + cli() + .arg("schema") + .arg("apply") + .arg("--schema") + .arg(&schema_path) + .arg("--json") + .arg(&graph), + ); + let payload: Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(payload["applied"], true); + + let drop_step = payload["steps"] + .as_array() + .unwrap() + .iter() + .find(|s| s["kind"] == "drop_property") + .expect("plan should include a drop_property step"); + assert_eq!( + drop_step["mode"], "soft", + "no flag should leave drops Soft; full step: {drop_step}", + ); +} + +#[test] +fn schema_plan_parity_cli_and_sdk() { + // Same .pg through `Omnigraph::plan_schema_with_options` (SDK) and + // `omnigraph schema plan --json` (CLI). Asserts the steps array is + // byte-identical after JSON round-trip. HTTP doesn't expose a + // separate /schema/plan route — that side of parity is covered by + // the HTTP soft/hard drop tests, which exercise apply with + // identical fixtures. + let temp = tempdir().unwrap(); + let graph = graph_path(temp.path()); + init_graph(&graph); + let schema_path = temp.path().join("plan-parity.pg"); + let next_schema = fs::read_to_string(fixture("test.pg")).unwrap().replace( + " age: I32?\n}", + " age: I32?\n nickname: String?\n}", + ); + fs::write(&schema_path, &next_schema).unwrap(); + + // CLI side. + let cli_output = output_success( + cli() + .arg("schema") + .arg("plan") + .arg("--schema") + .arg(&schema_path) + .arg("--json") + .arg(&graph), + ); + let cli_payload: Value = serde_json::from_slice(&cli_output.stdout).unwrap(); + + // SDK side: open graph, call plan_schema. + let plan = tokio::runtime::Runtime::new().unwrap().block_on(async { + let db = Omnigraph::open(graph.to_string_lossy().as_ref()) + .await + .unwrap(); + db.plan_schema(&next_schema).await.unwrap() + }); + let sdk_steps = serde_json::to_value(&plan.steps).unwrap(); + + assert_eq!( + cli_payload["steps"], sdk_steps, + "CLI plan steps must match SDK plan steps for identical input", + ); + assert_eq!(cli_payload["supported"], plan.supported); +} + +#[test] +fn graphs_subcommand_help_lists_list_only() { + let output = output_success(cli().arg("graphs").arg("--help")); + let stdout = stdout_string(&output); + assert!( + stdout.contains("list"), + "expected `list` subcommand in help output:\n{stdout}" + ); + let lowered = stdout.to_lowercase(); + assert!( + !lowered.contains("create a new graph"), + "graph create should not be in v0.6.0 help; got:\n{stdout}" + ); + assert!( + !lowered.contains("delete a graph"), + "graph delete should not be in v0.6.0 help; got:\n{stdout}" + ); +} + +#[test] +fn graphs_list_against_local_uri_errors_with_remote_only_message() { + let output = output_failure( + cli() + .arg("graphs") + .arg("list") + .arg("--uri") + .arg("/tmp/local"), + ); + let stderr = String::from_utf8_lossy(&output.stderr).into_owned(); + assert!( + stderr.contains("remote multi-graph server URL"), + "expected 'remote multi-graph server URL' rejection in stderr; got:\n{stderr}" + ); +} diff --git a/crates/omnigraph-cli/tests/support/mod.rs b/crates/omnigraph-cli/tests/support/mod.rs index 653be11..586bf93 100644 --- a/crates/omnigraph-cli/tests/support/mod.rs +++ b/crates/omnigraph-cli/tests/support/mod.rs @@ -317,3 +317,353 @@ impl SystemGraph { spawn_server_with_config_env(config, envs) } } + +// ---- helpers moved from the monolithic tests/cli.rs ---- +#[allow(unused_imports)] +use lance::Dataset; +#[allow(unused_imports)] +use lance::index::DatasetIndexExt; +#[allow(unused_imports)] +use omnigraph::db::{Omnigraph, ReadTarget}; + +pub const POLICY_YAML: &str = r#" +version: 1 +groups: + team: [act-andrew, act-bruno] + admins: [act-andrew] +protected_branches: [main] +rules: + - id: team-read + allow: + actors: { group: team } + actions: [read] + branch_scope: any + - id: team-write + allow: + actors: { group: team } + actions: [change] + branch_scope: unprotected + - id: admins-promote + allow: + actors: { group: admins } + actions: [branch_merge] + target_branch_scope: protected +"#; + +pub const POLICY_TESTS_YAML: &str = r#" +version: 1 +cases: + - id: allow-feature-write + actor: act-andrew + action: change + branch: feature + expect: allow + - id: deny-main-write + actor: act-bruno + action: change + branch: main + expect: deny +"#; + +pub fn manifest_dataset_version(graph: &std::path::Path) -> u64 { + tokio::runtime::Runtime::new().unwrap().block_on(async { + Omnigraph::open(graph.to_string_lossy().as_ref()) + .await + .unwrap() + .snapshot_of(ReadTarget::branch("main")) + .await + .unwrap() + .version() + }) +} + +pub fn forge_person_delete_drift(graph: &std::path::Path) -> (u64, u64) { + tokio::runtime::Runtime::new().unwrap().block_on(async { + let uri = graph.to_string_lossy(); + let db = Omnigraph::open(uri.as_ref()).await.unwrap(); + let snap = db + .snapshot_of(ReadTarget::branch("main")) + .await + .unwrap(); + let entry = snap.entry("node:Person").unwrap(); + let full_path = format!("{}/{}", uri.trim_end_matches('/'), entry.table_path); + let mut ds = Dataset::open(&full_path).await.unwrap(); + let deleted = ds.delete("name = 'Alice'").await.unwrap(); + assert_eq!(deleted.num_deleted_rows, 1); + let head = deleted.new_dataset.version().version; + assert!(head > entry.table_version); + (entry.table_version, head) + }) +} + +pub fn write_policy_config_fixture(root: &std::path::Path) -> (std::path::PathBuf, std::path::PathBuf) { + let config = root.join("omnigraph.yaml"); + let policy = root.join("policy.yaml"); + fs::write( + &config, + r#" +project: + name: policy-test-graph +policy: + file: ./policy.yaml +"#, + ) + .unwrap(); + fs::write(&policy, POLICY_YAML).unwrap(); + fs::write(root.join("policy.tests.yaml"), POLICY_TESTS_YAML).unwrap(); + (config, policy) +} + +pub fn write_cluster_config_fixture(root: &std::path::Path) { + fs::write( + root.join("people.pg"), + r#" +node Person { + name: String @key + age: I32? +} +"#, + ) + .unwrap(); + fs::write( + root.join("people.gq"), + r#" +query find_person($name: String) { + match { $p: Person { name: $name } } + return { $p.name, $p.age } +} +"#, + ) + .unwrap(); + fs::write(root.join("base.policy.yaml"), "rules: []\n").unwrap(); + fs::write( + root.join("cluster.yaml"), + r#" +version: 1 +metadata: + name: company-brain +state: + backend: cluster + lock: true +graphs: + knowledge: + schema: ./people.pg + queries: + find_person: + file: ./people.gq +policies: + base: + file: ./base.policy.yaml + applies_to: [knowledge] +"#, + ) + .unwrap(); +} + +pub fn init_cluster_derived_graph(root: &std::path::Path) { + init_named_cluster_graph(root, "knowledge", "people.pg"); +} + +pub fn init_named_cluster_graph(root: &std::path::Path, graph_id: &str, schema_file: &str) { + let graph_dir = root.join("graphs"); + fs::create_dir_all(&graph_dir).unwrap(); + output_success( + cli() + .arg("init") + .arg("--schema") + .arg(root.join(schema_file)) + .arg(graph_dir.join(format!("{graph_id}.omni"))), + ); +} + +pub fn write_cluster_lock(root: &std::path::Path, lock_id: &str, operation: &str) { + let state_dir = root.join("__cluster"); + fs::create_dir_all(&state_dir).unwrap(); + fs::write( + state_dir.join("lock.json"), + format!( + r#"{{"version":1,"lock_id":"{lock_id}","operation":"{operation}","created_at":"1970-01-01T00:00:00Z","pid":123}}"# + ), + ) + .unwrap(); +} + +pub fn write_cluster_applyable_state(root: &std::path::Path) -> serde_json::Value { + let validate = parse_stdout_json(&output_success( + cli() + .arg("cluster") + .arg("validate") + .arg("--config") + .arg(root) + .arg("--json"), + )); + let schema_digest = validate["resource_digests"]["schema.knowledge"] + .as_str() + .unwrap() + .to_string(); + let state_dir = root.join("__cluster"); + fs::create_dir_all(&state_dir).unwrap(); + fs::write( + state_dir.join("state.json"), + format!( + r#"{{ + "version": 1, + "state_revision": 1, + "applied_revision": {{ + "resources": {{ + "graph.knowledge": {{ "digest": "seed" }}, + "schema.knowledge": {{ "digest": "{schema_digest}" }} + }} + }} +}} +"# + ), + ) + .unwrap(); + validate +} + +pub fn cluster_json(root: &std::path::Path, command: &str) -> serde_json::Value { + parse_stdout_json(&output_success( + cli() + .arg("cluster") + .arg(command) + .arg("--config") + .arg(root) + .arg("--json"), + )) +} + +pub fn write_multi_graph_cluster_fixture(root: &std::path::Path) { + write_cluster_config_fixture(root); + fs::write( + root.join("services.pg"), + r#" +node Service { + name: String @key +} +"#, + ) + .unwrap(); + fs::write( + root.join("services.gq"), + r#" +query find_service($name: String) { + match { $s: Service { name: $name } } + return { $s.name } +} +"#, + ) + .unwrap(); + fs::write(root.join("cluster_wide.policy.yaml"), "rules: []\n").unwrap(); + fs::write(root.join("shared.policy.yaml"), "rules: []\n").unwrap(); + fs::write( + root.join("cluster.yaml"), + r#" +version: 1 +metadata: + name: company-brain +state: + backend: cluster + lock: true +graphs: + knowledge: + schema: ./people.pg + queries: + find_person: + file: ./people.gq + engineering: + schema: ./services.pg + queries: + find_service: + file: ./services.gq +policies: + shared: + file: ./shared.policy.yaml + applies_to: [knowledge, engineering] + cluster_wide: + file: ./cluster_wide.policy.yaml + applies_to: [cluster] +"#, + ) + .unwrap(); +} + +pub fn change_for<'j>(json: &'j serde_json::Value, resource: &str) -> &'j serde_json::Value { + json["changes"] + .as_array() + .unwrap() + .iter() + .find(|change| change["resource"] == resource) + .unwrap_or_else(|| panic!("missing change for {resource}: {json}")) +} + +pub fn write_seed_fixture(root: &std::path::Path) -> std::path::PathBuf { + fs::create_dir_all(root.join("data")).unwrap(); + fs::create_dir_all(root.join("build")).unwrap(); + let raw_seed = root.join("data/seed.jsonl"); + let seed = root.join("seed.yaml"); + + fs::write( + &raw_seed, + concat!( + "{\"type\":\"Decision\",\"data\":{\"slug\":\"dec-alpha\",\"intent\":\"Alpha ship\"}}\n", + "{\"type\":\"Decision\",\"data\":{\"slug\":\"dec-beta\",\"intent\":\"Beta ship\",\"embedding\":[0.1,0.2]}}\n" + ), + ) + .unwrap(); + + fs::write( + &seed, + concat!( + "graph:\n", + " slug: mr-context-graph\n", + "sources:\n", + " raw_seed: ./data/seed.jsonl\n", + "artifacts:\n", + " embedded_seed: ./build/seed.embedded.jsonl\n", + "embeddings:\n", + " model: gemini-embedding-2-preview\n", + " dimension: 4\n", + " types:\n", + " Decision:\n", + " target: embedding\n", + " fields: [slug, intent]\n" + ), + ) + .unwrap(); + + seed +} + +pub fn write_seed_fixture_with_edge(root: &std::path::Path) -> std::path::PathBuf { + let seed = write_seed_fixture(root); + let raw_seed = root.join("data/seed.jsonl"); + fs::write( + &raw_seed, + concat!( + "{\"type\":\"Decision\",\"data\":{\"slug\":\"dec-alpha\",\"intent\":\"Alpha ship\"}}\n", + "{\"type\":\"Decision\",\"data\":{\"slug\":\"dec-beta\",\"intent\":\"Beta ship\",\"embedding\":[0.1,0.2]}}\n", + "{\"edge\":\"Triggered\",\"from\":\"sig-alpha\",\"to\":\"dec-alpha\"}\n" + ), + ) + .unwrap(); + seed +} + +pub fn read_embedded_rows(path: std::path::PathBuf) -> Vec { + fs::read_to_string(path) + .unwrap() + .lines() + .filter(|line| !line.trim().is_empty()) + .map(|line| serde_json::from_str(line).unwrap()) + .collect() +} + +pub fn queries_test_config(graph_uri: &str, entry: &str, gq_file: &str) -> String { + format!( + "graphs:\n local:\n uri: '{}'\n queries:\n {entry}:\n file: ./{gq_file}\n\ + cli:\n graph: local\npolicy: {{}}\n", + graph_uri.replace('\'', "''") + ) +}