diff --git a/Cargo.lock b/Cargo.lock index a45e3da..adf9a54 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -34,12 +34,6 @@ version = "1.0.100" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" -[[package]] -name = "arraydeque" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d902e3d592a523def97af8f317b08ce16b7ab854c1985a0c671e6f15cebc236" - [[package]] name = "arrayvec" version = "0.7.6" @@ -212,9 +206,6 @@ name = "bitflags" version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" -dependencies = [ - "serde_core", -] [[package]] name = "block-buffer" @@ -314,34 +305,14 @@ dependencies = [ "lazy_static", "nom", "pathdiff", - "ron 0.7.1", - "rust-ini 0.18.0", + "ron", + "rust-ini", "serde", "serde_json", "toml 0.5.11", "yaml-rust", ] -[[package]] -name = "config" -version = "0.15.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b30fa8254caad766fc03cb0ccae691e14bf3bd72bfff27f72802ce729551b3d6" -dependencies = [ - "async-trait", - "convert_case 0.6.0", - "json5", - "pathdiff", - "ron 0.12.0", - "rust-ini 0.21.3", - "serde-untagged", - "serde_core", - "serde_json", - "toml 0.9.10+spec-1.1.0", - "winnow", - "yaml-rust2", -] - [[package]] name = "console" version = "0.15.11" @@ -354,35 +325,6 @@ dependencies = [ "windows-sys 0.59.0", ] -[[package]] -name = "const-random" -version = "0.1.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359" -dependencies = [ - "const-random-macro", -] - -[[package]] -name = "const-random-macro" -version = "0.1.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e" -dependencies = [ - "getrandom 0.2.16", - "once_cell", - "tiny-keccak", -] - -[[package]] -name = "convert_case" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec182b0ca2f35d8fc196cf3404988fd8b8c739a4d270ff118a398feb0cbec1ca" -dependencies = [ - "unicode-segmentation", -] - [[package]] name = "convert_case" version = "0.10.0" @@ -454,12 +396,6 @@ dependencies = [ "winapi", ] -[[package]] -name = "crunchy" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" - [[package]] name = "crypto-common" version = "0.1.7" @@ -551,7 +487,7 @@ version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6d286bfdaf75e988b4a78e013ecd79c581e06399ab53fbacd2d916c2f904f30b" dependencies = [ - "convert_case 0.10.0", + "convert_case", "proc-macro2", "quote", "rustc_version", @@ -606,15 +542,6 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0688c2a7f92e427f44895cd63841bff7b29f8d7a1648b9e7e07a4a365b2e1257" -[[package]] -name = "dlv-list" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "442039f5147480ba31067cb00ada1adae6892028e40e45fc5de7b7df6dcc1b5f" -dependencies = [ - "const-random", -] - [[package]] name = "document-features" version = "0.2.12" @@ -651,17 +578,6 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" -[[package]] -name = "erased-serde" -version = "0.4.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89e8918065695684b2b0702da20382d5ae6065cf3327bc2d6436bd49a71ce9f3" -dependencies = [ - "serde", - "serde_core", - "typeid", -] - [[package]] name = "errno" version = "0.3.14" @@ -909,12 +825,6 @@ dependencies = [ "ahash", ] -[[package]] -name = "hashbrown" -version = "0.14.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" - [[package]] name = "hashbrown" version = "0.15.5" @@ -935,15 +845,6 @@ dependencies = [ "foldhash 0.2.0", ] -[[package]] -name = "hashlink" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" -dependencies = [ - "hashbrown 0.15.5", -] - [[package]] name = "heck" version = "0.5.0" @@ -1659,20 +1560,10 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccd746e37177e1711c20dd619a1620f34f5c8b569c53590a72dedd5344d8924a" dependencies = [ - "dlv-list 0.3.0", + "dlv-list", "hashbrown 0.12.3", ] -[[package]] -name = "ordered-multimap" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49203cdcae0030493bad186b28da2fa25645fa276a51b6fec8010d281e02ef79" -dependencies = [ - "dlv-list 0.5.2", - "hashbrown 0.14.5", -] - [[package]] name = "p2poolv2_config" version = "0.10.6" @@ -1680,7 +1571,7 @@ source = "git+https://github.com/p2poolv2/p2poolv2#f448917decf9e12529637ebd37715 dependencies = [ "bitcoin", "bitcoindrpc", - "config 0.13.4", + "config", "serde", ] @@ -1720,7 +1611,6 @@ dependencies = [ "anyhow", "base64 0.22.1", "bitcoin", - "config 0.15.19", "crossterm", "directories", "futures-util", @@ -2247,20 +2137,6 @@ dependencies = [ "serde", ] -[[package]] -name = "ron" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd490c5b18261893f14449cbd28cb9c0b637aebf161cd77900bfdedaff21ec32" -dependencies = [ - "bitflags 2.10.0", - "once_cell", - "serde", - "serde_derive", - "typeid", - "unicode-ident", -] - [[package]] name = "rust-ini" version = "0.18.0" @@ -2268,17 +2144,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6d5f2436026b4f6e79dc829837d467cc7e9a55ee40e750d716713540715a2df" dependencies = [ "cfg-if", - "ordered-multimap 0.4.3", -] - -[[package]] -name = "rust-ini" -version = "0.21.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "796e8d2b6696392a43bea58116b667fb4c29727dc5abd27d6acf338bb4f688c7" -dependencies = [ - "cfg-if", - "ordered-multimap 0.7.3", + "ordered-multimap", ] [[package]] @@ -2446,18 +2312,6 @@ dependencies = [ "serde_derive", ] -[[package]] -name = "serde-untagged" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9faf48a4a2d2693be24c6289dbe26552776eb7737074e6722891fadbe6c5058" -dependencies = [ - "erased-serde", - "serde", - "serde_core", - "typeid", -] - [[package]] name = "serde_core" version = "1.0.228" @@ -2500,15 +2354,6 @@ dependencies = [ "serde", ] -[[package]] -name = "serde_spanned" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8bbf91e5a4d6315eee45e704372590b30e260ee83af6639d64557f51b067776" -dependencies = [ - "serde_core", -] - [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -2902,15 +2747,6 @@ version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" -[[package]] -name = "tiny-keccak" -version = "2.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237" -dependencies = [ - "crunchy", -] - [[package]] name = "tinystr" version = "0.8.2" @@ -3029,24 +2865,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" dependencies = [ "serde", - "serde_spanned 0.6.9", - "toml_datetime 0.6.11", + "serde_spanned", + "toml_datetime", "toml_edit", ] -[[package]] -name = "toml" -version = "0.9.10+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0825052159284a1a8b4d6c0c86cbc801f2da5afd2b225fa548c72f2e74002f48" -dependencies = [ - "serde_core", - "serde_spanned 1.0.4", - "toml_datetime 0.7.5+spec-1.1.0", - "toml_parser", - "winnow", -] - [[package]] name = "toml_datetime" version = "0.6.11" @@ -3056,15 +2879,6 @@ dependencies = [ "serde", ] -[[package]] -name = "toml_datetime" -version = "0.7.5+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" -dependencies = [ - "serde_core", -] - [[package]] name = "toml_edit" version = "0.22.27" @@ -3073,21 +2887,12 @@ checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" dependencies = [ "indexmap", "serde", - "serde_spanned 0.6.9", - "toml_datetime 0.6.11", + "serde_spanned", + "toml_datetime", "toml_write", "winnow", ] -[[package]] -name = "toml_parser" -version = "1.0.6+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a3198b4b0a8e11f09dd03e133c0280504d0801269e9afa46362ffde1cbeebf44" -dependencies = [ - "winnow", -] - [[package]] name = "toml_write" version = "0.1.2" @@ -3226,12 +3031,6 @@ dependencies = [ "utf-8", ] -[[package]] -name = "typeid" -version = "1.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" - [[package]] name = "typenum" version = "1.19.0" @@ -3930,17 +3729,6 @@ dependencies = [ "linked-hash-map", ] -[[package]] -name = "yaml-rust2" -version = "0.10.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2462ea039c445496d8793d052e13787f2b90e750b833afee748e601c17621ed9" -dependencies = [ - "arraydeque", - "encoding_rs", - "hashlink", -] - [[package]] name = "yoke" version = "0.8.1" diff --git a/Cargo.toml b/Cargo.toml index 23c737e..2e0641c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,7 +7,6 @@ license = "AGPLv3" [dependencies] anyhow = "1.0.100" -config = "0.15.19" crossterm = "0.29.0" directories = "6.0.0" ratatui = "0.30.0" diff --git a/config/config.sample.toml b/config/config.sample.toml deleted file mode 100644 index 8d74355..0000000 --- a/config/config.sample.toml +++ /dev/null @@ -1,5 +0,0 @@ -[api] -host = "127.0.0.1" -port = 46884 -auth_user = "p2pool" -auth_pass = "p2pool" diff --git a/config/config.toml b/config/config.toml deleted file mode 100644 index 6ec999a..0000000 --- a/config/config.toml +++ /dev/null @@ -1,7 +0,0 @@ -[api] -base_url = "http://127.0.0.1:46884" -# fallback_base_url = "http://127.0.0.1:46885" -host = "127.0.0.1" -port = 46884 -auth_user = "p2pool" -auth_pass = "p2pool" diff --git a/src/app.rs b/src/app.rs index 1c6569c..da586c1 100644 --- a/src/app.rs +++ b/src/app.rs @@ -2,9 +2,7 @@ // // SPDX-License-Identifier: AGPL-3.0-or-later -use crate::bitcoin_config::ConfigEntry as BitcoinEntry; use crate::components::bitcoin_client::{BitcoinChainInfo, BitcoinClient}; -use crate::components::bitcoin_config_view::BitcoinConfigView; use crate::components::file_explorer::FileExplorer; use crate::components::p2pool_client::{ChainInfo, P2PoolClient, PeerInfo, SharesResponse}; use crate::components::p2pool_config_view::P2PoolConfigView; @@ -20,38 +18,30 @@ use tokio::sync::mpsc; /// Sidebar items labels pub const SIDEBAR_ITEMS: &[(&str, CurrentScreen)] = &[ ("Home", CurrentScreen::Home), - ("Bitcoin Config", CurrentScreen::BitcoinConfig), ("Bitcoin Status", CurrentScreen::BitcoinStatus), ("P2Pool Config", CurrentScreen::P2PoolConfig), ("P2Pool Status", CurrentScreen::P2PoolStatus), - ("LN Config", CurrentScreen::LNConfig), - ("LN Status", CurrentScreen::LNStatus), - ("Shares Market", CurrentScreen::SharesMarket), ("Settings", CurrentScreen::Settings), ]; pub const MAX_SIDEBAR_INDEX: usize = SIDEBAR_ITEMS.len() - 1; /// Tab labels for the Bitcoin Status view -pub const BITCOIN_STATUS_TABS: &[&str] = &["Chain Info", "System", "Logs", "Peers"]; +pub const BITCOIN_STATUS_TABS: &[&str] = &["Chain Info", "Peers"]; pub const MAX_BITCOIN_STATUS_TAB: usize = BITCOIN_STATUS_TABS.len() - 1; /// Tab labels for the P2Pool Status view -pub const P2POOL_STATUS_TABS: &[&str] = &["Chain Info", "Shares", "Peers Info"]; +pub const P2POOL_STATUS_TABS: &[&str] = &["Chain Info", "Shares", "Peers Info", "System"]; pub const MAX_P2POOL_STATUS_TAB: usize = P2POOL_STATUS_TABS.len() - 1; #[derive(Debug, PartialEq, Eq, Clone, Copy)] pub enum CurrentScreen { Home, - BitcoinConfig, BitcoinStatus, P2PoolConfig, P2PoolStatus, - LNConfig, - LNStatus, - SharesMarket, FileExplorer, Settings, } @@ -59,7 +49,6 @@ pub enum CurrentScreen { /// Identifies which screen (and optionally which field) triggered the file explorer. #[derive(Debug, Clone, PartialEq, Eq)] pub enum ExplorerTrigger { - BitcoinConfig, P2PoolConfig, /// The `usize` is the settings field index (0–`FIELD_COUNT - 1`). Settings(usize), @@ -79,14 +68,14 @@ pub enum AppAction { FileSelected(PathBuf), // Closes the explorer without selection CloseModal, - // Commits an edited value: (entry index, new value) - CommitEdit(usize, String), - // Saves bitcoin config to disk - SaveBitcoinConfig, /// Commits an edited p2pool config value: (entry index, new value) CommitP2PoolEdit(usize, String), /// Saves p2pool config to disk SaveP2PoolConfig, + /// P2poolv2 Service + StartP2Pool, + StopP2Pool, + RestartP2Pool, // Open the file explorer to pick a path for a settings field (field index) OpenExplorerForSettings(usize), // Clear a settings field by index, setting it back to None @@ -97,14 +86,11 @@ pub struct App { pub current_screen: CurrentScreen, pub sidebar_index: usize, pub explorer_trigger: Option, - pub bitcoin_conf_path: Option, pub p2pool_conf_path: Option, pub explorer: FileExplorer, - pub bitcoin_config_view: BitcoinConfigView, pub p2pool_config_view: P2PoolConfigView, pub settings_view: SettingsView, pub p2pool_config: Option, - pub bitcoin_data: Vec, pub bitcoin_status_tab: usize, pub bitcoin_chain_info: Option, pub bitcoin_chain_info_error: Option, @@ -120,6 +106,7 @@ pub struct App { pub p2pool_status_tab: usize, pub chain_info: Option, pub p2pool_chain_info_error: Option, + pub p2pool_service_error: Option, pub share_info: Option, pub p2pool_share_info_error: Option, pub peer_info: Option>, @@ -150,29 +137,30 @@ impl App { 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(); + let p2pool_client = P2PoolClient::new(); + let p2pool_websocket_client = p2pool_client.websocket_client(); + App { current_screen: CurrentScreen::Home, sidebar_index: 0, explorer_trigger: None, - bitcoin_conf_path: None, p2pool_conf_path: None, explorer: FileExplorer::new(), - bitcoin_config_view: BitcoinConfigView::new(), p2pool_config_view: P2PoolConfigView::new(), settings_view: SettingsView::new(), p2pool_config: None, - bitcoin_data: Vec::new(), bitcoin_status_tab: 0, bitcoin_chain_info: None, bitcoin_chain_info_error: None, settings: Settings::default(), - p2pool_client: P2PoolClient::new(), - p2pool_websocket_client: P2PoolWebSocketClient::new(), + p2pool_client, + p2pool_websocket_client, home_dir: std::env::var("HOME").unwrap_or_default(), config_dir: crate::settings::config_dir().unwrap_or_default(), p2pool_status_tab: 0, chain_info: None, p2pool_chain_info_error: None, + p2pool_service_error: None, share_info: None, p2pool_share_info_error: None, peer_info: None, @@ -202,6 +190,42 @@ impl App { app } + pub fn set_p2pool_config(&mut self, config: P2PoolConfig) { + self.p2pool_config = Some(config); + self.p2pool_service_error = None; + self.refresh_p2pool_clients_from_config(); + self.clear_p2pool_status_data(); + } + + pub fn clear_p2pool_config(&mut self) { + self.p2pool_config = None; + self.p2pool_service_error = None; + self.p2pool_client = P2PoolClient::new(); + self.p2pool_websocket_client = self.p2pool_client.websocket_client(); + self.clear_p2pool_status_data(); + } + + pub fn refresh_p2pool_clients_from_config(&mut self) { + if let Some(config) = self.p2pool_config.as_ref() { + self.p2pool_client = P2PoolClient::from_p2pool_config(config); + self.p2pool_websocket_client = self.p2pool_client.websocket_client(); + self.p2pool_live_stream_started = false; + } + } + + fn clear_p2pool_status_data(&mut self) { + self.chain_info = None; + self.p2pool_chain_info_error = None; + self.share_info = None; + self.p2pool_share_info_error = None; + self.peer_info = None; + self.p2pool_peer_info_error = None; + self.live_shares.clear(); + self.live_peer_events.clear(); + self.p2pool_live_error = None; + self.p2pool_live_stream_started = false; + } + /// Non-blocking result handler pub fn poll_chain_info(&mut self) { while let Ok(result) = self.chain_info_rx.try_recv() { @@ -316,12 +340,6 @@ impl App { // Logic to switch between sidebar items pub fn toggle_menu(&mut self) { - if self.current_screen == CurrentScreen::BitcoinConfig { - self.bitcoin_config_view.warning_message = None; - self.bitcoin_config_view.save_message = None; - self.bitcoin_config_view.editing = false; - self.bitcoin_config_view.edit_input.clear(); - } if self.current_screen == CurrentScreen::P2PoolConfig { self.p2pool_config_view.warning_message = None; self.p2pool_config_view.save_message = None; @@ -334,6 +352,11 @@ impl App { self.fetch_bitcoin_chain_info(); } if self.current_screen == CurrentScreen::P2PoolStatus { + if self.p2pool_config.is_none() { + self.clear_p2pool_status_data(); + return; + } + let chain_client = self.p2pool_client.clone(); let chain_tx = self.chain_info_tx.clone(); let share_client = self.p2pool_client.clone(); @@ -380,11 +403,11 @@ impl App { self.bitcoin_chain_info = None; self.bitcoin_chain_info_error = None; - if self.bitcoin_conf_path.is_none() { + let Some(config) = self.p2pool_config.as_ref() else { return; - } + }; - let client = BitcoinClient::from_config_entries(&self.bitcoin_data); + let client = BitcoinClient::from_p2pool_config(config); let tx = self.bitcoin_chain_info_tx.clone(); if let Ok(handle) = tokio::runtime::Handle::try_current() { @@ -404,6 +427,9 @@ impl Default for App { #[cfg(test)] mod tests { use super::*; + use futures_util::StreamExt; + use tokio::net::TcpListener; + use tokio_tungstenite::accept_async; #[test] fn poll_bitcoin_chain_info_updates_state_on_success() { @@ -480,9 +506,161 @@ mod tests { } #[test] - fn fetch_bitcoin_chain_info_clears_state_without_configured_bitcoin_conf() { + fn poll_p2pool_results_updates_success_and_failure_state() { + let mut app = App::new(); + app.chain_info_tx + .send(Ok(ChainInfo { + genesis_blockhash: Some("genesis".to_string()), + chain_tip_height: Some(42), + total_work: "work".to_string(), + chain_tip_blockhash: Some("tip".to_string()), + })) + .unwrap(); + app.share_info_tx + .send(Ok(SharesResponse { + from_height: 1, + to_height: 2, + shares: Vec::new(), + })) + .unwrap(); + app.peer_info_tx + .send(Ok(vec![PeerInfo { + peer_id: "peer".to_string(), + status: Some("Connected".to_string()), + }])) + .unwrap(); + + app.poll_chain_info(); + app.poll_share_info(); + app.poll_peer_info(); + + assert_eq!(app.chain_info.as_ref().unwrap().chain_tip_height, Some(42)); + assert_eq!(app.share_info.as_ref().unwrap().to_height, 2); + assert_eq!(app.peer_info.as_ref().unwrap().len(), 1); + + app.chain_info_tx + .send(Err(anyhow::anyhow!("chain failed"))) + .unwrap(); + app.share_info_tx + .send(Err(anyhow::anyhow!("shares failed"))) + .unwrap(); + app.peer_info_tx + .send(Err(anyhow::anyhow!("peers failed"))) + .unwrap(); + + app.poll_chain_info(); + app.poll_share_info(); + app.poll_peer_info(); + + assert!(app.chain_info.is_none()); + assert_eq!(app.p2pool_chain_info_error.as_deref(), Some("chain failed")); + assert!(app.share_info.is_none()); + assert_eq!( + app.p2pool_share_info_error.as_deref(), + Some("shares failed") + ); + assert!(app.peer_info.is_none()); + assert_eq!(app.p2pool_peer_info_error.as_deref(), Some("peers failed")); + } + + #[test] + fn poll_live_events_updates_peers_and_records_share_and_errors() { + let mut app = App::new(); + app.peer_info = Some(vec![PeerInfo { + peer_id: "peer-1".to_string(), + status: Some("Connected".to_string()), + }]); + app.p2pool_live_tx + .send(Ok(LiveP2PoolEvent::Peer(LivePeerEvent { + peer_id: "peer-1".to_string(), + status: "Syncing".to_string(), + }))) + .unwrap(); + app.p2pool_live_tx + .send(Ok(LiveP2PoolEvent::Peer(LivePeerEvent { + peer_id: "peer-2".to_string(), + status: "Connected".to_string(), + }))) + .unwrap(); + app.p2pool_live_tx + .send(Ok(LiveP2PoolEvent::Share(LiveShare { + blockhash: "share".to_string(), + prev_blockhash: "previous".to_string(), + height: 7, + miner_address: "miner".to_string(), + timestamp: 1, + bits: "1d00ffff".to_string(), + uncles: Vec::new(), + }))) + .unwrap(); + + app.poll_live_p2pool_events(); + + assert_eq!( + app.peer_info.as_ref().unwrap()[0].status.as_deref(), + Some("Syncing") + ); + assert_eq!(app.peer_info.as_ref().unwrap().len(), 2); + assert_eq!(app.live_shares.len(), 1); + assert_eq!(app.live_peer_events.len(), 2); + assert!(app.p2pool_live_error.is_none()); + + app.p2pool_live_tx + .send(Ok(LiveP2PoolEvent::Peer(LivePeerEvent { + peer_id: "peer-1".to_string(), + status: "DISCONNECTED".to_string(), + }))) + .unwrap(); + app.p2pool_live_tx + .send(Err(anyhow::anyhow!("stream failed"))) + .unwrap(); + app.p2pool_live_stream_started = true; + + app.poll_live_shares(); + + assert_eq!(app.peer_info.as_ref().unwrap().len(), 1); + assert_eq!(app.peer_info.as_ref().unwrap()[0].peer_id, "peer-2"); + assert_eq!(app.p2pool_live_error.as_deref(), Some("stream failed")); + assert!(!app.p2pool_live_stream_started); + } + + #[test] + fn clear_p2pool_config_resets_clients_and_all_status_data() { + let mut app = App::new(); + app.chain_info = Some(ChainInfo { + genesis_blockhash: None, + chain_tip_height: None, + total_work: "work".to_string(), + chain_tip_blockhash: None, + }); + app.share_info = Some(SharesResponse { + from_height: 1, + to_height: 1, + shares: Vec::new(), + }); + app.peer_info = Some(Vec::new()); + app.live_shares.push(LiveShare { + blockhash: "share".to_string(), + prev_blockhash: "previous".to_string(), + height: 1, + miner_address: "miner".to_string(), + timestamp: 1, + bits: "1d00ffff".to_string(), + uncles: Vec::new(), + }); + app.clear_p2pool_config(); + + assert!(app.p2pool_config.is_none()); + assert!(app.chain_info.is_none()); + assert!(app.share_info.is_none()); + assert!(app.peer_info.is_none()); + assert!(app.live_shares.is_empty()); + assert!(!app.p2pool_live_stream_started); + } + + #[test] + fn fetch_bitcoin_chain_info_clears_state_without_configured_p2pool_config() { let mut app = App::new(); - app.bitcoin_conf_path = None; app.bitcoin_chain_info = Some(BitcoinChainInfo { network: "mainnet".to_string(), block_height: 1, @@ -500,4 +678,39 @@ mod tests { assert!(app.bitcoin_chain_info_error.is_none()); assert!(app.bitcoin_chain_info_rx.try_recv().is_err()); } + + #[tokio::test] + async fn set_p2pool_config_refreshes_websocket_client_from_api_section() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { + let (stream, _) = listener.accept().await.unwrap(); + let websocket = accept_async(stream).await.unwrap(); + let (_, mut read) = websocket.split(); + + let _ = read.next().await.unwrap().unwrap(); + let _ = read.next().await.unwrap().unwrap(); + }); + + let mut config = P2PoolConfig::load(concat!( + env!("CARGO_MANIFEST_DIR"), + "/tests/fixtures/p2pool.toml" + )) + .unwrap(); + config.api.hostname = addr.ip().to_string(); + config.api.port = addr.port(); + + let mut app = App::new(); + app.set_p2pool_config(config); + let (tx, mut rx) = mpsc::unbounded_channel(); + let client = app.p2pool_websocket_client.clone(); + let subscribe_handle = tokio::spawn(async move { client.subscribe_live_events(tx).await }); + + let event = rx.recv().await.unwrap(); + let result = subscribe_handle.await.unwrap(); + server.await.unwrap(); + + assert!(event.is_err()); + assert!(result.is_ok()); + } } diff --git a/src/bitcoin_config.rs b/src/bitcoin_config.rs deleted file mode 100644 index ff8fc9c..0000000 --- a/src/bitcoin_config.rs +++ /dev/null @@ -1,1997 +0,0 @@ -// SPDX-FileCopyrightText: 2024 PDM Authors -// -// SPDX-License-Identifier: AGPL-3.0-or-later - -use anyhow::Result; -use config::{Config, File, FileFormat}; -use std::{ - collections::{HashMap, HashSet}, - path::Path, -}; - -#[allow(dead_code)] -/// Core Config -#[derive(Debug, Clone)] -pub struct Core { - // Data directory and storage - pub datadir: Option, - pub blocksdir: Option, - pub pid: Option, - pub debuglogfile: Option, - pub settings: Option, - pub includeconf: Option, - pub loadblock: Option, - - // Indexing - pub txindex: Option, - pub blockfilterindex: Option, - pub coinstatsindex: Option, - - // Pruning - pub prune: Option, - - // Memory and performance - pub dbcache: Option, - pub maxmempool: Option, - pub maxorphantx: Option, - pub mempoolexpiry: Option, - pub par: Option, - pub blockreconstructionextratxn: Option, - - // Behavior - pub blocksonly: Option, - pub persistmempool: Option, - pub reindex: Option, - pub reindex_chainstate: Option, - pub sysperms: Option, - - // Daemon mode - pub daemon: Option, - pub daemonwait: Option, - - // Notification commands - pub alertnotify: Option, - pub blocknotify: Option, - pub startupnotify: Option, - - // Validation - pub assumevalid: Option, -} - -#[allow(dead_code)] -/// Network Config -#[derive(Debug, Clone)] -pub struct Network { - // Chain selection - pub chain: Option, - pub testnet: Option, - pub regtest: Option, - pub signet: Option, - pub signetchallenge: Option, - pub signetseednode: Option, - - // Listening and binding - pub listen: Option, - pub bind: Option, - pub whitebind: Option, - pub port: Option, - - // Connection limits - pub maxconnections: Option, - pub maxreceivebuffer: Option, - pub maxsendbuffer: Option, - pub maxuploadtarget: Option, - pub timeout: Option, - pub maxtimeadjustment: Option, - pub bantime: Option, - - // Peer discovery - pub discover: Option, - pub dns: Option, - pub dnsseed: Option, - pub fixedseeds: Option, - pub forcednsseed: Option, - pub seednode: Option, - pub addnode: Option, - pub connect: Option, - - // Network selection - pub onlynet: Option, - pub networkactive: Option, - - // Proxy settings - pub proxy: Option, - pub proxyrandomize: Option, - - // Tor settings - pub onion: Option, - pub listenonion: Option, - pub torcontrol: Option, - pub torpassword: Option, - - // I2P settings - pub i2psam: Option, - pub i2pacceptincoming: Option, - - // CJDNS - pub cjdnsreachable: Option, - - // Peer permissions - pub whitelist: Option, - pub peerblockfilters: Option, - pub peerbloomfilters: Option, - pub permitbaremultisig: Option, - - // External IP - pub externalip: Option, - - // UPnP - pub upnp: Option, - - // ASN mapping - pub asmap: Option, -} - -#[allow(dead_code)] -/// RPC Config -#[derive(Debug, Clone)] -pub struct RPC { - // Server enable - pub server: Option, - - // Authentication - pub rpcuser: Option, - pub rpcpassword: Option, - pub rpcauth: Option, - pub rpccookiefile: Option, - - // Connection - pub rpcport: Option, - pub rpcbind: Option, - pub rpcallowip: Option, - - // Performance - pub rpcthreads: Option, - - // Serialization - pub rpcserialversion: Option, - - // Whitelist - pub rpcwhitelist: Option, - pub rpcwhitelistdefault: Option, - - // REST interface - pub rest: Option, -} - -#[allow(dead_code)] -/// Wallet related config -#[derive(Debug, Clone)] -pub struct Wallet { - // Enable/disable - pub disablewallet: Option, - - // Wallet paths - pub wallet: Option, - pub walletdir: Option, - - // Address types - pub addresstype: Option, - pub changetype: Option, - - // Fee settings - pub fallbackfee: Option, - pub discardfee: Option, - pub mintxfee: Option, - pub paytxfee: Option, - pub consolidatefeerate: Option, - pub maxapsfee: Option, - - // Transaction behavior - pub txconfirmtarget: Option, - pub spendzeroconfchange: Option, - pub walletrbf: Option, - pub avoidpartialspends: Option, - - // Key management - pub keypool: Option, - - // External signer - pub signer: Option, - - // Broadcast - pub walletbroadcast: Option, - - // Notifications - pub walletnotify: Option, -} - -#[allow(dead_code)] -/// Debugging related config -#[derive(Debug, Clone)] -pub struct Debugging { - // Debug categories - pub debug: Option, - pub debugexclude: Option, - - // Logging options - pub logips: Option, - pub logsourcelocations: Option, - pub logthreadnames: Option, - pub logtimestamps: Option, - pub shrinkdebugfile: Option, - pub printtoconsole: Option, - - // User agent - pub uacomment: Option, - - // Fee limits - pub maxtxfee: Option, -} - -#[allow(dead_code)] -/// Mining related config -#[derive(Debug, Clone)] -pub struct Mining { - // Block creation - pub blockmaxweight: Option, - pub blockmintxfee: Option, -} - -#[allow(dead_code)] -/// Relay related config -#[derive(Debug, Clone)] -pub struct Relay { - // Relay fees - pub minrelaytxfee: Option, - - // Data carrier (OP_RETURN) - pub datacarrier: Option, - pub datacarriersize: Option, - - // Sigops - pub bytespersigop: Option, - - // Whitelist relay - pub whitelistforcerelay: Option, - pub whitelistrelay: Option, -} - -#[allow(dead_code)] -/// ZMQ related config -#[derive(Debug, Clone)] -pub struct ZMQ { - // Hash notifications - pub zmqpubhashblock: Option, - pub zmqpubhashtx: Option, - - // Raw data notifications - pub zmqpubrawblock: Option, - pub zmqpubrawtx: Option, - - // Sequence notifications - pub zmqpubsequence: Option, -} - -#[allow(dead_code)] -#[derive(Debug, Clone)] -pub struct BitcoinConfig { - pub core: Core, - pub network: Network, - pub rpc: RPC, - pub wallet: Wallet, - pub debugging: Debugging, - pub mining: Mining, - pub relay: Relay, - pub zmq: ZMQ, -} - -/// Type of a configuration option value -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ConfigType { - Bool, - Int, - Float, - String, - Path, - Address, -} - -impl std::fmt::Display for ConfigType { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - ConfigType::Bool => write!(f, "boolean"), - ConfigType::Int => write!(f, "integer"), - ConfigType::Float => write!(f, "float"), - ConfigType::String => write!(f, "string"), - ConfigType::Path => write!(f, "path"), - ConfigType::Address => write!(f, "address"), - } - } -} - -/// Category of a configuration option -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ConfigCategory { - Core, - Network, - RPC, - Wallet, - Debugging, - Mining, - Relay, - ZMQ, -} - -/// Schema for a single configuration option -#[derive(Debug, Clone)] -pub struct ConfigSchema { - pub key: String, - pub default: String, - pub config_type: ConfigType, - pub category: ConfigCategory, - pub description: String, -} - -impl ConfigSchema { - #[must_use] - pub fn new( - key: &str, - default: &str, - config_type: ConfigType, - category: ConfigCategory, - description: &str, - ) -> Self { - Self { - key: key.to_string(), - default: default.to_string(), - config_type, - category, - description: description.to_string(), - } - } -} - -/// A parsed configuration entry -#[derive(Debug, Clone)] -pub struct ConfigEntry { - pub key: String, - pub value: String, - pub schema: Option, - pub enabled: bool, - pub section: Option, -} - -/// Returns the default schema for all known bitcoin.conf options -#[must_use] -#[allow(clippy::too_many_lines)] -pub fn get_default_schema() -> Vec { - vec![ - // Core options - ConfigSchema::new( - "datadir", - "", - ConfigType::Path, - ConfigCategory::Core, - "Specify data directory", - ), - ConfigSchema::new( - "blocksdir", - "", - ConfigType::Path, - ConfigCategory::Core, - "Specify blocks directory", - ), - ConfigSchema::new( - "pid", - "", - ConfigType::Path, - ConfigCategory::Core, - "Specify pid file", - ), - ConfigSchema::new( - "debuglogfile", - "", - ConfigType::Path, - ConfigCategory::Core, - "Specify debug log file", - ), - ConfigSchema::new( - "settings", - "", - ConfigType::Path, - ConfigCategory::Core, - "Specify settings file", - ), - ConfigSchema::new( - "includeconf", - "", - ConfigType::Path, - ConfigCategory::Core, - "Include additional config file", - ), - ConfigSchema::new( - "loadblock", - "", - ConfigType::Path, - ConfigCategory::Core, - "Import blocks from external file", - ), - ConfigSchema::new( - "txindex", - "0", - ConfigType::Bool, - ConfigCategory::Core, - "Maintain full transaction index", - ), - ConfigSchema::new( - "blockfilterindex", - "", - ConfigType::String, - ConfigCategory::Core, - "Maintain compact block filter index", - ), - ConfigSchema::new( - "coinstatsindex", - "0", - ConfigType::Bool, - ConfigCategory::Core, - "Maintain coinstats index", - ), - ConfigSchema::new( - "prune", - "0", - ConfigType::Int, - ConfigCategory::Core, - "Reduce storage by pruning old blocks", - ), - ConfigSchema::new( - "dbcache", - "450", - ConfigType::Int, - ConfigCategory::Core, - "Database cache size in MiB", - ), - ConfigSchema::new( - "maxmempool", - "300", - ConfigType::Int, - ConfigCategory::Core, - "Maximum mempool size in MiB", - ), - ConfigSchema::new( - "maxorphantx", - "100", - ConfigType::Int, - ConfigCategory::Core, - "Maximum orphan transactions", - ), - ConfigSchema::new( - "mempoolexpiry", - "336", - ConfigType::Int, - ConfigCategory::Core, - "Mempool expiry in hours", - ), - ConfigSchema::new( - "par", - "0", - ConfigType::Int, - ConfigCategory::Core, - "Script verification threads", - ), - ConfigSchema::new( - "blockreconstructionextratxn", - "100", - ConfigType::Int, - ConfigCategory::Core, - "Extra transactions for block reconstruction", - ), - ConfigSchema::new( - "blocksonly", - "0", - ConfigType::Bool, - ConfigCategory::Core, - "Reject transactions from network peers", - ), - ConfigSchema::new( - "persistmempool", - "1", - ConfigType::Bool, - ConfigCategory::Core, - "Save mempool on shutdown", - ), - ConfigSchema::new( - "reindex", - "0", - ConfigType::Bool, - ConfigCategory::Core, - "Rebuild chain state and block index", - ), - ConfigSchema::new( - "reindex-chainstate", - "0", - ConfigType::Bool, - ConfigCategory::Core, - "Rebuild chain state from blocks", - ), - ConfigSchema::new( - "sysperms", - "0", - ConfigType::Bool, - ConfigCategory::Core, - "Create files with system default permissions", - ), - ConfigSchema::new( - "daemon", - "0", - ConfigType::Bool, - ConfigCategory::Core, - "Run in background as daemon", - ), - ConfigSchema::new( - "daemonwait", - "0", - ConfigType::Bool, - ConfigCategory::Core, - "Wait for initialization before backgrounding", - ), - ConfigSchema::new( - "alertnotify", - "", - ConfigType::String, - ConfigCategory::Core, - "Command to execute on alert", - ), - ConfigSchema::new( - "blocknotify", - "", - ConfigType::String, - ConfigCategory::Core, - "Command to execute on new block", - ), - ConfigSchema::new( - "startupnotify", - "", - ConfigType::String, - ConfigCategory::Core, - "Command to execute on startup", - ), - ConfigSchema::new( - "assumevalid", - "", - ConfigType::String, - ConfigCategory::Core, - "Assume blocks are valid up to this hash", - ), - // Network options - ConfigSchema::new( - "chain", - "main", - ConfigType::String, - ConfigCategory::Network, - "Chain to use (main, test, signet, regtest)", - ), - ConfigSchema::new( - "testnet", - "0", - ConfigType::Bool, - ConfigCategory::Network, - "Use testnet", - ), - ConfigSchema::new( - "regtest", - "0", - ConfigType::Bool, - ConfigCategory::Network, - "Use regtest", - ), - ConfigSchema::new( - "signet", - "0", - ConfigType::Bool, - ConfigCategory::Network, - "Use signet", - ), - ConfigSchema::new( - "signetchallenge", - "", - ConfigType::String, - ConfigCategory::Network, - "Signet challenge script", - ), - ConfigSchema::new( - "signetseednode", - "", - ConfigType::String, - ConfigCategory::Network, - "Signet seed node", - ), - ConfigSchema::new( - "listen", - "1", - ConfigType::Bool, - ConfigCategory::Network, - "Accept incoming connections", - ), - ConfigSchema::new( - "bind", - "", - ConfigType::Address, - ConfigCategory::Network, - "Bind to address", - ), - ConfigSchema::new( - "whitebind", - "", - ConfigType::Address, - ConfigCategory::Network, - "Bind with whitelist permissions", - ), - ConfigSchema::new( - "port", - "8333", - ConfigType::Int, - ConfigCategory::Network, - "Listen on port", - ), - ConfigSchema::new( - "maxconnections", - "125", - ConfigType::Int, - ConfigCategory::Network, - "Maximum peer connections", - ), - ConfigSchema::new( - "maxreceivebuffer", - "5000", - ConfigType::Int, - ConfigCategory::Network, - "Maximum receive buffer per connection", - ), - ConfigSchema::new( - "maxsendbuffer", - "1000", - ConfigType::Int, - ConfigCategory::Network, - "Maximum send buffer per connection", - ), - ConfigSchema::new( - "maxuploadtarget", - "0", - ConfigType::Int, - ConfigCategory::Network, - "Maximum upload target in MiB per day", - ), - ConfigSchema::new( - "timeout", - "5000", - ConfigType::Int, - ConfigCategory::Network, - "Connection timeout in milliseconds", - ), - ConfigSchema::new( - "maxtimeadjustment", - "4200", - ConfigType::Int, - ConfigCategory::Network, - "Maximum time adjustment in seconds", - ), - ConfigSchema::new( - "bantime", - "86400", - ConfigType::Int, - ConfigCategory::Network, - "Ban duration in seconds", - ), - ConfigSchema::new( - "discover", - "1", - ConfigType::Bool, - ConfigCategory::Network, - "Discover own IP address", - ), - ConfigSchema::new( - "dns", - "1", - ConfigType::Bool, - ConfigCategory::Network, - "Allow DNS lookups", - ), - ConfigSchema::new( - "dnsseed", - "1", - ConfigType::Bool, - ConfigCategory::Network, - "Query DNS seeds", - ), - ConfigSchema::new( - "fixedseeds", - "1", - ConfigType::Bool, - ConfigCategory::Network, - "Use fixed seeds if DNS fails", - ), - ConfigSchema::new( - "forcednsseed", - "0", - ConfigType::Bool, - ConfigCategory::Network, - "Always query DNS seeds", - ), - ConfigSchema::new( - "seednode", - "", - ConfigType::Address, - ConfigCategory::Network, - "Connect to seed node for addresses", - ), - ConfigSchema::new( - "addnode", - "", - ConfigType::Address, - ConfigCategory::Network, - "Add node to connect to", - ), - ConfigSchema::new( - "connect", - "", - ConfigType::Address, - ConfigCategory::Network, - "Connect only to specified node", - ), - ConfigSchema::new( - "onlynet", - "", - ConfigType::String, - ConfigCategory::Network, - "Only connect to network type", - ), - ConfigSchema::new( - "networkactive", - "1", - ConfigType::Bool, - ConfigCategory::Network, - "Enable network activity", - ), - ConfigSchema::new( - "proxy", - "", - ConfigType::Address, - ConfigCategory::Network, - "SOCKS5 proxy", - ), - ConfigSchema::new( - "proxyrandomize", - "1", - ConfigType::Bool, - ConfigCategory::Network, - "Randomize proxy credentials", - ), - ConfigSchema::new( - "onion", - "", - ConfigType::Address, - ConfigCategory::Network, - "SOCKS5 proxy for Tor", - ), - ConfigSchema::new( - "listenonion", - "1", - ConfigType::Bool, - ConfigCategory::Network, - "Create Tor onion service", - ), - ConfigSchema::new( - "torcontrol", - "127.0.0.1:9051", - ConfigType::Address, - ConfigCategory::Network, - "Tor control port", - ), - ConfigSchema::new( - "torpassword", - "", - ConfigType::String, - ConfigCategory::Network, - "Tor control password", - ), - ConfigSchema::new( - "i2psam", - "", - ConfigType::Address, - ConfigCategory::Network, - "I2P SAM proxy", - ), - ConfigSchema::new( - "i2pacceptincoming", - "1", - ConfigType::Bool, - ConfigCategory::Network, - "Accept incoming I2P connections", - ), - ConfigSchema::new( - "cjdnsreachable", - "0", - ConfigType::Bool, - ConfigCategory::Network, - "CJDNS reachable", - ), - ConfigSchema::new( - "whitelist", - "", - ConfigType::String, - ConfigCategory::Network, - "Whitelist peers", - ), - ConfigSchema::new( - "peerblockfilters", - "0", - ConfigType::Bool, - ConfigCategory::Network, - "Serve compact block filters", - ), - ConfigSchema::new( - "peerbloomfilters", - "0", - ConfigType::Bool, - ConfigCategory::Network, - "Support bloom filters", - ), - ConfigSchema::new( - "permitbaremultisig", - "1", - ConfigType::Bool, - ConfigCategory::Network, - "Relay bare multisig", - ), - ConfigSchema::new( - "externalip", - "", - ConfigType::Address, - ConfigCategory::Network, - "Specify external IP", - ), - ConfigSchema::new( - "upnp", - "0", - ConfigType::Bool, - ConfigCategory::Network, - "Use UPnP for port mapping", - ), - ConfigSchema::new( - "asmap", - "", - ConfigType::Path, - ConfigCategory::Network, - "ASN mapping file", - ), - // RPC options - ConfigSchema::new( - "server", - "0", - ConfigType::Bool, - ConfigCategory::RPC, - "Accept RPC commands", - ), - ConfigSchema::new( - "rpcuser", - "", - ConfigType::String, - ConfigCategory::RPC, - "RPC username", - ), - ConfigSchema::new( - "rpcpassword", - "", - ConfigType::String, - ConfigCategory::RPC, - "RPC password", - ), - ConfigSchema::new( - "rpcauth", - "", - ConfigType::String, - ConfigCategory::RPC, - "RPC auth credentials", - ), - ConfigSchema::new( - "rpccookiefile", - "", - ConfigType::Path, - ConfigCategory::RPC, - "RPC cookie file location", - ), - ConfigSchema::new( - "rpcport", - "8332", - ConfigType::Int, - ConfigCategory::RPC, - "RPC port", - ), - ConfigSchema::new( - "rpcbind", - "", - ConfigType::Address, - ConfigCategory::RPC, - "RPC bind address", - ), - ConfigSchema::new( - "rpcallowip", - "", - ConfigType::String, - ConfigCategory::RPC, - "Allow RPC from IP", - ), - ConfigSchema::new( - "rpcthreads", - "4", - ConfigType::Int, - ConfigCategory::RPC, - "RPC worker threads", - ), - ConfigSchema::new( - "rpcserialversion", - "1", - ConfigType::Int, - ConfigCategory::RPC, - "RPC serialization version", - ), - ConfigSchema::new( - "rpcwhitelist", - "", - ConfigType::String, - ConfigCategory::RPC, - "RPC method whitelist", - ), - ConfigSchema::new( - "rpcwhitelistdefault", - "1", - ConfigType::Bool, - ConfigCategory::RPC, - "Default RPC whitelist behavior", - ), - ConfigSchema::new( - "rest", - "0", - ConfigType::Bool, - ConfigCategory::RPC, - "Enable REST interface", - ), - // Wallet options - ConfigSchema::new( - "disablewallet", - "0", - ConfigType::Bool, - ConfigCategory::Wallet, - "Disable wallet", - ), - ConfigSchema::new( - "wallet", - "", - ConfigType::Path, - ConfigCategory::Wallet, - "Wallet to load", - ), - ConfigSchema::new( - "walletdir", - "", - ConfigType::Path, - ConfigCategory::Wallet, - "Wallet directory", - ), - ConfigSchema::new( - "addresstype", - "bech32", - ConfigType::String, - ConfigCategory::Wallet, - "Default address type", - ), - ConfigSchema::new( - "changetype", - "", - ConfigType::String, - ConfigCategory::Wallet, - "Change address type", - ), - ConfigSchema::new( - "fallbackfee", - "0.00", - ConfigType::Float, - ConfigCategory::Wallet, - "Fallback fee rate", - ), - ConfigSchema::new( - "discardfee", - "0.0001", - ConfigType::Float, - ConfigCategory::Wallet, - "Discard fee threshold", - ), - ConfigSchema::new( - "mintxfee", - "0.00001", - ConfigType::Float, - ConfigCategory::Wallet, - "Minimum transaction fee", - ), - ConfigSchema::new( - "paytxfee", - "0.00", - ConfigType::Float, - ConfigCategory::Wallet, - "Transaction fee rate", - ), - ConfigSchema::new( - "consolidatefeerate", - "0.0001", - ConfigType::Float, - ConfigCategory::Wallet, - "Consolidation fee rate", - ), - ConfigSchema::new( - "maxapsfee", - "0.00", - ConfigType::Float, - ConfigCategory::Wallet, - "Max fee for partial spend avoidance", - ), - ConfigSchema::new( - "txconfirmtarget", - "6", - ConfigType::Int, - ConfigCategory::Wallet, - "Confirmation target blocks", - ), - ConfigSchema::new( - "spendzeroconfchange", - "1", - ConfigType::Bool, - ConfigCategory::Wallet, - "Spend unconfirmed change", - ), - ConfigSchema::new( - "walletrbf", - "0", - ConfigType::Bool, - ConfigCategory::Wallet, - "Enable wallet RBF", - ), - ConfigSchema::new( - "avoidpartialspends", - "0", - ConfigType::Bool, - ConfigCategory::Wallet, - "Avoid partial spends", - ), - ConfigSchema::new( - "keypool", - "1000", - ConfigType::Int, - ConfigCategory::Wallet, - "Keypool size", - ), - ConfigSchema::new( - "signer", - "", - ConfigType::String, - ConfigCategory::Wallet, - "External signer command", - ), - ConfigSchema::new( - "walletbroadcast", - "1", - ConfigType::Bool, - ConfigCategory::Wallet, - "Broadcast wallet transactions", - ), - ConfigSchema::new( - "walletnotify", - "", - ConfigType::String, - ConfigCategory::Wallet, - "Command on wallet transaction", - ), - // Debugging options - ConfigSchema::new( - "debug", - "", - ConfigType::String, - ConfigCategory::Debugging, - "Debug categories", - ), - ConfigSchema::new( - "debugexclude", - "", - ConfigType::String, - ConfigCategory::Debugging, - "Exclude debug categories", - ), - ConfigSchema::new( - "logips", - "0", - ConfigType::Bool, - ConfigCategory::Debugging, - "Log IP addresses", - ), - ConfigSchema::new( - "logsourcelocations", - "0", - ConfigType::Bool, - ConfigCategory::Debugging, - "Log source locations", - ), - ConfigSchema::new( - "logthreadnames", - "0", - ConfigType::Bool, - ConfigCategory::Debugging, - "Log thread names", - ), - ConfigSchema::new( - "logtimestamps", - "1", - ConfigType::Bool, - ConfigCategory::Debugging, - "Log timestamps", - ), - ConfigSchema::new( - "shrinkdebugfile", - "1", - ConfigType::Bool, - ConfigCategory::Debugging, - "Shrink debug.log on startup", - ), - ConfigSchema::new( - "printtoconsole", - "0", - ConfigType::Bool, - ConfigCategory::Debugging, - "Print to console", - ), - ConfigSchema::new( - "uacomment", - "", - ConfigType::String, - ConfigCategory::Debugging, - "User agent comment", - ), - ConfigSchema::new( - "maxtxfee", - "0.10", - ConfigType::Float, - ConfigCategory::Debugging, - "Maximum transaction fee", - ), - // Mining options - ConfigSchema::new( - "blockmaxweight", - "3996000", - ConfigType::Int, - ConfigCategory::Mining, - "Maximum block weight", - ), - ConfigSchema::new( - "blockmintxfee", - "0.00001", - ConfigType::Float, - ConfigCategory::Mining, - "Minimum block transaction fee", - ), - // Relay options - ConfigSchema::new( - "minrelaytxfee", - "0.00001", - ConfigType::Float, - ConfigCategory::Relay, - "Minimum relay fee", - ), - ConfigSchema::new( - "datacarrier", - "1", - ConfigType::Bool, - ConfigCategory::Relay, - "Relay OP_RETURN transactions", - ), - ConfigSchema::new( - "datacarriersize", - "83", - ConfigType::Int, - ConfigCategory::Relay, - "Maximum OP_RETURN size", - ), - ConfigSchema::new( - "bytespersigop", - "20", - ConfigType::Int, - ConfigCategory::Relay, - "Bytes per sigop", - ), - ConfigSchema::new( - "whitelistforcerelay", - "0", - ConfigType::Bool, - ConfigCategory::Relay, - "Force relay from whitelist", - ), - ConfigSchema::new( - "whitelistrelay", - "1", - ConfigType::Bool, - ConfigCategory::Relay, - "Relay from whitelist", - ), - // ZMQ options - ConfigSchema::new( - "zmqpubhashblock", - "", - ConfigType::Address, - ConfigCategory::ZMQ, - "ZMQ hash block publisher", - ), - ConfigSchema::new( - "zmqpubhashtx", - "", - ConfigType::Address, - ConfigCategory::ZMQ, - "ZMQ hash tx publisher", - ), - ConfigSchema::new( - "zmqpubrawblock", - "", - ConfigType::Address, - ConfigCategory::ZMQ, - "ZMQ raw block publisher", - ), - ConfigSchema::new( - "zmqpubrawtx", - "", - ConfigType::Address, - ConfigCategory::ZMQ, - "ZMQ raw tx publisher", - ), - ConfigSchema::new( - "zmqpubsequence", - "", - ConfigType::Address, - ConfigCategory::ZMQ, - "ZMQ sequence publisher", - ), - ] -} - -/// Parse bitcoin.conf file -/// -/// # Errors -/// Returns an error if the file cannot be read or the config library fails to build. -/// On a parse failure the function returns schema defaults rather than an error. -#[allow(clippy::too_many_lines)] // Sequential key-mapping logic; refactoring adds no clarity -pub fn parse_config(path: &Path) -> Result> { - let schema_list = get_default_schema(); - let mut entries = Vec::new(); - let mut found_keys: HashSet = HashSet::new(); - let mut builder = Config::builder(); - - if path.exists() { - builder = builder.add_source(File::from(path).format(FileFormat::Ini)); - } - - let Ok(config) = builder.build() else { - // Return schema defaults if config can't be parsed - for schema in schema_list { - entries.push(ConfigEntry { - key: schema.key.clone(), - value: schema.default.clone(), - schema: Some(schema), - enabled: false, - section: None, - }); - } - return Ok(entries); - }; - - // Maps key name -> section it was first seen in (None = top-level) - let mut config_keys: HashMap> = HashMap::new(); - let sections = vec!["", "main", "test", "signet", "regtest"]; - - // Collect all keys from all sections, preserving which section each key came from - for section in §ions { - if let Ok(table) = if section.is_empty() { - config.get_table("") - } else { - config.get_table(section) - } { - for key in table.keys() { - let actual_key = if key.contains('.') { - key.split('.').next_back().unwrap_or(key).to_string() - } else { - key.clone() - }; - let key_section = if section.is_empty() { - None - } else { - Some((*section).to_string()) - }; - config_keys.entry(actual_key).or_insert(key_section); - } - } - } - - // Process known schema options - for schema in &schema_list { - let key = &schema.key; - let mut value = schema.default.clone(); - let mut enabled = false; - let mut entry_section: Option = None; - - 'find_section: for section in §ions { - let lookup_key = if section.is_empty() { - key.clone() - } else { - format!("{section}.{key}") - }; - - let resolved = if let Ok(val) = config.get_string(&lookup_key) { - Some(val) - } else if let Ok(val) = config.get_bool(&lookup_key) { - Some(if val { - "1".to_string() - } else { - "0".to_string() - }) - } else if let Ok(val) = config.get_int(&lookup_key) { - Some(val.to_string()) - } else if let Ok(val) = config.get_float(&lookup_key) { - Some(val.to_string()) - } else { - None - }; - - if let Some(v) = resolved { - value = v; - enabled = true; - found_keys.insert(key.clone()); - entry_section = if section.is_empty() { - None - } else { - Some((*section).to_string()) - }; - break 'find_section; - } - } - - entries.push(ConfigEntry { - key: key.clone(), - value, - schema: Some(schema.clone()), - enabled, - section: entry_section, - }); - } - - // Add unknown config keys (not in schema) - for (config_key, key_section) in &config_keys { - if !found_keys.contains(config_key) { - let lookup_key = match key_section { - None => config_key.clone(), - Some(s) => format!("{s}.{config_key}"), - }; - - let value = if let Ok(val) = config.get_string(&lookup_key) { - val - } else if let Ok(val) = config.get_bool(&lookup_key) { - if val { - "1".to_string() - } else { - "0".to_string() - } - } else if let Ok(val) = config.get_int(&lookup_key) { - val.to_string() - } else if let Ok(val) = config.get_float(&lookup_key) { - val.to_string() - } else { - String::new() - }; - - entries.push(ConfigEntry { - key: config_key.clone(), - value, - schema: None, - enabled: true, - section: key_section.clone(), - }); - } - } - - Ok(entries) -} - -/// Writes enabled entries back to the config file -/// -/// # Errors -/// Returns an error if the file cannot be created or written. -pub fn save_config(path: &Path, entries: &[ConfigEntry]) -> Result<()> { - use std::collections::BTreeMap; - use std::io::Write; - - let mut file = std::fs::File::create(path)?; - let mut sectioned: BTreeMap> = BTreeMap::new(); - - for entry in entries { - if !entry.enabled { - continue; - } - match &entry.section { - None => writeln!(file, "{}={}", entry.key, entry.value)?, - Some(s) => sectioned.entry(s.clone()).or_default().push(entry), - } - } - - // Write each named section - for (section, section_entries) in §ioned { - writeln!(file, "\n[{section}]")?; - for entry in section_entries { - writeln!(file, "{}={}", entry.key, entry.value)?; - } - } - - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - use std::io::Write; - - fn create_temp_config(content: &str) -> (tempfile::TempDir, std::path::PathBuf) { - let dir = tempfile::tempdir().unwrap(); - let file_path = dir.path().join("bitcoin.conf"); - let mut file = std::fs::File::create(&file_path).unwrap(); - file.write_all(content.as_bytes()).unwrap(); - (dir, file_path) - } - - // Tests for get_default_schema() - - #[test] - fn get_default_schema_returns_non_empty_list() { - let schema = get_default_schema(); - assert!(!schema.is_empty()); - } - - #[test] - fn get_default_schema_contains_core_options() { - let schema = get_default_schema(); - let keys: Vec<&str> = schema.iter().map(|s| s.key.as_str()).collect(); - - assert!(keys.contains(&"datadir")); - assert!(keys.contains(&"txindex")); - assert!(keys.contains(&"prune")); - assert!(keys.contains(&"dbcache")); - } - - #[test] - fn get_default_schema_contains_network_options() { - let schema = get_default_schema(); - let keys: Vec<&str> = schema.iter().map(|s| s.key.as_str()).collect(); - - assert!(keys.contains(&"testnet")); - assert!(keys.contains(&"regtest")); - assert!(keys.contains(&"listen")); - assert!(keys.contains(&"port")); - assert!(keys.contains(&"maxconnections")); - } - - #[test] - fn get_default_schema_contains_rpc_options() { - let schema = get_default_schema(); - let keys: Vec<&str> = schema.iter().map(|s| s.key.as_str()).collect(); - - assert!(keys.contains(&"server")); - assert!(keys.contains(&"rpcuser")); - assert!(keys.contains(&"rpcpassword")); - assert!(keys.contains(&"rpcport")); - } - - #[test] - fn get_default_schema_contains_zmq_options() { - let schema = get_default_schema(); - let keys: Vec<&str> = schema.iter().map(|s| s.key.as_str()).collect(); - - assert!(keys.contains(&"zmqpubhashblock")); - assert!(keys.contains(&"zmqpubhashtx")); - assert!(keys.contains(&"zmqpubrawblock")); - assert!(keys.contains(&"zmqpubrawtx")); - assert!(keys.contains(&"zmqpubsequence")); - } - - #[test] - fn get_default_schema_has_correct_categories() { - let schema = get_default_schema(); - - let txindex = schema.iter().find(|s| s.key == "txindex").unwrap(); - assert_eq!(txindex.category, ConfigCategory::Core); - - let testnet = schema.iter().find(|s| s.key == "testnet").unwrap(); - assert_eq!(testnet.category, ConfigCategory::Network); - - let server = schema.iter().find(|s| s.key == "server").unwrap(); - assert_eq!(server.category, ConfigCategory::RPC); - - let disablewallet = schema.iter().find(|s| s.key == "disablewallet").unwrap(); - assert_eq!(disablewallet.category, ConfigCategory::Wallet); - } - - #[test] - fn get_default_schema_has_correct_types() { - let schema = get_default_schema(); - - let txindex = schema.iter().find(|s| s.key == "txindex").unwrap(); - assert_eq!(txindex.config_type, ConfigType::Bool); - - let dbcache = schema.iter().find(|s| s.key == "dbcache").unwrap(); - assert_eq!(dbcache.config_type, ConfigType::Int); - - let fallbackfee = schema.iter().find(|s| s.key == "fallbackfee").unwrap(); - assert_eq!(fallbackfee.config_type, ConfigType::Float); - - let datadir = schema.iter().find(|s| s.key == "datadir").unwrap(); - assert_eq!(datadir.config_type, ConfigType::Path); - - let rpcbind = schema.iter().find(|s| s.key == "rpcbind").unwrap(); - assert_eq!(rpcbind.config_type, ConfigType::Address); - } - - // Tests for ConfigSchema::new() - - #[test] - fn config_schema_new_creates_correct_schema() { - let schema = ConfigSchema::new( - "testkey", - "testdefault", - ConfigType::String, - ConfigCategory::Core, - "Test description", - ); - - assert_eq!(schema.key, "testkey"); - assert_eq!(schema.default, "testdefault"); - assert_eq!(schema.config_type, ConfigType::String); - assert_eq!(schema.category, ConfigCategory::Core); - assert_eq!(schema.description, "Test description"); - } - - // Tests for parse_config() - - #[test] - fn parse_config_non_existent_file_returns_defaults() { - let path = Path::new("/non/existent/path/bitcoin.conf"); - let entries = parse_config(path).unwrap(); - - assert!(!entries.is_empty()); - - // All entries should have schema and be disabled - for entry in &entries { - assert!(entry.schema.is_some()); - assert!(!entry.enabled); - } - } - - #[test] - fn parse_config_malformed_ini_returns_schema_defaults() { - // An unclosed section bracket causes the config crate's INI parser to - // return Err, triggering the `let Ok(config) = ... else { return Ok(entries) }` - // fallback path in parse_config. - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("bitcoin.conf"); - std::fs::write(&path, b"[unclosed\n").unwrap(); - - let entries = parse_config(&path).unwrap(); - - // Must return schema-populated defaults, all disabled - assert!(!entries.is_empty()); - let disabled_with_schema = entries - .iter() - .filter(|e| e.schema.is_some() && !e.enabled) - .count(); - // If the parser actually fails, ALL schema entries are disabled defaults. - assert!(disabled_with_schema > 0 || entries.iter().any(|e| e.schema.is_some())); - } - - #[test] - fn parse_config_empty_file_returns_defaults() { - let (_dir, path) = create_temp_config(""); - let entries = parse_config(&path).unwrap(); - - assert!(!entries.is_empty()); - - // All entries should be disabled (not set in config) - let enabled_count = entries.iter().filter(|e| e.enabled).count(); - assert_eq!(enabled_count, 0); - } - - #[test] - fn parse_config_parses_bool_values() { - let (_dir, path) = create_temp_config("txindex=1\nserver=0\n"); - let entries = parse_config(&path).unwrap(); - - let txindex = entries.iter().find(|e| e.key == "txindex").unwrap(); - assert_eq!(txindex.value, "1"); - assert!(txindex.enabled); - - let server = entries.iter().find(|e| e.key == "server").unwrap(); - assert_eq!(server.value, "0"); - assert!(server.enabled); - } - - #[test] - fn parse_config_parses_int_values() { - let (_dir, path) = create_temp_config("dbcache=1000\nport=8334\n"); - let entries = parse_config(&path).unwrap(); - - let dbcache = entries.iter().find(|e| e.key == "dbcache").unwrap(); - assert_eq!(dbcache.value, "1000"); - assert!(dbcache.enabled); - - let port = entries.iter().find(|e| e.key == "port").unwrap(); - assert_eq!(port.value, "8334"); - assert!(port.enabled); - } - - #[test] - fn parse_config_parses_string_values() { - let (_dir, path) = create_temp_config("rpcuser=myuser\nrpcpassword=mypassword\n"); - let entries = parse_config(&path).unwrap(); - - let rpcuser = entries.iter().find(|e| e.key == "rpcuser").unwrap(); - assert_eq!(rpcuser.value, "myuser"); - assert!(rpcuser.enabled); - - let rpcpassword = entries.iter().find(|e| e.key == "rpcpassword").unwrap(); - assert_eq!(rpcpassword.value, "mypassword"); - assert!(rpcpassword.enabled); - } - - #[test] - fn parse_config_parses_path_values() { - let (_dir, path) = create_temp_config("datadir=/home/user/.bitcoin\n"); - let entries = parse_config(&path).unwrap(); - - let datadir = entries.iter().find(|e| e.key == "datadir").unwrap(); - assert_eq!(datadir.value, "/home/user/.bitcoin"); - assert!(datadir.enabled); - } - - #[test] - fn parse_config_parses_address_values() { - let (_dir, path) = create_temp_config("zmqpubhashblock=tcp://127.0.0.1:28332\n"); - let entries = parse_config(&path).unwrap(); - - let zmq = entries.iter().find(|e| e.key == "zmqpubhashblock").unwrap(); - assert_eq!(zmq.value, "tcp://127.0.0.1:28332"); - assert!(zmq.enabled); - } - - #[test] - fn parse_config_handles_unknown_keys() { - // Use a section to ensure the config crate parses the key properly - let (_dir, path) = create_temp_config("[main]\nunknownkey=unknownvalue\n"); - let entries = parse_config(&path).unwrap(); - - let unknown = entries.iter().find(|e| e.key == "unknownkey"); - assert!( - unknown.is_some(), - "Unknown key should be present in entries" - ); - - let unknown = unknown.unwrap(); - assert_eq!(unknown.value, "unknownvalue"); - assert!(unknown.enabled); - assert!(unknown.schema.is_none()); - } - - #[test] - fn parse_config_handles_section_values() { - let content = r#" -[main] -rpcport=8332 - -[test] -rpcport=18332 -"#; - let (_dir, path) = create_temp_config(content); - let entries = parse_config(&path).unwrap(); - - // Should find rpcport with first matching section value - let rpcport = entries.iter().find(|e| e.key == "rpcport").unwrap(); - assert!(rpcport.enabled); - } - - #[test] - fn parse_config_preserves_schema_info() { - let (_dir, path) = create_temp_config("txindex=1\n"); - let entries = parse_config(&path).unwrap(); - - let txindex = entries.iter().find(|e| e.key == "txindex").unwrap(); - assert!(txindex.schema.is_some()); - - let schema = txindex.schema.as_ref().unwrap(); - assert_eq!(schema.config_type, ConfigType::Bool); - assert_eq!(schema.category, ConfigCategory::Core); - assert!(!schema.description.is_empty()); - } - - #[test] - fn parse_config_uses_defaults_for_unset_options() { - let (_dir, path) = create_temp_config("txindex=1\n"); - let entries = parse_config(&path).unwrap(); - - // dbcache should have default value since not set - let dbcache = entries.iter().find(|e| e.key == "dbcache").unwrap(); - assert_eq!(dbcache.value, "450"); // default value - assert!(!dbcache.enabled); - } - - #[test] - fn parse_config_handles_comments() { - let content = r#" -# This is a comment -txindex=1 -# Another comment -server=1 -"#; - let (_dir, path) = create_temp_config(content); - let entries = parse_config(&path).unwrap(); - - let txindex = entries.iter().find(|e| e.key == "txindex").unwrap(); - assert_eq!(txindex.value, "1"); - assert!(txindex.enabled); - - let server = entries.iter().find(|e| e.key == "server").unwrap(); - assert_eq!(server.value, "1"); - assert!(server.enabled); - } - - #[test] - fn parse_config_handles_full_config() { - let content = r#" -# Bitcoin Core configuration - -# Network -testnet=0 -listen=1 -port=8333 -maxconnections=125 - -# RPC -server=1 -rpcuser=bitcoinrpc -rpcpassword=secretpassword -rpcport=8332 -rpcallowip=127.0.0.1 - -# Wallet -disablewallet=0 -fallbackfee=0.0002 - -# ZMQ -zmqpubhashblock=tcp://127.0.0.1:28332 -zmqpubhashtx=tcp://127.0.0.1:28333 -"#; - let (_dir, path) = create_temp_config(content); - let entries = parse_config(&path).unwrap(); - - // Verify various entries - let testnet = entries.iter().find(|e| e.key == "testnet").unwrap(); - assert_eq!(testnet.value, "0"); - assert!(testnet.enabled); - - let rpcuser = entries.iter().find(|e| e.key == "rpcuser").unwrap(); - assert_eq!(rpcuser.value, "bitcoinrpc"); - - let zmq = entries.iter().find(|e| e.key == "zmqpubhashblock").unwrap(); - assert_eq!(zmq.value, "tcp://127.0.0.1:28332"); - } - - // Tests for ConfigType and ConfigCategory enums - - #[test] - fn config_type_is_copy() { - let t1 = ConfigType::Bool; - let t2 = t1; // Copy - assert_eq!(t1, t2); - } - - #[test] - fn config_category_is_copy() { - let c1 = ConfigCategory::Core; - let c2 = c1; // Copy - assert_eq!(c1, c2); - } - - #[test] - fn config_entry_clone_works() { - let entry = ConfigEntry { - key: "test".to_string(), - value: "value".to_string(), - schema: None, - enabled: true, - section: None, - }; - let cloned = entry.clone(); - assert_eq!(entry.key, cloned.key); - assert_eq!(entry.value, cloned.value); - assert_eq!(entry.enabled, cloned.enabled); - } - - #[test] - fn config_schema_clone_works() { - let schema = ConfigSchema::new( - "test", - "default", - ConfigType::String, - ConfigCategory::Core, - "description", - ); - let cloned = schema.clone(); - assert_eq!(schema.key, cloned.key); - assert_eq!(schema.default, cloned.default); - assert_eq!(schema.config_type, cloned.config_type); - assert_eq!(schema.category, cloned.category); - assert_eq!(schema.description, cloned.description); - } - - // Tests for save_config() - - #[test] - fn save_config_writes_only_enabled_entries() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("out.conf"); - - let entries = vec![ - ConfigEntry { - key: "rpcuser".to_string(), - value: "alice".to_string(), - enabled: true, - schema: None, - section: None, - }, - ConfigEntry { - key: "rpcport".to_string(), - value: "8332".to_string(), - enabled: false, - schema: None, - section: None, - }, - ConfigEntry { - key: "server".to_string(), - value: "1".to_string(), - enabled: true, - schema: None, - section: None, - }, - ]; - - save_config(&path, &entries).unwrap(); - - let content = std::fs::read_to_string(&path).unwrap(); - assert!(content.contains("rpcuser=alice\n")); - assert!(content.contains("server=1\n")); - assert!(!content.contains("rpcport")); - } - - #[test] - fn save_config_empty_entries_creates_empty_file() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("empty.conf"); - - save_config(&path, &[]).unwrap(); - - let content = std::fs::read_to_string(&path).unwrap(); - assert!(content.is_empty()); - } - - #[test] - fn save_config_roundtrip_with_parse() { - let (_dir, path) = create_temp_config("rpcuser=bob\nserver=1\n"); - - let entries = parse_config(&path).unwrap(); - save_config(&path, &entries).unwrap(); - - let reparsed = parse_config(&path).unwrap(); - let enabled: Vec<_> = reparsed.iter().filter(|e| e.enabled).collect(); - - assert!( - enabled - .iter() - .any(|e| e.key == "rpcuser" && e.value == "bob") - ); - assert!(enabled.iter().any(|e| e.key == "server" && e.value == "1")); - - // No disabled entry should have been promoted to enabled by the round-trip - let originally_disabled_count = entries.iter().filter(|e| !e.enabled).count(); - let after_disabled_count = reparsed.iter().filter(|e| !e.enabled).count(); - assert_eq!( - originally_disabled_count, after_disabled_count, - "round-trip must not enable previously-disabled entries" - ); - - // No extra enabled entries should appear - let originally_enabled_count = entries.iter().filter(|e| e.enabled).count(); - assert_eq!( - enabled.len(), - originally_enabled_count, - "round-trip must not introduce extra enabled entries" - ); - } - - #[test] - fn save_config_preserves_sections() { - // A config with keys in different sections - let (_dir, path) = create_temp_config("[main]\nrpcuser=alice\n\n[test]\nrpcport=18332\n"); - - let entries = parse_config(&path).unwrap(); - - // Verify sections were captured during parse - let main_entry = entries.iter().find(|e| e.key == "rpcuser" && e.enabled); - let test_entry = entries.iter().find(|e| e.key == "rpcport" && e.enabled); - assert!(main_entry.is_some(), "rpcuser should be parsed"); - assert!(test_entry.is_some(), "rpcport should be parsed"); - assert_eq!(main_entry.unwrap().section.as_deref(), Some("main")); - assert_eq!(test_entry.unwrap().section.as_deref(), Some("test")); - - // Save then re-parse - save_config(&path, &entries).unwrap(); - let saved = std::fs::read_to_string(&path).unwrap(); - assert!(saved.contains("[main]"), "expected [main] section header"); - assert!(saved.contains("[test]"), "expected [test] section header"); - - let reparsed = parse_config(&path).unwrap(); - let rpcuser = reparsed - .iter() - .find(|e| e.key == "rpcuser" && e.enabled) - .unwrap(); - let rpcport = reparsed - .iter() - .find(|e| e.key == "rpcport" && e.enabled) - .unwrap(); - assert_eq!(rpcuser.value, "alice"); - assert_eq!(rpcport.value, "18332"); - assert_eq!(rpcuser.section.as_deref(), Some("main")); - assert_eq!(rpcport.section.as_deref(), Some("test")); - } - - #[test] - fn save_config_sections_written_after_top_level() { - // Entries with mixed sections: top-level first, then named sections - let entries = vec![ - ConfigEntry { - key: "daemon".to_string(), - value: "1".to_string(), - enabled: true, - schema: None, - section: None, - }, - ConfigEntry { - key: "rpcport".to_string(), - value: "18332".to_string(), - enabled: true, - schema: None, - section: Some("test".to_string()), - }, - ]; - - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("bitcoin.conf"); - save_config(&path, &entries).unwrap(); - - let content = std::fs::read_to_string(&path).unwrap(); - let daemon_pos = content.find("daemon=1").unwrap(); - let section_pos = content.find("[test]").unwrap(); - assert!( - daemon_pos < section_pos, - "top-level entries should come before section headers" - ); - assert!(content.contains("rpcport=18332")); - } -} diff --git a/src/components/bitcoin_client.rs b/src/components/bitcoin_client.rs index 05eae1c..d25433e 100644 --- a/src/components/bitcoin_client.rs +++ b/src/components/bitcoin_client.rs @@ -2,12 +2,12 @@ // // SPDX-License-Identifier: AGPL-3.0-or-later -use crate::bitcoin_config::ConfigEntry; use anyhow::{Context, Result, anyhow, bail}; +use p2poolv2_config::Config as P2PoolConfig; use reqwest::Client; use serde::{Deserialize, Serialize, de::DeserializeOwned}; use serde_json::Value; -use std::{path::PathBuf, time::Duration}; +use std::time::Duration; const REQUEST_TIMEOUT_SECONDS: u64 = 10; @@ -29,15 +29,6 @@ pub struct BitcoinChainInfo { pub connected_peer_addresses: Vec, } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum BitcoinNetwork { - Mainnet, - Testnet, - Testnet4, - Signet, - Regtest, -} - #[derive(Debug, Deserialize)] struct BlockchainInfoResponse { chain: String, @@ -74,19 +65,14 @@ struct RpcError { impl BitcoinClient { #[must_use] - pub fn from_config_entries(entries: &[ConfigEntry]) -> Self { - let network = network_from_entries(entries); - let port = entry_value(entries, "rpcport") - .and_then(|value| value.parse::().ok()) - .unwrap_or_else(|| default_rpc_port(network)); - let host = entry_value(entries, "rpcbind").unwrap_or("127.0.0.1"); - let url = rpc_url(host, port); - let auth_credentials = rpc_auth(entries, network); - + pub fn from_p2pool_config(config: &P2PoolConfig) -> Self { Self { client: build_client(), - url, - auth_credentials, + url: config.bitcoinrpc.url.clone(), + auth_credentials: Some(( + config.bitcoinrpc.username.clone(), + config.bitcoinrpc.password.clone(), + )), } } @@ -161,138 +147,6 @@ fn build_client() -> Client { .expect("Failed to build reqwest client") } -fn entry_value<'a>(entries: &'a [ConfigEntry], key: &str) -> Option<&'a str> { - entries - .iter() - .find(|entry| entry.enabled && entry.key == key && !entry.value.trim().is_empty()) - .map(|entry| entry.value.trim()) -} - -fn network_from_entries(entries: &[ConfigEntry]) -> BitcoinNetwork { - if bool_entry(entries, "regtest") { - return BitcoinNetwork::Regtest; - } - if bool_entry(entries, "signet") { - return BitcoinNetwork::Signet; - } - if bool_entry(entries, "testnet4") { - return BitcoinNetwork::Testnet4; - } - if bool_entry(entries, "testnet") { - return BitcoinNetwork::Testnet; - } - - match entry_value(entries, "chain") - .unwrap_or_default() - .to_ascii_lowercase() - .as_str() - { - "test" | "testnet" | "testnet3" => BitcoinNetwork::Testnet, - "testnet4" => BitcoinNetwork::Testnet4, - "signet" => BitcoinNetwork::Signet, - "regtest" => BitcoinNetwork::Regtest, - _ => BitcoinNetwork::Mainnet, - } -} - -fn bool_entry(entries: &[ConfigEntry], key: &str) -> bool { - matches!( - entry_value(entries, key) - .map(str::to_ascii_lowercase) - .as_deref(), - Some("1" | "true" | "yes" | "on") - ) -} - -fn default_rpc_port(network: BitcoinNetwork) -> u16 { - match network { - BitcoinNetwork::Mainnet => 8332, - BitcoinNetwork::Testnet => 18332, - BitcoinNetwork::Testnet4 => 48332, - BitcoinNetwork::Signet => 38332, - BitcoinNetwork::Regtest => 18443, - } -} - -fn rpc_url(host: &str, port: u16) -> String { - let host = host.trim().trim_matches('/'); - if host.starts_with("http://") || host.starts_with("https://") { - return host.to_string(); - } - if has_explicit_port(host) { - return format!("http://{host}"); - } - if host.contains(':') && !host.starts_with('[') { - return format!("http://[{host}]:{port}"); - } - format!("http://{host}:{port}") -} - -fn has_explicit_port(host: &str) -> bool { - if let Some(end_bracket) = host.find(']') { - return host[end_bracket + 1..].starts_with(':'); - } - - host.matches(':').count() == 1 - && host - .rsplit_once(':') - .is_some_and(|(_, port)| port.parse::().is_ok()) -} - -fn rpc_auth(entries: &[ConfigEntry], network: BitcoinNetwork) -> Option<(String, String)> { - if let (Some(user), Some(pass)) = ( - entry_value(entries, "rpcuser"), - entry_value(entries, "rpcpassword"), - ) { - return Some((user.to_string(), pass.to_string())); - } - - read_cookie_auth(entries, network).ok() -} - -fn read_cookie_auth(entries: &[ConfigEntry], network: BitcoinNetwork) -> Result<(String, String)> { - let cookie_path = cookie_path(entries, network); - let content = std::fs::read_to_string(&cookie_path) - .with_context(|| format!("could not read RPC cookie at {}", cookie_path.display()))?; - let (user, pass) = content - .trim() - .split_once(':') - .ok_or_else(|| anyhow!("RPC cookie did not contain username and password"))?; - - Ok((user.to_string(), pass.to_string())) -} - -fn cookie_path(entries: &[ConfigEntry], network: BitcoinNetwork) -> PathBuf { - if let Some(path) = entry_value(entries, "rpccookiefile") { - let configured = PathBuf::from(path); - if configured.is_absolute() { - return configured; - } - return data_dir(entries, network).join(configured); - } - - data_dir(entries, network).join(".cookie") -} - -fn data_dir(entries: &[ConfigEntry], network: BitcoinNetwork) -> PathBuf { - let base = entry_value(entries, "datadir") - .map(PathBuf::from) - .or_else(default_data_dir) - .unwrap_or_default(); - - match network { - BitcoinNetwork::Mainnet => base, - BitcoinNetwork::Testnet => base.join("testnet3"), - BitcoinNetwork::Testnet4 => base.join("testnet4"), - BitcoinNetwork::Signet => base.join("signet"), - BitcoinNetwork::Regtest => base.join("regtest"), - } -} - -fn default_data_dir() -> Option { - std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".bitcoin")) -} - fn display_network(chain: &str) -> &str { match chain { "main" => "mainnet", @@ -309,55 +163,22 @@ mod tests { use mockito::{Matcher, Server}; use serde_json::json; - fn entry(key: &str, value: &str) -> ConfigEntry { - ConfigEntry { - key: key.to_string(), - value: value.to_string(), - schema: None, - enabled: true, - section: None, - } - } - #[test] - fn builds_default_mainnet_endpoint() { - let client = BitcoinClient::from_config_entries(&[]); - - assert_eq!(client.url, "http://127.0.0.1:8332"); - } - - #[test] - fn uses_configured_rpc_port_and_auth() { - let entries = vec![ - entry("rpcport", "18443"), - entry("rpcuser", "alice"), - entry("rpcpassword", "secret"), - ]; - let client = BitcoinClient::from_config_entries(&entries); - - assert_eq!(client.url, "http://127.0.0.1:18443"); + fn uses_p2pool_bitcoinrpc_configuration() { + let config = p2poolv2_config::Config::load(concat!( + env!("CARGO_MANIFEST_DIR"), + "/tests/fixtures/p2pool.toml" + )) + .unwrap(); + let client = BitcoinClient::from_p2pool_config(&config); + + assert_eq!(client.url, config.bitcoinrpc.url); assert_eq!( client.auth_credentials, - Some(("alice".to_string(), "secret".to_string())) + Some((config.bitcoinrpc.username, config.bitcoinrpc.password)) ); } - #[test] - fn detects_network_from_chain_setting() { - let entries = vec![entry("chain", "testnet4")]; - let client = BitcoinClient::from_config_entries(&entries); - - assert_eq!(client.url, "http://127.0.0.1:48332"); - } - - #[test] - fn preserves_rpcbind_with_explicit_port() { - let entries = vec![entry("rpcbind", "127.0.0.1:18443")]; - let client = BitcoinClient::from_config_entries(&entries); - - assert_eq!(client.url, "http://127.0.0.1:18443"); - } - #[tokio::test] async fn fetch_chain_info_success() { let mut server = Server::new_async().await; @@ -666,58 +487,4 @@ mod tests { assert_eq!(result.block_height, 1); } - - #[test] - fn ignores_disabled_and_whitespace_only_config_entries() { - let entries = vec![ - entry("rpcport", " "), - ConfigEntry { - key: "rpcport".to_string(), - value: "18443".to_string(), - schema: None, - enabled: false, - section: None, - }, - ]; - let client = BitcoinClient::from_config_entries(&entries); - - assert_eq!(client.url, "http://127.0.0.1:8332"); - } - - #[test] - fn falls_back_to_cookie_auth_when_rpc_password_is_missing() { - let temp_dir = std::env::temp_dir().join(format!( - "pdm-bitcoin-client-test-{}-{}", - std::process::id(), - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_nanos() - )); - let cookie_path = temp_dir.join(".cookie"); - std::fs::create_dir_all(&temp_dir).unwrap(); - std::fs::write(&cookie_path, "alice:secret").unwrap(); - - let entries = vec![ - entry("rpcuser", "alice"), - entry("rpccookiefile", cookie_path.to_string_lossy().as_ref()), - ]; - let client = BitcoinClient::from_config_entries(&entries); - - assert_eq!( - client.auth_credentials, - Some(("alice".to_string(), "secret".to_string())) - ); - - let _ = std::fs::remove_file(cookie_path); - let _ = std::fs::remove_dir(temp_dir); - } - - #[test] - fn formats_ipv6_rpcbind_without_explicit_port() { - let entries = vec![entry("rpcbind", "::1")]; - let client = BitcoinClient::from_config_entries(&entries); - - assert_eq!(client.url, "http://[::1]:8332"); - } } diff --git a/src/components/bitcoin_config_view.rs b/src/components/bitcoin_config_view.rs deleted file mode 100644 index c2962e8..0000000 --- a/src/components/bitcoin_config_view.rs +++ /dev/null @@ -1,705 +0,0 @@ -// SPDX-FileCopyrightText: 2024 PDM Authors -// -// SPDX-License-Identifier: AGPL-3.0-or-later - -use crate::app::{App, AppAction}; -use crate::bitcoin_config::ConfigEntry; -use crossterm::event::{KeyCode, KeyEvent}; -use ratatui::{ - prelude::*, - widgets::{Block, Borders, List, ListItem, ListState, Paragraph}, -}; -use std::path::Path; -use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; - -/// Shortens a path to fit within `max_len` display columns. -fn shorten_path(path: &Path, max_len: usize, home: &str) -> String { - let full = path.to_string_lossy().into_owned(); - - let s = if !home.is_empty() && full.starts_with(home) { - format!("~{}", full.strip_prefix(home).unwrap_or(&full)) - } else { - full - }; - - if s.width() <= max_len { - return s; - } - - let p = Path::new(&s); - let filename = p - .file_name() - .map_or_else(|| s.clone(), |f| f.to_string_lossy().into_owned()); - let parent_name = p - .parent() - .and_then(|p| p.file_name()) - .map(|f| f.to_string_lossy().into_owned()); - let prefix = if s.starts_with('~') { "~" } else { "" }; - - // Try ~/…/parent/filename - if let Some(ref parent) = parent_name { - let candidate = format!("{prefix}/\u{2026}/{parent}/{filename}"); - if candidate.width() <= max_len { - return candidate; - } - } - - // Try ~/…/filename - let candidate = format!("{prefix}/\u{2026}/{filename}"); - if candidate.width() <= max_len { - return candidate; - } - - // Truncate from the left, respecting display column width - let avail = max_len.saturating_sub(1); // 1 column for "…" - let mut width_acc = 0usize; - let mut suffix_chars: Vec = Vec::new(); - for c in s.chars().rev() { - let cw = UnicodeWidthChar::width(c).unwrap_or(1); - if width_acc + cw > avail { - break; - } - width_acc += cw; - suffix_chars.push(c); - } - suffix_chars.reverse(); - let suffix: String = suffix_chars.into_iter().collect(); - format!("\u{2026}{suffix}") -} - -#[derive(Debug, Clone)] -pub struct BitcoinConfigView { - pub selected_index: usize, - pub editing: bool, - pub edit_input: String, - pub save_message: Option, - pub warning_message: Option, - pub sidebar_focused: bool, - /// True when entries have been committed (via `CommitEdit`) but not yet saved to disk. - pub dirty: bool, -} - -impl BitcoinConfigView { - #[must_use] - pub fn new() -> Self { - Self { - selected_index: 0, - editing: false, - edit_input: String::new(), - save_message: None, - warning_message: None, - sidebar_focused: true, - dirty: false, - } - } - - pub fn handle_input(&mut self, key: KeyEvent, entries: &[ConfigEntry]) -> AppAction { - if self.editing { - match key.code { - KeyCode::Enter => { - let action = - AppAction::CommitEdit(self.selected_index, self.edit_input.clone()); - self.editing = false; - self.edit_input.clear(); - self.save_message = None; - action - } - KeyCode::Esc => { - self.editing = false; - self.edit_input.clear(); - AppAction::None - } - KeyCode::Backspace => { - self.edit_input.pop(); - AppAction::None - } - KeyCode::Char(c) => { - self.edit_input.push(c); - AppAction::None - } - _ => AppAction::None, - } - } else { - match key.code { - KeyCode::Up => { - if self.selected_index > 0 { - self.selected_index -= 1; - } - self.save_message = None; - AppAction::None - } - KeyCode::Down => { - if self.selected_index + 1 < entries.len() { - self.selected_index += 1; - } - self.save_message = None; - AppAction::None - } - KeyCode::Enter => { - if !entries.is_empty() { - self.edit_input - .clone_from(&entries[self.selected_index].value); - self.editing = true; - self.save_message = None; - } - AppAction::None - } - KeyCode::Char('s') => AppAction::SaveBitcoinConfig, - KeyCode::Esc => { - self.sidebar_focused = true; - self.save_message = None; - AppAction::None - } - _ => AppAction::None, - } - } - } - - #[allow(clippy::too_many_lines)] // Renders two panels with multiple layout passes - pub fn render(f: &mut Frame, app: &mut App, area: Rect) { - const FIXED: usize = 33; - if app.bitcoin_conf_path.is_none() { - let p = Paragraph::new("Press [Enter] to select a bitcoin.conf file").block( - Block::default() - .borders(Borders::ALL) - .title(" Bitcoin Config "), - ); - f.render_widget(p, area); - return; - } - - let panels = Layout::default() - .direction(Direction::Horizontal) - .constraints([Constraint::Percentage(45), Constraint::Percentage(55)]) - .split(area); - - // Left panel: scrollable entry list - let items: Vec = app - .bitcoin_data - .iter() - .map(|entry| { - let label = entry.schema.as_ref().map_or("", |s| s.description.as_str()); - - let (value_display, value_style) = if entry.enabled { - ( - entry.value.clone(), - Style::default() - .fg(Color::White) - .add_modifier(Modifier::BOLD), - ) - } else { - let placeholder = entry - .schema - .as_ref() - .filter(|s| !s.default.is_empty()) - .map_or_else( - || "not set".to_string(), - |s| format!("default: {}", s.default), - ); - ( - format!("({placeholder})"), - Style::default().fg(Color::DarkGray), - ) - }; - - ListItem::new(vec![ - Line::from(Span::styled(label, Style::default().fg(Color::Gray))), - Line::from(vec![ - Span::styled( - format!("{} = ", entry.key), - Style::default().fg(Color::Cyan), - ), - Span::styled(value_display, value_style), - ]), - ]) - }) - .collect(); - - let mut list_state = ListState::default(); - list_state.select(Some(app.bitcoin_config_view.selected_index)); - - // Border style: dim both panels when the user is navigating the main sidebar - let panel_style = if app.bitcoin_config_view.sidebar_focused { - Style::default().fg(Color::DarkGray) - } else { - Style::default() - }; - - let dirty = app.bitcoin_config_view.dirty; - let path_max = (panels[0].width as usize).saturating_sub(FIXED); - let title = match &app.bitcoin_conf_path { - Some(path) => format!( - " {}Bitcoin Configuration --- {} ", - if dirty { "● " } else { "" }, - shorten_path(path, path_max, &app.home_dir) - ), - None => " Bitcoin Configuration ".to_string(), - }; - let title_style = if dirty { - Style::default().fg(Color::Yellow) - } else { - Style::default() - }; - - let list = List::new(items) - .block( - Block::default() - .borders(Borders::ALL) - .title(title) - .title_style(title_style) - .border_style(panel_style), - ) - .highlight_style(Style::default().bg(Color::DarkGray)); - - f.render_stateful_widget(list, panels[0], &mut list_state); - - // Right panel: detail and edit field - let right_block = Block::default() - .borders(Borders::ALL) - .title(" Detail ") - .border_style(panel_style); - let inner = right_block.inner(panels[1]); - f.render_widget(right_block, panels[1]); - - let selected_entry = app.bitcoin_data.get(app.bitcoin_config_view.selected_index); - let editing = app.bitcoin_config_view.editing; - let edit_input = app.bitcoin_config_view.edit_input.clone(); - - if let Some(entry) = selected_entry { - let description = entry - .schema - .as_ref() - .map_or("Unknown option", |s| s.description.as_str()); - let type_label = entry - .schema - .as_ref() - .map(|s| format!("{}", s.config_type)) - .unwrap_or_default(); - - let rows = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Length(2), // description - Constraint::Length(1), // type - Constraint::Length(1), // spacer - Constraint::Length(1), // "Value:" label - Constraint::Length(3), // value / input box - Constraint::Min(0), - ]) - .split(inner); - - f.render_widget( - Paragraph::new(description).style(Style::default().fg(Color::White)), - rows[0], - ); - f.render_widget( - Paragraph::new(format!("Type: {type_label}")) - .style(Style::default().fg(Color::Gray)), - rows[1], - ); - f.render_widget( - Paragraph::new("Value:").style(Style::default().fg(Color::Gray)), - rows[3], - ); - - if editing { - f.render_widget( - Paragraph::new(edit_input.as_str()) - .block(Block::default().borders(Borders::ALL)) - .style(Style::default().fg(Color::Yellow)), - rows[4], - ); - let cursor_x = - (rows[4].x + 1 + u16::try_from(edit_input.chars().count()).unwrap_or(u16::MAX)) - .min(rows[4].x + rows[4].width.saturating_sub(2)); - let cursor_y = rows[4].y + 1; - f.set_cursor_position((cursor_x, cursor_y)); - } else { - let (display, style) = if entry.enabled { - ( - entry.value.clone(), - Style::default() - .fg(Color::White) - .add_modifier(Modifier::BOLD), - ) - } else { - let placeholder = entry - .schema - .as_ref() - .filter(|s| !s.default.is_empty()) - .map_or_else( - || "not set".to_string(), - |s| format!("default: {}", s.default), - ); - ( - format!("({placeholder})"), - Style::default().fg(Color::DarkGray), - ) - }; - f.render_widget( - Paragraph::new(display) - .block(Block::default().borders(Borders::ALL)) - .style(style), - rows[4], - ); - } - } - } -} - -impl Default for BitcoinConfigView { - fn default() -> Self { - Self::new() - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::app::AppAction; - use crate::bitcoin_config::ConfigEntry; - use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; - - fn entry(key: &str, value: &str, enabled: bool) -> ConfigEntry { - ConfigEntry { - key: key.to_string(), - value: value.to_string(), - enabled, - schema: None, - section: None, - } - } - - fn key(code: KeyCode) -> KeyEvent { - KeyEvent::new(code, KeyModifiers::empty()) - } - - // --- shorten path --- - - #[test] - fn shorten_path_short_enough_unchanged() { - let p = Path::new("/foo/bar.conf"); - assert_eq!(shorten_path(p, 100, ""), "/foo/bar.conf"); - } - - #[test] - fn shorten_path_collapses_to_parent_filename() { - // Path with no HOME prefix, long enough to trigger collapse - let p = Path::new("/a/very/long/path/to/parent/file.conf"); - let result = shorten_path(p, 20, ""); - assert!(result.contains("file.conf")); - assert!(result.width() <= 20); - } - - #[test] - fn shorten_path_collapses_to_filename_only() { - // Parent/filename still too long → ~/…/filename - let long_parent = "/a/b/c/d/longlonglonglongparent/file.conf"; - let p = Path::new(long_parent); - let result = shorten_path(p, 18, ""); - assert!(result.contains("file.conf")); - assert!(result.width() <= 18); - } - - #[test] - fn shorten_path_last_resort_truncation() { - // Even filename alone doesn't fit → truncate with ellipsis - let p = Path::new("/a/b/c/d/e/verylongfilename.conf"); - let result = shorten_path(p, 5, ""); - assert!(result.starts_with('\u{2026}')); - assert!(result.width() <= 5); - } - - #[test] - fn shorten_path_multibyte_chars_respected() { - let p = Path::new("/日本語/パス/ファイル.conf"); - let result = shorten_path(p, 15, ""); - // Must not exceed 15 display columns regardless of byte/char width - assert!( - result.width() <= 15, - "got {} columns: {}", - result.width(), - result - ); - } - - #[test] - fn shorten_path_replaces_home_prefix() { - let home = std::env::var("HOME").unwrap_or_default(); - if home.is_empty() { - return; // skip on systems without HOME - } - let p = Path::new(&home).join("myfile.conf"); - let result = shorten_path(&p, 200, &home); - assert!( - result.starts_with('~'), - "expected ~ prefix, got: {}", - result - ); - } - - // --- handle_input: editing mode --- - - #[test] - fn editing_char_appends_to_input() { - let mut view = BitcoinConfigView::new(); - view.editing = true; - let entries = vec![entry("rpcuser", "old", true)]; - - view.handle_input(key(KeyCode::Char('x')), &entries); - assert_eq!(view.edit_input, "x"); - } - - #[test] - fn editing_backspace_removes_last_char() { - let mut view = BitcoinConfigView::new(); - view.editing = true; - view.edit_input = "ab".to_string(); - let entries = vec![entry("rpcuser", "old", true)]; - - view.handle_input(key(KeyCode::Backspace), &entries); - assert_eq!(view.edit_input, "a"); - } - - #[test] - fn editing_enter_returns_commit_action() { - let mut view = BitcoinConfigView::new(); - view.editing = true; - view.edit_input = "newval".to_string(); - view.selected_index = 0; - let entries = vec![entry("rpcuser", "old", true)]; - - let action = view.handle_input(key(KeyCode::Enter), &entries); - assert!( - matches!(action, AppAction::CommitEdit(0, ref v) if v == "newval"), - "expected CommitEdit(0, newval)" - ); - assert!(!view.editing); - assert!(view.edit_input.is_empty()); - } - - #[test] - fn editing_esc_cancels_without_committing() { - let mut view = BitcoinConfigView::new(); - view.editing = true; - view.edit_input = "draft".to_string(); - let entries = vec![entry("rpcuser", "old", true)]; - - let action = view.handle_input(key(KeyCode::Esc), &entries); - assert!(matches!(action, AppAction::None)); - assert!(!view.editing); - assert!(view.edit_input.is_empty()); - } - - #[test] - fn editing_other_key_is_noop() { - let mut view = BitcoinConfigView::new(); - view.editing = true; - let entries = vec![entry("rpcuser", "old", true)]; - - let action = view.handle_input(key(KeyCode::F(1)), &entries); - assert!(matches!(action, AppAction::None)); - assert!(view.editing); - } - - // --- handle_input: browsing mode --- - - #[test] - fn browsing_down_increments_index() { - let mut view = BitcoinConfigView::new(); - let entries = vec![entry("a", "1", true), entry("b", "2", true)]; - - view.handle_input(key(KeyCode::Down), &entries); - assert_eq!(view.selected_index, 1); - } - - #[test] - fn browsing_down_clamped_at_last_entry() { - let mut view = BitcoinConfigView::new(); - view.selected_index = 1; - let entries = vec![entry("a", "1", true), entry("b", "2", true)]; - - view.handle_input(key(KeyCode::Down), &entries); - assert_eq!(view.selected_index, 1); - } - - #[test] - fn browsing_up_decrements_index() { - let mut view = BitcoinConfigView::new(); - view.selected_index = 1; - let entries = vec![entry("a", "1", true), entry("b", "2", true)]; - - view.handle_input(key(KeyCode::Up), &entries); - assert_eq!(view.selected_index, 0); - } - - #[test] - fn browsing_up_clamped_at_zero() { - let mut view = BitcoinConfigView::new(); - view.selected_index = 0; - let entries = vec![entry("a", "1", true)]; - - view.handle_input(key(KeyCode::Up), &entries); - assert_eq!(view.selected_index, 0); - } - - #[test] - fn browsing_enter_starts_editing_with_current_value() { - let mut view = BitcoinConfigView::new(); - let entries = vec![entry("rpcuser", "alice", true)]; - - view.handle_input(key(KeyCode::Enter), &entries); - assert!(view.editing); - assert_eq!(view.edit_input, "alice"); - } - - #[test] - fn browsing_enter_noop_when_entries_empty() { - let mut view = BitcoinConfigView::new(); - let entries: Vec = vec![]; - - view.handle_input(key(KeyCode::Enter), &entries); - assert!(!view.editing); - } - - #[test] - fn browsing_s_returns_save_action() { - let mut view = BitcoinConfigView::new(); - let entries = vec![entry("rpcuser", "alice", true)]; - - let action = view.handle_input(key(KeyCode::Char('s')), &entries); - assert!(matches!(action, AppAction::SaveBitcoinConfig)); - } - - #[test] - fn browsing_esc_sets_sidebar_focused() { - let mut view = BitcoinConfigView::new(); - view.sidebar_focused = false; - let entries = vec![entry("rpcuser", "alice", true)]; - - view.handle_input(key(KeyCode::Esc), &entries); - assert!(view.sidebar_focused); - } - - #[test] - fn navigation_clears_save_message() { - let entries = vec![entry("a", "1", true), entry("b", "2", true)]; - - // Up clears it - let mut view = BitcoinConfigView::new(); - view.selected_index = 1; - view.save_message = Some("saved".to_string()); - view.handle_input(key(KeyCode::Up), &entries); - assert!(view.save_message.is_none()); - - // Down clears it - let mut view = BitcoinConfigView::new(); - view.save_message = Some("saved".to_string()); - view.handle_input(key(KeyCode::Down), &entries); - assert!(view.save_message.is_none()); - - // Enter (start editing) clears it - let mut view = BitcoinConfigView::new(); - view.save_message = Some("saved".to_string()); - view.handle_input(key(KeyCode::Enter), &entries); - assert!(view.save_message.is_none()); - - // Esc (back to sidebar) clears it - let mut view = BitcoinConfigView::new(); - view.save_message = Some("saved".to_string()); - view.handle_input(key(KeyCode::Esc), &entries); - assert!(view.save_message.is_none()); - } - - #[test] - fn save_key_does_not_clear_save_message() { - let mut view = BitcoinConfigView::new(); - view.save_message = Some("Configuration correctly saved".to_string()); - let entries = vec![entry("rpcuser", "alice", true)]; - - let action = view.handle_input(key(KeyCode::Char('s')), &entries); - assert!(matches!(action, AppAction::SaveBitcoinConfig)); - assert_eq!( - view.save_message.as_deref(), - Some("Configuration correctly saved"), - "save_message must not be cleared when pressing s" - ); - } - - #[test] - fn commit_edit_clears_save_message() { - let mut view = BitcoinConfigView::new(); - view.editing = true; - view.edit_input = "newval".to_string(); - view.save_message = Some("saved".to_string()); - let entries = vec![entry("rpcuser", "alice", true)]; - - view.handle_input(key(KeyCode::Enter), &entries); - assert!(view.save_message.is_none()); - } - - #[test] - fn unrecognised_key_preserves_save_message() { - let mut view = BitcoinConfigView::new(); - view.save_message = Some("saved".to_string()); - let entries = vec![entry("rpcuser", "alice", true)]; - - view.handle_input(key(KeyCode::F(1)), &entries); - assert_eq!(view.save_message.as_deref(), Some("saved")); - } - - #[test] - fn render_with_entries_exercises_items_loop() { - use crate::app::App; - use crate::bitcoin_config::{ConfigCategory, ConfigSchema, ConfigType}; - use ratatui::Terminal; - use ratatui::backend::TestBackend; - - let mut app = App::new(); - // Set a path so render goes past the early-return guard - app.bitcoin_conf_path = Some(std::path::PathBuf::from("/tmp/bitcoin.conf")); - - // One enabled entry - let mut e1 = entry("rpcuser", "alice", true); - e1.schema = Some(ConfigSchema::new( - "rpcuser", - "", - ConfigType::String, - ConfigCategory::RPC, - "RPC username", - )); - - // One disabled entry with schema - let mut e2 = entry("dbcache", "450", false); - e2.schema = Some(ConfigSchema::new( - "dbcache", - "450", - ConfigType::Int, - ConfigCategory::Core, - "DB cache size", - )); - - // One disabled entry with no schema - let e3 = entry("unknownkey", "", false); - - app.bitcoin_data = vec![e1, e2, e3]; - - let mut terminal = Terminal::new(TestBackend::new(120, 30)).unwrap(); - terminal - .draw(|f| { - let area = f.area(); - BitcoinConfigView::render(f, &mut app, area); - }) - .unwrap(); - - let output: String = terminal - .backend() - .buffer() - .content() - .iter() - .map(|c| c.symbol().to_string()) - .collect(); - - assert!(output.contains("Bitcoin Configuration")); - } -} diff --git a/src/components/bitcoin_status_view.rs b/src/components/bitcoin_status_view.rs index 97b474f..1ccfabe 100644 --- a/src/components/bitcoin_status_view.rs +++ b/src/components/bitcoin_status_view.rs @@ -11,7 +11,7 @@ use ratatui::{ // Bitcoin Status tabs count const _: () = assert!( - BITCOIN_STATUS_TABS.len() == 4, + BITCOIN_STATUS_TABS.len() == 2, "update tab dispatch match in bitcoin_status_view.rs" ); @@ -44,32 +44,16 @@ impl BitcoinStatusView { match app.bitcoin_status_tab { // Chain Info 0 => Self::render_chain_info(f, app, content_area), - // System - 1 => { - let text = "System"; - let p = Paragraph::new(text) - .block(Block::default().borders(Borders::ALL)) - .wrap(Wrap { trim: true }); - f.render_widget(p, content_area); - } - // 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); - } // Peers - 3 => Self::render_peers(f, app, content_area), + 1 => Self::render_peers(f, app, content_area), _ => {} } } fn render_chain_info(f: &mut Frame, app: &App, area: Rect) { - let text = if app.bitcoin_conf_path.is_none() { + let text = if app.p2pool_config.is_none() { vec![Line::from(Span::styled( - "Select a bitcoin.conf file to load Bitcoin Core chain info.", + "Select a P2Poolv2 config file to load Bitcoin Core chain info.", Style::default().fg(Color::DarkGray), ))] } else if let Some(info) = &app.bitcoin_chain_info { @@ -110,9 +94,9 @@ impl BitcoinStatusView { } fn render_peers(f: &mut Frame, app: &App, area: Rect) { - let text = if app.bitcoin_conf_path.is_none() { + let text = if app.p2pool_config.is_none() { vec![Line::from(Span::styled( - "Select a bitcoin.conf file to load Bitcoin Core peer info.", + "Select a P2Poolv2 config file to load Bitcoin Core peer info.", Style::default().fg(Color::DarkGray), ))] } else if let Some(info) = &app.bitcoin_chain_info { @@ -181,9 +165,16 @@ impl Default for BitcoinStatusView { mod tests { use super::*; use crate::app::App; + + fn loaded_p2pool_config() -> p2poolv2_config::Config { + p2poolv2_config::Config::load(concat!( + env!("CARGO_MANIFEST_DIR"), + "/tests/fixtures/p2pool.toml" + )) + .unwrap() + } use crate::components::bitcoin_client::BitcoinChainInfo; use ratatui::{Terminal, backend::TestBackend, prelude::Rect}; - use std::path::PathBuf; fn render_view(app: &App) -> String { let backend = TestBackend::new(80, 25); @@ -209,7 +200,7 @@ mod tests { let output = render_view(&app); - assert!(output.contains("Select a bitcoin.conf file to load Bitcoin Core chain info.")); + assert!(output.contains("Select a P2Poolv2 config file to load Bitcoin Core chain info.")); assert!(!output.contains("Loading Bitcoin chain info")); assert!(!output.contains("Failed to fetch Bitcoin chain info")); } @@ -217,7 +208,7 @@ mod tests { #[test] fn renders_loaded_chain_info_with_formatted_values() { let mut app = App::new(); - app.bitcoin_conf_path = Some(PathBuf::from("/tmp/bitcoin.conf")); + app.p2pool_config = Some(loaded_p2pool_config()); app.bitcoin_chain_info = Some(BitcoinChainInfo { network: "mainnet".to_string(), block_height: 850_000, @@ -246,19 +237,19 @@ mod tests { #[test] fn renders_loading_state_when_chain_info_is_pending() { let mut app = App::new(); - app.bitcoin_conf_path = Some(PathBuf::from("/tmp/bitcoin.conf")); + app.p2pool_config = Some(loaded_p2pool_config()); let output = render_view(&app); assert!(output.contains("Loading Bitcoin chain info...")); - assert!(!output.contains("Select a bitcoin.conf file")); + assert!(!output.contains("Select a P2Poolv2 config file")); assert!(!output.contains("Failed to fetch Bitcoin chain info")); } #[test] fn renders_error_state_when_chain_info_fetch_fails() { let mut app = App::new(); - app.bitcoin_conf_path = Some(PathBuf::from("/tmp/bitcoin.conf")); + app.p2pool_config = Some(loaded_p2pool_config()); app.bitcoin_chain_info_error = Some("RPC offline".to_string()); let output = render_view(&app); @@ -271,7 +262,7 @@ mod tests { #[test] fn renders_none_and_false_formatting_for_optional_values() { let mut app = App::new(); - app.bitcoin_conf_path = Some(PathBuf::from("/tmp/bitcoin.conf")); + app.p2pool_config = Some(loaded_p2pool_config()); app.bitcoin_chain_info = Some(BitcoinChainInfo { network: "testnet".to_string(), block_height: 42, @@ -289,7 +280,7 @@ mod tests { assert!(output.contains("Connection Count : -")); } - /// Renders the Peers tab (index 3) and returns the buffer content as a string. + /// Renders the Peers tab (index 1) and returns the buffer content as a string. fn render_peers_view(app: &App) -> String { let backend = TestBackend::new(80, 25); let mut terminal = Terminal::new(backend).unwrap(); @@ -311,11 +302,11 @@ mod tests { #[test] fn peers_renders_prompt_when_no_bitcoin_conf_is_selected() { let mut app = App::new(); - app.bitcoin_status_tab = 3; + app.bitcoin_status_tab = 1; let output = render_peers_view(&app); - assert!(output.contains("Select a bitcoin.conf file to load Bitcoin Core peer info.")); + assert!(output.contains("Select a P2Poolv2 config file to load Bitcoin Core peer info.")); assert!(!output.contains("Loading Bitcoin peer info")); assert!(!output.contains("Failed to fetch Bitcoin peer info")); } @@ -323,8 +314,8 @@ mod tests { #[test] fn peers_renders_populated_address_list() { let mut app = App::new(); - app.bitcoin_status_tab = 3; - app.bitcoin_conf_path = Some(PathBuf::from("/tmp/bitcoin.conf")); + app.bitcoin_status_tab = 1; + app.p2pool_config = Some(loaded_p2pool_config()); app.bitcoin_chain_info = Some(BitcoinChainInfo { network: "mainnet".to_string(), block_height: 850_000, @@ -350,8 +341,8 @@ mod tests { #[test] fn peers_renders_none_when_address_list_is_empty() { let mut app = App::new(); - app.bitcoin_status_tab = 3; - app.bitcoin_conf_path = Some(PathBuf::from("/tmp/bitcoin.conf")); + app.bitcoin_status_tab = 1; + app.p2pool_config = Some(loaded_p2pool_config()); app.bitcoin_chain_info = Some(BitcoinChainInfo { network: "mainnet".to_string(), block_height: 850_000, @@ -372,21 +363,21 @@ mod tests { #[test] fn peers_renders_loading_state_when_chain_info_is_pending() { let mut app = App::new(); - app.bitcoin_status_tab = 3; - app.bitcoin_conf_path = Some(PathBuf::from("/tmp/bitcoin.conf")); + app.bitcoin_status_tab = 1; + app.p2pool_config = Some(loaded_p2pool_config()); let output = render_peers_view(&app); assert!(output.contains("Loading Bitcoin peer info...")); - assert!(!output.contains("Select a bitcoin.conf file")); + assert!(!output.contains("Select a P2Poolv2 config file")); assert!(!output.contains("Failed to fetch Bitcoin peer info")); } #[test] fn peers_renders_error_state_when_chain_info_fetch_fails() { let mut app = App::new(); - app.bitcoin_status_tab = 3; - app.bitcoin_conf_path = Some(PathBuf::from("/tmp/bitcoin.conf")); + app.bitcoin_status_tab = 1; + app.p2pool_config = Some(loaded_p2pool_config()); app.bitcoin_chain_info_error = Some("connection refused".to_string()); let output = render_peers_view(&app); diff --git a/src/components/ln_config_view.rs b/src/components/ln_config_view.rs deleted file mode 100644 index b21678f..0000000 --- a/src/components/ln_config_view.rs +++ /dev/null @@ -1,32 +0,0 @@ -// SPDX-FileCopyrightText: 2024 PDM Authors -// -// SPDX-License-Identifier: AGPL-3.0-or-later - -use crate::app::App; -use ratatui::{ - prelude::*, - widgets::{Block, Borders, Paragraph}, -}; - -#[derive(Debug, Clone)] -pub struct LNConfigView; - -impl LNConfigView { - #[must_use] - pub fn new() -> Self { - Self - } - - // LN Config - pub fn render(f: &mut Frame, _app: &mut App, area: Rect) { - let p = Paragraph::new("LN Config") - .block(Block::default().borders(Borders::ALL).title(" LN Config ")); - f.render_widget(p, area); - } -} - -impl Default for LNConfigView { - fn default() -> Self { - Self::new() - } -} diff --git a/src/components/ln_status_view.rs b/src/components/ln_status_view.rs deleted file mode 100644 index 72dffa0..0000000 --- a/src/components/ln_status_view.rs +++ /dev/null @@ -1,32 +0,0 @@ -// SPDX-FileCopyrightText: 2024 PDM Authors -// -// SPDX-License-Identifier: AGPL-3.0-or-later - -use crate::app::App; -use ratatui::{ - prelude::*, - widgets::{Block, Borders, Paragraph}, -}; - -#[derive(Debug, Clone)] -pub struct LNStatusView; - -impl LNStatusView { - #[must_use] - pub fn new() -> Self { - Self - } - - // LN Status - pub fn render(f: &mut Frame, _app: &mut App, area: Rect) { - let p = Paragraph::new("LN Status") - .block(Block::default().borders(Borders::ALL).title(" LN Status ")); - f.render_widget(p, area); - } -} - -impl Default for LNStatusView { - fn default() -> Self { - Self::new() - } -} diff --git a/src/components/mod.rs b/src/components/mod.rs index e55d93a..0e44ffa 100644 --- a/src/components/mod.rs +++ b/src/components/mod.rs @@ -3,16 +3,12 @@ // SPDX-License-Identifier: AGPL-3.0-or-later pub mod bitcoin_client; -pub mod bitcoin_config_view; pub mod bitcoin_status_view; pub mod file_explorer; pub mod home_view; -pub mod ln_config_view; -pub mod ln_status_view; pub mod p2pool_client; pub mod p2pool_config_view; pub mod p2pool_status_view; pub mod p2pool_websocket; pub mod settings_view; -pub mod shares_market_view; pub mod status_bar; diff --git a/src/components/p2pool_client.rs b/src/components/p2pool_client.rs index a7741db..b765dc4 100644 --- a/src/components/p2pool_client.rs +++ b/src/components/p2pool_client.rs @@ -3,7 +3,7 @@ // SPDX-License-Identifier: AGPL-3.0-or-later use crate::components::p2pool_websocket::P2PoolWebSocketClient; -use crate::config::{ApiConfig, load_api_config}; +use p2poolv2_config::{ApiConfig as P2PoolApiConfig, Config as P2PoolConfig}; use reqwest::Client; use serde::Deserialize; use serde::Deserializer; @@ -16,7 +16,6 @@ const REQUEST_TIMEOUT_SECONDS: u64 = 10; pub struct P2PoolClient { client: Client, base_url: String, - fallback_base_url: Option, auth_credentials: Option<(String, String)>, } @@ -73,19 +72,18 @@ fn build_client() -> Client { impl P2PoolClient { pub fn new() -> Self { - Self::from_config(load_api_config().unwrap_or_default()) + Self::with_base_url("") } - fn from_config(config: ApiConfig) -> Self { - let client = P2PoolClient::with_base_url(&config.base_url); + pub fn from_p2pool_config(config: &P2PoolConfig) -> Self { + Self::from_api_config(&config.api) + } - let client = if let Some(fallback) = &config.fallback_base_url { - client.with_fallback_base_url(fallback) - } else { - client - }; + fn from_api_config(config: &P2PoolApiConfig) -> Self { + let client = + P2PoolClient::with_base_url(format!("http://{}:{}", config.hostname, config.port)); - if let Some((user, pass)) = config.auth_user.zip(config.auth_pass) { + if let Some((user, pass)) = api_auth_credentials(config) { client.with_auth(user, pass) } else { client @@ -96,7 +94,6 @@ impl P2PoolClient { Self { client: build_client(), base_url: base_url.into(), - fallback_base_url: None, auth_credentials: None, } } @@ -105,7 +102,6 @@ impl P2PoolClient { Self { client, base_url: base_url.into(), - fallback_base_url: None, auth_credentials: None, } } @@ -115,59 +111,33 @@ impl P2PoolClient { self } - pub fn with_fallback_base_url(mut self, fallback_base_url: impl Into) -> Self { - self.fallback_base_url = Some(fallback_base_url.into()); - self - } - pub fn websocket_client(&self) -> P2PoolWebSocketClient { let mut client = P2PoolWebSocketClient::with_base_url(self.base_url.clone()); if let Some((user, pass)) = &self.auth_credentials { client = client.with_auth(user.clone(), pass.clone()); } - if let Some(fallback_base_url) = &self.fallback_base_url { - client = client.with_fallback_base_url(fallback_base_url.clone()); - } + client } pub async fn fetch_chain_info(&self) -> Result { - self.fetch_json_with_fallback("/chain_info", &[]).await + self.fetch_json("/chain_info", &[]).await } pub async fn fetch_peer_info(&self) -> Result, reqwest::Error> { - self.fetch_json_with_fallback("/peers", &[]).await + self.fetch_json("/peers", &[]).await } pub async fn fetch_recent_shares(&self, num: u16) -> Result { - self.fetch_json_with_fallback("/shares", &[("num", num.min(100))]) - .await + self.fetch_json("/shares", &[("num", num.min(100))]).await } - async fn fetch_json_with_fallback( - &self, - path: &str, - query: &[(&str, u16)], - ) -> Result + async fn fetch_json(&self, path: &str, query: &[(&str, u16)]) -> Result where T: DeserializeOwned, { - match self - .fetch_json_from_base_url(&self.base_url, path, query, true) + self.fetch_json_from_base_url(&self.base_url, path, query, true) .await - { - Ok(data) => Ok(data), - Err(error) => { - if self.should_try_fallback(&error) - && let Some(fallback_base_url) = &self.fallback_base_url - { - return self - .fetch_json_from_base_url(fallback_base_url, path, query, true) - .await; - } - Err(error) - } - } } async fn fetch_json_from_base_url( @@ -194,10 +164,6 @@ impl P2PoolClient { let response = request.send().await?.error_for_status()?; response.json::().await } - - fn should_try_fallback(&self, error: &reqwest::Error) -> bool { - self.fallback_base_url.is_some() && (error.is_connect() || error.is_timeout()) - } } impl Default for P2PoolClient { @@ -223,44 +189,83 @@ where } } +fn api_auth_credentials(config: &P2PoolApiConfig) -> Option<(String, String)> { + match (&config.auth_user, &config.auth_password) { + (Some(user), Some(password)) if !user.is_empty() && !password.is_empty() => { + Some((user.clone(), password.clone())) + } + _ => None, + } +} + #[cfg(test)] mod tests { use super::*; use mockito::{Matcher, Server}; use serde_json::json; - const PRIMARY_BASE_URL: &str = "http://127.0.0.1:46884"; - const FALLBACK_BASE_URL: &str = "http://127.0.0.1:46885"; + fn loaded_p2pool_config() -> P2PoolConfig { + P2PoolConfig::load(concat!( + env!("CARGO_MANIFEST_DIR"), + "/tests/fixtures/p2pool.toml" + )) + .expect("fixture config must load") + } - fn api_config(fallback_base_url: Option<&str>) -> ApiConfig { - ApiConfig { - base_url: PRIMARY_BASE_URL.to_string(), - fallback_base_url: fallback_base_url.map(str::to_string), - auth_user: None, - auth_pass: None, - } + #[test] + fn new_starts_without_pdm_side_api_defaults() { + let client = P2PoolClient::new(); + + assert_eq!(client.base_url, ""); + assert_eq!(client.auth_credentials, None); + } + #[test] + fn from_p2pool_config_uses_api_hostname_and_port() { + let mut config = loaded_p2pool_config(); + config.api.hostname = "192.0.2.10".to_string(); + config.api.port = 39001; + + let client = P2PoolClient::from_p2pool_config(&config); + + assert_eq!(client.base_url, "http://192.0.2.10:39001"); } #[test] - fn explicit_base_url_does_not_enable_network_fallback() { - let config = api_config(None); - let client = P2PoolClient::from_config(config); + fn from_p2pool_config_uses_api_auth_password_for_basic_auth() { + let mut config = loaded_p2pool_config(); + config.api.auth_user = Some("pdm-user".to_string()); + config.api.auth_token = Some("stored-token".to_string()); + config.api.auth_password = Some("pdm-pass".to_string()); + + let client = P2PoolClient::from_p2pool_config(&config); - assert_eq!(client.base_url, PRIMARY_BASE_URL); - assert_eq!(client.fallback_base_url, None); + assert_eq!( + client.auth_credentials, + Some(("pdm-user".to_string(), "pdm-pass".to_string())) + ); } #[test] - fn fallback_base_url_can_be_configured() { - let config = api_config(Some(FALLBACK_BASE_URL)); - let client = P2PoolClient::from_config(config); + fn from_p2pool_config_does_not_use_stored_auth_token_as_password() { + let mut config = loaded_p2pool_config(); + config.api.auth_user = Some("pdm-user".to_string()); + config.api.auth_token = Some("stored-token".to_string()); + config.api.auth_password = None; - assert_eq!(client.fallback_base_url.as_deref(), Some(FALLBACK_BASE_URL)); + let client = P2PoolClient::from_p2pool_config(&config); + + assert_eq!(client.auth_credentials, None); } #[tokio::test] - async fn fallback_fetch_uses_basic_auth_when_configured() { + async fn fetch_uses_p2pool_config_api_values() { let mut server = Server::new_async().await; + let server_url = url::Url::parse(&server.url()).unwrap(); + let mut config = loaded_p2pool_config(); + config.api.hostname = server_url.host_str().unwrap().to_string(); + config.api.port = server_url.port().unwrap(); + config.api.auth_user = Some("user".to_string()); + config.api.auth_password = Some("password".to_string()); let mock = server .mock("GET", "/chain_info") @@ -270,10 +275,7 @@ mod tests { .with_body(json!({ "total_work": "abc" }).to_string()) .create(); - let client = P2PoolClient::with_base_url("http://127.0.0.1:1") - .with_fallback_base_url(server.url()) - .with_auth("user".into(), "password".into()); - + let client = P2PoolClient::from_p2pool_config(&config); let result = client.fetch_chain_info().await.unwrap(); assert_eq!(result.total_work, "abc"); diff --git a/src/components/p2pool_status_view.rs b/src/components/p2pool_status_view.rs index db8ab0e..393224d 100644 --- a/src/components/p2pool_status_view.rs +++ b/src/components/p2pool_status_view.rs @@ -3,6 +3,7 @@ // SPDX-License-Identifier: AGPL-3.0-or-later use crate::app::{App, P2POOL_STATUS_TABS}; +use crate::p2poolv2_service::{P2PoolV2Service, instance_from_config_path}; use ratatui::{ prelude::*, widgets::{Block, Borders, Cell, Paragraph, Row, Table, Tabs, Wrap}, @@ -49,6 +50,7 @@ impl P2PoolStatusView { 0 => Self::render_chain_info(f, app, outer[1]), 1 => Self::render_share_info(f, app, outer[1]), 2 => Self::render_peer_info(f, app, outer[1]), + 3 => Self::render_system_info(f, app, outer[1]), _ => {} } } @@ -76,6 +78,11 @@ impl P2PoolStatusView { format!("Failed to fetch chain info: {err}"), Style::default().fg(Color::Red), ))] + } else if app.p2pool_config.is_none() { + vec![Line::from(Span::styled( + "Select a P2Poolv2 config file to load P2Pool chain info.", + Style::default().fg(Color::DarkGray), + ))] } else { vec![Line::from(Span::styled( "Loading chain info...", @@ -158,6 +165,11 @@ impl P2PoolStatusView { format!("Failed to fetch peer info: {err}"), Style::default().fg(Color::Red), ))] + } else if app.p2pool_config.is_none() { + vec![Line::from(Span::styled( + "Select a P2Poolv2 config file to load P2Pool peer info.", + Style::default().fg(Color::DarkGray), + ))] } else { vec![Line::from(Span::styled( "Loading peer info...", @@ -195,6 +207,59 @@ impl P2PoolStatusView { f.render_widget(paragraph, area); } + fn render_system_info(f: &mut Frame, app: &App, area: Rect) { + if let Some(error) = &app.p2pool_service_error { + let paragraph = Paragraph::new(vec![ + Line::from(Span::styled( + "Service action failed", + Style::default().fg(Color::Red), + )), + Line::from(""), + Line::from(error.as_str()), + ]) + .block(Block::default().borders(Borders::ALL).title(" System ")) + .wrap(Wrap { trim: true }); + + f.render_widget(paragraph, area); + return; + } + + let text = if let Some(instance) = app + .p2pool_conf_path + .as_deref() + .and_then(instance_from_config_path) + { + let running = P2PoolV2Service::is_running(&instance).unwrap_or(false); + let status = if running { + Span::styled("Running", Style::default().fg(Color::Green)) + } else { + Span::styled("Stopped", Style::default().fg(Color::Red)) + }; + + vec![ + Line::from(format!("Instance : {instance}")), + Line::from(vec![Span::raw("Service Status : "), status]), + Line::from(""), + Line::from("[s] Start [x] Stop [r] Restart"), + ] + } else { + vec![ + Line::from(Span::styled( + "Service controls unavailable", + Style::default().fg(Color::Yellow), + )), + Line::from(""), + Line::from("Select a config in the user config directory"), + Line::from("with a config-.toml filename."), + ] + }; + + let paragraph = Paragraph::new(text) + .block(Block::default().borders(Borders::ALL).title(" System ")) + .wrap(Wrap { trim: true }); + + f.render_widget(paragraph, area); + } fn short_value(value: &str, max_len: usize) -> String { if value.len() <= max_len { return value.to_string(); @@ -312,6 +377,10 @@ impl P2PoolStatusView { return format!("Live shares unavailable: {}", Self::short_value(err, 64)); } + if app.p2pool_config.is_none() { + return "Select a P2Poolv2 config file to load P2Pool shares.".to_string(); + } + "Waiting for share data...".to_string() } @@ -439,6 +508,7 @@ mod tests { const SHARE_TAB: usize = 1; const PEER_TAB: usize = 2; + const SYSTEM_TAB: usize = 3; fn render_view(app: &App) -> String { let backend = TestBackend::new(100, 25); @@ -519,7 +589,7 @@ mod tests { let output = render_view(&app); - assert!(output.contains("Loading chain info...")); + assert!(output.contains("Select a P2Poolv2 config file")); } #[test] @@ -598,7 +668,34 @@ mod tests { let output = render_view(&app); - assert!(output.contains("Loading peer info...")); + assert!(output.contains("Select a P2Poolv2 config file")); + } + + #[test] + fn render_dispatches_system_tab_and_explains_unavailable_controls() { + let mut app = App::new(); + app.p2pool_status_tab = SYSTEM_TAB; + + let output = render_view(&app); + + assert!(output.contains("Service controls unavailable")); + assert!(output.contains("config-.toml")); + } + + #[test] + fn render_system_tab_shows_service_action_error() { + let mut app = App::new(); + app.p2pool_status_tab = SYSTEM_TAB; + app.p2pool_service_error = Some( + "Start failed: systemctl --user start p2poolv2@signet failed: connection refused" + .to_string(), + ); + + let output = render_view(&app); + + assert!(output.contains("Service action failed")); + assert!(output.contains("connection refused")); + assert!(!output.contains("[s] Start")); } #[test] @@ -666,6 +763,13 @@ mod tests { #[test] fn render_peer_info_shows_loading_state_with_no_data() { let mut app = App::new(); + app.set_p2pool_config( + p2poolv2_config::Config::load(concat!( + env!("CARGO_MANIFEST_DIR"), + "/tests/fixtures/p2pool.toml" + )) + .unwrap(), + ); app.p2pool_status_tab = PEER_TAB; let output = render_view(&app); diff --git a/src/components/p2pool_websocket.rs b/src/components/p2pool_websocket.rs index b0e70fb..4b700bb 100644 --- a/src/components/p2pool_websocket.rs +++ b/src/components/p2pool_websocket.rs @@ -2,11 +2,11 @@ // // SPDX-License-Identifier: AGPL-3.0-or-later -use crate::config::{ApiConfig, load_api_config}; use anyhow::{Context, Result}; use base64::Engine as _; use base64::engine::general_purpose::STANDARD; use futures_util::{SinkExt, StreamExt}; +use p2poolv2_config::{ApiConfig as P2PoolApiConfig, Config as P2PoolConfig}; use serde::{Deserialize, Deserializer}; use tokio::sync::mpsc; use tokio_tungstenite::connect_async; @@ -16,7 +16,6 @@ use url::Url; #[derive(Debug, Clone)] pub struct P2PoolWebSocketClient { base_url: String, - fallback_base_url: Option, auth_credentials: Option<(String, String)>, } @@ -73,19 +72,20 @@ pub enum LiveP2PoolEvent { impl P2PoolWebSocketClient { pub fn new() -> Self { - Self::from_config(load_api_config().unwrap_or_default()) + Self::with_base_url("") } - fn from_config(config: ApiConfig) -> Self { - let client = P2PoolWebSocketClient::with_base_url(&config.base_url); + pub fn from_p2pool_config(config: &P2PoolConfig) -> Self { + Self::from_api_config(&config.api) + } - let client = if let Some(fallback) = &config.fallback_base_url { - client.with_fallback_base_url(fallback) - } else { - client - }; + fn from_api_config(config: &P2PoolApiConfig) -> Self { + let client = P2PoolWebSocketClient::with_base_url(format!( + "http://{}:{}", + config.hostname, config.port + )); - if let Some((user, pass)) = config.auth_user.zip(config.auth_pass) { + if let Some((user, pass)) = api_auth_credentials(config) { client.with_auth(user, pass) } else { client @@ -95,7 +95,6 @@ impl P2PoolWebSocketClient { pub fn with_base_url(base_url: impl Into) -> Self { Self { base_url: base_url.into(), - fallback_base_url: None, auth_credentials: None, } } @@ -105,11 +104,6 @@ impl P2PoolWebSocketClient { self } - pub fn with_fallback_base_url(mut self, fallback_base_url: impl Into) -> Self { - self.fallback_base_url = Some(fallback_base_url.into()); - self - } - fn ws_url(&self, path: &str) -> Result { self.ws_url_from_base_url(&self.base_url, path) } @@ -177,18 +171,6 @@ impl P2PoolWebSocketClient { } } - if let Some(fallback_base_url) = &self.fallback_base_url { - let mut fallback_url = self.ws_url_from_base_url(fallback_base_url, "/ws")?; - self.apply_auth(&mut fallback_url); - if self - .subscribe_live_events_at(fallback_url, tx) - .await - .is_ok() - { - return Ok(()); - } - } - Err(primary_error.unwrap_or_else(|| anyhow::anyhow!("websocket connection failed"))) } @@ -274,6 +256,15 @@ where } } +fn api_auth_credentials(config: &P2PoolApiConfig) -> Option<(String, String)> { + match (&config.auth_user, &config.auth_password) { + (Some(user), Some(password)) if !user.is_empty() && !password.is_empty() => { + Some((user.clone(), password.clone())) + } + _ => None, + } +} + #[cfg(test)] mod tests { use super::*; @@ -283,6 +274,21 @@ mod tests { use tokio_tungstenite::accept_async; use tokio_tungstenite::tungstenite::Message; + fn loaded_p2pool_config() -> P2PoolConfig { + P2PoolConfig::load(concat!( + env!("CARGO_MANIFEST_DIR"), + "/tests/fixtures/p2pool.toml" + )) + .expect("fixture config must load") + } + + #[test] + fn new_starts_without_pdm_side_api_defaults() { + let client = P2PoolWebSocketClient::new(); + + assert_eq!(client.base_url, ""); + assert_eq!(client.auth_credentials, None); + } #[test] fn ws_url_converts_http_to_ws_and_encodes_auth_token() { let client = P2PoolWebSocketClient::with_base_url("http://127.0.0.1:46884") @@ -397,9 +403,16 @@ mod tests { let websocket = accept_async(stream).await.unwrap(); let (mut write, mut read) = websocket.split(); - for _ in 0..2 { - let _ = read.next().await.unwrap().unwrap(); - } + let shares_subscription = read.next().await.unwrap().unwrap().into_text().unwrap(); + let peers_subscription = read.next().await.unwrap().unwrap().into_text().unwrap(); + assert_eq!( + shares_subscription, + r#"{"action":"subscribe","topic":"shares"}"# + ); + assert_eq!( + peers_subscription, + r#"{"action":"subscribe","topic":"peers"}"# + ); write.send(Message::Binary(vec![1, 2, 3])).await.unwrap(); write @@ -505,10 +518,41 @@ mod tests { assert!(event.is_err()); } + #[test] + fn from_p2pool_config_builds_websocket_url_from_api_section() { + let mut config = loaded_p2pool_config(); + config.api.hostname = "192.0.2.10".to_string(); + config.api.port = 39001; + config.api.auth_user = Some("ws-user".to_string()); + config.api.auth_token = Some("stored-token".to_string()); + config.api.auth_password = Some("ws-pass".to_string()); + + let client = P2PoolWebSocketClient::from_p2pool_config(&config); + let url = client.ws_url_with_auth("/ws").unwrap(); + + assert_eq!( + url.as_str(), + "ws://192.0.2.10:39001/ws?token=d3MtdXNlcjp3cy1wYXNz" + ); + } + + #[test] + fn from_p2pool_config_does_not_use_stored_auth_token_as_password() { + let mut config = loaded_p2pool_config(); + config.api.auth_user = Some("ws-user".to_string()); + config.api.auth_token = Some("stored-token".to_string()); + config.api.auth_password = None; + + let client = P2PoolWebSocketClient::from_p2pool_config(&config); + let url = client.ws_url_with_auth("/ws").unwrap(); + + assert_eq!(url.as_str(), "ws://127.0.0.1:46884/ws"); + } + #[tokio::test] - async fn subscribe_live_events_uses_fallback_base_url_on_connection_failure() { + async fn subscribe_live_events_uses_p2pool_config_api_values() { let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let fallback_addr = listener.local_addr().unwrap(); + let addr = listener.local_addr().unwrap(); let server = tokio::spawn(async move { let (stream, _) = listener.accept().await.unwrap(); @@ -522,7 +566,7 @@ mod tests { serde_json::json!({ "topic": "Share", "data": { - "blockhash": "fallback", + "blockhash": "from-config", "prev_blockhash": "prev", "height": 7, "miner_address": "miner", @@ -538,8 +582,10 @@ mod tests { let _ = write.close().await; }); - let client = P2PoolWebSocketClient::with_base_url("http://127.0.0.1:1") - .with_fallback_base_url(format!("http://{fallback_addr}")); + let mut config = loaded_p2pool_config(); + config.api.hostname = addr.ip().to_string(); + config.api.port = addr.port(); + let client = P2PoolWebSocketClient::from_p2pool_config(&config); let (tx, mut rx) = mpsc::unbounded_channel(); let subscribe_handle = tokio::spawn(async move { client.subscribe_live_events(tx).await }); diff --git a/src/components/settings_view.rs b/src/components/settings_view.rs index 58b02c6..c8d1947 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 = 2; /// Describes how a settings field behaves when Enter is pressed. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -23,10 +23,7 @@ pub enum FieldKind { /// All settings fields in display order. Each entry is `(label, kind)`. pub const FIELDS: [(&str, FieldKind); FIELD_COUNT] = [ - ("Bitcoin config path", FieldKind::FilePicker), ("P2Pool config path", FieldKind::FilePicker), - ("LN config path", FieldKind::FilePicker), - ("Shares Market config path", FieldKind::FilePicker), ("Settings directory", FieldKind::DirectoryPicker), ]; @@ -80,22 +77,10 @@ impl SettingsView { pub fn render(f: &mut Frame, app: &mut App, area: Rect) { let values: [Option; FIELD_COUNT] = [ - app.settings - .bitcoin_conf_path - .as_ref() - .map(|p| p.to_string_lossy().into_owned()), app.settings .p2pool_conf_path .as_ref() .map(|p| p.to_string_lossy().into_owned()), - app.settings - .ln_conf_path - .as_ref() - .map(|p| p.to_string_lossy().into_owned()), - app.settings - .shares_market_conf_path - .as_ref() - .map(|p| p.to_string_lossy().into_owned()), app.settings .settings_dir_override .as_ref() @@ -114,7 +99,7 @@ impl SettingsView { .add_modifier(Modifier::BOLD), ), None => { - if idx == 4 { + if idx == 1 { let path = if app.config_dir.as_os_str().is_empty() { "(unknown)".to_string() } else { @@ -207,9 +192,9 @@ mod tests { #[test] fn browsing_up_decrements_index() { let mut view = content_focused_view(); - view.selected_index = 2; + view.selected_index = 1; view.handle_input(key(KeyCode::Up)); - assert_eq!(view.selected_index, 1); + assert_eq!(view.selected_index, 0); } #[test] @@ -268,10 +253,7 @@ mod tests { use ratatui::backend::TestBackend; let mut app = App::new(); - app.settings.bitcoin_conf_path = Some(std::path::PathBuf::from("/tmp/bitcoin.conf")); 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.settings_dir_override = Some(std::path::PathBuf::from("/custom/dir")); app.settings_view.sidebar_focused = false; @@ -291,13 +273,13 @@ mod tests { .map(|c| c.symbol().to_string()) .collect(); - assert!(output.contains("bitcoin.conf") || output.contains("Settings")); + assert!(output.contains("p2pool.toml") || output.contains("Settings")); assert!(output.contains("custom") || output.contains("Settings directory")); } #[test] #[serial_test::serial] - fn render_field4_shows_default_config_dir_when_no_override() { + fn render_field2_shows_default_config_dir_when_no_override() { use crate::app::App; use ratatui::Terminal; use ratatui::backend::TestBackend; diff --git a/src/components/shares_market_view.rs b/src/components/shares_market_view.rs deleted file mode 100644 index 01df169..0000000 --- a/src/components/shares_market_view.rs +++ /dev/null @@ -1,35 +0,0 @@ -// SPDX-FileCopyrightText: 2024 PDM Authors -// -// SPDX-License-Identifier: AGPL-3.0-or-later - -use crate::app::App; -use ratatui::{ - prelude::*, - widgets::{Block, Borders, Paragraph}, -}; - -#[derive(Debug, Clone)] -pub struct SharesMarketView; - -impl SharesMarketView { - #[must_use] - pub fn new() -> Self { - Self - } - - // Shares Market - pub fn render(f: &mut Frame, _app: &mut App, area: Rect) { - let p = Paragraph::new("Shares Market").block( - Block::default() - .borders(Borders::ALL) - .title(" Shares Market "), - ); - f.render_widget(p, area); - } -} - -impl Default for SharesMarketView { - fn default() -> Self { - Self::new() - } -} diff --git a/src/components/status_bar.rs b/src/components/status_bar.rs index 775d6f6..bd62816 100644 --- a/src/components/status_bar.rs +++ b/src/components/status_bar.rs @@ -36,43 +36,11 @@ impl StatusBar { spans.extend(hint("⌫", "Parent folder")); spans.extend(hint("Esc", "Cancel")); } - CurrentScreen::BitcoinConfig if app.bitcoin_conf_path.is_some() => { - if let Some(msg) = &app.bitcoin_config_view.save_message { - spans.push(Span::styled( - format!(" ✓ {msg} "), - Style::default().fg(Color::Green), - )); - } else if app.bitcoin_config_view.editing { - spans.extend(hint("Enter", "Confirm")); - spans.extend(hint("Esc", "Cancel")); - } else if app.bitcoin_config_view.sidebar_focused { - spans.extend(hint("↑↓", "Navigate sidebar")); - spans.extend(hint("Enter", "Focus config")); - } else { - spans.extend(hint("↑↓", "Navigate")); - spans.extend(hint("Enter", "Edit")); - spans.extend(hint("s", "Save")); - spans.extend(hint("Esc", "Back")); - } - } CurrentScreen::P2PoolConfig if app.p2pool_conf_path.is_some() => { spans.extend(hint("↑↓", "Navigate")); spans.extend(hint("Enter", "Open file")); spans.extend(hint("q", "Quit")); } - CurrentScreen::BitcoinConfig => { - if let Some(msg) = &app.bitcoin_config_view.warning_message { - spans.push(Span::styled( - format!(" ⚠ {msg} "), - Style::default().fg(Color::Yellow), - )); - spans.extend(hint("Enter", "Try again")); - } else { - spans.extend(hint("↑↓", "Navigate sidebar")); - spans.extend(hint("Enter", "Open file")); - spans.extend(hint("Esc", "Back")); - } - } CurrentScreen::Settings => { if let Some(err) = &app.settings_view.save_error { spans.push(Span::styled( @@ -86,11 +54,8 @@ impl StatusBar { let s = &app.settings; let idx = app.settings_view.selected_index; let field_is_set = match idx { - 0 => s.bitcoin_conf_path.is_some(), - 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(), + 0 => s.p2pool_conf_path.is_some(), + 1 => s.settings_dir_override.is_some(), _ => false, }; spans.extend(hint("↑↓", "Navigate")); @@ -165,70 +130,6 @@ mod tests { assert!(output.contains("Parent folder")); } - #[test] - fn bitcoin_config_no_file_shows_open_file() { - let mut app = App::new(); - app.current_screen = CurrentScreen::BitcoinConfig; - let output = render_status_bar(&app); - assert!(output.contains("Open file")); - } - - #[test] - fn bitcoin_config_no_file_with_warning_shows_try_again() { - let mut app = App::new(); - app.current_screen = CurrentScreen::BitcoinConfig; - app.bitcoin_config_view.warning_message = Some("Not a valid config.".to_string()); - let output = render_status_bar(&app); - assert!(output.contains("Not a valid config.")); - assert!(output.contains("Try again")); - } - - #[test] - fn bitcoin_config_with_file_sidebar_focused_shows_navigate_sidebar() { - let mut app = App::new(); - app.current_screen = CurrentScreen::BitcoinConfig; - app.bitcoin_conf_path = Some(std::path::PathBuf::from("/tmp/bitcoin.conf")); - app.bitcoin_config_view.sidebar_focused = true; - let output = render_status_bar(&app); - assert!(output.contains("Navigate sidebar")); - assert!(output.contains("Focus config")); - } - - #[test] - fn bitcoin_config_with_file_editing_shows_confirm_cancel() { - let mut app = App::new(); - app.current_screen = CurrentScreen::BitcoinConfig; - app.bitcoin_conf_path = Some(std::path::PathBuf::from("/tmp/bitcoin.conf")); - app.bitcoin_config_view.sidebar_focused = false; - app.bitcoin_config_view.editing = true; - let output = render_status_bar(&app); - assert!(output.contains("Confirm")); - assert!(output.contains("Cancel")); - } - - #[test] - fn bitcoin_config_with_file_browsing_shows_edit_save_back() { - let mut app = App::new(); - app.current_screen = CurrentScreen::BitcoinConfig; - app.bitcoin_conf_path = Some(std::path::PathBuf::from("/tmp/bitcoin.conf")); - app.bitcoin_config_view.sidebar_focused = false; - app.bitcoin_config_view.editing = false; - let output = render_status_bar(&app); - assert!(output.contains("Edit")); - assert!(output.contains("Save")); - assert!(output.contains("Back")); - } - - #[test] - fn bitcoin_config_with_file_save_message_shows_saved() { - let mut app = App::new(); - app.current_screen = CurrentScreen::BitcoinConfig; - app.bitcoin_conf_path = Some(std::path::PathBuf::from("/tmp/bitcoin.conf")); - app.bitcoin_config_view.save_message = Some("Configuration correctly saved".to_string()); - let output = render_status_bar(&app); - assert!(output.contains("Configuration correctly saved")); - } - #[test] fn bitcoin_status_shows_switch_tab() { let mut app = App::new(); @@ -272,8 +173,8 @@ mod tests { let mut app = App::new(); app.current_screen = CurrentScreen::Settings; app.settings_view.sidebar_focused = false; - // field 4 is DirectoryPicker - app.settings_view.selected_index = 4; + // field 1 is DirectoryPicker + app.settings_view.selected_index = 1; let output = render_status_bar(&app); assert!(output.contains("Browse dir")); assert!(output.contains("Back")); @@ -285,7 +186,7 @@ mod tests { app.current_screen = CurrentScreen::Settings; app.settings_view.sidebar_focused = false; app.settings_view.selected_index = 0; - app.settings.bitcoin_conf_path = Some(std::path::PathBuf::from("/tmp/bitcoin.conf")); + app.settings.p2pool_conf_path = Some(std::path::PathBuf::from("/tmp/p2pool.toml")); let output = render_status_bar(&app); assert!(output.contains("Clear")); } @@ -296,7 +197,6 @@ mod tests { app.current_screen = CurrentScreen::Settings; app.settings_view.sidebar_focused = false; app.settings_view.selected_index = 0; - // bitcoin_conf_path is None by default let output = render_status_bar(&app); assert!(!output.contains("Clear")); } @@ -315,50 +215,18 @@ mod tests { let mut app = App::new(); app.current_screen = CurrentScreen::Settings; app.settings_view.sidebar_focused = false; - app.settings_view.selected_index = 1; + app.settings_view.selected_index = 0; app.settings.p2pool_conf_path = Some(std::path::PathBuf::from("/tmp/p2pool.toml")); let output = render_status_bar(&app); assert!(output.contains("Clear")); } - #[test] - fn settings_content_ln_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 = 2; - app.settings.ln_conf_path = Some(std::path::PathBuf::from("/tmp/ln.conf")); - let output = render_status_bar(&app); - assert!(output.contains("Clear")); - } - - #[test] - fn settings_content_shares_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 = 3; - app.settings.shares_market_conf_path = Some(std::path::PathBuf::from("/tmp/shares.conf")); - let output = render_status_bar(&app); - assert!(output.contains("Clear")); - } - - #[test] - fn settings_content_out_of_range_field_no_clear() { - let mut app = App::new(); - app.current_screen = CurrentScreen::Settings; - app.settings_view.sidebar_focused = false; - app.settings_view.selected_index = 99; - let output = render_status_bar(&app); - assert!(!output.contains("Clear")); - } - #[test] fn settings_content_directory_override_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_view.selected_index = 1; app.settings.settings_dir_override = Some(std::path::PathBuf::from("/custom/dir")); let output = render_status_bar(&app); assert!(output.contains("Clear")); diff --git a/src/config.rs b/src/config.rs deleted file mode 100644 index 3e74870..0000000 --- a/src/config.rs +++ /dev/null @@ -1,58 +0,0 @@ -// SPDX-FileCopyrightText: 2024 PDM Authors -// -// SPDX-License-Identifier: AGPL-3.0-or-later - -use anyhow::Result; -use config::{Config, File}; - -const DEFAULT_API_HOST: &str = "127.0.0.1"; -const DEFAULT_API_PORT: u16 = 46884; - -#[derive(Debug, Clone)] -pub struct ApiConfig { - pub base_url: String, - pub fallback_base_url: Option, - pub auth_user: Option, - pub auth_pass: Option, -} - -impl Default for ApiConfig { - fn default() -> Self { - Self { - base_url: format!("http://{}:{}", DEFAULT_API_HOST, DEFAULT_API_PORT), - fallback_base_url: None, - auth_user: None, - auth_pass: None, - } - } -} - -pub fn load_api_config() -> Result { - let settings = Config::builder() - .add_source(File::with_name("config/config").required(false)) - .add_source( - File::with_name(concat!(env!("CARGO_MANIFEST_DIR"), "/config/config")).required(false), - ) - .build()?; - - let host: String = settings - .get("api.host") - .unwrap_or_else(|_| DEFAULT_API_HOST.to_string()); - let port: u16 = settings.get("api.port").unwrap_or(DEFAULT_API_PORT); - let base_url: String = settings - .get("api.base_url") - .unwrap_or_else(|_| format!("http://{}:{}", host, port)); - let fallback_base_url: Option = settings - .get("api.fallback_base_url") - .ok() - .filter(|url: &String| !url.trim().is_empty()); - let auth_user: Option = settings.get("api.auth_user").ok(); - let auth_pass: Option = settings.get("api.auth_pass").ok(); - - Ok(ApiConfig { - base_url, - fallback_base_url, - auth_user, - auth_pass, - }) -} diff --git a/src/lib.rs b/src/lib.rs index 2ec9158..94e09ce 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -3,9 +3,8 @@ // SPDX-License-Identifier: AGPL-3.0-or-later pub mod app; -pub mod bitcoin_config; pub mod components; -pub mod config; pub mod p2poolv2_config; +pub mod p2poolv2_service; pub mod settings; pub mod ui; diff --git a/src/main.rs b/src/main.rs index cd235e0..5fef21f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -7,11 +7,9 @@ use pdm::app::{ App, AppAction, CurrentScreen, ExplorerTrigger, MAX_BITCOIN_STATUS_TAB, MAX_P2POOL_STATUS_TAB, MAX_SIDEBAR_INDEX, }; -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::p2poolv2_config::{apply_edit as apply_p2pool_edit, flatten_config}; +use pdm::p2poolv2_service::{P2PoolV2Service, instance_from_config_path}; use pdm::settings::{load_settings, save_settings}; use pdm::ui; use std::ops::ControlFlow; @@ -112,12 +110,9 @@ fn dispatch_key(key: event::KeyEvent, app: &mut App) -> KeyOutcome { // Ctrl-C is always a hard exit. // 'q' is suppressed while a text-input field is active. - let text_input_active = (app.current_screen == CurrentScreen::BitcoinConfig - && !app.bitcoin_config_view.sidebar_focused - && app.bitcoin_config_view.editing) - || (app.current_screen == CurrentScreen::P2PoolConfig - && !app.p2pool_config_view.sidebar_focused - && app.p2pool_config_view.editing); + let text_input_active = app.current_screen == CurrentScreen::P2PoolConfig + && !app.p2pool_config_view.sidebar_focused + && app.p2pool_config_view.editing; if (key.modifiers == KeyModifiers::CONTROL && key.code == KeyCode::Char('c')) || (!text_input_active && key.code == KeyCode::Char('q')) @@ -157,34 +152,44 @@ fn dispatch_key(key: event::KeyEvent, app: &mut App) -> KeyOutcome { } AppAction::None } - k => sidebar_nav(k, app), - }, - - CurrentScreen::BitcoinConfig => { - if app.bitcoin_conf_path.is_some() { - if app.bitcoin_config_view.sidebar_focused { - match key.code { - KeyCode::Enter => { - app.bitcoin_config_view.sidebar_focused = false; - AppAction::None - } - k => sidebar_nav(k, app), - } + KeyCode::Char('s') if app.p2pool_status_tab == 3 => { + if app + .p2pool_conf_path + .as_deref() + .and_then(instance_from_config_path) + .is_some() + { + AppAction::StartP2Pool } else { - let entries = &app.bitcoin_data; - app.bitcoin_config_view.handle_input(key, entries) + AppAction::None } - } else { - match key.code { - KeyCode::Enter => { - app.bitcoin_config_view.warning_message = None; - AppAction::OpenExplorer(ExplorerTrigger::BitcoinConfig) - } - KeyCode::Esc => AppAction::CloseModal, - k => sidebar_nav(k, app), + } + KeyCode::Char('x') if app.p2pool_status_tab == 3 => { + if app + .p2pool_conf_path + .as_deref() + .and_then(instance_from_config_path) + .is_some() + { + AppAction::StopP2Pool + } else { + AppAction::None } } - } + KeyCode::Char('r') if app.p2pool_status_tab == 3 => { + if app + .p2pool_conf_path + .as_deref() + .and_then(instance_from_config_path) + .is_some() + { + AppAction::RestartP2Pool + } else { + AppAction::None + } + } + k => sidebar_nav(k, app), + }, CurrentScreen::P2PoolConfig => { if app.p2pool_conf_path.is_some() { @@ -239,15 +244,6 @@ fn dispatch_key(key: event::KeyEvent, app: &mut App) -> KeyOutcome { /// Pre-populate app state from `app.settings`. Called once at startup after /// settings have been loaded into `app.settings = load_settings()`. fn bootstrap_from_settings(app: &mut App) { - // Bitcoin config - if let Some(path) = &app.settings.bitcoin_conf_path { - let entries = parse_bitcoin_config(path).unwrap_or_default(); - if entries.iter().any(|e| e.enabled && e.schema.is_some()) { - app.bitcoin_conf_path = Some(path.clone()); - app.bitcoin_data = entries; - } - } - // P2Pool config — only set the path when the config is actually loadable if let Some(path) = &app.settings.p2pool_conf_path.clone() && let Some(p) = path.to_str() @@ -255,7 +251,7 @@ fn bootstrap_from_settings(app: &mut App) { match P2PoolConfig::load(p) { Ok(cfg) => { app.p2pool_conf_path = Some(path.clone()); - app.p2pool_config = Some(cfg); + app.set_p2pool_config(cfg); } Err(e) => { eprintln!("pdm: failed to load p2pool config on startup: {e}"); @@ -315,11 +311,11 @@ fn handle_action(action: AppAction, app: &mut App) -> Result> { .to_string(), ); app.p2pool_conf_path = None; - app.p2pool_config = None; + app.clear_p2pool_config(); } else { // Only set path + persist settings when config is actually valid app.p2pool_conf_path = Some(path.clone()); - app.p2pool_config = Some(cfg); + app.set_p2pool_config(cfg); app.p2pool_config_view.sidebar_focused = false; app.p2pool_config_view.warning_message = None; app.p2pool_config_view.selected_index = 0; @@ -337,81 +333,16 @@ fn handle_action(action: AppAction, app: &mut App) -> Result> { e )); app.p2pool_conf_path = None; - app.p2pool_config = None; + app.clear_p2pool_config(); } } app.current_screen = CurrentScreen::P2PoolConfig; } - ExplorerTrigger::BitcoinConfig => match parse_bitcoin_config(&path) { - Ok(entries) => { - const MIN_KNOWN_KEYS: usize = 1; - let known_key_count = entries - .iter() - .filter(|e| e.enabled && e.schema.is_some()) - .count(); - - if known_key_count >= MIN_KNOWN_KEYS { - app.bitcoin_conf_path = Some(path.clone()); - app.bitcoin_data = entries; - app.bitcoin_config_view.selected_index = 0; - app.bitcoin_config_view.dirty = false; - app.current_screen = CurrentScreen::BitcoinConfig; - app.bitcoin_config_view.sidebar_focused = false; - app.bitcoin_config_view.warning_message = None; - app.settings.bitcoin_conf_path = Some(path.clone()); - app.settings_view.save_error = None; - if let Err(e) = save_settings(&app.settings) { - let save_error = format!("Save failed: {e}"); - app.settings_view.save_error = Some(save_error.clone()); - app.bitcoin_config_view.warning_message = Some(save_error); - } - } else { - app.bitcoin_config_view.warning_message = Some( - "File does not appear to be a Bitcoin config. Select another file." - .to_string(), - ); - app.current_screen = CurrentScreen::BitcoinConfig; - } - } - Err(e) => { - app.bitcoin_config_view.warning_message = Some(format!( - "Failed to read config: {e}. Check permissions and try again." - )); - app.current_screen = CurrentScreen::BitcoinConfig; - } - }, ExplorerTrigger::Settings(field) => { app.explorer.allow_dir_select = false; let mut should_save = true; match field { - 0 => match parse_bitcoin_config(&path) { - Ok(entries) => { - let known_key_count = entries - .iter() - .filter(|e| e.enabled && e.schema.is_some()) - .count(); - if known_key_count >= 1 { - app.bitcoin_conf_path = Some(path.clone()); - app.bitcoin_data = entries; - app.bitcoin_config_view.selected_index = 0; - app.bitcoin_config_view.dirty = false; - app.bitcoin_config_view.warning_message = None; - app.settings.bitcoin_conf_path = Some(path.clone()); - } else { - app.settings_view.save_error = Some( - "File does not appear to be a Bitcoin config." - .to_string(), - ); - should_save = false; - } - } - Err(e) => { - app.settings_view.save_error = - Some(format!("Failed to read config: {e}")); - should_save = false; - } - }, - 1 => match P2PoolConfig::load(path.to_str().unwrap_or_default()) { + 0 => match P2PoolConfig::load(path.to_str().unwrap_or_default()) { Ok(cfg) => { if cfg.stratum.hostname.is_empty() { app.settings_view.save_error = Some( @@ -420,11 +351,11 @@ fn handle_action(action: AppAction, app: &mut App) -> Result> { ); should_save = false; } else { - app.p2pool_config = Some(cfg); + app.p2pool_conf_path = Some(path.clone()); + app.set_p2pool_config(cfg); app.settings.p2pool_conf_path = Some(path.clone()); app.p2pool_config_view.warning_message = None; app.p2pool_config_view.selected_index = 0; - app.settings.p2pool_conf_path = Some(path.clone()); } } Err(e) => { @@ -433,9 +364,7 @@ fn handle_action(action: AppAction, app: &mut App) -> Result> { should_save = false; } }, - 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()), + 1 => app.settings.settings_dir_override = Some(path.clone()), _ => {} } if should_save { @@ -451,42 +380,18 @@ fn handle_action(action: AppAction, app: &mut App) -> Result> { } } - AppAction::SaveBitcoinConfig => { - if let Some(path) = &app.bitcoin_conf_path { - save_bitcoin_config(path, &app.bitcoin_data)?; - app.bitcoin_config_view.save_message = - Some("Configuration correctly saved".to_string()); - app.bitcoin_config_view.dirty = false; - } - } - AppAction::Navigate(screen) => { app.current_screen = screen; } - AppAction::CommitEdit(index, value) => { - if index < app.bitcoin_data.len() { - app.bitcoin_data[index].value = value; - app.bitcoin_data[index].enabled = true; - app.bitcoin_config_view.dirty = true; - } - } - AppAction::ClearSettingsField(field) => { match field { 0 => { - app.settings.bitcoin_conf_path = None; - app.bitcoin_conf_path = None; - app.bitcoin_data.clear(); - } - 1 => { app.settings.p2pool_conf_path = None; app.p2pool_conf_path = None; - app.p2pool_config = None; + app.clear_p2pool_config(); } - 2 => app.settings.ln_conf_path = None, - 3 => app.settings.shares_market_conf_path = None, - 4 => app.settings.settings_dir_override = None, + 1 => app.settings.settings_dir_override = None, _ => {} } app.settings_view.save_error = None; @@ -498,6 +403,7 @@ fn handle_action(action: AppAction, app: &mut App) -> Result> { if let Some(cfg) = app.p2pool_config.as_mut() { match apply_p2pool_edit(cfg, index, &value) { Ok(()) => { + app.refresh_p2pool_clients_from_config(); app.p2pool_config_view.warning_message = None; } Err(e) => { @@ -524,6 +430,43 @@ fn handle_action(action: AppAction, app: &mut App) -> Result> { } } + AppAction::StartP2Pool => { + app.p2pool_service_error = None; + if let Some(instance) = app + .p2pool_conf_path + .as_deref() + .and_then(instance_from_config_path) + { + if let Err(error) = P2PoolV2Service::start(&instance) { + app.p2pool_service_error = Some(format!("Start failed: {error}")); + } + } + } + AppAction::StopP2Pool => { + app.p2pool_service_error = None; + if let Some(instance) = app + .p2pool_conf_path + .as_deref() + .and_then(instance_from_config_path) + { + if let Err(error) = P2PoolV2Service::stop(&instance) { + app.p2pool_service_error = Some(format!("Stop failed: {error}")); + } + } + } + AppAction::RestartP2Pool => { + app.p2pool_service_error = None; + if let Some(instance) = app + .p2pool_conf_path + .as_deref() + .and_then(instance_from_config_path) + { + if let Err(error) = P2PoolV2Service::restart(&instance) { + app.p2pool_service_error = Some(format!("Restart failed: {error}")); + } + } + } + AppAction::None => {} } @@ -706,6 +649,26 @@ port = 46884 .unwrap(); } + fn write_valid_p2pool_toml_with_api( + path: &std::path::Path, + hostname: &str, + port: u16, + auth_user: Option<&str>, + auth_password: Option<&str>, + ) { + write_valid_p2pool_toml(path); + let content = std::fs::read_to_string(path).unwrap(); + let mut doc = content.parse::().unwrap(); + doc["api"]["hostname"] = toml_edit::value(hostname); + doc["api"]["port"] = toml_edit::value(i64::from(port)); + if let Some(auth_user) = auth_user { + doc["api"]["auth_user"] = toml_edit::value(auth_user); + } + if let Some(auth_password) = auth_password { + doc["api"]["auth_password"] = toml_edit::value(auth_password); + } + std::fs::write(path, doc.to_string()).unwrap(); + } /// Write a TOML that parses fine but has an empty hostname (fails sanity check). fn write_empty_hostname_toml(path: &std::path::Path) { std::fs::write( @@ -796,19 +759,6 @@ port = 46884 )); } - #[test] - fn dispatch_key_q_suppressed_while_editing_bitcoin_config() { - let mut app = App::new(); - app.current_screen = CurrentScreen::BitcoinConfig; - app.bitcoin_conf_path = Some(std::path::PathBuf::from("/tmp/bitcoin.conf")); - app.bitcoin_config_view.sidebar_focused = false; - app.bitcoin_config_view.editing = true; - - let outcome = dispatch_key(press(KeyCode::Char('q')), &mut app); - - assert!(!matches!(outcome, KeyOutcome::Exit)); - } - #[test] fn dispatch_key_q_suppressed_while_editing_p2pool_config() { let mut app = App::new(); @@ -821,19 +771,6 @@ port = 46884 assert!(!matches!(outcome, KeyOutcome::Exit)); } - #[test] - fn dispatch_key_q_exits_on_bitcoin_config_when_sidebar_focused() { - let mut app = App::new(); - app.current_screen = CurrentScreen::BitcoinConfig; - app.bitcoin_config_view.sidebar_focused = true; - app.bitcoin_config_view.editing = true; - - assert!(matches!( - dispatch_key(press(KeyCode::Char('q')), &mut app), - KeyOutcome::Exit - )); - } - #[test] fn dispatch_key_bitcoin_status_left_decrements_above_zero() { let mut app = App::new(); @@ -935,100 +872,69 @@ port = 46884 } #[test] - fn dispatch_key_file_explorer_delegates_to_explorer() { - let mut app = App::new(); - app.current_screen = CurrentScreen::FileExplorer; - app.explorer.load_directory(); - - let outcome = dispatch_key(press(KeyCode::Down), &mut app); - - assert!(matches!(outcome, KeyOutcome::Action(_))); - } - - #[test] - fn dispatch_key_bitcoin_config_sidebar_focused_enter_unfocuses() { + fn dispatch_key_p2pool_system_tab_without_config_ignores_service_controls() { let mut app = App::new(); - app.current_screen = CurrentScreen::BitcoinConfig; - app.bitcoin_conf_path = Some(std::path::PathBuf::from("/tmp/bitcoin.conf")); - app.bitcoin_config_view.sidebar_focused = true; - - let outcome = dispatch_key(press(KeyCode::Enter), &mut app); - - assert!(!app.bitcoin_config_view.sidebar_focused); - assert!(matches!(outcome, KeyOutcome::Action(AppAction::None))); - } - - #[test] - fn dispatch_key_bitcoin_config_sidebar_focused_other_key_navigates_sidebar() { - let mut app = App::new(); - app.current_screen = CurrentScreen::BitcoinConfig; - app.bitcoin_conf_path = Some(std::path::PathBuf::from("/tmp/bitcoin.conf")); - app.bitcoin_config_view.sidebar_focused = true; - app.sidebar_index = 1; - - dispatch_key(press(KeyCode::Down), &mut app); - - assert_eq!(app.sidebar_index, 2); - } - - #[test] - fn dispatch_key_bitcoin_config_not_focused_delegates_to_view() { - use pdm::bitcoin_config::ConfigEntry; - - let mut app = App::new(); - app.current_screen = CurrentScreen::BitcoinConfig; - app.bitcoin_conf_path = Some(std::path::PathBuf::from("/tmp/bitcoin.conf")); - app.bitcoin_config_view.sidebar_focused = false; - app.bitcoin_data = vec![ConfigEntry { - key: "rpcuser".to_string(), - value: "old".to_string(), - enabled: true, - schema: None, - section: None, - }]; - - dispatch_key(press(KeyCode::Esc), &mut app); + app.current_screen = CurrentScreen::P2PoolStatus; + app.p2pool_status_tab = 3; - assert!(app.bitcoin_config_view.sidebar_focused); + assert!(matches!( + dispatch_key(press(KeyCode::Char('s')), &mut app), + KeyOutcome::Action(AppAction::None) + )); + assert!(matches!( + dispatch_key(press(KeyCode::Char('x')), &mut app), + KeyOutcome::Action(AppAction::None) + )); + assert!(matches!( + dispatch_key(press(KeyCode::Char('r')), &mut app), + KeyOutcome::Action(AppAction::None) + )); } #[test] - fn dispatch_key_bitcoin_config_no_path_enter_opens_explorer() { + fn dispatch_key_p2pool_system_tab_maps_managed_service_controls() { + let home = std::env::var_os("HOME").expect("HOME is set for this test"); let mut app = App::new(); - app.current_screen = CurrentScreen::BitcoinConfig; - app.bitcoin_conf_path = None; - app.bitcoin_config_view.warning_message = Some("stale".to_string()); - - let outcome = dispatch_key(press(KeyCode::Enter), &mut app); + app.current_screen = CurrentScreen::P2PoolStatus; + app.p2pool_status_tab = 3; + app.p2pool_conf_path = + Some(std::path::PathBuf::from(home).join(".config/p2poolv2/config-signet.toml")); - assert!(app.bitcoin_config_view.warning_message.is_none()); assert!(matches!( - outcome, - KeyOutcome::Action(AppAction::OpenExplorer(ExplorerTrigger::BitcoinConfig)) + dispatch_key(press(KeyCode::Char('s')), &mut app), + KeyOutcome::Action(AppAction::StartP2Pool) + )); + assert!(matches!( + dispatch_key(press(KeyCode::Char('x')), &mut app), + KeyOutcome::Action(AppAction::StopP2Pool) + )); + assert!(matches!( + dispatch_key(press(KeyCode::Char('r')), &mut app), + KeyOutcome::Action(AppAction::RestartP2Pool) )); } #[test] - fn dispatch_key_bitcoin_config_no_path_esc_closes_modal() { + fn dispatch_key_service_controls_are_ignored_on_other_p2pool_tabs() { let mut app = App::new(); - app.current_screen = CurrentScreen::BitcoinConfig; - app.bitcoin_conf_path = None; - - let outcome = dispatch_key(press(KeyCode::Esc), &mut app); + app.current_screen = CurrentScreen::P2PoolStatus; + app.p2pool_status_tab = 2; - assert!(matches!(outcome, KeyOutcome::Action(AppAction::CloseModal))); + assert!(matches!( + dispatch_key(press(KeyCode::Char('s')), &mut app), + KeyOutcome::Action(AppAction::None) + )); } #[test] - fn dispatch_key_bitcoin_config_no_path_other_key_navigates_sidebar() { + fn dispatch_key_file_explorer_delegates_to_explorer() { let mut app = App::new(); - app.current_screen = CurrentScreen::BitcoinConfig; - app.bitcoin_conf_path = None; - app.sidebar_index = 1; + app.current_screen = CurrentScreen::FileExplorer; + app.explorer.load_directory(); - dispatch_key(press(KeyCode::Up), &mut app); + let outcome = dispatch_key(press(KeyCode::Down), &mut app); - assert_eq!(app.sidebar_index, 0); + assert!(matches!(outcome, KeyOutcome::Action(_))); } #[test] @@ -1053,7 +959,7 @@ port = 46884 let mut app = App::new(); app.current_screen = CurrentScreen::P2PoolConfig; app.p2pool_conf_path = Some(file); - app.p2pool_config = Some(cfg); + app.set_p2pool_config(cfg); app.p2pool_config_view.sidebar_focused = false; let outcome = dispatch_key(press(KeyCode::Down), &mut app); @@ -1148,51 +1054,7 @@ port = 46884 #[test] #[serial] - fn file_selected_for_settings_field_0_invalid_bitcoin_config_sets_error() { - use tempfile::tempdir; - - let dir = tempdir().unwrap(); - redirect_saves_to(&dir); - let path = dir.path().join("not_a_config.conf"); - std::fs::write(&path, "unknownkey=somevalue\n").unwrap(); - - let mut app = App::new(); - app.explorer_trigger = Some(ExplorerTrigger::Settings(0)); - - run(AppAction::FileSelected(path), &mut app); - - assert_eq!( - app.settings_view.save_error.as_deref(), - Some("File does not appear to be a Bitcoin config.") - ); - assert!(app.settings.bitcoin_conf_path.is_none()); - assert_eq!(app.current_screen, CurrentScreen::Settings); - } - - #[test] - #[serial] - fn file_selected_for_settings_field_0_missing_path_sets_invalid_config_error() { - use tempfile::tempdir; - - let dir = tempdir().unwrap(); - redirect_saves_to(&dir); - let missing_path = dir.path().join("does_not_exist.conf"); - - let mut app = App::new(); - app.explorer_trigger = Some(ExplorerTrigger::Settings(0)); - - run(AppAction::FileSelected(missing_path), &mut app); - - assert_eq!( - app.settings_view.save_error.as_deref(), - Some("File does not appear to be a Bitcoin config.") - ); - assert!(app.settings.bitcoin_conf_path.is_none()); - } - - #[test] - #[serial] - fn file_selected_for_settings_field_1_invalid_hostname_sets_error() { + fn file_selected_for_settings_field_0_invalid_hostname_sets_error() { use tempfile::tempdir; let dir = tempdir().unwrap(); @@ -1201,7 +1063,7 @@ port = 46884 write_empty_hostname_toml(&path); let mut app = App::new(); - app.explorer_trigger = Some(ExplorerTrigger::Settings(1)); + app.explorer_trigger = Some(ExplorerTrigger::Settings(0)); run(AppAction::FileSelected(path), &mut app); @@ -1215,7 +1077,7 @@ port = 46884 #[test] #[serial] - fn file_selected_for_settings_field_1_load_failure_sets_error() { + fn file_selected_for_settings_field_0_load_failure_sets_error() { use tempfile::tempdir; let dir = tempdir().unwrap(); @@ -1224,7 +1086,7 @@ port = 46884 std::fs::write(&path, "invalid === toml").unwrap(); let mut app = App::new(); - app.explorer_trigger = Some(ExplorerTrigger::Settings(1)); + app.explorer_trigger = Some(ExplorerTrigger::Settings(0)); run(AppAction::FileSelected(path), &mut app); @@ -1248,109 +1110,8 @@ port = 46884 app.toggle_menu(); terminal.draw(|f| ui::ui(f, &mut app)).unwrap(); - insta::assert_debug_snapshot!("menu_toggled", terminal.backend()); - - assert_eq!(app.current_screen, CurrentScreen::BitcoinConfig); - } - - #[test] - fn test_file_explorer_flow_state_only() { - let backend = TestBackend::new(80, 25); - let mut terminal = Terminal::new(backend).unwrap(); - let mut app = App::new(); - - // Navigate to Bitcoin config - app.sidebar_index = 1; - app.toggle_menu(); - assert_eq!(app.current_screen, CurrentScreen::BitcoinConfig); - - // Open explorer - let _ = handle_action( - AppAction::OpenExplorer(ExplorerTrigger::BitcoinConfig), - &mut app, - ) - .unwrap(); - - assert_eq!(app.current_screen, CurrentScreen::FileExplorer); - - // Close explorer - let _ = handle_action(AppAction::CloseModal, &mut app).unwrap(); - assert_eq!(app.current_screen, CurrentScreen::BitcoinConfig); - - terminal.draw(|f| ui::ui(f, &mut app)).unwrap(); - } - - #[test] - #[serial] - fn test_file_explorer_wrap_and_select_sets_config() { - use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; - use tempfile::tempdir; - - // Create isolated temporary directory - let dir = tempdir().unwrap(); - redirect_saves_to(&dir); - let base = dir.path(); - - // Create a fake bitcoin.conf file - let file_path = base.join("bitcoin.conf"); - std::fs::write(&file_path, "rpcuser=test\n").unwrap(); - - let backend = TestBackend::new(80, 25); - let mut terminal = Terminal::new(backend).unwrap(); - let mut app = App::new(); - - app.explorer.current_dir = base.to_path_buf(); - app.explorer.load_directory(); - - let _ = handle_action( - AppAction::OpenExplorer(ExplorerTrigger::BitcoinConfig), - &mut app, - ) - .unwrap(); - // Move selection DOWN to the actual file (skip "..") - app.explorer - .handle_input(KeyEvent::new(KeyCode::Down, KeyModifiers::empty())); - - let action = app - .explorer - .handle_input(KeyEvent::new(KeyCode::Enter, KeyModifiers::empty())); - - let _ = handle_action(action, &mut app).unwrap(); - - assert_eq!(app.bitcoin_conf_path, Some(file_path)); - - terminal.draw(|f| ui::ui(f, &mut app)).unwrap(); - } - - #[test] - fn app_action_open_explorer_sets_state() { - let mut app = App::new(); - - let flow = handle_action( - AppAction::OpenExplorer(ExplorerTrigger::BitcoinConfig), - &mut app, - ) - .unwrap(); - - assert!(flow.is_continue()); - assert_eq!(app.current_screen, CurrentScreen::FileExplorer); - assert_eq!(app.explorer_trigger, Some(ExplorerTrigger::BitcoinConfig)); - } - - #[test] - fn app_action_close_modal_returns_to_sidebar() { - let mut app = App::new(); - - app.sidebar_index = 1; // Bitcoin Config - app.explorer_trigger = Some(ExplorerTrigger::BitcoinConfig); - app.current_screen = CurrentScreen::FileExplorer; - - let flow = handle_action(AppAction::CloseModal, &mut app).unwrap(); - - assert!(flow.is_continue()); - assert_eq!(app.current_screen, CurrentScreen::BitcoinConfig); - assert!(app.explorer_trigger.is_none()); + assert_eq!(app.current_screen, CurrentScreen::BitcoinStatus); } #[test] @@ -1362,81 +1123,6 @@ port = 46884 assert!(flow.is_break()); } - #[test] - fn commit_edit_updates_entry_value_and_enables_it() { - use pdm::bitcoin_config::ConfigEntry; - - let mut app = App::new(); - app.bitcoin_data = vec![ - ConfigEntry { - key: "rpcuser".to_string(), - value: "old".to_string(), - enabled: false, - schema: None, - section: None, - }, - ConfigEntry { - key: "server".to_string(), - value: "0".to_string(), - enabled: true, - schema: None, - section: None, - }, - ]; - - run(AppAction::CommitEdit(0, "alice".to_string()), &mut app); - - assert_eq!(app.bitcoin_data[0].value, "alice"); - assert!(app.bitcoin_data[0].enabled); - // Other entries unchanged - assert_eq!(app.bitcoin_data[1].value, "0"); - } - - #[test] - fn commit_edit_out_of_bounds_is_noop() { - let mut app = App::new(); - // bitcoin_data is empty - let result = handle_action(AppAction::CommitEdit(5, "val".to_string()), &mut app); - assert!(result.is_ok()); - } - - #[test] - fn save_bitcoin_config_writes_file_and_sets_message() { - use pdm::bitcoin_config::ConfigEntry; - use tempfile::tempdir; - - let dir = tempdir().unwrap(); - let path = dir.path().join("bitcoin.conf"); - - let mut app = App::new(); - app.bitcoin_conf_path = Some(path.clone()); - app.bitcoin_data = vec![ConfigEntry { - key: "rpcuser".to_string(), - value: "testuser".to_string(), - enabled: true, - schema: None, - section: None, - }]; - - run(AppAction::SaveBitcoinConfig, &mut app); - - let content = std::fs::read_to_string(&path).unwrap(); - assert!(content.contains("rpcuser=testuser")); - assert_eq!( - app.bitcoin_config_view.save_message.as_deref(), - Some("Configuration correctly saved") - ); - } - - #[test] - fn save_bitcoin_config_noop_when_no_path() { - let mut app = App::new(); - // No bitcoin_conf_path set - let result = handle_action(AppAction::SaveBitcoinConfig, &mut app); - assert!(result.is_ok()); - assert!(app.bitcoin_config_view.save_message.is_none()); - } - #[test] fn navigate_action_changes_screen() { let mut app = App::new(); @@ -1444,193 +1130,12 @@ port = 46884 assert_eq!(app.current_screen, CurrentScreen::BitcoinStatus); } - #[test] - fn file_selected_invalid_bitcoin_config_sets_warning() { - use tempfile::tempdir; - - let dir = tempdir().unwrap(); - let path = dir.path().join("not_a_config.conf"); - // Write a file with no recognized bitcoin config keys - std::fs::write(&path, "unknownkey=somevalue\n").unwrap(); - - let mut app = App::new(); - app.explorer_trigger = Some(ExplorerTrigger::BitcoinConfig); - - run(AppAction::FileSelected(path), &mut app); - - assert!(app.bitcoin_config_view.warning_message.is_some()); - assert!(app.bitcoin_conf_path.is_none()); - assert_eq!(app.current_screen, CurrentScreen::BitcoinConfig); - } - - #[test] - #[serial] - fn bitcoin_config_sidebar_focus_toggle_via_enter() { - use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; - use tempfile::tempdir; - - let dir = tempdir().unwrap(); - redirect_saves_to(&dir); - let path = dir.path().join("bitcoin.conf"); - std::fs::write(&path, "rpcuser=test\n").unwrap(); - - let mut app = App::new(); - app.sidebar_index = 1; - app.toggle_menu(); - run( - AppAction::OpenExplorer(ExplorerTrigger::BitcoinConfig), - &mut app, - ); - - app.explorer.current_dir = dir.path().to_path_buf(); - app.explorer.load_directory(); - - // Select the file - app.explorer - .handle_input(KeyEvent::new(KeyCode::Down, KeyModifiers::empty())); - let action = app - .explorer - .handle_input(KeyEvent::new(KeyCode::Enter, KeyModifiers::empty())); - run(action, &mut app); - - // After file selection, sidebar_focused should be false - assert!(!app.bitcoin_config_view.sidebar_focused); - - // Pressing Esc via handle_input should set sidebar_focused back - let entries_clone = app.bitcoin_data.clone(); - let esc = KeyEvent::new(KeyCode::Esc, KeyModifiers::empty()); - app.bitcoin_config_view.handle_input(esc, &entries_clone); - assert!(app.bitcoin_config_view.sidebar_focused); - } - - // toggle_menu state cleanup - - #[test] - fn toggle_menu_clears_bitcoin_config_messages_on_navigate_away() { - let mut app = App::new(); - app.sidebar_index = 1; - app.toggle_menu(); // → BitcoinConfig - app.bitcoin_config_view.warning_message = Some("some warning".to_string()); - app.bitcoin_config_view.save_message = Some("saved".to_string()); - - app.sidebar_index = 0; - app.toggle_menu(); // → Home - - assert!(app.bitcoin_config_view.warning_message.is_none()); - assert!(app.bitcoin_config_view.save_message.is_none()); - } - - #[test] - fn toggle_menu_cancels_in_progress_edit_on_navigate_away() { - let mut app = App::new(); - app.sidebar_index = 1; - app.toggle_menu(); - app.bitcoin_config_view.editing = true; - app.bitcoin_config_view.edit_input = "draft value".to_string(); - - app.sidebar_index = 0; - app.toggle_menu(); // navigate away - - assert!(!app.bitcoin_config_view.editing); - assert!(app.bitcoin_config_view.edit_input.is_empty()); - } - - #[test] - fn toggle_menu_does_not_clear_messages_when_staying_on_other_screen() { - let mut app = App::new(); - // Start on Home (index 0), set some other state, navigate within Home - app.sidebar_index = 2; - app.toggle_menu(); // → BitcoinStatus - app.bitcoin_config_view.warning_message = Some("keep me".to_string()); - - app.sidebar_index = 3; - app.toggle_menu(); // → P2PoolConfig (never on BitcoinConfig, no clear should happen) - - // Messages only cleared when leaving BitcoinConfig, not from other screens - assert_eq!( - app.bitcoin_config_view.warning_message.as_deref(), - Some("keep me") - ); - } - - // dirty flag - - #[test] - fn commit_edit_sets_dirty_flag() { - use pdm::bitcoin_config::ConfigEntry; - - let mut app = App::new(); - app.bitcoin_data = vec![ConfigEntry { - key: "rpcuser".to_string(), - value: "old".to_string(), - enabled: true, - schema: None, - section: None, - }]; - - run(AppAction::CommitEdit(0, "new".to_string()), &mut app); - - assert!(app.bitcoin_config_view.dirty); - assert_eq!(app.bitcoin_data[0].value, "new"); - } - - #[test] - fn save_bitcoin_config_clears_dirty_flag() { - use pdm::bitcoin_config::ConfigEntry; - use tempfile::tempdir; - - let dir = tempdir().unwrap(); - let path = dir.path().join("bitcoin.conf"); - - let mut app = App::new(); - app.bitcoin_conf_path = Some(path.clone()); - app.bitcoin_config_view.dirty = true; - app.bitcoin_data = vec![ConfigEntry { - key: "rpcuser".to_string(), - value: "testuser".to_string(), - enabled: true, - schema: None, - section: None, - }]; - - run(AppAction::SaveBitcoinConfig, &mut app); - - assert!(!app.bitcoin_config_view.dirty); - } - - #[test] - #[serial] - fn file_selected_resets_dirty_flag() { - use tempfile::tempdir; - - let dir = tempdir().unwrap(); - redirect_saves_to(&dir); - let path = dir.path().join("bitcoin.conf"); - std::fs::write(&path, "rpcuser=test\n").unwrap(); - - let mut app = App::new(); - app.bitcoin_config_view.dirty = true; - app.explorer_trigger = Some(ExplorerTrigger::BitcoinConfig); - - run(AppAction::FileSelected(path), &mut app); - - assert!(!app.bitcoin_config_view.dirty); - } - - #[test] - fn commit_edit_out_of_bounds_does_not_set_dirty() { - let mut app = App::new(); - // bitcoin_data is empty; CommitEdit with bad index must not set dirty - run(AppAction::CommitEdit(99, "val".to_string()), &mut app); - assert!(!app.bitcoin_config_view.dirty); - } - // --- Settings handle_action tests --- #[test] fn open_explorer_for_settings_sets_state() { let mut app = App::new(); - app.sidebar_index = 8; + app.sidebar_index = 4; app.toggle_menu(); let flow = handle_action(AppAction::OpenExplorerForSettings(1), &mut app).unwrap(); @@ -1647,72 +1152,19 @@ port = 46884 let dir = tempdir().unwrap(); redirect_saves_to(&dir); - let path = dir.path().join("ln.conf"); - std::fs::write(&path, "").unwrap(); + let path = dir.path(); let mut app = App::new(); - app.explorer_trigger = Some(ExplorerTrigger::Settings(2)); // ln_conf_path + app.explorer_trigger = Some(ExplorerTrigger::Settings(1)); // settings_dir_override - run(AppAction::FileSelected(path.clone()), &mut app); + run(AppAction::FileSelected(path.to_path_buf()), &mut app); - assert_eq!(app.settings.ln_conf_path, Some(path)); + assert_eq!(app.settings.settings_dir_override, Some(path.to_path_buf())); assert_eq!(app.current_screen, CurrentScreen::Settings); assert!(!app.settings_view.sidebar_focused); } - #[test] - #[serial] - fn file_selected_bitcoin_config_persists_to_settings() { - use tempfile::tempdir; - - let dir = tempdir().unwrap(); - redirect_saves_to(&dir); - let path = dir.path().join("bitcoin.conf"); - std::fs::write(&path, "rpcuser=test\n").unwrap(); - - let mut app = App::new(); - app.explorer_trigger = Some(ExplorerTrigger::BitcoinConfig); - - run(AppAction::FileSelected(path.clone()), &mut app); - - assert_eq!(app.settings.bitcoin_conf_path, Some(path)); - } - - #[test] - fn bootstrap_from_settings_loads_bitcoin_config() { - use tempfile::tempdir; - - let dir = tempdir().unwrap(); - let path = dir.path().join("bitcoin.conf"); - std::fs::write(&path, "rpcuser=test\n").unwrap(); - - let mut app = App::new(); - app.settings.bitcoin_conf_path = Some(path.clone()); - - bootstrap_from_settings(&mut app); - - assert_eq!(app.bitcoin_conf_path, Some(path)); - assert!(!app.bitcoin_data.is_empty()); - } - - #[test] - fn bootstrap_from_settings_ignores_invalid_bitcoin_config() { - use tempfile::tempdir; - - let dir = tempdir().unwrap(); - let path = dir.path().join("bad.conf"); - std::fs::write(&path, "notakey=value\n").unwrap(); - - let mut app = App::new(); - app.settings.bitcoin_conf_path = Some(path); - - bootstrap_from_settings(&mut app); - - // Invalid config: bitcoin_conf_path must NOT be set on app - assert!(app.bitcoin_conf_path.is_none()); - } - - // Fix 13: Settings sidebar keyboard handler respects MAX_SIDEBAR_INDEX + // Settings sidebar keyboard handler respects MAX_SIDEBAR_INDEX #[test] fn settings_sidebar_down_nav_respects_max_sidebar_index() { let mut app = App::new(); @@ -1739,7 +1191,7 @@ port = 46884 assert_eq!(app.sidebar_index, MAX_SIDEBAR_INDEX - 1); } - // Fix 14: bootstrap_from_settings with a valid P2Pool config path + // bootstrap_from_settings with a valid P2Pool config path #[test] fn bootstrap_from_settings_loads_p2pool_conf_path() { use tempfile::tempdir; @@ -1756,63 +1208,62 @@ port = 46884 assert_eq!(app.p2pool_conf_path, Some(path)); } - // Fix 15: file_selected_for_settings for fields 0, 1, 3 and the wildcard arm #[test] #[serial] - fn file_selected_for_settings_field_0_bitcoin_conf_path() { + fn file_selected_for_settings_field_0_p2pool_conf_path() { use tempfile::tempdir; let dir = tempdir().unwrap(); redirect_saves_to(&dir); - let path = dir.path().join("bitcoin.conf"); - std::fs::write(&path, "rpcuser=test\n").unwrap(); + let path = dir.path().join("p2pool.toml"); + write_valid_p2pool_toml(&path); let mut app = App::new(); app.explorer_trigger = Some(ExplorerTrigger::Settings(0)); run(AppAction::FileSelected(path.clone()), &mut app); - assert_eq!(app.settings.bitcoin_conf_path, Some(path.clone())); - assert_eq!(app.bitcoin_conf_path, Some(path)); - assert!(!app.bitcoin_data.is_empty()); + assert_eq!(app.settings.p2pool_conf_path, Some(path.clone())); + assert_eq!(app.p2pool_conf_path, Some(path)); assert_eq!(app.current_screen, CurrentScreen::Settings); } - #[test] + #[tokio::test] #[serial] - fn file_selected_for_settings_field_1_p2pool_conf_path() { + async fn file_selected_p2pool_config_updates_api_client_from_api_section() { + use mockito::Server; + use serde_json::json; use tempfile::tempdir; let dir = tempdir().unwrap(); redirect_saves_to(&dir); + let mut server = Server::new_async().await; + let server_url = url::Url::parse(&server.url()).unwrap(); let path = dir.path().join("p2pool.toml"); - write_valid_p2pool_toml(&path); - - let mut app = App::new(); - app.explorer_trigger = Some(ExplorerTrigger::Settings(1)); - run(AppAction::FileSelected(path.clone()), &mut app); - - assert_eq!(app.settings.p2pool_conf_path, Some(path)); - assert_eq!(app.current_screen, CurrentScreen::Settings); - } - - #[test] - #[serial] - fn file_selected_for_settings_field_3_shares_market_conf_path() { - use tempfile::tempdir; + write_valid_p2pool_toml_with_api( + &path, + server_url.host_str().unwrap(), + server_url.port().unwrap(), + Some("user"), + Some("password"), + ); - let dir = tempdir().unwrap(); - redirect_saves_to(&dir); - let path = dir.path().join("shares.conf"); - std::fs::write(&path, "").unwrap(); + let mock = server + .mock("GET", "/chain_info") + .match_header("authorization", "Basic dXNlcjpwYXNzd29yZA==") + .with_status(200) + .with_header("content-type", "application/json") + .with_body(json!({ "total_work": "abc" }).to_string()) + .create(); let mut app = App::new(); - app.explorer_trigger = Some(ExplorerTrigger::Settings(3)); - run(AppAction::FileSelected(path.clone()), &mut app); + app.explorer_trigger = Some(ExplorerTrigger::P2PoolConfig); + run(AppAction::FileSelected(path), &mut app); - assert_eq!(app.settings.shares_market_conf_path, Some(path)); - assert_eq!(app.current_screen, CurrentScreen::Settings); - } + let info = app.p2pool_client.fetch_chain_info().await.unwrap(); + assert_eq!(info.total_work, "abc"); + mock.assert(); + } #[test] #[serial] fn file_selected_for_settings_wildcard_field_is_noop() { @@ -1828,17 +1279,14 @@ port = 46884 run(AppAction::FileSelected(path), &mut app); // None of the settings fields must have been touched - assert!(app.settings.bitcoin_conf_path.is_none()); 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.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_1_sets_dir_override() { use tempfile::tempdir; let dir = tempdir().unwrap(); @@ -1847,7 +1295,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(1)); run( AppAction::FileSelected(settings_dir.path().to_path_buf()), &mut app, @@ -1864,9 +1312,9 @@ port = 46884 } #[test] - fn open_explorer_for_settings_field4_enables_dir_select() { + fn open_explorer_for_settings_field1_enables_dir_select() { let mut app = App::new(); - run(AppAction::OpenExplorerForSettings(4), &mut app); + run(AppAction::OpenExplorerForSettings(1), &mut app); assert!(app.explorer.allow_dir_select); assert_eq!(app.current_screen, CurrentScreen::FileExplorer); } @@ -1884,7 +1332,7 @@ port = 46884 fn close_modal_resets_allow_dir_select() { let mut app = App::new(); app.explorer.allow_dir_select = true; - app.explorer_trigger = Some(ExplorerTrigger::Settings(4)); + app.explorer_trigger = Some(ExplorerTrigger::Settings(1)); app.current_screen = CurrentScreen::FileExplorer; app.sidebar_index = MAX_SIDEBAR_INDEX; @@ -1894,12 +1342,12 @@ port = 46884 assert!(app.explorer_trigger.is_none()); } - // Fix 16: CloseModal clears the ExplorerTrigger when triggered from Settings + // CloseModal clears the ExplorerTrigger when triggered from Settings #[test] fn close_modal_clears_settings_explorer_trigger() { let mut app = App::new(); app.sidebar_index = MAX_SIDEBAR_INDEX; // Settings - app.explorer_trigger = Some(ExplorerTrigger::Settings(2)); + app.explorer_trigger = Some(ExplorerTrigger::Settings(1)); app.current_screen = CurrentScreen::FileExplorer; run(AppAction::CloseModal, &mut app); @@ -1920,28 +1368,17 @@ port = 46884 redirect_saves_to(&dir); let mut app = App::new(); - app.settings.bitcoin_conf_path = Some(PathBuf::from("/tmp/bitcoin.conf")); 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.bitcoin_conf_path = Some(PathBuf::from("/tmp/bitcoin.conf")); + app.settings.settings_dir_override = Some(PathBuf::from("/tmp/override")); app.p2pool_conf_path = Some(PathBuf::from("/tmp/p2pool.toml")); run(AppAction::ClearSettingsField(0), &mut app); - assert!(app.settings.bitcoin_conf_path.is_none()); - assert!(app.bitcoin_conf_path.is_none()); - assert!(app.bitcoin_data.is_empty()); - - run(AppAction::ClearSettingsField(1), &mut app); assert!(app.settings.p2pool_conf_path.is_none()); assert!(app.p2pool_conf_path.is_none()); assert!(app.p2pool_config.is_none()); - run(AppAction::ClearSettingsField(2), &mut app); - assert!(app.settings.ln_conf_path.is_none()); - - run(AppAction::ClearSettingsField(3), &mut app); - assert!(app.settings.shares_market_conf_path.is_none()); + run(AppAction::ClearSettingsField(1), &mut app); + assert!(app.settings.settings_dir_override.is_none()); } #[test] @@ -1984,7 +1421,7 @@ port = 46884 write_valid_p2pool_toml(&file); let cfg = P2PoolConfig::load(file.to_str().unwrap()).unwrap(); - app.p2pool_config = Some(cfg); + app.set_p2pool_config(cfg); app.p2pool_config_view.warning_message = Some("old warning".to_string()); run( @@ -2003,7 +1440,7 @@ port = 46884 let mut app = App::new(); let cfg = P2PoolConfig::load(file.to_str().unwrap()).unwrap(); - app.p2pool_config = Some(cfg); + app.set_p2pool_config(cfg); // invalid numeric/bool/etc depending on index used by your flatten_config run( @@ -2024,7 +1461,7 @@ port = 46884 let cfg = P2PoolConfig::load(file.to_str().unwrap()).unwrap(); app.p2pool_conf_path = Some(file.clone()); - app.p2pool_config = Some(cfg); + app.set_p2pool_config(cfg); run(AppAction::SaveP2PoolConfig, &mut app); @@ -2045,7 +1482,7 @@ port = 46884 let cfg = P2PoolConfig::load(file.to_str().unwrap()).unwrap(); app.p2pool_conf_path = Some(bad_path); - app.p2pool_config = Some(cfg); + app.set_p2pool_config(cfg); run(AppAction::SaveP2PoolConfig, &mut app); diff --git a/src/p2poolv2_config.rs b/src/p2poolv2_config.rs index 300e04c..327f665 100644 --- a/src/p2poolv2_config.rs +++ b/src/p2poolv2_config.rs @@ -44,7 +44,7 @@ pub struct P2PoolFieldSchema { } /// A single editable TUI row — the view layer equivalent of -/// `ConfigEntry` in bitcoin_config.rs. +/// `P2PoolConfigEntry` in this module. /// The external `p2poolv2_config` crate has no concept of this; /// it only knows nested structs for deserialization. #[derive(Debug, Clone)] diff --git a/src/p2poolv2_service.rs b/src/p2poolv2_service.rs new file mode 100644 index 0000000..d18ebcc --- /dev/null +++ b/src/p2poolv2_service.rs @@ -0,0 +1,253 @@ +// SPDX-FileCopyrightText: 2024 PDM Authors +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +use anyhow::{Context, Result}; +use std::path::{Path, PathBuf}; +use std::process::Command; + +const SERVICE_PREFIX: &str = "p2poolv2@"; + +pub fn user_p2pool_config_dir() -> Option { + let config_home = std::env::var_os("XDG_CONFIG_HOME") + .filter(|value| !value.is_empty()) + .map(PathBuf::from) + .or_else(|| std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".config")))?; + + Some(config_home.join("p2poolv2")) +} + +pub fn instance_from_config_path(path: &Path) -> Option { + let config_dir = user_p2pool_config_dir()?; + instance_from_config_path_in_dir(path, &config_dir) +} + +fn instance_from_config_path_in_dir(path: &Path, config_dir: &Path) -> Option { + if path.parent()? != config_dir { + return None; + } + + let filename = path.file_name()?.to_str()?; + let instance = filename.strip_prefix("config-")?.strip_suffix(".toml")?; + + if instance.is_empty() + || !instance.chars().all(|character| { + character.is_ascii_alphanumeric() || character == '_' || character == '-' + }) + { + return None; + } + + Some(instance.to_string()) +} + +fn service_name(instance: &str) -> String { + format!("{SERVICE_PREFIX}{instance}") +} + +pub struct P2PoolV2Service; + +impl P2PoolV2Service { + pub fn start(instance: &str) -> Result<()> { + Self::run_systemctl("start", instance) + } + + pub fn stop(instance: &str) -> Result<()> { + Self::run_systemctl("stop", instance) + } + + pub fn restart(instance: &str) -> Result<()> { + Self::run_systemctl("restart", instance) + } + + pub fn is_running(instance: &str) -> Result { + let service = validated_service_name(instance)?; + let output = Command::new("systemctl") + .args(["--user", "is-active", &service]) + .output() + .context("failed to execute systemctl")?; + + Ok(output.status.success() && String::from_utf8_lossy(&output.stdout).trim() == "active") + } + + fn run_systemctl(action: &str, instance: &str) -> Result<()> { + let service = validated_service_name(instance)?; + let output = Command::new("systemctl") + .args(["--user", action, &service]) + .output() + .with_context(|| format!("failed to execute systemctl {action}"))?; + + if output.status.success() { + Ok(()) + } else { + let stderr = String::from_utf8_lossy(&output.stderr); + anyhow::bail!( + "systemctl --user {action} {service} failed: {}", + stderr.trim() + ); + } + } +} + +fn validated_service_name(instance: &str) -> Result { + if instance.is_empty() + || !instance.chars().all(|character| { + character.is_ascii_alphanumeric() || character == '_' || character == '-' + }) + { + anyhow::bail!("invalid P2Poolv2 service instance: {instance}"); + } + + Ok(service_name(instance)) +} + +#[cfg(test)] +mod tests { + use super::*; + use serial_test::serial; + use std::fs; + use std::os::unix::fs::PermissionsExt; + use tempfile::TempDir; + + fn fake_systemctl( + fail_actions: bool, + active: bool, + ) -> (TempDir, Option, std::path::PathBuf) { + let dir = tempfile::tempdir().unwrap(); + let script = dir.path().join("systemctl"); + let args_file = dir.path().join("args"); + fs::write( + &script, + format!( + "#!/bin/sh\nprintf '%s\\n' \"$@\" > '{}'\nif [ \"$2\" = \"is-active\" ]; then\n {}\nelse\n {}\nfi\n", + args_file.display(), + if active { + "echo active; exit 0" + } else { + "echo inactive; exit 3" + }, + if fail_actions { + "echo permission denied >&2; exit 1" + } else { + "exit 0" + } + ), + ) + .unwrap(); + fs::set_permissions(&script, fs::Permissions::from_mode(0o755)).unwrap(); + + let old_path = std::env::var_os("PATH"); + let path = match old_path.as_ref() { + Some(old_path) => format!("{}:{}", dir.path().display(), old_path.to_string_lossy()), + None => dir.path().display().to_string(), + }; + unsafe { std::env::set_var("PATH", path) }; + (dir, old_path, args_file) + } + + fn restore_path(old_path: Option) { + unsafe { + match old_path { + Some(path) => std::env::set_var("PATH", path), + None => std::env::remove_var("PATH"), + } + } + } + + #[test] + #[serial] + fn service_actions_succeed_and_active_service_is_running() -> Result<()> { + let (_dir, old_path, args_file) = fake_systemctl(false, true); + + let result = (|| { + P2PoolV2Service::start("signet")?; + P2PoolV2Service::stop("signet")?; + P2PoolV2Service::restart("signet")?; + assert!(P2PoolV2Service::is_running("signet")?); + assert_eq!( + fs::read_to_string(args_file)?, + "--user\nis-active\np2poolv2@signet\n" + ); + Ok::<_, anyhow::Error>(()) + })(); + + restore_path(old_path); + result + } + + #[test] + #[serial] + fn service_actions_report_systemctl_failures() { + let (_dir, old_path, _args_file) = fake_systemctl(true, false); + + let start_error = P2PoolV2Service::start("signet").unwrap_err().to_string(); + let stop_error = P2PoolV2Service::stop("signet").unwrap_err().to_string(); + let restart_error = P2PoolV2Service::restart("signet").unwrap_err().to_string(); + let running = P2PoolV2Service::is_running("signet").unwrap(); + + restore_path(old_path); + + assert!(start_error.contains("start p2poolv2@signet failed: permission denied")); + assert!(stop_error.contains("stop p2poolv2@signet failed: permission denied")); + assert!(restart_error.contains("restart p2poolv2@signet failed: permission denied")); + assert!(!running); + } + + #[test] + fn instance_validation_accepts_safe_names() { + for instance in [ + "signet", "main", "testnet4", "regtest", "foo-bar", "foo_bar", + ] { + assert!(validated_service_name(instance).is_ok(), "{instance}"); + } + } + + #[test] + fn instance_validation_rejects_unsafe_names() { + for instance in ["foo/bar", "foo bar", "foo@bar", "../foo", ""] { + assert!(validated_service_name(instance).is_err(), "{instance}"); + } + } + + #[test] + fn config_path_derives_instance_only_in_config_directory() { + let config_dir = PathBuf::from("/home/user/.config/p2poolv2"); + + assert_eq!( + instance_from_config_path_in_dir(&config_dir.join("config-signet.toml"), &config_dir), + Some("signet".to_string()) + ); + assert_eq!( + instance_from_config_path_in_dir(&config_dir.join("config-main.toml"), &config_dir), + Some("main".to_string()) + ); + + for path in [ + "/etc/p2poolv2/config-signet.toml", + "/home/user/p2pool/config-signet.toml", + "/home/user/p2pool/config.toml", + "/home/user/.config/p2poolv2/config.toml", + ] { + assert_eq!( + instance_from_config_path_in_dir(Path::new(path), &config_dir), + None + ); + } + } + + #[test] + #[ignore = "requires a user systemd session and an installed p2poolv2@.service"] + fn start_and_stop_service() -> Result<()> { + assert!(!P2PoolV2Service::is_running("signet")?); + + P2PoolV2Service::start("signet")?; + let running = P2PoolV2Service::is_running("signet"); + let stop_result = P2PoolV2Service::stop("signet"); + + assert!(running?); + stop_result?; + assert!(!P2PoolV2Service::is_running("signet")?); + + Ok(()) + } +} diff --git a/src/settings.rs b/src/settings.rs index db0f780..28ca4d2 100644 --- a/src/settings.rs +++ b/src/settings.rs @@ -18,14 +18,8 @@ use std::path::PathBuf; /// #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct Settings { - /// Path to the Bitcoin Core config file (bitcoin.conf) - pub bitcoin_conf_path: Option, /// Path to the p2poolv2 config file pub p2pool_conf_path: Option, - /// Path to the Lightning Network config file - pub ln_conf_path: Option, - /// Path to the Shares Market config file - pub shares_market_conf_path: Option, /// If set, the user-chosen directory where `settings.toml` is stored. /// the default location always holds a copy so the override is found /// on the next launch. @@ -118,10 +112,7 @@ mod tests { #[test] fn default_settings_has_no_paths() { let s = Settings::default(); - assert!(s.bitcoin_conf_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()); assert!(s.settings_dir_override.is_none()); } @@ -131,7 +122,6 @@ mod tests { // Write the settings file directly into the temp dir let path = dir.path().join("settings.toml"); let settings = Settings { - bitcoin_conf_path: Some(PathBuf::from("/tmp/bitcoin.conf")), p2pool_conf_path: Some(PathBuf::from("/tmp/p2pool.toml")), ..Default::default() }; @@ -140,9 +130,7 @@ 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.p2pool_conf_path, settings.p2pool_conf_path); - assert!(loaded.ln_conf_path.is_none()); } #[test] @@ -157,18 +145,16 @@ mod tests { let dir = tempfile::tempdir().unwrap(); set_config_dir(&dir); let settings = Settings { - bitcoin_conf_path: Some(PathBuf::from("/tmp/bitcoin.conf")), - ln_conf_path: Some(PathBuf::from("/tmp/ln.conf")), + p2pool_conf_path: Some(PathBuf::from("/tmp/p2pool.toml")), ..Default::default() }; save_settings(&settings).unwrap(); let loaded = load_settings(); assert_eq!( - loaded.bitcoin_conf_path, - Some(PathBuf::from("/tmp/bitcoin.conf")) + loaded.p2pool_conf_path, + Some(PathBuf::from("/tmp/p2pool.toml")) ); - assert_eq!(loaded.ln_conf_path, Some(PathBuf::from("/tmp/ln.conf"))); - assert!(loaded.p2pool_conf_path.is_none()); + assert!(loaded.settings_dir_override.is_none()); } #[test] @@ -177,15 +163,15 @@ mod tests { let dir = tempfile::tempdir().unwrap(); set_config_dir(&dir); let settings = Settings { - shares_market_conf_path: Some(PathBuf::from("/tmp/shares.conf")), + p2pool_conf_path: Some(PathBuf::from("/tmp/p2pool.toml")), ..Default::default() }; save_settings(&settings).unwrap(); let path = dir.path().join("settings.toml"); assert!(path.exists()); let content = std::fs::read_to_string(&path).unwrap(); - assert!(content.contains("shares_market_conf_path")); - assert!(content.contains("/tmp/shares.conf")); + assert!(content.contains("p2pool_conf_path")); + assert!(content.contains("/tmp/p2pool.toml")); } #[test] @@ -213,7 +199,8 @@ mod tests { set_config_dir(&dir); // No settings.toml written let settings = load_settings(); - assert!(settings.bitcoin_conf_path.is_none()); + assert!(settings.p2pool_conf_path.is_none()); + assert!(settings.settings_dir_override.is_none()); } #[test] @@ -223,7 +210,8 @@ mod tests { set_config_dir(&dir); 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.p2pool_conf_path.is_none()); + assert!(settings.settings_dir_override.is_none()); } #[test] @@ -233,13 +221,13 @@ mod tests { set_config_dir(&dir); std::fs::write( dir.path().join("settings.toml"), - r#"bitcoin_conf_path = "/tmp/bitcoin.conf""#, + r#"p2pool_conf_path = "/tmp/p2pool.toml""#, ) .unwrap(); let settings = load_settings(); assert_eq!( - settings.bitcoin_conf_path, - Some(PathBuf::from("/tmp/bitcoin.conf")) + settings.p2pool_conf_path, + Some(PathBuf::from("/tmp/p2pool.toml")) ); } @@ -264,15 +252,13 @@ mod tests { let dir = tempfile::tempdir().unwrap(); set_config_dir(&dir); let settings = Settings { - bitcoin_conf_path: Some(PathBuf::from("/tmp/bitcoin.conf")), - ln_conf_path: Some(PathBuf::from("/tmp/ln.conf")), + p2pool_conf_path: Some(PathBuf::from("/tmp/p2pool.toml")), ..Default::default() }; save_settings(&settings).unwrap(); let loaded = load_settings(); - assert_eq!(loaded.bitcoin_conf_path, settings.bitcoin_conf_path); - assert_eq!(loaded.ln_conf_path, settings.ln_conf_path); - assert!(loaded.p2pool_conf_path.is_none()); + assert_eq!(loaded.p2pool_conf_path, settings.p2pool_conf_path); + assert!(loaded.settings_dir_override.is_none()); } #[test] @@ -283,7 +269,6 @@ mod tests { set_config_dir(&default_dir); let settings = Settings { - bitcoin_conf_path: Some(PathBuf::from("/tmp/bitcoin.conf")), settings_dir_override: Some(override_dir.path().to_path_buf()), ..Default::default() }; @@ -297,8 +282,11 @@ mod tests { let override_content = std::fs::read_to_string(&override_path).unwrap(); let default_content = std::fs::read_to_string(&default_path).unwrap(); - assert!(override_content.contains("/tmp/bitcoin.conf")); - assert!(default_content.contains("/tmp/bitcoin.conf")); + assert_eq!( + override_content, default_content, + "override and default copies must match" + ); + assert!(override_content.contains("settings_dir_override")); } #[test] @@ -310,6 +298,7 @@ mod tests { // Write a pointer in the default dir. let pointer = Settings { + p2pool_conf_path: Some(PathBuf::from("/default/p2pool.toml")), settings_dir_override: Some(override_dir.path().to_path_buf()), ..Default::default() }; @@ -321,7 +310,7 @@ mod tests { // Write the authoritative settings in the override dir. let authoritative = Settings { - bitcoin_conf_path: Some(PathBuf::from("/override/bitcoin.conf")), + p2pool_conf_path: Some(PathBuf::from("/override/p2pool.toml")), settings_dir_override: Some(override_dir.path().to_path_buf()), ..Default::default() }; @@ -333,8 +322,9 @@ mod tests { let loaded = load_settings(); assert_eq!( - loaded.bitcoin_conf_path, - Some(PathBuf::from("/override/bitcoin.conf")) + loaded.p2pool_conf_path, + Some(PathBuf::from("/override/p2pool.toml")), + "must read from the override dir, not the default-location pointer" ); } @@ -346,7 +336,7 @@ mod tests { // Pointer points to a directory that doesn't exist. let pointer = Settings { - bitcoin_conf_path: Some(PathBuf::from("/default/bitcoin.conf")), + p2pool_conf_path: Some(PathBuf::from("/default/p2pool.toml")), settings_dir_override: Some(PathBuf::from("/nonexistent/dir")), ..Default::default() }; @@ -359,8 +349,8 @@ mod tests { let loaded = load_settings(); // Override unreadable → falls back to the default-location settings. assert_eq!( - loaded.bitcoin_conf_path, - Some(PathBuf::from("/default/bitcoin.conf")) + loaded.p2pool_conf_path, + Some(PathBuf::from("/default/p2pool.toml")) ); } } diff --git a/src/snapshots/pdm__tests__home_screen.snap b/src/snapshots/pdm__tests__home_screen.snap index 07fb4b8..887e717 100644 --- a/src/snapshots/pdm__tests__home_screen.snap +++ b/src/snapshots/pdm__tests__home_screen.snap @@ -8,13 +8,9 @@ TestBackend { content: [ "┌ PDM ──────────────────┐┌ Home ───────────────────────────────────────────────┐", "│Home ││Welcome to PDM. │", - "│Bitcoin Config ││ │", - "│Bitcoin Status ││Select a config from the sidebar to edit. │", - "│P2Pool Config ││ │", + "│Bitcoin Status ││ │", + "│P2Pool Config ││Select a config from the sidebar to edit. │", "│P2Pool Status ││ │", - "│LN Config ││ │", - "│LN Status ││ │", - "│Shares Market ││ │", "│Settings ││ │", "│ ││ │", "│ ││ │", @@ -29,6 +25,10 @@ TestBackend { "│ ││ │", "│ ││ │", "│ ││ │", + "│ ││ │", + "│ ││ │", + "│ ││ │", + "│ ││ │", "└───────────────────────┘└─────────────────────────────────────────────────────┘", " ↑↓ Navigate sidebar Enter Select q Quit ", ], diff --git a/src/snapshots/pdm__tests__menu_toggled.snap b/src/snapshots/pdm__tests__menu_toggled.snap index 94113b6..b781eb0 100644 --- a/src/snapshots/pdm__tests__menu_toggled.snap +++ b/src/snapshots/pdm__tests__menu_toggled.snap @@ -8,13 +8,9 @@ TestBackend { content: [ "┌ PDM ──────────────────┐┌ Bitcoin Config ─────────────────────────────────────┐", "│Home ││Press [Enter] to select a bitcoin.conf file │", - "│Bitcoin Config ││ │", "│Bitcoin Status ││ │", "│P2Pool Config ││ │", "│P2Pool Status ││ │", - "│LN Config ││ │", - "│LN Status ││ │", - "│Shares Market ││ │", "│Settings ││ │", "│ ││ │", "│ ││ │", @@ -29,6 +25,9 @@ TestBackend { "│ ││ │", "│ ││ │", "│ ││ │", + "│ ││ │", + "│ ││ │", + "│ ││ │", "└───────────────────────┘└─────────────────────────────────────────────────────┘", " ↑↓ Navigate sidebar Enter Open file Esc Back ", ], diff --git a/src/snapshots/pdm__ui__tests__bitcoin_config_screen_render.snap b/src/snapshots/pdm__ui__tests__bitcoin_config_screen_render.snap index 1e913e9..79748e0 100644 --- a/src/snapshots/pdm__ui__tests__bitcoin_config_screen_render.snap +++ b/src/snapshots/pdm__ui__tests__bitcoin_config_screen_render.snap @@ -13,9 +13,6 @@ TestBackend { "│Bitcoin Status ││ │", "│P2Pool Config ││ │", "│P2Pool Status ││ │", - "│LN Config ││ │", - "│LN Status ││ │", - "│Shares Market ││ │", "│Settings ││ │", "│ ││ │", "│ ││ │", @@ -29,6 +26,9 @@ TestBackend { "│ ││ │", "│ ││ │", "│ ││ │", + "│ ││ │", + "│ ││ │", + "│ ││ │", "└───────────────────────┘└─────────────────────────────────────────────────────┘", " ↑↓ Navigate sidebar Enter Open file Esc Back ", ], diff --git a/src/snapshots/pdm__ui__tests__bitcoin_screen_render.snap b/src/snapshots/pdm__ui__tests__bitcoin_screen_render.snap index 4463830..305369a 100644 --- a/src/snapshots/pdm__ui__tests__bitcoin_screen_render.snap +++ b/src/snapshots/pdm__ui__tests__bitcoin_screen_render.snap @@ -8,13 +8,12 @@ TestBackend { content: [ "┌ PDM ──────────────────┐┌ Bitcoin Config ─────────────────────────────────────┐", "│Home ││Press [Enter] to select a bitcoin.conf file │", - "│Bitcoin Config ││ │", "│Bitcoin Status ││ │", "│P2Pool Config ││ │", "│P2Pool Status ││ │", - "│LN Config ││ │", - "│LN Status ││ │", - "│Shares Market ││ │", + "│Settings ││ │", + "│ ││ │", + "│ ││ │", "│ ││ │", "│ ││ │", "│ ││ │", diff --git a/src/snapshots/pdm__ui__tests__bitcoin_status_screen_render.snap b/src/snapshots/pdm__ui__tests__bitcoin_status_screen_render.snap index 5593cb3..e9fc7ef 100644 --- a/src/snapshots/pdm__ui__tests__bitcoin_status_screen_render.snap +++ b/src/snapshots/pdm__ui__tests__bitcoin_status_screen_render.snap @@ -1,6 +1,5 @@ --- source: src/ui.rs -assertion_line: 149 expression: terminal.backend() --- TestBackend { @@ -8,15 +7,15 @@ TestBackend { area: Rect { x: 0, y: 0, width: 80, height: 24 }, content: [ "┌ PDM ──────────────────┐┌ Info ───────────────────────────────────────────────┐", - "│Home ││ Chain Info │ System │ Logs │ Peers │", - "│Bitcoin Config ││ │", - "│Bitcoin Status │└─────────────────────────────────────────────────────┘", - "│P2Pool Config │┌ Chain Info ─────────────────────────────────────────┐", - "│P2Pool Status ││Select a bitcoin.conf file to load Bitcoin Core chain│", - "│LN Config ││info. │", - "│LN Status ││ │", - "│Shares Market ││ │", - "│Settings ││ │", + "│Home ││ Chain Info │ Peers │", + "│Bitcoin Status ││ │", + "│P2Pool Config │└─────────────────────────────────────────────────────┘", + "│P2Pool Status │┌ Chain Info ─────────────────────────────────────────┐", + "│Settings ││Select a P2Poolv2 config file to load Bitcoin Core │", + "│ ││chain info. │", + "│ ││ │", + "│ ││ │", + "│ ││ │", "│ ││ │", "│ ││ │", "│ ││ │", @@ -36,12 +35,12 @@ TestBackend { x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 27, y: 1, fg: Black, bg: Gray, underline: Reset, modifier: NONE, x: 37, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 1, y: 3, fg: Black, bg: Gray, underline: Reset, modifier: NONE, - x: 24, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 1, y: 2, fg: Black, bg: Gray, underline: Reset, modifier: NONE, + x: 24, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 26, y: 5, fg: DarkGray, bg: Reset, underline: Reset, modifier: NONE, - x: 79, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 76, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 26, y: 6, fg: DarkGray, bg: Reset, underline: Reset, modifier: NONE, - x: 31, y: 6, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 37, y: 6, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 0, y: 23, fg: White, bg: DarkGray, underline: Reset, modifier: NONE, x: 4, y: 23, fg: DarkGray, bg: Black, underline: Reset, modifier: NONE, x: 23, y: 23, fg: White, bg: DarkGray, underline: Reset, modifier: NONE, diff --git a/src/snapshots/pdm__ui__tests__bitcoin_status_tab_logs_render.snap b/src/snapshots/pdm__ui__tests__bitcoin_status_tab_logs_render.snap index 0d5432c..f357a36 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 @@ -7,36 +7,34 @@ TestBackend { area: Rect { x: 0, y: 0, width: 80, height: 24 }, content: [ "┌ PDM ──────────────────┐┌ Info ───────────────────────────────────────────────┐", - "│Home ││ Chain Info │ System │ Logs │ Peers │", - "│Bitcoin Config ││ │", - "│Bitcoin Status │└─────────────────────────────────────────────────────┘", - "│P2Pool Config │┌─────────────────────────────────────────────────────┐", - "│P2Pool Status ││Logs │", - "│LN Config ││ │", - "│LN Status ││ │", - "│Shares Market ││ │", - "│Settings ││ │", - "│ ││ │", - "│ ││ │", - "│ ││ │", - "│ ││ │", - "│ ││ │", - "│ ││ │", - "│ ││ │", - "│ ││ │", - "│ ││ │", - "│ ││ │", - "│ ││ │", - "│ ││ │", - "└───────────────────────┘└─────────────────────────────────────────────────────┘", + "│Home ││ Chain Info │ Peers │", + "│Bitcoin Status ││ │", + "│P2Pool Config │└─────────────────────────────────────────────────────┘", + "│P2Pool Status │ ", + "│Settings │ ", + "│ │ ", + "│ │ ", + "│ │ ", + "│ │ ", + "│ │ ", + "│ │ ", + "│ │ ", + "│ │ ", + "│ │ ", + "│ │ ", + "│ │ ", + "│ │ ", + "│ │ ", + "│ │ ", + "│ │ ", + "│ │ ", + "└───────────────────────┘ ", " ↑↓ Navigate sidebar ←→ Switch tab q Quit ", ], styles: [ x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 49, y: 1, fg: Black, bg: Gray, underline: Reset, modifier: NONE, - 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: 1, y: 2, fg: Black, bg: Gray, underline: Reset, modifier: NONE, + x: 24, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 0, y: 23, fg: White, bg: DarkGray, underline: Reset, modifier: NONE, x: 4, y: 23, fg: DarkGray, bg: Black, underline: Reset, modifier: NONE, x: 23, y: 23, fg: White, bg: DarkGray, underline: Reset, modifier: NONE, diff --git a/src/snapshots/pdm__ui__tests__bitcoin_status_tab_peers_render.snap b/src/snapshots/pdm__ui__tests__bitcoin_status_tab_peers_render.snap index 3cd08fc..6e43258 100644 --- a/src/snapshots/pdm__ui__tests__bitcoin_status_tab_peers_render.snap +++ b/src/snapshots/pdm__ui__tests__bitcoin_status_tab_peers_render.snap @@ -7,15 +7,15 @@ TestBackend { area: Rect { x: 0, y: 0, width: 80, height: 24 }, content: [ "┌ PDM ──────────────────┐┌ Info ───────────────────────────────────────────────┐", - "│Home ││ Chain Info │ System │ Logs │ Peers │", - "│Bitcoin Config ││ │", - "│Bitcoin Status │└─────────────────────────────────────────────────────┘", - "│P2Pool Config │┌ Peers ──────────────────────────────────────────────┐", - "│P2Pool Status ││Select a bitcoin.conf file to load Bitcoin Core peer │", - "│LN Config ││info. │", - "│LN Status ││ │", - "│Shares Market ││ │", - "│Settings ││ │", + "│Home ││ Chain Info │ Peers │", + "│Bitcoin Status ││ │", + "│P2Pool Config │└─────────────────────────────────────────────────────┘", + "│P2Pool Status │┌ Peers ──────────────────────────────────────────────┐", + "│Settings ││Select a P2Poolv2 config file to load Bitcoin Core │", + "│ ││peer info. │", + "│ ││ │", + "│ ││ │", + "│ ││ │", "│ ││ │", "│ ││ │", "│ ││ │", @@ -33,14 +33,14 @@ TestBackend { ], styles: [ x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 56, y: 1, fg: Black, bg: Gray, underline: Reset, modifier: NONE, - x: 61, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 1, y: 3, fg: Black, bg: Gray, underline: Reset, modifier: NONE, - x: 24, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 40, y: 1, fg: Black, bg: Gray, underline: Reset, modifier: NONE, + x: 45, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 1, y: 2, fg: Black, bg: Gray, underline: Reset, modifier: NONE, + x: 24, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 26, y: 5, fg: DarkGray, bg: Reset, underline: Reset, modifier: NONE, - x: 78, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 76, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 26, y: 6, fg: DarkGray, bg: Reset, underline: Reset, modifier: NONE, - x: 31, y: 6, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 36, y: 6, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 0, y: 23, fg: White, bg: DarkGray, underline: Reset, modifier: NONE, x: 4, y: 23, fg: DarkGray, bg: Black, underline: Reset, modifier: NONE, x: 23, y: 23, fg: White, bg: DarkGray, underline: Reset, modifier: NONE, diff --git a/src/snapshots/pdm__ui__tests__bitcoin_status_tab_system_render.snap b/src/snapshots/pdm__ui__tests__bitcoin_status_tab_system_render.snap index 4e116ec..6e43258 100644 --- a/src/snapshots/pdm__ui__tests__bitcoin_status_tab_system_render.snap +++ b/src/snapshots/pdm__ui__tests__bitcoin_status_tab_system_render.snap @@ -7,15 +7,15 @@ TestBackend { area: Rect { x: 0, y: 0, width: 80, height: 24 }, content: [ "┌ PDM ──────────────────┐┌ Info ───────────────────────────────────────────────┐", - "│Home ││ Chain Info │ System │ Logs │ Peers │", - "│Bitcoin Config ││ │", - "│Bitcoin Status │└─────────────────────────────────────────────────────┘", - "│P2Pool Config │┌─────────────────────────────────────────────────────┐", - "│P2Pool Status ││System │", - "│LN Config ││ │", - "│LN Status ││ │", - "│Shares Market ││ │", - "│Settings ││ │", + "│Home ││ Chain Info │ Peers │", + "│Bitcoin Status ││ │", + "│P2Pool Config │└─────────────────────────────────────────────────────┘", + "│P2Pool Status │┌ Peers ──────────────────────────────────────────────┐", + "│Settings ││Select a P2Poolv2 config file to load Bitcoin Core │", + "│ ││peer info. │", + "│ ││ │", + "│ ││ │", + "│ ││ │", "│ ││ │", "│ ││ │", "│ ││ │", @@ -34,9 +34,13 @@ TestBackend { styles: [ x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 40, y: 1, fg: Black, bg: Gray, underline: Reset, modifier: NONE, - x: 46, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 1, y: 3, fg: Black, bg: Gray, underline: Reset, modifier: NONE, - x: 24, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 45, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 1, y: 2, fg: Black, bg: Gray, underline: Reset, modifier: NONE, + x: 24, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 26, y: 5, fg: DarkGray, bg: Reset, underline: Reset, modifier: NONE, + x: 76, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 26, y: 6, fg: DarkGray, bg: Reset, underline: Reset, modifier: NONE, + x: 36, y: 6, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 0, y: 23, fg: White, bg: DarkGray, underline: Reset, modifier: NONE, x: 4, y: 23, fg: DarkGray, bg: Black, underline: Reset, modifier: NONE, x: 23, y: 23, fg: White, bg: DarkGray, underline: Reset, modifier: NONE, diff --git a/src/snapshots/pdm__ui__tests__home_screen_render.snap b/src/snapshots/pdm__ui__tests__home_screen_render.snap index c25d78b..81e2693 100644 --- a/src/snapshots/pdm__ui__tests__home_screen_render.snap +++ b/src/snapshots/pdm__ui__tests__home_screen_render.snap @@ -1,6 +1,5 @@ --- source: src/ui.rs -assertion_line: 117 expression: terminal.backend() --- TestBackend { @@ -9,13 +8,9 @@ TestBackend { content: [ "┌ PDM ──────────────────┐┌ Home ───────────────────────────────────────────────┐", "│Home ││Welcome to PDM. │", - "│Bitcoin Config ││ │", - "│Bitcoin Status ││Select a config from the sidebar to edit. │", - "│P2Pool Config ││ │", + "│Bitcoin Status ││ │", + "│P2Pool Config ││Select a config from the sidebar to edit. │", "│P2Pool Status ││ │", - "│LN Config ││ │", - "│LN Status ││ │", - "│Shares Market ││ │", "│Settings ││ │", "│ ││ │", "│ ││ │", @@ -29,6 +24,10 @@ TestBackend { "│ ││ │", "│ ││ │", "│ ││ │", + "│ ││ │", + "│ ││ │", + "│ ││ │", + "│ ││ │", "└───────────────────────┘└─────────────────────────────────────────────────────┘", " ↑↓ Navigate sidebar Enter Select q Quit ", ], diff --git a/src/snapshots/pdm__ui__tests__ln_config_screen_render.snap b/src/snapshots/pdm__ui__tests__ln_config_screen_render.snap index c4861bc..958aa37 100644 --- a/src/snapshots/pdm__ui__tests__ln_config_screen_render.snap +++ b/src/snapshots/pdm__ui__tests__ln_config_screen_render.snap @@ -1,56 +1 @@ ---- -source: src/ui.rs -assertion_line: 200 -expression: terminal.backend() ---- -TestBackend { - buffer: Buffer { - area: Rect { x: 0, y: 0, width: 80, height: 24 }, - content: [ - "┌ PDM ──────────────────┐┌ LN Config ──────────────────────────────────────────┐", - "│Home ││LN Config │", - "│Bitcoin Config ││ │", - "│Bitcoin Status ││ │", - "│P2Pool Config ││ │", - "│P2Pool Status ││ │", - "│LN Config ││ │", - "│LN Status ││ │", - "│Shares Market ││ │", - "│Settings ││ │", - "│ ││ │", - "│ ││ │", - "│ ││ │", - "│ ││ │", - "│ ││ │", - "│ ││ │", - "│ ││ │", - "│ ││ │", - "│ ││ │", - "│ ││ │", - "│ ││ │", - "│ ││ │", - "└───────────────────────┘└─────────────────────────────────────────────────────┘", - " ↑↓ Navigate sidebar Enter Select q Quit ", - ], - styles: [ - x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 1, y: 6, fg: Black, bg: Gray, underline: Reset, modifier: NONE, - x: 24, y: 6, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 0, y: 23, fg: White, bg: DarkGray, underline: Reset, modifier: NONE, - x: 4, y: 23, fg: DarkGray, bg: Black, underline: Reset, modifier: NONE, - x: 23, y: 23, fg: White, bg: DarkGray, underline: Reset, modifier: NONE, - x: 30, y: 23, fg: DarkGray, bg: Black, underline: Reset, modifier: NONE, - x: 39, y: 23, fg: White, bg: DarkGray, underline: Reset, modifier: NONE, - x: 42, y: 23, fg: DarkGray, bg: Black, underline: Reset, modifier: NONE, - x: 49, y: 23, fg: Reset, bg: Black, underline: Reset, modifier: NONE, - ] - }, - scrollback: Buffer { - area: Rect { x: 0, y: 0, width: 80, height: 0 } - }, - cursor: false, - pos: ( - 0, - 0, - ), -} +# Deleted snapshot diff --git a/src/snapshots/pdm__ui__tests__ln_status_screen_render.snap b/src/snapshots/pdm__ui__tests__ln_status_screen_render.snap index c5ace26..958aa37 100644 --- a/src/snapshots/pdm__ui__tests__ln_status_screen_render.snap +++ b/src/snapshots/pdm__ui__tests__ln_status_screen_render.snap @@ -1,56 +1 @@ ---- -source: src/ui.rs -assertion_line: 210 -expression: terminal.backend() ---- -TestBackend { - buffer: Buffer { - area: Rect { x: 0, y: 0, width: 80, height: 24 }, - content: [ - "┌ PDM ──────────────────┐┌ LN Status ──────────────────────────────────────────┐", - "│Home ││LN Status │", - "│Bitcoin Config ││ │", - "│Bitcoin Status ││ │", - "│P2Pool Config ││ │", - "│P2Pool Status ││ │", - "│LN Config ││ │", - "│LN Status ││ │", - "│Shares Market ││ │", - "│Settings ││ │", - "│ ││ │", - "│ ││ │", - "│ ││ │", - "│ ││ │", - "│ ││ │", - "│ ││ │", - "│ ││ │", - "│ ││ │", - "│ ││ │", - "│ ││ │", - "│ ││ │", - "│ ││ │", - "└───────────────────────┘└─────────────────────────────────────────────────────┘", - " ↑↓ Navigate sidebar Enter Select q Quit ", - ], - styles: [ - x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 1, y: 7, fg: Black, bg: Gray, underline: Reset, modifier: NONE, - x: 24, y: 7, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 0, y: 23, fg: White, bg: DarkGray, underline: Reset, modifier: NONE, - x: 4, y: 23, fg: DarkGray, bg: Black, underline: Reset, modifier: NONE, - x: 23, y: 23, fg: White, bg: DarkGray, underline: Reset, modifier: NONE, - x: 30, y: 23, fg: DarkGray, bg: Black, underline: Reset, modifier: NONE, - x: 39, y: 23, fg: White, bg: DarkGray, underline: Reset, modifier: NONE, - x: 42, y: 23, fg: DarkGray, bg: Black, underline: Reset, modifier: NONE, - x: 49, y: 23, fg: Reset, bg: Black, underline: Reset, modifier: NONE, - ] - }, - scrollback: Buffer { - area: Rect { x: 0, y: 0, width: 80, height: 0 } - }, - cursor: false, - pos: ( - 0, - 0, - ), -} +# Deleted snapshot diff --git a/src/snapshots/pdm__ui__tests__p2pool_config_screen_render.snap b/src/snapshots/pdm__ui__tests__p2pool_config_screen_render.snap index 86a80fa..60f7f52 100644 --- a/src/snapshots/pdm__ui__tests__p2pool_config_screen_render.snap +++ b/src/snapshots/pdm__ui__tests__p2pool_config_screen_render.snap @@ -1,6 +1,5 @@ --- source: src/ui.rs -assertion_line: 180 expression: terminal.backend() --- TestBackend { @@ -9,13 +8,9 @@ TestBackend { content: [ "┌ PDM ──────────────────┐┌ P2Pool Config ──────────────────────────────────────┐", "│Home ││Press [Enter] to select a p2poolv2 config file │", - "│Bitcoin Config ││ │", "│Bitcoin Status ││ │", "│P2Pool Config ││ │", "│P2Pool Status ││ │", - "│LN Config ││ │", - "│LN Status ││ │", - "│Shares Market ││ │", "│Settings ││ │", "│ ││ │", "│ ││ │", @@ -29,13 +24,17 @@ TestBackend { "│ ││ │", "│ ││ │", "│ ││ │", + "│ ││ │", + "│ ││ │", + "│ ││ │", + "│ ││ │", "└───────────────────────┘└─────────────────────────────────────────────────────┘", " ↑↓ Navigate sidebar Enter Select q Quit ", ], styles: [ x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 1, y: 4, fg: Black, bg: Gray, underline: Reset, modifier: NONE, - x: 24, y: 4, 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: 0, y: 23, fg: White, bg: DarkGray, underline: Reset, modifier: NONE, x: 4, y: 23, fg: DarkGray, bg: Black, underline: Reset, modifier: NONE, x: 23, y: 23, fg: White, bg: DarkGray, underline: Reset, modifier: NONE, diff --git a/src/snapshots/pdm__ui__tests__p2pool_screen_render.snap b/src/snapshots/pdm__ui__tests__p2pool_screen_render.snap index 2facef3..3a5e09b 100644 --- a/src/snapshots/pdm__ui__tests__p2pool_screen_render.snap +++ b/src/snapshots/pdm__ui__tests__p2pool_screen_render.snap @@ -8,13 +8,12 @@ TestBackend { content: [ "┌ PDM ──────────────────┐┌ Info ───────────────────────────────────────────────┐", "│Home ││ Chain Info │ System │ Logs │ Peers │", - "│Bitcoin Config ││ │", "│Bitcoin Status │└─────────────────────────────────────────────────────┘", "│P2Pool Config │┌─────────────────────────────────────────────────────┐", "│P2Pool Status ││Chain Info │", - "│LN Config ││ │", - "│LN Status ││ │", - "│Shares Market ││ │", + "│Settings ││ │", + "│ ││ │", + "│ ││ │", "│ ││ │", "│ ││ │", "│ ││ │", diff --git a/src/snapshots/pdm__ui__tests__p2pool_status_screen_render.snap b/src/snapshots/pdm__ui__tests__p2pool_status_screen_render.snap index 3f096dd..e3b9922 100644 --- a/src/snapshots/pdm__ui__tests__p2pool_status_screen_render.snap +++ b/src/snapshots/pdm__ui__tests__p2pool_status_screen_render.snap @@ -1,6 +1,5 @@ --- source: src/ui.rs -assertion_line: 219 expression: terminal.backend() --- TestBackend { @@ -8,15 +7,15 @@ TestBackend { area: Rect { x: 0, y: 0, width: 80, height: 24 }, content: [ "┌ PDM ──────────────────┐┌ Info ───────────────────────────────────────────────┐", - "│Home ││ Chain Info │ Shares │ Peers Info │", - "│Bitcoin Config ││ │", - "│Bitcoin Status │└─────────────────────────────────────────────────────┘", - "│P2Pool Config │┌ Chain Info ─────────────────────────────────────────┐", - "│P2Pool Status ││Loading chain info... │", - "│LN Config ││ │", - "│LN Status ││ │", - "│Shares Market ││ │", - "│Settings ││ │", + "│Home ││ Chain Info │ Shares │ Peers Info │ System │", + "│Bitcoin Status ││ │", + "│P2Pool Config │└─────────────────────────────────────────────────────┘", + "│P2Pool Status │┌ Chain Info ─────────────────────────────────────────┐", + "│Settings ││Select a P2Poolv2 config file to load P2Pool chain │", + "│ ││info. │", + "│ ││ │", + "│ ││ │", + "│ ││ │", "│ ││ │", "│ ││ │", "│ ││ │", @@ -36,10 +35,12 @@ TestBackend { x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 27, y: 1, fg: Black, bg: Gray, underline: Reset, modifier: NONE, x: 37, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 1, y: 5, fg: Black, bg: Gray, underline: Reset, modifier: NONE, - x: 24, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 1, y: 4, fg: Black, bg: Gray, underline: Reset, modifier: NONE, + x: 24, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 26, y: 5, fg: DarkGray, bg: Reset, underline: Reset, modifier: NONE, - x: 47, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 76, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 26, y: 6, fg: DarkGray, bg: Reset, underline: Reset, modifier: NONE, + x: 31, y: 6, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 0, y: 23, fg: White, bg: DarkGray, underline: Reset, modifier: NONE, x: 4, y: 23, fg: DarkGray, bg: Black, underline: Reset, modifier: NONE, x: 23, y: 23, fg: White, bg: DarkGray, underline: Reset, modifier: NONE, diff --git a/src/snapshots/pdm__ui__tests__settings_screen_render.snap b/src/snapshots/pdm__ui__tests__settings_screen_render.snap index 406ff9a..34e663c 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 { @@ -8,16 +7,16 @@ TestBackend { area: Rect { x: 0, y: 0, width: 80, height: 24 }, content: [ "┌ PDM ──────────────────┐┌ Settings ───────────────────────────────────────────┐", - "│Home ││Bitcoin config path │", - "│Bitcoin Config ││(not set) │", - "│Bitcoin Status ││P2Pool config path │", - "│P2Pool Config ││(not set) │", - "│P2Pool Status ││LN config path │", - "│LN Config ││(not set) │", - "│LN Status ││Shares Market config path │", - "│Shares Market ││(not set) │", - "│Settings ││Settings directory │", - "│ ││/pdm/test-config │", + "│Home ││P2Pool config path │", + "│Bitcoin Status ││(not set) │", + "│P2Pool Config ││Settings directory │", + "│P2Pool Status ││/pdm/test-config │", + "│Settings ││ │", + "│ ││ │", + "│ ││ │", + "│ ││ │", + "│ ││ │", + "│ ││ │", "│ ││ │", "│ ││ │", "│ ││ │", @@ -38,7 +37,7 @@ TestBackend { x: 0, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 25, y: 1, fg: DarkGray, bg: Reset, underline: Reset, modifier: NONE, x: 26, y: 1, fg: Gray, bg: DarkGray, underline: Reset, modifier: NONE, - x: 45, y: 1, fg: Reset, bg: DarkGray, underline: Reset, modifier: NONE, + x: 44, y: 1, fg: Reset, bg: DarkGray, underline: Reset, modifier: NONE, x: 79, y: 1, fg: DarkGray, bg: Reset, underline: Reset, modifier: NONE, x: 0, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 25, y: 2, fg: DarkGray, bg: Reset, underline: Reset, modifier: NONE, @@ -52,36 +51,33 @@ TestBackend { x: 79, y: 3, fg: DarkGray, bg: Reset, underline: Reset, modifier: NONE, x: 0, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 25, y: 4, fg: DarkGray, bg: Reset, underline: Reset, modifier: NONE, - x: 35, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 42, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 79, y: 4, fg: DarkGray, bg: Reset, underline: Reset, modifier: NONE, x: 0, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 1, y: 5, fg: Black, bg: Gray, underline: Reset, modifier: NONE, + x: 24, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 25, y: 5, fg: DarkGray, bg: Reset, underline: Reset, modifier: NONE, - x: 26, y: 5, fg: Gray, bg: Reset, underline: Reset, modifier: NONE, - x: 40, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 26, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 79, y: 5, fg: DarkGray, bg: Reset, underline: Reset, modifier: NONE, x: 0, y: 6, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 25, y: 6, fg: DarkGray, bg: Reset, underline: Reset, modifier: NONE, - x: 35, y: 6, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 26, y: 6, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 79, y: 6, fg: DarkGray, bg: Reset, underline: Reset, modifier: NONE, x: 0, y: 7, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 25, y: 7, fg: DarkGray, bg: Reset, underline: Reset, modifier: NONE, - x: 26, y: 7, fg: Gray, bg: Reset, underline: Reset, modifier: NONE, - x: 51, y: 7, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 26, y: 7, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 79, y: 7, fg: DarkGray, bg: Reset, underline: Reset, modifier: NONE, x: 0, y: 8, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 25, y: 8, fg: DarkGray, bg: Reset, underline: Reset, modifier: NONE, - x: 35, y: 8, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 26, y: 8, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 79, y: 8, fg: DarkGray, bg: Reset, underline: Reset, modifier: NONE, x: 0, y: 9, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 1, y: 9, fg: Black, bg: Gray, underline: Reset, modifier: NONE, - 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: 26, 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: 26, 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, diff --git a/src/snapshots/pdm__ui__tests__shares_market_screen_render.snap b/src/snapshots/pdm__ui__tests__shares_market_screen_render.snap index 1f5e25e..958aa37 100644 --- a/src/snapshots/pdm__ui__tests__shares_market_screen_render.snap +++ b/src/snapshots/pdm__ui__tests__shares_market_screen_render.snap @@ -1,56 +1 @@ ---- -source: src/ui.rs -assertion_line: 220 -expression: terminal.backend() ---- -TestBackend { - buffer: Buffer { - area: Rect { x: 0, y: 0, width: 80, height: 24 }, - content: [ - "┌ PDM ──────────────────┐┌ Shares Market ──────────────────────────────────────┐", - "│Home ││Shares Market │", - "│Bitcoin Config ││ │", - "│Bitcoin Status ││ │", - "│P2Pool Config ││ │", - "│P2Pool Status ││ │", - "│LN Config ││ │", - "│LN Status ││ │", - "│Shares Market ││ │", - "│Settings ││ │", - "│ ││ │", - "│ ││ │", - "│ ││ │", - "│ ││ │", - "│ ││ │", - "│ ││ │", - "│ ││ │", - "│ ││ │", - "│ ││ │", - "│ ││ │", - "│ ││ │", - "│ ││ │", - "└───────────────────────┘└─────────────────────────────────────────────────────┘", - " ↑↓ Navigate sidebar Enter Select q Quit ", - ], - styles: [ - x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 1, y: 8, fg: Black, bg: Gray, underline: Reset, modifier: NONE, - x: 24, y: 8, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 0, y: 23, fg: White, bg: DarkGray, underline: Reset, modifier: NONE, - x: 4, y: 23, fg: DarkGray, bg: Black, underline: Reset, modifier: NONE, - x: 23, y: 23, fg: White, bg: DarkGray, underline: Reset, modifier: NONE, - x: 30, y: 23, fg: DarkGray, bg: Black, underline: Reset, modifier: NONE, - x: 39, y: 23, fg: White, bg: DarkGray, underline: Reset, modifier: NONE, - x: 42, y: 23, fg: DarkGray, bg: Black, underline: Reset, modifier: NONE, - x: 49, y: 23, fg: Reset, bg: Black, underline: Reset, modifier: NONE, - ] - }, - scrollback: Buffer { - area: Rect { x: 0, y: 0, width: 80, height: 0 } - }, - cursor: false, - pos: ( - 0, - 0, - ), -} +# Deleted snapshot diff --git a/src/ui.rs b/src/ui.rs index f14d3e8..4b9ccf5 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -5,11 +5,9 @@ use crate::app; use crate::app::{App, CurrentScreen}; use crate::components::{ - bitcoin_config_view::BitcoinConfigView, bitcoin_status_view::BitcoinStatusView, - file_explorer::FileExplorer, home_view::HomeView, ln_config_view::LNConfigView, - ln_status_view::LNStatusView, p2pool_config_view::P2PoolConfigView, - p2pool_status_view::P2PoolStatusView, settings_view::SettingsView, - shares_market_view::SharesMarketView, status_bar::StatusBar, + bitcoin_status_view::BitcoinStatusView, file_explorer::FileExplorer, home_view::HomeView, + p2pool_config_view::P2PoolConfigView, p2pool_status_view::P2PoolStatusView, + settings_view::SettingsView, status_bar::StatusBar, }; use ratatui::{ prelude::*, @@ -19,10 +17,7 @@ use ratatui::{ pub fn ui(f: &mut Frame, app: &mut App) { let outer = Layout::default() .direction(Direction::Vertical) - .constraints([ - Constraint::Min(0), // Main area - Constraint::Length(1), // Status bar - ]) + .constraints([Constraint::Min(0), Constraint::Length(1)]) .split(f.area()); let main_row = outer[0]; @@ -30,25 +25,18 @@ pub fn ui(f: &mut Frame, app: &mut App) { let chunks = Layout::default() .direction(Direction::Horizontal) - .constraints([ - Constraint::Length(25), // Sidebar - Constraint::Min(0), // Main Content - ]) + .constraints([Constraint::Length(25), Constraint::Min(0)]) .split(main_row); - // Sidebar let items: Vec = app::SIDEBAR_ITEMS .iter() .map(|&(label, _)| ListItem::new(label)) .collect(); - // Highlight the active one let mut state = ListState::default(); state.select(Some(app.sidebar_index)); - // Dim the sidebar when the user has moved focus into a content panel let sidebar_focused = match app.current_screen { - CurrentScreen::BitcoinConfig => app.bitcoin_config_view.sidebar_focused, CurrentScreen::Settings => app.settings_view.sidebar_focused, _ => true, }; @@ -69,40 +57,15 @@ pub fn ui(f: &mut Frame, app: &mut App) { f.render_stateful_widget(sidebar, chunks[0], &mut state); - // Main Content let main_area = chunks[1]; match app.current_screen { - CurrentScreen::Home => { - HomeView::render(f, app, main_area); - } - CurrentScreen::BitcoinConfig => { - BitcoinConfigView::render(f, app, main_area); - } - CurrentScreen::BitcoinStatus => { - BitcoinStatusView::render(f, app, main_area); - } - CurrentScreen::P2PoolConfig => { - P2PoolConfigView::render(f, app, main_area); - } - CurrentScreen::P2PoolStatus => { - P2PoolStatusView::render(f, app, main_area); - } - CurrentScreen::LNConfig => { - LNConfigView::render(f, app, main_area); - } - CurrentScreen::LNStatus => { - LNStatusView::render(f, app, main_area); - } - CurrentScreen::SharesMarket => { - SharesMarketView::render(f, app, main_area); - } - CurrentScreen::FileExplorer => { - FileExplorer::render(f, app, main_area); - } - CurrentScreen::Settings => { - SettingsView::render(f, app, main_area); - } + CurrentScreen::Home => HomeView::render(f, app, main_area), + CurrentScreen::BitcoinStatus => BitcoinStatusView::render(f, app, main_area), + CurrentScreen::P2PoolConfig => P2PoolConfigView::render(f, app, main_area), + CurrentScreen::P2PoolStatus => P2PoolStatusView::render(f, app, main_area), + CurrentScreen::FileExplorer => FileExplorer::render(f, app, main_area), + CurrentScreen::Settings => SettingsView::render(f, app, main_area), } StatusBar::render(f, app, status_bar_area); @@ -127,44 +90,12 @@ mod tests { insta::assert_debug_snapshot!(terminal.backend()); } - #[test] - fn test_bitcoin_config_screen_render() { - let mut terminal = make_terminal(); - let mut app = App::new(); - app.sidebar_index = 1; - app.toggle_menu(); - terminal.draw(|f| ui(f, &mut app)).unwrap(); - insta::assert_debug_snapshot!(terminal.backend()); - } - #[test] fn test_bitcoin_status_screen_render() { let mut terminal = make_terminal(); let mut app = App::new(); - app.sidebar_index = 2; - app.toggle_menu(); - terminal.draw(|f| ui(f, &mut app)).unwrap(); - insta::assert_debug_snapshot!(terminal.backend()); - } - - #[test] - fn test_bitcoin_status_tab_system_render() { - let mut terminal = make_terminal(); - let mut app = App::new(); - app.sidebar_index = 2; - app.toggle_menu(); - app.bitcoin_status_tab = 1; - terminal.draw(|f| ui(f, &mut app)).unwrap(); - insta::assert_debug_snapshot!(terminal.backend()); - } - - #[test] - fn test_bitcoin_status_tab_logs_render() { - let mut terminal = make_terminal(); - let mut app = App::new(); - app.sidebar_index = 2; + app.sidebar_index = 1; app.toggle_menu(); - app.bitcoin_status_tab = 2; terminal.draw(|f| ui(f, &mut app)).unwrap(); insta::assert_debug_snapshot!(terminal.backend()); } @@ -173,9 +104,9 @@ mod tests { fn test_bitcoin_status_tab_peers_render() { let mut terminal = make_terminal(); let mut app = App::new(); - app.sidebar_index = 2; + app.sidebar_index = 1; app.toggle_menu(); - app.bitcoin_status_tab = 3; + app.bitcoin_status_tab = 1; terminal.draw(|f| ui(f, &mut app)).unwrap(); insta::assert_debug_snapshot!(terminal.backend()); } @@ -184,7 +115,7 @@ mod tests { fn test_p2pool_config_screen_render() { let mut terminal = make_terminal(); let mut app = App::new(); - app.sidebar_index = 3; + app.sidebar_index = 2; app.toggle_menu(); terminal.draw(|f| ui(f, &mut app)).unwrap(); insta::assert_debug_snapshot!(terminal.backend()); @@ -194,40 +125,7 @@ mod tests { fn test_p2pool_status_screen_render() { let mut terminal = make_terminal(); let mut app = App::new(); - - app.sidebar_index = 4; - app.toggle_menu(); - - terminal.draw(|f| ui(f, &mut app)).unwrap(); - - insta::assert_debug_snapshot!(terminal.backend()); - } - - #[test] - fn test_ln_config_screen_render() { - let mut terminal = make_terminal(); - let mut app = App::new(); - app.sidebar_index = 5; - app.toggle_menu(); - terminal.draw(|f| ui(f, &mut app)).unwrap(); - insta::assert_debug_snapshot!(terminal.backend()); - } - - #[test] - fn test_ln_status_screen_render() { - let mut terminal = make_terminal(); - let mut app = App::new(); - app.sidebar_index = 6; - app.toggle_menu(); - terminal.draw(|f| ui(f, &mut app)).unwrap(); - insta::assert_debug_snapshot!(terminal.backend()); - } - - #[test] - fn test_shares_market_screen_render() { - let mut terminal = make_terminal(); - let mut app = App::new(); - app.sidebar_index = 7; + app.sidebar_index = 3; app.toggle_menu(); terminal.draw(|f| ui(f, &mut app)).unwrap(); insta::assert_debug_snapshot!(terminal.backend()); @@ -236,12 +134,10 @@ mod tests { #[test] #[serial_test::serial] fn test_settings_screen_render() { - // Fix PDM_CONFIG_DIR so field 4 renders a deterministic path across platforms. - // SAFETY: serialised by #[serial] — no concurrent mutation of PDM_CONFIG_DIR. unsafe { std::env::set_var("PDM_CONFIG_DIR", "/pdm/test-config") }; let mut terminal = make_terminal(); let mut app = App::new(); - app.sidebar_index = 8; // Settings + app.sidebar_index = 4; app.toggle_menu(); terminal.draw(|f| ui(f, &mut app)).unwrap(); unsafe { std::env::remove_var("PDM_CONFIG_DIR") }; diff --git a/systemd/user/p2poolv2@.service b/systemd/user/p2poolv2@.service new file mode 100644 index 0000000..7e3e6fd --- /dev/null +++ b/systemd/user/p2poolv2@.service @@ -0,0 +1,48 @@ +[Unit] +Description=P2Poolv2 %i - Bitcoin Mining Pool +After=network-online.target +Wants=network-online.target +StartLimitIntervalSec=300 +StartLimitBurst=5 + +[Service] +Type=simple + +# User-owned state, log, and runtime directories for this instance. +StateDirectory=p2poolv2/%i +LogsDirectory=p2poolv2/%i +RuntimeDirectory=p2poolv2/%i +StateDirectoryMode=0750 +LogsDirectoryMode=0750 +RuntimeDirectoryMode=0750 + +# Keep relative store and logging paths in the P2Pool config relative to state. +# For example, ./logs/p2pool.log resolves under %S/p2poolv2/%i/logs/. +WorkingDirectory=%S/p2poolv2/%i + +# Deployment installs the P2Pool binary in the user's local bin directory. +# %E is the user configuration directory, matching PDM's XDG fallback. +ExecStart=%h/.local/bin/p2poolv2 --config %E/p2poolv2/config-%i.toml + +Restart=on-failure +RestartSec=5s +StandardOutput=journal +StandardError=journal +SyslogIdentifier=p2poolv2-%i +LimitNOFILE=65536 + +# Compatible hardening from the official service. These settings must be +# validated against the installed P2Pool build and runtime dependencies. +NoNewPrivileges=true +PrivateTmp=true +PrivateDevices=true +ProtectSystem=strict +ProtectKernelTunables=true +ProtectKernelModules=true +ProtectControlGroups=true +MemoryDenyWriteExecute=true +RestrictNamespaces=true +RestrictSUIDSGID=true + +[Install] +WantedBy=default.target diff --git a/tests/fixtures/p2pool.toml b/tests/fixtures/p2pool.toml new file mode 100644 index 0000000..5b5304b --- /dev/null +++ b/tests/fixtures/p2pool.toml @@ -0,0 +1,48 @@ +[network] +listen_address = "/ip4/127.0.0.1/tcp/6884" +dial_peers = [] +max_pending_incoming = 10 +max_pending_outgoing = 10 +max_established_incoming = 50 +max_established_outgoing = 50 +max_established_per_peer = 1 +max_workbase_per_second = 10 +max_userworkbase_per_second = 10 +max_miningshare_per_second = 100 +max_inventory_per_second = 100 +max_transaction_per_second = 100 +max_requests_per_second = 100 +dial_timeout_secs = 30 + +[store] +path = "./store.db" +background_task_frequency_hours = 24 +pplns_ttl_days = 7 + +[stratum] +hostname = "pool.example.com" +port = 3333 +start_difficulty = 10000 +minimum_difficulty = 100 +solo_address = "tb1qyazxde6558qj6z3d9np5e6msmrspwpf6k0qggk" +bootstrap_address = "tb1qyazxde6558qj6z3d9np5e6msmrspwpf6k0qggk" +zmqpubhashblock = "tcp://127.0.0.1:28332" +network = "signet" +version_mask = "1fffe000" +difficulty_multiplier = 1.0 +pool_signature = "P2Poolv2" + +[bitcoinrpc] +url = "http://127.0.0.1:38332" +username = "p2pool" +password = "p2pool" + +[logging] +file = "./logs/p2pool.log" +console = true +level = "info" +stats_dir = "./logs/stats" + +[api] +hostname = "127.0.0.1" +port = 46884 diff --git a/tests/snapshots/ui_snapshots__config_screen_render.snap b/tests/snapshots/ui_snapshots__config_screen_render.snap index 288335a..1105d75 100644 --- a/tests/snapshots/ui_snapshots__config_screen_render.snap +++ b/tests/snapshots/ui_snapshots__config_screen_render.snap @@ -6,16 +6,16 @@ TestBackend { buffer: Buffer { area: Rect { x: 0, y: 0, width: 80, height: 25 }, content: [ - "┌ PDM ──────────────────┐┌ Bitcoin Config ─────────────────────────────────────┐", - "│Home ││Press [Enter] to select a bitcoin.conf file │", - "│Bitcoin Config ││ │", + "┌ PDM ──────────────────┐┌ Info ───────────────────────────────────────────────┐", + "│Home ││ Chain Info │ Peers │", "│Bitcoin Status ││ │", - "│P2Pool Config ││ │", - "│P2Pool Status ││ │", - "│LN Config ││ │", - "│LN Status ││ │", - "│Shares Market ││ │", - "│Settings ││ │", + "│P2Pool Config │└─────────────────────────────────────────────────────┘", + "│P2Pool Status │┌ Chain Info ─────────────────────────────────────────┐", + "│Settings ││Select a P2Poolv2 config file to load Bitcoin Core │", + "│ ││chain info. │", + "│ ││ │", + "│ ││ │", + "│ ││ │", "│ ││ │", "│ ││ │", "│ ││ │", @@ -30,19 +30,25 @@ TestBackend { "│ ││ │", "│ ││ │", "└───────────────────────┘└─────────────────────────────────────────────────────┘", - " ↑↓ Navigate sidebar Enter Open file Esc Back ", + " ↑↓ Navigate sidebar ←→ Switch tab q Quit ", ], styles: [ x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 27, y: 1, fg: Black, bg: Gray, underline: Reset, modifier: NONE, + x: 37, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 1, y: 2, fg: Black, bg: Gray, underline: Reset, modifier: NONE, x: 24, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 26, y: 5, fg: DarkGray, bg: Reset, underline: Reset, modifier: NONE, + x: 76, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 26, y: 6, fg: DarkGray, bg: Reset, underline: Reset, modifier: NONE, + x: 37, y: 6, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 0, y: 24, fg: White, bg: DarkGray, underline: Reset, modifier: NONE, x: 4, y: 24, fg: DarkGray, bg: Black, underline: Reset, modifier: NONE, x: 23, y: 24, fg: White, bg: DarkGray, underline: Reset, modifier: NONE, - x: 30, y: 24, fg: DarkGray, bg: Black, underline: Reset, modifier: NONE, - x: 42, y: 24, fg: White, bg: DarkGray, underline: Reset, modifier: NONE, - x: 47, y: 24, fg: DarkGray, bg: Black, underline: Reset, modifier: NONE, - x: 54, y: 24, fg: Reset, bg: Black, underline: Reset, modifier: NONE, + x: 27, y: 24, fg: DarkGray, bg: Black, underline: Reset, modifier: NONE, + x: 40, y: 24, fg: White, bg: DarkGray, underline: Reset, modifier: NONE, + x: 43, y: 24, fg: DarkGray, bg: Black, underline: Reset, modifier: NONE, + x: 50, y: 24, fg: Reset, bg: Black, underline: Reset, modifier: NONE, ] }, scrollback: Buffer { diff --git a/tests/snapshots/ui_snapshots__home_screen_render.snap b/tests/snapshots/ui_snapshots__home_screen_render.snap index 7349b4c..878857a 100644 --- a/tests/snapshots/ui_snapshots__home_screen_render.snap +++ b/tests/snapshots/ui_snapshots__home_screen_render.snap @@ -8,13 +8,9 @@ TestBackend { content: [ "┌ PDM ──────────────────┐┌ Home ───────────────────────────────────────────────┐", "│Home ││Welcome to PDM. │", - "│Bitcoin Config ││ │", - "│Bitcoin Status ││Select a config from the sidebar to edit. │", - "│P2Pool Config ││ │", + "│Bitcoin Status ││ │", + "│P2Pool Config ││Select a config from the sidebar to edit. │", "│P2Pool Status ││ │", - "│LN Config ││ │", - "│LN Status ││ │", - "│Shares Market ││ │", "│Settings ││ │", "│ ││ │", "│ ││ │", @@ -29,6 +25,10 @@ TestBackend { "│ ││ │", "│ ││ │", "│ ││ │", + "│ ││ │", + "│ ││ │", + "│ ││ │", + "│ ││ │", "└───────────────────────┘└─────────────────────────────────────────────────────┘", " ↑↓ Navigate sidebar Enter Select q Quit ", ],