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