diff --git a/crates/aura-cli/src/main.rs b/crates/aura-cli/src/main.rs index 1d2e65d..8348991 100644 --- a/crates/aura-cli/src/main.rs +++ b/crates/aura-cli/src/main.rs @@ -1258,15 +1258,15 @@ struct DataCmd { #[derive(Subcommand)] enum DataCommand { - /// Report a symbol's monthly file-index coverage (span + interior gaps). - Coverage(DataCoverageCmd), - /// List the archive's known symbols, sorted, one per line. + /// Print a symbol's recorded geometry as one flat JSON object. + Info(DataInfoCmd), + /// List the archive's known symbols as NDJSON, sorted, one per line. List, } #[derive(Args)] -struct DataCoverageCmd { - /// The symbol to inventory (e.g. `GER40`). +struct DataInfoCmd { + /// The symbol to inspect (e.g. `GER40`). symbol: String, } @@ -2280,7 +2280,7 @@ fn dispatch_nodes_new(a: NodesNewCmd) { fn dispatch_data(cmd: DataCmd, env: &aura_runner::project::Env) { match cmd.command { - DataCommand::Coverage(a) => dispatch_data_coverage(a, env), + DataCommand::Info(a) => dispatch_data_info(a, env), DataCommand::List => dispatch_data_list(env), } } @@ -2325,57 +2325,116 @@ fn dispatch_measure_ic(a: MeasureIcCmd, env: &aura_runner::project::Env) { println!("{}", report.to_json()); } -/// `aura data list` (#264 cut 2): print the archive's known symbols, sorted -/// ascending, one per line — the discovery step before `aura data coverage` -/// or scoping a campaign's instrument matrix. Resolves the archive root -/// through the normal project path ([`dispatch_data_coverage`]'s -/// `Env::data_path`), then reuses `DataServer`'s own symbol index -/// (`DataServer::symbols()`, already sorted) rather than re-deriving a -/// directory scan. An empty or absent archive is informational absence, not a -/// fault: a `no symbols` prose line, exit 0 (the verb corpus's established -/// empty-result register — cf. `runs family`'s unknown-id empty exit 0). +/// `aura data list` (#273: the namespace-wide NDJSON reshape): print the +/// archive's known symbols as NDJSON, sorted ascending, one JSON string per +/// line — the discovery step before `aura data info ` or scoping a +/// campaign's instrument matrix. Resolves the archive root through the normal +/// project path (`Env::data_path`), then reuses `DataServer`'s own symbol +/// index (`DataServer::symbols()`, already sorted) rather than re-deriving a +/// directory scan. An empty or absent archive prints zero stdout lines — the +/// honest NDJSON of an empty set, since a placeholder prose line would break +/// `| jq`-style consumption; the human affordance moves to a +/// `crate::diag::note!` stderr notice instead (#273 design minutes, +/// 2026-07-25). fn dispatch_data_list(env: &aura_runner::project::Env) { let data_path = std::path::PathBuf::from(env.data_path()); let server = aura_ingest::DataServer::new(&data_path); - for line in data_list_report(&server.symbols()) { + let report = data_list_report(&server.symbols()); + if report.is_empty() { + crate::diag::note!("no symbols in the archive"); + return; + } + for line in report { println!("{line}"); } } /// Pure: render `symbols` (already sorted by `DataServer::symbols()`) as one -/// line per symbol, or a single `no symbols` line when the archive is empty -/// or absent — informational absence, never a fault (#264). +/// JSON-string line per symbol (`serde_json::to_string`, so `SYMA` becomes the +/// quoted `"SYMA"` NDJSON line) — an empty archive yields an empty `Vec`, +/// never a placeholder line, so stdout stays pure NDJSON either way (#273). fn data_list_report(symbols: &[std::sync::Arc]) -> Vec { - if symbols.is_empty() { - return vec!["no symbols".to_string()]; - } - symbols.iter().map(|s| s.to_string()).collect() + symbols + .iter() + .map(|s| serde_json::to_string(s.as_ref()).expect("a symbol string always serializes")) + .collect() } -/// `aura data coverage ` (#264 cut 1): print the archive's month -/// coverage for `SYMBOL` — the Copper failure mode (files present at both -/// ends of a window while an interior month is missing, so a first/last-bounds -/// check passes but a campaign run aborts mid-window) made visible up front. -/// Resolves the archive root through the normal project path (`Env::data_path` -/// — a project-local `[paths] data` override when set, the data-server default -/// otherwise), so a project-scoped archive is inventoried, not the host one. -fn dispatch_data_coverage(a: DataCoverageCmd, env: &aura_runner::project::Env) { +/// `aura data info ` (#273): print a symbol's recorded geometry as one +/// flat JSON object — the machine-readable follow-up to `aura data list`'s +/// discovery pass, surfacing `DataServer::symbol_meta`'s neutral, +/// broker-agnostic geometry (digits / pip / tick / lot sizing, base / quote +/// asset) without a human ever opening the `.meta.json` sidecar by +/// hand. Resolves the archive root through the normal project path +/// (`Env::data_path`, same as every other `data` verb). A symbol is KNOWN +/// when the archive holds bar files for it (`DataServer::has_symbol`) OR it +/// carries a usable geometry sidecar (`DataServer::symbol_meta`) — a +/// sidecar-only symbol (bars not yet ingested) still reports its geometry. An +/// unknown symbol (neither) refuses on stderr, exit 1. Nothing else about the +/// archive (span, gaps, provider metadata) surfaces here — that was `aura +/// data coverage`'s job; #272's per-cell fault isolation made it redundant, +/// so this issue retires it rather than folding it in (#273 design minutes). +fn dispatch_data_info(a: DataInfoCmd, env: &aura_runner::project::Env) { let data_path = std::path::PathBuf::from(env.data_path()); - let months = aura_ingest::list_m1_months(&data_path, &a.symbol); - // The report body is single-sourced with a campaign cell's own coverage - // annotation (#295 Task 7: `aura_runner::coverage::interior_gap_months` - // is the one gap walk both consumers call). - match aura_runner::coverage::data_coverage_report(&a.symbol, &months) { - Ok(lines) => { - for line in lines { - println!("{line}"); - } - } - Err(e) => { - eprintln!("aura: {e} at {}", env.data_path()); - std::process::exit(1); - } + let server = aura_ingest::DataServer::new(&data_path); + let geo = server.symbol_meta(&a.symbol); + if geo.is_none() && !server.has_symbol(&a.symbol) { + eprintln!( + "aura: no recorded data for symbol \"{}\" (no archive files, no geometry sidecar)", + a.symbol + ); + std::process::exit(1); } + println!("{}", data_info_report(&a.symbol, geo.as_ref())); +} + +/// One `aura data info` object's shape: `symbol` plus, when a geometry +/// sidecar was found, its six neutral price-geometry fields — never a +/// provider-specific field (`generator`, `schemaVersion`, `generatedAtUtc`, +/// #273 acceptance), since `InstrumentGeometry` never carries one to begin +/// with. `InstrumentGeometry` itself has no `Serialize` impl, so +/// [`data_info_report`] copies its fields in here. `rename_all = "camelCase"` +/// turns `pip_size`/`tick_size`/`lot_size`/`base_asset`/`quote_asset` into the +/// sidecar's own `pipSize`/`tickSize`/`lotSize`/`baseAsset`/`quoteAsset` +/// spelling; `#[serde(skip_serializing_if)]` drops the whole geometry tail +/// when absent rather than emitting `null`s, so a sidecar-less known symbol +/// reports as bare `{"symbol":"…"}` (#273 design minutes, 2026-07-25: +/// `description` stays out of this report until the reader exposes it). +#[derive(serde::Serialize)] +#[serde(rename_all = "camelCase")] +struct DataInfoReport { + symbol: String, + #[serde(skip_serializing_if = "Option::is_none")] + digits: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pip_size: Option, + #[serde(skip_serializing_if = "Option::is_none")] + tick_size: Option, + #[serde(skip_serializing_if = "Option::is_none")] + lot_size: Option, + #[serde(skip_serializing_if = "Option::is_none")] + base_asset: Option, + #[serde(skip_serializing_if = "Option::is_none")] + quote_asset: Option, +} + +/// Pure: `aura data info `'s report body (#273) — one flat JSON +/// object, field order `symbol`, `digits`, `pipSize`, `tickSize`, `lotSize`, +/// `baseAsset`, `quoteAsset` (serde's field-declaration order is stable, +/// unlike `serde_json::Map`'s alphabetical iteration). `geo` is `None` for a +/// known-but-sidecar-less symbol, in which case every geometry field is +/// omitted (see [`DataInfoReport`]). +fn data_info_report(symbol: &str, geo: Option<&aura_ingest::InstrumentGeometry>) -> String { + let report = DataInfoReport { + symbol: symbol.to_string(), + digits: geo.map(|g| g.digits), + pip_size: geo.map(|g| g.pip_size), + tick_size: geo.map(|g| g.tick_size), + lot_size: geo.map(|g| g.lot_size), + base_asset: geo.map(|g| g.base.clone()), + quote_asset: geo.map(|g| g.quote.clone()), + }; + serde_json::to_string(&report).expect("a finite DataInfoReport always serializes") } /// `aura sweep`: loaded-blueprint by-name axis sweep (or `--list-axes` probe) when the @@ -2819,23 +2878,55 @@ mod tests { } /// An empty (or absent) archive is informational absence, not a fault - /// (#264): `data_list_report` returns the single `no symbols` prose line - /// rather than an empty `Vec`, so the caller's exit-0 println always has - /// something to say. + /// (#264), but under the all-JSON namespace reshape (#273) stdout must + /// stay pure NDJSON: `data_list_report` returns an empty `Vec` rather + /// than a placeholder prose line, so the caller has nothing to print and + /// moves the human affordance to stderr instead. #[test] - fn data_list_report_of_an_empty_archive_is_a_no_symbols_line() { + fn data_list_report_of_an_empty_archive_is_empty() { let symbols: Vec> = vec![]; - assert_eq!(data_list_report(&symbols), vec!["no symbols".to_string()]); + assert_eq!(data_list_report(&symbols), Vec::::new()); } - /// A non-empty archive renders one line per symbol, in the order handed - /// in (`DataServer::symbols()` already sorts) — no `no symbols` line - /// mixed in (#264). + /// A non-empty archive renders one NDJSON line per symbol, in the order + /// handed in (`DataServer::symbols()` already sorts) — each symbol as a + /// quoted JSON string, not a bare name (#273: the NDJSON reshape). #[test] - fn data_list_report_of_a_populated_archive_is_one_line_per_symbol() { + fn data_list_report_of_a_populated_archive_is_one_json_string_per_line() { let symbols: Vec> = vec![std::sync::Arc::from("SYMA"), std::sync::Arc::from("SYMB")]; - assert_eq!(data_list_report(&symbols), vec!["SYMA".to_string(), "SYMB".to_string()]); + assert_eq!( + data_list_report(&symbols), + vec!["\"SYMA\"".to_string(), "\"SYMB\"".to_string()] + ); + } + + /// `aura data info`'s report body with a full geometry sidecar present: + /// the exact output string, pinning both the field order (`symbol` then + /// the six geometry fields, camelCased) and serde_json's own float + /// rendering (#273). + #[test] + fn data_info_report_with_geometry_is_symbol_plus_six_camelcase_fields() { + let geo = aura_ingest::InstrumentGeometry { + digits: 5, + pip_size: 0.0001, + tick_size: 1e-05, + lot_size: 100_000.0, + base: "EUR".to_string(), + quote: "USD".to_string(), + }; + assert_eq!( + data_info_report("EURUSD", Some(&geo)), + r#"{"symbol":"EURUSD","digits":5,"pipSize":0.0001,"tickSize":0.00001,"lotSize":100000.0,"baseAsset":"EUR","quoteAsset":"USD"}"# + ); + } + + /// A known symbol with no geometry sidecar reports bare identity — no + /// `null` geometry fields, no provider metadata, just `symbol` (#273: + /// "a sidecar-less known symbol yields just `{"symbol":"…"}`"). + #[test] + fn data_info_report_without_geometry_is_bare_symbol() { + assert_eq!(data_info_report("SYMX", None), r#"{"symbol":"SYMX"}"#); } /// #234: `campaign_run::persist_campaign_traces`'s C1 drift alarm re-runs a diff --git a/crates/aura-cli/tests/cli_run.rs b/crates/aura-cli/tests/cli_run.rs index b6d9e9a..b776cf1 100644 --- a/crates/aura-cli/tests/cli_run.rs +++ b/crates/aura-cli/tests/cli_run.rs @@ -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 ` 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 `. 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::`, 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 `, 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 = lines + .iter() + .map(|l| serde_json::from_str::(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 ` on a symbol the archive holds +/// bar files for but no `.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 ` 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 ` prints the registered diff --git a/crates/aura-runner/src/coverage.rs b/crates/aura-runner/src/coverage.rs index a21a13e..b037546 100644 --- a/crates/aura-runner/src/coverage.rs +++ b/crates/aura-runner/src/coverage.rs @@ -1,8 +1,8 @@ -//! Coverage reporting: the single interior-gap-month walk, shared by a -//! campaign cell's coverage annotation -//! (`DefaultMemberRunner::window_coverage`) and `aura data coverage`'s -//! archive-wide report — two independent walk implementations before this -//! module existed (#295 dedup). +//! The interior-gap-month walk behind a campaign cell's coverage annotation +//! (`DefaultMemberRunner::window_coverage`) — originally shared with `aura +//! data coverage`'s archive-wide report (#295 dedup: two independent walk +//! implementations before this module existed) before that verb retired in +//! favor of per-cell fault isolation (#272, #273). /// `"YYYY-MM"` rendering of a `(year, month)` pair. pub fn fmt_year_month((y, m): (u16, u8)) -> String { @@ -18,11 +18,11 @@ pub fn next_year_month((y, m): (u16, u8)) -> (u16, u8) { /// fall inside `window_ms` (Unix-ms) — one `"YYYY-MM"` entry per missing /// month, never a collapsed range: the registry's `CellCoverage::gap_months` /// is a flat list an aggregate counts directly. `months` is assumed sorted -/// ([`aura_ingest::list_m1_months`]'s own contract). The single gap-walk -/// implementation (#295): both `DefaultMemberRunner::window_coverage` -/// (a swept cell's own window) and [`data_coverage_report`] (the full -/// archive span, via [`FULL_ARCHIVE_WINDOW_MS`]) call this one walk instead -/// of maintaining independent copies. +/// ([`aura_ingest::list_m1_months`]'s own contract). `DefaultMemberRunner:: +/// window_coverage` (a swept cell's own window) is the sole caller since +/// `aura data coverage`'s archive-wide report retired (#273); the walk stays +/// its own function because it was single-sourced with that verb before then +/// (#295). pub fn interior_gap_months(months: &[(u16, u8)], window_ms: (i64, i64)) -> Vec { let from_ym = data_server::records::unix_ms_to_year_month(window_ms.0); let to_ym = data_server::records::unix_ms_to_year_month(window_ms.1); @@ -40,68 +40,6 @@ pub fn interior_gap_months(months: &[(u16, u8)], window_ms: (i64, i64)) -> Vec`'s pure report body (#264): render `symbol`'s -/// coverage report from its sorted `(year, month)` file list — one `span:` -/// line framing the first/last present month, then either a `no gaps` line -/// or one `missing: YYYY-MM..YYYY-MM` line per interior contiguous gap (a -/// ten-month hole is one line, not ten). `Err` exactly when `months` is -/// empty — no archive file exists for `symbol` at all (the caller's "unknown -/// symbol" refusal, stderr + exit 1). The gap detection itself is -/// [`interior_gap_months`] over the full archive span -/// ([`FULL_ARCHIVE_WINDOW_MS`]); collapsing its flat per-month list into -/// contiguous ranges is presentation-only regrouping, done here since it -/// stays byte-identical to the pre-dedup report. -pub fn data_coverage_report(symbol: &str, months: &[(u16, u8)]) -> Result, String> { - let Some(&first) = months.first() else { - return Err(format!("no archive files found for symbol \"{symbol}\"")); - }; - let last = *months.last().expect("non-empty checked above"); - let mut lines = - vec![format!("{symbol} span: {}..{}", fmt_year_month(first), fmt_year_month(last))]; - let gap_months = interior_gap_months(months, FULL_ARCHIVE_WINDOW_MS); - if gap_months.is_empty() { - lines.push(format!("{symbol} no gaps")); - } else { - for (from, to) in collapse_contiguous_year_months(&gap_months) { - lines.push(format!("{symbol} missing: {from}..{to}")); - } - } - Ok(lines) -} - -/// Collapse [`interior_gap_months`]' flat, ascending `"YYYY-MM"` list into -/// contiguous inclusive `(from, to)` ranges — a ten-month hole collapses to -/// one pair, not ten. Presentation-only regrouping: the gap WALK itself -/// already ran in `interior_gap_months`. -fn collapse_contiguous_year_months(months: &[String]) -> Vec<(String, String)> { - let mut out: Vec<(String, String)> = Vec::new(); - for m in months { - let ym = parse_year_month(m); - match out.last_mut() { - Some((_, to)) if next_year_month(parse_year_month(to)) == ym => { - *to = m.clone(); - } - _ => out.push((m.clone(), m.clone())), - } - } - out -} - -/// Inverse of [`fmt_year_month`] — parses back the `"YYYY-MM"` shape -/// [`interior_gap_months`] always emits. -fn parse_year_month(s: &str) -> (u16, u8) { - let (y, m) = s.split_once('-').expect("interior_gap_months emits YYYY-MM"); - (y.parse().expect("YYYY digits"), m.parse().expect("MM digits")) -} - #[cfg(test)] mod tests { use super::*; @@ -125,37 +63,4 @@ mod tests { "both interior gap months must be named, in order" ); } - - /// An unknown symbol (no archive files at all — an empty month list) - /// refuses rather than reporting a bogus empty-span coverage (#264): the - /// caller eprintln's the message and exits 1. - #[test] - fn data_coverage_report_refuses_an_unknown_symbol() { - let err = data_coverage_report("GHOST", &[]).unwrap_err(); - assert!(err.contains("GHOST"), "names the unknown symbol: {err}"); - } - - /// A symbol with a fully contiguous file index reports its span plus an - /// explicit `no gaps` line — never a bare span with no gap-status line at - /// all, which would leave "no gaps" indistinguishable from "gaps not yet - /// checked" (#264). - #[test] - fn data_coverage_report_of_a_gapless_symbol_is_span_plus_no_gaps() { - let months = [(2024, 1), (2024, 2), (2024, 3)]; - let lines = data_coverage_report("SYMA", &months).expect("known symbol"); - assert_eq!(lines, vec!["SYMA span: 2024-01..2024-03", "SYMA no gaps"]); - } - - /// The Copper failure shape itself: one interior gap collapses to a single - /// `missing: YYYY-MM..YYYY-MM` line naming the whole contiguous hole, not - /// one line per missing month (#264). - #[test] - fn data_coverage_report_collapses_an_interior_gap_to_one_range_line() { - let months = [(2024, 1), (2024, 2), (2024, 5), (2024, 6)]; - let lines = data_coverage_report("GAPSYM", &months).expect("known symbol"); - assert_eq!( - lines, - vec!["GAPSYM span: 2024-01..2024-06", "GAPSYM missing: 2024-03..2024-04"] - ); - } }