feat(aura-cli, aura-runner): JSON data namespace — info verb, NDJSON list, coverage retired

Reshape the aura data namespace around one principle: every verb emits
JSON, no format flag (the headless-agent posture #264 named).

- aura data info <symbol> (new): one flat JSON object — symbol plus the
  six neutral geometry fields (digits, pipSize, tickSize, lotSize,
  baseAsset, quoteAsset) from DataServer::symbol_meta when the
  <SYMBOL>.meta.json sidecar yields geometry; a symbol is known via bar
  files OR sidecar (a sidecar-only symbol still reports its geometry);
  an unknown symbol refuses with prose, exit 1. description stays out
  until the neutral reader exposes it (data-server#4).
- aura data list: NDJSON — one JSON string per line; an empty archive
  emits zero stdout lines, with the human affordance moved to a stderr
  note in the #278 class vocabulary.
- aura data coverage: retired — #272's per-cell fault isolation subsumed
  the prophylactic pre-run check; aura-runner's data_coverage_report and
  its presentation helpers go with it, while interior_gap_months stays
  as the campaign path's single gap walk (#295).

The three derived design decisions (description omitted, broader
refusal prose, empty-archive stderr notice) are minuted on the issue.

closes #273
This commit is contained in:
2026-07-25 12:25:45 +02:00
parent 32eb5a6a9e
commit 9cfe2965c0
3 changed files with 322 additions and 218 deletions
+167 -59
View File
@@ -5,7 +5,7 @@ use std::path::Path;
use std::process::Command;
mod common;
use common::{fresh_project, fresh_project_with_data, fresh_project_with_gapped_data};
use common::{fresh_project, fresh_project_with_data};
/// Path to the freshly-built `aura` binary (Cargo sets this env var for the test
/// crate; the binary is named `aura` in `Cargo.toml`).
@@ -6738,61 +6738,18 @@ fn shipped_r_meanrev_example_runs_end_to_end_via_aura_run() {
assert!(stdout.contains("\"total_pips\":0.0"), "{stdout}");
}
/// Property (#264, coverage cut 1): `aura data coverage <SYMBOL>` surfaces the
/// archive's month COVERAGE from the monthly file index — the first and last
/// present month AND every interior missing-month range (a gap) — so the Copper
/// failure mode is visible up front. Copper had files from 2016-08 but was
/// missing 2018-01..2018-10, so a first/last-bounds check passed while a
/// window opening in the hole had no data, aborting a multi-hour campaign
/// mid-run; a per-symbol gap report would have caught it before the run. Over a
/// hermetic synthetic archive whose `GAPSYM` has 2024-01..02 and 2024-05..06
/// present and 2024-03..04 absent, the verb exits 0, frames the span by its
/// first (2024-01) and last (2024-06) present month, and reports the interior
/// hole as a single collapsed month-range line `missing: 2024-03..2024-04`
/// (one line for the whole contiguous gap, as Copper's ten-month hole would be
/// one line — not one line per missing month).
/// Property (#273, list reshape): `aura data list` enumerates the archive's
/// symbols as NDJSON, sorted — the discovery step a campaign author (or an
/// agent operating through the CLI alone, the deployment posture) needs
/// before `aura data info <symbol>`. The archive root resolves through the
/// same project path as `info` (`Env::data_path` — a project-local `[paths]
/// data` override when set). Over a hermetic synthetic archive holding two
/// symbols (`SYMA` 2024-01..08 and `SYMB` 2024-03..06), the verb exits 0 and
/// prints EXACTLY two lines, each a JSON string parseable via
/// `serde_json::from_str::<String>`, in ascending order — `"SYMA"` before
/// `"SYMB"`, nothing else.
#[test]
fn data_coverage_reports_month_span_and_interior_gap_range() {
let (cwd, _g) = fresh_project_with_gapped_data();
let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
.args(["data", "coverage", "GAPSYM"])
.current_dir(&cwd)
.output()
.expect("spawn aura data coverage");
assert_eq!(
out.status.code(),
Some(0),
"exit: {:?}, stderr: {}",
out.status,
String::from_utf8_lossy(&out.stderr)
);
let stdout = String::from_utf8(out.stdout).expect("utf-8 stdout");
// The interior gap is the headline — the Copper failure the verb exists to
// surface — reported as one collapsed range for the whole contiguous hole.
assert!(
stdout.contains("missing: 2024-03..2024-04"),
"interior gap month-range reported as one line: {stdout}"
);
// The span bounds frame the gap: first and last present month.
assert!(
stdout.contains("2024-01") && stdout.contains("2024-06"),
"first (2024-01) and last (2024-06) present month reported: {stdout}"
);
}
/// Property (#264, list cut 2): `aura data list` enumerates the symbols the
/// archive contains, one per line, sorted — the discovery step a campaign
/// author (or an agent operating through the CLI alone, the deployment
/// posture) needs before `aura data coverage <symbol>`, since without it the
/// inventory is obtainable only by listing the archive directory outside
/// aura. The archive root resolves through the same project path as `coverage`
/// (`Env::data_path` — a project-local `[paths] data` override when set). Over
/// a hermetic synthetic archive holding two symbols (`SYMA` 2024-01..08 and
/// `SYMB` 2024-03..06), the verb exits 0 and prints EXACTLY the two symbol
/// names in ascending order, one per line — `SYMA` before `SYMB`, nothing
/// else.
#[test]
fn data_list_enumerates_archive_symbols_one_per_line_sorted() {
fn data_list_on_a_populated_archive_is_ndjson_one_symbol_per_line() {
let (cwd, _g) = fresh_project_with_data();
let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
.args(["data", "list"])
@@ -6807,10 +6764,161 @@ fn data_list_enumerates_archive_symbols_one_per_line_sorted() {
String::from_utf8_lossy(&out.stderr)
);
let stdout = String::from_utf8(out.stdout).expect("utf-8 stdout");
// The whole contract: the two archive symbols, sorted, one per line, and
// nothing else — this is the inventory a campaign author reads before
// scoping an instrument matrix.
assert_eq!(stdout, "SYMA\nSYMB\n", "sorted symbol enumeration: {stdout:?}");
let lines: Vec<&str> = stdout.lines().collect();
assert_eq!(lines.len(), 2, "exactly the two archive symbols: {stdout:?}");
let parsed: Vec<String> = lines
.iter()
.map(|l| serde_json::from_str::<String>(l).unwrap_or_else(|e| panic!("line {l:?} is a JSON string: {e}")))
.collect();
assert_eq!(parsed, vec!["SYMA".to_string(), "SYMB".to_string()], "sorted NDJSON enumeration: {stdout:?}");
}
/// Property (#273 design minutes, 2026-07-25): an empty archive under the
/// all-JSON namespace prints ZERO stdout lines — the honest NDJSON of an
/// empty set, since a placeholder prose line would break `| jq`-style
/// consumption — with the human affordance carried on stderr instead (the
/// `aura: note:` class, #278).
#[test]
fn data_list_on_an_empty_archive_is_empty_stdout_with_a_stderr_notice() {
let (cwd, _g) = fresh_project_with_data();
let data_dir = cwd.join("data");
std::fs::remove_dir_all(&data_dir).expect("clear the synthetic archive");
std::fs::create_dir_all(&data_dir).expect("recreate an empty synthetic archive dir");
let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
.args(["data", "list"])
.current_dir(&cwd)
.output()
.expect("spawn aura data list");
assert_eq!(
out.status.code(),
Some(0),
"exit: {:?}, stderr: {}",
out.status,
String::from_utf8_lossy(&out.stderr)
);
assert_eq!(out.stdout, b"", "empty archive: zero stdout lines");
let stderr = String::from_utf8(out.stderr).expect("utf-8 stderr");
assert!(stderr.contains("aura: note:") && stderr.contains("no symbols"), "stderr carries the notice: {stderr}");
}
/// Property (#273): `aura data info <SYMBOL>` on a symbol the archive holds
/// bar files for but no `<SYMBOL>.meta.json` sidecar reports bare identity —
/// exit 0, stdout is exactly `{"symbol":"…"}`, no `null` geometry fields.
#[test]
fn data_info_on_a_symbol_without_sidecar_is_bare_symbol_json() {
let (cwd, _g) = fresh_project_with_data();
std::fs::remove_file(cwd.join("data/SYMB.meta.json")).expect("drop SYMB's sidecar");
let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
.args(["data", "info", "SYMB"])
.current_dir(&cwd)
.output()
.expect("spawn aura data info");
assert_eq!(
out.status.code(),
Some(0),
"exit: {:?}, stderr: {}",
out.status,
String::from_utf8_lossy(&out.stderr)
);
assert_eq!(String::from_utf8(out.stdout).expect("utf-8 stdout"), "{\"symbol\":\"SYMB\"}\n");
}
/// Property (#273): `aura data info <SYMBOL>` on a symbol carrying a usable
/// geometry sidecar surfaces exactly the six neutral geometry fields
/// (`digits`, `pipSize`, `tickSize`, `lotSize`, `baseAsset`, `quoteAsset`)
/// alongside `symbol` — and NOTHING provider-specific (`description`,
/// `generator`, `schemaVersion`) leaks through, even though the sidecar
/// itself carries those fields: `InstrumentGeometry`'s reader never parses
/// them out of the sidecar JSON in the first place (#273 acceptance).
#[test]
fn data_info_on_a_symbol_with_a_sidecar_carries_the_six_geometry_fields() {
let (cwd, _g) = fresh_project_with_data();
std::fs::write(
cwd.join("data/SYMA.meta.json"),
r#"{"schemaVersion":2,"generator":"MS_SimpleExport (cTrader.Automate)","description":"Euro vs US Dollar","digits":5,"pipSize":0.0001,"tickSize":1e-05,"lotSize":100000,"baseAsset":"EUR","quoteAsset":"USD"}"#,
)
.expect("overwrite SYMA's sidecar with the EURUSD-shaped fixture");
let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
.args(["data", "info", "SYMA"])
.current_dir(&cwd)
.output()
.expect("spawn aura data info");
assert_eq!(
out.status.code(),
Some(0),
"exit: {:?}, stderr: {}",
out.status,
String::from_utf8_lossy(&out.stderr)
);
let stdout = String::from_utf8(out.stdout).expect("utf-8 stdout");
let obj: serde_json::Value = serde_json::from_str(stdout.trim()).expect("one JSON object");
let map = obj.as_object().expect("a flat object");
let mut keys: Vec<&str> = map.keys().map(String::as_str).collect();
keys.sort_unstable();
assert_eq!(
keys,
vec!["baseAsset", "digits", "lotSize", "pipSize", "quoteAsset", "symbol", "tickSize"],
"exactly symbol + the six geometry fields, no provider metadata: {stdout}"
);
assert_eq!(map["symbol"], "SYMA");
assert_eq!(map["digits"], 5);
assert_eq!(map["baseAsset"], "EUR");
assert_eq!(map["quoteAsset"], "USD");
}
/// Property (#273): a symbol carrying a usable geometry sidecar but NO
/// archive bar files is still KNOWN to `aura data info` — the `symbol_meta`
/// arm of the known-check's OR, independent of `has_symbol` — and its
/// geometry reports normally, exit 0. This is the real-world case of a
/// sidecar exported ahead of the bars themselves being ingested.
#[test]
fn data_info_on_a_sidecar_only_symbol_reports_geometry() {
let (cwd, _g) = fresh_project_with_data();
std::fs::write(
cwd.join("data/METAONLY.meta.json"),
r#"{"schemaVersion":2,"generator":"MS_SimpleExport (cTrader.Automate)","description":"Euro vs US Dollar","digits":5,"pipSize":0.0001,"tickSize":1e-05,"lotSize":100000,"baseAsset":"EUR","quoteAsset":"USD"}"#,
)
.expect("write METAONLY's sidecar-only fixture (no archive bar files)");
let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
.args(["data", "info", "METAONLY"])
.current_dir(&cwd)
.output()
.expect("spawn aura data info");
assert_eq!(
out.status.code(),
Some(0),
"exit: {:?}, stderr: {}",
out.status,
String::from_utf8_lossy(&out.stderr)
);
let stdout = String::from_utf8(out.stdout).expect("utf-8 stdout");
let obj: serde_json::Value = serde_json::from_str(stdout.trim()).expect("one JSON object");
let map = obj.as_object().expect("a flat object");
let mut keys: Vec<&str> = map.keys().map(String::as_str).collect();
keys.sort_unstable();
assert_eq!(
keys,
vec!["baseAsset", "digits", "lotSize", "pipSize", "quoteAsset", "symbol", "tickSize"],
"exactly symbol + the six geometry fields, no provider metadata: {stdout}"
);
assert_eq!(map["symbol"], "METAONLY");
}
/// Property (#273): a symbol with neither archive bar files nor a geometry
/// sidecar is unknown to `aura data info` — refuses on stderr, exit 1, no
/// panic, no bare Debug struct.
#[test]
fn data_info_on_an_unknown_symbol_refuses() {
let (cwd, _g) = fresh_project_with_data();
let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
.args(["data", "info", "GHOST"])
.current_dir(&cwd)
.output()
.expect("spawn aura data info");
assert_eq!(out.status.code(), Some(1), "stdout: {}", String::from_utf8_lossy(&out.stdout));
let stderr = String::from_utf8(out.stderr).expect("utf-8 stderr");
assert!(stderr.contains("no recorded data for symbol"), "{stderr}");
assert!(stderr.contains("GHOST"), "names the unknown symbol: {stderr}");
}
/// Property (#300): `aura campaign show <CID>` prints the registered