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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user