mirror of
https://github.com/ModernRelay/omnigraph.git
synced 2026-06-15 01:55:13 +02:00
* chore(deps): bump clap to 4.6.1
Workspace constraint "4" → "4.6" so the resolver picks up the 4.6 line
(a plain `cargo update` stayed on 4.5.x). clap 4.5.58 → 4.6.1
(clap_builder 4.6.0, clap_derive 4.6.1). Minor bump, no API breakage; the
workspace builds and all CLI suites pass unchanged.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* feat(cli): group --help by plane (RFC-010 Slice 2)
Slice 1 declared the planes (the command_plane table + the wrong-plane
guard); this makes them visible in `--help`. clap can't print labeled
heading rows between subcommand groups (verified against the source —
help_heading is args-only, {subcommands} is one flat block), so per the
chosen approach: cluster + legend.
- Reorder the `Command` enum into plane bands (clap lists subcommands in
declaration order): data (query, mutate, load, branch, snapshot, export,
commit, schema, graphs) → storage/local-graph ops (init, optimize,
repair, cleanup, lint, queries) → control (cluster) → session (policy,
embed, login, logout, config, version). No magic display_order numbers —
the source order IS the help order, with band comments for readers. The
band placement matches `command_plane` (lint/queries are storage-plane:
they reject --server), so the help grouping and the guard agree.
- Add an `after_help` legend on `Cli` naming the planes. Written to
describe the planes (not enumerate every command) so it doesn't drift.
Help-polish (post-review): hide the deprecated `ingest` from the list
(still a valid command); trim the long `login` and `--as` descriptions to
one line each so the columns don't blow up.
The behavioral source of truth for planes stays `planes::command_plane`;
this ordering is its cosmetic counterpart.
Test: `help_groups_commands_by_plane` pins the legend phrase + the cluster
ordering (query < optimize < cluster). Doc: a line under cli-reference's
*Command planes* section.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* feat(cli): qualify mixed-plane commands in the --help legend
Addresses the Greptile P2 on #220: the legend placed `schema` entirely in
Data and `queries` entirely in Storage, but per `command_plane` the
subcommands differ — `schema plan` is storage-plane (rejects --server) and
`queries list` is session (no graph). A user reading the legend then running
`schema plan --server` would hit a rejection contradicting it. The Commands
list is one entry per top-level command (necessarily coarse), so the legend
carries the nuance: `schema [plan: storage]` and `queries [list: session]`.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
702 lines
23 KiB
Rust
702 lines
23 KiB
Rust
//! 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 help_groups_commands_by_plane() {
|
|
// RFC-010 Slice 2: `--help` clusters commands by plane (declaration order
|
|
// in the Command enum) and explains the planes in an after_help legend.
|
|
// Pinned lightly — the legend phrase + the cluster ordering — to avoid
|
|
// brittle full-text assertions on clap's help body.
|
|
let output = output_success(cli().arg("--help"));
|
|
let stdout = stdout_string(&output);
|
|
|
|
assert!(
|
|
stdout.contains("COMMANDS BY PLANE"),
|
|
"plane legend (after_help) missing from --help:\n{stdout}"
|
|
);
|
|
|
|
// The Commands list precedes the legend, so first occurrences sit in the
|
|
// list and must appear in plane order: a data verb, then a storage verb,
|
|
// then the control verb.
|
|
let pos = |needle: &str| {
|
|
stdout
|
|
.find(needle)
|
|
.unwrap_or_else(|| panic!("'{needle}' not found in --help:\n{stdout}"))
|
|
};
|
|
assert!(
|
|
pos("query") < pos("optimize"),
|
|
"data commands should be listed before storage commands"
|
|
);
|
|
assert!(
|
|
pos("optimize") < pos("cluster"),
|
|
"storage commands should be listed before the control command"
|
|
);
|
|
}
|
|
|
|
#[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());
|
|
// RFC-008 stage 3: init no longer scaffolds the legacy config file.
|
|
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_with_server_flag_errors_wrong_plane() {
|
|
// RFC-010 Slice 1: `schema plan` is storage-plane while `schema show/apply`
|
|
// are data-plane — the guard rejects --server on plan with the per-subcommand
|
|
// label (proving command_plane/command_label descend into the nested enum).
|
|
let output = output_failure(
|
|
cli()
|
|
.arg("schema")
|
|
.arg("plan")
|
|
.arg("--schema")
|
|
.arg(fixture("test.pg"))
|
|
.arg("--server")
|
|
.arg("prod"),
|
|
);
|
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
assert!(
|
|
stderr.contains("`schema plan` is a storage-plane command")
|
|
&& stderr.contains("Use --target <name> or a storage URI."),
|
|
"schema plan wrong-plane message not found; got: {stderr}"
|
|
);
|
|
}
|
|
|
|
#[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}"
|
|
);
|
|
}
|
|
|
|
/// RFC-008 stage 1: loading a legacy omnigraph.yaml emits the per-key
|
|
/// deprecation block (the migration map applied to THIS file), suppressible
|
|
/// via OMNIGRAPH_SUPPRESS_YAML_DEPRECATION.
|
|
#[test]
|
|
fn legacy_config_load_warns_per_key_and_suppression_silences() {
|
|
let temp = tempdir().unwrap();
|
|
fs::write(
|
|
temp.path().join("omnigraph.yaml"),
|
|
"cli:\n actor: act-x\ngraphs:\n g:\n uri: /tmp/never-opened\n",
|
|
)
|
|
.unwrap();
|
|
|
|
// `graphs list --json` loads the config and exits without touching the
|
|
// graph URI.
|
|
let output = cli()
|
|
.current_dir(temp.path())
|
|
.arg("graphs")
|
|
.arg("list")
|
|
.arg("--json")
|
|
.output()
|
|
.unwrap();
|
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
assert!(
|
|
stderr.contains("deprecated (RFC-008)") && stderr.contains("`cli.actor` -> `operator.actor`"),
|
|
"{stderr}"
|
|
);
|
|
assert!(stderr.contains("config migrate"), "{stderr}");
|
|
|
|
let output = cli()
|
|
.current_dir(temp.path())
|
|
.env("OMNIGRAPH_SUPPRESS_YAML_DEPRECATION", "1")
|
|
.arg("graphs")
|
|
.arg("list")
|
|
.arg("--json")
|
|
.output()
|
|
.unwrap();
|
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
assert!(!stderr.contains("deprecated (RFC-008)"), "{stderr}");
|
|
}
|
|
|
|
/// RFC-008 stage 2: `config migrate` proposes the split read-only, applies
|
|
/// it with --write (operator merge never clobbers; cluster.yaml emitted),
|
|
/// and a second --write is idempotent.
|
|
#[test]
|
|
fn config_migrate_splits_legacy_config() {
|
|
let temp = tempdir().unwrap();
|
|
fs::write(
|
|
temp.path().join("omnigraph.yaml"),
|
|
"graphs:\n prod:\n uri: https://graph.example.com\n bearer_token_env: PROD_TOKEN\ncli:\n actor: act-me\n output_format: json\npolicy:\n file: ./top.policy.yaml\n",
|
|
)
|
|
.unwrap();
|
|
let operator_home = tempfile::tempdir().unwrap();
|
|
fs::write(
|
|
operator_home.path().join("config.yaml"),
|
|
"operator:\n actor: act-existing\n",
|
|
)
|
|
.unwrap();
|
|
|
|
// Read-only proposal: names both halves, writes nothing.
|
|
let output = cli()
|
|
.current_dir(temp.path())
|
|
.env("OMNIGRAPH_HOME", operator_home.path())
|
|
.env("OMNIGRAPH_SUPPRESS_YAML_DEPRECATION", "1")
|
|
.arg("config")
|
|
.arg("migrate")
|
|
.output()
|
|
.unwrap();
|
|
assert!(output.status.success(), "{output:?}");
|
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
assert!(stdout.contains("team half -> cluster.yaml"), "{stdout}");
|
|
assert!(stdout.contains("operator.actor: act-me"), "{stdout}");
|
|
assert!(stdout.contains("omnigraph login prod"), "{stdout}");
|
|
assert!(!temp.path().join("cluster.yaml").exists());
|
|
|
|
// --write: cluster.yaml lands; the existing operator actor is KEPT.
|
|
let output = cli()
|
|
.current_dir(temp.path())
|
|
.env("OMNIGRAPH_HOME", operator_home.path())
|
|
.env("OMNIGRAPH_SUPPRESS_YAML_DEPRECATION", "1")
|
|
.arg("config")
|
|
.arg("migrate")
|
|
.arg("--write")
|
|
.output()
|
|
.unwrap();
|
|
assert!(output.status.success(), "{output:?}");
|
|
let cluster = fs::read_to_string(temp.path().join("cluster.yaml")).unwrap();
|
|
assert!(cluster.contains("version: 1") && cluster.contains(" prod:"), "{cluster}");
|
|
let operator_text =
|
|
fs::read_to_string(operator_home.path().join("config.yaml")).unwrap();
|
|
assert!(operator_text.contains("act-existing"), "{operator_text}");
|
|
assert!(!operator_text.contains("act-me"), "existing keys win: {operator_text}");
|
|
assert!(operator_text.contains("output: json"), "{operator_text}");
|
|
assert!(
|
|
operator_text.contains("url: https://graph.example.com"),
|
|
"{operator_text}"
|
|
);
|
|
|
|
// Second --write: cluster.yaml exists -> proposal file, no clobber.
|
|
let output = cli()
|
|
.current_dir(temp.path())
|
|
.env("OMNIGRAPH_HOME", operator_home.path())
|
|
.env("OMNIGRAPH_SUPPRESS_YAML_DEPRECATION", "1")
|
|
.arg("config")
|
|
.arg("migrate")
|
|
.arg("--write")
|
|
.output()
|
|
.unwrap();
|
|
assert!(output.status.success(), "{output:?}");
|
|
assert!(temp.path().join("cluster.yaml.proposed").exists());
|
|
}
|
|
|
|
/// RFC-008 stage 4: OMNIGRAPH_NO_LEGACY_CONFIG refuses a present legacy
|
|
/// file (pointing at config migrate) but changes nothing on migrated
|
|
/// setups with no file.
|
|
#[test]
|
|
fn strict_mode_refuses_legacy_file_but_not_its_absence() {
|
|
let temp = tempdir().unwrap();
|
|
fs::write(temp.path().join("omnigraph.yaml"), "cli:\n actor: a\n").unwrap();
|
|
let output = cli()
|
|
.current_dir(temp.path())
|
|
.env("OMNIGRAPH_NO_LEGACY_CONFIG", "1")
|
|
.arg("graphs")
|
|
.arg("list")
|
|
.arg("--json")
|
|
.output()
|
|
.unwrap();
|
|
assert!(!output.status.success());
|
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
assert!(
|
|
stderr.contains("OMNIGRAPH_NO_LEGACY_CONFIG") && stderr.contains("config migrate"),
|
|
"{stderr}"
|
|
);
|
|
|
|
// Migrated setup (no file): strict mode is a no-op — a config-loading
|
|
// command that tolerates empty defaults succeeds.
|
|
let clean = tempdir().unwrap();
|
|
let output = cli()
|
|
.current_dir(clean.path())
|
|
.env("OMNIGRAPH_NO_LEGACY_CONFIG", "1")
|
|
.arg("queries")
|
|
.arg("list")
|
|
.arg("--json")
|
|
.output()
|
|
.unwrap();
|
|
assert!(output.status.success(), "{output:?}");
|
|
}
|