diff --git a/Cargo.lock b/Cargo.lock index a45e3da..7a72d3d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1725,6 +1725,7 @@ dependencies = [ "directories", "futures-util", "insta", + "libc", "mockito", "p2poolv2_config", "ratatui", diff --git a/Cargo.toml b/Cargo.toml index 23c737e..16acddf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,6 +25,9 @@ tokio-tungstenite = { version = "0.24", features = ["rustls-tls-webpki-roots"] } url = "2" serde_json = "1.0.135" +[target.'cfg(unix)'.dependencies] +libc = "0.2" + [dev-dependencies] insta = "1.44.3" serial_test = "3" diff --git a/src/app.rs b/src/app.rs index 824b447..1cda408 100644 --- a/src/app.rs +++ b/src/app.rs @@ -3,6 +3,8 @@ // SPDX-License-Identifier: AGPL-3.0-or-later use crate::bitcoin_config::ConfigEntry as BitcoinEntry; +use crate::bitcoin_logs::{self, BitcoinLogSnapshot}; +use crate::components::bitcoin_client::{BitcoinChainInfo, BitcoinClient}; use crate::components::bitcoin_config_view::BitcoinConfigView; use crate::components::file_explorer::FileExplorer; use crate::components::p2pool_client::{ChainInfo, P2PoolClient, PeerInfo, SharesResponse}; @@ -11,11 +13,18 @@ use crate::components::p2pool_websocket::{ LiveP2PoolEvent, LivePeerEvent, LiveShare, P2PoolWebSocketClient, }; use crate::components::settings_view::SettingsView; +use crate::process_manager::{ProcessManager, ProcessState}; use crate::settings::Settings; +use anyhow::Result; +use base64::{Engine as _, engine::general_purpose}; use p2poolv2_config::Config as P2PoolConfig; +use std::io::Write; use std::path::PathBuf; +use std::time::{Duration, Instant}; use tokio::sync::mpsc; +const BITCOIN_LOG_REFRESH_INTERVAL: Duration = Duration::from_secs(1); + /// Sidebar items labels pub const SIDEBAR_ITEMS: &[(&str, CurrentScreen)] = &[ ("Home", CurrentScreen::Home), @@ -59,6 +68,8 @@ pub enum CurrentScreen { #[derive(Debug, Clone, PartialEq, Eq)] pub enum ExplorerTrigger { BitcoinConfig, + BitcoinCoreDataDir, + BitcoinCoreLogFile, P2PoolConfig, /// The `usize` is the settings field index (0–`FIELD_COUNT - 1`). Settings(usize), @@ -90,6 +101,27 @@ pub enum AppAction { OpenExplorerForSettings(usize), // Clear a settings field by index, setting it back to None ClearSettingsField(usize), + RefreshBitcoinLogs, + ToggleBitcoinLogAutoScroll, + SetBitcoinLogDataDir(PathBuf), + SetBitcoinLogFile(PathBuf), + CopyBitcoinLogs, + StartBitcoinCore, + StopBitcoinCore, + RestartBitcoinCore, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BitcoinLogInputMode { + Search, + LogFilePath, + DataDirPath, +} + +#[derive(Debug)] +pub struct BitcoinLogReadMessage { + pub path: PathBuf, + pub result: Result, } pub struct App { @@ -105,6 +137,21 @@ pub struct App { pub p2pool_config: Option, pub bitcoin_data: Vec, pub bitcoin_status_tab: usize, + pub bitcoin_chain_info: Option, + pub bitcoin_chain_info_error: Option, + pub bitcoin_process_manager: ProcessManager, + pub bitcoin_process_state: ProcessState, + pub bitcoin_process_error: Option, + pub bitcoin_log_path: Option, + pub bitcoin_log_lines: Vec, + pub bitcoin_log_status: String, + pub bitcoin_log_filter: String, + pub bitcoin_log_scroll: u16, + pub bitcoin_log_auto_scroll: bool, + pub bitcoin_log_input_mode: Option, + pub bitcoin_log_input: String, + pub bitcoin_log_refresh_in_progress: bool, + pub bitcoin_log_last_refresh: Option, pub settings: Settings, pub p2pool_client: P2PoolClient, pub p2pool_websocket_client: P2PoolWebSocketClient, @@ -125,6 +172,10 @@ pub struct App { pub live_peer_events: Vec, pub p2pool_live_error: Option, pub p2pool_live_stream_started: bool, + pub bitcoin_chain_info_tx: mpsc::UnboundedSender>, + pub bitcoin_chain_info_rx: mpsc::UnboundedReceiver>, + pub bitcoin_log_tx: mpsc::UnboundedSender, + pub bitcoin_log_rx: mpsc::UnboundedReceiver, pub p2pool_live_tx: mpsc::UnboundedSender>, pub p2pool_live_rx: mpsc::UnboundedReceiver>, // async channel to receive chain info updates from the background task that @@ -141,6 +192,8 @@ impl App { #[must_use] pub fn new() -> App { let (chain_info_tx, chain_info_rx) = mpsc::unbounded_channel(); + let (bitcoin_chain_info_tx, bitcoin_chain_info_rx) = mpsc::unbounded_channel(); + let (bitcoin_log_tx, bitcoin_log_rx) = mpsc::unbounded_channel(); let (peer_info_tx, peer_info_rx) = mpsc::unbounded_channel(); let (share_info_tx, share_info_rx) = mpsc::unbounded_channel(); let (p2pool_live_tx, p2pool_live_rx) = mpsc::unbounded_channel(); @@ -157,6 +210,21 @@ impl App { p2pool_config: None, bitcoin_data: Vec::new(), bitcoin_status_tab: 0, + bitcoin_chain_info: None, + bitcoin_chain_info_error: None, + bitcoin_process_manager: ProcessManager::new(), + bitcoin_process_state: ProcessState::Stopped, + bitcoin_process_error: None, + bitcoin_log_path: None, + bitcoin_log_lines: Vec::new(), + bitcoin_log_status: "No Bitcoin Core debug.log found.".to_string(), + bitcoin_log_filter: String::new(), + bitcoin_log_scroll: 0, + bitcoin_log_auto_scroll: true, + bitcoin_log_input_mode: None, + bitcoin_log_input: String::new(), + bitcoin_log_refresh_in_progress: false, + bitcoin_log_last_refresh: None, settings: Settings::default(), p2pool_client: P2PoolClient::new(), p2pool_websocket_client: P2PoolWebSocketClient::new(), @@ -173,6 +241,10 @@ impl App { live_peer_events: Vec::new(), p2pool_live_error: None, p2pool_live_stream_started: false, + bitcoin_chain_info_tx, + bitcoin_chain_info_rx, + bitcoin_log_tx, + bitcoin_log_rx, p2pool_live_tx, p2pool_live_rx, chain_info_tx, @@ -208,6 +280,240 @@ impl App { } } + pub fn poll_bitcoin_chain_info(&mut self) { + while let Ok(result) = self.bitcoin_chain_info_rx.try_recv() { + match result { + Ok(info) => { + self.bitcoin_chain_info = Some(info); + self.bitcoin_chain_info_error = None; + } + Err(e) => { + self.bitcoin_chain_info = None; + self.bitcoin_chain_info_error = Some(e.to_string()); + } + } + } + } + + pub fn resolve_bitcoin_log_path(&mut self) { + self.bitcoin_log_path = bitcoin_logs::resolve_log_path(&self.settings, &self.bitcoin_data); + if let Some(path) = &self.bitcoin_log_path { + self.bitcoin_log_status = format!("Ready to read {}", path.display()); + } else { + self.bitcoin_log_status = + "No Bitcoin Core debug.log found. Choose a log file or data directory.".to_string(); + } + } + + pub fn poll_bitcoin_process(&mut self) { + self.bitcoin_process_manager.poll(); + self.bitcoin_process_state = self.bitcoin_process_manager.state(); + self.bitcoin_process_error = self.bitcoin_process_manager.error().map(str::to_string); + } + + pub fn start_bitcoin_process(&mut self) -> anyhow::Result<()> { + let exe = self + .settings + .bitcoind_path + .as_deref() + .unwrap_or(std::path::Path::new("bitcoind")); + if self.settings.bitcoind_path.is_some() && !exe.exists() { + let msg = format!("bitcoind not found at {}", exe.display()); + self.bitcoin_process_manager.mark_failed(&msg); + self.bitcoin_process_state = self.bitcoin_process_manager.state(); + self.bitcoin_process_error = self.bitcoin_process_manager.error().map(str::to_string); + return Err(anyhow::anyhow!("{msg}")); + } + let mut command = self.bitcoin_process_command(); + let result = self.bitcoin_process_manager.start(&mut command); + self.bitcoin_process_state = self.bitcoin_process_manager.state(); + self.bitcoin_process_error = self.bitcoin_process_manager.error().map(str::to_string); + result + } + + fn bitcoin_process_command(&self) -> std::process::Command { + let exe = self + .settings + .bitcoind_path + .clone() + .unwrap_or_else(|| std::path::PathBuf::from("bitcoind")); + let mut cmd = std::process::Command::new(exe); + + if let Some(conf) = &self.bitcoin_conf_path { + cmd.arg(format!("-conf={}", conf.display())); + } + if let Some(datadir) = &self.settings.bitcoin_core_data_dir { + cmd.arg(format!("-datadir={}", datadir.display())); + } + + cmd.stdout(std::process::Stdio::null()); + cmd.stderr(std::process::Stdio::null()); + cmd + } + + pub fn stop_bitcoin_process(&mut self) -> anyhow::Result<()> { + let result = self.bitcoin_process_manager.stop(); + self.bitcoin_process_state = self.bitcoin_process_manager.state(); + self.bitcoin_process_error = self.bitcoin_process_manager.error().map(str::to_string); + result + } + + pub fn shutdown_bitcoin_process(&mut self) { + let _ = self.stop_bitcoin_process(); + } + + pub fn restart_bitcoin_process(&mut self) -> anyhow::Result<()> { + let mut command = self.bitcoin_process_command(); + let result = self.bitcoin_process_manager.restart(&mut command); + self.bitcoin_process_state = self.bitcoin_process_manager.state(); + self.bitcoin_process_error = self.bitcoin_process_manager.error().map(str::to_string); + result + } + + pub fn poll_bitcoin_logs(&mut self) { + while let Ok(message) = self.bitcoin_log_rx.try_recv() { + if Some(&message.path) != self.bitcoin_log_path.as_ref() { + continue; + } + + self.bitcoin_log_refresh_in_progress = false; + + match message.result { + Ok(snapshot) => { + let line_count = snapshot.lines.len(); + self.bitcoin_log_lines = snapshot.lines; + self.bitcoin_log_status = if line_count == 0 { + format!("{} is empty.", snapshot.path.display()) + } else { + format!( + "Showing {line_count} recent lines from {}", + snapshot.path.display() + ) + }; + self.bitcoin_log_last_refresh = Some(Instant::now()); + if self.bitcoin_log_auto_scroll { + self.bitcoin_log_scroll = 0; + } + } + Err(error) => { + self.bitcoin_log_status = format!( + "Log file unavailable. Bitcoin Core may not be running or debug.log has not been created: {error}" + ); + self.bitcoin_log_last_refresh = Some(Instant::now()); + } + } + } + } + + pub fn maybe_refresh_bitcoin_logs(&mut self) { + if self.current_screen != CurrentScreen::BitcoinStatus || self.bitcoin_status_tab != 2 { + return; + } + + let refresh_due = self + .bitcoin_log_last_refresh + .is_none_or(|last_refresh| last_refresh.elapsed() >= BITCOIN_LOG_REFRESH_INTERVAL); + + if refresh_due { + self.refresh_bitcoin_logs(); + } + } + + pub fn refresh_bitcoin_logs(&mut self) { + if self.bitcoin_log_refresh_in_progress { + return; + } + + if self.bitcoin_log_path.is_none() { + self.resolve_bitcoin_log_path(); + } + + let Some(path) = self.bitcoin_log_path.clone() else { + self.bitcoin_log_status = + "No Bitcoin Core debug.log found. Choose a log file or data directory.".to_string(); + self.bitcoin_log_lines.clear(); + return; + }; + + self.bitcoin_log_status = format!("Reading {}", path.display()); + self.bitcoin_log_refresh_in_progress = true; + + let tx = self.bitcoin_log_tx.clone(); + if let Ok(handle) = tokio::runtime::Handle::try_current() { + handle.spawn(async move { + let read_path = path.clone(); + let result = tokio::task::spawn_blocking(move || { + bitcoin_logs::read_log_snapshot(&read_path, bitcoin_logs::DEFAULT_MAX_LOG_LINES) + }) + .await + .unwrap_or_else(|error| Err(anyhow::anyhow!("log reader task failed: {error}"))); + let _ = tx.send(BitcoinLogReadMessage { path, result }); + }); + } else { + let result = + bitcoin_logs::read_log_snapshot(&path, bitcoin_logs::DEFAULT_MAX_LOG_LINES); + let _ = self + .bitcoin_log_tx + .send(BitcoinLogReadMessage { path, result }); + } + } + + pub fn set_bitcoin_log_data_dir(&mut self, path: PathBuf) { + let data_dir = bitcoin_logs::expand_path(&path); + let log_path = bitcoin_logs::best_log_path_for_data_dir(&data_dir, &self.bitcoin_data); + self.settings.bitcoin_core_data_dir = Some(data_dir); + self.settings.bitcoin_core_log_path = None; + self.bitcoin_log_path = Some(log_path); + self.reset_bitcoin_log_reader(); + } + + pub fn set_bitcoin_log_file(&mut self, path: PathBuf) { + let log_path = bitcoin_logs::expand_path(&path); + self.settings.bitcoin_core_log_path = Some(log_path.clone()); + self.settings.bitcoin_core_data_dir = None; + self.bitcoin_log_path = Some(log_path); + self.reset_bitcoin_log_reader(); + } + + pub fn reset_bitcoin_log_reader(&mut self) { + self.bitcoin_log_lines.clear(); + self.bitcoin_log_scroll = 0; + self.bitcoin_log_refresh_in_progress = false; + self.bitcoin_log_last_refresh = None; + self.bitcoin_log_status = "Ready to read Bitcoin Core logs.".to_string(); + } + + #[must_use] + pub fn filtered_bitcoin_log_lines(&self) -> Vec<&str> { + let filter = self.bitcoin_log_filter.trim().to_ascii_lowercase(); + if filter.is_empty() { + return self.bitcoin_log_lines.iter().map(String::as_str).collect(); + } + + self.bitcoin_log_lines + .iter() + .filter(|line| line.to_ascii_lowercase().contains(&filter)) + .map(String::as_str) + .collect() + } + + pub fn copy_filtered_bitcoin_logs(&mut self) { + let text = self.filtered_bitcoin_log_lines().join("\n"); + if text.is_empty() { + self.bitcoin_log_status = "No Bitcoin Core log lines to copy.".to_string(); + return; + } + + match copy_to_terminal_clipboard(&text) { + Ok(()) => { + self.bitcoin_log_status = format!("Copied {} log lines.", text.lines().count()); + } + Err(error) => { + self.bitcoin_log_status = format!("Failed to copy logs: {error}"); + } + } + } + pub fn poll_peer_info(&mut self) { while let Ok(result) = self.peer_info_rx.try_recv() { match result { @@ -305,6 +611,10 @@ impl App { } if let Some(&(_, screen)) = SIDEBAR_ITEMS.get(self.sidebar_index) { self.current_screen = screen; + if self.current_screen == CurrentScreen::BitcoinStatus { + self.fetch_bitcoin_chain_info(); + self.resolve_bitcoin_log_path(); + } if self.current_screen == CurrentScreen::P2PoolStatus { let chain_client = self.p2pool_client.clone(); let chain_tx = self.chain_info_tx.clone(); @@ -347,9 +657,139 @@ impl App { } } } + + fn fetch_bitcoin_chain_info(&mut self) { + self.bitcoin_chain_info = None; + self.bitcoin_chain_info_error = None; + + if self.bitcoin_conf_path.is_none() { + return; + } + + let client = BitcoinClient::from_config_entries(&self.bitcoin_data); + let tx = self.bitcoin_chain_info_tx.clone(); + + if let Ok(handle) = tokio::runtime::Handle::try_current() { + handle.spawn(async move { + let res = client.fetch_chain_info().await; + let _ = tx.send(res); + }); + } + } } + +fn copy_to_terminal_clipboard(text: &str) -> Result<()> { + let encoded = general_purpose::STANDARD.encode(text.as_bytes()); + let sequence = format!("\x1b]52;c;{encoded}\x07"); + let mut stdout = std::io::stdout(); + stdout.write_all(sequence.as_bytes())?; + stdout.flush()?; + Ok(()) +} + impl Default for App { fn default() -> Self { Self::new() } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn poll_bitcoin_chain_info_updates_state_on_success() { + let mut app = App::new(); + app.bitcoin_chain_info_error = Some("stale".to_string()); + app.bitcoin_chain_info_tx + .send(Ok(BitcoinChainInfo { + network: "mainnet".to_string(), + block_height: 1, + best_block_hash: "abc".to_string(), + verification_progress: None, + initial_block_download: None, + connection_count: None, + connected_peer_addresses: Vec::new(), + })) + .unwrap(); + + app.poll_bitcoin_chain_info(); + + let info = app.bitcoin_chain_info.as_ref().unwrap(); + + assert_eq!(info.block_height, 1); + assert_eq!(info.best_block_hash, "abc"); + assert!(app.bitcoin_chain_info_error.is_none()); + } + + #[test] + fn poll_bitcoin_chain_info_updates_state_on_error() { + let mut app = App::new(); + app.bitcoin_chain_info = Some(BitcoinChainInfo { + network: "mainnet".to_string(), + block_height: 1, + best_block_hash: "abc".to_string(), + verification_progress: None, + initial_block_download: None, + connection_count: None, + connected_peer_addresses: Vec::new(), + }); + app.bitcoin_chain_info_tx + .send(Err(anyhow::anyhow!("boom"))) + .unwrap(); + + app.poll_bitcoin_chain_info(); + + assert!(app.bitcoin_chain_info.is_none()); + assert_eq!(app.bitcoin_chain_info_error.as_deref(), Some("boom")); + } + + #[test] + fn poll_bitcoin_chain_info_processes_all_queued_results() { + let mut app = App::new(); + app.bitcoin_chain_info_tx + .send(Ok(BitcoinChainInfo { + network: "mainnet".to_string(), + block_height: 1, + best_block_hash: "abc".to_string(), + verification_progress: None, + initial_block_download: None, + connection_count: None, + connected_peer_addresses: Vec::new(), + })) + .unwrap(); + app.bitcoin_chain_info_tx + .send(Err(anyhow::anyhow!("second failure"))) + .unwrap(); + + app.poll_bitcoin_chain_info(); + + assert!(app.bitcoin_chain_info.is_none()); + assert_eq!( + app.bitcoin_chain_info_error.as_deref(), + Some("second failure") + ); + } + + #[test] + fn fetch_bitcoin_chain_info_clears_state_without_configured_bitcoin_conf() { + let mut app = App::new(); + app.bitcoin_conf_path = None; + app.bitcoin_chain_info = Some(BitcoinChainInfo { + network: "mainnet".to_string(), + block_height: 1, + best_block_hash: "abc".to_string(), + verification_progress: None, + initial_block_download: None, + connection_count: None, + connected_peer_addresses: Vec::new(), + }); + app.bitcoin_chain_info_error = Some("stale".to_string()); + + app.fetch_bitcoin_chain_info(); + + assert!(app.bitcoin_chain_info.is_none()); + assert!(app.bitcoin_chain_info_error.is_none()); + assert!(app.bitcoin_chain_info_rx.try_recv().is_err()); + } +} diff --git a/src/bitcoin_logs.rs b/src/bitcoin_logs.rs new file mode 100644 index 0000000..c91b9fb --- /dev/null +++ b/src/bitcoin_logs.rs @@ -0,0 +1,346 @@ +// SPDX-FileCopyrightText: 2024 PDM Authors +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +use crate::bitcoin_config::ConfigEntry; +use crate::settings::Settings; +use anyhow::{Context, Result}; +use std::fs::File; +use std::io::{Read, Seek, SeekFrom}; +use std::path::{Path, PathBuf}; + +pub const DEFAULT_MAX_LOG_LINES: usize = 300; + +const READ_CHUNK_SIZE: u64 = 8 * 1024; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BitcoinLogSnapshot { + pub path: PathBuf, + pub lines: Vec, + pub file_size: u64, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum BitcoinLogNetwork { + Mainnet, + Testnet, + Testnet4, + Signet, + Regtest, +} + +pub fn read_log_snapshot(path: &Path, max_lines: usize) -> Result { + let lines = read_recent_log_lines(path, max_lines)?; + let file_size = std::fs::metadata(path) + .with_context(|| format!("could not read metadata for {}", path.display()))? + .len(); + + Ok(BitcoinLogSnapshot { + path: path.to_path_buf(), + lines, + file_size, + }) +} + +pub fn read_recent_log_lines(path: &Path, max_lines: usize) -> Result> { + if max_lines == 0 { + return Ok(Vec::new()); + } + + let mut file = + File::open(path).with_context(|| format!("could not open {}", path.display()))?; + let file_len = file + .metadata() + .with_context(|| format!("could not read metadata for {}", path.display()))? + .len(); + + if file_len == 0 { + return Ok(Vec::new()); + } + + let mut position = file_len; + let mut newline_count = 0usize; + let mut chunks: Vec> = Vec::new(); + + while position > 0 && newline_count <= max_lines { + let read_size = READ_CHUNK_SIZE.min(position); + position -= read_size; + + file.seek(SeekFrom::Start(position)) + .with_context(|| format!("could not seek {}", path.display()))?; + + let mut chunk = vec![0u8; read_size as usize]; + file.read_exact(&mut chunk) + .with_context(|| format!("could not read {}", path.display()))?; + + newline_count += chunk.iter().filter(|byte| **byte == b'\n').count(); + chunks.push(chunk); + } + + let total_len = chunks.iter().map(Vec::len).sum(); + let mut bytes = Vec::with_capacity(total_len); + for chunk in chunks.into_iter().rev() { + bytes.extend(chunk); + } + + let text = String::from_utf8_lossy(&bytes); + let mut lines: Vec = text.lines().map(ToOwned::to_owned).collect(); + + if lines.len() > max_lines { + lines = lines.split_off(lines.len() - max_lines); + } + + lines.reverse(); + Ok(lines) +} + +pub fn resolve_log_path(settings: &Settings, entries: &[ConfigEntry]) -> Option { + if let Some(path) = &settings.bitcoin_core_log_path { + return Some(expand_path(path)); + } + + if let Some(data_dir) = &settings.bitcoin_core_data_dir { + return Some(best_log_path_for_data_dir(&expand_path(data_dir), entries)); + } + + if let Some(data_dir) = configured_data_dir(entries) { + return Some(best_log_path_for_data_dir(&data_dir, entries)); + } + + default_data_dirs() + .into_iter() + .find_map(|data_dir| existing_log_path_for_data_dir(&data_dir, entries)) +} + +pub fn best_log_path_for_data_dir(data_dir: &Path, entries: &[ConfigEntry]) -> PathBuf { + existing_log_path_for_data_dir(data_dir, entries).unwrap_or_else(|| { + log_path_candidates_for_data_dir(data_dir, entries) + .into_iter() + .next() + .unwrap_or_else(|| data_dir.join("debug.log")) + }) +} + +pub fn log_path_candidates_for_data_dir(data_dir: &Path, entries: &[ConfigEntry]) -> Vec { + let data_dir = expand_path(data_dir); + let network = network_from_entries(entries); + let mut suffixes = Vec::new(); + + match network { + BitcoinLogNetwork::Mainnet => { + suffixes.push(PathBuf::from("debug.log")); + } + BitcoinLogNetwork::Testnet => { + suffixes.push(PathBuf::from("testnet3/debug.log")); + suffixes.push(PathBuf::from("testnet/debug.log")); + } + BitcoinLogNetwork::Testnet4 => { + suffixes.push(PathBuf::from("testnet4/debug.log")); + } + BitcoinLogNetwork::Signet => { + suffixes.push(PathBuf::from("signet/debug.log")); + } + BitcoinLogNetwork::Regtest => { + suffixes.push(PathBuf::from("regtest/debug.log")); + } + } + + // Also support selecting the network-specific directory itself and common + // layouts that differ from the currently selected chain. + suffixes.extend([ + PathBuf::from("debug.log"), + PathBuf::from("testnet3/debug.log"), + PathBuf::from("testnet4/debug.log"), + PathBuf::from("testnet/debug.log"), + PathBuf::from("signet/debug.log"), + PathBuf::from("regtest/debug.log"), + ]); + + let mut candidates = Vec::new(); + for suffix in suffixes { + let candidate = data_dir.join(suffix); + if !candidates.iter().any(|path| path == &candidate) { + candidates.push(candidate); + } + } + + candidates +} + +pub fn expand_path(path: &Path) -> PathBuf { + let raw = path.to_string_lossy(); + expand_path_str(raw.trim()) +} + +pub fn expand_path_str(raw: &str) -> PathBuf { + let mut expanded = raw.to_string(); + + if expanded == "~" || expanded.starts_with("~/") { + if let Some(home) = home_dir() { + let suffix = expanded.trim_start_matches('~').trim_start_matches('/'); + expanded = home.join(suffix).to_string_lossy().into_owned(); + } + } + + if expanded.contains("%APPDATA%") + && let Some(appdata) = std::env::var_os("APPDATA") + { + expanded = expanded.replace("%APPDATA%", &appdata.to_string_lossy()); + } + + PathBuf::from(expanded) +} + +fn existing_log_path_for_data_dir(data_dir: &Path, entries: &[ConfigEntry]) -> Option { + log_path_candidates_for_data_dir(data_dir, entries) + .into_iter() + .find(|path| path.is_file()) +} + +fn configured_data_dir(entries: &[ConfigEntry]) -> Option { + entry_value(entries, "datadir") + .map(expand_path_str) + .filter(|path| !path.as_os_str().is_empty()) +} + +fn default_data_dirs() -> Vec { + let mut dirs = Vec::new(); + + if let Some(home) = home_dir() { + dirs.push(home.join(".bitcoin")); + dirs.push(home.join("Library/Application Support/Bitcoin")); + } + + if let Some(appdata) = std::env::var_os("APPDATA") { + dirs.push(PathBuf::from(appdata).join("Bitcoin")); + } else if let Some(user_profile) = std::env::var_os("USERPROFILE") { + dirs.push( + PathBuf::from(user_profile) + .join("AppData") + .join("Roaming") + .join("Bitcoin"), + ); + } + + dirs +} + +fn home_dir() -> Option { + std::env::var_os("HOME") + .or_else(|| std::env::var_os("USERPROFILE")) + .map(PathBuf::from) +} + +fn entry_value<'a>(entries: &'a [ConfigEntry], key: &str) -> Option<&'a str> { + entries + .iter() + .find(|entry| entry.enabled && entry.key == key && !entry.value.trim().is_empty()) + .map(|entry| entry.value.trim()) +} + +fn network_from_entries(entries: &[ConfigEntry]) -> BitcoinLogNetwork { + if bool_entry(entries, "regtest") { + return BitcoinLogNetwork::Regtest; + } + if bool_entry(entries, "signet") { + return BitcoinLogNetwork::Signet; + } + if bool_entry(entries, "testnet4") { + return BitcoinLogNetwork::Testnet4; + } + if bool_entry(entries, "testnet") { + return BitcoinLogNetwork::Testnet; + } + + match entry_value(entries, "chain") + .unwrap_or_default() + .to_ascii_lowercase() + .as_str() + { + "test" | "testnet" | "testnet3" => BitcoinLogNetwork::Testnet, + "testnet4" => BitcoinLogNetwork::Testnet4, + "signet" => BitcoinLogNetwork::Signet, + "regtest" => BitcoinLogNetwork::Regtest, + _ => BitcoinLogNetwork::Mainnet, + } +} + +fn bool_entry(entries: &[ConfigEntry], key: &str) -> bool { + matches!( + entry_value(entries, key) + .map(str::to_ascii_lowercase) + .as_deref(), + Some("1" | "true" | "yes" | "on") + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + fn entry(key: &str, value: &str) -> ConfigEntry { + ConfigEntry { + key: key.to_string(), + value: value.to_string(), + schema: None, + enabled: true, + section: None, + } + } + + #[test] + fn reads_recent_lines_newest_first() { + let dir = tempdir().unwrap(); + let path = dir.path().join("debug.log"); + std::fs::write( + &path, + "2026-01-01T00:00:00Z first\n2026-01-01T00:00:01Z second\n2026-01-01T00:00:02Z third\n", + ) + .unwrap(); + + let lines = read_recent_log_lines(&path, 2).unwrap(); + + assert_eq!( + lines, + vec![ + "2026-01-01T00:00:02Z third".to_string(), + "2026-01-01T00:00:01Z second".to_string(), + ] + ); + } + + #[test] + fn resolves_direct_log_path_from_settings() { + let settings = Settings { + bitcoin_core_log_path: Some(PathBuf::from("/tmp/bitcoin/debug.log")), + ..Default::default() + }; + + assert_eq!( + resolve_log_path(&settings, &[]), + Some(PathBuf::from("/tmp/bitcoin/debug.log")) + ); + } + + #[test] + fn prefers_network_layout_for_configured_data_dir() { + let entries = vec![entry("signet", "1")]; + let path = best_log_path_for_data_dir(Path::new("/tmp/bitcoin"), &entries); + + assert_eq!(path, PathBuf::from("/tmp/bitcoin/signet/debug.log")); + } + + #[test] + fn finds_existing_testnet4_log() { + let dir = tempdir().unwrap(); + let log_dir = dir.path().join("testnet4"); + std::fs::create_dir_all(&log_dir).unwrap(); + std::fs::write(log_dir.join("debug.log"), "hello\n").unwrap(); + + let entries = vec![entry("chain", "testnet4")]; + let path = existing_log_path_for_data_dir(dir.path(), &entries).unwrap(); + + assert_eq!(path, log_dir.join("debug.log")); + } +} diff --git a/src/components/bitcoin_client.rs b/src/components/bitcoin_client.rs new file mode 100644 index 0000000..05eae1c --- /dev/null +++ b/src/components/bitcoin_client.rs @@ -0,0 +1,723 @@ +// SPDX-FileCopyrightText: 2024 PDM Authors +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +use crate::bitcoin_config::ConfigEntry; +use anyhow::{Context, Result, anyhow, bail}; +use reqwest::Client; +use serde::{Deserialize, Serialize, de::DeserializeOwned}; +use serde_json::Value; +use std::{path::PathBuf, time::Duration}; + +const REQUEST_TIMEOUT_SECONDS: u64 = 10; + +#[derive(Debug, Clone)] +pub struct BitcoinClient { + client: Client, + url: String, + auth_credentials: Option<(String, String)>, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct BitcoinChainInfo { + pub network: String, + pub block_height: u64, + pub best_block_hash: String, + pub verification_progress: Option, + pub initial_block_download: Option, + pub connection_count: Option, + pub connected_peer_addresses: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum BitcoinNetwork { + Mainnet, + Testnet, + Testnet4, + Signet, + Regtest, +} + +#[derive(Debug, Deserialize)] +struct BlockchainInfoResponse { + chain: String, + blocks: u64, + bestblockhash: String, + verificationprogress: Option, + initialblockdownload: Option, +} + +#[derive(Debug, Deserialize)] +struct PeerInfoResponse { + addr: Option, +} + +#[derive(Debug, Serialize)] +struct RpcRequest<'a> { + jsonrpc: &'static str, + id: &'static str, + method: &'a str, + params: &'static [Value], +} + +#[derive(Debug, Deserialize)] +struct RpcResponse { + result: Option, + error: Option, +} + +#[derive(Debug, Deserialize)] +struct RpcError { + code: i64, + message: String, +} + +impl BitcoinClient { + #[must_use] + pub fn from_config_entries(entries: &[ConfigEntry]) -> Self { + let network = network_from_entries(entries); + let port = entry_value(entries, "rpcport") + .and_then(|value| value.parse::().ok()) + .unwrap_or_else(|| default_rpc_port(network)); + let host = entry_value(entries, "rpcbind").unwrap_or("127.0.0.1"); + let url = rpc_url(host, port); + let auth_credentials = rpc_auth(entries, network); + + Self { + client: build_client(), + url, + auth_credentials, + } + } + + pub async fn fetch_chain_info(&self) -> Result { + let chain_info: BlockchainInfoResponse = self.rpc_call("getblockchaininfo").await?; + let connection_count = self.rpc_call("getconnectioncount").await.ok(); + let connected_peer_addresses = self.fetch_connected_peer_addresses().await?; + + Ok(BitcoinChainInfo { + network: display_network(&chain_info.chain).to_string(), + block_height: chain_info.blocks, + best_block_hash: chain_info.bestblockhash, + verification_progress: chain_info.verificationprogress, + initial_block_download: chain_info.initialblockdownload, + connection_count, + connected_peer_addresses, + }) + } + + async fn fetch_connected_peer_addresses(&self) -> Result> { + let peers: Vec = self.rpc_call("getpeerinfo").await?; + + Ok(peers + .into_iter() + .filter_map(|peer| { + let address = peer.addr?.trim().to_string(); + (!address.is_empty()).then_some(address) + }) + .collect()) + } + + async fn rpc_call(&self, method: &str) -> Result + where + T: DeserializeOwned, + { + let request = RpcRequest { + jsonrpc: "1.0", + id: "pdm", + method, + params: &[], + }; + + let mut builder = self.client.post(&self.url).json(&request); + if let Some((user, pass)) = &self.auth_credentials { + builder = builder.basic_auth(user, Some(pass)); + } + + let response = builder + .send() + .await + .with_context(|| format!("could not connect to Bitcoin Core at {}", self.url))? + .error_for_status() + .context("Bitcoin Core RPC returned an HTTP error")? + .json::>() + .await + .context("Bitcoin Core RPC returned an invalid response")?; + + if let Some(error) = response.error { + bail!("Bitcoin Core RPC error {}: {}", error.code, error.message); + } + + response + .result + .ok_or_else(|| anyhow!("Bitcoin Core RPC response did not include a result")) + } +} + +fn build_client() -> Client { + Client::builder() + .timeout(Duration::from_secs(REQUEST_TIMEOUT_SECONDS)) + .build() + .expect("Failed to build reqwest client") +} + +fn entry_value<'a>(entries: &'a [ConfigEntry], key: &str) -> Option<&'a str> { + entries + .iter() + .find(|entry| entry.enabled && entry.key == key && !entry.value.trim().is_empty()) + .map(|entry| entry.value.trim()) +} + +fn network_from_entries(entries: &[ConfigEntry]) -> BitcoinNetwork { + if bool_entry(entries, "regtest") { + return BitcoinNetwork::Regtest; + } + if bool_entry(entries, "signet") { + return BitcoinNetwork::Signet; + } + if bool_entry(entries, "testnet4") { + return BitcoinNetwork::Testnet4; + } + if bool_entry(entries, "testnet") { + return BitcoinNetwork::Testnet; + } + + match entry_value(entries, "chain") + .unwrap_or_default() + .to_ascii_lowercase() + .as_str() + { + "test" | "testnet" | "testnet3" => BitcoinNetwork::Testnet, + "testnet4" => BitcoinNetwork::Testnet4, + "signet" => BitcoinNetwork::Signet, + "regtest" => BitcoinNetwork::Regtest, + _ => BitcoinNetwork::Mainnet, + } +} + +fn bool_entry(entries: &[ConfigEntry], key: &str) -> bool { + matches!( + entry_value(entries, key) + .map(str::to_ascii_lowercase) + .as_deref(), + Some("1" | "true" | "yes" | "on") + ) +} + +fn default_rpc_port(network: BitcoinNetwork) -> u16 { + match network { + BitcoinNetwork::Mainnet => 8332, + BitcoinNetwork::Testnet => 18332, + BitcoinNetwork::Testnet4 => 48332, + BitcoinNetwork::Signet => 38332, + BitcoinNetwork::Regtest => 18443, + } +} + +fn rpc_url(host: &str, port: u16) -> String { + let host = host.trim().trim_matches('/'); + if host.starts_with("http://") || host.starts_with("https://") { + return host.to_string(); + } + if has_explicit_port(host) { + return format!("http://{host}"); + } + if host.contains(':') && !host.starts_with('[') { + return format!("http://[{host}]:{port}"); + } + format!("http://{host}:{port}") +} + +fn has_explicit_port(host: &str) -> bool { + if let Some(end_bracket) = host.find(']') { + return host[end_bracket + 1..].starts_with(':'); + } + + host.matches(':').count() == 1 + && host + .rsplit_once(':') + .is_some_and(|(_, port)| port.parse::().is_ok()) +} + +fn rpc_auth(entries: &[ConfigEntry], network: BitcoinNetwork) -> Option<(String, String)> { + if let (Some(user), Some(pass)) = ( + entry_value(entries, "rpcuser"), + entry_value(entries, "rpcpassword"), + ) { + return Some((user.to_string(), pass.to_string())); + } + + read_cookie_auth(entries, network).ok() +} + +fn read_cookie_auth(entries: &[ConfigEntry], network: BitcoinNetwork) -> Result<(String, String)> { + let cookie_path = cookie_path(entries, network); + let content = std::fs::read_to_string(&cookie_path) + .with_context(|| format!("could not read RPC cookie at {}", cookie_path.display()))?; + let (user, pass) = content + .trim() + .split_once(':') + .ok_or_else(|| anyhow!("RPC cookie did not contain username and password"))?; + + Ok((user.to_string(), pass.to_string())) +} + +fn cookie_path(entries: &[ConfigEntry], network: BitcoinNetwork) -> PathBuf { + if let Some(path) = entry_value(entries, "rpccookiefile") { + let configured = PathBuf::from(path); + if configured.is_absolute() { + return configured; + } + return data_dir(entries, network).join(configured); + } + + data_dir(entries, network).join(".cookie") +} + +fn data_dir(entries: &[ConfigEntry], network: BitcoinNetwork) -> PathBuf { + let base = entry_value(entries, "datadir") + .map(PathBuf::from) + .or_else(default_data_dir) + .unwrap_or_default(); + + match network { + BitcoinNetwork::Mainnet => base, + BitcoinNetwork::Testnet => base.join("testnet3"), + BitcoinNetwork::Testnet4 => base.join("testnet4"), + BitcoinNetwork::Signet => base.join("signet"), + BitcoinNetwork::Regtest => base.join("regtest"), + } +} + +fn default_data_dir() -> Option { + std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".bitcoin")) +} + +fn display_network(chain: &str) -> &str { + match chain { + "main" => "mainnet", + "test" | "testnet" | "testnet3" | "testnet4" => "testnet", + "signet" => "signet", + "regtest" => "regtest", + other => other, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use mockito::{Matcher, Server}; + use serde_json::json; + + fn entry(key: &str, value: &str) -> ConfigEntry { + ConfigEntry { + key: key.to_string(), + value: value.to_string(), + schema: None, + enabled: true, + section: None, + } + } + + #[test] + fn builds_default_mainnet_endpoint() { + let client = BitcoinClient::from_config_entries(&[]); + + assert_eq!(client.url, "http://127.0.0.1:8332"); + } + + #[test] + fn uses_configured_rpc_port_and_auth() { + let entries = vec![ + entry("rpcport", "18443"), + entry("rpcuser", "alice"), + entry("rpcpassword", "secret"), + ]; + let client = BitcoinClient::from_config_entries(&entries); + + assert_eq!(client.url, "http://127.0.0.1:18443"); + assert_eq!( + client.auth_credentials, + Some(("alice".to_string(), "secret".to_string())) + ); + } + + #[test] + fn detects_network_from_chain_setting() { + let entries = vec![entry("chain", "testnet4")]; + let client = BitcoinClient::from_config_entries(&entries); + + assert_eq!(client.url, "http://127.0.0.1:48332"); + } + + #[test] + fn preserves_rpcbind_with_explicit_port() { + let entries = vec![entry("rpcbind", "127.0.0.1:18443")]; + let client = BitcoinClient::from_config_entries(&entries); + + assert_eq!(client.url, "http://127.0.0.1:18443"); + } + + #[tokio::test] + async fn fetch_chain_info_success() { + let mut server = Server::new_async().await; + + let chain_mock = server + .mock("POST", "/") + .match_body(Matcher::Regex("getblockchaininfo".to_string())) + .with_status(200) + .with_header("content-type", "application/json") + .with_body( + json!({ + "result": { + "chain": "main", + "blocks": 850_000u64, + "bestblockhash": "00000000000000000002a7c4c1e48d76c5a37902165a270156b7a8d72728a054", + "verificationprogress": 0.9999, + "initialblockdownload": false + }, + "error": null, + "id": "pdm" + }) + .to_string(), + ) + .create(); + let connections_mock = server + .mock("POST", "/") + .match_body(Matcher::Regex("getconnectioncount".to_string())) + .with_status(200) + .with_header("content-type", "application/json") + .with_body(json!({ "result": 8u64, "error": null, "id": "pdm" }).to_string()) + .create(); + let peers_mock = server + .mock("POST", "/") + .match_body(Matcher::Regex("getpeerinfo".to_string())) + .with_status(200) + .with_header("content-type", "application/json") + .with_body( + json!({ + "result": [ + { "addr": "192.0.2.1:8333" }, + { "addr": "203.0.113.5:8333" } + ], + "error": null, + "id": "pdm" + }) + .to_string(), + ) + .create(); + let client = BitcoinClient { + client: build_client(), + url: server.url(), + auth_credentials: None, + }; + + let result = client.fetch_chain_info().await.unwrap(); + + assert_eq!(result.network, "mainnet"); + assert_eq!(result.block_height, 850_000); + assert_eq!( + result.best_block_hash, + "00000000000000000002a7c4c1e48d76c5a37902165a270156b7a8d72728a054" + ); + assert_eq!(result.verification_progress, Some(0.9999)); + assert_eq!(result.initial_block_download, Some(false)); + assert_eq!(result.connection_count, Some(8)); + assert_eq!( + result.connected_peer_addresses, + vec!["192.0.2.1:8333".to_string(), "203.0.113.5:8333".to_string(),] + ); + chain_mock.assert(); + connections_mock.assert(); + peers_mock.assert(); + } + + #[tokio::test] + async fn fetch_chain_info_returns_error_for_rpc_error_response() { + let mut server = Server::new_async().await; + + server + .mock("POST", "/") + .match_body(Matcher::Regex("getblockchaininfo".to_string())) + .with_status(200) + .with_header("content-type", "application/json") + .with_body( + json!({ + "result": null, + "error": {"code": -8, "message": "invalid parameter"}, + "id": "pdm" + }) + .to_string(), + ) + .create(); + + let client = BitcoinClient { + client: build_client(), + url: server.url(), + auth_credentials: None, + }; + + let error = client.fetch_chain_info().await.unwrap_err(); + + assert_eq!( + error.to_string(), + "Bitcoin Core RPC error -8: invalid parameter" + ); + } + + #[tokio::test] + async fn fetch_chain_info_returns_error_for_http_error_response() { + let mut server = Server::new_async().await; + + server + .mock("POST", "/") + .match_body(Matcher::Regex("getblockchaininfo".to_string())) + .with_status(500) + .with_body("internal error") + .create(); + + let client = BitcoinClient { + client: build_client(), + url: server.url(), + auth_credentials: None, + }; + + let error = client.fetch_chain_info().await.unwrap_err(); + + assert_eq!(error.to_string(), "Bitcoin Core RPC returned an HTTP error"); + } + + #[tokio::test] + async fn fetch_chain_info_returns_error_for_invalid_json_response() { + let mut server = Server::new_async().await; + + server + .mock("POST", "/") + .match_body(Matcher::Regex("getblockchaininfo".to_string())) + .with_status(200) + .with_header("content-type", "application/json") + .with_body("not-json") + .create(); + + let client = BitcoinClient { + client: build_client(), + url: server.url(), + auth_credentials: None, + }; + + let error = client.fetch_chain_info().await.unwrap_err(); + + assert_eq!( + error.to_string(), + "Bitcoin Core RPC returned an invalid response" + ); + } + + #[tokio::test] + async fn fetch_chain_info_returns_error_when_result_is_missing() { + let mut server = Server::new_async().await; + + server + .mock("POST", "/") + .match_body(Matcher::Regex("getblockchaininfo".to_string())) + .with_status(200) + .with_header("content-type", "application/json") + .with_body(json!({ "result": null, "error": null, "id": "pdm" }).to_string()) + .create(); + + let client = BitcoinClient { + client: build_client(), + url: server.url(), + auth_credentials: None, + }; + + let error = client.fetch_chain_info().await.unwrap_err(); + + assert_eq!( + error.to_string(), + "Bitcoin Core RPC response did not include a result" + ); + } + + #[tokio::test] + async fn fetch_chain_info_treats_connection_count_failure_as_none() { + let mut server = Server::new_async().await; + + server + .mock("POST", "/") + .match_body(Matcher::Regex("getblockchaininfo".to_string())) + .with_status(200) + .with_header("content-type", "application/json") + .with_body( + json!({ + "result": { + "chain": "main", + "blocks": 111, + "bestblockhash": "abc", + "verificationprogress": 0.5, + "initialblockdownload": true + }, + "error": null, + "id": "pdm" + }) + .to_string(), + ) + .create(); + server + .mock("POST", "/") + .match_body(Matcher::Regex("getconnectioncount".to_string())) + .with_status(500) + .with_body("boom") + .create(); + server + .mock("POST", "/") + .match_body(Matcher::Regex("getpeerinfo".to_string())) + .with_status(200) + .with_header("content-type", "application/json") + .with_body( + json!({ + "result": [], + "error": null, + "id": "pdm" + }) + .to_string(), + ) + .create(); + + let client = BitcoinClient { + client: build_client(), + url: server.url(), + auth_credentials: None, + }; + + let result = client.fetch_chain_info().await.unwrap(); + + assert_eq!(result.block_height, 111); + assert_eq!(result.best_block_hash, "abc"); + assert_eq!(result.verification_progress, Some(0.5)); + assert_eq!(result.initial_block_download, Some(true)); + assert_eq!(result.connection_count, None); + } + + #[tokio::test] + async fn fetch_chain_info_sends_basic_auth_credentials() { + let mut server = Server::new_async().await; + + server + .mock("POST", "/") + .match_header("authorization", "Basic YWxpY2U6c2VjcmV0") + .match_body(Matcher::Regex("getblockchaininfo".to_string())) + .with_status(200) + .with_header("content-type", "application/json") + .with_body( + json!({ + "result": { + "chain": "main", + "blocks": 1, + "bestblockhash": "abc", + "verificationprogress": null, + "initialblockdownload": null + }, + "error": null, + "id": "pdm" + }) + .to_string(), + ) + .create(); + server + .mock("POST", "/") + .match_header("authorization", "Basic YWxpY2U6c2VjcmV0") + .match_body(Matcher::Regex("getconnectioncount".to_string())) + .with_status(200) + .with_header("content-type", "application/json") + .with_body( + json!({ + "result": 8, + "error": null, + "id": "pdm" + }) + .to_string(), + ) + .create(); + + server + .mock("POST", "/") + .match_header("authorization", "Basic YWxpY2U6c2VjcmV0") + .match_body(Matcher::Regex("getpeerinfo".to_string())) + .with_status(200) + .with_header("content-type", "application/json") + .with_body( + json!({ + "result": [], + "error": null, + "id": "pdm" + }) + .to_string(), + ) + .create(); + + let client = BitcoinClient { + client: build_client(), + url: server.url(), + auth_credentials: Some(("alice".to_string(), "secret".to_string())), + }; + + let result = client.fetch_chain_info().await.unwrap(); + + assert_eq!(result.block_height, 1); + } + + #[test] + fn ignores_disabled_and_whitespace_only_config_entries() { + let entries = vec![ + entry("rpcport", " "), + ConfigEntry { + key: "rpcport".to_string(), + value: "18443".to_string(), + schema: None, + enabled: false, + section: None, + }, + ]; + let client = BitcoinClient::from_config_entries(&entries); + + assert_eq!(client.url, "http://127.0.0.1:8332"); + } + + #[test] + fn falls_back_to_cookie_auth_when_rpc_password_is_missing() { + let temp_dir = std::env::temp_dir().join(format!( + "pdm-bitcoin-client-test-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + let cookie_path = temp_dir.join(".cookie"); + std::fs::create_dir_all(&temp_dir).unwrap(); + std::fs::write(&cookie_path, "alice:secret").unwrap(); + + let entries = vec![ + entry("rpcuser", "alice"), + entry("rpccookiefile", cookie_path.to_string_lossy().as_ref()), + ]; + let client = BitcoinClient::from_config_entries(&entries); + + assert_eq!( + client.auth_credentials, + Some(("alice".to_string(), "secret".to_string())) + ); + + let _ = std::fs::remove_file(cookie_path); + let _ = std::fs::remove_dir(temp_dir); + } + + #[test] + fn formats_ipv6_rpcbind_without_explicit_port() { + let entries = vec![entry("rpcbind", "::1")]; + let client = BitcoinClient::from_config_entries(&entries); + + assert_eq!(client.url, "http://[::1]:8332"); + } +} diff --git a/src/components/bitcoin_status_view.rs b/src/components/bitcoin_status_view.rs index c2b3017..98efa71 100644 --- a/src/components/bitcoin_status_view.rs +++ b/src/components/bitcoin_status_view.rs @@ -2,8 +2,9 @@ // // SPDX-License-Identifier: AGPL-3.0-or-later -use crate::app::{App, BITCOIN_STATUS_TABS}; +use crate::app::{App, AppAction, BITCOIN_STATUS_TABS, BitcoinLogInputMode, ExplorerTrigger}; +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use ratatui::{ prelude::*, widgets::{Block, Borders, Paragraph, Tabs, Wrap}, @@ -43,40 +44,455 @@ impl BitcoinStatusView { let content_area = outer[1]; match app.bitcoin_status_tab { // Chain Info - 0 => { - let text = "Chain Info"; - let p = Paragraph::new(text) - .block(Block::default().borders(Borders::ALL)) - .wrap(Wrap { trim: true }); - f.render_widget(p, content_area); - } + 0 => Self::render_chain_info(f, app, content_area), // System - 1 => { - let text = "System"; - let p = Paragraph::new(text) - .block(Block::default().borders(Borders::ALL)) - .wrap(Wrap { trim: true }); - f.render_widget(p, content_area); - } + 1 => Self::render_system(f, app, content_area), // Logs - 2 => { - let text = "Logs"; - let p = Paragraph::new(text) - .block(Block::default().borders(Borders::ALL)) - .wrap(Wrap { trim: true }); - f.render_widget(p, content_area); - } + 2 => Self::render_logs(f, app, content_area), // Peers - 3 => { - let text = "Peers"; - let p = Paragraph::new(text) - .block(Block::default().borders(Borders::ALL)) - .wrap(Wrap { trim: true }); - f.render_widget(p, content_area); - } + 3 => Self::render_peers(f, app, content_area), _ => {} } } + + pub fn handle_system_input(app: &mut App, key: KeyEvent) -> AppAction { + match key.code { + KeyCode::Char('s') if app.bitcoin_process_state.can_start() => { + AppAction::StartBitcoinCore + } + KeyCode::Char('t') if app.bitcoin_process_state.can_stop() => { + AppAction::StopBitcoinCore + } + KeyCode::Char('r') if app.bitcoin_process_state.can_restart() => { + AppAction::RestartBitcoinCore + } + _ => AppAction::None, + } + } + + pub fn handle_logs_input(app: &mut App, key: KeyEvent) -> AppAction { + if let Some(mode) = app.bitcoin_log_input_mode { + return Self::handle_logs_text_input(app, key, mode); + } + + match key.code { + KeyCode::Char('/') => { + app.bitcoin_log_input_mode = Some(BitcoinLogInputMode::Search); + app.bitcoin_log_input = app.bitcoin_log_filter.clone(); + AppAction::None + } + KeyCode::Char('p') => { + app.bitcoin_log_input_mode = Some(BitcoinLogInputMode::LogFilePath); + app.bitcoin_log_input = app + .bitcoin_log_path + .as_ref() + .map(|path| path.to_string_lossy().into_owned()) + .unwrap_or_default(); + AppAction::None + } + KeyCode::Char('g') | KeyCode::Char('d') => { + app.bitcoin_log_input_mode = Some(BitcoinLogInputMode::DataDirPath); + app.bitcoin_log_input = app + .settings + .bitcoin_core_data_dir + .as_ref() + .map(|path| path.to_string_lossy().into_owned()) + .unwrap_or_default(); + AppAction::None + } + KeyCode::Char('b') => AppAction::OpenExplorer(ExplorerTrigger::BitcoinCoreLogFile), + KeyCode::Char('o') => AppAction::OpenExplorer(ExplorerTrigger::BitcoinCoreDataDir), + KeyCode::Char('r') => AppAction::RefreshBitcoinLogs, + KeyCode::Char('a') => AppAction::ToggleBitcoinLogAutoScroll, + KeyCode::Char('c') => AppAction::CopyBitcoinLogs, + KeyCode::Esc if !app.bitcoin_log_filter.is_empty() => { + app.bitcoin_log_filter.clear(); + app.bitcoin_log_scroll = 0; + AppAction::None + } + KeyCode::Up => { + app.bitcoin_log_scroll = app.bitcoin_log_scroll.saturating_sub(1); + app.bitcoin_log_auto_scroll = app.bitcoin_log_scroll == 0; + AppAction::None + } + KeyCode::Down => { + app.bitcoin_log_scroll = Self::next_scroll(app, 1); + app.bitcoin_log_auto_scroll = false; + AppAction::None + } + KeyCode::PageUp => { + app.bitcoin_log_scroll = app.bitcoin_log_scroll.saturating_sub(10); + app.bitcoin_log_auto_scroll = app.bitcoin_log_scroll == 0; + AppAction::None + } + KeyCode::PageDown => { + app.bitcoin_log_scroll = Self::next_scroll(app, 10); + app.bitcoin_log_auto_scroll = false; + AppAction::None + } + KeyCode::Home => { + app.bitcoin_log_scroll = 0; + app.bitcoin_log_auto_scroll = true; + AppAction::None + } + KeyCode::End => { + app.bitcoin_log_scroll = Self::max_scroll(app); + app.bitcoin_log_auto_scroll = false; + AppAction::None + } + _ => AppAction::None, + } + } + + fn handle_logs_text_input( + app: &mut App, + key: KeyEvent, + mode: BitcoinLogInputMode, + ) -> AppAction { + match key.code { + KeyCode::Enter => { + let input = app.bitcoin_log_input.trim().to_string(); + app.bitcoin_log_input.clear(); + app.bitcoin_log_input_mode = None; + + match mode { + BitcoinLogInputMode::Search => { + app.bitcoin_log_filter = input; + app.bitcoin_log_scroll = 0; + AppAction::None + } + BitcoinLogInputMode::LogFilePath if input.is_empty() => { + app.bitcoin_log_status = "Log file path cannot be empty.".to_string(); + AppAction::None + } + BitcoinLogInputMode::LogFilePath => { + AppAction::SetBitcoinLogFile(std::path::PathBuf::from(input)) + } + BitcoinLogInputMode::DataDirPath if input.is_empty() => { + app.bitcoin_log_status = + "Bitcoin Core data directory cannot be empty.".to_string(); + AppAction::None + } + BitcoinLogInputMode::DataDirPath => { + AppAction::SetBitcoinLogDataDir(std::path::PathBuf::from(input)) + } + } + } + KeyCode::Esc => { + app.bitcoin_log_input.clear(); + app.bitcoin_log_input_mode = None; + AppAction::None + } + KeyCode::Backspace => { + app.bitcoin_log_input.pop(); + AppAction::None + } + KeyCode::Char(ch) + if !key + .modifiers + .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) => + { + app.bitcoin_log_input.push(ch); + AppAction::None + } + _ => AppAction::None, + } + } + + fn render_system(f: &mut Frame, app: &App, area: Rect) { + let state = app.bitcoin_process_state.as_str(); + let error = app.bitcoin_process_error.clone().unwrap_or_default(); + let status_line = if error.is_empty() { + format!("Process state: {state}") + } else { + format!("Process state: {state} ({error})") + }; + let start_style = if app.bitcoin_process_state.can_start() { + Style::default().fg(Color::Cyan) + } else { + Style::default().fg(Color::DarkGray) + }; + let stop_style = if app.bitcoin_process_state.can_stop() { + Style::default().fg(Color::Cyan) + } else { + Style::default().fg(Color::DarkGray) + }; + let restart_style = if app.bitcoin_process_state.can_restart() { + Style::default().fg(Color::Cyan) + } else { + Style::default().fg(Color::DarkGray) + }; + let controls = vec![ + Line::from(vec![ + Span::styled("[s] Start ", start_style), + Span::styled("[t] Stop ", stop_style), + Span::styled("[r] Restart", restart_style), + ]), + Line::from(""), + Line::from(status_line), + ]; + let paragraph = Paragraph::new(controls) + .block( + Block::default() + .borders(Borders::ALL) + .title(" Bitcoin Core Process "), + ) + .wrap(Wrap { trim: true }); + f.render_widget(paragraph, area); + } + + fn render_chain_info(f: &mut Frame, app: &App, area: Rect) { + let text = if app.bitcoin_conf_path.is_none() { + vec![Line::from(Span::styled( + "Select a bitcoin.conf file to load Bitcoin Core chain info.", + Style::default().fg(Color::DarkGray), + ))] + } else if let Some(info) = &app.bitcoin_chain_info { + vec![ + Line::from(format!("Network : {}", info.network)), + Line::from(format!("Block Height : {}", info.block_height)), + Line::from(format!("Best Block Hash : {}", info.best_block_hash)), + Line::from(format!( + "Verification Progress : {}", + Self::format_verification_progress(info.verification_progress) + )), + Line::from(format!( + "Initial Block Download : {}", + Self::format_optional_bool(info.initial_block_download) + )), + Line::from(format!( + "Connection Count : {}", + Self::format_optional_u64(info.connection_count) + )), + ] + } else if let Some(err) = &app.bitcoin_chain_info_error { + vec![Line::from(Span::styled( + format!("Failed to fetch Bitcoin chain info: {err}"), + Style::default().fg(Color::Red), + ))] + } else { + vec![Line::from(Span::styled( + "Loading Bitcoin chain info...", + Style::default().fg(Color::DarkGray), + ))] + }; + + let paragraph = Paragraph::new(text) + .block(Block::default().borders(Borders::ALL).title(" Chain Info ")) + .wrap(Wrap { trim: true }); + + f.render_widget(paragraph, area); + } + + fn render_peers(f: &mut Frame, app: &App, area: Rect) { + let text = if app.bitcoin_conf_path.is_none() { + vec![Line::from(Span::styled( + "Select a bitcoin.conf file to load Bitcoin Core peer info.", + Style::default().fg(Color::DarkGray), + ))] + } else if let Some(info) = &app.bitcoin_chain_info { + let mut lines = Vec::with_capacity(info.connected_peer_addresses.len() + 3); + lines.push(Line::from(format!( + "Connected Peers: {}", + info.connected_peer_addresses.len() + ))); + lines.push(Line::from("")); + lines.push(Line::from("Peer Addresses:")); + + if info.connected_peer_addresses.is_empty() { + lines.push(Line::from("None")); + } else { + lines.extend( + info.connected_peer_addresses + .iter() + .map(|address| Line::from(format!("* {address}"))), + ); + } + + lines + } else if let Some(err) = &app.bitcoin_chain_info_error { + vec![Line::from(Span::styled( + format!("Failed to fetch Bitcoin peer info: {err}"), + Style::default().fg(Color::Red), + ))] + } else { + vec![Line::from(Span::styled( + "Loading Bitcoin peer info...", + Style::default().fg(Color::DarkGray), + ))] + }; + + let paragraph = Paragraph::new(text) + .block(Block::default().borders(Borders::ALL).title(" Peers ")) + .wrap(Wrap { trim: true }); + + f.render_widget(paragraph, area); + } + + fn render_logs(f: &mut Frame, app: &App, area: Rect) { + let constraints = if app.bitcoin_log_input_mode.is_some() { + vec![ + Constraint::Length(6), + Constraint::Length(3), + Constraint::Min(0), + ] + } else { + vec![Constraint::Length(6), Constraint::Min(0)] + }; + + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints(constraints) + .split(area); + + let path = app + .bitcoin_log_path + .as_ref() + .map(|path| path.to_string_lossy().into_owned()) + .unwrap_or_else(|| "(not configured)".to_string()); + let filtered_count = app.filtered_bitcoin_log_lines().len(); + let total_count = app.bitcoin_log_lines.len(); + let filter = if app.bitcoin_log_filter.trim().is_empty() { + "(none)".to_string() + } else { + app.bitcoin_log_filter.clone() + }; + let auto_scroll = if app.bitcoin_log_auto_scroll { + "on" + } else { + "off" + }; + + let summary = vec![ + Line::from(vec![ + Span::styled("Current log file path: ", Style::default().fg(Color::Gray)), + Span::raw(path), + ]), + Line::from(vec![ + Span::styled("Status: ", Style::default().fg(Color::Gray)), + Span::raw(app.bitcoin_log_status.clone()), + ]), + Line::from(vec![ + Span::styled("Filter: ", Style::default().fg(Color::Gray)), + Span::raw(filter), + Span::styled(" Auto-scroll: ", Style::default().fg(Color::Gray)), + Span::raw(auto_scroll), + Span::styled(" Lines: ", Style::default().fg(Color::Gray)), + Span::raw(format!("{filtered_count}/{total_count}")), + ]), + Self::log_controls_line(), + ]; + + let summary_panel = Paragraph::new(summary) + .block( + Block::default() + .borders(Borders::ALL) + .title(" Bitcoin Core Logs "), + ) + .wrap(Wrap { trim: false }); + f.render_widget(summary_panel, chunks[0]); + + let log_area = if app.bitcoin_log_input_mode.is_some() { + let input_panel = Paragraph::new(app.bitcoin_log_input.clone()) + .block( + Block::default() + .borders(Borders::ALL) + .title(Self::input_title(app.bitcoin_log_input_mode)), + ) + .style(Style::default().fg(Color::White)); + f.render_widget(input_panel, chunks[1]); + chunks[2] + } else { + chunks[1] + }; + + let log_lines = Self::log_lines(app); + let log_panel = Paragraph::new(log_lines) + .block(Block::default().borders(Borders::ALL).title(" debug.log ")) + .style(Style::default().bg(Color::Black).fg(Color::LightGreen)) + .scroll((app.bitcoin_log_scroll, 0)); + f.render_widget(log_panel, log_area); + } + + fn log_controls_line() -> Line<'static> { + Line::from(vec![ + Span::styled("[b] Browse log ", Style::default().fg(Color::Cyan)), + Span::styled("[o] Data dir ", Style::default().fg(Color::Cyan)), + Span::styled("[p] Path ", Style::default().fg(Color::Cyan)), + Span::styled("[g] Dir path ", Style::default().fg(Color::Cyan)), + Span::styled("[/] Search ", Style::default().fg(Color::Cyan)), + Span::styled("[r] Refresh ", Style::default().fg(Color::Cyan)), + Span::styled("[a] Auto ", Style::default().fg(Color::Cyan)), + Span::styled("[c] Copy", Style::default().fg(Color::Cyan)), + ]) + } + + fn input_title(mode: Option) -> &'static str { + match mode { + Some(BitcoinLogInputMode::Search) => " Search/filter logs ", + Some(BitcoinLogInputMode::LogFilePath) => " Bitcoin Core debug.log path ", + Some(BitcoinLogInputMode::DataDirPath) => " Bitcoin Core data directory ", + None => " Input ", + } + } + + fn log_lines(app: &App) -> Vec> { + if app.bitcoin_log_path.is_none() { + return vec![Line::from(Span::styled( + "No Bitcoin Core debug.log found. Choose a log file or Bitcoin data directory.", + Style::default().fg(Color::Yellow), + ))]; + } + + if app.bitcoin_log_lines.is_empty() { + return vec![Line::from(Span::styled( + app.bitcoin_log_status.clone(), + Style::default().fg(Color::DarkGray), + ))]; + } + + let filtered = app.filtered_bitcoin_log_lines(); + if filtered.is_empty() { + return vec![Line::from(Span::styled( + "No log entries match the current filter.", + Style::default().fg(Color::DarkGray), + ))]; + } + + filtered + .into_iter() + .map(|line| Line::from(Span::raw(line.to_string()))) + .collect() + } + + fn max_scroll(app: &App) -> u16 { + app.filtered_bitcoin_log_lines() + .len() + .saturating_sub(1) + .min(u16::MAX as usize) as u16 + } + + fn next_scroll(app: &App, delta: u16) -> u16 { + app.bitcoin_log_scroll + .saturating_add(delta) + .min(Self::max_scroll(app)) + } + + fn format_verification_progress(progress: Option) -> String { + progress.map_or_else(|| "-".to_string(), |value| format!("{:.2}%", value * 100.0)) + } + + fn format_optional_bool(value: Option) -> &'static str { + match value { + Some(true) => "yes", + Some(false) => "no", + None => "-", + } + } + + fn format_optional_u64(value: Option) -> String { + value.map_or_else(|| "-".to_string(), |value| value.to_string()) + } } impl Default for BitcoinStatusView { @@ -84,3 +500,130 @@ impl Default for BitcoinStatusView { Self::new() } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::app::App; + use crate::components::bitcoin_client::BitcoinChainInfo; + use ratatui::{Terminal, backend::TestBackend, prelude::Rect}; + use std::path::PathBuf; + + fn render_view(app: &App) -> String { + let backend = TestBackend::new(80, 25); + let mut terminal = Terminal::new(backend).unwrap(); + let area = Rect::new(0, 0, 80, 25); + + terminal + .draw(|f| BitcoinStatusView::render(f, app, area)) + .unwrap(); + + terminal + .backend() + .buffer() + .content() + .iter() + .map(|cell| cell.symbol()) + .collect() + } + + #[test] + fn renders_process_state_panel_on_system_tab() { + let mut app = App::new(); + app.bitcoin_status_tab = 1; + + let output = render_view(&app); + + assert!(output.contains("Bitcoin Core Process")); + assert!(output.contains("[s] Start")); + assert!(output.contains("[t] Stop")); + assert!(output.contains("[r] Restart")); + assert!(output.contains("Process state: Stopped")); + } + + #[test] + fn renders_prompt_when_no_bitcoin_conf_is_selected() { + let app = App::new(); + + let output = render_view(&app); + + assert!(output.contains("Select a bitcoin.conf file to load Bitcoin Core chain info.")); + assert!(!output.contains("Loading Bitcoin chain info")); + assert!(!output.contains("Failed to fetch Bitcoin chain info")); + } + + #[test] + fn renders_loaded_chain_info_with_formatted_values() { + let mut app = App::new(); + app.bitcoin_conf_path = Some(PathBuf::from("/tmp/bitcoin.conf")); + app.bitcoin_chain_info = Some(BitcoinChainInfo { + network: "mainnet".to_string(), + block_height: 850_000, + best_block_hash: "abc123".to_string(), + verification_progress: Some(0.9123), + initial_block_download: Some(true), + connection_count: Some(7), + connected_peer_addresses: vec![ + "192.168.1.100:8333".to_string(), + "192.168.1.101:8333".to_string(), + ], + }); + + let output = render_view(&app).replace(" ", " "); + + assert!(output.contains("Network : mainnet")); + assert!(output.contains("Block Height : 850000")); + assert!(output.contains("Best Block Hash : abc123")); + assert!(output.contains("Verification Progress : 91.23%")); + assert!(output.contains("Initial Block Download : yes")); + assert!(output.contains("Connection Count : 7")); + assert!(!output.contains("Loading Bitcoin chain info")); + assert!(!output.contains("Failed to fetch Bitcoin chain info")); + } + + #[test] + fn renders_loading_state_when_chain_info_is_pending() { + let mut app = App::new(); + app.bitcoin_conf_path = Some(PathBuf::from("/tmp/bitcoin.conf")); + + let output = render_view(&app); + + assert!(output.contains("Loading Bitcoin chain info...")); + assert!(!output.contains("Select a bitcoin.conf file")); + assert!(!output.contains("Failed to fetch Bitcoin chain info")); + } + + #[test] + fn renders_error_state_when_chain_info_fetch_fails() { + let mut app = App::new(); + app.bitcoin_conf_path = Some(PathBuf::from("/tmp/bitcoin.conf")); + app.bitcoin_chain_info_error = Some("RPC offline".to_string()); + + let output = render_view(&app); + + assert!(output.contains("Failed to fetch Bitcoin chain info: RPC offline")); + assert!(!output.contains("Loading Bitcoin chain info")); + assert!(!output.contains("Network")); + } + + #[test] + fn renders_none_and_false_formatting_for_optional_values() { + let mut app = App::new(); + app.bitcoin_conf_path = Some(PathBuf::from("/tmp/bitcoin.conf")); + app.bitcoin_chain_info = Some(BitcoinChainInfo { + network: "testnet".to_string(), + block_height: 42, + best_block_hash: "def456".to_string(), + verification_progress: None, + initial_block_download: Some(false), + connection_count: None, + connected_peer_addresses: vec![], + }); + + let output = render_view(&app); + + assert!(output.contains("Verification Progress : -")); + assert!(output.contains("Initial Block Download : no")); + assert!(output.contains("Connection Count : -")); + } +} diff --git a/src/components/mod.rs b/src/components/mod.rs index 874a783..e55d93a 100644 --- a/src/components/mod.rs +++ b/src/components/mod.rs @@ -2,6 +2,7 @@ // // SPDX-License-Identifier: AGPL-3.0-or-later +pub mod bitcoin_client; pub mod bitcoin_config_view; pub mod bitcoin_status_view; pub mod file_explorer; diff --git a/src/components/p2pool_status_view.rs b/src/components/p2pool_status_view.rs index 157eb01..db8ab0e 100644 --- a/src/components/p2pool_status_view.rs +++ b/src/components/p2pool_status_view.rs @@ -223,15 +223,16 @@ impl P2PoolStatusView { let mut seen = HashSet::new(); for share in app.live_shares.iter().rev() { - seen.insert(share.blockhash.clone()); - entries.push(ShareTableEntry { - height: share.height, - blockhash: share.blockhash.clone(), - miner: share.miner_address.clone(), - bits: share.bits.clone(), - timestamp: share.timestamp, - uncles: share.uncles.len(), - }); + if seen.insert(share.blockhash.clone()) { + entries.push(ShareTableEntry { + height: share.height, + blockhash: share.blockhash.clone(), + miner: share.miner_address.clone(), + bits: share.bits.clone(), + timestamp: share.timestamp, + uncles: share.uncles.len(), + }); + } } if let Some(info) = &app.share_info { @@ -756,6 +757,21 @@ mod tests { assert!(output.contains("12D3KooWNoStatus (Connected)")); } + #[test] + fn render_share_info_deduplicates_duplicate_live_shares() { + let mut app = App::new(); + app.p2pool_status_tab = SHARE_TAB; + + app.live_shares = vec![ + live_share(42, "samehash", "duplicated", 1_700_000_000, "1d00ffff", 0), + live_share(42, "samehash", "duplicated", 1_700_000_001, "1d00ffff", 0), + ]; + + let output = render_view(&app); + + assert_eq!(output.matches("duplicated").count(), 1); + } + #[test] fn short_value_preserves_short_values_and_truncates_long_values() { assert_eq!(P2PoolStatusView::short_value("short", 10), "short"); diff --git a/src/components/settings_view.rs b/src/components/settings_view.rs index 58b02c6..8908d70 100644 --- a/src/components/settings_view.rs +++ b/src/components/settings_view.rs @@ -10,7 +10,7 @@ use ratatui::{ }; /// Number of settings fields. -pub const FIELD_COUNT: usize = 5; +pub const FIELD_COUNT: usize = 8; /// Describes how a settings field behaves when Enter is pressed. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -27,7 +27,10 @@ pub const FIELDS: [(&str, FieldKind); FIELD_COUNT] = [ ("P2Pool config path", FieldKind::FilePicker), ("LN config path", FieldKind::FilePicker), ("Shares Market config path", FieldKind::FilePicker), + ("Bitcoin Core data directory", FieldKind::DirectoryPicker), + ("Bitcoin Core log file", FieldKind::FilePicker), ("Settings directory", FieldKind::DirectoryPicker), + ("Bitcoin Core executable", FieldKind::FilePicker), ]; #[derive(Debug, Clone)] @@ -96,10 +99,22 @@ impl SettingsView { .shares_market_conf_path .as_ref() .map(|p| p.to_string_lossy().into_owned()), + app.settings + .bitcoin_core_data_dir + .as_ref() + .map(|p| p.to_string_lossy().into_owned()), + app.settings + .bitcoin_core_log_path + .as_ref() + .map(|p| p.to_string_lossy().into_owned()), app.settings .settings_dir_override .as_ref() .map(|p| p.to_string_lossy().into_owned()), + app.settings + .bitcoind_path + .as_ref() + .map(|p| p.to_string_lossy().into_owned()), ]; let items: Vec = (0..FIELD_COUNT) @@ -114,7 +129,7 @@ impl SettingsView { .add_modifier(Modifier::BOLD), ), None => { - if idx == 4 { + if idx == 6 { let path = if app.config_dir.as_os_str().is_empty() { "(unknown)".to_string() } else { @@ -272,6 +287,9 @@ mod tests { app.settings.p2pool_conf_path = Some(std::path::PathBuf::from("/tmp/p2pool.toml")); app.settings.ln_conf_path = Some(std::path::PathBuf::from("/tmp/ln.conf")); app.settings.shares_market_conf_path = Some(std::path::PathBuf::from("/tmp/shares.conf")); + app.settings.bitcoin_core_data_dir = Some(std::path::PathBuf::from("/tmp/bitcoin")); + app.settings.bitcoin_core_log_path = + Some(std::path::PathBuf::from("/tmp/bitcoin/debug.log")); app.settings.settings_dir_override = Some(std::path::PathBuf::from("/custom/dir")); app.settings_view.sidebar_focused = false; @@ -297,7 +315,7 @@ mod tests { #[test] #[serial_test::serial] - fn render_field4_shows_default_config_dir_when_no_override() { + fn render_field6_shows_default_config_dir_when_no_override() { use crate::app::App; use ratatui::Terminal; use ratatui::backend::TestBackend; diff --git a/src/components/status_bar.rs b/src/components/status_bar.rs index 775d6f6..59f6f76 100644 --- a/src/components/status_bar.rs +++ b/src/components/status_bar.rs @@ -90,7 +90,9 @@ impl StatusBar { 1 => s.p2pool_conf_path.is_some(), 2 => s.ln_conf_path.is_some(), 3 => s.shares_market_conf_path.is_some(), - 4 => s.settings_dir_override.is_some(), + 4 => s.bitcoin_core_data_dir.is_some(), + 5 => s.bitcoin_core_log_path.is_some(), + 6 => s.settings_dir_override.is_some(), _ => false, }; spans.extend(hint("↑↓", "Navigate")); @@ -108,6 +110,21 @@ impl StatusBar { spans.extend(hint("Esc", "Back")); } } + CurrentScreen::BitcoinStatus if app.bitcoin_status_tab == 2 => { + if app.bitcoin_log_input_mode.is_some() { + spans.extend(hint("Enter", "Apply")); + spans.extend(hint("Esc", "Cancel")); + spans.extend(hint("⌫", "Delete")); + } else { + spans.extend(hint("↑↓", "Scroll logs")); + spans.extend(hint("←→", "Switch tab")); + spans.extend(hint("b", "Browse log")); + spans.extend(hint("o", "Browse dir")); + spans.extend(hint("/", "Search")); + spans.extend(hint("r", "Refresh")); + spans.extend(hint("q", "Quit")); + } + } CurrentScreen::BitcoinStatus | CurrentScreen::P2PoolStatus => { spans.extend(hint("↑↓", "Navigate sidebar")); spans.extend(hint("←→", "Switch tab")); @@ -358,9 +375,32 @@ mod tests { let mut app = App::new(); app.current_screen = CurrentScreen::Settings; app.settings_view.sidebar_focused = false; - app.settings_view.selected_index = 4; + app.settings_view.selected_index = 6; app.settings.settings_dir_override = Some(std::path::PathBuf::from("/custom/dir")); let output = render_status_bar(&app); assert!(output.contains("Clear")); } + + #[test] + fn settings_content_bitcoin_core_data_dir_field_set_shows_clear() { + let mut app = App::new(); + app.current_screen = CurrentScreen::Settings; + app.settings_view.sidebar_focused = false; + app.settings_view.selected_index = 4; + app.settings.bitcoin_core_data_dir = Some(std::path::PathBuf::from("/tmp/bitcoin")); + let output = render_status_bar(&app); + assert!(output.contains("Clear")); + } + + #[test] + fn settings_content_bitcoin_core_log_path_field_set_shows_clear() { + let mut app = App::new(); + app.current_screen = CurrentScreen::Settings; + app.settings_view.sidebar_focused = false; + app.settings_view.selected_index = 5; + app.settings.bitcoin_core_log_path = + Some(std::path::PathBuf::from("/tmp/bitcoin/debug.log")); + let output = render_status_bar(&app); + assert!(output.contains("Clear")); + } } diff --git a/src/lib.rs b/src/lib.rs index 2ec9158..23357f8 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -4,8 +4,10 @@ pub mod app; pub mod bitcoin_config; +pub mod bitcoin_logs; pub mod components; pub mod config; pub mod p2poolv2_config; +pub mod process_manager; pub mod settings; pub mod ui; diff --git a/src/main.rs b/src/main.rs index 5b0acca..fda392b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -10,6 +10,7 @@ use pdm::app::{ use pdm::bitcoin_config::{ parse_config as parse_bitcoin_config, save_config as save_bitcoin_config, }; +use pdm::components::bitcoin_status_view::BitcoinStatusView; use pdm::components::settings_view::{FIELDS, FieldKind}; use pdm::p2poolv2_config::{apply_edit as apply_p2pool_edit, flatten_config}; use pdm::settings::{load_settings, save_settings}; @@ -81,6 +82,10 @@ where ::Error: Send + Sync + 'static, { loop { + app.poll_bitcoin_chain_info(); + app.poll_bitcoin_process(); + app.poll_bitcoin_logs(); + app.maybe_refresh_bitcoin_logs(); app.poll_chain_info(); app.poll_share_info(); app.poll_peer_info(); @@ -92,7 +97,10 @@ where { match dispatch_key(key, app) { KeyOutcome::Ignored => {} - KeyOutcome::Exit => return Ok(()), + KeyOutcome::Exit => { + app.shutdown_bitcoin_process(); + return Ok(()); + } KeyOutcome::Action(action) => { if handle_action(action, app)?.is_break() { return Ok(()); @@ -140,7 +148,18 @@ fn dispatch_key(key: event::KeyEvent, app: &mut App) -> KeyOutcome { } AppAction::None } - k => sidebar_nav(k, app), + _ => match app.bitcoin_status_tab { + 1 => { + let action = BitcoinStatusView::handle_system_input(app, key); + if matches!(action, AppAction::None) { + sidebar_nav(key.code, app) + } else { + action + } + } + 2 => BitcoinStatusView::handle_logs_input(app, key), + _ => sidebar_nav(key.code, app), + }, }, CurrentScreen::P2PoolStatus => match key.code { @@ -262,6 +281,8 @@ fn bootstrap_from_settings(app: &mut App) { } } } + + app.resolve_bitcoin_log_path(); } // Logic Handler @@ -273,10 +294,12 @@ fn handle_action(action: AppAction, app: &mut App) -> Result> { AppAction::ToggleMenu => app.toggle_menu(), AppAction::OpenExplorer(trigger) => { - if app.explorer.allow_dir_select { - app.explorer.allow_dir_select = false; - app.explorer.load_directory(); + let dir_select = matches!(trigger, ExplorerTrigger::BitcoinCoreDataDir); + if app.explorer.allow_dir_select != dir_select { + app.explorer.allow_dir_select = dir_select; } + prepare_explorer_start_dir(app, &trigger); + app.explorer.load_directory(); app.explorer_trigger = Some(trigger); app.current_screen = CurrentScreen::FileExplorer; } @@ -341,6 +364,26 @@ fn handle_action(action: AppAction, app: &mut App) -> Result> { } app.current_screen = CurrentScreen::P2PoolConfig; } + ExplorerTrigger::BitcoinCoreDataDir => { + app.set_bitcoin_log_data_dir(path.clone()); + app.settings_view.save_error = None; + if let Err(e) = save_settings(&app.settings) { + app.settings_view.save_error = Some(format!("Save failed: {e}")); + } + app.current_screen = CurrentScreen::BitcoinStatus; + app.bitcoin_status_tab = 2; + app.refresh_bitcoin_logs(); + } + ExplorerTrigger::BitcoinCoreLogFile => { + app.set_bitcoin_log_file(path.clone()); + app.settings_view.save_error = None; + if let Err(e) = save_settings(&app.settings) { + app.settings_view.save_error = Some(format!("Save failed: {e}")); + } + app.current_screen = CurrentScreen::BitcoinStatus; + app.bitcoin_status_tab = 2; + app.refresh_bitcoin_logs(); + } ExplorerTrigger::BitcoinConfig => match parse_bitcoin_config(&path) { Ok(entries) => { const MIN_KNOWN_KEYS: usize = 1; @@ -364,6 +407,7 @@ fn handle_action(action: AppAction, app: &mut App) -> Result> { app.settings_view.save_error = Some(save_error.clone()); app.bitcoin_config_view.warning_message = Some(save_error); } + app.resolve_bitcoin_log_path(); } else { app.bitcoin_config_view.warning_message = Some( "File does not appear to be a Bitcoin config. Select another file." @@ -396,6 +440,7 @@ fn handle_action(action: AppAction, app: &mut App) -> Result> { app.bitcoin_config_view.dirty = false; app.bitcoin_config_view.warning_message = None; app.settings.bitcoin_conf_path = Some(path.clone()); + app.resolve_bitcoin_log_path(); } else { app.settings_view.save_error = Some( "File does not appear to be a Bitcoin config." @@ -434,7 +479,10 @@ fn handle_action(action: AppAction, app: &mut App) -> Result> { }, 2 => app.settings.ln_conf_path = Some(path.clone()), 3 => app.settings.shares_market_conf_path = Some(path.clone()), - 4 => app.settings.settings_dir_override = Some(path.clone()), + 4 => app.set_bitcoin_log_data_dir(path.clone()), + 5 => app.set_bitcoin_log_file(path.clone()), + 6 => app.settings.settings_dir_override = Some(path.clone()), + 7 => app.settings.bitcoind_path = Some(path.clone()), _ => {} } if should_save { @@ -477,6 +525,7 @@ fn handle_action(action: AppAction, app: &mut App) -> Result> { app.settings.bitcoin_conf_path = None; app.bitcoin_conf_path = None; app.bitcoin_data.clear(); + app.resolve_bitcoin_log_path(); } 1 => { app.settings.p2pool_conf_path = None; @@ -485,7 +534,18 @@ fn handle_action(action: AppAction, app: &mut App) -> Result> { } 2 => app.settings.ln_conf_path = None, 3 => app.settings.shares_market_conf_path = None, - 4 => app.settings.settings_dir_override = None, + 4 => { + app.settings.bitcoin_core_data_dir = None; + app.resolve_bitcoin_log_path(); + app.reset_bitcoin_log_reader(); + } + 5 => { + app.settings.bitcoin_core_log_path = None; + app.resolve_bitcoin_log_path(); + app.reset_bitcoin_log_reader(); + } + 6 => app.settings.settings_dir_override = None, + 7 => app.settings.bitcoind_path = None, _ => {} } app.settings_view.save_error = None; @@ -523,12 +583,79 @@ fn handle_action(action: AppAction, app: &mut App) -> Result> { } } + AppAction::RefreshBitcoinLogs => { + app.refresh_bitcoin_logs(); + } + + AppAction::ToggleBitcoinLogAutoScroll => { + app.bitcoin_log_auto_scroll = !app.bitcoin_log_auto_scroll; + if app.bitcoin_log_auto_scroll { + app.bitcoin_log_scroll = 0; + } + } + + AppAction::SetBitcoinLogDataDir(path) => { + app.set_bitcoin_log_data_dir(path); + app.settings_view.save_error = None; + if let Err(e) = save_settings(&app.settings) { + app.settings_view.save_error = Some(format!("Save failed: {e}")); + } + app.refresh_bitcoin_logs(); + } + + AppAction::SetBitcoinLogFile(path) => { + app.set_bitcoin_log_file(path); + app.settings_view.save_error = None; + if let Err(e) = save_settings(&app.settings) { + app.settings_view.save_error = Some(format!("Save failed: {e}")); + } + app.refresh_bitcoin_logs(); + } + + AppAction::CopyBitcoinLogs => { + app.copy_filtered_bitcoin_logs(); + } + + AppAction::StartBitcoinCore => { + let _ = app.start_bitcoin_process(); + } + + AppAction::StopBitcoinCore => { + let _ = app.stop_bitcoin_process(); + } + + AppAction::RestartBitcoinCore => { + let _ = app.restart_bitcoin_process(); + } + AppAction::None => {} } Ok(ControlFlow::Continue(())) } +fn prepare_explorer_start_dir(app: &mut App, trigger: &ExplorerTrigger) { + let start_dir = match trigger { + ExplorerTrigger::BitcoinCoreDataDir => app + .settings + .bitcoin_core_data_dir + .as_ref() + .filter(|path| path.is_dir()) + .cloned(), + ExplorerTrigger::BitcoinCoreLogFile => app + .bitcoin_log_path + .as_ref() + .and_then(|path| path.parent()) + .filter(|path| path.is_dir()) + .map(std::path::Path::to_path_buf), + _ => None, + }; + + if let Some(dir) = start_dir { + app.explorer.current_dir = dir; + } +} + /// Matches the TOML type of an existing item and parses the new string /// value into that same type. This prevents numeric/bool fields from /// being written back as quoted strings (e.g. port = "3333"). @@ -1831,13 +1958,61 @@ port = 46884 assert!(app.settings.p2pool_conf_path.is_none()); assert!(app.settings.ln_conf_path.is_none()); assert!(app.settings.shares_market_conf_path.is_none()); + assert!(app.settings.bitcoin_core_data_dir.is_none()); + assert!(app.settings.bitcoin_core_log_path.is_none()); assert!(app.settings.settings_dir_override.is_none()); assert_eq!(app.current_screen, CurrentScreen::Settings); } #[test] #[serial] - fn file_selected_for_settings_field_4_sets_dir_override() { + fn file_selected_for_settings_field_4_sets_bitcoin_core_data_dir() { + use tempfile::tempdir; + + let dir = tempdir().unwrap(); + redirect_saves_to(&dir); + let bitcoin_dir = tempdir().unwrap(); + + let mut app = App::new(); + app.explorer_trigger = Some(ExplorerTrigger::Settings(4)); + run( + AppAction::FileSelected(bitcoin_dir.path().to_path_buf()), + &mut app, + ); + + assert_eq!( + app.settings.bitcoin_core_data_dir, + Some(bitcoin_dir.path().to_path_buf()) + ); + assert_eq!( + app.bitcoin_log_path, + Some(bitcoin_dir.path().join("debug.log")) + ); + assert_eq!(app.current_screen, CurrentScreen::Settings); + } + + #[test] + #[serial] + fn file_selected_for_settings_field_5_sets_bitcoin_core_log_path() { + use tempfile::tempdir; + + let dir = tempdir().unwrap(); + redirect_saves_to(&dir); + let path = dir.path().join("debug.log"); + std::fs::write(&path, "log\n").unwrap(); + + let mut app = App::new(); + app.explorer_trigger = Some(ExplorerTrigger::Settings(5)); + run(AppAction::FileSelected(path.clone()), &mut app); + + assert_eq!(app.settings.bitcoin_core_log_path, Some(path.clone())); + assert_eq!(app.bitcoin_log_path, Some(path)); + assert_eq!(app.current_screen, CurrentScreen::Settings); + } + + #[test] + #[serial] + fn file_selected_for_settings_field_6_sets_dir_override() { use tempfile::tempdir; let dir = tempdir().unwrap(); @@ -1846,7 +2021,7 @@ port = 46884 let settings_dir = tempdir().unwrap(); let mut app = App::new(); - app.explorer_trigger = Some(ExplorerTrigger::Settings(4)); + app.explorer_trigger = Some(ExplorerTrigger::Settings(6)); run( AppAction::FileSelected(settings_dir.path().to_path_buf()), &mut app, @@ -1870,6 +2045,14 @@ port = 46884 assert_eq!(app.current_screen, CurrentScreen::FileExplorer); } + #[test] + fn open_explorer_for_settings_field6_enables_dir_select() { + let mut app = App::new(); + run(AppAction::OpenExplorerForSettings(6), &mut app); + assert!(app.explorer.allow_dir_select); + assert_eq!(app.current_screen, CurrentScreen::FileExplorer); + } + #[test] fn open_explorer_for_settings_non_dir_field_disables_dir_select() { let mut app = App::new(); @@ -1923,8 +2106,12 @@ port = 46884 app.settings.p2pool_conf_path = Some(PathBuf::from("/tmp/p2pool.toml")); app.settings.ln_conf_path = Some(PathBuf::from("/tmp/ln.conf")); app.settings.shares_market_conf_path = Some(PathBuf::from("/tmp/shares.conf")); + app.settings.bitcoin_core_data_dir = Some(PathBuf::from("/tmp/bitcoin")); + app.settings.bitcoin_core_log_path = Some(PathBuf::from("/tmp/bitcoin/debug.log")); + app.settings.settings_dir_override = Some(PathBuf::from("/tmp/pdm")); app.bitcoin_conf_path = Some(PathBuf::from("/tmp/bitcoin.conf")); app.p2pool_conf_path = Some(PathBuf::from("/tmp/p2pool.toml")); + app.bitcoin_log_path = Some(PathBuf::from("/tmp/bitcoin/debug.log")); run(AppAction::ClearSettingsField(0), &mut app); assert!(app.settings.bitcoin_conf_path.is_none()); @@ -1941,6 +2128,15 @@ port = 46884 run(AppAction::ClearSettingsField(3), &mut app); assert!(app.settings.shares_market_conf_path.is_none()); + + run(AppAction::ClearSettingsField(4), &mut app); + assert!(app.settings.bitcoin_core_data_dir.is_none()); + + run(AppAction::ClearSettingsField(5), &mut app); + assert!(app.settings.bitcoin_core_log_path.is_none()); + + run(AppAction::ClearSettingsField(6), &mut app); + assert!(app.settings.settings_dir_override.is_none()); } #[test] diff --git a/src/process_manager.rs b/src/process_manager.rs new file mode 100644 index 0000000..3221398 --- /dev/null +++ b/src/process_manager.rs @@ -0,0 +1,394 @@ +// SPDX-FileCopyrightText: 2024 PDM Authors +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +use anyhow::Result; +use std::process::{Child, Command}; +use std::thread; +use std::time::{Duration, Instant}; + +const DEFAULT_STOP_TIMEOUT: Duration = Duration::from_secs(30); + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ProcessState { + Stopped, + Starting, + Running, + Stopping, + Failed, +} + +impl Default for ProcessState { + fn default() -> Self { + Self::Stopped + } +} + +impl ProcessState { + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Stopped => "Stopped", + Self::Starting => "Starting", + Self::Running => "Running", + Self::Stopping => "Stopping", + Self::Failed => "Failed", + } + } + + #[must_use] + pub const fn can_start(self) -> bool { + matches!(self, Self::Stopped | Self::Failed) + } + + #[must_use] + pub const fn can_stop(self) -> bool { + matches!(self, Self::Running | Self::Starting) + } + + #[must_use] + pub const fn can_restart(self) -> bool { + matches!(self, Self::Running | Self::Stopped | Self::Failed) + } +} + +#[derive(Debug)] +pub struct ProcessManager { + state: ProcessState, + error: Option, + child: Option, + stop_requested_at: Option, + stop_timeout: Duration, +} + +impl ProcessManager { + #[must_use] + pub fn new() -> Self { + Self { + state: ProcessState::Stopped, + error: None, + child: None, + stop_requested_at: None, + stop_timeout: DEFAULT_STOP_TIMEOUT, + } + } + + #[must_use] + pub fn with_stop_timeout(stop_timeout: Duration) -> Self { + Self { + state: ProcessState::Stopped, + error: None, + child: None, + stop_requested_at: None, + stop_timeout, + } + } + + #[must_use] + pub fn state(&self) -> ProcessState { + self.state + } + + #[must_use] + pub fn error(&self) -> Option<&str> { + self.error.as_deref() + } + + #[must_use] + pub fn is_active(&self) -> bool { + self.child.is_some() + } + + pub fn mark_failed(&mut self, error: impl Into) { + if self.child.is_some() { + self.shutdown_blocking(); + } + self.child = None; + self.stop_requested_at = None; + self.state = ProcessState::Failed; + self.error = Some(error.into()); + } + + pub fn start(&mut self, command: &mut Command) -> Result<()> { + self.poll(); + + if self.child.is_some() || self.state.can_stop() { + return Err(anyhow::anyhow!( + "Process is already {}", + self.state.as_str().to_lowercase() + )); + } + + self.error = None; + self.state = ProcessState::Starting; + self.stop_requested_at = None; + + match command.spawn() { + Ok(child) => { + self.child = Some(child); + self.state = ProcessState::Running; + Ok(()) + } + Err(error) => { + self.child = None; + self.state = ProcessState::Failed; + self.error = Some(format!("Failed to start process: {error}")); + Err(anyhow::anyhow!("Failed to start process: {error}")) + } + } + } + + pub fn stop(&mut self) -> Result<()> { + self.poll(); + + let Some(child) = self.child.as_mut() else { + self.state = ProcessState::Stopped; + self.error = None; + self.stop_requested_at = None; + return Ok(()); + }; + + if self.state == ProcessState::Stopping { + return Ok(()); + } + + self.state = ProcessState::Stopping; + self.error = None; + self.stop_requested_at = Some(Instant::now()); + + match Self::request_child_stop(child) { + Ok(()) => Ok(()), + Err(error) => { + self.error = Some(format!( + "Failed to request graceful stop: {error}. Will force kill after timeout." + )); + Err(anyhow::anyhow!("Failed to stop process: {error}")) + } + } + } + + pub fn restart(&mut self, command: &mut Command) -> Result<()> { + if self.state == ProcessState::Stopping { + return Err(anyhow::anyhow!("Process is still stopping")); + } + + if self.child.is_some() { + self.stop()?; + self.shutdown_blocking(); + } + + self.start(command) + } + + pub fn shutdown_blocking(&mut self) { + if self.child.is_none() { + return; + } + + let _ = self.stop(); + while self.child.is_some() { + self.poll(); + if self.child.is_some() { + thread::sleep(Duration::from_millis(50)); + } + } + } + + pub fn poll(&mut self) { + let Some(child) = self.child.as_mut() else { + if self.state == ProcessState::Stopping { + self.state = ProcessState::Stopped; + } + return; + }; + + let was_stopping = self.state == ProcessState::Stopping; + + match child.try_wait() { + Ok(Some(status)) => { + self.child = None; + self.stop_requested_at = None; + + if was_stopping { + self.state = ProcessState::Stopped; + if !status.success() { + self.error = Some(format!("Process stopped with status {status}")); + } + } else { + self.state = ProcessState::Failed; + self.error = Some(format!("Process exited unexpectedly with status {status}")); + } + } + Ok(None) => self.kill_after_timeout(), + Err(error) => { + self.child = None; + self.stop_requested_at = None; + self.state = ProcessState::Failed; + self.error = Some(format!("Failed to check process state: {error}")); + } + } + } + + fn kill_after_timeout(&mut self) { + if self.state != ProcessState::Stopping { + return; + } + + let Some(stop_requested_at) = self.stop_requested_at else { + return; + }; + + if stop_requested_at.elapsed() < self.stop_timeout { + return; + } + + let Some(mut child) = self.child.take() else { + self.state = ProcessState::Stopped; + self.stop_requested_at = None; + return; + }; + + match Self::force_kill_child(&mut child).and_then(|()| child.wait().map(|_| ())) { + Ok(()) => { + self.state = ProcessState::Stopped; + self.error = Some(format!( + "Process did not stop within {}s and was killed.", + self.stop_timeout.as_secs() + )); + } + Err(error) => { + self.state = ProcessState::Failed; + self.error = Some(format!("Failed to kill process: {error}")); + } + } + self.stop_requested_at = None; + } + + fn request_child_stop(child: &mut Child) -> std::io::Result<()> { + #[cfg(unix)] + { + let pid = child.id() as libc::pid_t; + let result = unsafe { libc::kill(pid, libc::SIGTERM) }; + if result == 0 { + Ok(()) + } else { + Err(std::io::Error::last_os_error()) + } + } + + #[cfg(not(unix))] + { + child.kill()?; + Ok(()) + } + } + + fn force_kill_child(child: &mut Child) -> std::io::Result<()> { + child.kill() + } +} + +impl Default for ProcessManager { + fn default() -> Self { + Self::new() + } +} + +impl Drop for ProcessManager { + fn drop(&mut self) { + self.shutdown_blocking(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::process::Stdio; + use std::thread; + + fn short_lived_failure_command() -> Command { + #[cfg(unix)] + { + let mut command = Command::new("sh"); + command.arg("-c").arg("exit 7"); + command + } + + #[cfg(windows)] + { + let mut command = Command::new("cmd"); + command.args(["/C", "exit 7"]); + command + } + } + + fn long_running_command() -> Command { + #[cfg(unix)] + { + let mut command = Command::new("sleep"); + command.arg("30"); + command + } + + #[cfg(windows)] + { + let mut command = Command::new("powershell"); + command.args(["-NoProfile", "-Command", "Start-Sleep -Seconds 30"]); + command + } + } + + #[test] + fn start_marks_failed_for_missing_executable() { + let mut manager = ProcessManager::new(); + let mut command = std::process::Command::new("/definitely/not/a/real/executable"); + + let err = manager.start(&mut command).unwrap_err(); + + assert_eq!(manager.state(), ProcessState::Failed); + assert!(manager.error().is_some()); + assert!(err.to_string().contains("Failed to start")); + } + + #[test] + fn stop_on_idle_process_is_a_noop() { + let mut manager = ProcessManager::new(); + + let result = manager.stop(); + + assert!(result.is_ok()); + assert_eq!(manager.state(), ProcessState::Stopped); + } + + #[test] + fn poll_marks_unexpected_exit_failed() { + let mut manager = ProcessManager::new(); + let mut command = short_lived_failure_command(); + command.stdout(Stdio::null()).stderr(Stdio::null()); + + manager.start(&mut command).unwrap(); + thread::sleep(Duration::from_millis(50)); + manager.poll(); + + assert_eq!(manager.state(), ProcessState::Failed); + assert!( + manager + .error() + .is_some_and(|error| error.contains("unexpectedly")) + ); + } + + #[test] + fn stop_moves_running_process_to_stopping() { + let mut manager = ProcessManager::with_stop_timeout(Duration::from_secs(1)); + let mut command = long_running_command(); + command.stdout(Stdio::null()).stderr(Stdio::null()); + + manager.start(&mut command).unwrap(); + manager.stop().unwrap(); + + assert_eq!(manager.state(), ProcessState::Stopping); + manager.shutdown_blocking(); + assert_eq!(manager.state(), ProcessState::Stopped); + } +} diff --git a/src/settings.rs b/src/settings.rs index db0f780..e812113 100644 --- a/src/settings.rs +++ b/src/settings.rs @@ -20,6 +20,10 @@ use std::path::PathBuf; pub struct Settings { /// Path to the Bitcoin Core config file (bitcoin.conf) pub bitcoin_conf_path: Option, + /// Path to the Bitcoin Core data directory. + pub bitcoin_core_data_dir: Option, + /// Direct path to the Bitcoin Core debug log file. + pub bitcoin_core_log_path: Option, /// Path to the p2poolv2 config file pub p2pool_conf_path: Option, /// Path to the Lightning Network config file @@ -30,6 +34,8 @@ pub struct Settings { /// the default location always holds a copy so the override is found /// on the next launch. pub settings_dir_override: Option, + /// Path to the Bitcoin Core executable (`bitcoind`). + pub bitcoind_path: Option, } /// Returns the directory where `settings.toml` is stored. @@ -119,6 +125,8 @@ mod tests { fn default_settings_has_no_paths() { let s = Settings::default(); assert!(s.bitcoin_conf_path.is_none()); + assert!(s.bitcoin_core_data_dir.is_none()); + assert!(s.bitcoin_core_log_path.is_none()); assert!(s.p2pool_conf_path.is_none()); assert!(s.ln_conf_path.is_none()); assert!(s.shares_market_conf_path.is_none()); @@ -132,6 +140,8 @@ mod tests { let path = dir.path().join("settings.toml"); let settings = Settings { bitcoin_conf_path: Some(PathBuf::from("/tmp/bitcoin.conf")), + bitcoin_core_data_dir: Some(PathBuf::from("/tmp/bitcoin")), + bitcoin_core_log_path: Some(PathBuf::from("/tmp/bitcoin/debug.log")), p2pool_conf_path: Some(PathBuf::from("/tmp/p2pool.toml")), ..Default::default() }; @@ -141,6 +151,8 @@ mod tests { let loaded: Settings = toml::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap(); assert_eq!(loaded.bitcoin_conf_path, settings.bitcoin_conf_path); + assert_eq!(loaded.bitcoin_core_data_dir, settings.bitcoin_core_data_dir); + assert_eq!(loaded.bitcoin_core_log_path, settings.bitcoin_core_log_path); assert_eq!(loaded.p2pool_conf_path, settings.p2pool_conf_path); assert!(loaded.ln_conf_path.is_none()); } @@ -214,6 +226,8 @@ mod tests { // No settings.toml written let settings = load_settings(); assert!(settings.bitcoin_conf_path.is_none()); + assert!(settings.bitcoin_core_data_dir.is_none()); + assert!(settings.bitcoin_core_log_path.is_none()); } #[test] @@ -224,6 +238,8 @@ mod tests { std::fs::write(dir.path().join("settings.toml"), "not valid toml :::").unwrap(); let settings = load_settings(); assert!(settings.bitcoin_conf_path.is_none()); + assert!(settings.bitcoin_core_data_dir.is_none()); + assert!(settings.bitcoin_core_log_path.is_none()); } #[test] @@ -243,6 +259,32 @@ mod tests { ); } + #[test] + #[serial_test::serial] + fn load_settings_reads_bitcoin_core_log_paths() { + let dir = tempfile::tempdir().unwrap(); + set_config_dir(&dir); + std::fs::write( + dir.path().join("settings.toml"), + r#" +bitcoin_core_data_dir = "/tmp/bitcoin" +bitcoin_core_log_path = "/tmp/bitcoin/debug.log" +"#, + ) + .unwrap(); + + let settings = load_settings(); + + assert_eq!( + settings.bitcoin_core_data_dir, + Some(PathBuf::from("/tmp/bitcoin")) + ); + assert_eq!( + settings.bitcoin_core_log_path, + Some(PathBuf::from("/tmp/bitcoin/debug.log")) + ); + } + #[test] fn settings_dir_override_field_serializes() { let settings = Settings { diff --git a/src/snapshots/pdm__ui__tests__bitcoin_status_screen_render.snap b/src/snapshots/pdm__ui__tests__bitcoin_status_screen_render.snap index 1a0eafc..5593cb3 100644 --- a/src/snapshots/pdm__ui__tests__bitcoin_status_screen_render.snap +++ b/src/snapshots/pdm__ui__tests__bitcoin_status_screen_render.snap @@ -1,5 +1,6 @@ --- source: src/ui.rs +assertion_line: 149 expression: terminal.backend() --- TestBackend { @@ -10,9 +11,9 @@ TestBackend { "│Home ││ Chain Info │ System │ Logs │ Peers │", "│Bitcoin Config ││ │", "│Bitcoin Status │└─────────────────────────────────────────────────────┘", - "│P2Pool Config │┌─────────────────────────────────────────────────────┐", - "│P2Pool Status ││Chain Info │", - "│LN Config ││ │", + "│P2Pool Config │┌ Chain Info ─────────────────────────────────────────┐", + "│P2Pool Status ││Select a bitcoin.conf file to load Bitcoin Core chain│", + "│LN Config ││info. │", "│LN Status ││ │", "│Shares Market ││ │", "│Settings ││ │", @@ -37,6 +38,10 @@ TestBackend { x: 37, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 1, y: 3, fg: Black, bg: Gray, underline: Reset, modifier: NONE, x: 24, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 26, y: 5, fg: DarkGray, bg: Reset, underline: Reset, modifier: NONE, + x: 79, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 26, y: 6, fg: DarkGray, bg: Reset, underline: Reset, modifier: NONE, + x: 31, y: 6, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 0, y: 23, fg: White, bg: DarkGray, underline: Reset, modifier: NONE, x: 4, y: 23, fg: DarkGray, bg: Black, underline: Reset, modifier: NONE, x: 23, y: 23, fg: White, bg: DarkGray, underline: Reset, modifier: NONE, diff --git a/src/snapshots/pdm__ui__tests__bitcoin_status_tab_logs_render.snap b/src/snapshots/pdm__ui__tests__bitcoin_status_tab_logs_render.snap index 0d5432c..f3b7f19 100644 --- a/src/snapshots/pdm__ui__tests__bitcoin_status_tab_logs_render.snap +++ b/src/snapshots/pdm__ui__tests__bitcoin_status_tab_logs_render.snap @@ -10,14 +10,14 @@ TestBackend { "│Home ││ Chain Info │ System │ Logs │ Peers │", "│Bitcoin Config ││ │", "│Bitcoin Status │└─────────────────────────────────────────────────────┘", - "│P2Pool Config │┌─────────────────────────────────────────────────────┐", - "│P2Pool Status ││Logs │", - "│LN Config ││ │", - "│LN Status ││ │", - "│Shares Market ││ │", - "│Settings ││ │", - "│ ││ │", - "│ ││ │", + "│P2Pool Config │┌ Bitcoin Core Logs ──────────────────────────────────┐", + "│P2Pool Status ││Current log file path: (not configured) │", + "│LN Config ││Status: No Bitcoin Core debug.log found. Choose a log│", + "│LN Status ││file or data directory. │", + "│Shares Market ││Filter: (none) Auto-scroll: on Lines: 0/0 │", + "│Settings │└─────────────────────────────────────────────────────┘", + "│ │┌ debug.log ──────────────────────────────────────────┐", + "│ ││No Bitcoin Core debug.log found. Choose a log file or│", "│ ││ │", "│ ││ │", "│ ││ │", @@ -29,7 +29,7 @@ TestBackend { "│ ││ │", "│ ││ │", "└───────────────────────┘└─────────────────────────────────────────────────────┘", - " ↑↓ Navigate sidebar ←→ Switch tab q Quit ", + " ↑↓ Scroll logs ←→ Switch tab b Browse log o Browse dir / Search ", ], styles: [ x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, @@ -37,13 +37,54 @@ TestBackend { x: 53, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 1, y: 3, fg: Black, bg: Gray, underline: Reset, modifier: NONE, x: 24, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 26, y: 5, fg: Gray, bg: Reset, underline: Reset, modifier: NONE, + x: 49, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 26, y: 6, fg: Gray, bg: Reset, underline: Reset, modifier: NONE, + x: 34, y: 6, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 26, y: 8, fg: Gray, bg: Reset, underline: Reset, modifier: NONE, + x: 34, y: 8, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 40, y: 8, fg: Gray, bg: Reset, underline: Reset, modifier: NONE, + x: 55, y: 8, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 57, y: 8, fg: Gray, bg: Reset, underline: Reset, modifier: NONE, + x: 66, y: 8, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 25, y: 10, fg: LightGreen, bg: Black, underline: Reset, modifier: NONE, + x: 0, y: 11, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 25, y: 11, fg: LightGreen, bg: Black, underline: Reset, modifier: NONE, + x: 26, y: 11, fg: Yellow, bg: Black, underline: Reset, modifier: NONE, + x: 79, y: 11, fg: LightGreen, bg: Black, underline: Reset, modifier: NONE, + x: 0, y: 12, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 25, y: 12, fg: LightGreen, bg: Black, underline: Reset, modifier: NONE, + x: 0, y: 13, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 25, y: 13, fg: LightGreen, bg: Black, underline: Reset, modifier: NONE, + x: 0, y: 14, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 25, y: 14, fg: LightGreen, bg: Black, underline: Reset, modifier: NONE, + x: 0, y: 15, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 25, y: 15, fg: LightGreen, bg: Black, underline: Reset, modifier: NONE, + x: 0, y: 16, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 25, y: 16, fg: LightGreen, bg: Black, underline: Reset, modifier: NONE, + x: 0, y: 17, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 25, y: 17, fg: LightGreen, bg: Black, underline: Reset, modifier: NONE, + x: 0, y: 18, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 25, y: 18, fg: LightGreen, bg: Black, underline: Reset, modifier: NONE, + x: 0, y: 19, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 25, y: 19, fg: LightGreen, bg: Black, underline: Reset, modifier: NONE, + x: 0, y: 20, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 25, y: 20, fg: LightGreen, bg: Black, underline: Reset, modifier: NONE, + x: 0, y: 21, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 25, y: 21, fg: LightGreen, bg: Black, underline: Reset, modifier: NONE, + x: 0, y: 22, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 25, y: 22, fg: LightGreen, bg: Black, underline: Reset, modifier: NONE, x: 0, y: 23, fg: White, bg: DarkGray, underline: Reset, modifier: NONE, x: 4, y: 23, fg: DarkGray, bg: Black, underline: Reset, modifier: NONE, - x: 23, y: 23, fg: White, bg: DarkGray, underline: Reset, modifier: NONE, - x: 27, y: 23, fg: DarkGray, bg: Black, underline: Reset, modifier: NONE, - x: 40, y: 23, fg: White, bg: DarkGray, underline: Reset, modifier: NONE, - x: 43, y: 23, fg: DarkGray, bg: Black, underline: Reset, modifier: NONE, - x: 50, y: 23, fg: Reset, bg: Black, underline: Reset, modifier: NONE, + x: 18, y: 23, fg: White, bg: DarkGray, underline: Reset, modifier: NONE, + x: 22, y: 23, fg: DarkGray, bg: Black, underline: Reset, modifier: NONE, + x: 35, y: 23, fg: White, bg: DarkGray, underline: Reset, modifier: NONE, + x: 38, y: 23, fg: DarkGray, bg: Black, underline: Reset, modifier: NONE, + x: 51, y: 23, fg: White, bg: DarkGray, underline: Reset, modifier: NONE, + x: 54, y: 23, fg: DarkGray, bg: Black, underline: Reset, modifier: NONE, + x: 67, y: 23, fg: White, bg: DarkGray, underline: Reset, modifier: NONE, + x: 70, y: 23, fg: DarkGray, bg: Black, underline: Reset, modifier: NONE, + x: 79, y: 23, fg: White, bg: DarkGray, underline: Reset, modifier: NONE, ] }, scrollback: Buffer { diff --git a/src/snapshots/pdm__ui__tests__bitcoin_status_tab_peers_render.snap b/src/snapshots/pdm__ui__tests__bitcoin_status_tab_peers_render.snap index 8bb00f5..3cd08fc 100644 --- a/src/snapshots/pdm__ui__tests__bitcoin_status_tab_peers_render.snap +++ b/src/snapshots/pdm__ui__tests__bitcoin_status_tab_peers_render.snap @@ -10,9 +10,9 @@ TestBackend { "│Home ││ Chain Info │ System │ Logs │ Peers │", "│Bitcoin Config ││ │", "│Bitcoin Status │└─────────────────────────────────────────────────────┘", - "│P2Pool Config │┌─────────────────────────────────────────────────────┐", - "│P2Pool Status ││Peers │", - "│LN Config ││ │", + "│P2Pool Config │┌ Peers ──────────────────────────────────────────────┐", + "│P2Pool Status ││Select a bitcoin.conf file to load Bitcoin Core peer │", + "│LN Config ││info. │", "│LN Status ││ │", "│Shares Market ││ │", "│Settings ││ │", @@ -37,6 +37,10 @@ TestBackend { x: 61, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 1, y: 3, fg: Black, bg: Gray, underline: Reset, modifier: NONE, x: 24, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 26, y: 5, fg: DarkGray, bg: Reset, underline: Reset, modifier: NONE, + x: 78, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 26, y: 6, fg: DarkGray, bg: Reset, underline: Reset, modifier: NONE, + x: 31, y: 6, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 0, y: 23, fg: White, bg: DarkGray, underline: Reset, modifier: NONE, x: 4, y: 23, fg: DarkGray, bg: Black, underline: Reset, modifier: NONE, x: 23, y: 23, fg: White, bg: DarkGray, underline: Reset, modifier: NONE, diff --git a/src/snapshots/pdm__ui__tests__bitcoin_status_tab_system_render.snap b/src/snapshots/pdm__ui__tests__bitcoin_status_tab_system_render.snap index 4e116ec..36c6425 100644 --- a/src/snapshots/pdm__ui__tests__bitcoin_status_tab_system_render.snap +++ b/src/snapshots/pdm__ui__tests__bitcoin_status_tab_system_render.snap @@ -1,5 +1,6 @@ --- source: src/ui.rs +assertion_line: 158 expression: terminal.backend() --- TestBackend { @@ -10,10 +11,10 @@ TestBackend { "│Home ││ Chain Info │ System │ Logs │ Peers │", "│Bitcoin Config ││ │", "│Bitcoin Status │└─────────────────────────────────────────────────────┘", - "│P2Pool Config │┌─────────────────────────────────────────────────────┐", - "│P2Pool Status ││System │", + "│P2Pool Config │┌ Bitcoin Core Process ───────────────────────────────┐", + "│P2Pool Status ││[s] Start [t] Stop [r] Restart │", "│LN Config ││ │", - "│LN Status ││ │", + "│LN Status ││Process state: Stopped │", "│Shares Market ││ │", "│Settings ││ │", "│ ││ │", @@ -37,6 +38,10 @@ TestBackend { x: 46, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 1, y: 3, fg: Black, bg: Gray, underline: Reset, modifier: NONE, x: 24, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 26, y: 5, fg: Cyan, bg: Reset, underline: Reset, modifier: NONE, + x: 36, y: 5, fg: DarkGray, bg: Reset, underline: Reset, modifier: NONE, + x: 45, y: 5, fg: Cyan, bg: Reset, underline: Reset, modifier: NONE, + x: 56, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 0, y: 23, fg: White, bg: DarkGray, underline: Reset, modifier: NONE, x: 4, y: 23, fg: DarkGray, bg: Black, underline: Reset, modifier: NONE, x: 23, y: 23, fg: White, bg: DarkGray, underline: Reset, modifier: NONE, diff --git a/src/snapshots/pdm__ui__tests__settings_screen_render.snap b/src/snapshots/pdm__ui__tests__settings_screen_render.snap index 406ff9a..03fe386 100644 --- a/src/snapshots/pdm__ui__tests__settings_screen_render.snap +++ b/src/snapshots/pdm__ui__tests__settings_screen_render.snap @@ -1,6 +1,6 @@ --- source: src/ui.rs -assertion_line: 245 +assertion_line: 252 expression: terminal.backend() --- TestBackend { @@ -16,14 +16,14 @@ TestBackend { "│LN Config ││(not set) │", "│LN Status ││Shares Market config path │", "│Shares Market ││(not set) │", - "│Settings ││Settings directory │", + "│Settings ││Bitcoin Core data directory │", + "│ ││(not set) │", + "│ ││Bitcoin Core log file │", + "│ ││(not set) │", + "│ ││Settings directory │", "│ ││/pdm/test-config │", - "│ ││ │", - "│ ││ │", - "│ ││ │", - "│ ││ │", - "│ ││ │", - "│ ││ │", + "│ ││Bitcoin Core executable │", + "│ ││(not set) │", "│ ││ │", "│ ││ │", "│ ││ │", @@ -77,35 +77,38 @@ TestBackend { x: 24, y: 9, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 25, y: 9, fg: DarkGray, bg: Reset, underline: Reset, modifier: NONE, x: 26, y: 9, fg: Gray, bg: Reset, underline: Reset, modifier: NONE, - x: 44, y: 9, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 53, y: 9, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 79, y: 9, fg: DarkGray, bg: Reset, underline: Reset, modifier: NONE, x: 0, y: 10, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 25, y: 10, fg: DarkGray, bg: Reset, underline: Reset, modifier: NONE, - x: 42, y: 10, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 35, y: 10, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 79, y: 10, fg: DarkGray, bg: Reset, underline: Reset, modifier: NONE, x: 0, y: 11, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 25, y: 11, fg: DarkGray, bg: Reset, underline: Reset, modifier: NONE, - x: 26, y: 11, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 26, y: 11, fg: Gray, bg: Reset, underline: Reset, modifier: NONE, + x: 47, y: 11, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 79, y: 11, fg: DarkGray, bg: Reset, underline: Reset, modifier: NONE, x: 0, y: 12, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 25, y: 12, fg: DarkGray, bg: Reset, underline: Reset, modifier: NONE, - x: 26, y: 12, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 35, y: 12, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 79, y: 12, fg: DarkGray, bg: Reset, underline: Reset, modifier: NONE, x: 0, y: 13, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 25, y: 13, fg: DarkGray, bg: Reset, underline: Reset, modifier: NONE, - x: 26, y: 13, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 26, y: 13, fg: Gray, bg: Reset, underline: Reset, modifier: NONE, + x: 44, y: 13, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 79, y: 13, fg: DarkGray, bg: Reset, underline: Reset, modifier: NONE, x: 0, y: 14, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 25, y: 14, fg: DarkGray, bg: Reset, underline: Reset, modifier: NONE, - x: 26, y: 14, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 42, y: 14, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 79, y: 14, fg: DarkGray, bg: Reset, underline: Reset, modifier: NONE, x: 0, y: 15, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 25, y: 15, fg: DarkGray, bg: Reset, underline: Reset, modifier: NONE, - x: 26, y: 15, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 26, y: 15, fg: Gray, bg: Reset, underline: Reset, modifier: NONE, + x: 49, y: 15, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 79, y: 15, fg: DarkGray, bg: Reset, underline: Reset, modifier: NONE, x: 0, y: 16, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 25, y: 16, fg: DarkGray, bg: Reset, underline: Reset, modifier: NONE, - x: 26, y: 16, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 35, y: 16, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 79, y: 16, fg: DarkGray, bg: Reset, underline: Reset, modifier: NONE, x: 0, y: 17, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 25, y: 17, fg: DarkGray, bg: Reset, underline: Reset, modifier: NONE, diff --git a/src/ui.rs b/src/ui.rs index f14d3e8..3804489 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -165,6 +165,10 @@ mod tests { app.sidebar_index = 2; app.toggle_menu(); app.bitcoin_status_tab = 2; + app.bitcoin_log_path = None; + app.bitcoin_log_lines.clear(); + app.bitcoin_log_status = + "No Bitcoin Core debug.log found. Choose a log file or data directory.".to_string(); terminal.draw(|f| ui(f, &mut app)).unwrap(); insta::assert_debug_snapshot!(terminal.backend()); }