#[cfg(unix)]
use crate::status_display::{
cancel_build, drain_worker, enable_worker, force_kill_build, query_daemon_full_status,
};
#[cfg(any(unix, test))]
use crate::status_types::DaemonFullStatusResponse;
#[cfg(unix)]
use crate::tui::event::{Action, poll_event_with_flags};
use crate::tui::state::ColorBlindMode;
#[cfg(unix)]
use crate::tui::state::{ConfirmAction, ConfirmDialog};
#[cfg(any(unix, test))]
use crate::tui::{
state::{
ActiveBuild, BuildProgress, BuildStatus, CircuitState, DaemonState, HistoricalBuild, Panel,
Status, TuiState, WorkerState, WorkerStatus,
},
widgets,
};
use anyhow::Result;
#[cfg(any(unix, test))]
use chrono::{DateTime, Utc};
#[cfg(any(unix, test))]
use ftui::Frame;
#[cfg(unix)]
use ftui_backend::{Backend, BackendEventSource, BackendFeatures, BackendPresenter};
#[cfg(any(unix, test))]
use ftui_render::buffer::Buffer;
#[cfg(unix)]
use ftui_render::diff::BufferDiff;
#[cfg(any(unix, test))]
use ftui_render::grapheme_pool::GraphemePool;
#[cfg(unix)]
use ftui_tty::{TtyBackend, TtySessionOptions};
#[cfg(any(unix, test))]
use std::path::PathBuf;
#[cfg(any(unix, test))]
use std::time::{Duration, Instant};
#[derive(Debug, Clone)]
pub struct TuiConfig {
pub refresh_interval_ms: u64,
pub mouse_support: bool,
pub test_mode: bool,
pub mock_data: bool,
pub dump_state: bool,
pub high_contrast: bool,
pub color_blind: ColorBlindMode,
}
impl Default for TuiConfig {
fn default() -> Self {
Self {
refresh_interval_ms: 1000,
mouse_support: true,
test_mode: false,
mock_data: false,
dump_state: false,
high_contrast: false,
color_blind: ColorBlindMode::None,
}
}
}
#[cfg(unix)]
pub async fn run_tui(config: TuiConfig) -> Result<()> {
if config.dump_state {
let state = build_initial_state(&config).await;
let json = state_to_json(&state);
println!("{}", serde_json::to_string_pretty(&json)?);
return Ok(());
}
if config.test_mode {
let state = build_initial_state(&config).await;
let (width, height) = test_backend_size();
let snapshot = render_snapshot(&state, width, height);
print!("{snapshot}");
return Ok(());
}
let (width, height) = terminal_size::terminal_size()
.map(|(w, h)| (w.0, h.0))
.unwrap_or((80, 24));
let features = BackendFeatures {
mouse_capture: config.mouse_support,
..BackendFeatures::default()
};
let options = TtySessionOptions {
alternate_screen: true,
features,
intercept_signals: true,
};
let mut backend = TtyBackend::open(width, height, options)?;
let mut state = TuiState::new();
state.high_contrast = config.high_contrast;
state.color_blind = config.color_blind;
refresh_state(&mut state, &config).await;
let result = run_app(&mut backend, &mut state, &config).await;
drop(backend);
result
}
#[cfg(not(unix))]
pub async fn run_tui(_config: TuiConfig) -> Result<()> {
anyhow::bail!("rch dashboard requires a Unix terminal (ftui tty backend)")
}
#[cfg(unix)]
async fn refresh_state(state: &mut TuiState, config: &TuiConfig) {
if config.mock_data {
apply_mock_data(state);
state.error = None;
state.last_update = Instant::now();
return;
}
match query_daemon_full_status().await {
Ok(response) => {
update_state_from_daemon(state, response);
state.error = None;
}
Err(e) => {
state.daemon.status = Status::Stopped;
state.error = Some(format!("Failed to connect to daemon: {}", e));
}
}
state.last_update = Instant::now();
}
#[cfg(unix)]
async fn build_initial_state(config: &TuiConfig) -> TuiState {
let mut state = TuiState::new();
state.high_contrast = config.high_contrast;
state.color_blind = config.color_blind;
refresh_state(&mut state, config).await;
state
}
#[cfg(unix)]
fn test_backend_size() -> (u16, u16) {
let width = std::env::var("COLUMNS")
.ok()
.and_then(|v| v.parse::<u16>().ok())
.unwrap_or(80);
let height = std::env::var("LINES")
.ok()
.and_then(|v| v.parse::<u16>().ok())
.unwrap_or(24);
(width.max(40), height.max(12))
}
#[cfg(any(unix, test))]
fn render_snapshot(state: &TuiState, width: u16, height: u16) -> String {
let mut pool = GraphemePool::new();
let mut frame = Frame::new(width, height, &mut pool);
widgets::render(&mut frame, state);
ftui_buffer_to_string(&frame.buffer, &pool)
}
#[cfg(any(unix, test))]
fn ftui_buffer_to_string(buffer: &Buffer, pool: &GraphemePool) -> String {
let mut out = String::new();
let width = buffer.width();
let height = buffer.height();
for y in 0..height {
for x in 0..width {
if let Some(cell) = buffer.get(x, y) {
if cell.content.is_continuation() {
continue;
}
if cell.content.is_empty() {
out.push(' ');
} else if let Some(c) = cell.content.as_char() {
out.push(c);
} else if let Some(gid) = cell.content.grapheme_id() {
if let Some(s) = pool.get(gid) {
out.push_str(s);
} else {
out.push(' ');
}
} else {
out.push(' ');
}
} else {
out.push(' ');
}
}
out.push('\n');
}
out
}
#[cfg(any(unix, test))]
fn state_to_json(state: &TuiState) -> serde_json::Value {
let daemon_status = match state.daemon.status {
Status::Unknown => "unknown",
Status::Running => "running",
Status::Stopped => "stopped",
Status::Error => "error",
};
let selected_panel = match state.selected_panel {
Panel::Workers => "workers",
Panel::ActiveBuilds => "active_builds",
Panel::BuildHistory => "build_history",
Panel::Logs => "logs",
};
serde_json::json!({
"daemon": {
"status": daemon_status,
"version": &state.daemon.version,
"uptime_ms": state.daemon.uptime.as_millis() as u64,
"socket_path": state.daemon.socket_path.display().to_string(),
"builds_today": state.daemon.builds_today,
"bytes_transferred": state.daemon.bytes_transferred,
},
"workers": &state.workers,
"active_builds": &state.active_builds,
"build_history": state.build_history.iter().cloned().collect::<Vec<_>>(),
"selected_panel": selected_panel,
"selected_index": state.selected_index,
"filter": {
"query": &state.filter.query,
"worker_filter": &state.filter.worker_filter,
"success_only": state.filter.success_only,
"failed_only": state.filter.failed_only,
},
"high_contrast": state.high_contrast,
"color_blind": state.color_blind,
"error": &state.error,
"remediation": &state.remediation,
})
}
#[cfg(any(unix, test))]
fn apply_mock_data(state: &mut TuiState) {
use crate::tui::state::{BuildStatus, CircuitState, WorkerStatus};
use chrono::Utc;
state.daemon = DaemonState {
status: Status::Running,
uptime: Duration::from_secs(123),
version: "mock".to_string(),
config_path: PathBuf::new(),
socket_path: PathBuf::from("/tmp/rch.sock"),
builds_today: 7,
bytes_transferred: 42_000_000,
};
state.workers = vec![
WorkerState {
id: "worker-1".to_string(),
host: "127.0.0.1".to_string(),
status: WorkerStatus::Healthy,
circuit: CircuitState::Closed,
total_slots: 16,
used_slots: 4,
latency_ms: 12,
last_seen: Utc::now(),
builds_completed: 3,
},
WorkerState {
id: "worker-2".to_string(),
host: "127.0.0.2".to_string(),
status: WorkerStatus::Degraded,
circuit: CircuitState::HalfOpen,
total_slots: 32,
used_slots: 20,
latency_ms: 25,
last_seen: Utc::now(),
builds_completed: 9,
},
WorkerState {
id: "worker-3".to_string(),
host: "127.0.0.3".to_string(),
status: WorkerStatus::Draining,
circuit: CircuitState::Open,
total_slots: 8,
used_slots: 7,
latency_ms: 40,
last_seen: Utc::now(),
builds_completed: 1,
},
];
state.active_builds = vec![ActiveBuild {
id: "1234".to_string(),
command: "cargo build --release".to_string(),
worker: Some("worker-2".to_string()),
started_at: Utc::now(),
progress: Some(BuildProgress {
phase: "compiling".to_string(),
percent: Some(42),
current_file: Some("serde_derive".to_string()),
}),
status: BuildStatus::Compiling,
}];
state.build_history.clear();
for i in 0..5 {
state.build_history.push_back(HistoricalBuild {
id: format!("h{}", 1000 + i),
command: "cargo test".to_string(),
worker: Some("worker-1".to_string()),
started_at: Utc::now(),
completed_at: Utc::now(),
duration_ms: 1200 + (i as u64 * 100),
success: true,
exit_code: Some(0),
});
}
state.remediation = Some(build_mock_remediation_view());
}
#[cfg(any(unix, test))]
fn build_mock_remediation_view() -> rch_common::remediation_view::RemediationView {
use rch_common::fleet_diff::WorkerObservation;
use rch_common::remediation_view::{
DiskLevel, JobsInput, ProofQueueInput, RemediationWorkerRow, assemble, build_inputs,
};
let healthy = |id: &str| RemediationWorkerRow {
observation: WorkerObservation {
worker_id: id.to_string(),
configured: true,
in_daemon_pool: true,
reachable: true,
admin_disabled: false,
temporarily_bypassed: false,
facts_known: true,
command_admissible: true,
},
disk_level: DiskLevel::Ok,
reclaiming: false,
free_ratio: Some(0.6),
slots_used: 4,
slots_total: 16,
telemetry_known: true,
telemetry_fresh: true,
telemetry_age_secs: Some(8),
recovered_pending_canary: false,
absent_secs: None,
};
let mut degraded = healthy("worker-2");
degraded.observation.temporarily_bypassed = true;
let rows = vec![healthy("worker-1"), degraded];
let inputs = build_inputs(
&rows,
JobsInput {
active: 2,
queued: 1,
stuck: 0,
},
ProofQueueInput::default(),
Vec::new(),
300,
);
assemble(&inputs, 1_700_000_000_000)
}
#[cfg(any(unix, test))]
fn update_state_from_daemon(state: &mut TuiState, response: DaemonFullStatusResponse) {
state.remediation = response.remediation;
state.daemon = DaemonState {
status: Status::Running,
uptime: Duration::from_secs(response.daemon.uptime_secs),
version: response.daemon.version,
config_path: PathBuf::new(),
socket_path: PathBuf::from(&response.daemon.socket_path),
builds_today: response.stats.total_builds as u32,
bytes_transferred: 0,
};
state.workers = response
.workers
.into_iter()
.map(|w| {
let status = match w.status.as_str() {
"healthy" => WorkerStatus::Healthy,
"degraded" => WorkerStatus::Degraded,
"draining" => WorkerStatus::Draining,
"drained" => WorkerStatus::Drained,
"disabled" => WorkerStatus::Disabled,
_ => WorkerStatus::Unreachable,
};
let circuit = match w.circuit_state.as_str() {
"closed" => CircuitState::Closed,
"half_open" => CircuitState::HalfOpen,
_ => CircuitState::Open,
};
WorkerState {
id: w.id,
host: w.host,
status,
circuit,
total_slots: w.total_slots,
used_slots: w.used_slots,
latency_ms: 0,
last_seen: Utc::now(),
builds_completed: 0,
}
})
.collect();
state.active_builds = response
.active_builds
.into_iter()
.map(|b| {
let started_at = DateTime::parse_from_rfc3339(&b.started_at)
.map(|dt| dt.with_timezone(&Utc))
.unwrap_or_else(|_| Utc::now());
ActiveBuild {
id: b.id.to_string(),
command: b.command,
worker: Some(b.worker_id),
started_at,
progress: Some(BuildProgress {
phase: "compiling".to_string(),
percent: None,
current_file: None,
}),
status: BuildStatus::Compiling,
}
})
.collect();
state.build_history.clear();
for b in response.recent_builds.into_iter().take(100) {
let started_at = DateTime::parse_from_rfc3339(&b.started_at)
.map(|dt| dt.with_timezone(&Utc))
.unwrap_or_else(|_| Utc::now());
let completed_at = DateTime::parse_from_rfc3339(&b.completed_at)
.map(|dt| dt.with_timezone(&Utc))
.unwrap_or_else(|_| Utc::now());
state.build_history.push_back(HistoricalBuild {
id: b.id.to_string(),
command: b.command,
worker: b.worker_id,
started_at,
completed_at,
duration_ms: b.duration_ms,
success: b.exit_code == 0,
exit_code: Some(b.exit_code),
});
}
}
#[cfg(unix)]
async fn run_app(backend: &mut TtyBackend, state: &mut TuiState, config: &TuiConfig) -> Result<()> {
let tick_rate = Duration::from_millis(config.refresh_interval_ms);
let refresh_interval = Duration::from_millis(config.refresh_interval_ms * 5); let mut pool = GraphemePool::new();
let mut prev_buf: Option<Buffer> = None;
loop {
let (w, h) = backend.events().size()?;
{
let mut frame = Frame::new(w, h, &mut pool);
widgets::render(&mut frame, state);
let diff = prev_buf
.as_ref()
.map(|prev| BufferDiff::compute(prev, &frame.buffer));
backend
.presenter()
.present_ui(&frame.buffer, diff.as_ref(), prev_buf.is_none())?;
prev_buf = Some(frame.buffer.clone());
}
let confirm_mode = state.confirm_dialog.is_some();
if let Some(action) =
poll_event_with_flags(backend.events(), tick_rate, state.filter_mode, confirm_mode)?
{
match action {
Action::Quit => {
if state.show_remediation {
state.show_remediation = false;
} else if state.show_help {
state.show_help = false;
} else if state.filter_mode {
state.filter_mode = false;
} else {
state.should_quit = true;
}
}
Action::Up => {
if !state.show_help && !state.filter_mode {
if let Some(ref mut log_view) = state.log_view {
log_view.scroll_offset = log_view.scroll_offset.saturating_sub(1);
log_view.auto_scroll = false;
} else {
state.select_up();
}
}
}
Action::Down => {
if !state.show_help && !state.filter_mode {
if let Some(ref mut log_view) = state.log_view {
log_view.scroll_offset = log_view.scroll_offset.saturating_add(1);
} else {
state.select_down();
}
}
}
Action::PageUp => {
if let Some(ref mut log_view) = state.log_view {
log_view.scroll_offset = log_view.scroll_offset.saturating_sub(20);
log_view.auto_scroll = false;
}
}
Action::PageDown => {
if let Some(ref mut log_view) = state.log_view {
log_view.scroll_offset = log_view.scroll_offset.saturating_add(20);
}
}
Action::JumpTop => {
if let Some(ref mut log_view) = state.log_view {
log_view.scroll_offset = 0;
log_view.auto_scroll = false;
} else if !state.show_help && !state.filter_mode {
state.select_first();
}
}
Action::JumpBottom => {
if let Some(ref mut log_view) = state.log_view {
log_view.scroll_offset = usize::MAX;
log_view.auto_scroll = true;
} else if !state.show_help && !state.filter_mode {
state.select_last();
}
}
Action::JumpToPanel(index) => {
if !state.show_help && !state.filter_mode {
state.jump_to_panel(index);
}
}
Action::NextPanel => {
if !state.show_help && !state.filter_mode {
state.next_panel();
}
}
Action::PrevPanel => {
if !state.show_help && !state.filter_mode {
state.prev_panel();
}
}
Action::Refresh => {
refresh_state(state, config).await;
}
Action::Select => {
if state.filter_mode {
state.filter_mode = false;
state.selected_index = 0; } else if !state.show_help {
state.handle_select();
}
}
Action::Back => {
if state.show_remediation {
state.show_remediation = false;
} else if state.show_help {
state.show_help = false;
} else if state.filter_mode {
state.filter_mode = false;
} else if state.log_view.is_some() {
state.log_view = None;
}
}
Action::Help => {
state.show_help = !state.show_help;
}
Action::ToggleRemediation => {
state.show_remediation = !state.show_remediation;
}
Action::Filter => {
if !state.show_help {
state.filter_mode = !state.filter_mode;
}
}
Action::Copy => {
state.copy_selected();
}
Action::SortCycle => {
if !state.show_help
&& !state.filter_mode
&& state.selected_panel == Panel::BuildHistory
{
state.cycle_build_history_sort();
}
}
Action::SortReverse => {
if !state.show_help
&& !state.filter_mode
&& state.selected_panel == Panel::BuildHistory
{
state.reverse_build_history_sort();
}
}
Action::DrainWorker => {
if !state.show_help
&& !state.filter_mode
&& state.selected_panel == Panel::Workers
&& let Some(worker) = state.selected_worker()
{
let worker_id = worker.id.clone();
state.confirm_dialog = Some(ConfirmDialog {
title: format!("Drain worker '{}'?", worker_id),
message: "This will stop routing new\njobs to this worker.".into(),
action: ConfirmAction::DrainWorker(worker_id),
});
}
}
Action::EnableWorker => {
if !state.show_help
&& !state.filter_mode
&& state.selected_panel == Panel::Workers
&& let Some(worker) = state.selected_worker()
{
let worker_id = worker.id.clone();
if let Err(e) = enable_worker(&worker_id).await {
state.error = Some(format!("Enable failed: {}", e));
} else {
refresh_state(state, config).await;
}
}
}
Action::DrainAllWorkers => {
if !state.show_help
&& !state.filter_mode
&& state.selected_panel == Panel::Workers
&& !state.workers.is_empty()
{
state.confirm_dialog = Some(ConfirmDialog {
title: "Drain ALL workers?".into(),
message: "This will stop routing new\njobs to every worker.".into(),
action: ConfirmAction::DrainAllWorkers,
});
}
}
Action::EnableAllWorkers => {
if !state.show_help
&& !state.filter_mode
&& state.selected_panel == Panel::Workers
{
let worker_ids: Vec<String> =
state.workers.iter().map(|w| w.id.clone()).collect();
let mut had_error = false;
for wid in &worker_ids {
if let Err(e) = enable_worker(wid).await {
state.error = Some(format!("Enable {} failed: {}", wid, e));
had_error = true;
break;
}
}
if !had_error {
refresh_state(state, config).await;
}
}
}
Action::CancelBuild => {
if !state.show_help
&& !state.filter_mode
&& state.selected_panel == Panel::ActiveBuilds
&& let Some(build) = state.selected_active_build()
{
let build_id = build.id.clone();
state.confirm_dialog = Some(ConfirmDialog {
title: format!("Cancel build '{}'?", build_id),
message: "This will send SIGTERM to\nthe build process.".into(),
action: ConfirmAction::CancelBuild(build_id),
});
}
}
Action::ForceKillBuild => {
if !state.show_help
&& !state.filter_mode
&& state.selected_panel == Panel::ActiveBuilds
&& let Some(build) = state.selected_active_build()
{
let build_id = build.id.clone();
state.confirm_dialog = Some(ConfirmDialog {
title: format!("FORCE KILL build '{}'?", build_id),
message: "This will send SIGKILL.\nThe process cannot clean up.".into(),
action: ConfirmAction::ForceKillBuild(build_id),
});
}
}
Action::Confirm => {
if let Some(dialog) = state.confirm_dialog.take() {
match dialog.action {
ConfirmAction::DrainWorker(ref wid) => {
if let Err(e) = drain_worker(wid).await {
state.error = Some(format!("Drain failed: {}", e));
} else {
refresh_state(state, config).await;
}
}
ConfirmAction::DrainAllWorkers => {
let worker_ids: Vec<String> =
state.workers.iter().map(|w| w.id.clone()).collect();
let mut had_error = false;
for wid in &worker_ids {
if let Err(e) = drain_worker(wid).await {
state.error = Some(format!("Drain {} failed: {}", wid, e));
had_error = true;
break;
}
}
if !had_error {
refresh_state(state, config).await;
}
}
ConfirmAction::CancelBuild(ref bid) => {
if let Err(e) = cancel_build(bid).await {
state.error = Some(format!("Cancel failed: {}", e));
} else {
refresh_state(state, config).await;
}
}
ConfirmAction::ForceKillBuild(ref bid) => {
if let Err(e) = force_kill_build(bid).await {
state.error = Some(format!("Force kill failed: {}", e));
} else {
refresh_state(state, config).await;
}
}
}
}
}
Action::Dismiss => {
state.confirm_dialog = None;
}
Action::TextInput(c) => {
if state.filter_mode {
state.filter.query.push(c);
}
}
Action::DeleteChar => {
if state.filter_mode {
state.filter.query.pop();
}
}
Action::Tick => {
if state.last_update.elapsed() >= refresh_interval {
refresh_state(state, config).await;
}
}
}
}
if state.should_quit {
break;
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::status_types::{
ActiveBuildFromApi, BuildRecordFromApi, BuildStatsFromApi, DaemonInfoFromApi, IssueFromApi,
WorkerStatusFromApi,
};
use rch_common::test_guard;
use tracing::info;
fn init_test_logging() {
let _ = tracing_subscriber::fmt()
.with_test_writer()
.with_max_level(tracing::Level::DEBUG)
.try_init();
}
fn make_response(
workers: Vec<WorkerStatusFromApi>,
active_builds: Vec<ActiveBuildFromApi>,
recent_builds: Vec<BuildRecordFromApi>,
) -> DaemonFullStatusResponse {
DaemonFullStatusResponse {
daemon: DaemonInfoFromApi {
pid: 1234,
uptime_secs: 3600,
version: "0.1.0".to_string(),
socket_path: rch_common::default_socket_path(),
started_at: "2026-01-17T00:00:00Z".to_string(),
workers_total: workers.len(),
workers_healthy: workers.len(),
slots_total: 32,
slots_available: 24,
},
workers,
active_builds,
queued_builds: vec![],
recent_builds,
issues: vec![IssueFromApi {
severity: "info".to_string(),
summary: "all good".to_string(),
remediation: None,
}],
alerts: vec![],
stats: BuildStatsFromApi {
total_builds: 42,
success_count: 40,
failure_count: 2,
remote_count: 40,
local_count: 2,
avg_duration_ms: 1200,
},
test_stats: None,
saved_time: None,
remediation: None,
}
}
fn worker_status(id: &str, status: &str, circuit: &str) -> WorkerStatusFromApi {
WorkerStatusFromApi {
id: id.to_string(),
host: "worker.local".to_string(),
user: "builder".to_string(),
status: status.to_string(),
circuit_state: circuit.to_string(),
used_slots: 2,
total_slots: 8,
speed_score: 75.0,
last_error: None,
consecutive_failures: 0,
recovery_in_secs: None,
failure_history: vec![],
pressure_state: None,
pressure_confidence: None,
pressure_reason_code: None,
pressure_policy_rule: None,
pressure_disk_free_gb: None,
pressure_disk_total_gb: None,
pressure_disk_free_ratio: None,
pressure_build_disk_free_gb: None,
pressure_build_disk_total_gb: None,
pressure_disk_io_util_pct: None,
pressure_memory_pressure: None,
pressure_telemetry_age_secs: None,
pressure_telemetry_fresh: None,
bypass: None,
}
}
fn active_build(id: u64, worker_id: &str, command: &str) -> ActiveBuildFromApi {
ActiveBuildFromApi {
id,
project_id: "proj".to_string(),
worker_id: worker_id.to_string(),
command: command.to_string(),
started_at: "2026-01-17T00:00:00Z".to_string(),
last_heartbeat_at: Some("2026-01-17T00:00:04Z".to_string()),
heartbeat_age_secs: Some(2),
last_progress_at: Some("2026-01-17T00:00:03Z".to_string()),
progress_age_secs: Some(3),
heartbeat_phase: Some("execute".to_string()),
heartbeat_detail: Some("Compiling".to_string()),
heartbeat_counter: Some(5),
heartbeat_percent: Some(50.0),
slots: Some(4),
detector_hook_alive: Some(false),
detector_heartbeat_stale: Some(true),
detector_progress_stale: Some(true),
detector_confidence: Some(0.88),
detector_build_age_secs: Some(95),
detector_slots_owned: Some(4),
detector_last_evaluated_at: Some("2026-01-17T00:00:04Z".to_string()),
}
}
fn build_record(id: u64, exit_code: i32, command: &str) -> BuildRecordFromApi {
BuildRecordFromApi {
id,
started_at: "2026-01-17T00:00:00Z".to_string(),
completed_at: "2026-01-17T00:00:05Z".to_string(),
project_id: "proj".to_string(),
worker_id: Some("worker-1".to_string()),
command: command.to_string(),
exit_code,
duration_ms: 5000,
location: "remote".to_string(),
bytes_transferred: Some(1234),
timing: None,
cancellation: None,
}
}
#[test]
fn test_tui_config_default() {
let _guard = test_guard!();
init_test_logging();
info!("TEST START: test_tui_config_default");
let config = TuiConfig::default();
info!(
"VERIFY: refresh_interval_ms={} mouse_support={} high_contrast={} color_blind={:?}",
config.refresh_interval_ms,
config.mouse_support,
config.high_contrast,
config.color_blind
);
assert_eq!(config.refresh_interval_ms, 1000);
assert!(config.mouse_support);
assert!(!config.test_mode);
assert!(!config.mock_data);
assert!(!config.dump_state);
assert!(!config.high_contrast);
assert_eq!(config.color_blind, ColorBlindMode::None);
info!("TEST PASS: test_tui_config_default");
}
#[test]
fn test_dump_state_json_mock_data_has_expected_shape() {
let _guard = test_guard!();
init_test_logging();
info!("TEST START: test_dump_state_json_mock_data_has_expected_shape");
let mut state = TuiState::new();
apply_mock_data(&mut state);
let json = state_to_json(&state);
info!("VERIFY: daemon.status=running and workers array present");
assert_eq!(json["daemon"]["status"], "running");
assert!(json["workers"].is_array());
assert!(json["active_builds"].is_array());
assert!(json["build_history"].is_array());
assert!(json["remediation"]["bands"].is_array());
assert_eq!(json["remediation"]["bands"].as_array().unwrap().len(), 8);
assert!(json["remediation"]["overall"].is_string());
info!("TEST PASS: test_dump_state_json_mock_data_has_expected_shape");
}
#[test]
fn test_render_snapshot_contains_header() {
let _guard = test_guard!();
init_test_logging();
info!("TEST START: test_render_snapshot_contains_header");
let mut state = TuiState::new();
apply_mock_data(&mut state);
let snapshot = render_snapshot(&state, 80, 24);
info!("VERIFY: snapshot contains dashboard title");
assert!(snapshot.contains("RCH Dashboard"));
info!("TEST PASS: test_render_snapshot_contains_header");
}
#[test]
fn test_update_state_sets_daemon_running() {
let _guard = test_guard!();
init_test_logging();
info!("TEST START: test_update_state_sets_daemon_running");
let response = make_response(vec![], vec![], vec![]);
let mut state = TuiState::new();
update_state_from_daemon(&mut state, response);
info!(
"VERIFY: status={:?} uptime={:?}",
state.daemon.status, state.daemon.uptime
);
assert_eq!(state.daemon.status, Status::Running);
assert_eq!(state.daemon.uptime, Duration::from_secs(3600));
info!("TEST PASS: test_update_state_sets_daemon_running");
}
#[test]
fn test_update_state_worker_status_mapping() {
let _guard = test_guard!();
init_test_logging();
info!("TEST START: test_update_state_worker_status_mapping");
let workers = vec![
worker_status("w1", "healthy", "closed"),
worker_status("w2", "degraded", "half_open"),
worker_status("w3", "draining", "open"),
worker_status("w4", "unknown", "closed"),
];
let response = make_response(workers, vec![], vec![]);
let mut state = TuiState::new();
update_state_from_daemon(&mut state, response);
info!("VERIFY: worker_count={}", state.workers.len());
assert_eq!(state.workers.len(), 4);
assert_eq!(state.workers[0].status, WorkerStatus::Healthy);
assert_eq!(state.workers[1].status, WorkerStatus::Degraded);
assert_eq!(state.workers[2].status, WorkerStatus::Draining);
assert_eq!(state.workers[3].status, WorkerStatus::Unreachable);
info!("TEST PASS: test_update_state_worker_status_mapping");
}
#[test]
fn test_update_state_circuit_mapping() {
let _guard = test_guard!();
init_test_logging();
info!("TEST START: test_update_state_circuit_mapping");
let workers = vec![
worker_status("w1", "healthy", "closed"),
worker_status("w2", "healthy", "half_open"),
worker_status("w3", "healthy", "open"),
worker_status("w4", "healthy", "mystery"),
];
let response = make_response(workers, vec![], vec![]);
let mut state = TuiState::new();
update_state_from_daemon(&mut state, response);
assert_eq!(state.workers[0].circuit, CircuitState::Closed);
assert_eq!(state.workers[1].circuit, CircuitState::HalfOpen);
assert_eq!(state.workers[2].circuit, CircuitState::Open);
assert_eq!(state.workers[3].circuit, CircuitState::Open);
info!("TEST PASS: test_update_state_circuit_mapping");
}
#[test]
fn test_update_state_active_builds_mapping() {
let _guard = test_guard!();
init_test_logging();
info!("TEST START: test_update_state_active_builds_mapping");
let active = vec![active_build(1, "worker-1", "cargo build --release")];
let response = make_response(vec![], active, vec![]);
let mut state = TuiState::new();
update_state_from_daemon(&mut state, response);
info!("VERIFY: active_builds={}", state.active_builds.len());
assert_eq!(state.active_builds.len(), 1);
assert_eq!(state.active_builds[0].status, BuildStatus::Compiling);
assert_eq!(
state.active_builds[0].progress.as_ref().unwrap().phase,
"compiling"
);
assert_eq!(state.active_builds[0].worker.as_deref(), Some("worker-1"));
info!("TEST PASS: test_update_state_active_builds_mapping");
}
#[test]
fn test_update_state_build_history_limit() {
let _guard = test_guard!();
init_test_logging();
info!("TEST START: test_update_state_build_history_limit");
let mut history = Vec::new();
for i in 0..120u64 {
history.push(build_record(i, 0, "cargo build"));
}
let response = make_response(vec![], vec![], history);
let mut state = TuiState::new();
update_state_from_daemon(&mut state, response);
info!("VERIFY: history_len={}", state.build_history.len());
assert_eq!(state.build_history.len(), 100);
info!("TEST PASS: test_update_state_build_history_limit");
}
#[test]
fn test_update_state_build_history_success_flag() {
let _guard = test_guard!();
init_test_logging();
info!("TEST START: test_update_state_build_history_success_flag");
let history = vec![
build_record(1, 0, "cargo build"),
build_record(2, 1, "cargo test"),
];
let response = make_response(vec![], vec![], history);
let mut state = TuiState::new();
update_state_from_daemon(&mut state, response);
assert_eq!(state.build_history.len(), 2);
assert!(state.build_history[0].success);
assert!(!state.build_history[1].success);
info!("TEST PASS: test_update_state_build_history_success_flag");
}
#[test]
fn test_update_state_daemon_socket_path() {
let _guard = test_guard!();
init_test_logging();
info!("TEST START: test_update_state_daemon_socket_path");
let response = make_response(vec![], vec![], vec![]);
let mut state = TuiState::new();
update_state_from_daemon(&mut state, response);
info!(
"VERIFY: socket_path={:?} version={}",
state.daemon.socket_path, state.daemon.version
);
assert_eq!(
state.daemon.socket_path,
PathBuf::from(rch_common::default_socket_path())
);
assert_eq!(state.daemon.version, "0.1.0");
info!("TEST PASS: test_update_state_daemon_socket_path");
}
#[test]
fn test_buffer_to_string_basic() {
let _guard = test_guard!();
init_test_logging();
info!("TEST START: test_buffer_to_string_basic");
let pool = GraphemePool::new();
let buffer = Buffer::new(5, 2);
let result = ftui_buffer_to_string(&buffer, &pool);
info!("VERIFY: buffer_to_string output len={}", result.len());
assert_eq!(result.lines().count(), 2);
assert!(result.lines().all(|l| l.len() == 5));
info!("TEST PASS: test_buffer_to_string_basic");
}
#[test]
fn test_buffer_to_string_with_content() {
let _guard = test_guard!();
init_test_logging();
info!("TEST START: test_buffer_to_string_with_content");
use ftui_render::cell::{Cell, CellContent};
let pool = GraphemePool::new();
let mut buffer = Buffer::new(10, 1);
for (i, c) in "Hello".chars().enumerate() {
let cell = Cell {
content: CellContent::from_char(c),
..Default::default()
};
buffer.set(i as u16, 0, cell);
}
let result = ftui_buffer_to_string(&buffer, &pool);
info!("VERIFY: buffer contains Hello: {}", result.trim());
assert!(result.contains("Hello"));
info!("TEST PASS: test_buffer_to_string_with_content");
}
#[test]
fn test_state_to_json_different_panels() {
let _guard = test_guard!();
init_test_logging();
info!("TEST START: test_state_to_json_different_panels");
for panel in [
Panel::Workers,
Panel::ActiveBuilds,
Panel::BuildHistory,
Panel::Logs,
] {
let mut state = TuiState::new();
state.selected_panel = panel;
let json = state_to_json(&state);
let expected = match panel {
Panel::Workers => "workers",
Panel::ActiveBuilds => "active_builds",
Panel::BuildHistory => "build_history",
Panel::Logs => "logs",
};
info!("VERIFY: panel {:?} -> {}", panel, expected);
assert_eq!(json["selected_panel"], expected);
}
info!("TEST PASS: test_state_to_json_different_panels");
}
#[test]
fn test_state_to_json_daemon_statuses() {
let _guard = test_guard!();
init_test_logging();
info!("TEST START: test_state_to_json_daemon_statuses");
for status in [
Status::Unknown,
Status::Running,
Status::Stopped,
Status::Error,
] {
let mut state = TuiState::new();
state.daemon.status = status;
let json = state_to_json(&state);
let expected = match status {
Status::Unknown => "unknown",
Status::Running => "running",
Status::Stopped => "stopped",
Status::Error => "error",
};
info!("VERIFY: status {:?} -> {}", status, expected);
assert_eq!(json["daemon"]["status"], expected);
}
info!("TEST PASS: test_state_to_json_daemon_statuses");
}
#[test]
fn test_state_to_json_color_blind_modes() {
let _guard = test_guard!();
init_test_logging();
info!("TEST START: test_state_to_json_color_blind_modes");
for mode in [
ColorBlindMode::None,
ColorBlindMode::Deuteranopia,
ColorBlindMode::Protanopia,
ColorBlindMode::Tritanopia,
] {
let mut state = TuiState::new();
state.color_blind = mode;
let json = state_to_json(&state);
info!("VERIFY: color_blind mode {:?}", mode);
assert!(json["color_blind"].is_string() || json["color_blind"].is_null());
}
info!("TEST PASS: test_state_to_json_color_blind_modes");
}
#[test]
fn test_state_to_json_filter_state() {
let _guard = test_guard!();
init_test_logging();
info!("TEST START: test_state_to_json_filter_state");
let mut state = TuiState::new();
state.filter.query = "test query".to_string();
state.filter.worker_filter = Some("worker-1".to_string());
state.filter.success_only = true;
state.filter.failed_only = false;
let json = state_to_json(&state);
info!(
"VERIFY: filter={} worker_filter={:?}",
json["filter"]["query"], json["filter"]["worker_filter"]
);
assert_eq!(json["filter"]["query"], "test query");
assert_eq!(json["filter"]["worker_filter"], "worker-1");
assert_eq!(json["filter"]["success_only"], true);
assert_eq!(json["filter"]["failed_only"], false);
info!("TEST PASS: test_state_to_json_filter_state");
}
#[test]
fn test_state_to_json_with_error() {
let _guard = test_guard!();
init_test_logging();
info!("TEST START: test_state_to_json_with_error");
let mut state = TuiState::new();
state.error = Some("Connection refused".to_string());
let json = state_to_json(&state);
info!("VERIFY: error={}", json["error"]);
assert_eq!(json["error"], "Connection refused");
info!("TEST PASS: test_state_to_json_with_error");
}
#[test]
fn test_state_to_json_high_contrast() {
let _guard = test_guard!();
init_test_logging();
info!("TEST START: test_state_to_json_high_contrast");
let mut state = TuiState::new();
state.high_contrast = true;
let json = state_to_json(&state);
info!("VERIFY: high_contrast={}", json["high_contrast"]);
assert_eq!(json["high_contrast"], true);
info!("TEST PASS: test_state_to_json_high_contrast");
}
#[test]
fn test_render_snapshot_various_sizes() {
let _guard = test_guard!();
init_test_logging();
info!("TEST START: test_render_snapshot_various_sizes");
let mut state = TuiState::new();
apply_mock_data(&mut state);
for (w, h) in [(40, 12), (80, 24), (120, 40), (160, 50)] {
let snapshot = render_snapshot(&state, w, h);
info!(
"VERIFY: render {}x{} lines={}",
w,
h,
snapshot.lines().count()
);
assert!(snapshot.lines().count() >= h as usize);
}
info!("TEST PASS: test_render_snapshot_various_sizes");
}
#[test]
fn test_render_snapshot_empty_state() {
let _guard = test_guard!();
init_test_logging();
info!("TEST START: test_render_snapshot_empty_state");
let state = TuiState::new();
let snapshot = render_snapshot(&state, 80, 24);
info!("VERIFY: empty state renders");
assert!(snapshot.contains("RCH Dashboard"));
info!("TEST PASS: test_render_snapshot_empty_state");
}
#[test]
fn test_render_snapshot_with_error() {
let _guard = test_guard!();
init_test_logging();
info!("TEST START: test_render_snapshot_with_error");
let mut state = TuiState::new();
state.error = Some("Daemon not running".to_string());
let snapshot = render_snapshot(&state, 80, 24);
info!("VERIFY: error state renders");
assert!(snapshot.contains("RCH Dashboard"));
info!("TEST PASS: test_render_snapshot_with_error");
}
#[test]
fn test_render_snapshot_with_help_overlay() {
let _guard = test_guard!();
init_test_logging();
info!("TEST START: test_render_snapshot_with_help_overlay");
let mut state = TuiState::new();
state.show_help = true;
let snapshot = render_snapshot(&state, 80, 24);
info!("VERIFY: help overlay renders");
assert!(snapshot.contains("Help") || snapshot.contains("?"));
info!("TEST PASS: test_render_snapshot_with_help_overlay");
}
#[test]
fn test_render_snapshot_with_filter_mode() {
let _guard = test_guard!();
init_test_logging();
info!("TEST START: test_render_snapshot_with_filter_mode");
let mut state = TuiState::new();
state.filter_mode = true;
state.filter.query = "cargo".to_string();
let snapshot = render_snapshot(&state, 80, 24);
info!("VERIFY: filter mode renders");
assert!(snapshot.contains("RCH Dashboard"));
info!("TEST PASS: test_render_snapshot_with_filter_mode");
}
#[test]
fn test_render_snapshot_high_contrast() {
let _guard = test_guard!();
init_test_logging();
info!("TEST START: test_render_snapshot_high_contrast");
let mut state = TuiState::new();
apply_mock_data(&mut state);
state.high_contrast = true;
let snapshot = render_snapshot(&state, 80, 24);
info!("VERIFY: high contrast renders");
assert!(snapshot.contains("RCH Dashboard"));
info!("TEST PASS: test_render_snapshot_high_contrast");
}
#[test]
fn test_render_snapshot_color_blind_deuteranopia() {
let _guard = test_guard!();
init_test_logging();
info!("TEST START: test_render_snapshot_color_blind_deuteranopia");
let mut state = TuiState::new();
apply_mock_data(&mut state);
state.color_blind = ColorBlindMode::Deuteranopia;
let snapshot = render_snapshot(&state, 80, 24);
info!("VERIFY: deuteranopia mode renders");
assert!(snapshot.contains("RCH Dashboard"));
info!("TEST PASS: test_render_snapshot_color_blind_deuteranopia");
}
#[test]
fn test_render_snapshot_color_blind_protanopia() {
let _guard = test_guard!();
init_test_logging();
info!("TEST START: test_render_snapshot_color_blind_protanopia");
let mut state = TuiState::new();
apply_mock_data(&mut state);
state.color_blind = ColorBlindMode::Protanopia;
let snapshot = render_snapshot(&state, 80, 24);
info!("VERIFY: protanopia mode renders");
assert!(snapshot.contains("RCH Dashboard"));
info!("TEST PASS: test_render_snapshot_color_blind_protanopia");
}
#[test]
fn test_render_snapshot_color_blind_tritanopia() {
let _guard = test_guard!();
init_test_logging();
info!("TEST START: test_render_snapshot_color_blind_tritanopia");
let mut state = TuiState::new();
apply_mock_data(&mut state);
state.color_blind = ColorBlindMode::Tritanopia;
let snapshot = render_snapshot(&state, 80, 24);
info!("VERIFY: tritanopia mode renders");
assert!(snapshot.contains("RCH Dashboard"));
info!("TEST PASS: test_render_snapshot_color_blind_tritanopia");
}
#[test]
fn test_apply_mock_data_populates_all_fields() {
let _guard = test_guard!();
init_test_logging();
info!("TEST START: test_apply_mock_data_populates_all_fields");
let mut state = TuiState::new();
apply_mock_data(&mut state);
info!("VERIFY: mock data populates all expected fields");
assert_eq!(state.daemon.status, Status::Running);
assert_eq!(state.daemon.uptime, Duration::from_secs(123));
assert_eq!(state.daemon.version, "mock");
assert_eq!(state.daemon.builds_today, 7);
assert_eq!(state.daemon.bytes_transferred, 42_000_000);
assert_eq!(state.workers.len(), 3);
assert_eq!(state.workers[0].id, "worker-1");
assert_eq!(state.workers[0].status, WorkerStatus::Healthy);
assert_eq!(state.workers[1].status, WorkerStatus::Degraded);
assert_eq!(state.workers[2].status, WorkerStatus::Draining);
assert_eq!(state.active_builds.len(), 1);
assert_eq!(state.active_builds[0].command, "cargo build --release");
assert_eq!(state.build_history.len(), 5);
info!("TEST PASS: test_apply_mock_data_populates_all_fields");
}
#[test]
fn test_update_state_invalid_timestamps_fallback() {
let _guard = test_guard!();
init_test_logging();
info!("TEST START: test_update_state_invalid_timestamps_fallback");
let active = vec![ActiveBuildFromApi {
id: 1,
project_id: "proj".to_string(),
worker_id: "worker-1".to_string(),
command: "cargo build".to_string(),
started_at: "not-a-valid-timestamp".to_string(),
last_heartbeat_at: Some("2026-01-17T00:00:04Z".to_string()),
heartbeat_age_secs: Some(2),
last_progress_at: Some("2026-01-17T00:00:03Z".to_string()),
progress_age_secs: Some(3),
heartbeat_phase: Some("execute".to_string()),
heartbeat_detail: Some("Compiling".to_string()),
heartbeat_counter: Some(2),
heartbeat_percent: Some(20.0),
slots: Some(4),
detector_hook_alive: Some(false),
detector_heartbeat_stale: Some(true),
detector_progress_stale: Some(true),
detector_confidence: Some(0.90),
detector_build_age_secs: Some(110),
detector_slots_owned: Some(4),
detector_last_evaluated_at: Some("2026-01-17T00:00:04Z".to_string()),
}];
let history = vec![BuildRecordFromApi {
id: 1,
started_at: "invalid".to_string(),
completed_at: "also-invalid".to_string(),
project_id: "proj".to_string(),
worker_id: Some("worker-1".to_string()),
command: "cargo test".to_string(),
exit_code: 0,
duration_ms: 1000,
location: "remote".to_string(),
bytes_transferred: None,
timing: None,
cancellation: None,
}];
let response = make_response(vec![], active, history);
let mut state = TuiState::new();
update_state_from_daemon(&mut state, response);
info!("VERIFY: invalid timestamps fall back to now");
assert_eq!(state.active_builds.len(), 1);
assert_eq!(state.build_history.len(), 1);
info!("TEST PASS: test_update_state_invalid_timestamps_fallback");
}
#[test]
fn test_tui_config_custom_values() {
let _guard = test_guard!();
init_test_logging();
info!("TEST START: test_tui_config_custom_values");
let config = TuiConfig {
refresh_interval_ms: 500,
mouse_support: false,
test_mode: true,
mock_data: true,
dump_state: false,
high_contrast: true,
color_blind: ColorBlindMode::Deuteranopia,
};
assert_eq!(config.refresh_interval_ms, 500);
assert!(!config.mouse_support);
assert!(config.test_mode);
assert!(config.mock_data);
assert!(config.high_contrast);
assert_eq!(config.color_blind, ColorBlindMode::Deuteranopia);
info!("TEST PASS: test_tui_config_custom_values");
}
#[test]
fn test_render_all_panel_selections() {
let _guard = test_guard!();
init_test_logging();
info!("TEST START: test_render_all_panel_selections");
let mut state = TuiState::new();
apply_mock_data(&mut state);
for panel in [
Panel::Workers,
Panel::ActiveBuilds,
Panel::BuildHistory,
Panel::Logs,
] {
state.selected_panel = panel;
let snapshot = render_snapshot(&state, 80, 24);
info!("VERIFY: panel {:?} renders", panel);
assert!(!snapshot.is_empty());
}
info!("TEST PASS: test_render_all_panel_selections");
}
#[test]
fn test_render_with_selected_index() {
let _guard = test_guard!();
init_test_logging();
info!("TEST START: test_render_with_selected_index");
let mut state = TuiState::new();
apply_mock_data(&mut state);
state.selected_panel = Panel::Workers;
for idx in 0..3 {
state.selected_index = idx;
let snapshot = render_snapshot(&state, 80, 24);
info!("VERIFY: selection index {} renders", idx);
assert!(!snapshot.is_empty());
}
info!("TEST PASS: test_render_with_selected_index");
}
}