From 8453d92d3cef579e183fd6967d0f2da47b8e95ad Mon Sep 17 00:00:00 2001 From: Raunak Kumar Date: Sat, 11 Apr 2026 08:28:38 +0000 Subject: [PATCH 01/10] add editing to p2pool config file --- Cargo.lock | 3 + Cargo.toml | 3 + src/app.rs | 7 + src/components/p2pool_config_view.rs | 492 ++++++++++++++--- src/lib.rs | 1 + src/main.rs | 1 + src/p2poolv2_config.rs | 783 +++++++++++++++++++++++++++ 7 files changed, 1204 insertions(+), 86 deletions(-) create mode 100644 src/p2poolv2_config.rs diff --git a/Cargo.lock b/Cargo.lock index a670443..586f641 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -86,6 +86,8 @@ checksum = "2c8d66485a3a2ea485c1913c4572ce0256067a5377ac8c75c4960e1cda98605f" dependencies = [ "bitcoin-internals", "bitcoin_hashes", + "bitcoin-internals", + "bitcoin_hashes", ] [[package]] @@ -1631,6 +1633,7 @@ name = "pdm" version = "0.1.0" dependencies = [ "anyhow", + "bitcoin", "config 0.15.19", "crossterm", "directories", diff --git a/Cargo.toml b/Cargo.toml index 66d2d03..df7bc86 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,6 +15,9 @@ serde = { version = "1", features = ["derive"] } toml = "0.8" unicode-width = "0.2" p2poolv2_config = { git = "https://github.com/p2poolv2/p2poolv2", package = "p2poolv2_config" } +bitcoin = "0.32.5" +toml = "0.8" +toml_edit = "0.22" [dev-dependencies] insta = "1.44.3" diff --git a/src/app.rs b/src/app.rs index d47e726..69d2bc9 100644 --- a/src/app.rs +++ b/src/app.rs @@ -5,6 +5,7 @@ use crate::bitcoin_config::ConfigEntry as BitcoinEntry; use crate::components::bitcoin_config_view::BitcoinConfigView; use crate::components::file_explorer::FileExplorer; +use crate::components::p2pool_config_view::P2PoolConfigView; use crate::components::settings_view::SettingsView; use crate::settings::Settings; use p2poolv2_config::Config as P2PoolConfig; @@ -71,6 +72,10 @@ pub enum AppAction { 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, // 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 @@ -85,6 +90,7 @@ pub struct App { 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, @@ -109,6 +115,7 @@ impl App { 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(), diff --git a/src/components/p2pool_config_view.rs b/src/components/p2pool_config_view.rs index 2166793..29e9228 100644 --- a/src/components/p2pool_config_view.rs +++ b/src/components/p2pool_config_view.rs @@ -2,111 +2,282 @@ // // SPDX-License-Identifier: AGPL-3.0-or-later -use crate::app::App; +use crate::app::{App, AppAction}; +use crate::p2poolv2_config::{FieldKind, P2PoolConfigEntry, flatten_config}; +use crossterm::event::{KeyCode, KeyEvent}; use ratatui::{ prelude::*, - widgets::{Block, Borders, List, ListItem, Paragraph}, + widgets::{Block, Borders, List, ListItem, ListState, Paragraph}, }; #[derive(Debug, Clone)] -pub struct P2PoolConfigView; +pub struct P2PoolConfigView { + pub selected_index: usize, + pub editing: bool, + pub edit_input: String, + pub save_message: Option, + pub warning_message: Option, + pub sidebar_focused: bool, +} impl P2PoolConfigView { #[must_use] pub fn new() -> Self { - Self + Self { + selected_index: 0, + editing: false, + edit_input: String::new(), + save_message: None, + warning_message: None, + sidebar_focused: true, + } } - // P2Pool Config - pub fn render(f: &mut Frame, app: &mut App, area: Rect) { - if app.p2pool_conf_path.is_some() { - let mut items: Vec = Vec::new(); - - if let Some(cfg) = &app.p2pool_config { - // STRATUM - items.push(ListItem::new(Line::from(vec![ - Span::styled("[stratum] ", Style::default().fg(Color::Blue)), - Span::raw(format!("hostname = {}", cfg.stratum.hostname)), - ]))); - - items.push(ListItem::new(Line::from(vec![ - Span::styled("[stratum] ", Style::default().fg(Color::Blue)), - Span::raw(format!("port = {}", cfg.stratum.port)), - ]))); - - items.push(ListItem::new(Line::from(vec![ - Span::styled("[stratum] ", Style::default().fg(Color::Blue)), - Span::raw(format!( - "start_difficulty = {}", - cfg.stratum.start_difficulty - )), - ]))); - - items.push(ListItem::new(Line::from(vec![ - Span::styled("[stratum] ", Style::default().fg(Color::Blue)), - Span::raw(format!( - "minimum_difficulty = {}", - cfg.stratum.minimum_difficulty - )), - ]))); - - // BITCOIN RPC - items.push(ListItem::new(Line::from(vec![ - Span::styled("[bitcoinrpc] ", Style::default().fg(Color::Blue)), - Span::raw(format!("url = {}", cfg.bitcoinrpc.url)), - ]))); - - items.push(ListItem::new(Line::from(vec![ - Span::styled("[bitcoinrpc] ", Style::default().fg(Color::Blue)), - Span::raw(format!("username = {}", cfg.bitcoinrpc.username)), - ]))); - - // NETWORK - items.push(ListItem::new(Line::from(vec![ - Span::styled("[network] ", Style::default().fg(Color::Blue)), - Span::raw(format!("listen_address = {}", cfg.network.listen_address)), - ]))); - - items.push(ListItem::new(Line::from(vec![ - Span::styled("[network] ", Style::default().fg(Color::Blue)), - Span::raw(format!( - "max_established_incoming = {}", - cfg.network.max_established_incoming - )), - ]))); - - // STORE - items.push(ListItem::new(Line::from(vec![ - Span::styled("[store] ", Style::default().fg(Color::Blue)), - Span::raw(format!("path = {}", cfg.store.path)), - ]))); - - // API - items.push(ListItem::new(Line::from(vec![ - Span::styled("[api] ", Style::default().fg(Color::Blue)), - Span::raw(format!("hostname = {}", cfg.api.hostname)), - ]))); - - items.push(ListItem::new(Line::from(vec![ - Span::styled("[api] ", Style::default().fg(Color::Blue)), - Span::raw(format!("port = {}", cfg.api.port)), - ]))); + pub fn handle_input(&mut self, key: KeyEvent, entries: &[P2PoolConfigEntry]) -> AppAction { + self.save_message = None; + + if self.editing { + match key.code { + KeyCode::Enter => { + let action = + AppAction::CommitP2PoolEdit(self.selected_index, self.edit_input.clone()); + self.editing = false; + self.edit_input.clear(); + 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; + } + AppAction::None + } + KeyCode::Down => { + if self.selected_index + 1 < entries.len() { + self.selected_index += 1; + } + AppAction::None + } + KeyCode::Enter => { + if !entries.is_empty() { + self.edit_input = entries[self.selected_index].value.clone(); + self.editing = true; + } + AppAction::None + } + KeyCode::Char('s') => AppAction::SaveP2PoolConfig, + KeyCode::Esc => { + self.sidebar_focused = true; + AppAction::None + } + _ => AppAction::None, + } + } + } - let list = List::new(items).block( - Block::default() - .borders(Borders::ALL) - .title(" P2Pool Configuration "), - ); + pub fn render(f: &mut Frame, app: &mut App, area: Rect) { + if app.p2pool_conf_path.is_none() { + // Show warning if there is one, + let msg = app + .p2pool_config_view + .warning_message + .as_deref() + .unwrap_or("Press [Enter] to select a p2poolv2 config file"); - f.render_widget(list, area); - } else { - let p = Paragraph::new("Press [Enter] to select a p2poolv2 config file").block( + let style = if app.p2pool_config_view.warning_message.is_some() { + Style::default().fg(Color::Red) + } else { + Style::default() + }; + + let p = Paragraph::new(msg).style(style).block( Block::default() .borders(Borders::ALL) .title(" P2Pool Config "), ); f.render_widget(p, area); + return; + } + + let entries: Vec = app + .p2pool_config + .as_ref() + .map(|cfg| flatten_config(cfg)) + .unwrap_or_default(); + + let panels = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(45), Constraint::Percentage(55)]) + .split(area); + + // Left panel: scrollable entry list + let items: Vec = entries + .iter() + .map(|entry| { + let (value_display, value_style) = if entry.enabled { + ( + if entry.schema.sensitive { + "••••••••".to_string() + } else { + entry.value.clone() + }, + Style::default() + .fg(Color::White) + .add_modifier(Modifier::BOLD), + ) + } else { + let placeholder = match &entry.schema.kind { + FieldKind::Optional { default: Some(d) } => format!("default: {}", d), + _ => "not set".to_string(), + }; + ( + format!("({})", placeholder), + Style::default().fg(Color::DarkGray), + ) + }; + + ListItem::new(vec![ + Line::from(vec![ + Span::styled( + format!("[{}] ", entry.section), + Style::default().fg(Color::Blue), + ), + Span::styled( + entry.schema.description.as_str(), + 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.p2pool_config_view.selected_index)); + + let title = match &app.p2pool_conf_path { + Some(path) => format!(" P2Pool Configuration --- {} ", path.display()), + None => " P2Pool Configuration ".to_string(), + }; + + let list = List::new(items) + .block(Block::default().borders(Borders::ALL).title(title)) + .highlight_style(Style::default().bg(Color::DarkGray)); + + f.render_stateful_widget(list, panels[0], &mut list_state); + + // Right panel: detail + edit + let right_block = Block::default().borders(Borders::ALL).title(" Detail "); + let inner = right_block.inner(panels[1]); + f.render_widget(right_block, panels[1]); + + let selected_entry = entries.get(app.p2pool_config_view.selected_index); + let editing = app.p2pool_config_view.editing; + let edit_input = app.p2pool_config_view.edit_input.clone(); + + if let Some(entry) = selected_entry { + let rows = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(2), // description + Constraint::Length(1), // type + section + Constraint::Length(1), // spacer + Constraint::Length(1), // "Value:" label + Constraint::Length(3), // value / input box + Constraint::Length(1), // sensitive notice + Constraint::Min(0), + ]) + .split(inner); + + f.render_widget( + Paragraph::new(entry.schema.description.as_str()) + .style(Style::default().fg(Color::White)), + rows[0], + ); + f.render_widget( + Paragraph::new(format!( + "[{}] type: {}", + entry.section, entry.schema.type_hint + )) + .style(Style::default().fg(Color::Gray)), + rows[1], + ); + f.render_widget( + Paragraph::new("Value:").style(Style::default().fg(Color::Gray)), + rows[3], + ); + + if editing { + let display = if entry.schema.sensitive { + "•".repeat(edit_input.len()) + "_" + } else { + format!("{}_", edit_input) + }; + f.render_widget( + Paragraph::new(display) + .block(Block::default().borders(Borders::ALL)) + .style(Style::default().fg(Color::Yellow)), + rows[4], + ); + } else { + let (display, style) = if entry.enabled { + let v = if entry.schema.sensitive { + "••••••••".to_string() + } else { + entry.value.clone() + }; + ( + v, + Style::default() + .fg(Color::White) + .add_modifier(Modifier::BOLD), + ) + } else { + let placeholder = match &entry.schema.kind { + FieldKind::Optional { default: Some(d) } => format!("default: {}", d), + _ => "not set".to_string(), + }; + ( + format!("({})", placeholder), + Style::default().fg(Color::DarkGray), + ) + }; + f.render_widget( + Paragraph::new(display) + .block(Block::default().borders(Borders::ALL)) + .style(style), + rows[4], + ); + } + + if entry.schema.sensitive { + f.render_widget( + Paragraph::new("⚠ sensitive field").style(Style::default().fg(Color::Yellow)), + rows[5], + ); + } } } } @@ -116,3 +287,152 @@ impl Default for P2PoolConfigView { Self::new() } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::p2poolv2_config::{ConfigSection, FieldKind, P2PoolConfigEntry, P2PoolFieldSchema}; + use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + + fn make_entry(key: &str, value: &str, enabled: bool) -> P2PoolConfigEntry { + P2PoolConfigEntry { + section: ConfigSection::Stratum, + key: key.to_string(), + value: value.to_string(), + enabled, + schema: P2PoolFieldSchema { + description: "test field".to_string(), + kind: FieldKind::Required, + type_hint: "String".to_string(), + sensitive: false, + }, + } + } + + fn key(code: KeyCode) -> KeyEvent { + KeyEvent::new(code, KeyModifiers::empty()) + } + + #[test] + fn editing_char_appends() { + let mut view = P2PoolConfigView::new(); + view.editing = true; + let entries = vec![make_entry("hostname", "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 = P2PoolConfigView::new(); + view.editing = true; + view.edit_input = "ab".to_string(); + let entries = vec![make_entry("hostname", "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 = P2PoolConfigView::new(); + view.editing = true; + view.edit_input = "newval".to_string(); + view.selected_index = 0; + let entries = vec![make_entry("hostname", "old", true)]; + let action = view.handle_input(key(KeyCode::Enter), &entries); + assert!( + matches!(action, AppAction::CommitP2PoolEdit(0, ref v) if v == "newval"), + "expected CommitP2PoolEdit(0, newval)" + ); + assert!(!view.editing); + assert!(view.edit_input.is_empty()); + } + + #[test] + fn editing_esc_cancels() { + let mut view = P2PoolConfigView::new(); + view.editing = true; + view.edit_input = "draft".to_string(); + let entries = vec![make_entry("hostname", "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 browsing_down_increments_index() { + let mut view = P2PoolConfigView::new(); + let entries = vec![make_entry("a", "1", true), make_entry("b", "2", true)]; + view.handle_input(key(KeyCode::Down), &entries); + assert_eq!(view.selected_index, 1); + } + + #[test] + fn browsing_down_clamped_at_last() { + let mut view = P2PoolConfigView::new(); + view.selected_index = 1; + let entries = vec![make_entry("a", "1", true), make_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 = P2PoolConfigView::new(); + view.selected_index = 1; + let entries = vec![make_entry("a", "1", true), make_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 = P2PoolConfigView::new(); + let entries = vec![make_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 = P2PoolConfigView::new(); + let entries = vec![make_entry("hostname", "127.0.0.1", true)]; + view.handle_input(key(KeyCode::Enter), &entries); + assert!(view.editing); + assert_eq!(view.edit_input, "127.0.0.1"); + } + + #[test] + fn browsing_enter_noop_on_empty_entries() { + let mut view = P2PoolConfigView::new(); + view.handle_input(key(KeyCode::Enter), &[]); + assert!(!view.editing); + } + + #[test] + fn browsing_s_returns_save_action() { + let mut view = P2PoolConfigView::new(); + let entries = vec![make_entry("hostname", "127.0.0.1", true)]; + let action = view.handle_input(key(KeyCode::Char('s')), &entries); + assert!(matches!(action, AppAction::SaveP2PoolConfig)); + } + + #[test] + fn browsing_esc_sets_sidebar_focused() { + let mut view = P2PoolConfigView::new(); + view.sidebar_focused = false; + let entries = vec![make_entry("hostname", "127.0.0.1", true)]; + view.handle_input(key(KeyCode::Esc), &entries); + assert!(view.sidebar_focused); + } + + #[test] + fn any_key_clears_save_message() { + let mut view = P2PoolConfigView::new(); + view.save_message = Some("saved".to_string()); + let entries = vec![make_entry("hostname", "127.0.0.1", true)]; + view.handle_input(key(KeyCode::Up), &entries); + assert!(view.save_message.is_none()); + } +} diff --git a/src/lib.rs b/src/lib.rs index 069e198..82d6883 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -5,5 +5,6 @@ pub mod app; pub mod bitcoin_config; pub mod components; +pub mod p2poolv2_config; pub mod settings; pub mod ui; diff --git a/src/main.rs b/src/main.rs index 67e3129..0370d0f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -9,6 +9,7 @@ use pdm::app::{ use pdm::bitcoin_config::{ parse_config as parse_bitcoin_config, save_config as save_bitcoin_config, }; +use pdm::p2poolv2_config::{apply_edit as apply_p2pool_edit, flatten_config}; use pdm::components::settings_view::{FIELDS, FieldKind}; use pdm::settings::{load_settings, save_settings}; use pdm::ui; diff --git a/src/p2poolv2_config.rs b/src/p2poolv2_config.rs new file mode 100644 index 0000000..4c8a183 --- /dev/null +++ b/src/p2poolv2_config.rs @@ -0,0 +1,783 @@ +// SPDX-FileCopyrightText: 2024 PDM Authors +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +use bitcoin::Network; +use p2poolv2_config::Config; +use std::fmt; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ConfigSection { + Stratum, + BitcoinRpc, + Network, + Store, + Logging, + Api, +} + +impl fmt::Display for ConfigSection { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + ConfigSection::Stratum => write!(f, "stratum"), + ConfigSection::BitcoinRpc => write!(f, "bitcoinrpc"), + ConfigSection::Network => write!(f, "network"), + ConfigSection::Store => write!(f, "store"), + ConfigSection::Logging => write!(f, "logging"), + ConfigSection::Api => write!(f, "api"), + } + } +} + +#[derive(Debug, Clone)] +pub enum FieldKind { + Required, + Optional { default: Option }, +} + +#[derive(Debug, Clone)] +pub struct P2PoolFieldSchema { + pub description: String, + pub kind: FieldKind, + pub type_hint: String, + pub sensitive: bool, +} + +/// A single editable TUI row — the view layer equivalent of +/// `ConfigEntry` in bitcoin_config.rs. +/// The external `p2poolv2_config` crate has no concept of this; +/// it only knows nested structs for deserialization. +#[derive(Debug, Clone)] +pub struct P2PoolConfigEntry { + pub section: ConfigSection, + pub key: String, + pub value: String, + pub enabled: bool, + pub schema: P2PoolFieldSchema, +} + +impl P2PoolConfigEntry { + fn required( + section: ConfigSection, + key: &str, + value: String, + description: &str, + type_hint: &str, + ) -> Self { + Self { + section, + key: key.to_string(), + value, + enabled: true, + schema: P2PoolFieldSchema { + description: description.to_string(), + kind: FieldKind::Required, + type_hint: type_hint.to_string(), + sensitive: false, + }, + } + } + + fn optional( + section: ConfigSection, + key: &str, + value: Option, + description: &str, + type_hint: &str, + default: Option<&str>, + ) -> Self { + let enabled = value.is_some(); + Self { + section, + key: key.to_string(), + value: value.unwrap_or_default(), + enabled, + schema: P2PoolFieldSchema { + description: description.to_string(), + kind: FieldKind::Optional { + default: default.map(str::to_string), + }, + type_hint: type_hint.to_string(), + sensitive: false, + }, + } + } + + fn sensitive(mut self) -> Self { + self.schema.sensitive = true; + self + } +} + +/// Flattens the nested `p2poolv2_config::Config` into a flat +/// `Vec` that the TUI list can render row by row. +pub fn flatten_config(cfg: &Config) -> Vec { + let mut e: Vec = Vec::new(); + let s = &cfg.stratum; + + // Stratum + e.push(P2PoolConfigEntry::required( + ConfigSection::Stratum, + "hostname", + s.hostname.clone(), + "Stratum server hostname", + "String", + )); + e.push(P2PoolConfigEntry::required( + ConfigSection::Stratum, + "port", + s.port.to_string(), + "Stratum server port", + "u16", + )); + e.push(P2PoolConfigEntry::required( + ConfigSection::Stratum, + "start_difficulty", + s.start_difficulty.to_string(), + "Initial difficulty assigned to new miners", + "u64", + )); + e.push(P2PoolConfigEntry::required( + ConfigSection::Stratum, + "minimum_difficulty", + s.minimum_difficulty.to_string(), + "Minimum allowed difficulty", + "u64", + )); + e.push(P2PoolConfigEntry::optional( + ConfigSection::Stratum, + "maximum_difficulty", + s.maximum_difficulty.map(|v| v.to_string()), + "Maximum allowed difficulty (unset = unlimited)", + "u64", + None, + )); + e.push(P2PoolConfigEntry::optional( + ConfigSection::Stratum, + "solo_address", + s.solo_address.clone(), + "Bitcoin address for solo mining payouts", + "Address", + None, + )); + e.push(P2PoolConfigEntry::required( + ConfigSection::Stratum, + "zmqpubhashblock", + s.zmqpubhashblock.clone(), + "ZMQ address for new block notifications", + "URI", + )); + e.push(P2PoolConfigEntry::required( + ConfigSection::Stratum, + "bootstrap_address", + s.bootstrap_address.clone(), + "Bitcoin address for first jobs before any share exists", + "Address", + )); + e.push(P2PoolConfigEntry::optional( + ConfigSection::Stratum, + "donation_address", + s.donation_address.clone(), + "Developer donation address (must pair with donation)", + "Address", + None, + )); + e.push(P2PoolConfigEntry::optional( + ConfigSection::Stratum, + "donation", + s.donation.map(|v| v.to_string()), + "Developer donation in basis points (100 = 1%)", + "u16", + None, + )); + e.push(P2PoolConfigEntry::optional( + ConfigSection::Stratum, + "fee_address", + s.fee_address.clone(), + "Pool fee address (must pair with fee)", + "Address", + None, + )); + e.push(P2PoolConfigEntry::optional( + ConfigSection::Stratum, + "fee", + s.fee.map(|v| v.to_string()), + "Pool fee in basis points (100 = 1%)", + "u16", + None, + )); + e.push(P2PoolConfigEntry::required( + ConfigSection::Stratum, + "network", + cfg.stratum.network.to_string(), + "Bitcoin network: main / testnet4 / signet / regtest", + "Network", + )); + e.push(P2PoolConfigEntry::required( + ConfigSection::Stratum, + "version_mask", + format!("{:x}", s.version_mask), + "Version-rolling mask (hex)", + "hex i32", + )); + e.push(P2PoolConfigEntry::required( + ConfigSection::Stratum, + "difficulty_multiplier", + s.difficulty_multiplier.to_string(), + "Multiplier for dynamic difficulty adjustment", + "f64", + )); + e.push(P2PoolConfigEntry::optional( + ConfigSection::Stratum, + "ignore_difficulty", + s.ignore_difficulty.map(|v| v.to_string()), + "Skip difficulty checks (test environments only)", + "bool", + Some("false"), + )); + e.push(P2PoolConfigEntry::optional( + ConfigSection::Stratum, + "pool_signature", + s.pool_signature.clone(), + "Coinbase pool signature (max 16 bytes)", + "String", + None, + )); + + // BitcoinRPC + let rpc = &cfg.bitcoinrpc; + e.push(P2PoolConfigEntry::required( + ConfigSection::BitcoinRpc, + "url", + rpc.url.clone(), + "Bitcoin Core RPC endpoint URL", + "URI", + )); + e.push(P2PoolConfigEntry::required( + ConfigSection::BitcoinRpc, + "username", + rpc.username.clone(), + "Bitcoin RPC username", + "String", + )); + e.push( + P2PoolConfigEntry::required( + ConfigSection::BitcoinRpc, + "password", + rpc.password.clone(), + "Bitcoin RPC password", + "String", + ) + .sensitive(), + ); + + // Network + let n = &cfg.network; + e.push(P2PoolConfigEntry::required( + ConfigSection::Network, + "listen_address", + n.listen_address.clone(), + "P2P listen address (host:port)", + "String", + )); + e.push(P2PoolConfigEntry::required( + ConfigSection::Network, + "dial_peers", + n.dial_peers.join(","), + "Bootstrap peers, comma-separated (host:port,...)", + "CSV", + )); + e.push(P2PoolConfigEntry::required( + ConfigSection::Network, + "max_established_incoming", + n.max_established_incoming.to_string(), + "Maximum established inbound connections", + "u32", + )); + e.push(P2PoolConfigEntry::required( + ConfigSection::Network, + "max_established_outgoing", + n.max_established_outgoing.to_string(), + "Maximum established outbound connections", + "u32", + )); + e.push(P2PoolConfigEntry::required( + ConfigSection::Network, + "max_established_per_peer", + n.max_established_per_peer.to_string(), + "Maximum connections per individual peer", + "u32", + )); + e.push(P2PoolConfigEntry::required( + ConfigSection::Network, + "dial_timeout_secs", + n.dial_timeout_secs.to_string(), + "Timeout in seconds for outbound dial attempts", + "u64", + )); + e.push(P2PoolConfigEntry::required( + ConfigSection::Network, + "max_requests_per_second", + n.max_requests_per_second.to_string(), + "Rate limit: max requests per second per peer", + "u64", + )); + + // Store + let st = &cfg.store; + e.push(P2PoolConfigEntry::required( + ConfigSection::Store, + "path", + st.path.clone(), + "Path to the persistent share store", + "Path", + )); + e.push(P2PoolConfigEntry::required( + ConfigSection::Store, + "background_task_frequency_hours", + st.background_task_frequency_hours.to_string(), + "How often to run background cleanup tasks (hours)", + "u64", + )); + e.push(P2PoolConfigEntry::required( + ConfigSection::Store, + "pplns_ttl_days", + st.pplns_ttl_days.to_string(), + "Time-to-live for PPLNS shares (days)", + "u64", + )); + + // Logging + let l = &cfg.logging; + e.push(P2PoolConfigEntry::optional( + ConfigSection::Logging, + "file", + l.file.clone(), + "Log file path (omit to disable file logging)", + "Path", + None, + )); + e.push(P2PoolConfigEntry::required( + ConfigSection::Logging, + "level", + l.level.clone(), + "Log verbosity: error / warn / info / debug / trace", + "String", + )); + e.push(P2PoolConfigEntry::required( + ConfigSection::Logging, + "stats_dir", + l.stats_dir.clone(), + "Directory for stats output files", + "Path", + )); + e.push(P2PoolConfigEntry::optional( + ConfigSection::Logging, + "console", + l.console.map(|v| v.to_string()), + "Log to stdout (true/false)", + "bool", + Some("true"), + )); + + // API + let a = &cfg.api; + e.push(P2PoolConfigEntry::required( + ConfigSection::Api, + "hostname", + a.hostname.clone(), + "API server hostname", + "String", + )); + e.push(P2PoolConfigEntry::required( + ConfigSection::Api, + "port", + a.port.to_string(), + "API server port", + "u16", + )); + e.push(P2PoolConfigEntry::optional( + ConfigSection::Api, + "auth_user", + a.auth_user.clone(), + "API authentication username", + "String", + None, + )); + e.push( + P2PoolConfigEntry::optional( + ConfigSection::Api, + "auth_token", + a.auth_token.clone(), + "API auth token (salt$hmac, managed by server)", + "String", + None, + ) + .sensitive(), + ); + e.push( + P2PoolConfigEntry::optional( + ConfigSection::Api, + "auth_password", + a.auth_password.clone(), + "API password for CLI client auth", + "String", + None, + ) + .sensitive(), + ); + + e +} + +/// Writes one edited value back into the live `Config`. +/// Returns `Err` with a user-facing message on parse failure. +/// This is the write-path counterpart to `flatten_config`. +pub fn apply_edit(cfg: &mut Config, index: usize, new_value: &str) -> Result<(), String> { + let entries = flatten_config(cfg); + let entry = entries + .get(index) + .ok_or_else(|| "index out of range".to_string())?; + + match (&entry.section, entry.key.as_str()) { + // Stratum + (ConfigSection::Stratum, "hostname") => { + cfg.stratum.hostname = new_value.to_string(); + } + (ConfigSection::Stratum, "port") => { + cfg.stratum.port = new_value.parse().map_err(|_| "port must be 0–65535")?; + } + (ConfigSection::Stratum, "start_difficulty") => { + cfg.stratum.start_difficulty = new_value.parse().map_err(|_| "must be u64")?; + } + (ConfigSection::Stratum, "minimum_difficulty") => { + cfg.stratum.minimum_difficulty = new_value.parse().map_err(|_| "must be u64")?; + } + (ConfigSection::Stratum, "maximum_difficulty") => { + cfg.stratum.maximum_difficulty = if new_value.is_empty() { + None + } else { + Some(new_value.parse().map_err(|_| "must be u64")?) + }; + } + (ConfigSection::Stratum, "solo_address") => { + cfg.stratum.solo_address = if new_value.is_empty() { + None + } else { + Some(new_value.to_string()) + }; + } + (ConfigSection::Stratum, "zmqpubhashblock") => { + cfg.stratum.zmqpubhashblock = new_value.to_string(); + } + (ConfigSection::Stratum, "bootstrap_address") => { + cfg.stratum.bootstrap_address = new_value.to_string(); + } + (ConfigSection::Stratum, "donation_address") => { + cfg.stratum.donation_address = if new_value.is_empty() { + None + } else { + Some(new_value.to_string()) + }; + } + (ConfigSection::Stratum, "donation") => { + cfg.stratum.donation = if new_value.is_empty() { + None + } else { + Some(new_value.parse().map_err(|_| "must be u16")?) + }; + } + (ConfigSection::Stratum, "fee_address") => { + cfg.stratum.fee_address = if new_value.is_empty() { + None + } else { + Some(new_value.to_string()) + }; + } + (ConfigSection::Stratum, "fee") => { + cfg.stratum.fee = if new_value.is_empty() { + None + } else { + Some(new_value.parse().map_err(|_| "must be u16")?) + }; + } + (ConfigSection::Stratum, "network") => { + cfg.stratum.network = Network::from_core_arg(new_value) + .map_err(|_| "must be main / testnet4 / signet / regtest")?; + } + (ConfigSection::Stratum, "version_mask") => { + cfg.stratum.version_mask = i32::from_str_radix(new_value.trim_start_matches("0x"), 16) + .map_err(|_| "must be hex (e.g. 1fffe000)")?; + } + (ConfigSection::Stratum, "difficulty_multiplier") => { + cfg.stratum.difficulty_multiplier = new_value.parse().map_err(|_| "must be f64")?; + } + (ConfigSection::Stratum, "ignore_difficulty") => { + cfg.stratum.ignore_difficulty = if new_value.is_empty() { + None + } else { + Some(new_value.parse().map_err(|_| "must be true or false")?) + }; + } + (ConfigSection::Stratum, "pool_signature") => { + cfg.stratum.pool_signature = if new_value.is_empty() { + None + } else { + Some(new_value.to_string()) + }; + } + + // BitcoinRPC + (ConfigSection::BitcoinRpc, "url") => { + cfg.bitcoinrpc.url = new_value.to_string(); + } + (ConfigSection::BitcoinRpc, "username") => { + cfg.bitcoinrpc.username = new_value.to_string(); + } + (ConfigSection::BitcoinRpc, "password") => { + cfg.bitcoinrpc.password = new_value.to_string(); + } + + // Network + (ConfigSection::Network, "listen_address") => { + cfg.network.listen_address = new_value.to_string(); + } + (ConfigSection::Network, "dial_peers") => { + cfg.network.dial_peers = if new_value.is_empty() { + vec![] + } else { + new_value.split(',').map(|s| s.trim().to_string()).collect() + }; + } + (ConfigSection::Network, "max_established_incoming") => { + cfg.network.max_established_incoming = new_value.parse().map_err(|_| "must be u32")?; + } + (ConfigSection::Network, "max_established_outgoing") => { + cfg.network.max_established_outgoing = new_value.parse().map_err(|_| "must be u32")?; + } + (ConfigSection::Network, "max_established_per_peer") => { + cfg.network.max_established_per_peer = new_value.parse().map_err(|_| "must be u32")?; + } + (ConfigSection::Network, "dial_timeout_secs") => { + cfg.network.dial_timeout_secs = new_value.parse().map_err(|_| "must be u64")?; + } + (ConfigSection::Network, "max_requests_per_second") => { + cfg.network.max_requests_per_second = new_value.parse().map_err(|_| "must be u64")?; + } + + // Store + (ConfigSection::Store, "path") => { + cfg.store.path = new_value.to_string(); + } + (ConfigSection::Store, "background_task_frequency_hours") => { + cfg.store.background_task_frequency_hours = + new_value.parse().map_err(|_| "must be u64")?; + } + (ConfigSection::Store, "pplns_ttl_days") => { + cfg.store.pplns_ttl_days = new_value.parse().map_err(|_| "must be u64")?; + } + + // Logging + (ConfigSection::Logging, "file") => { + cfg.logging.file = if new_value.is_empty() { + None + } else { + Some(new_value.to_string()) + }; + } + (ConfigSection::Logging, "level") => { + cfg.logging.level = new_value.to_string(); + } + (ConfigSection::Logging, "stats_dir") => { + cfg.logging.stats_dir = new_value.to_string(); + } + (ConfigSection::Logging, "console") => { + cfg.logging.console = if new_value.is_empty() { + None + } else { + Some(new_value.parse().map_err(|_| "must be true or false")?) + }; + } + + // API + (ConfigSection::Api, "hostname") => { + cfg.api.hostname = new_value.to_string(); + } + (ConfigSection::Api, "port") => { + cfg.api.port = new_value.parse().map_err(|_| "port must be 0–65535")?; + } + (ConfigSection::Api, "auth_user") => { + cfg.api.auth_user = if new_value.is_empty() { + None + } else { + Some(new_value.to_string()) + }; + } + (ConfigSection::Api, "auth_token") => { + cfg.api.auth_token = if new_value.is_empty() { + None + } else { + Some(new_value.to_string()) + }; + } + (ConfigSection::Api, "auth_password") => { + cfg.api.auth_password = if new_value.is_empty() { + None + } else { + Some(new_value.to_string()) + }; + } + + _ => return Err(format!("unknown field: {}.{}", entry.section, entry.key)), + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use p2poolv2_config::Config; + use tempfile::tempdir; + + fn make_config() -> Config { + let dir = tempdir().unwrap(); + let path = dir.path().join("config.toml"); + std::fs::write( + &path, + r#" +[stratum] +hostname = "127.0.0.1" +port = 3333 +start_difficulty = 1000 +minimum_difficulty = 100 +maximum_difficulty = 100000 +zmqpubhashblock = "tcp://127.0.0.1:28332" +bootstrap_address = "tb1qyazxde6558qj6z3d9np5e6msmrspwpf6k0qggk" +network = "signet" +version_mask = "1fffe000" +difficulty_multiplier = 1.0 +pool_signature = "MyPool/1.0" + +[bitcoinrpc] +url = "http://127.0.0.1:38332" +username = "rpcuser" +password = "rpcpassword" + +[network] +listen_address = "0.0.0.0:8333" +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 +rate_limit_window_secs = 1 +max_requests_per_second = 1 +dial_timeout_secs = 30 + +[store] +path = "./data/store" +background_task_frequency_hours = 1 +pplns_ttl_days = 7 + +[logging] +level = "info" +stats_dir = "./logs/stats" +console = true + +[api] +hostname = "127.0.0.1" +port = 3030 + "#, + ) + .unwrap(); + + // keep dir alive until Config is loaded + let cfg = Config::load(path.to_str().unwrap()).expect("inline test config must parse"); + + // dir drops here but we already have cfg + cfg + } + + #[test] + fn flatten_produces_entries_for_all_sections() { + let cfg = make_config(); + let entries = flatten_config(&cfg); + assert!(entries.iter().any(|e| e.section == ConfigSection::Stratum)); + assert!( + entries + .iter() + .any(|e| e.section == ConfigSection::BitcoinRpc) + ); + assert!(entries.iter().any(|e| e.section == ConfigSection::Network)); + assert!(entries.iter().any(|e| e.section == ConfigSection::Store)); + assert!(entries.iter().any(|e| e.section == ConfigSection::Logging)); + assert!(entries.iter().any(|e| e.section == ConfigSection::Api)); + } + + #[test] + fn sensitive_fields_are_marked() { + let cfg = make_config(); + let entries = flatten_config(&cfg); + let password = entries + .iter() + .find(|e| e.section == ConfigSection::BitcoinRpc && e.key == "password") + .expect("password entry must exist"); + assert!(password.schema.sensitive); + } + + #[test] + fn apply_edit_stratum_port_roundtrip() { + let mut cfg = make_config(); + let idx = flatten_config(&cfg) + .iter() + .position(|e| e.section == ConfigSection::Stratum && e.key == "port") + .unwrap(); + apply_edit(&mut cfg, idx, "4444").unwrap(); + assert_eq!(cfg.stratum.port, 4444); + } + + #[test] + fn apply_edit_rejects_bad_port() { + let mut cfg = make_config(); + let idx = flatten_config(&cfg) + .iter() + .position(|e| e.section == ConfigSection::Stratum && e.key == "port") + .unwrap(); + assert!(apply_edit(&mut cfg, idx, "notanumber").is_err()); + } + + #[test] + fn apply_edit_optional_empty_clears_field() { + let mut cfg = make_config(); + cfg.stratum.pool_signature = Some("MyPool".to_string()); + let idx = flatten_config(&cfg) + .iter() + .position(|e| e.section == ConfigSection::Stratum && e.key == "pool_signature") + .unwrap(); + apply_edit(&mut cfg, idx, "").unwrap(); + assert!(cfg.stratum.pool_signature.is_none()); + } + + #[test] + fn apply_edit_dial_peers_csv_roundtrip() { + let mut cfg = make_config(); + let idx = flatten_config(&cfg) + .iter() + .position(|e| e.section == ConfigSection::Network && e.key == "dial_peers") + .unwrap(); + apply_edit(&mut cfg, idx, "a:1,b:2").unwrap(); + assert_eq!(cfg.network.dial_peers, vec!["a:1", "b:2"]); + } + + #[test] + fn apply_edit_out_of_range_returns_err() { + let mut cfg = make_config(); + assert!(apply_edit(&mut cfg, 9999, "x").is_err()); + } +} From 9dee77e7ab13b879503e8743073c741e9e4ca131 Mon Sep 17 00:00:00 2001 From: Raunak Kumar Date: Fri, 17 Apr 2026 08:45:01 +0000 Subject: [PATCH 02/10] test: improve test coverage --- src/components/p2pool_config_view.rs | 173 +++++++++++++++++++-------- src/main.rs | 122 ++++++++++++++++++- src/p2poolv2_config.rs | 115 ++++++++++++++++++ 3 files changed, 359 insertions(+), 51 deletions(-) diff --git a/src/components/p2pool_config_view.rs b/src/components/p2pool_config_view.rs index 29e9228..d819013 100644 --- a/src/components/p2pool_config_view.rs +++ b/src/components/p2pool_config_view.rs @@ -20,6 +20,41 @@ pub struct P2PoolConfigView { pub sidebar_focused: bool, } +/// Returns `(display_string, style)` for a config entry value. +pub fn entry_display(entry: &P2PoolConfigEntry) -> (String, Style) { + if entry.enabled { + let v = if entry.schema.sensitive { + "••••••••".to_string() + } else { + entry.value.clone() + }; + ( + v, + Style::default() + .fg(Color::White) + .add_modifier(Modifier::BOLD), + ) + } else { + let placeholder = match &entry.schema.kind { + FieldKind::Optional { default: Some(d) } => format!("default: {}", d), + _ => "not set".to_string(), + }; + ( + format!("({})", placeholder), + Style::default().fg(Color::DarkGray), + ) + } +} + +/// Returns the edit-mode display string (masked if sensitive). +pub fn edit_display(input: &str, sensitive: bool) -> String { + if sensitive { + "•".repeat(input.len()) + "_" + } else { + format!("{}_", input) + } +} + impl P2PoolConfigView { #[must_use] pub fn new() -> Self { @@ -130,27 +165,7 @@ impl P2PoolConfigView { let items: Vec = entries .iter() .map(|entry| { - let (value_display, value_style) = if entry.enabled { - ( - if entry.schema.sensitive { - "••••••••".to_string() - } else { - entry.value.clone() - }, - Style::default() - .fg(Color::White) - .add_modifier(Modifier::BOLD), - ) - } else { - let placeholder = match &entry.schema.kind { - FieldKind::Optional { default: Some(d) } => format!("default: {}", d), - _ => "not set".to_string(), - }; - ( - format!("({})", placeholder), - Style::default().fg(Color::DarkGray), - ) - }; + let (value_display, value_style) = entry_display(entry); ListItem::new(vec![ Line::from(vec![ @@ -230,40 +245,14 @@ impl P2PoolConfigView { ); if editing { - let display = if entry.schema.sensitive { - "•".repeat(edit_input.len()) + "_" - } else { - format!("{}_", edit_input) - }; f.render_widget( - Paragraph::new(display) + Paragraph::new(edit_display(&edit_input, entry.schema.sensitive)) .block(Block::default().borders(Borders::ALL)) .style(Style::default().fg(Color::Yellow)), rows[4], ); } else { - let (display, style) = if entry.enabled { - let v = if entry.schema.sensitive { - "••••••••".to_string() - } else { - entry.value.clone() - }; - ( - v, - Style::default() - .fg(Color::White) - .add_modifier(Modifier::BOLD), - ) - } else { - let placeholder = match &entry.schema.kind { - FieldKind::Optional { default: Some(d) } => format!("default: {}", d), - _ => "not set".to_string(), - }; - ( - format!("({})", placeholder), - Style::default().fg(Color::DarkGray), - ) - }; + let (display, style) = entry_display(entry); f.render_widget( Paragraph::new(display) .block(Block::default().borders(Borders::ALL)) @@ -293,6 +282,7 @@ mod tests { use super::*; use crate::p2poolv2_config::{ConfigSection, FieldKind, P2PoolConfigEntry, P2PoolFieldSchema}; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + use ratatui::{Terminal, backend::TestBackend}; fn make_entry(key: &str, value: &str, enabled: bool) -> P2PoolConfigEntry { P2PoolConfigEntry { @@ -313,6 +303,16 @@ mod tests { KeyEvent::new(code, KeyModifiers::empty()) } + fn buffer_text(terminal: &Terminal) -> String { + terminal + .backend() + .buffer() + .content() + .iter() + .map(|c| c.symbol()) + .collect() + } + #[test] fn editing_char_appends() { let mut view = P2PoolConfigView::new(); @@ -435,4 +435,79 @@ mod tests { view.handle_input(key(KeyCode::Up), &entries); assert!(view.save_message.is_none()); } + + #[test] + fn entry_display_enabled_non_sensitive() { + let entry = make_entry("host", "127.0.0.1", true); + let (display, style) = entry_display(&entry); + assert_eq!(display, "127.0.0.1"); + assert_eq!(style.fg, Some(Color::White)); + } + + #[test] + fn entry_display_enabled_sensitive() { + let mut entry = make_entry("pass", "secret", true); + entry.schema.sensitive = true; + let (display, _) = entry_display(&entry); + assert_eq!(display, "••••••••"); + } + + #[test] + fn entry_display_disabled_with_default() { + let mut entry = make_entry("port", "", false); + entry.schema.kind = FieldKind::Optional { + default: Some("3333".into()), + }; + let (display, style) = entry_display(&entry); + assert_eq!(display, "(default: 3333)"); + assert_eq!(style.fg, Some(Color::DarkGray)); + } + + #[test] + fn entry_display_disabled_no_default() { + let entry = make_entry("port", "", false); + let (display, _) = entry_display(&entry); + assert_eq!(display, "(not set)"); + } + + #[test] + fn edit_display_non_sensitive() { + assert_eq!(edit_display("hello", false), "hello_"); + } + + #[test] + fn edit_display_sensitive_masks_chars() { + assert_eq!(edit_display("abc", true), "•••_"); + } + + #[test] + fn edit_display_sensitive_empty_input() { + assert_eq!(edit_display("", true), "_"); + } + + #[test] + fn render_no_path_shows_prompt() { + let backend = TestBackend::new(80, 10); + let mut terminal = Terminal::new(backend).unwrap(); + let mut app = App::default(); + app.p2pool_conf_path = None; + app.p2pool_config_view.warning_message = None; + terminal + .draw(|f| P2PoolConfigView::render(f, &mut app, f.size())) + .unwrap(); + assert!(buffer_text(&terminal).contains("Press [Enter] to select")); + } + + #[test] + fn render_no_path_shows_warning_message() { + let backend = TestBackend::new(80, 10); + let mut terminal = Terminal::new(backend).unwrap(); + let mut app = App::default(); + app.p2pool_conf_path = None; + app.p2pool_config_view.warning_message = Some("File not found".into()); + terminal + .draw(|f| P2PoolConfigView::render(f, &mut app, f.size())) + .unwrap(); + assert!(buffer_text(&terminal).contains("File not found")); + } } diff --git a/src/main.rs b/src/main.rs index 0370d0f..326423e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -408,6 +408,124 @@ mod tests { let _ = handle_action(action, app).unwrap(); } + /// Write a p2pool TOML to `path`. + fn write_valid_p2pool_toml(path: &std::path::Path) { + std::fs::write( + path, + r#" +[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 +rate_limit_window_secs = 1 +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 +"#, + ) + .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( + path, + r#" +[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 +rate_limit_window_secs = 1 +max_requests_per_second = 100 +dial_timeout_secs = 30 + +[store] +path = "./store.db" +background_task_frequency_hours = 24 +pplns_ttl_days = 7 + +[stratum] +hostname = "" # empty hostname should trigger a warning +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 +"#, + ) + .unwrap(); + } + #[test] fn test_app_integration_smoke_test() { let backend = TestBackend::new(80, 25); @@ -678,7 +796,7 @@ mod tests { assert!(app.bitcoin_config_view.sidebar_focused); } - // --- toggle_menu state cleanup --- + // toggle_menu state cleanup #[test] fn toggle_menu_clears_bitcoin_config_messages_on_navigate_away() { @@ -728,7 +846,7 @@ mod tests { ); } - // --- dirty flag --- + // dirty flag #[test] fn commit_edit_sets_dirty_flag() { diff --git a/src/p2poolv2_config.rs b/src/p2poolv2_config.rs index 4c8a183..0a3680a 100644 --- a/src/p2poolv2_config.rs +++ b/src/p2poolv2_config.rs @@ -780,4 +780,119 @@ port = 3030 let mut cfg = make_config(); assert!(apply_edit(&mut cfg, 9999, "x").is_err()); } + + #[test] + fn config_section_display_outputs_correct_strings() { + use super::ConfigSection; + + let cases = vec![ + (ConfigSection::Stratum, "stratum"), + (ConfigSection::BitcoinRpc, "bitcoinrpc"), + (ConfigSection::Network, "network"), + (ConfigSection::Store, "store"), + (ConfigSection::Logging, "logging"), + (ConfigSection::Api, "api"), + ]; + + for (section, expected) in cases { + assert_eq!(section.to_string(), expected); + } + } + + #[test] + fn apply_edit_covers_multiple_field_types() { + use super::{ConfigSection, apply_edit, flatten_config}; + + let mut cfg = make_config(); + let entries = flatten_config(&cfg); + + let idx = |section: ConfigSection, key: &str| { + entries + .iter() + .position(|e| e.section == section && e.key == key) + .expect("field must exist") + }; + + // String field + apply_edit( + &mut cfg, + idx(ConfigSection::Stratum, "hostname"), + "new.host", + ) + .unwrap(); + assert_eq!(cfg.stratum.hostname, "new.host"); + + // Numeric field + apply_edit(&mut cfg, idx(ConfigSection::Stratum, "port"), "5555").unwrap(); + assert_eq!(cfg.stratum.port, 5555); + + // Optional cleared + apply_edit(&mut cfg, idx(ConfigSection::Stratum, "pool_signature"), "").unwrap(); + assert!(cfg.stratum.pool_signature.is_none()); + + // Optional set + apply_edit(&mut cfg, idx(ConfigSection::Stratum, "donation"), "25").unwrap(); + assert_eq!(cfg.stratum.donation, Some(25)); + + // CSV parsing + apply_edit( + &mut cfg, + idx(ConfigSection::Network, "dial_peers"), + "a:1,b:2", + ) + .unwrap(); + assert_eq!(cfg.network.dial_peers, vec!["a:1", "b:2"]); + + // Enum parsing + apply_edit(&mut cfg, idx(ConfigSection::Stratum, "network"), "signet").unwrap(); + assert_eq!(cfg.stratum.network.to_string(), "signet"); + + // Hex parsing + apply_edit( + &mut cfg, + idx(ConfigSection::Stratum, "version_mask"), + "1fffe000", + ) + .unwrap(); + assert_eq!(cfg.stratum.version_mask, 0x1fffe000); + + // Bool parsing + apply_edit(&mut cfg, idx(ConfigSection::Logging, "console"), "false").unwrap(); + assert_eq!(cfg.logging.console, Some(false)); + } + + #[test] + fn apply_edit_rejects_invalid_inputs() { + use super::{apply_edit, flatten_config}; + + let mut cfg = make_config(); + let entries = flatten_config(&cfg); + + // Invalid number + let port_idx = entries.iter().position(|e| e.key == "port").unwrap(); + assert!(apply_edit(&mut cfg, port_idx, "notanumber").is_err()); + + // Invalid bool + let console_idx = entries.iter().position(|e| e.key == "console").unwrap(); + assert!(apply_edit(&mut cfg, console_idx, "notabool").is_err()); + + // Invalid hex + let hex_idx = entries + .iter() + .position(|e| e.key == "version_mask") + .unwrap(); + assert!(apply_edit(&mut cfg, hex_idx, "zzzz").is_err()); + } + + #[test] + fn apply_edit_unknown_field_hits_fallback_branch() { + use super::apply_edit; + + let mut cfg = make_config(); + + // Out of bounds → triggers error path + let result = apply_edit(&mut cfg, usize::MAX, "value"); + + assert!(result.is_err()); + } } From 4bb123d2c1239cb7523a4a15378bd93a88123a56 Mon Sep 17 00:00:00 2001 From: Raunak Kumar Date: Fri, 17 Apr 2026 11:09:28 +0000 Subject: [PATCH 03/10] test: improve test coverage for p2pool_config_view --- src/components/p2pool_config_view.rs | 102 +++++++++++++++++++++++++++ 1 file changed, 102 insertions(+) diff --git a/src/components/p2pool_config_view.rs b/src/components/p2pool_config_view.rs index d819013..cdac1c4 100644 --- a/src/components/p2pool_config_view.rs +++ b/src/components/p2pool_config_view.rs @@ -282,7 +282,9 @@ mod tests { use super::*; use crate::p2poolv2_config::{ConfigSection, FieldKind, P2PoolConfigEntry, P2PoolFieldSchema}; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + use p2poolv2_config::Config; use ratatui::{Terminal, backend::TestBackend}; + use tempfile::tempdir; fn make_entry(key: &str, value: &str, enabled: bool) -> P2PoolConfigEntry { P2PoolConfigEntry { @@ -313,6 +315,71 @@ mod tests { .collect() } + fn make_config() -> Config { + let dir = tempdir().unwrap(); + let path = dir.path().join("config.toml"); + std::fs::write( + &path, + r#" +[stratum] +hostname = "127.0.0.1" +port = 3333 +start_difficulty = 1000 +minimum_difficulty = 100 +maximum_difficulty = 100000 +zmqpubhashblock = "tcp://127.0.0.1:28332" +bootstrap_address = "tb1qyazxde6558qj6z3d9np5e6msmrspwpf6k0qggk" +network = "signet" +version_mask = "1fffe000" +difficulty_multiplier = 1.0 +pool_signature = "MyPool/1.0" + +[bitcoinrpc] +url = "http://127.0.0.1:38332" +username = "rpcuser" +password = "rpcpassword" + +[network] +listen_address = "0.0.0.0:8333" +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 +rate_limit_window_secs = 1 +max_requests_per_second = 1 +dial_timeout_secs = 30 + +[store] +path = "./data/store" +background_task_frequency_hours = 1 +pplns_ttl_days = 7 + +[logging] +level = "info" +stats_dir = "./logs/stats" +console = true + +[api] +hostname = "127.0.0.1" +port = 3030 + "#, + ) + .unwrap(); + + // keep dir alive until Config is loaded + let cfg = Config::load(path.to_str().unwrap()).expect("inline test config must parse"); + + // dir drops here but we already have cfg + cfg + } + #[test] fn editing_char_appends() { let mut view = P2PoolConfigView::new(); @@ -510,4 +577,39 @@ mod tests { .unwrap(); assert!(buffer_text(&terminal).contains("File not found")); } + + #[test] + fn render_full_coverage() { + use ratatui::{Terminal, backend::TestBackend}; + use std::path::PathBuf; + + let backend = TestBackend::new(120, 30); + let mut terminal = Terminal::new(backend).unwrap(); + + let mut app = App::default(); + + app.p2pool_conf_path = Some(PathBuf::from("test.toml")); + + let cfg = make_config(); + app.p2pool_config = Some(cfg.clone()); + + let entries = flatten_config(&cfg); + + let sensitive_idx = entries.iter().position(|e| e.schema.sensitive).unwrap_or(0); + + app.p2pool_config_view.selected_index = sensitive_idx; + + app.p2pool_config_view.editing = true; + app.p2pool_config_view.edit_input = "secret123".into(); + + terminal + .draw(|f| P2PoolConfigView::render(f, &mut app, f.size())) + .unwrap(); + + let text = buffer_text(&terminal); + + assert!(text.contains("P2Pool Configuration")); // list rendered + assert!(text.contains("Value:")); // detail panel rendered + assert!(text.contains("_")); // editing cursor present + } } From 5538578fb48ecd307a1f39261f81b7e636fb2d96 Mon Sep 17 00:00:00 2001 From: Raunak Kumar Date: Sat, 18 Apr 2026 10:49:07 +0000 Subject: [PATCH 04/10] update the config view to render warning and save messages so validation errors and save feedback are visible --- Cargo.lock | 28 +---------------------- Cargo.toml | 1 - src/components/p2pool_config_view.rs | 33 +++++++++++++++++++++++++++- 3 files changed, 33 insertions(+), 29 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 586f641..2957264 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2277,15 +2277,6 @@ dependencies = [ "zmij", ] -[[package]] -name = "serde_spanned" -version = "0.6.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" -dependencies = [ - "serde", -] - [[package]] name = "serde_spanned" version = "1.0.4" @@ -2766,18 +2757,6 @@ dependencies = [ "serde", ] -[[package]] -name = "toml" -version = "0.8.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" -dependencies = [ - "serde", - "serde_spanned 0.6.9", - "toml_datetime 0.6.11", - "toml_edit", -] - [[package]] name = "toml" version = "0.9.10+spec-1.1.0" @@ -2785,7 +2764,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0825052159284a1a8b4d6c0c86cbc801f2da5afd2b225fa548c72f2e74002f48" dependencies = [ "serde_core", - "serde_spanned 1.0.4", + "serde_spanned", "toml_datetime 0.7.5+spec-1.1.0", "toml_parser", "winnow", @@ -2796,9 +2775,6 @@ name = "toml_datetime" version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" -dependencies = [ - "serde", -] [[package]] name = "toml_datetime" @@ -2816,8 +2792,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" dependencies = [ "indexmap", - "serde", - "serde_spanned 0.6.9", "toml_datetime 0.6.11", "toml_write", "winnow", diff --git a/Cargo.toml b/Cargo.toml index df7bc86..7b690d0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,7 +16,6 @@ toml = "0.8" unicode-width = "0.2" p2poolv2_config = { git = "https://github.com/p2poolv2/p2poolv2", package = "p2poolv2_config" } bitcoin = "0.32.5" -toml = "0.8" toml_edit = "0.22" [dev-dependencies] diff --git a/src/components/p2pool_config_view.rs b/src/components/p2pool_config_view.rs index cdac1c4..7629ea6 100644 --- a/src/components/p2pool_config_view.rs +++ b/src/components/p2pool_config_view.rs @@ -156,10 +156,41 @@ impl P2PoolConfigView { .map(|cfg| flatten_config(cfg)) .unwrap_or_default(); + // Status bar (warning or save message) + // Warning (red) takes priority over save message (green). + let status_message = app + .p2pool_config_view + .warning_message + .as_deref() + .map(|msg| (msg, Style::default().fg(Color::Red))) + .or_else(|| { + app.p2pool_config_view + .save_message + .as_deref() + .map(|msg| (msg, Style::default().fg(Color::Green))) + }); + + // If there is a message, crave a 3-row strip off the top and render the rest of the view beneath it. + let content_area = if let Some((msg, style)) = status_message { + let layout = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Length(3), Constraint::Min(0)]) + .split(area); + + let status = Paragraph::new(msg) + .style(style) + .block(Block::default().borders(Borders::ALL).title(" Status ")); + f.render_widget(status, layout[0]); + layout[1] + } else { + area + }; + + // Split panel layout let panels = Layout::default() .direction(Direction::Horizontal) .constraints([Constraint::Percentage(45), Constraint::Percentage(55)]) - .split(area); + .split(content_area); // Left panel: scrollable entry list let items: Vec = entries From 873ca059e64c0da51538cd7c58bed23bc8f387bf Mon Sep 17 00:00:00 2001 From: Raunak Kumar Date: Sat, 18 Apr 2026 11:33:39 +0000 Subject: [PATCH 05/10] added type_toml_item_like func,it inspects the existing item's type in the document and parses the new value into that same type. --- src/main.rs | 77 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 77 insertions(+) diff --git a/src/main.rs b/src/main.rs index 326423e..147e306 100644 --- a/src/main.rs +++ b/src/main.rs @@ -385,6 +385,83 @@ fn handle_action(action: AppAction, app: &mut App) -> Result> { } Ok(ControlFlow::Continue(())) + Ok(false) +} + +/// Matches the TOML type of an existing item and parses the new string +/// value into that same type. This prevents numeric/bool fields from +/// being written back as quoted strings (e.g. port = "3333"). +fn typed_toml_item_like(existing: &toml_edit::Item, new_value: &str) -> Result { + if existing.as_integer().is_some() { + let parsed = new_value + .parse::() + .map_err(|e| anyhow::anyhow!("Expected integer, got '{}': {}", new_value, e))?; + Ok(toml_edit::value(parsed)) + } else if existing.as_float().is_some() { + let parsed = new_value + .parse::() + .map_err(|e| anyhow::anyhow!("Expected float, got '{}': {}", new_value, e))?; + Ok(toml_edit::value(parsed)) + } else if existing.as_bool().is_some() { + let parsed = new_value.parse::().map_err(|e| { + anyhow::anyhow!("Expected bool (true/false), got '{}': {}", new_value, e) + })?; + Ok(toml_edit::value(parsed)) + } else if existing.as_str().is_some() { + Ok(toml_edit::value(new_value.to_owned())) + } else { + Err(anyhow::anyhow!( + "Unsupported TOML value type for key: {}", + existing + )) + } +} + +/// Serialize the live `P2PoolConfig` back to TOML and write it to disk. +/// Saves P2Pool config by patching the original TOML file in-place. +/// Uses toml_edit so comments and formatting are preserved. +fn save_p2pool_config(path: &std::path::Path, cfg: &P2PoolConfig) -> Result<()> { + use pdm::p2poolv2_config::flatten_config; + use toml_edit::DocumentMut; + + // Read the original file so we preserve comments/ordering + let original = std::fs::read_to_string(path) + .map_err(|e| anyhow::anyhow!("Failed to read P2Pool config: {}", e))?; + + let mut doc = original + .parse::() + .map_err(|e| anyhow::anyhow!("Failed to parse P2Pool config TOML: {}", e))?; + + // Walk every flattened entry and patch the matching TOML key + for entry in flatten_config(cfg) { + let section = entry.section.to_string(); + let key = entry.key.as_str(); + + // Skip optional fields that are unset — leave them absent in the file + if !entry.enabled { + continue; + } + + if let Some(table) = doc.get_mut(§ion).and_then(|v| v.as_table_mut()) { + // Only update keys that already exist in the file to avoid + // injecting fields the user intentionally omitted + if let Some(existing) = table.get(key) { + match typed_toml_item_like(existing, &entry.value) { + Ok(updated) => table[key] = updated, + Err(e) => { + // Soft error — skip this field and continue + // saving the rest rather than aborting entirely + eprintln!("Warning: skipping {}.{}: {}", section, key, e); + } + } + } + } + } + + std::fs::write(path, doc.to_string()) + .map_err(|e| anyhow::anyhow!("Failed to write P2Pool config: {}", e))?; + + Ok(()) } #[cfg(test)] From f747eae69525b838d7640d84531ce1249cd9769f Mon Sep 17 00:00:00 2001 From: Raunak Kumar Date: Sat, 18 Apr 2026 12:33:09 +0000 Subject: [PATCH 06/10] tests: improvement --- src/p2poolv2_config.rs | 68 ++++++++++++++++++++++++++++++++++-------- 1 file changed, 55 insertions(+), 13 deletions(-) diff --git a/src/p2poolv2_config.rs b/src/p2poolv2_config.rs index 0a3680a..ec0e8ca 100644 --- a/src/p2poolv2_config.rs +++ b/src/p2poolv2_config.rs @@ -430,15 +430,17 @@ pub fn flatten_config(cfg: &Config) -> Vec { e } -/// Writes one edited value back into the live `Config`. -/// Returns `Err` with a user-facing message on parse failure. -/// This is the write-path counterpart to `flatten_config`. -pub fn apply_edit(cfg: &mut Config, index: usize, new_value: &str) -> Result<(), String> { - let entries = flatten_config(cfg); - let entry = entries - .get(index) - .ok_or_else(|| "index out of range".to_string())?; - +/// Inner dispatch for config edits. +/// +/// Matches a flattened `(section, key)` pair to the corresponding nested field inside `Config` and applies the parsed update. +/// +/// This is split from `apply_edit()` so tests can directly inject +/// synthetic entries and explicitly verify the defensive `_ => unknown field` fallback branch, which `flatten_config()` cannot normally produce. +pub fn dispatch_edit( + cfg: &mut Config, + entry: &P2PoolConfigEntry, + new_value: &str, +) -> Result<(), String> { match (&entry.section, entry.key.as_str()) { // Stratum (ConfigSection::Stratum, "hostname") => { @@ -633,6 +635,17 @@ pub fn apply_edit(cfg: &mut Config, index: usize, new_value: &str) -> Result<(), Ok(()) } +/// Writes an edited flat-row value back into `Config`. +/// Resolves the selected row from `flatten_config()` and delegates the actual update to `dispatch_edit()`. +/// Returns `Err` if the index is invalid or parsing fails +pub fn apply_edit(cfg: &mut Config, index: usize, new_value: &str) -> Result<(), String> { + let entries = flatten_config(cfg); + let entry = entries + .get(index) + .ok_or_else(|| "index out of range".to_string())?; + dispatch_edit(cfg, entry, new_value) +} + #[cfg(test)] mod tests { use super::*; @@ -886,13 +899,42 @@ port = 3030 #[test] fn apply_edit_unknown_field_hits_fallback_branch() { - use super::apply_edit; - let mut cfg = make_config(); - // Out of bounds → triggers error path - let result = apply_edit(&mut cfg, usize::MAX, "value"); + // Construct a fake entry with a key that exists in no match arm. + // In normal use flatten_config never produces unknown keys — + // this branch is a defensive guard against future bugs (e.g. a + // new field added to flatten_config but forgotten in dispatch_edit). + let fake_entry = P2PoolConfigEntry { + section: ConfigSection::Api, + key: "nonexistent_key_xyz".to_string(), + value: String::new(), + enabled: true, + schema: P2PoolFieldSchema { + description: "fake".to_string(), + kind: FieldKind::Required, + type_hint: "String".to_string(), + sensitive: false, + }, + }; + let result = dispatch_edit(&mut cfg, &fake_entry, "value"); + assert!(result.is_err()); + assert!( + result.unwrap_err().contains("unknown field"), + "error message must mention unknown field" + ); + } + #[test] + fn apply_edit_out_of_bounds_index_returns_error() { + let mut cfg = make_config(); + // usize::MAX is always out of range — hits the bounds check + // before any section/key dispatch occurs. + let result = apply_edit(&mut cfg, usize::MAX, "value"); assert!(result.is_err()); + assert!( + result.unwrap_err().contains("index out of range"), + "error message must mention index out of range" + ); } } From c9f76a9f527295ced676694c65b4787b9a50ff1a Mon Sep 17 00:00:00 2001 From: Raunak Kumar Date: Thu, 23 Apr 2026 11:27:02 +0000 Subject: [PATCH 07/10] Resolve remaining changes after rebase --- Cargo.lock | 31 +++++++++-- src/main.rs | 150 +++++++++++++++++++++++++++++++++++++++++++--------- 2 files changed, 154 insertions(+), 27 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 2957264..194b0bb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -86,8 +86,6 @@ checksum = "2c8d66485a3a2ea485c1913c4572ce0256067a5377ac8c75c4960e1cda98605f" dependencies = [ "bitcoin-internals", "bitcoin_hashes", - "bitcoin-internals", - "bitcoin_hashes", ] [[package]] @@ -1644,6 +1642,7 @@ dependencies = [ "serial_test", "tempfile", "toml 0.8.23", + "toml_edit", "unicode-width", ] @@ -2277,6 +2276,15 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + [[package]] name = "serde_spanned" version = "1.0.4" @@ -2757,6 +2765,18 @@ dependencies = [ "serde", ] +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.11", + "toml_edit", +] + [[package]] name = "toml" version = "0.9.10+spec-1.1.0" @@ -2764,7 +2784,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0825052159284a1a8b4d6c0c86cbc801f2da5afd2b225fa548c72f2e74002f48" dependencies = [ "serde_core", - "serde_spanned", + "serde_spanned 1.0.4", "toml_datetime 0.7.5+spec-1.1.0", "toml_parser", "winnow", @@ -2775,6 +2795,9 @@ name = "toml_datetime" version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] [[package]] name = "toml_datetime" @@ -2792,6 +2815,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" dependencies = [ "indexmap", + "serde", + "serde_spanned 0.6.9", "toml_datetime 0.6.11", "toml_write", "winnow", diff --git a/src/main.rs b/src/main.rs index 147e306..3703a82 100644 --- a/src/main.rs +++ b/src/main.rs @@ -9,8 +9,8 @@ use pdm::app::{ use pdm::bitcoin_config::{ parse_config as parse_bitcoin_config, save_config as save_bitcoin_config, }; -use pdm::p2poolv2_config::{apply_edit as apply_p2pool_edit, flatten_config}; use pdm::components::settings_view::{FIELDS, FieldKind}; +use pdm::p2poolv2_config::{apply_edit as apply_p2pool_edit, flatten_config}; use pdm::settings::{load_settings, save_settings}; use pdm::ui; use std::ops::ControlFlow; @@ -133,6 +133,38 @@ where } } + // P2Pool config + CurrentScreen::P2PoolConfig => { + if app.p2pool_conf_path.is_some() { + if app.p2pool_config_view.sidebar_focused { + match key.code { + KeyCode::Enter => { + app.p2pool_config_view.sidebar_focused = false; + AppAction::None + } + k => sidebar_nav(k, app), + } + } else { + // Build flat entry list and delegate to the view + let entries = app + .p2pool_config + .as_ref() + .map(|cfg| flatten_config(cfg)) + .unwrap_or_default(); + app.p2pool_config_view.handle_input(key, &entries) + } + } else { + match key.code { + KeyCode::Enter => { + app.p2pool_config_view.warning_message = None; + AppAction::OpenExplorer(ExplorerTrigger::P2PoolConfig) + } + KeyCode::Esc => AppAction::CloseModal, + k => sidebar_nav(k, app), + } + } + } + CurrentScreen::Settings => { if app.settings_view.sidebar_focused { match key.code { @@ -178,13 +210,19 @@ fn bootstrap_from_settings(app: &mut App) { } } - // P2Pool config - if let Some(path) = &app.settings.p2pool_conf_path { - app.p2pool_conf_path = Some(path.clone()); - if let Some(p) = path.to_str() - && let Ok(cfg) = P2PoolConfig::load(p) - { - app.p2pool_config = Some(cfg); + // P2Pool config — only set the path when the config is actually loadable + if let Some(path) = &app.settings.p2pool_conf_path.clone() { + if let Some(p) = path.to_str() { + match P2PoolConfig::load(p) { + Ok(cfg) => { + app.p2pool_conf_path = Some(path.clone()); + app.p2pool_config = Some(cfg); + } + Err(e) => { + eprintln!("pdm: failed to load p2pool config on startup: {e}"); + // Leave both as None so the view prompts the user to re-select + } + } } } } @@ -228,11 +266,34 @@ fn handle_action(action: AppAction, app: &mut App) -> Result> { if let Some(trigger) = app.explorer_trigger.take() { match trigger { ExplorerTrigger::P2PoolConfig => { - app.p2pool_conf_path = Some(path.clone()); - if let Some(p) = path.to_str() - && let Ok(cfg) = P2PoolConfig::load(p) - { - app.p2pool_config = Some(cfg); + match P2PoolConfig::load(path.to_str().unwrap_or_default()) { + Ok(cfg) => { + // Sanity check — a valid p2pool config must have + // a stratum section with at least a hostname + + if cfg.stratum.hostname.is_empty() { + app.p2pool_config_view.warning_message = Some( + "Config loaded but appears invalid: stratum.hostname is empty. Select another file." + .to_string(), + ); + app.p2pool_conf_path = None; + app.p2pool_config = None; + } else { + app.p2pool_conf_path = Some(path.clone()); + app.p2pool_config = Some(cfg); + app.p2pool_config_view.sidebar_focused = false; + app.p2pool_config_view.warning_message = None; + app.p2pool_config_view.selected_index = 0; + } + } + Err(e) => { + app.p2pool_config_view.warning_message = Some(format!( + "Failed to load P2Pool config: {}. Select another file.", + e + )); + app.p2pool_conf_path = None; + app.p2pool_config = None; + } } app.current_screen = CurrentScreen::P2PoolConfig; app.settings.p2pool_conf_path = Some(path.clone()); @@ -310,15 +371,28 @@ fn handle_action(action: AppAction, app: &mut App) -> Result> { should_save = false; } }, - 1 => { - app.p2pool_conf_path = Some(path.clone()); - if let Some(p) = path.to_str() - && let Ok(cfg) = P2PoolConfig::load(p) - { - app.p2pool_config = Some(cfg); + 1 => match P2PoolConfig::load(path.to_str().unwrap_or_default()) { + Ok(cfg) => { + if cfg.stratum.hostname.is_empty() { + app.settings_view.save_error = Some( + "Config appears invalid: stratum.hostname is empty." + .to_string(), + ); + should_save = false; + } else { + app.p2pool_config = Some(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()); + } } - app.settings.p2pool_conf_path = Some(path.clone()); - } + Err(e) => { + app.settings_view.save_error = + Some(format!("Failed to load P2Pool config: {}", e)); + 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()), @@ -380,12 +454,40 @@ fn handle_action(action: AppAction, app: &mut App) -> Result> { app.settings_view.save_error = Some(format!("Save failed: {e}")); } } + AppAction::CommitP2PoolEdit(index, value) => { + if let Some(cfg) = app.p2pool_config.as_mut() { + match apply_p2pool_edit(cfg, index, &value) { + Ok(()) => { + app.p2pool_config_view.warning_message = None; + } + Err(e) => { + app.p2pool_config_view.warning_message = Some(e); + } + } + } + } + + AppAction::SaveP2PoolConfig => { + if let (Some(path), Some(cfg)) = + (app.p2pool_conf_path.clone(), app.p2pool_config.as_ref()) + { + match save_p2pool_config(&path, cfg) { + Ok(()) => { + app.p2pool_config_view.save_message = + Some("Configuration correctly saved".to_string()); + } + Err(e) => { + app.p2pool_config_view.warning_message = + Some(format!("Save failed: {}", e)); + } + } + } + } AppAction::None => {} } Ok(ControlFlow::Continue(())) - Ok(false) } /// Matches the TOML type of an existing item and parses the new string @@ -1119,7 +1221,7 @@ port = 46884 // Write a minimal but syntactically valid TOML file; P2PoolConfig::load // may fail to parse it, but bootstrap_from_settings should at least set // app.p2pool_conf_path regardless of whether the config is parseable. - std::fs::write(&path, "").unwrap(); + let cfg = write_valid_p2pool_toml(&path); let mut app = App::new(); app.settings.p2pool_conf_path = Some(path.clone()); @@ -1159,7 +1261,7 @@ port = 46884 let dir = tempdir().unwrap(); redirect_saves_to(&dir); let path = dir.path().join("p2pool.toml"); - std::fs::write(&path, "").unwrap(); + let cfg = write_valid_p2pool_toml(&path); let mut app = App::new(); app.explorer_trigger = Some(ExplorerTrigger::Settings(1)); From 8ddafa2c1d49e0c9956255713d131ccf74aa568e Mon Sep 17 00:00:00 2001 From: Raunak Kumar Date: Thu, 23 Apr 2026 13:11:33 +0000 Subject: [PATCH 08/10] Tests: Improve test coverage for main.rs --- src/main.rs | 190 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 190 insertions(+) diff --git a/src/main.rs b/src/main.rs index 3703a82..3ac5887 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1450,4 +1450,194 @@ port = 46884 // A successful save clears the error assert!(app.settings_view.save_error.is_none()); } + + #[test] + fn commit_p2pool_edit_success_clears_warning() { + use std::path::PathBuf; + + let mut app = App::new(); + let path = PathBuf::from("dummy.toml"); + write_valid_p2pool_toml(&path); + + let dir = tempfile::tempdir().unwrap(); + let file = dir.path().join("p2pool.toml"); + write_valid_p2pool_toml(&file); + + let cfg = P2PoolConfig::load(file.to_str().unwrap()).unwrap(); + app.p2pool_config = Some(cfg); + app.p2pool_config_view.warning_message = Some("old warning".to_string()); + + run( + AppAction::CommitP2PoolEdit(0, "/ip4/127.0.0.1/tcp/9999".to_string()), + &mut app, + ); + + assert!(app.p2pool_config_view.warning_message.is_none()); + } + + #[test] + fn commit_p2pool_edit_failure_sets_warning() { + let dir = tempfile::tempdir().unwrap(); + let file = dir.path().join("p2pool.toml"); + write_valid_p2pool_toml(&file); + + let mut app = App::new(); + let cfg = P2PoolConfig::load(file.to_str().unwrap()).unwrap(); + app.p2pool_config = Some(cfg); + + // invalid numeric/bool/etc depending on index used by your flatten_config + run( + AppAction::CommitP2PoolEdit(9999, "bad-value".to_string()), + &mut app, + ); + + assert!(app.p2pool_config_view.warning_message.is_some()); + } + + #[test] + fn save_p2pool_config_action_success_sets_message() { + let dir = tempfile::tempdir().unwrap(); + let file = dir.path().join("p2pool.toml"); + write_valid_p2pool_toml(&file); + + let mut app = App::new(); + let cfg = P2PoolConfig::load(file.to_str().unwrap()).unwrap(); + + app.p2pool_conf_path = Some(file.clone()); + app.p2pool_config = Some(cfg); + + run(AppAction::SaveP2PoolConfig, &mut app); + + assert_eq!( + app.p2pool_config_view.save_message.as_deref(), + Some("Configuration correctly saved") + ); + } + + #[test] + fn save_p2pool_config_action_failure_sets_warning() { + let mut app = App::new(); + + let bad_path = std::path::PathBuf::from("/definitely/missing/path.toml"); + let dir = tempfile::tempdir().unwrap(); + let file = dir.path().join("p2pool.toml"); + write_valid_p2pool_toml(&file); + let cfg = P2PoolConfig::load(file.to_str().unwrap()).unwrap(); + + app.p2pool_conf_path = Some(bad_path); + app.p2pool_config = Some(cfg); + + run(AppAction::SaveP2PoolConfig, &mut app); + + assert!(app.p2pool_config_view.warning_message.is_some()); + } + + #[test] + fn typed_toml_item_like_integer_success() { + let existing = toml_edit::value(3333); + let updated = typed_toml_item_like(&existing, "4444").unwrap(); + assert_eq!(updated.as_integer(), Some(4444)); + } + + #[test] + fn typed_toml_item_like_float_success() { + let existing = toml_edit::value(1.5); + let updated = typed_toml_item_like(&existing, "2.5").unwrap(); + assert_eq!(updated.as_float(), Some(2.5)); + } + + #[test] + fn typed_toml_item_like_bool_success() { + let existing = toml_edit::value(true); + let updated = typed_toml_item_like(&existing, "false").unwrap(); + assert_eq!(updated.as_bool(), Some(false)); + } + + #[test] + fn typed_toml_item_like_string_success() { + let existing = toml_edit::value("old"); + let updated = typed_toml_item_like(&existing, "new").unwrap(); + assert_eq!(updated.as_str(), Some("new")); + } + + #[test] + fn typed_toml_item_like_invalid_parse_fails() { + let existing = toml_edit::value(true); + assert!(typed_toml_item_like(&existing, "not_bool").is_err()); + + let existing = toml_edit::value(1); + assert!(typed_toml_item_like(&existing, "abc").is_err()); + } + + #[test] + fn save_p2pool_config_missing_file_fails() { + let dir = tempfile::tempdir().unwrap(); + let valid = dir.path().join("valid.toml"); + write_valid_p2pool_toml(&valid); + let cfg = P2PoolConfig::load(valid.to_str().unwrap()).unwrap(); + + let missing = dir.path().join("missing.toml"); + let result = save_p2pool_config(&missing, &cfg); + assert!(result.is_err()); + } + + #[test] + fn save_p2pool_config_invalid_toml_fails() { + let dir = tempfile::tempdir().unwrap(); + let file = dir.path().join("bad.toml"); + std::fs::write(&file, "not valid toml = = =").unwrap(); + + let valid = dir.path().join("valid.toml"); + write_valid_p2pool_toml(&valid); + let cfg = P2PoolConfig::load(valid.to_str().unwrap()).unwrap(); + + let result = save_p2pool_config(&file, &cfg); + assert!(result.is_err()); + } + + #[test] + fn file_selected_p2pool_invalid_hostname_sets_warning() { + let dir = tempfile::tempdir().unwrap(); + let file = dir.path().join("p2pool.toml"); + write_empty_hostname_toml(&file); + + let mut app = App::new(); + app.explorer_trigger = Some(ExplorerTrigger::P2PoolConfig); + + run(AppAction::FileSelected(file), &mut app); + + assert!(app.p2pool_config_view.warning_message.is_some()); + assert!(app.p2pool_conf_path.is_none()); + assert!(app.p2pool_config.is_none()); + } + + #[test] + fn file_selected_p2pool_parse_failure_sets_warning() { + let dir = tempfile::tempdir().unwrap(); + let file = dir.path().join("bad.toml"); + std::fs::write(&file, "invalid === toml").unwrap(); + + let mut app = App::new(); + app.explorer_trigger = Some(ExplorerTrigger::P2PoolConfig); + + run(AppAction::FileSelected(file), &mut app); + + assert!(app.p2pool_config_view.warning_message.is_some()); + assert!(app.p2pool_conf_path.is_none()); + } + + #[test] + fn bootstrap_from_settings_invalid_p2pool_keeps_none() { + let dir = tempfile::tempdir().unwrap(); + let file = dir.path().join("bad.toml"); + std::fs::write(&file, "invalid === toml").unwrap(); + + let mut app = App::new(); + app.settings.p2pool_conf_path = Some(file); + + bootstrap_from_settings(&mut app); + + assert!(app.p2pool_conf_path.is_none()); + assert!(app.p2pool_config.is_none()); + } } From 31a293ee85f3db9ff89b6daec9bbefb9aec85997 Mon Sep 17 00:00:00 2001 From: Raunak Kumar Date: Fri, 24 Apr 2026 14:12:21 +0000 Subject: [PATCH 09/10] fix code and address review --- src/app.rs | 6 ++++++ src/main.rs | 43 ++++++++++++++++++++++++++----------------- 2 files changed, 32 insertions(+), 17 deletions(-) diff --git a/src/app.rs b/src/app.rs index 69d2bc9..8452e30 100644 --- a/src/app.rs +++ b/src/app.rs @@ -134,6 +134,12 @@ impl App { 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; + self.p2pool_config_view.editing = false; + self.p2pool_config_view.edit_input.clear(); + } if let Some(&(_, screen)) = SIDEBAR_ITEMS.get(self.sidebar_index) { self.current_screen = screen; } diff --git a/src/main.rs b/src/main.rs index 3ac5887..560a068 100644 --- a/src/main.rs +++ b/src/main.rs @@ -78,9 +78,12 @@ where // 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 + let text_input_active = (app.current_screen == CurrentScreen::BitcoinConfig && !app.bitcoin_config_view.sidebar_focused - && app.bitcoin_config_view.editing; + && app.bitcoin_config_view.editing) + || (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')) @@ -179,16 +182,7 @@ where } } - _ => match key.code { - KeyCode::Enter => { - if matches!(app.current_screen, CurrentScreen::P2PoolConfig) { - AppAction::OpenExplorer(ExplorerTrigger::P2PoolConfig) - } else { - AppAction::None - } - } - k => sidebar_nav(k, app), - }, + _ => sidebar_nav(key.code, app), }; if handle_action(action, app)?.is_break() { @@ -279,11 +273,18 @@ fn handle_action(action: AppAction, app: &mut App) -> Result> { app.p2pool_conf_path = None; app.p2pool_config = None; } else { + // Only set path + persist settings when config is actually valid app.p2pool_conf_path = Some(path.clone()); app.p2pool_config = Some(cfg); app.p2pool_config_view.sidebar_focused = false; app.p2pool_config_view.warning_message = None; app.p2pool_config_view.selected_index = 0; + app.settings.p2pool_conf_path = Some(path.clone()); + app.settings_view.save_error = None; + if let Err(e) = save_settings(&app.settings) { + app.settings_view.save_error = + Some(format!("Save failed: {e}")); + } } } Err(e) => { @@ -296,11 +297,6 @@ fn handle_action(action: AppAction, app: &mut App) -> Result> { } } app.current_screen = CurrentScreen::P2PoolConfig; - app.settings.p2pool_conf_path = Some(path.clone()); - app.settings_view.save_error = None; - if let Err(e) = save_settings(&app.settings) { - app.settings_view.save_error = Some(format!("Save failed: {e}")); - } } ExplorerTrigger::BitcoinConfig => match parse_bitcoin_config(&path) { Ok(entries) => { @@ -511,6 +507,19 @@ fn typed_toml_item_like(existing: &toml_edit::Item, new_value: &str) -> Result = new_value + .split(',') + .map(|s| s.trim()) + .filter(|s| !s.is_empty()) + .map(ToOwned::to_owned) + .collect(); + + let mut new_arr = toml_edit::Array::default(); + for value in values { + new_arr.push(value); + } + Ok(toml_edit::Item::Value(toml_edit::Value::Array(new_arr))) } else { Err(anyhow::anyhow!( "Unsupported TOML value type for key: {}", From 7a31316a7b64b96d94b8a7040f0bf126fe0e9013 Mon Sep 17 00:00:00 2001 From: Raunak Kumar Date: Mon, 27 Apr 2026 16:45:39 +0000 Subject: [PATCH 10/10] update p2poolv2_config dependency --- Cargo.lock | 18 +++++++++--------- src/components/p2pool_config_view.rs | 1 - src/main.rs | 2 -- src/p2poolv2_config.rs | 1 - 4 files changed, 9 insertions(+), 13 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 194b0bb..a15efaa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -177,8 +177,8 @@ dependencies = [ [[package]] name = "bitcoindrpc" -version = "0.1.0" -source = "git+https://github.com/p2poolv2/p2poolv2#a79d76c756cfa65bafb4d539566c7401d8fda473" +version = "0.10.6" +source = "git+https://github.com/p2poolv2/p2poolv2#f448917decf9e12529637ebd37715cb3327c572b" dependencies = [ "base64 0.22.1", "bitcoin", @@ -555,7 +555,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -638,7 +638,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -1463,7 +1463,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -1588,8 +1588,8 @@ dependencies = [ [[package]] name = "p2poolv2_config" -version = "0.1.0" -source = "git+https://github.com/p2poolv2/p2poolv2#a79d76c756cfa65bafb4d539566c7401d8fda473" +version = "0.10.6" +source = "git+https://github.com/p2poolv2/p2poolv2#f448917decf9e12529637ebd37715cb3327c572b" dependencies = [ "bitcoin", "bitcoindrpc", @@ -2093,7 +2093,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -2540,7 +2540,7 @@ dependencies = [ "getrandom 0.3.4", "once_cell", "rustix", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] diff --git a/src/components/p2pool_config_view.rs b/src/components/p2pool_config_view.rs index 7629ea6..f7c0f9b 100644 --- a/src/components/p2pool_config_view.rs +++ b/src/components/p2pool_config_view.rs @@ -383,7 +383,6 @@ max_userworkbase_per_second = 10 max_miningshare_per_second = 100 max_inventory_per_second = 100 max_transaction_per_second = 100 -rate_limit_window_secs = 1 max_requests_per_second = 1 dial_timeout_secs = 30 diff --git a/src/main.rs b/src/main.rs index 560a068..0771b47 100644 --- a/src/main.rs +++ b/src/main.rs @@ -614,7 +614,6 @@ max_userworkbase_per_second = 10 max_miningshare_per_second = 100 max_inventory_per_second = 100 max_transaction_per_second = 100 -rate_limit_window_secs = 1 max_requests_per_second = 100 dial_timeout_secs = 30 @@ -673,7 +672,6 @@ max_userworkbase_per_second = 10 max_miningshare_per_second = 100 max_inventory_per_second = 100 max_transaction_per_second = 100 -rate_limit_window_secs = 1 max_requests_per_second = 100 dial_timeout_secs = 30 diff --git a/src/p2poolv2_config.rs b/src/p2poolv2_config.rs index ec0e8ca..141ea5b 100644 --- a/src/p2poolv2_config.rs +++ b/src/p2poolv2_config.rs @@ -689,7 +689,6 @@ max_userworkbase_per_second = 10 max_miningshare_per_second = 100 max_inventory_per_second = 100 max_transaction_per_second = 100 -rate_limit_window_secs = 1 max_requests_per_second = 1 dial_timeout_secs = 30