tidaldb/tidalctl/tests/cli.rs
2026-02-23 22:41:16 -07:00

481 lines
14 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;
use tidaldb::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
}
#[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 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");
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());
assert!(json["usearch_directory_bytes"].is_number());
assert!(json["degradation_level"].is_number());
assert!(json["collection_count"].is_number());
assert!(json["cohort_count"].is_number());
}
#[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}"
);
}
#[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));
}