From 0a4fdc319fa5a9a31bd997357963227c41aaa9cd Mon Sep 17 00:00:00 2001 From: Raunak Kumar Date: Tue, 12 May 2026 21:22:16 +0000 Subject: [PATCH 1/6] Initial commit for share_info --- src/components/p2pool_client.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/components/p2pool_client.rs b/src/components/p2pool_client.rs index a7741db..5322370 100644 --- a/src/components/p2pool_client.rs +++ b/src/components/p2pool_client.rs @@ -11,6 +11,7 @@ use serde::de::DeserializeOwned; use std::time::Duration; const REQUEST_TIMEOUT_SECONDS: u64 = 10; +const TESTNET4_FALLBACK_BASE_URL: &str = "https://testnet4.p2poolv2.org"; #[derive(Debug, Clone)] pub struct P2PoolClient { From b1a17485ce3a1d5dfbc2832e6abb7050b6e0e848 Mon Sep 17 00:00:00 2001 From: Raunak Kumar Date: Thu, 18 Jun 2026 05:13:44 +0000 Subject: [PATCH 2/6] feat: Implement Bitcoin core logs(DRAFT) --- src/app.rs | 217 +++++++++++ src/bitcoin_logs.rs | 346 ++++++++++++++++++ src/components/bitcoin_status_view.rs | 289 ++++++++++++++- src/components/settings_view.rs | 19 +- src/components/status_bar.rs | 44 ++- src/lib.rs | 1 + src/main.rs | 184 +++++++++- src/settings.rs | 40 ++ ...tests__bitcoin_status_tab_logs_render.snap | 69 +++- ...dm__ui__tests__settings_screen_render.snap | 25 +- src/ui.rs | 4 + 11 files changed, 1191 insertions(+), 47 deletions(-) create mode 100644 src/bitcoin_logs.rs diff --git a/src/app.rs b/src/app.rs index 1c6569c..68742aa 100644 --- a/src/app.rs +++ b/src/app.rs @@ -3,6 +3,7 @@ // 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; @@ -13,10 +14,16 @@ use crate::components::p2pool_websocket::{ }; use crate::components::settings_view::SettingsView; 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), @@ -60,6 +67,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), @@ -91,6 +100,24 @@ 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, +} + +#[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 { @@ -108,6 +135,16 @@ pub struct App { pub bitcoin_status_tab: usize, pub bitcoin_chain_info: Option, pub bitcoin_chain_info_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, @@ -130,6 +167,8 @@ pub struct App { 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 @@ -147,6 +186,7 @@ impl App { 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(); @@ -165,6 +205,16 @@ impl App { bitcoin_status_tab: 0, bitcoin_chain_info: None, bitcoin_chain_info_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(), @@ -183,6 +233,8 @@ impl App { 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, @@ -233,6 +285,160 @@ impl App { } } + 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_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 { @@ -332,6 +538,7 @@ impl App { 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(); @@ -395,6 +602,16 @@ impl App { } } } + +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() 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_status_view.rs b/src/components/bitcoin_status_view.rs index 97b474f..c58f2bf 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}, @@ -53,19 +54,142 @@ impl BitcoinStatusView { f.render_widget(p, 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 => Self::render_peers(f, app, content_area), _ => {} } } + 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_chain_info(f: &mut Frame, app: &App, area: Rect) { let text = if app.bitcoin_conf_path.is_none() { vec![Line::from(Span::styled( @@ -154,6 +278,155 @@ impl BitcoinStatusView { 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)) } diff --git a/src/components/settings_view.rs b/src/components/settings_view.rs index 58b02c6..5b05c53 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 = 7; /// Describes how a settings field behaves when Enter is pressed. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -27,6 +27,8 @@ 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), ]; @@ -96,6 +98,14 @@ 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() @@ -114,7 +124,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 +282,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 +310,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..f472861 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -4,6 +4,7 @@ pub mod app; pub mod bitcoin_config; +pub mod bitcoin_logs; pub mod components; pub mod config; pub mod p2poolv2_config; diff --git a/src/main.rs b/src/main.rs index cd235e0..67cff6c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -10,7 +10,10 @@ use pdm::app::{ use pdm::bitcoin_config::{ parse_config as parse_bitcoin_config, save_config as save_bitcoin_config, }; -use pdm::components::settings_view::{FIELDS, FieldKind}; +use pdm::components::{ + bitcoin_status_view::BitcoinStatusView, + settings_view::{FIELDS, FieldKind}, +}; use pdm::p2poolv2_config::{apply_edit as apply_p2pool_edit, flatten_config}; use pdm::settings::{load_settings, save_settings}; use pdm::ui; @@ -82,6 +85,8 @@ where { loop { app.poll_bitcoin_chain_info(); + app.poll_bitcoin_logs(); + app.maybe_refresh_bitcoin_logs(); app.poll_chain_info(); app.poll_share_info(); app.poll_peer_info(); @@ -263,6 +268,8 @@ fn bootstrap_from_settings(app: &mut App) { } } } + + app.resolve_bitcoin_log_path(); } // Logic Handler @@ -274,10 +281,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; } @@ -342,6 +351,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; @@ -365,6 +394,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." @@ -397,6 +427,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." @@ -435,7 +466,9 @@ 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()), _ => {} } if should_save { @@ -478,6 +511,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; @@ -486,7 +520,17 @@ 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, _ => {} } app.settings_view.save_error = None; @@ -524,12 +568,67 @@ 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::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"). @@ -1832,13 +1931,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(); @@ -1847,7 +1994,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, @@ -1871,6 +2018,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(); @@ -1924,8 +2079,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()); @@ -1942,6 +2101,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/settings.rs b/src/settings.rs index db0f780..d4966f1 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 @@ -119,6 +123,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 +138,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 +149,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 +224,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 +236,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 +257,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_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__settings_screen_render.snap b/src/snapshots/pdm__ui__tests__settings_screen_render.snap index 406ff9a..ecb7205 100644 --- a/src/snapshots/pdm__ui__tests__settings_screen_render.snap +++ b/src/snapshots/pdm__ui__tests__settings_screen_render.snap @@ -1,6 +1,5 @@ --- source: src/ui.rs -assertion_line: 245 expression: terminal.backend() --- TestBackend { @@ -16,7 +15,11 @@ 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 │", "│ ││ │", "│ ││ │", @@ -25,10 +28,6 @@ TestBackend { "│ ││ │", "│ ││ │", "│ ││ │", - "│ ││ │", - "│ ││ │", - "│ ││ │", - "│ ││ │", "└───────────────────────┘└─────────────────────────────────────────────────────┘", " ↑↓ Navigate sidebar Enter Focus settings ", ], @@ -77,27 +76,29 @@ 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, 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()); } From b9fc3352adc1059fa21cae18a117bfcb9be39c29 Mon Sep 17 00:00:00 2001 From: Raunak Kumar Date: Sun, 19 Jul 2026 08:14:51 +0000 Subject: [PATCH 3/6] small fix after rebase --- src/components/p2pool_client.rs | 1 - src/main.rs | 5 +---- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/src/components/p2pool_client.rs b/src/components/p2pool_client.rs index 5322370..a7741db 100644 --- a/src/components/p2pool_client.rs +++ b/src/components/p2pool_client.rs @@ -11,7 +11,6 @@ use serde::de::DeserializeOwned; use std::time::Duration; const REQUEST_TIMEOUT_SECONDS: u64 = 10; -const TESTNET4_FALLBACK_BASE_URL: &str = "https://testnet4.p2poolv2.org"; #[derive(Debug, Clone)] pub struct P2PoolClient { diff --git a/src/main.rs b/src/main.rs index 67cff6c..b217d1a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -10,10 +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, - settings_view::{FIELDS, FieldKind}, -}; +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}; use pdm::ui; From a6a8648b64cbff5d6cc5aa384a82e13efe7bda60 Mon Sep 17 00:00:00 2001 From: Raunak Kumar Date: Sun, 9 Aug 2026 10:16:12 +0000 Subject: [PATCH 4/6] test: improve app and p2pool config coverage --- src/app.rs | 611 +++++++++++++++++++++++++++++++++++++++++ src/p2poolv2_config.rs | 315 +++++++++++++++++++++ 2 files changed, 926 insertions(+) diff --git a/src/app.rs b/src/app.rs index 68742aa..a6e8f18 100644 --- a/src/app.rs +++ b/src/app.rs @@ -717,4 +717,615 @@ mod tests { assert!(app.bitcoin_chain_info_error.is_none()); assert!(app.bitcoin_chain_info_rx.try_recv().is_err()); } + + // P2Pool chain info polling + + #[test] + fn poll_chain_info_updates_state_on_success() { + use crate::components::p2pool_client::ChainInfo; + + let mut app = App::new(); + app.p2pool_chain_info_error = Some("stale".to_string()); + + app.chain_info_tx + .send(Ok(ChainInfo { + genesis_blockhash: Some("genesis".to_string()), + chain_tip_height: Some(100), + total_work: "work".to_string(), + chain_tip_blockhash: Some("tip".to_string()), + })) + .unwrap(); + + app.poll_chain_info(); + + let info = app.chain_info.as_ref().unwrap(); + assert_eq!(info.chain_tip_height, Some(100)); + assert_eq!(info.total_work, "work"); + assert!(app.p2pool_chain_info_error.is_none()); + } + + #[test] + fn poll_chain_info_updates_state_on_error() { + use crate::components::p2pool_client::ChainInfo; + + let mut app = App::new(); + app.chain_info = Some(ChainInfo { + genesis_blockhash: None, + chain_tip_height: Some(50), + total_work: "old".to_string(), + chain_tip_blockhash: None, + }); + + app.chain_info_tx + .send(Err(anyhow::anyhow!("connection refused"))) + .unwrap(); + + app.poll_chain_info(); + + assert!(app.chain_info.is_none()); + assert_eq!( + app.p2pool_chain_info_error.as_deref(), + Some("connection refused") + ); + } + + // Bitcoin log polling + + #[test] + fn poll_bitcoin_logs_processes_matching_ok_snapshot() { + let mut app = App::new(); + let path = PathBuf::from("/tmp/test/debug.log"); + app.bitcoin_log_path = Some(path.clone()); + app.bitcoin_log_auto_scroll = true; + app.bitcoin_log_scroll = 5; + app.bitcoin_log_refresh_in_progress = true; + + app.bitcoin_log_tx + .send(BitcoinLogReadMessage { + path: path.clone(), + result: Ok(BitcoinLogSnapshot { + path, + lines: vec!["line1".to_string(), "line2".to_string()], + file_size: 100, + }), + }) + .unwrap(); + + app.poll_bitcoin_logs(); + + assert_eq!(app.bitcoin_log_lines, vec!["line1", "line2"]); + assert!(app.bitcoin_log_status.contains("Showing 2 recent lines")); + assert!(!app.bitcoin_log_refresh_in_progress); + assert!(app.bitcoin_log_last_refresh.is_some()); + // auto_scroll is true, so scroll resets to 0 + assert_eq!(app.bitcoin_log_scroll, 0); + } + + #[test] + fn poll_bitcoin_logs_auto_scroll_disabled_preserves_scroll() { + let mut app = App::new(); + let path = PathBuf::from("/tmp/test/debug.log"); + app.bitcoin_log_path = Some(path.clone()); + app.bitcoin_log_auto_scroll = false; + app.bitcoin_log_scroll = 10; + + app.bitcoin_log_tx + .send(BitcoinLogReadMessage { + path: path.clone(), + result: Ok(BitcoinLogSnapshot { + path, + lines: vec!["line1".to_string()], + file_size: 50, + }), + }) + .unwrap(); + + app.poll_bitcoin_logs(); + + // auto_scroll is false, so scroll position stays + assert_eq!(app.bitcoin_log_scroll, 10); + } + + #[test] + fn poll_bitcoin_logs_empty_snapshot_shows_empty_message() { + let mut app = App::new(); + let path = PathBuf::from("/tmp/test/debug.log"); + app.bitcoin_log_path = Some(path.clone()); + + app.bitcoin_log_tx + .send(BitcoinLogReadMessage { + path: path.clone(), + result: Ok(BitcoinLogSnapshot { + path, + lines: Vec::new(), + file_size: 0, + }), + }) + .unwrap(); + + app.poll_bitcoin_logs(); + + assert!(app.bitcoin_log_lines.is_empty()); + assert!(app.bitcoin_log_status.contains("is empty")); + } + + #[test] + fn poll_bitcoin_logs_ignores_non_matching_path() { + let mut app = App::new(); + app.bitcoin_log_path = Some(PathBuf::from("/expected/debug.log")); + app.bitcoin_log_refresh_in_progress = true; + + app.bitcoin_log_tx + .send(BitcoinLogReadMessage { + path: PathBuf::from("/wrong/debug.log"), + result: Ok(BitcoinLogSnapshot { + path: PathBuf::from("/wrong/debug.log"), + lines: vec!["should be ignored".to_string()], + file_size: 10, + }), + }) + .unwrap(); + + app.poll_bitcoin_logs(); + + // State unchanged — message for a different path is skipped + assert!(app.bitcoin_log_lines.is_empty()); + assert!(app.bitcoin_log_refresh_in_progress); + } + + #[test] + fn poll_bitcoin_logs_handles_error_result() { + let mut app = App::new(); + let path = PathBuf::from("/tmp/test/debug.log"); + app.bitcoin_log_path = Some(path.clone()); + app.bitcoin_log_refresh_in_progress = true; + + app.bitcoin_log_tx + .send(BitcoinLogReadMessage { + path, + result: Err(anyhow::anyhow!("file not found")), + }) + .unwrap(); + + app.poll_bitcoin_logs(); + + assert!(app.bitcoin_log_status.contains("Log file unavailable")); + assert!(app.bitcoin_log_status.contains("file not found")); + assert!(!app.bitcoin_log_refresh_in_progress); + assert!(app.bitcoin_log_last_refresh.is_some()); + } + + // maybe_refresh_bitcoin_logs + + #[test] + fn maybe_refresh_does_nothing_on_wrong_screen() { + let mut app = App::new(); + app.current_screen = CurrentScreen::Home; + app.bitcoin_status_tab = 2; + app.bitcoin_log_last_refresh = None; + + app.maybe_refresh_bitcoin_logs(); + + // No refresh triggered — wrong screen + assert!(!app.bitcoin_log_refresh_in_progress); + } + + #[test] + fn maybe_refresh_does_nothing_on_wrong_tab() { + let mut app = App::new(); + app.current_screen = CurrentScreen::BitcoinStatus; + app.bitcoin_status_tab = 0; // Chain Info, not Logs (tab 2) + app.bitcoin_log_last_refresh = None; + + app.maybe_refresh_bitcoin_logs(); + + assert!(!app.bitcoin_log_refresh_in_progress); + } + + // refresh_bitcoin_logs + + #[test] + fn refresh_bitcoin_logs_skips_when_in_progress() { + let mut app = App::new(); + app.bitcoin_log_refresh_in_progress = true; + let status_before = app.bitcoin_log_status.clone(); + + app.refresh_bitcoin_logs(); + + // Status unchanged because early return + assert_eq!(app.bitcoin_log_status, status_before); + } + + #[test] + fn refresh_bitcoin_logs_with_path_sends_via_channel() { + use tempfile::tempdir; + + let dir = tempdir().unwrap(); + let log_file = dir.path().join("debug.log"); + std::fs::write(&log_file, "2026-01-01 test line\n").unwrap(); + + let mut app = App::new(); + app.bitcoin_log_path = Some(log_file.clone()); + + // No tokio runtime → takes the synchronous fallback branch + app.refresh_bitcoin_logs(); + + assert!(app.bitcoin_log_refresh_in_progress); + assert!(app.bitcoin_log_status.contains("Reading")); + + // The synchronous path sends a message through the channel + let message = app.bitcoin_log_rx.try_recv().unwrap(); + assert_eq!(message.path, log_file); + assert!(message.result.is_ok()); + } + + // set_bitcoin_log_data_dir / set_bitcoin_log_file + + #[test] + fn set_bitcoin_log_data_dir_updates_settings_and_resets_reader() { + let mut app = App::new(); + app.bitcoin_log_lines = vec!["old log".to_string()]; + app.bitcoin_log_scroll = 5; + app.settings.bitcoin_core_log_path = Some(PathBuf::from("/old/log.path")); + + let data_dir = PathBuf::from("/tmp/bitcoin"); + app.set_bitcoin_log_data_dir(data_dir.clone()); + + assert_eq!(app.settings.bitcoin_core_data_dir, Some(data_dir)); + // log_path is cleared when data_dir is set + assert!(app.settings.bitcoin_core_log_path.is_none()); + // reader is reset + assert!(app.bitcoin_log_lines.is_empty()); + assert_eq!(app.bitcoin_log_scroll, 0); + assert!(app.bitcoin_log_path.is_some()); + } + + #[test] + fn set_bitcoin_log_file_updates_settings_and_resets_reader() { + let mut app = App::new(); + app.bitcoin_log_lines = vec!["old log".to_string()]; + app.bitcoin_log_scroll = 5; + app.settings.bitcoin_core_data_dir = Some(PathBuf::from("/old/data/dir")); + + let log_file = PathBuf::from("/tmp/bitcoin/debug.log"); + app.set_bitcoin_log_file(log_file.clone()); + + assert_eq!(app.settings.bitcoin_core_log_path, Some(log_file.clone())); + // data_dir is cleared when explicit log file is set + assert!(app.settings.bitcoin_core_data_dir.is_none()); + assert_eq!(app.bitcoin_log_path, Some(log_file)); + // reader is reset + assert!(app.bitcoin_log_lines.is_empty()); + assert_eq!(app.bitcoin_log_scroll, 0); + } + + // filtered_bitcoin_log_lines + + #[test] + fn filtered_bitcoin_log_lines_returns_all_when_no_filter() { + let mut app = App::new(); + app.bitcoin_log_lines = vec!["first".to_string(), "second".to_string()]; + app.bitcoin_log_filter = String::new(); + + let result = app.filtered_bitcoin_log_lines(); + + assert_eq!(result, vec!["first", "second"]); + } + + #[test] + fn filtered_bitcoin_log_lines_filters_case_insensitive() { + let mut app = App::new(); + app.bitcoin_log_lines = vec![ + "2026-01-01 UpdateTip: new best".to_string(), + "2026-01-01 Received block".to_string(), + "2026-01-01 updatetip: another".to_string(), + ]; + app.bitcoin_log_filter = "UpdateTip".to_string(); + + let result = app.filtered_bitcoin_log_lines(); + + assert_eq!(result.len(), 2); + assert!(result[0].contains("UpdateTip")); + assert!(result[1].contains("updatetip")); + } + + #[test] + fn filtered_bitcoin_log_lines_empty_lines_returns_empty() { + let app = App::new(); + let result = app.filtered_bitcoin_log_lines(); + assert!(result.is_empty()); + } + + // copy_filtered_bitcoin_logs + + #[test] + fn copy_filtered_bitcoin_logs_no_lines_sets_status() { + let mut app = App::new(); + // No log lines + app.copy_filtered_bitcoin_logs(); + + assert_eq!(app.bitcoin_log_status, "No Bitcoin Core log lines to copy."); + } + + // peer info polling + + #[test] + fn poll_peer_info_updates_state_on_success() { + use crate::components::p2pool_client::PeerInfo; + + let mut app = App::new(); + app.p2pool_peer_info_error = Some("stale".to_string()); + + app.peer_info_tx + .send(Ok(vec![PeerInfo { + peer_id: "peer1".to_string(), + status: Some("Connected".to_string()), + }])) + .unwrap(); + + app.poll_peer_info(); + + let peers = app.peer_info.as_ref().unwrap(); + assert_eq!(peers.len(), 1); + assert_eq!(peers[0].peer_id, "peer1"); + assert!(app.p2pool_peer_info_error.is_none()); + } + + #[test] + fn poll_peer_info_updates_state_on_error() { + use crate::components::p2pool_client::PeerInfo; + + let mut app = App::new(); + app.peer_info = Some(vec![PeerInfo { + peer_id: "old".to_string(), + status: None, + }]); + + app.peer_info_tx + .send(Err(anyhow::anyhow!("timeout"))) + .unwrap(); + + app.poll_peer_info(); + + assert!(app.peer_info.is_none()); + assert_eq!(app.p2pool_peer_info_error.as_deref(), Some("timeout")); + } + + // share info polling + + #[test] + fn poll_share_info_updates_state_on_success() { + use crate::components::p2pool_client::SharesResponse; + + let mut app = App::new(); + app.p2pool_share_info_error = Some("stale".to_string()); + + app.share_info_tx + .send(Ok(SharesResponse { + from_height: 1, + to_height: 10, + shares: Vec::new(), + })) + .unwrap(); + + app.poll_share_info(); + + let info = app.share_info.as_ref().unwrap(); + assert_eq!(info.from_height, 1); + assert_eq!(info.to_height, 10); + assert!(app.p2pool_share_info_error.is_none()); + } + + #[test] + fn poll_share_info_updates_state_on_error() { + use crate::components::p2pool_client::SharesResponse; + + let mut app = App::new(); + app.share_info = Some(SharesResponse { + from_height: 1, + to_height: 10, + shares: Vec::new(), + }); + + app.share_info_tx + .send(Err(anyhow::anyhow!("server error"))) + .unwrap(); + + app.poll_share_info(); + + assert!(app.share_info.is_none()); + assert_eq!(app.p2pool_share_info_error.as_deref(), Some("server error")); + } + + // live P2Pool events + + #[test] + fn poll_live_p2pool_events_adds_share() { + use crate::components::p2pool_websocket::{LiveP2PoolEvent, LiveShare}; + + let mut app = App::new(); + app.p2pool_live_error = Some("old error".to_string()); + + app.p2pool_live_tx + .send(Ok(LiveP2PoolEvent::Share(LiveShare { + blockhash: "0000".to_string(), + prev_blockhash: "ffff".to_string(), + height: 42, + miner_address: "miner".to_string(), + timestamp: 1700000000, + bits: "1d00ffff".to_string(), + uncles: Vec::new(), + }))) + .unwrap(); + + app.poll_live_p2pool_events(); + + assert_eq!(app.live_shares.len(), 1); + assert_eq!(app.live_shares[0].height, 42); + assert!(app.p2pool_live_error.is_none()); + } + + #[test] + fn poll_live_p2pool_events_adds_peer_and_updates_peer_info() { + use crate::components::p2pool_websocket::{LiveP2PoolEvent, LivePeerEvent}; + + let mut app = App::new(); + + app.p2pool_live_tx + .send(Ok(LiveP2PoolEvent::Peer(LivePeerEvent { + peer_id: "peer1".to_string(), + status: "Connected".to_string(), + }))) + .unwrap(); + + app.poll_live_p2pool_events(); + + assert_eq!(app.live_peer_events.len(), 1); + assert_eq!(app.live_peer_events[0].peer_id, "peer1"); + // apply_live_peer_event should have added the peer to peer_info + let peers = app.peer_info.as_ref().unwrap(); + assert_eq!(peers.len(), 1); + assert_eq!(peers[0].peer_id, "peer1"); + assert_eq!(peers[0].status.as_deref(), Some("Connected")); + } + + #[test] + fn poll_live_p2pool_events_handles_error() { + let mut app = App::new(); + app.p2pool_live_stream_started = true; + + app.p2pool_live_tx + .send(Err(anyhow::anyhow!("websocket closed"))) + .unwrap(); + + app.poll_live_p2pool_events(); + + assert_eq!(app.p2pool_live_error.as_deref(), Some("websocket closed")); + assert!(!app.p2pool_live_stream_started); + } + + // apply_live_peer_event + + #[test] + fn apply_live_peer_event_adds_new_peer() { + use crate::components::p2pool_websocket::LivePeerEvent; + + let mut app = App::new(); + + app.apply_live_peer_event(&LivePeerEvent { + peer_id: "new_peer".to_string(), + status: "Connected".to_string(), + }); + + let peers = app.peer_info.as_ref().unwrap(); + assert_eq!(peers.len(), 1); + assert_eq!(peers[0].peer_id, "new_peer"); + assert_eq!(peers[0].status.as_deref(), Some("Connected")); + } + + #[test] + fn apply_live_peer_event_updates_existing_peer() { + use crate::components::p2pool_client::PeerInfo; + use crate::components::p2pool_websocket::LivePeerEvent; + + let mut app = App::new(); + app.peer_info = Some(vec![PeerInfo { + peer_id: "peer1".to_string(), + status: Some("Connected".to_string()), + }]); + + app.apply_live_peer_event(&LivePeerEvent { + peer_id: "peer1".to_string(), + status: "Syncing".to_string(), + }); + + let peers = app.peer_info.as_ref().unwrap(); + assert_eq!(peers.len(), 1); + assert_eq!(peers[0].status.as_deref(), Some("Syncing")); + } + + #[test] + fn apply_live_peer_event_disconnected_removes_peer() { + use crate::components::p2pool_client::PeerInfo; + use crate::components::p2pool_websocket::LivePeerEvent; + + let mut app = App::new(); + app.peer_info = Some(vec![ + PeerInfo { + peer_id: "peer1".to_string(), + status: Some("Connected".to_string()), + }, + PeerInfo { + peer_id: "peer2".to_string(), + status: Some("Connected".to_string()), + }, + ]); + + app.apply_live_peer_event(&LivePeerEvent { + peer_id: "peer1".to_string(), + status: "disconnected".to_string(), + }); + + let peers = app.peer_info.as_ref().unwrap(); + assert_eq!(peers.len(), 1); + assert_eq!(peers[0].peer_id, "peer2"); + } + + // push_limited + + #[test] + fn push_limited_within_capacity() { + let mut items = vec![1, 2, 3]; + App::push_limited(&mut items, 4, 5); + assert_eq!(items, vec![1, 2, 3, 4]); + } + + #[test] + fn push_limited_drains_oldest_when_over_capacity() { + let mut items = vec![1, 2, 3]; + App::push_limited(&mut items, 4, 3); + assert_eq!(items, vec![2, 3, 4]); + } + + #[test] + fn push_limited_handles_max_one() { + let mut items = vec![10]; + App::push_limited(&mut items, 20, 1); + assert_eq!(items, vec![20]); + } + + // resolve_bitcoin_log_path + + #[test] + fn resolve_bitcoin_log_path_with_settings_log_path() { + let mut app = App::new(); + app.settings.bitcoin_core_log_path = Some(PathBuf::from("/custom/debug.log")); + + app.resolve_bitcoin_log_path(); + + assert_eq!( + app.bitcoin_log_path, + Some(PathBuf::from("/custom/debug.log")) + ); + assert!(app.bitcoin_log_status.contains("Ready to read")); + } + + // reset_bitcoin_log_reader + + #[test] + fn reset_bitcoin_log_reader_clears_all_state() { + let mut app = App::new(); + app.bitcoin_log_lines = vec!["old".to_string()]; + app.bitcoin_log_scroll = 5; + app.bitcoin_log_refresh_in_progress = true; + app.bitcoin_log_last_refresh = Some(Instant::now()); + + app.reset_bitcoin_log_reader(); + + assert!(app.bitcoin_log_lines.is_empty()); + assert_eq!(app.bitcoin_log_scroll, 0); + assert!(!app.bitcoin_log_refresh_in_progress); + assert!(app.bitcoin_log_last_refresh.is_none()); + assert_eq!(app.bitcoin_log_status, "Ready to read Bitcoin Core logs."); + } } diff --git a/src/p2poolv2_config.rs b/src/p2poolv2_config.rs index 300e04c..9bac9a0 100644 --- a/src/p2poolv2_config.rs +++ b/src/p2poolv2_config.rs @@ -933,4 +933,319 @@ port = 3030 "error message must mention index out of range" ); } + + /// Exercises every `dispatch_edit` match arm not already covered by + /// `apply_edit_covers_multiple_field_types`. + #[test] + fn dispatch_edit_covers_all_remaining_branches() { + let mut cfg = make_config(); + + // Helper to build a synthetic P2PoolConfigEntry for dispatch_edit + fn entry(section: ConfigSection, key: &str) -> P2PoolConfigEntry { + P2PoolConfigEntry { + section, + key: key.to_string(), + value: String::new(), + enabled: true, + schema: P2PoolFieldSchema { + description: String::new(), + kind: FieldKind::Required, + type_hint: String::new(), + sensitive: false, + }, + } + } + + // --- Stratum fields not covered by apply_edit_covers_multiple_field_types --- + dispatch_edit( + &mut cfg, + &entry(ConfigSection::Stratum, "start_difficulty"), + "500", + ) + .unwrap(); + assert_eq!(cfg.stratum.start_difficulty, 500); + + dispatch_edit( + &mut cfg, + &entry(ConfigSection::Stratum, "minimum_difficulty"), + "50", + ) + .unwrap(); + assert_eq!(cfg.stratum.minimum_difficulty, 50); + + // maximum_difficulty: set and clear + dispatch_edit( + &mut cfg, + &entry(ConfigSection::Stratum, "maximum_difficulty"), + "99999", + ) + .unwrap(); + assert_eq!(cfg.stratum.maximum_difficulty, Some(99999)); + dispatch_edit( + &mut cfg, + &entry(ConfigSection::Stratum, "maximum_difficulty"), + "", + ) + .unwrap(); + assert!(cfg.stratum.maximum_difficulty.is_none()); + + // solo_address: set and clear + dispatch_edit( + &mut cfg, + &entry(ConfigSection::Stratum, "solo_address"), + "tb1qaddr", + ) + .unwrap(); + assert_eq!(cfg.stratum.solo_address.as_deref(), Some("tb1qaddr")); + dispatch_edit(&mut cfg, &entry(ConfigSection::Stratum, "solo_address"), "").unwrap(); + assert!(cfg.stratum.solo_address.is_none()); + + dispatch_edit( + &mut cfg, + &entry(ConfigSection::Stratum, "zmqpubhashblock"), + "tcp://127.0.0.1:28333", + ) + .unwrap(); + assert_eq!(cfg.stratum.zmqpubhashblock, "tcp://127.0.0.1:28333"); + + dispatch_edit( + &mut cfg, + &entry(ConfigSection::Stratum, "bootstrap_address"), + "tb1qnew", + ) + .unwrap(); + assert_eq!(cfg.stratum.bootstrap_address, "tb1qnew"); + + // donation_address: set and clear + dispatch_edit( + &mut cfg, + &entry(ConfigSection::Stratum, "donation_address"), + "tb1qdonation", + ) + .unwrap(); + assert_eq!( + cfg.stratum.donation_address.as_deref(), + Some("tb1qdonation") + ); + dispatch_edit( + &mut cfg, + &entry(ConfigSection::Stratum, "donation_address"), + "", + ) + .unwrap(); + assert!(cfg.stratum.donation_address.is_none()); + + // donation: already tested via apply_edit — but test clear path + dispatch_edit(&mut cfg, &entry(ConfigSection::Stratum, "donation"), "").unwrap(); + assert!(cfg.stratum.donation.is_none()); + + // fee_address: set and clear + dispatch_edit( + &mut cfg, + &entry(ConfigSection::Stratum, "fee_address"), + "tb1qfee", + ) + .unwrap(); + assert_eq!(cfg.stratum.fee_address.as_deref(), Some("tb1qfee")); + dispatch_edit(&mut cfg, &entry(ConfigSection::Stratum, "fee_address"), "").unwrap(); + assert!(cfg.stratum.fee_address.is_none()); + + // fee: set and clear + dispatch_edit(&mut cfg, &entry(ConfigSection::Stratum, "fee"), "100").unwrap(); + assert_eq!(cfg.stratum.fee, Some(100)); + dispatch_edit(&mut cfg, &entry(ConfigSection::Stratum, "fee"), "").unwrap(); + assert!(cfg.stratum.fee.is_none()); + + dispatch_edit( + &mut cfg, + &entry(ConfigSection::Stratum, "difficulty_multiplier"), + "2.5", + ) + .unwrap(); + assert!((cfg.stratum.difficulty_multiplier - 2.5).abs() < f64::EPSILON); + + // ignore_difficulty: set and clear + dispatch_edit( + &mut cfg, + &entry(ConfigSection::Stratum, "ignore_difficulty"), + "true", + ) + .unwrap(); + assert_eq!(cfg.stratum.ignore_difficulty, Some(true)); + dispatch_edit( + &mut cfg, + &entry(ConfigSection::Stratum, "ignore_difficulty"), + "", + ) + .unwrap(); + assert!(cfg.stratum.ignore_difficulty.is_none()); + + // pool_signature: set (clear already covered) + dispatch_edit( + &mut cfg, + &entry(ConfigSection::Stratum, "pool_signature"), + "TestPool", + ) + .unwrap(); + assert_eq!(cfg.stratum.pool_signature.as_deref(), Some("TestPool")); + + // --- BitcoinRPC --- + dispatch_edit( + &mut cfg, + &entry(ConfigSection::BitcoinRpc, "url"), + "http://localhost:8332", + ) + .unwrap(); + assert_eq!(cfg.bitcoinrpc.url, "http://localhost:8332"); + + dispatch_edit( + &mut cfg, + &entry(ConfigSection::BitcoinRpc, "username"), + "alice", + ) + .unwrap(); + assert_eq!(cfg.bitcoinrpc.username, "alice"); + + dispatch_edit( + &mut cfg, + &entry(ConfigSection::BitcoinRpc, "password"), + "secret", + ) + .unwrap(); + assert_eq!(cfg.bitcoinrpc.password, "secret"); + + // --- Network --- + dispatch_edit( + &mut cfg, + &entry(ConfigSection::Network, "listen_address"), + "0.0.0.0:9333", + ) + .unwrap(); + assert_eq!(cfg.network.listen_address, "0.0.0.0:9333"); + + // dial_peers: empty string → empty vec + dispatch_edit(&mut cfg, &entry(ConfigSection::Network, "dial_peers"), "").unwrap(); + assert!(cfg.network.dial_peers.is_empty()); + + dispatch_edit( + &mut cfg, + &entry(ConfigSection::Network, "max_established_incoming"), + "100", + ) + .unwrap(); + assert_eq!(cfg.network.max_established_incoming, 100); + + dispatch_edit( + &mut cfg, + &entry(ConfigSection::Network, "max_established_outgoing"), + "200", + ) + .unwrap(); + assert_eq!(cfg.network.max_established_outgoing, 200); + + dispatch_edit( + &mut cfg, + &entry(ConfigSection::Network, "max_established_per_peer"), + "3", + ) + .unwrap(); + assert_eq!(cfg.network.max_established_per_peer, 3); + + dispatch_edit( + &mut cfg, + &entry(ConfigSection::Network, "dial_timeout_secs"), + "60", + ) + .unwrap(); + assert_eq!(cfg.network.dial_timeout_secs, 60); + + dispatch_edit( + &mut cfg, + &entry(ConfigSection::Network, "max_requests_per_second"), + "10", + ) + .unwrap(); + assert_eq!(cfg.network.max_requests_per_second, 10); + + // --- Store --- + dispatch_edit( + &mut cfg, + &entry(ConfigSection::Store, "path"), + "/data/store", + ) + .unwrap(); + assert_eq!(cfg.store.path, "/data/store"); + + dispatch_edit( + &mut cfg, + &entry(ConfigSection::Store, "background_task_frequency_hours"), + "2", + ) + .unwrap(); + assert_eq!(cfg.store.background_task_frequency_hours, 2); + + dispatch_edit( + &mut cfg, + &entry(ConfigSection::Store, "pplns_ttl_days"), + "14", + ) + .unwrap(); + assert_eq!(cfg.store.pplns_ttl_days, 14); + + // --- Logging --- + dispatch_edit( + &mut cfg, + &entry(ConfigSection::Logging, "file"), + "/var/log/p2pool.log", + ) + .unwrap(); + assert_eq!(cfg.logging.file.as_deref(), Some("/var/log/p2pool.log")); + dispatch_edit(&mut cfg, &entry(ConfigSection::Logging, "file"), "").unwrap(); + assert!(cfg.logging.file.is_none()); + + dispatch_edit(&mut cfg, &entry(ConfigSection::Logging, "level"), "debug").unwrap(); + assert_eq!(cfg.logging.level, "debug"); + + dispatch_edit( + &mut cfg, + &entry(ConfigSection::Logging, "stats_dir"), + "/stats", + ) + .unwrap(); + assert_eq!(cfg.logging.stats_dir, "/stats"); + + // console: clear path + dispatch_edit(&mut cfg, &entry(ConfigSection::Logging, "console"), "").unwrap(); + assert!(cfg.logging.console.is_none()); + + // --- API --- + dispatch_edit(&mut cfg, &entry(ConfigSection::Api, "hostname"), "0.0.0.0").unwrap(); + assert_eq!(cfg.api.hostname, "0.0.0.0"); + + dispatch_edit(&mut cfg, &entry(ConfigSection::Api, "port"), "8080").unwrap(); + assert_eq!(cfg.api.port, 8080); + + // auth_user: set and clear + dispatch_edit(&mut cfg, &entry(ConfigSection::Api, "auth_user"), "admin").unwrap(); + assert_eq!(cfg.api.auth_user.as_deref(), Some("admin")); + dispatch_edit(&mut cfg, &entry(ConfigSection::Api, "auth_user"), "").unwrap(); + assert!(cfg.api.auth_user.is_none()); + + // auth_token: set and clear + dispatch_edit(&mut cfg, &entry(ConfigSection::Api, "auth_token"), "tok123").unwrap(); + assert_eq!(cfg.api.auth_token.as_deref(), Some("tok123")); + dispatch_edit(&mut cfg, &entry(ConfigSection::Api, "auth_token"), "").unwrap(); + assert!(cfg.api.auth_token.is_none()); + + // auth_password: set and clear + dispatch_edit( + &mut cfg, + &entry(ConfigSection::Api, "auth_password"), + "pass", + ) + .unwrap(); + assert_eq!(cfg.api.auth_password.as_deref(), Some("pass")); + dispatch_edit(&mut cfg, &entry(ConfigSection::Api, "auth_password"), "").unwrap(); + assert!(cfg.api.auth_password.is_none()); + } } From 43811bc6ef09b5cfb88874fc42b650ff02206a61 Mon Sep 17 00:00:00 2001 From: Raunak Kumar Date: Sun, 16 Aug 2026 11:23:33 +0000 Subject: [PATCH 5/6] test: Add test cases to increase test coverage --- src/components/bitcoin_status_view.rs | 578 +++++++++++++++++++++++++- 1 file changed, 575 insertions(+), 3 deletions(-) diff --git a/src/components/bitcoin_status_view.rs b/src/components/bitcoin_status_view.rs index c58f2bf..6374d59 100644 --- a/src/components/bitcoin_status_view.rs +++ b/src/components/bitcoin_status_view.rs @@ -453,15 +453,19 @@ impl Default for BitcoinStatusView { #[cfg(test)] mod tests { use super::*; - use crate::app::App; + use crate::app::{App, AppAction, BitcoinLogInputMode, ExplorerTrigger}; use crate::components::bitcoin_client::BitcoinChainInfo; + use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use ratatui::{Terminal, backend::TestBackend, prelude::Rect}; use std::path::PathBuf; + const LOGS_TAB: usize = 2; + const PEERS_TAB: usize = 3; + fn render_view(app: &App) -> String { - let backend = TestBackend::new(80, 25); + let backend = TestBackend::new(100, 25); let mut terminal = Terminal::new(backend).unwrap(); - let area = Rect::new(0, 0, 80, 25); + let area = Rect::new(0, 0, 100, 25); terminal .draw(|f| BitcoinStatusView::render(f, app, area)) @@ -476,6 +480,574 @@ mod tests { .collect() } + fn key(code: KeyCode) -> KeyEvent { + KeyEvent::new(code, KeyModifiers::empty()) + } + + fn modified_key(code: KeyCode, modifiers: KeyModifiers) -> KeyEvent { + KeyEvent::new(code, modifiers) + } + + fn assert_none(action: AppAction) { + assert!(matches!(action, AppAction::None)); + } + + fn chain_info_with_peers(peer_addresses: &[&str]) -> BitcoinChainInfo { + 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(peer_addresses.len() as u64), + connected_peer_addresses: peer_addresses + .iter() + .map(|address| (*address).to_string()) + .collect(), + } + } + + #[test] + fn new_and_default_construct_status_view() { + let view = BitcoinStatusView::new(); + let default_view = BitcoinStatusView::default(); + + assert_eq!(format!("{view:?}"), "BitcoinStatusView"); + assert_eq!(format!("{default_view:?}"), "BitcoinStatusView"); + } + + #[test] + fn render_dispatches_system_tab() { + let mut app = App::new(); + app.bitcoin_status_tab = 1; + + let output = render_view(&app); + + assert!(output.contains("System")); + assert!(!output.contains("Loading Bitcoin chain info")); + assert!(!output.contains("debug.log")); + } + + #[test] + fn render_with_unknown_tab_only_renders_tabs() { + let mut app = App::new(); + app.bitcoin_status_tab = 99; + + let output = render_view(&app); + + assert!(output.contains("Chain Info")); + assert!(output.contains("System")); + assert!(output.contains("Logs")); + assert!(output.contains("Peers")); + assert!(!output.contains("Loading Bitcoin chain info")); + assert!(!output.contains("Bitcoin Core Logs")); + assert!(!output.contains("Loading Bitcoin peer info")); + } + + #[test] + fn log_shortcuts_start_input_modes_with_existing_values() { + let mut app = App::new(); + let log_path = PathBuf::from("bitcoin").join("debug.log"); + let data_dir = PathBuf::from("bitcoin-data"); + let log_path_text = log_path.to_string_lossy().into_owned(); + let data_dir_text = data_dir.to_string_lossy().into_owned(); + app.bitcoin_log_filter = "UpdateTip".to_string(); + app.bitcoin_log_path = Some(log_path); + app.settings.bitcoin_core_data_dir = Some(data_dir); + + assert_none(BitcoinStatusView::handle_logs_input( + &mut app, + key(KeyCode::Char('/')), + )); + assert_eq!( + app.bitcoin_log_input_mode, + Some(BitcoinLogInputMode::Search) + ); + assert_eq!(app.bitcoin_log_input, "UpdateTip"); + + app.bitcoin_log_input_mode = None; + app.bitcoin_log_input.clear(); + + assert_none(BitcoinStatusView::handle_logs_input( + &mut app, + key(KeyCode::Char('p')), + )); + assert_eq!( + app.bitcoin_log_input_mode, + Some(BitcoinLogInputMode::LogFilePath) + ); + assert_eq!(app.bitcoin_log_input, log_path_text); + + app.bitcoin_log_input_mode = None; + app.bitcoin_log_input.clear(); + + assert_none(BitcoinStatusView::handle_logs_input( + &mut app, + key(KeyCode::Char('g')), + )); + assert_eq!( + app.bitcoin_log_input_mode, + Some(BitcoinLogInputMode::DataDirPath) + ); + assert_eq!(app.bitcoin_log_input, data_dir_text); + } + + #[test] + fn log_path_shortcuts_use_empty_input_when_paths_are_unconfigured() { + let mut app = App::new(); + + assert_none(BitcoinStatusView::handle_logs_input( + &mut app, + key(KeyCode::Char('p')), + )); + assert_eq!( + app.bitcoin_log_input_mode, + Some(BitcoinLogInputMode::LogFilePath) + ); + assert!(app.bitcoin_log_input.is_empty()); + + app.bitcoin_log_input_mode = None; + + assert_none(BitcoinStatusView::handle_logs_input( + &mut app, + key(KeyCode::Char('d')), + )); + assert_eq!( + app.bitcoin_log_input_mode, + Some(BitcoinLogInputMode::DataDirPath) + ); + assert!(app.bitcoin_log_input.is_empty()); + } + + #[test] + fn log_shortcuts_return_app_actions() { + let mut app = App::new(); + + let action = BitcoinStatusView::handle_logs_input(&mut app, key(KeyCode::Char('b'))); + assert!(matches!( + action, + AppAction::OpenExplorer(ExplorerTrigger::BitcoinCoreLogFile) + )); + + let action = BitcoinStatusView::handle_logs_input(&mut app, key(KeyCode::Char('o'))); + assert!(matches!( + action, + AppAction::OpenExplorer(ExplorerTrigger::BitcoinCoreDataDir) + )); + + let action = BitcoinStatusView::handle_logs_input(&mut app, key(KeyCode::Char('r'))); + assert!(matches!(action, AppAction::RefreshBitcoinLogs)); + + let action = BitcoinStatusView::handle_logs_input(&mut app, key(KeyCode::Char('a'))); + assert!(matches!(action, AppAction::ToggleBitcoinLogAutoScroll)); + + let action = BitcoinStatusView::handle_logs_input(&mut app, key(KeyCode::Char('c'))); + assert!(matches!(action, AppAction::CopyBitcoinLogs)); + } + + #[test] + fn escape_clears_existing_log_filter_and_scroll() { + let mut app = App::new(); + app.bitcoin_log_filter = "error".to_string(); + app.bitcoin_log_scroll = 12; + + assert_none(BitcoinStatusView::handle_logs_input( + &mut app, + key(KeyCode::Esc), + )); + + assert!(app.bitcoin_log_filter.is_empty()); + assert_eq!(app.bitcoin_log_scroll, 0); + } + + #[test] + fn unhandled_log_key_is_noop() { + let mut app = App::new(); + app.bitcoin_log_scroll = 3; + + assert_none(BitcoinStatusView::handle_logs_input( + &mut app, + key(KeyCode::F(1)), + )); + + assert_eq!(app.bitcoin_log_scroll, 3); + assert!(app.bitcoin_log_input_mode.is_none()); + } + + #[test] + fn log_scroll_keys_update_scroll_and_auto_scroll() { + let mut app = App::new(); + app.bitcoin_log_lines = vec![ + "first".to_string(), + "second".to_string(), + "third".to_string(), + ]; + app.bitcoin_log_scroll = 1; + app.bitcoin_log_auto_scroll = false; + + assert_none(BitcoinStatusView::handle_logs_input( + &mut app, + key(KeyCode::Up), + )); + assert_eq!(app.bitcoin_log_scroll, 0); + assert!(app.bitcoin_log_auto_scroll); + + assert_none(BitcoinStatusView::handle_logs_input( + &mut app, + key(KeyCode::Down), + )); + assert_eq!(app.bitcoin_log_scroll, 1); + assert!(!app.bitcoin_log_auto_scroll); + + assert_none(BitcoinStatusView::handle_logs_input( + &mut app, + key(KeyCode::PageDown), + )); + assert_eq!(app.bitcoin_log_scroll, 2); + assert!(!app.bitcoin_log_auto_scroll); + + assert_none(BitcoinStatusView::handle_logs_input( + &mut app, + key(KeyCode::PageUp), + )); + assert_eq!(app.bitcoin_log_scroll, 0); + assert!(app.bitcoin_log_auto_scroll); + + app.bitcoin_log_scroll = 1; + app.bitcoin_log_auto_scroll = false; + assert_none(BitcoinStatusView::handle_logs_input( + &mut app, + key(KeyCode::Home), + )); + assert_eq!(app.bitcoin_log_scroll, 0); + assert!(app.bitcoin_log_auto_scroll); + + assert_none(BitcoinStatusView::handle_logs_input( + &mut app, + key(KeyCode::End), + )); + assert_eq!(app.bitcoin_log_scroll, 2); + assert!(!app.bitcoin_log_auto_scroll); + } + + #[test] + fn log_scroll_helpers_clamp_to_available_lines_and_u16_max() { + let mut app = App::new(); + app.bitcoin_log_lines = vec!["first".to_string(), "second".to_string()]; + app.bitcoin_log_scroll = 1; + + assert_eq!(BitcoinStatusView::max_scroll(&app), 1); + assert_eq!(BitcoinStatusView::next_scroll(&app, 10), 1); + + app.bitcoin_log_lines = (0..70_000).map(|index| format!("line {index}")).collect(); + app.bitcoin_log_scroll = u16::MAX - 1; + + assert_eq!(BitcoinStatusView::max_scroll(&app), u16::MAX); + assert_eq!(BitcoinStatusView::next_scroll(&app, 10), u16::MAX); + } + + #[test] + fn search_input_enter_applies_trimmed_filter_and_resets_scroll() { + let mut app = App::new(); + app.bitcoin_log_input_mode = Some(BitcoinLogInputMode::Search); + app.bitcoin_log_input = " UpdateTip ".to_string(); + app.bitcoin_log_scroll = 7; + + assert_none(BitcoinStatusView::handle_logs_input( + &mut app, + key(KeyCode::Enter), + )); + + assert_eq!(app.bitcoin_log_filter, "UpdateTip"); + assert_eq!(app.bitcoin_log_scroll, 0); + assert!(app.bitcoin_log_input.is_empty()); + assert!(app.bitcoin_log_input_mode.is_none()); + } + + #[test] + fn log_file_input_enter_rejects_empty_path() { + let mut app = App::new(); + app.bitcoin_log_input_mode = Some(BitcoinLogInputMode::LogFilePath); + app.bitcoin_log_input = " ".to_string(); + + assert_none(BitcoinStatusView::handle_logs_input( + &mut app, + key(KeyCode::Enter), + )); + + assert_eq!(app.bitcoin_log_status, "Log file path cannot be empty."); + assert!(app.bitcoin_log_input.is_empty()); + assert!(app.bitcoin_log_input_mode.is_none()); + } + + #[test] + fn log_file_input_enter_returns_set_log_file_action() { + let mut app = App::new(); + app.bitcoin_log_input_mode = Some(BitcoinLogInputMode::LogFilePath); + app.bitcoin_log_input = "bitcoin/debug.log".to_string(); + + let action = BitcoinStatusView::handle_logs_input(&mut app, key(KeyCode::Enter)); + + assert!(matches!( + action, + AppAction::SetBitcoinLogFile(ref path) + if path == &PathBuf::from("bitcoin/debug.log") + )); + assert!(app.bitcoin_log_input.is_empty()); + assert!(app.bitcoin_log_input_mode.is_none()); + } + + #[test] + fn data_dir_input_enter_rejects_empty_path() { + let mut app = App::new(); + app.bitcoin_log_input_mode = Some(BitcoinLogInputMode::DataDirPath); + app.bitcoin_log_input = " ".to_string(); + + assert_none(BitcoinStatusView::handle_logs_input( + &mut app, + key(KeyCode::Enter), + )); + + assert_eq!( + app.bitcoin_log_status, + "Bitcoin Core data directory cannot be empty." + ); + assert!(app.bitcoin_log_input.is_empty()); + assert!(app.bitcoin_log_input_mode.is_none()); + } + + #[test] + fn data_dir_input_enter_returns_set_data_dir_action() { + let mut app = App::new(); + app.bitcoin_log_input_mode = Some(BitcoinLogInputMode::DataDirPath); + app.bitcoin_log_input = "bitcoin-data".to_string(); + + let action = BitcoinStatusView::handle_logs_input(&mut app, key(KeyCode::Enter)); + + assert!(matches!( + action, + AppAction::SetBitcoinLogDataDir(ref path) + if path == &PathBuf::from("bitcoin-data") + )); + assert!(app.bitcoin_log_input.is_empty()); + assert!(app.bitcoin_log_input_mode.is_none()); + } + + #[test] + fn text_input_editing_keys_update_or_cancel_current_prompt() { + let mut app = App::new(); + app.bitcoin_log_input_mode = Some(BitcoinLogInputMode::Search); + app.bitcoin_log_input = "err".to_string(); + + assert_none(BitcoinStatusView::handle_logs_input( + &mut app, + key(KeyCode::Char('o')), + )); + assert_eq!(app.bitcoin_log_input, "erro"); + + assert_none(BitcoinStatusView::handle_logs_input( + &mut app, + key(KeyCode::Backspace), + )); + assert_eq!(app.bitcoin_log_input, "err"); + + assert_none(BitcoinStatusView::handle_logs_input( + &mut app, + modified_key(KeyCode::Char('x'), KeyModifiers::CONTROL), + )); + assert_eq!(app.bitcoin_log_input, "err"); + + assert_none(BitcoinStatusView::handle_logs_input( + &mut app, + key(KeyCode::F(1)), + )); + assert_eq!(app.bitcoin_log_input, "err"); + + assert_none(BitcoinStatusView::handle_logs_input( + &mut app, + key(KeyCode::Esc), + )); + assert!(app.bitcoin_log_input.is_empty()); + assert!(app.bitcoin_log_input_mode.is_none()); + } + + #[test] + fn input_title_matches_active_prompt_mode() { + assert_eq!( + BitcoinStatusView::input_title(Some(BitcoinLogInputMode::Search)), + " Search/filter logs " + ); + assert_eq!( + BitcoinStatusView::input_title(Some(BitcoinLogInputMode::LogFilePath)), + " Bitcoin Core debug.log path " + ); + assert_eq!( + BitcoinStatusView::input_title(Some(BitcoinLogInputMode::DataDirPath)), + " Bitcoin Core data directory " + ); + assert_eq!(BitcoinStatusView::input_title(None), " Input "); + } + + #[test] + fn render_logs_shows_summary_and_no_path_message() { + let mut app = App::new(); + app.bitcoin_status_tab = LOGS_TAB; + app.bitcoin_log_status = "Waiting for logs.".to_string(); + + let output = render_view(&app); + + assert!(output.contains("Bitcoin Core Logs")); + assert!(output.contains("(not configured)")); + assert!(output.contains("Waiting for logs.")); + assert!(output.contains("Filter: (none)")); + assert!(output.contains("Auto-scroll: on")); + assert!(output.contains("Lines: 0/0")); + assert!(output.contains("[b] Browse log")); + assert!(output.contains("[c] Copy")); + assert!(output.contains( + "No Bitcoin Core debug.log found. Choose a log file or Bitcoin data directory." + )); + } + + #[test] + fn render_logs_shows_empty_snapshot_status_when_log_file_has_no_lines() { + let mut app = App::new(); + app.bitcoin_status_tab = LOGS_TAB; + app.bitcoin_log_path = Some(PathBuf::from("debug.log")); + app.bitcoin_log_status = "debug.log is empty.".to_string(); + + let output = render_view(&app); + + assert!(output.contains("debug.log")); + assert!(output.contains("debug.log is empty.")); + assert!(!output.contains("No Bitcoin Core debug.log found")); + } + + #[test] + fn render_logs_applies_filter_counts_and_shows_matching_lines() { + let mut app = App::new(); + app.bitcoin_status_tab = LOGS_TAB; + app.bitcoin_log_path = Some(PathBuf::from("debug.log")); + app.bitcoin_log_status = "Showing recent lines.".to_string(); + app.bitcoin_log_auto_scroll = false; + app.bitcoin_log_filter = "updatetip".to_string(); + app.bitcoin_log_lines = vec![ + "2026-01-01 UpdateTip: new best block".to_string(), + "2026-01-01 socket connected".to_string(), + ]; + + let output = render_view(&app); + + assert!(output.contains("Filter: updatetip")); + assert!(output.contains("Auto-scroll: off")); + assert!(output.contains("Lines: 1/2")); + assert!(output.contains("UpdateTip: new best block")); + assert!(!output.contains("socket connected")); + } + + #[test] + fn render_logs_shows_empty_filter_state_when_no_lines_match() { + let mut app = App::new(); + app.bitcoin_status_tab = LOGS_TAB; + app.bitcoin_log_path = Some(PathBuf::from("debug.log")); + app.bitcoin_log_filter = "mempool".to_string(); + app.bitcoin_log_lines = vec!["2026-01-01 UpdateTip: new best block".to_string()]; + + let output = render_view(&app); + + assert!(output.contains("Lines: 0/1")); + assert!(output.contains("No log entries match the current filter.")); + } + + #[test] + fn render_logs_draws_active_input_prompt() { + let mut app = App::new(); + app.bitcoin_status_tab = LOGS_TAB; + app.bitcoin_log_path = Some(PathBuf::from("debug.log")); + app.bitcoin_log_input_mode = Some(BitcoinLogInputMode::DataDirPath); + app.bitcoin_log_input = "bitcoin-data".to_string(); + + let output = render_view(&app); + + assert!(output.contains("Bitcoin Core data directory")); + assert!(output.contains("bitcoin-data")); + } + + #[test] + fn render_peers_prompts_when_no_bitcoin_conf_is_selected() { + let mut app = App::new(); + app.bitcoin_status_tab = PEERS_TAB; + + let output = render_view(&app); + + assert!(output.contains("Select a bitcoin.conf file to load Bitcoin Core peer info.")); + assert!(!output.contains("Loading Bitcoin peer info")); + } + + #[test] + fn render_peers_lists_connected_peer_addresses() { + let mut app = App::new(); + app.bitcoin_status_tab = PEERS_TAB; + app.bitcoin_conf_path = Some(PathBuf::from("bitcoin.conf")); + app.bitcoin_chain_info = Some(chain_info_with_peers(&["127.0.0.1:8333", "10.0.0.2:18333"])); + + let output = render_view(&app); + + assert!(output.contains("Connected Peers: 2")); + assert!(output.contains("Peer Addresses:")); + assert!(output.contains("* 127.0.0.1:8333")); + assert!(output.contains("* 10.0.0.2:18333")); + } + + #[test] + fn render_peers_shows_none_when_chain_info_has_no_peer_addresses() { + let mut app = App::new(); + app.bitcoin_status_tab = PEERS_TAB; + app.bitcoin_conf_path = Some(PathBuf::from("bitcoin.conf")); + app.bitcoin_chain_info = Some(chain_info_with_peers(&[])); + + let output = render_view(&app); + + assert!(output.contains("Connected Peers: 0")); + assert!(output.contains("Peer Addresses:")); + assert!(output.contains("None")); + } + + #[test] + fn render_peers_shows_loading_and_error_states() { + let mut loading_app = App::new(); + loading_app.bitcoin_status_tab = PEERS_TAB; + loading_app.bitcoin_conf_path = Some(PathBuf::from("bitcoin.conf")); + + let loading_output = render_view(&loading_app); + + assert!(loading_output.contains("Loading Bitcoin peer info...")); + + let mut error_app = App::new(); + error_app.bitcoin_status_tab = PEERS_TAB; + error_app.bitcoin_conf_path = Some(PathBuf::from("bitcoin.conf")); + error_app.bitcoin_chain_info_error = Some("RPC offline".to_string()); + + let error_output = render_view(&error_app); + + assert!(error_output.contains("Failed to fetch Bitcoin peer info: RPC offline")); + assert!(!error_output.contains("Loading Bitcoin peer info")); + } + + #[test] + fn optional_formatters_handle_all_display_cases() { + assert_eq!(BitcoinStatusView::format_verification_progress(None), "-"); + assert_eq!( + BitcoinStatusView::format_verification_progress(Some(0.9999)), + "99.99%" + ); + assert_eq!(BitcoinStatusView::format_optional_bool(Some(true)), "yes"); + assert_eq!(BitcoinStatusView::format_optional_bool(Some(false)), "no"); + assert_eq!(BitcoinStatusView::format_optional_bool(None), "-"); + assert_eq!(BitcoinStatusView::format_optional_u64(Some(42)), "42"); + assert_eq!(BitcoinStatusView::format_optional_u64(None), "-"); + } + #[test] fn renders_prompt_when_no_bitcoin_conf_is_selected() { let app = App::new(); From 43143d256982edbf9518dea2cf1aeb3bac4f5697 Mon Sep 17 00:00:00 2001 From: Raunak Kumar Date: Fri, 21 Aug 2026 16:31:00 +0000 Subject: [PATCH 6/6] fix: address Copilot review comments --- src/app.rs | 36 ++++++++++++++ src/bitcoin_logs.rs | 81 +++++++++++++++++++++++++++++--- src/components/bitcoin_client.rs | 74 ++++++++++++++++++++++++++++- 3 files changed, 183 insertions(+), 8 deletions(-) diff --git a/src/app.rs b/src/app.rs index a6e8f18..4f95105 100644 --- a/src/app.rs +++ b/src/app.rs @@ -23,6 +23,7 @@ use std::time::{Duration, Instant}; use tokio::sync::mpsc; const BITCOIN_LOG_REFRESH_INTERVAL: Duration = Duration::from_secs(1); +const OSC52_CLIPBOARD_MAX_BYTES: usize = 32 * 1024; /// Sidebar items labels pub const SIDEBAR_ITEMS: &[(&str, CurrentScreen)] = &[ @@ -428,6 +429,11 @@ impl App { self.bitcoin_log_status = "No Bitcoin Core log lines to copy.".to_string(); return; } + if text.as_bytes().len() > OSC52_CLIPBOARD_MAX_BYTES { + self.bitcoin_log_status = + "Selection is too large to copy. Narrow the filter.".to_string(); + return; + } match copy_to_terminal_clipboard(&text) { Ok(()) => { @@ -604,6 +610,13 @@ impl App { } fn copy_to_terminal_clipboard(text: &str) -> Result<()> { + if text.as_bytes().len() > OSC52_CLIPBOARD_MAX_BYTES { + anyhow::bail!( + "clipboard payload exceeds {} bytes", + OSC52_CLIPBOARD_MAX_BYTES + ); + } + let encoded = general_purpose::STANDARD.encode(text.as_bytes()); let sequence = format!("\x1b]52;c;{encoded}\x07"); let mut stdout = std::io::stdout(); @@ -1047,6 +1060,29 @@ mod tests { assert_eq!(app.bitcoin_log_status, "No Bitcoin Core log lines to copy."); } + #[test] + fn copy_filtered_bitcoin_logs_successful_copy_sets_status() { + let mut app = App::new(); + app.bitcoin_log_lines = vec!["first".to_string(), "second".to_string()]; + + app.copy_filtered_bitcoin_logs(); + + assert_eq!(app.bitcoin_log_status, "Copied 2 log lines."); + } + + #[test] + fn copy_filtered_bitcoin_logs_rejects_oversized_selection() { + let mut app = App::new(); + app.bitcoin_log_lines = vec!["x".repeat(OSC52_CLIPBOARD_MAX_BYTES + 1)]; + + app.copy_filtered_bitcoin_logs(); + + assert_eq!( + app.bitcoin_log_status, + "Selection is too large to copy. Narrow the filter." + ); + } + // peer info polling #[test] diff --git a/src/bitcoin_logs.rs b/src/bitcoin_logs.rs index c91b9fb..09110e7 100644 --- a/src/bitcoin_logs.rs +++ b/src/bitcoin_logs.rs @@ -10,6 +10,7 @@ use std::io::{Read, Seek, SeekFrom}; use std::path::{Path, PathBuf}; pub const DEFAULT_MAX_LOG_LINES: usize = 300; +pub const MAX_LOG_READ_BYTES: u64 = 512 * 1024; const READ_CHUNK_SIZE: u64 = 8 * 1024; @@ -58,15 +59,27 @@ pub fn read_recent_log_lines(path: &Path, max_lines: usize) -> Result> = Vec::new(); + let mut partial_prefix = false; - while position > 0 && newline_count <= max_lines { - let read_size = READ_CHUNK_SIZE.min(position); - position -= read_size; + if file_len > MAX_LOG_READ_BYTES { + let mut prev_byte = [0u8; 1]; + file.seek(SeekFrom::Start(window_start.saturating_sub(1))) + .with_context(|| format!("could not seek {}", path.display()))?; + file.read_exact(&mut prev_byte) + .with_context(|| format!("could not read {}", path.display()))?; + partial_prefix = prev_byte[0] != b'\n'; + } - file.seek(SeekFrom::Start(position)) + while position > window_start && newline_count < max_lines { + let remaining_bytes = position.saturating_sub(window_start); + let read_size = READ_CHUNK_SIZE.min(remaining_bytes); + let chunk_start = position - read_size; + + file.seek(SeekFrom::Start(chunk_start)) .with_context(|| format!("could not seek {}", path.display()))?; let mut chunk = vec![0u8; read_size as usize]; @@ -75,6 +88,7 @@ pub fn read_recent_log_lines(path: &Path, max_lines: usize) -> Result Result = text.lines().map(ToOwned::to_owned).collect(); + let mut lines: Vec = String::from_utf8_lossy(&bytes) + .lines() + .map(ToOwned::to_owned) + .collect(); + + if partial_prefix && !lines.is_empty() { + lines.remove(0); + } if lines.len() > max_lines { lines = lines.split_off(lines.len() - max_lines); @@ -310,6 +330,55 @@ mod tests { ); } + #[test] + fn reads_all_available_lines_when_fewer_than_requested() { + let dir = tempdir().unwrap(); + let path = dir.path().join("debug.log"); + std::fs::write(&path, "one\ntwo\nthree\n").unwrap(); + + let lines = read_recent_log_lines(&path, 5).unwrap(); + + assert_eq!( + lines, + vec!["three".to_string(), "two".to_string(), "one".to_string(),] + ); + } + + #[test] + fn reads_exact_boundary_line_counts() { + let dir = tempdir().unwrap(); + let path = dir.path().join("debug.log"); + std::fs::write(&path, "a\nb\nc\nd\n").unwrap(); + + let lines = read_recent_log_lines(&path, 2).unwrap(); + + assert_eq!(lines, vec!["d".to_string(), "c".to_string()]); + } + + #[test] + fn reads_tail_from_end_for_large_file() { + let dir = tempdir().unwrap(); + let path = dir.path().join("debug.log"); + let long_prefix = "x".repeat(MAX_LOG_READ_BYTES as usize + 1024); + std::fs::write(&path, format!("{long_prefix}\nalpha\nbeta\ngamma\n")).unwrap(); + + let lines = read_recent_log_lines(&path, 3).unwrap(); + + assert_eq!( + lines, + vec!["gamma".to_string(), "beta".to_string(), "alpha".to_string()] + ); + } + + #[test] + fn returns_empty_for_empty_file() { + let dir = tempdir().unwrap(); + let path = dir.path().join("debug.log"); + std::fs::write(&path, "").unwrap(); + + assert!(read_recent_log_lines(&path, 5).unwrap().is_empty()); + } + #[test] fn resolves_direct_log_path_from_settings() { let settings = Settings { diff --git a/src/components/bitcoin_client.rs b/src/components/bitcoin_client.rs index 05eae1c..d449c3a 100644 --- a/src/components/bitcoin_client.rs +++ b/src/components/bitcoin_client.rs @@ -7,7 +7,10 @@ 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}; +use std::{ + path::{Path, PathBuf}, + time::Duration, +}; const REQUEST_TIMEOUT_SECONDS: u64 = 10; @@ -290,7 +293,49 @@ fn data_dir(entries: &[ConfigEntry], network: BitcoinNetwork) -> PathBuf { } fn default_data_dir() -> Option { - std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".bitcoin")) + let home = std::env::var_os("HOME"); + + #[cfg(target_os = "windows")] + { + return std::env::var_os("APPDATA") + .map(PathBuf::from) + .or_else(|| { + std::env::var_os("USERPROFILE") + .map(|profile| PathBuf::from(profile).join("AppData").join("Roaming")) + }) + .map(|dir| dir.join("Bitcoin")) + .or_else(|| home.map(|home| default_data_dir_for_home(Path::new(&home)))); + } + + #[cfg(target_os = "macos")] + { + return home.map(|home| default_data_dir_for_home(Path::new(&home))); + } + + #[cfg(not(any(target_os = "windows", target_os = "macos")))] + { + return home.map(|home| default_data_dir_for_home(Path::new(&home))); + } +} + +fn default_data_dir_for_home(home: &std::path::Path) -> PathBuf { + #[cfg(target_os = "windows")] + { + let appdata = std::env::var_os("APPDATA") + .map(PathBuf::from) + .unwrap_or_else(|| home.join("AppData").join("Roaming")); + return appdata.join("Bitcoin"); + } + + #[cfg(target_os = "macos")] + { + return home.join("Library/Application Support/Bitcoin"); + } + + #[cfg(not(any(target_os = "windows", target_os = "macos")))] + { + return home.join(".bitcoin"); + } } fn display_network(chain: &str) -> &str { @@ -319,6 +364,31 @@ mod tests { } } + #[test] + fn default_data_dir_for_home_uses_platform_defaults() { + let expected = if cfg!(target_os = "windows") { + PathBuf::from("C:/Users/alice/AppData/Roaming/Bitcoin") + } else if cfg!(target_os = "macos") { + PathBuf::from("/Users/alice/Library/Application Support/Bitcoin") + } else { + PathBuf::from("/Users/alice/.bitcoin") + }; + + let actual = default_data_dir_for_home(Path::new("/Users/alice")); + + assert_eq!(actual, expected); + } + + #[test] + fn cookie_path_uses_absolute_data_dir() { + let entries = vec![entry("datadir", "/tmp/bitcoin")]; + + assert_eq!( + cookie_path(&entries, BitcoinNetwork::Mainnet), + PathBuf::from("/tmp/bitcoin/.cookie") + ); + } + #[test] fn builds_default_mainnet_endpoint() { let client = BitcoinClient::from_config_entries(&[]);