//! 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)); }