tidaldb/tidalctl/tests/cli.rs
jx12n b55ad70141 fix: M0-M10 third-pass remediation — durability, replication, and CLI hardening
Resolves the 142 findings from tidal/docs/reviews/CODE_REVIEW_m0-m10.md across
the engine, server, net, and CLI surfaces:

- WAL/session-journal durability, checkpoint format, and crash-recovery hardening
- Replication shipper/receiver, tenant isolation, and migration paths
- Cluster scatter-gather, router, standalone server + health/offload endpoints
- tidalctl refactored into command modules with JSON output and WAL-state tooling
- Cohort, governance, signal-ledger, and vector-registry correctness fixes
- Expanded UAT/integration/durability test coverage across all milestones
2026-06-08 10:28:34 -06:00

1209 lines
41 KiB
Rust

//! Integration tests for the tidalctl binary.
//!
//! Tests run the actual binary as a subprocess via `std::process::Command`.
#![forbid(unsafe_code)]
#![allow(clippy::unwrap_used)]
use std::process::Command;
use tidaldb::{
db::temp::TempTidalHome,
wal::{SignalEvent, WalConfig, WalHandle},
};
fn tidalctl_bin() -> Command {
Command::new(env!("CARGO_BIN_EXE_tidalctl"))
}
/// Create a `TempTidalHome` with WAL segments and a checkpoint.
/// Returns the home so it stays alive (not dropped) for the test.
fn home_with_wal_data() -> TempTidalHome {
let home = TempTidalHome::new().expect("create temp home");
let paths = home.paths();
paths.ensure_all().expect("create subdirs");
let config = WalConfig {
dir: home.path().to_path_buf(),
..WalConfig::default()
};
let (handle, _replayed, _session_events) = WalHandle::open(config).expect("open WAL");
for i in 1..=5 {
let event = SignalEvent {
entity_id: i,
signal_type: 1,
weight: 1.0,
timestamp_nanos: i * 1_000_000_000,
};
let _seq = handle.append(event).expect("append event");
}
handle.checkpoint(3).expect("checkpoint");
handle.shutdown().expect("shutdown WAL");
home
}
/// Create a `TempTidalHome` with WAL segments but NO checkpoint, so a
/// never-checkpointed database can be exercised (`checkpoint_ts` == 0).
fn home_with_wal_no_checkpoint() -> TempTidalHome {
let home = TempTidalHome::new().expect("create temp home");
let paths = home.paths();
paths.ensure_all().expect("create subdirs");
let config = WalConfig {
dir: home.path().to_path_buf(),
..WalConfig::default()
};
let (handle, _replayed, _session_events) = WalHandle::open(config).expect("open WAL");
for i in 1..=5 {
let event = SignalEvent {
entity_id: i,
signal_type: 1,
weight: 1.0,
timestamp_nanos: i * 1_000_000_000,
};
let _seq = handle.append(event).expect("append event");
}
// Deliberately NO `handle.checkpoint(...)` -> no checkpoint.meta on disk.
handle.shutdown().expect("shutdown WAL");
home
}
/// Corrupt the WAL `checkpoint.meta` so `CheckpointManager::read` returns a
/// `Corruption` error. Both `status` (via `gather_wal_state`) and `diagnostics`
/// (via `diagnose_wal`) then fail their WAL read, which must surface as the
/// degraded exit code 2.
fn corrupt_wal_checkpoint(home: &TempTidalHome) {
let checkpoint = home.paths().wal_dir().join("checkpoint.meta");
// An unparseable `seq=` value yields WalError::Corruption (not NotFound).
std::fs::write(&checkpoint, "seq=not_a_number\nts=123\n").expect("overwrite checkpoint.meta");
}
#[test]
fn status_with_wal_segments_exits_0() {
let home = home_with_wal_data();
let output = tidalctl_bin()
.args(["status", "--path", home.path().to_str().unwrap()])
.output()
.expect("run tidalctl");
assert!(
output.status.success(),
"exit code: {}, stderr: {}",
output.status,
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8(output.stdout).expect("utf8");
let json: serde_json::Value = serde_json::from_str(&stdout).expect("parse JSON");
assert_eq!(json["status"], "ok");
assert!(
json["wal"]["segments"].as_u64().unwrap_or(0) > 0,
"should have segments: {stdout}"
);
assert!(
json["wal"]["checkpoint_seq"].as_u64().unwrap_or(0) > 0,
"should have checkpoint: {stdout}"
);
assert!(json["version"].is_string());
assert!(json["build_hash"].is_string());
assert!(json["dirs"]["base"].is_string());
}
#[test]
fn status_empty_dir_shows_empty() {
let home = TempTidalHome::new().expect("create temp home");
let paths = home.paths();
paths.ensure_all().expect("create subdirs");
let output = tidalctl_bin()
.args(["status", "--path", home.path().to_str().unwrap()])
.output()
.expect("run tidalctl");
assert!(output.status.success());
let stdout = String::from_utf8(output.stdout).expect("utf8");
let json: serde_json::Value = serde_json::from_str(&stdout).expect("parse JSON");
assert_eq!(json["status"], "empty");
}
#[test]
fn status_nonexistent_path_exits_0_with_empty() {
// When the WAL dir doesn't exist, gather_wal_state returns segments=0
// which maps to "empty" status. The base dir may or may not exist.
let output = tidalctl_bin()
.args(["status", "--path", "/nonexistent/path/that/does/not/exist"])
.output()
.expect("run tidalctl");
let stdout = String::from_utf8(output.stdout).expect("utf8");
// This should still be valid JSON regardless of status
if output.status.success() {
let json: serde_json::Value = serde_json::from_str(&stdout).expect("parse JSON");
assert!(
json["status"] == "empty" || json["status"] == "error",
"status should be empty or error: {stdout}"
);
} else {
let stderr = String::from_utf8(output.stderr).expect("utf8");
let json: serde_json::Value = serde_json::from_str(&stderr).expect("parse error JSON");
assert!(json["error"].is_string());
}
}
#[test]
fn status_corrupt_wal_exits_2_and_reports_error() {
// A corrupt checkpoint.meta makes gather_wal_state fail. `status` must then
// exit 2 (degraded/unreadable), mirroring `diagnostics`, NOT 0 — otherwise
// `tidalctl status --path X && deploy` would treat a corrupt WAL as success.
let home = home_with_wal_data();
corrupt_wal_checkpoint(&home);
let output = tidalctl_bin()
.args(["status", "--path", home.path().to_str().unwrap()])
.output()
.expect("run tidalctl");
assert_eq!(
output.status.code(),
Some(2),
"corrupt WAL must exit 2, stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
// The body is still valid JSON and carries the error status + envelope.
let stdout = String::from_utf8(output.stdout).expect("utf8");
let json: serde_json::Value = serde_json::from_str(&stdout).expect("parse JSON");
assert_eq!(json["status"], "error", "status must be error: {stdout}");
assert!(
json["wal"]["error"].is_string(),
"wal error envelope must be present: {stdout}"
);
}
#[test]
fn paths_exits_0_with_all_dirs() {
let home = TempTidalHome::new().expect("create temp home");
let paths = home.paths();
paths.ensure_all().expect("create subdirs");
let output = tidalctl_bin()
.args(["paths", "--path", home.path().to_str().unwrap()])
.output()
.expect("run tidalctl");
assert!(output.status.success());
let stdout = String::from_utf8(output.stdout).expect("utf8");
let json: serde_json::Value = serde_json::from_str(&stdout).expect("parse JSON");
// Check all six directory paths are present
assert!(json["base"].is_string(), "missing base: {stdout}");
assert!(json["wal"].is_string(), "missing wal: {stdout}");
assert!(json["items"].is_string(), "missing items: {stdout}");
assert!(json["users"].is_string(), "missing users: {stdout}");
assert!(json["creators"].is_string(), "missing creators: {stdout}");
assert!(json["cache"].is_string(), "missing cache: {stdout}");
// Check exists map
assert!(json["exists"]["base"].is_boolean());
assert!(json["exists"]["wal"].is_boolean());
assert_eq!(json["exists"]["base"], true);
assert_eq!(json["exists"]["wal"], true);
}
#[test]
fn paths_nonexistent_dir_shows_false_exists() {
let output = tidalctl_bin()
.args(["paths", "--path", "/nonexistent/does/not/exist"])
.output()
.expect("run tidalctl");
assert!(output.status.success());
let stdout = String::from_utf8(output.stdout).expect("utf8");
let json: serde_json::Value = serde_json::from_str(&stdout).expect("parse JSON");
assert_eq!(json["exists"]["base"], false);
assert_eq!(json["exists"]["wal"], false);
}
#[test]
fn pretty_flag_produces_indented_json() {
let home = TempTidalHome::new().expect("create temp home");
let output = tidalctl_bin()
.args(["paths", "--path", home.path().to_str().unwrap(), "--pretty"])
.output()
.expect("run tidalctl");
assert!(output.status.success());
let stdout = String::from_utf8(output.stdout).expect("utf8");
// Pretty-printed JSON should have newlines and indentation
assert!(
stdout.contains('\n'),
"pretty output should have newlines: {stdout}"
);
assert!(
stdout.contains(" "),
"pretty output should have indentation: {stdout}"
);
// Should still be valid JSON
let _json: serde_json::Value = serde_json::from_str(&stdout).expect("parse JSON");
}
#[test]
fn bad_command_exits_1() {
let output = tidalctl_bin()
.args(["nonexistent_command", "--path", "/tmp"])
.output()
.expect("run tidalctl");
assert!(!output.status.success(), "should exit 1 for bad command");
let stderr = String::from_utf8(output.stderr).expect("utf8");
assert!(
stderr.contains("error"),
"stderr should contain error: {stderr}"
);
}
#[test]
fn no_args_exits_1() {
let output = tidalctl_bin().output().expect("run tidalctl");
assert!(!output.status.success(), "should exit 1 with no args");
}
#[test]
fn missing_path_flag_exits_1() {
let output = tidalctl_bin()
.args(["status"])
.output()
.expect("run tidalctl");
assert!(!output.status.success(), "should exit 1 without --path");
let stderr = String::from_utf8(output.stderr).expect("utf8");
assert!(
stderr.contains("error"),
"stderr should contain error: {stderr}"
);
}
// ---------------------------------------------------------------------------
// recover --verify-only tests
// ---------------------------------------------------------------------------
#[test]
fn recover_verify_only_with_wal_data_json() {
let home = home_with_wal_data();
let output = tidalctl_bin()
.args([
"recover",
"--verify-only",
"--path",
home.path().to_str().unwrap(),
])
.output()
.expect("run tidalctl");
assert!(
output.status.success(),
"exit code: {}, stderr: {}",
output.status,
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8(output.stdout).expect("utf8");
let json: serde_json::Value = serde_json::from_str(&stdout).expect("parse JSON");
assert!(
json["total_events"].as_u64().unwrap_or(0) > 0,
"should have total_events: {stdout}"
);
assert!(
json["segment_count"].as_u64().unwrap_or(0) > 0,
"should have segments: {stdout}"
);
assert!(
json["checkpoint_seq"].as_u64().unwrap_or(0) > 0,
"should have checkpoint: {stdout}"
);
assert!(
json["estimated_recovery_secs"].as_f64().unwrap_or(0.0) > 0.0,
"should have recovery time estimate: {stdout}"
);
assert!(
json["segments"].is_array(),
"should have segments array: {stdout}"
);
}
#[test]
fn recover_verify_only_empty_dir() {
let home = TempTidalHome::new().expect("create temp home");
let paths = home.paths();
paths.ensure_all().expect("create subdirs");
let output = tidalctl_bin()
.args([
"recover",
"--verify-only",
"--path",
home.path().to_str().unwrap(),
])
.output()
.expect("run tidalctl");
assert!(output.status.success());
let stdout = String::from_utf8(output.stdout).expect("utf8");
let json: serde_json::Value = serde_json::from_str(&stdout).expect("parse JSON");
assert_eq!(json["total_events"], 0);
assert_eq!(json["segment_count"], 0);
assert_eq!(json["inconsistency_count"], 0);
}
#[test]
fn recover_verify_only_pretty_output() {
let home = home_with_wal_data();
let output = tidalctl_bin()
.args([
"recover",
"--verify-only",
"--path",
home.path().to_str().unwrap(),
"--pretty",
])
.output()
.expect("run tidalctl");
assert!(
output.status.success(),
"exit code: {}, stderr: {}",
output.status,
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8(output.stdout).expect("utf8");
assert!(
stdout.contains("WAL Diagnostic Report"),
"should contain report header: {stdout}"
);
assert!(
stdout.contains("Checkpoint:"),
"should contain checkpoint section: {stdout}"
);
assert!(
stdout.contains("Events:"),
"should contain events section: {stdout}"
);
assert!(
stdout.contains("Estimated Recovery Time:"),
"should contain recovery estimate: {stdout}"
);
assert!(
stdout.contains("Segment Inventory:"),
"should contain segment table: {stdout}"
);
}
#[test]
fn recover_without_verify_only_exits_1() {
let home = TempTidalHome::new().expect("create temp home");
let output = tidalctl_bin()
.args(["recover", "--path", home.path().to_str().unwrap()])
.output()
.expect("run tidalctl");
assert!(
!output.status.success(),
"should exit 1 without --verify-only"
);
let stderr = String::from_utf8(output.stderr).expect("utf8");
assert!(
stderr.contains("verify-only"),
"stderr should mention verify-only: {stderr}"
);
}
#[test]
fn recover_nonexistent_path_returns_empty_report() {
let output = tidalctl_bin()
.args([
"recover",
"--verify-only",
"--path",
"/nonexistent/path/that/does/not/exist",
])
.output()
.expect("run tidalctl");
// Should succeed with an empty report (no WAL dir = empty report).
assert!(output.status.success());
let stdout = String::from_utf8(output.stdout).expect("utf8");
let json: serde_json::Value = serde_json::from_str(&stdout).expect("parse JSON");
assert_eq!(json["total_events"], 0);
assert_eq!(json["segment_count"], 0);
}
// ---------------------------------------------------------------------------
// diagnostics tests
// ---------------------------------------------------------------------------
#[test]
fn diagnostics_json_output_valid() {
let home = home_with_wal_data();
let output = tidalctl_bin()
.args(["diagnostics", "--path", home.path().to_str().unwrap()])
.output()
.expect("run tidalctl diagnostics");
assert!(
output.status.success(),
"exit code: {}, stderr: {}",
output.status.code().unwrap_or(-1),
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8(output.stdout).expect("valid utf8");
let json: serde_json::Value = serde_json::from_str(&stdout).expect("valid JSON");
// Fields genuinely derivable offline from disk are real numbers.
assert!(json["version"].is_string());
assert!(json["wal_segments"].is_number());
assert!(json["checkpoint_age_seconds"].is_number());
assert!(json["tantivy_segments"].is_number());
assert!(json["tantivy_indexed_docs"].is_number());
assert!(json["checkpoint_wal_sequence"].is_number());
assert!(json["wal_total_bytes"].is_number());
// Fields that cannot be honestly derived offline MUST be JSON `null`, never
// a fabricated `0` that an operator would misread as a real value.
for field in [
"usearch_directory_bytes",
"sessions_active",
"sessions_closed_total",
"sessions_auto_closed_total",
"degradation_level",
"collection_count",
"cohort_count",
] {
assert!(
json[field].is_null(),
"{field} must be null offline, got {}: {stdout}",
json[field]
);
}
// The output is self-describing: every null field is documented with a reason.
let unavailable = json["offline_unavailable"]
.as_object()
.expect("offline_unavailable should be an object");
for field in [
"usearch_directory_bytes",
"sessions_active",
"sessions_closed_total",
"sessions_auto_closed_total",
"degradation_level",
"collection_count",
"cohort_count",
] {
assert!(
unavailable
.get(field)
.and_then(serde_json::Value::as_str)
.is_some(),
"offline_unavailable should explain {field}: {stdout}"
);
}
}
#[test]
fn diagnostics_pretty_output_readable() {
let home = home_with_wal_data();
let output = tidalctl_bin()
.args([
"diagnostics",
"--path",
home.path().to_str().unwrap(),
"--pretty",
])
.output()
.expect("run tidalctl diagnostics");
assert!(output.status.success());
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("tidalDB Diagnostics"),
"missing header: {stdout}"
);
assert!(stdout.contains("WAL"), "missing WAL section: {stdout}");
assert!(
stdout.contains("Checkpoint"),
"missing Checkpoint section: {stdout}"
);
assert!(
stdout.contains("Signal Ledger"),
"missing Signal Ledger section: {stdout}"
);
assert!(
stdout.contains("Text Index (Tantivy)"),
"missing Text Index section: {stdout}"
);
assert!(
stdout.contains("Vector Index (USearch)"),
"missing Vector Index section: {stdout}"
);
assert!(
stdout.contains("Sessions"),
"missing Sessions section: {stdout}"
);
assert!(
stdout.contains("Degradation"),
"missing Degradation section: {stdout}"
);
// Runtime-only / locked-keyspace fields must be marked unavailable, never
// printed as a fabricated "0".
assert!(
stdout.contains("not available offline"),
"offline-unavailable fields must say so, not print a fabricated 0: {stdout}"
);
assert!(
stdout.contains("lock"),
"pretty output should explain the lock-free constraint: {stdout}"
);
}
#[test]
fn diagnostics_missing_dir_exits_1() {
let output = tidalctl_bin()
.args([
"diagnostics",
"--path",
"/nonexistent/tidaldb/diagnostics/path",
])
.output()
.expect("run tidalctl diagnostics");
assert_eq!(output.status.code(), Some(1));
}
#[test]
fn diagnostics_no_checkpoint_reports_null_age() {
// A never-checkpointed database has checkpoint_ts == 0. `checkpoint_age_secs`
// is then None and MUST render as JSON `null`, never a fabricated `0` that
// an operator reads as "checkpointed just now". The pretty path already says
// "none"; the two surfaces must agree.
let home = home_with_wal_no_checkpoint();
let output = tidalctl_bin()
.args(["diagnostics", "--path", home.path().to_str().unwrap()])
.output()
.expect("run tidalctl diagnostics");
assert!(
output.status.success(),
"no-checkpoint WAL is not degraded; exit 0, stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8(output.stdout).expect("utf8");
let json: serde_json::Value = serde_json::from_str(&stdout).expect("parse JSON");
assert!(
json["checkpoint_age_seconds"].is_null(),
"never-checkpointed DB must report null age, not 0: {stdout}"
);
assert_eq!(
json["checkpoint_wal_sequence"], 0,
"no checkpoint -> sequence 0: {stdout}"
);
// The pretty surface must agree: "Last checkpoint: none".
let pretty = tidalctl_bin()
.args([
"diagnostics",
"--path",
home.path().to_str().unwrap(),
"--pretty",
])
.output()
.expect("run tidalctl diagnostics pretty");
assert!(pretty.status.success());
let pretty_out = String::from_utf8(pretty.stdout).expect("utf8");
assert!(
pretty_out.contains("Last checkpoint: none"),
"pretty must print 'none' for a never-checkpointed DB: {pretty_out}"
);
}
#[test]
fn diagnostics_corrupt_wal_exits_2() {
// A corrupt checkpoint.meta makes diagnose_wal fail. `diagnostics` must exit
// 2 (degraded/unreadable), matching `status`.
let home = home_with_wal_data();
corrupt_wal_checkpoint(&home);
let output = tidalctl_bin()
.args(["diagnostics", "--path", home.path().to_str().unwrap()])
.output()
.expect("run tidalctl diagnostics");
assert_eq!(
output.status.code(),
Some(2),
"corrupt WAL must exit 2 for diagnostics, stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
// Body is still valid JSON (the command renders what it could gather).
let stdout = String::from_utf8(output.stdout).expect("utf8");
let _json: serde_json::Value = serde_json::from_str(&stdout).expect("parse JSON");
}
#[test]
fn diagnostics_unreadable_text_index_exits_2_and_reports_null() {
// A `text_index/` directory that exists but is NOT a valid Tantivy index
// (corruption / partial write) makes read_stats_from_dir return None.
// diagnostics must distinguish this from a healthy-empty (absent) index:
// exit 2 and emit `tantivy_segments: null`, never a fabricated 0 that is
// byte-identical to a healthy empty index.
let home = home_with_wal_data();
let text_dir = home.path().join("text_index");
std::fs::create_dir_all(&text_dir).expect("create text_index dir");
// A bogus file makes the directory present but not openable as a Tantivy
// index.
std::fs::write(text_dir.join("meta.json"), b"not a tantivy index")
.expect("write bogus index file");
let output = tidalctl_bin()
.args(["diagnostics", "--path", home.path().to_str().unwrap()])
.output()
.expect("run tidalctl diagnostics");
assert_eq!(
output.status.code(),
Some(2),
"unreadable text index must exit 2, stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8(output.stdout).expect("utf8");
let json: serde_json::Value = serde_json::from_str(&stdout).expect("parse JSON");
assert!(
json["tantivy_segments"].is_null(),
"unreadable index must report null segments, not 0: {stdout}"
);
assert!(
json["tantivy_indexed_docs"].is_null(),
"unreadable index must report null docs, not 0: {stdout}"
);
// The pretty surface must say "not readable", not print a fabricated 0.
let pretty = tidalctl_bin()
.args([
"diagnostics",
"--path",
home.path().to_str().unwrap(),
"--pretty",
])
.output()
.expect("run tidalctl diagnostics pretty");
assert_eq!(pretty.status.code(), Some(2));
let pretty_out = String::from_utf8(pretty.stdout).expect("utf8");
assert!(
pretty_out.contains("not readable"),
"pretty must mark an unreadable index 'not readable': {pretty_out}"
);
}
#[test]
fn diagnostics_absent_text_index_is_healthy_zero() {
// No text_index/ directory at all is a healthy empty store, NOT degraded:
// exit 0 and report 0 segments/docs (a real number, not null).
let home = home_with_wal_data();
let output = tidalctl_bin()
.args(["diagnostics", "--path", home.path().to_str().unwrap()])
.output()
.expect("run tidalctl diagnostics");
assert!(
output.status.success(),
"absent text index is healthy-empty; exit 0, stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8(output.stdout).expect("utf8");
let json: serde_json::Value = serde_json::from_str(&stdout).expect("parse JSON");
assert_eq!(
json["tantivy_segments"], 0,
"absent index is 0 segments (healthy empty): {stdout}"
);
assert_eq!(
json["tantivy_indexed_docs"], 0,
"absent index is 0 docs (healthy empty): {stdout}"
);
}
// ── scope-stats (M9p1) ──────────────────────────────────────────────────────
/// Create a WAL with a mix of scoped events: 3 local, 2 community, 1 session.
fn home_with_scoped_wal_data() -> TempTidalHome {
use tidaldb::{governance::SignalScope, wal::format::EventRecord};
let home = TempTidalHome::new().expect("create temp home");
home.paths().ensure_all().expect("create subdirs");
let config = WalConfig {
dir: home.path().to_path_buf(),
..WalConfig::default()
};
let (handle, _replayed, _session_events) = WalHandle::open(config).expect("open WAL");
let sender = handle.sender();
// 3 local signals (default path).
for i in 1..=3 {
handle
.append(SignalEvent {
entity_id: i,
signal_type: 1,
weight: 1.0,
timestamp_nanos: i * 1_000_000_000,
})
.expect("append local");
}
// 2 community signals.
let community = SignalScope::Community(tidaldb::governance::CommunityId(1)).discriminant();
for i in 10..=11 {
sender
.append_record(EventRecord::scoped(
i,
1,
1.0,
i * 1_000_000_000,
community,
0,
1,
0,
))
.expect("append community");
}
// 1 session signal.
sender
.append_record(EventRecord::scoped(
20,
1,
1.0,
20_000_000_000,
SignalScope::Session.discriminant(),
0,
0,
0,
))
.expect("append session");
handle.checkpoint(0).expect("checkpoint");
handle.shutdown().expect("shutdown WAL");
home
}
#[test]
fn scope_stats_tallies_events_by_scope() {
let home = home_with_scoped_wal_data();
let output = tidalctl_bin()
.args(["scope-stats", "--path", home.path().to_str().unwrap()])
.output()
.expect("run tidalctl scope-stats");
assert!(
output.status.success(),
"stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8(output.stdout).expect("utf8");
let json: serde_json::Value = serde_json::from_str(&stdout).expect("parse JSON");
let stats = &json["scope_stats"];
assert_eq!(stats["total_events"], 6, "6 total events: {stdout}");
assert_eq!(stats["by_scope"]["local"], 3);
assert_eq!(stats["by_scope"]["community"], 2);
assert_eq!(stats["by_scope"]["session"], 1);
assert_eq!(stats["by_scope"]["agent"], 0);
// share-eligible = everything non-local = 2 community + 1 session.
assert_eq!(stats["share_eligible"], 3, "share-eligible count: {stdout}");
}
#[test]
fn scope_stats_empty_wal_is_all_zero() {
let home = TempTidalHome::new().expect("create temp home");
home.paths().ensure_all().expect("create subdirs");
let output = tidalctl_bin()
.args(["scope-stats", "--path", home.path().to_str().unwrap()])
.output()
.expect("run tidalctl scope-stats");
assert!(output.status.success());
let stdout = String::from_utf8(output.stdout).expect("utf8");
let json: serde_json::Value = serde_json::from_str(&stdout).expect("parse JSON");
assert_eq!(json["scope_stats"]["total_events"], 0);
assert_eq!(json["scope_stats"]["share_eligible"], 0);
}
// ── JSON escaping correctness (json_escape control-char hole) ────────────────
/// A path containing JSON control characters (tab, newline, carriage return,
/// NUL) must still produce VALID JSON on stdout — the old hand-rolled
/// `json_escape` only escaped `\` and `"`, emitting raw control chars that
/// strict parsers reject. `serde_json` now owns escaping, so the value must both
/// parse and round-trip byte-for-byte.
#[test]
fn paths_with_control_chars_emits_valid_json() {
// A directory path containing a tab, newline, and carriage return. (A NUL
// byte cannot survive an argv element on Unix, so the OS would reject it
// before reaching us; serde handles NUL in-process at the serializer layer
// either way.) We never create the path on disk; `paths` simply echoes the
// resolved path strings, so the exact bytes flow through the JSON serializer
// regardless of existence.
let weird = "/tmp/tab\there/new\nline/cr\rret/control\u{1}byte";
let output = tidalctl_bin()
.args(["paths", "--path", weird])
.output()
.expect("run tidalctl");
assert!(output.status.success(), "paths should exit 0");
let stdout = String::from_utf8(output.stdout).expect("utf8");
// The whole document must be a single valid JSON object on one line: a raw
// newline in the value would either split the line or make this fail.
assert_eq!(
stdout.lines().count(),
1,
"compact JSON must be exactly one physical line: {stdout:?}"
);
let json: serde_json::Value =
serde_json::from_str(&stdout).expect("control-char path must still be valid JSON");
// The control characters must survive a round-trip exactly (serde escapes
// them on the wire and un-escapes on parse).
assert_eq!(
json["base"].as_str().expect("base is a string"),
weird,
"control chars must round-trip byte-for-byte: {stdout:?}"
);
}
/// The error envelope path (`CliError::to_json`) must also escape control
/// characters: an unknown-flag message that embeds user-supplied bytes with a
/// newline must yield parseable JSON on stderr, not a torn record.
#[test]
fn error_envelope_with_control_chars_is_valid_json() {
let output = tidalctl_bin()
.args(["status", "--path", "/tmp", "--bad\nflag"])
.output()
.expect("run tidalctl");
assert!(!output.status.success(), "unknown flag should exit 1");
let stderr = String::from_utf8(output.stderr).expect("utf8");
let json: serde_json::Value =
serde_json::from_str(stderr.trim()).expect("error envelope must be valid JSON");
let msg = json["error"].as_str().expect("error is a string");
assert!(
msg.contains("--bad\nflag"),
"error message must preserve the embedded control char: {stderr:?}"
);
}
// ── diagnostics: real signal-entry count + real WAL replay lag ───────────────
/// `signal_estimated_entries` must report an ACTUAL event count, not the old
/// `last_segment_seq` proxy. `home_with_wal_data` appends exactly 5 events at
/// sequences 1..=5, so the count must be 5 — the sequence-number proxy would
/// have reported the segment's first sequence (1) instead.
#[test]
fn diagnostics_signal_entries_is_real_count_not_seq_proxy() {
let home = home_with_wal_data();
let output = tidalctl_bin()
.args(["diagnostics", "--path", home.path().to_str().unwrap()])
.output()
.expect("run tidalctl diagnostics");
assert!(output.status.success());
let stdout = String::from_utf8(output.stdout).expect("utf8");
let json: serde_json::Value = serde_json::from_str(&stdout).expect("parse JSON");
assert_eq!(
json["signal_estimated_entries"], 5,
"should be the real 5-event count, not a sequence proxy: {stdout}"
);
}
/// `wal_lag_bytes` must be a REAL lag metric, not a verbatim copy of
/// `wal_total_bytes`. With a checkpoint advanced PAST every event, nothing is
/// left to replay, so lag must be 0 while total bytes stay > 0 — a duplicate
/// field could never produce that divergence.
#[test]
fn diagnostics_wal_lag_is_zero_when_fully_checkpointed() {
let home = TempTidalHome::new().expect("create temp home");
home.paths().ensure_all().expect("create subdirs");
let config = WalConfig {
dir: home.path().to_path_buf(),
..WalConfig::default()
};
let (handle, _replayed, _session_events) = WalHandle::open(config).expect("open WAL");
let mut last_seq = 0;
for i in 1..=5 {
last_seq = handle
.append(SignalEvent {
entity_id: i,
signal_type: 1,
weight: 1.0,
timestamp_nanos: i * 1_000_000_000,
})
.expect("append event");
}
// Checkpoint past the last written sequence: every event is now durable, so
// there is zero replay backlog.
handle
.checkpoint(last_seq + 1)
.expect("checkpoint past tail");
handle.shutdown().expect("shutdown WAL");
let output = tidalctl_bin()
.args(["diagnostics", "--path", home.path().to_str().unwrap()])
.output()
.expect("run tidalctl diagnostics");
assert!(output.status.success());
let stdout = String::from_utf8(output.stdout).expect("utf8");
let json: serde_json::Value = serde_json::from_str(&stdout).expect("parse JSON");
let total = json["wal_total_bytes"].as_u64().expect("total is a number");
let lag = json["wal_lag_bytes"].as_u64().expect("lag is a number");
assert!(total > 0, "should have WAL bytes on disk: {stdout}");
assert_eq!(
lag, 0,
"fully-checkpointed WAL has zero replay lag (not a copy of total={total}): {stdout}"
);
}
/// With a checkpoint covering only part of the WAL, lag must be non-zero and at
/// most the total — a real subset, again proving it is not a duplicate of total.
#[test]
fn diagnostics_wal_lag_is_nonzero_with_uncheckpointed_tail() {
// home_with_wal_data checkpoints at seq 3 but writes events 1..=5, so events
// 3,4,5 are uncheckpointed -> non-zero lag.
let home = home_with_wal_data();
let output = tidalctl_bin()
.args(["diagnostics", "--path", home.path().to_str().unwrap()])
.output()
.expect("run tidalctl diagnostics");
assert!(output.status.success());
let stdout = String::from_utf8(output.stdout).expect("utf8");
let json: serde_json::Value = serde_json::from_str(&stdout).expect("parse JSON");
let total = json["wal_total_bytes"].as_u64().expect("total is a number");
let lag = json["wal_lag_bytes"].as_u64().expect("lag is a number");
assert!(lag > 0, "uncheckpointed tail must report lag: {stdout}");
assert!(lag <= total, "lag is a subset of total bytes: {stdout}");
}
// ── diagnostics: offline-unavailable reasons single-sourced ──────────────────
/// The JSON `offline_unavailable` reasons and the pretty-output reasons must be
/// the SAME strings (single-sourced from the REASON_* constants). We assert the
/// pretty output contains each exact JSON reason verbatim.
#[test]
fn diagnostics_offline_reasons_single_sourced_across_json_and_pretty() {
let home = home_with_wal_data();
let json_out = tidalctl_bin()
.args(["diagnostics", "--path", home.path().to_str().unwrap()])
.output()
.expect("run diagnostics json");
assert!(json_out.status.success());
let json: serde_json::Value =
serde_json::from_str(&String::from_utf8(json_out.stdout).expect("utf8")).expect("json");
let pretty_out = tidalctl_bin()
.args([
"diagnostics",
"--path",
home.path().to_str().unwrap(),
"--pretty",
])
.output()
.expect("run diagnostics pretty");
assert!(pretty_out.status.success());
let pretty = String::from_utf8(pretty_out.stdout).expect("utf8");
let reasons = json["offline_unavailable"]
.as_object()
.expect("offline_unavailable is an object");
assert!(!reasons.is_empty(), "expected at least one reason");
for (field, reason) in reasons {
let reason = reason.as_str().expect("reason is a string");
assert!(
pretty.contains(reason),
"pretty output must reuse the JSON reason for {field}: {reason:?}\n{pretty}"
);
}
}
// ── recover: session_journal summary must be in the output ───────────────────
/// `recover --verify-only` JSON must include the `session_journal` summary that
/// `diagnose_wal` now produces — the old hand-rolled builder dropped it.
#[test]
fn recover_json_includes_session_journal_summary() {
let home = home_with_wal_data();
let output = tidalctl_bin()
.args([
"recover",
"--verify-only",
"--path",
home.path().to_str().unwrap(),
])
.output()
.expect("run tidalctl");
assert!(output.status.success());
let stdout = String::from_utf8(output.stdout).expect("utf8");
let json: serde_json::Value = serde_json::from_str(&stdout).expect("parse JSON");
let sj = json["session_journal"]
.as_object()
.unwrap_or_else(|| panic!("session_journal must be present: {stdout}"));
assert!(sj.contains_key("present"), "present field: {stdout}");
assert!(sj.contains_key("file_size"), "file_size field: {stdout}");
assert!(
sj.contains_key("event_count"),
"event_count field: {stdout}"
);
assert!(
sj.contains_key("corrupt_records"),
"corrupt_records field: {stdout}"
);
assert!(
json["session_journal"]["present"].is_boolean(),
"present is a bool: {stdout}"
);
}
/// The pretty recover output must also render the session-journal section.
#[test]
fn recover_pretty_includes_session_journal_section() {
let home = home_with_wal_data();
let output = tidalctl_bin()
.args([
"recover",
"--verify-only",
"--path",
home.path().to_str().unwrap(),
"--pretty",
])
.output()
.expect("run tidalctl");
assert!(output.status.success());
let stdout = String::from_utf8(output.stdout).expect("utf8");
assert!(
stdout.contains("Session Journal:"),
"pretty recover must include session-journal section: {stdout}"
);
}
// ── scope-stats: torn-tail / inconsistency indicator ─────────────────────────
/// Append raw garbage after the last valid WAL batch to simulate a torn/corrupt
/// tail. `read_all_events` stops at the bad magic and silently undercounts, so
/// scope-stats must surface `complete: false` with a non-zero
/// `inconsistency_count` instead of pretending the tally is whole.
#[test]
fn scope_stats_torn_tail_reports_inconsistency() {
use std::io::Write;
let home = home_with_scoped_wal_data();
let wal_dir = home.paths().wal_dir();
// Find the (single) WAL segment file and append garbage past the last batch.
let seg_path = std::fs::read_dir(&wal_dir)
.expect("read wal dir")
.filter_map(Result::ok)
.map(|e| e.path())
.find(|p| {
let is_seg = p
.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("seg"));
let has_prefix = p
.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| n.starts_with("wal-"));
has_prefix && is_seg
})
.expect("a WAL segment file must exist");
let mut f = std::fs::OpenOptions::new()
.append(true)
.open(&seg_path)
.expect("open segment for append");
// Bytes that are NOT the batch magic -> the scan stops here and counts a
// corrupt/truncated tail.
f.write_all(&[0xDE, 0xAD, 0xBE, 0xEF, 0x00, 0x01, 0x02, 0x03])
.expect("append garbage tail");
f.sync_all().expect("sync");
drop(f);
let output = tidalctl_bin()
.args(["scope-stats", "--path", home.path().to_str().unwrap()])
.output()
.expect("run tidalctl scope-stats");
assert!(
output.status.success(),
"scope-stats must still succeed on a torn tail: {}",
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8(output.stdout).expect("utf8");
let json: serde_json::Value = serde_json::from_str(&stdout).expect("parse JSON");
let body = &json["scope_stats"];
assert_eq!(
body["complete"], false,
"torn tail must mark the tally incomplete: {stdout}"
);
assert!(
body["inconsistency_count"].as_u64().unwrap_or(0) > 0,
"torn tail must surface a non-zero inconsistency_count: {stdout}"
);
// The valid prefix is still tallied (3 local + 2 community + 1 session were
// written before the garbage).
assert!(
body["total_events"].as_u64().unwrap_or(0) >= 1,
"the valid prefix must still be counted: {stdout}"
);
}
/// A clean WAL must report `complete: true` and zero inconsistencies, so the
/// indicator is meaningful (not always-false).
#[test]
fn scope_stats_clean_wal_reports_complete() {
let home = home_with_scoped_wal_data();
let output = tidalctl_bin()
.args(["scope-stats", "--path", home.path().to_str().unwrap()])
.output()
.expect("run tidalctl scope-stats");
assert!(output.status.success());
let stdout = String::from_utf8(output.stdout).expect("utf8");
let json: serde_json::Value = serde_json::from_str(&stdout).expect("parse JSON");
let body = &json["scope_stats"];
assert_eq!(body["complete"], true, "clean WAL is complete: {stdout}");
assert_eq!(
body["inconsistency_count"], 0,
"clean WAL has no inconsistencies: {stdout}"
);
}