tidaldb/tidalctl/tests/cli.rs
jordan 4f076c927d feat: M0p1 runtime skeleton, M0p2 tooling & diagnostics, m1p4 signal ledger
## M0p1 — Embeddable Runtime Skeleton (329 tests)
- TidalDb with builder(), health_check(), close(), and Drop-based cleanup
- TidalDbBuilder fluent API: ephemeral(), with_data_dir(), wal_dir(), cache_dir()
- Config, StorageMode, ConfigError types; Config(ConfigError) variant on LumenError
- Paths: single source of truth for directory layout (wal, items, users, creators, cache)
- TempTidalHome: test isolation helper gated behind #[cfg(test)] / test-utils feature
- 8 integration tests: tests/sandboxed_storage.rs

## M0p2 — Tooling & Diagnostics (349 tests)
- Workspace root Cargo.toml (members: ["tidal", "tidalctl"])
- tidal/build.rs: BUILD_HASH from GIT_HASH with option_env!() fallback to "dev"
- MetricsState: always-compiled Arc-shared atomics (uptime, health_ok)
- MetricsHandle (metrics feature): hand-rolled TcpListener HTTP, zero new deps
  - GET /healthz → {"status":"ok","uptime_secs":N}
  - GET /metrics → Prometheus text (tidaldb_uptime_seconds, health_ok, info)
- TidalDbBuilder.enable_metrics(addr) starts background metrics thread
- tidalctl binary: status + paths commands, manual std::env::args() parsing
- 7 metrics integration tests, 9 tidalctl CLI tests

## m1p4 Signal Ledger (in-progress)
- SignalLedger: DashMap<(EntityId, SignalTypeId), EntitySignalEntry>, WAL-first writes
- HotSignalState: #[repr(C, align(64))], lock-free CAS decay, out-of-order handling
- BucketedCounter: 60 per-minute + 168 per-hour circular buffers, trigger-based rotation
- CheckpointMeta + serialize/restore: 983-byte fixed records, atomic WriteBatch
- Property tests: running score matches analytical to 1e-6, decay monotonic, non-negative
- Proptest regression: signals/warm.txt

## Documentation and planning
- ROADMAP: m0p1 COMPLETE (329), m0p2 COMPLETE (349), product track milestones
- PRODUCT_ROADMAP: P0-P4 product milestone track (personal briefing beachhead)
- Milestone planning docs: milestone-0 (phases 1-3), milestone-p (phases 1-5)
- docs/research/tidaldb_tooling_and_diagnostics.md
- ARCHITECTURE.md, CLAUDE.md, VISION.md updates

## Site
- Blog: every-platform-builds-the-same-6-systems.mdx (new)
- Blog: why-tidaldb.mdx (updated)
- next.config.ts, layout.tsx, blog/page.tsx updates

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-20 20:32:00 -07:00

226 lines
7.1 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) = 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}"
);
}