omnigraph/crates/omnigraph-cli/tests/cli_queries.rs
Andrew Altshuler 3feb23af05
feat(cli): surface stored-query @description/@instruction in queries list (#280)
* test: e2e coverage for @description/@instruction surfaces

Add end-to-end tests pinning the two annotation surfaces as they exist
today, at their real boundaries:

- engine (lifecycle.rs): schema-level @description (node/edge/property)
  and @instruction (node/edge) persist verbatim into the on-disk
  _schema.ir.json through Omnigraph::init; property-level @instruction
  aborts init and writes no schema IR.
- server (stored_queries.rs): query-level @description/@instruction on a
  stored query surface as typed QueryCatalogEntry fields over
  GET /queries, and a query declaring neither omits both fields.

No behavior change — these document the current contract.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(cli): surface stored-query @description/@instruction in `queries list`

A stored query's @description/@instruction are its catalog metadata —
what it does and how to invoke it. The HTTP GET /queries catalog already
carries them, but `omnigraph queries list` dropped both fields in human
and --json output even though they were available on the registry entry.

Carry description/instruction on QueriesListItem (Option, skipped when
None) and copy them from the query decl. Human output prints an indented
`description:` / `instruction:` line per query when present; --json
includes the fields when present and omits them otherwise — matching the
HTTP catalog shape documented in docs/user/operations/server.md.

Tests (cli_queries.rs): a query with both annotations surfaces them in
human + --json; a query with neither prints no annotation lines and omits
both JSON fields.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(cli): document `queries list` output incl. description/instruction

Per AGENTS.md maintenance Rule 1, document the user-visible `queries list`
output alongside the field addition. The `queries` command family had no
row in the CLI reference top-level table; add one covering `list` (human
+ --json shapes, with description/instruction shown only when declared,
matching the HTTP GET /queries catalog) and `validate`.

Addresses the Greptile P2 review finding on PR #280.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(cli): indent multiline stored-query annotations in `queries list`

A `@description`/`@instruction` value can be multiline (GQ string literals
admit newlines), which made the human `queries list` output break back to
the left margin on continuation lines. Indent continuation lines to align
under the first via a `print_query_annotation` helper. Addresses review
feedback from @martin-g on PR #280.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Ragnor Comerford <ragnor.comerford@gmail.com>
2026-06-19 14:26:50 +03:00

384 lines
13 KiB
Rust

//! Stored-query commands and alias resolution.
//! Moved verbatim from tests/cli.rs in the modularization.
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));
}
// Legacy `omnigraph.yaml` `aliases:` invoked via the `--alias` flag were
// removed in RFC-011 D4 — operator aliases now live under `omnigraph alias
// <name>` (the happy path is covered by system_local's operator-alias e2e).
// The legacy file-alias path has no CLI entry point.
#[test]
fn alias_flag_is_removed_from_query() {
// RFC-011 D4: `--alias` no longer exists on query/mutate; use `alias <name>`.
let output = output_failure(cli().arg("query").arg("--alias").arg("who"));
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("unexpected argument") && stderr.contains("--alias"),
"expected clap to reject --alias on query; got: {stderr}"
);
}
#[test]
fn alias_unknown_name_errors_listing_defined() {
// Hermetic: an unknown alias fails before any network, listing defined ones.
let home = tempdir().unwrap();
std::fs::write(
home.path().join("config.yaml"),
"servers:\n dev:\n url: https://x\naliases:\n who:\n server: dev\n query: find_person\n",
)
.unwrap();
let output = output_failure(
cli()
.env("OMNIGRAPH_HOME", home.path())
.arg("alias")
.arg("nope"),
);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("unknown alias 'nope'") && stderr.contains("who"),
"expected an unknown-alias error listing defined aliases; got: {stderr}"
);
}
#[test]
fn alias_rejects_global_scope_flags_that_the_binding_owns() {
for (flag, value) in [
("--server", "dev"),
("--graph", "local"),
("--store", "file:///tmp/graph.omni"),
("--cluster", "."),
("--profile", "prod"),
("--as", "act-op"),
] {
let output = output_failure(cli().arg(flag).arg(value).arg("alias").arg("who"));
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("`alias` uses the server, graph, and stored query")
&& stderr.contains(flag),
"expected {flag} to be rejected by the alias binding guard; got: {stderr}"
);
}
}
#[test]
fn queries_and_policy_wrong_server_scope_points_at_cluster_scope() {
let output = output_failure(cli().arg("--server").arg("prod").arg("queries").arg("list"));
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("pass --cluster <dir|uri>") && !stderr.contains("pass --config <dir>"),
"queries should point at --cluster, not --config; got: {stderr}"
);
let output = output_failure(
cli()
.arg("--server")
.arg("prod")
.arg("policy")
.arg("validate"),
);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("pass --cluster <dir|uri>") && !stderr.contains("pass --config <dir>"),
"policy should point at --cluster, not --config; got: {stderr}"
);
}
// RFC-011: `queries validate`/`list` source the registry + schemas from a
// converged cluster's applied state (`--cluster <dir>`), not omnigraph.yaml.
/// Build a converged single-graph cluster (id `knowledge`) with one stored
/// query. `query_block` is the YAML under the graph's `queries:` key.
fn converged_cluster_with_query(query_file: &str, query_src: &str, query_block: &str) -> tempfile::TempDir {
let temp = tempdir().unwrap();
let dir = temp.path();
std::fs::copy(fixture("test.pg"), dir.join("graph.pg")).unwrap();
write_query_file(&dir.join(query_file), query_src);
std::fs::write(
dir.join("cluster.yaml"),
format!(
"version: 1\nmetadata:\n name: sys\nstate:\n backend: cluster\n lock: true\n\
graphs:\n knowledge:\n schema: ./graph.pg\n queries:\n{query_block}"
),
)
.unwrap();
output_success(cli().arg("cluster").arg("import").arg("--config").arg(dir));
output_success(cli().arg("cluster").arg("apply").arg("--config").arg(dir));
temp
}
#[test]
fn queries_validate_exits_zero_on_clean_registry() {
let cluster = converged_cluster_with_query(
"find_person.gq",
"query find_person($name: String) { match { $p: Person { name: $name } } return { $p.age } }",
" find_person:\n file: ./find_person.gq\n",
);
let output = output_success(
cli()
.arg("queries")
.arg("validate")
.arg("--cluster")
.arg(cluster.path()),
);
let stdout = stdout_string(&output);
assert!(stdout.contains("OK"), "stdout:\n{stdout}");
}
#[test]
fn cluster_import_rejects_a_type_broken_query() {
// In the cluster model a stored query is type-checked at the cluster
// boundary (import/apply), so a broken query can never reach the applied
// state `queries validate` reads — the gate is upstream. `Widget` is not in
// the fixture schema, so import must reject it, naming the query.
let temp = tempdir().unwrap();
let dir = temp.path();
std::fs::copy(fixture("test.pg"), dir.join("graph.pg")).unwrap();
write_query_file(
&dir.join("ghost.gq"),
"query ghost() { match { $w: Widget } return { $w.name } }",
);
std::fs::write(
dir.join("cluster.yaml"),
"version: 1\nmetadata:\n name: sys\nstate:\n backend: cluster\n lock: true\n\
graphs:\n knowledge:\n schema: ./graph.pg\n queries:\n ghost:\n file: ./ghost.gq\n",
)
.unwrap();
let output = output_failure(cli().arg("cluster").arg("import").arg("--config").arg(dir));
let combined = format!(
"{}{}",
stdout_string(&output),
String::from_utf8_lossy(&output.stderr)
);
assert!(
combined.contains("ghost"),
"cluster import must reject the broken query, naming it; got:\n{combined}"
);
}
#[test]
fn queries_list_prints_registered_query() {
let cluster = converged_cluster_with_query(
"find_person.gq",
"query find_person($name: String) { match { $p: Person { name: $name } } return { $p.age } }",
" find_person:\n file: ./find_person.gq\n",
);
let output = output_success(
cli()
.arg("queries")
.arg("list")
.arg("--cluster")
.arg(cluster.path()),
);
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}"
);
}
#[test]
fn queries_list_surfaces_description_and_instruction() {
// `@description`/`@instruction` are the whole point of a stored query in a
// catalog — they tell an agent/operator what it does and how to invoke it.
// The CLI catalog must surface them in both human and --json output, to
// match the HTTP `GET /queries` surface.
let cluster = converged_cluster_with_query(
"described.gq",
"query described($name: String) \
@description(\"Find a person by exact name.\") \
@instruction(\"Use for exact lookups; prefer search for fuzzy matches.\") \
{ match { $p: Person { name: $name } } return { $p.age } }",
" described:\n file: ./described.gq\n",
);
// Human output.
let output = output_success(
cli().arg("queries").arg("list").arg("--cluster").arg(cluster.path()),
);
let stdout = stdout_string(&output);
assert!(
stdout.contains("description: Find a person by exact name."),
"human list must show @description; stdout:\n{stdout}"
);
assert!(
stdout.contains("instruction: Use for exact lookups; prefer search for fuzzy matches."),
"human list must show @instruction; stdout:\n{stdout}"
);
// --json output.
let output = output_success(
cli()
.arg("queries")
.arg("list")
.arg("--cluster")
.arg(cluster.path())
.arg("--json"),
);
let body: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
let entry = body["queries"]
.as_array()
.unwrap()
.iter()
.find(|q| q["name"] == "described")
.unwrap();
assert_eq!(entry["description"], "Find a person by exact name.");
assert_eq!(
entry["instruction"],
"Use for exact lookups; prefer search for fuzzy matches."
);
}
#[test]
fn queries_list_indents_multiline_annotation_continuation() {
// GQ string literals admit newlines, so a `@description`/`@instruction`
// can be multiline. Human output must indent continuation lines to align
// under the first rather than breaking back to the left margin.
let cluster = converged_cluster_with_query(
"multi.gq",
"query multi($name: String) \
@description(\"line one\\nline two\") \
{ match { $p: Person { name: $name } } return { $p.age } }",
" multi:\n file: ./multi.gq\n",
);
let output = output_success(
cli().arg("queries").arg("list").arg("--cluster").arg(cluster.path()),
);
let stdout = stdout_string(&output);
// " description: " is 17 chars wide; the continuation aligns under it.
assert!(
stdout.contains(" description: line one\n line two"),
"multiline annotation must indent the continuation; stdout:\n{stdout}"
);
}
#[test]
fn queries_list_omits_annotations_when_absent() {
// The other half of the contract: a query that declares neither annotation
// prints no extra lines and omits both JSON fields entirely. This keeps the
// catalog clean rather than echoing empty `description:`/`instruction:`.
let cluster = converged_cluster_with_query(
"bare.gq",
"query bare() { match { $p: Person } return { $p.name } }",
" bare:\n file: ./bare.gq\n",
);
// Human output: the query is listed, but no annotation lines.
let output = output_success(
cli().arg("queries").arg("list").arg("--cluster").arg(cluster.path()),
);
let stdout = stdout_string(&output);
assert!(stdout.contains("bare()"), "stdout:\n{stdout}");
assert!(
!stdout.contains("description:") && !stdout.contains("instruction:"),
"a query without annotations prints no annotation lines; stdout:\n{stdout}"
);
// --json output: both fields omitted (not present as null).
let output = output_success(
cli()
.arg("queries")
.arg("list")
.arg("--cluster")
.arg(cluster.path())
.arg("--json"),
);
let body: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
let entry = body["queries"]
.as_array()
.unwrap()
.iter()
.find(|q| q["name"] == "bare")
.unwrap();
assert!(
entry.get("description").is_none() && entry.get("instruction").is_none(),
"a query without annotations omits both JSON fields: {entry}"
);
}
#[test]
fn queries_validate_requires_a_cluster() {
// RFC-011: with no --cluster (and no cluster profile), the command errors
// loudly rather than reading any omnigraph.yaml.
let output = output_failure(cli().arg("queries").arg("validate"));
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("needs a cluster") || stderr.contains("--cluster"),
"queries validate must require a cluster; stderr:\n{stderr}"
);
}
#[test]
fn queries_validate_graph_filter_selects_one_graph() {
// A multi-graph cluster: validate scoped to `knowledge` type-checks only
// that graph's registry, ignoring `engineering`'s.
let temp = tempdir().unwrap();
let dir = temp.path();
write_multi_graph_cluster_fixture(dir);
output_success(cli().arg("cluster").arg("import").arg("--config").arg(dir));
output_success(cli().arg("cluster").arg("apply").arg("--config").arg(dir));
let output = output_success(
cli()
.arg("queries")
.arg("validate")
.arg("--cluster")
.arg(dir)
.arg("--graph")
.arg("knowledge"),
);
assert!(stdout_string(&output).contains("OK"));
}