0.0.2
This commit is contained in:
@@ -22,6 +22,7 @@ solana-address-lookup-table-interface.workspace = true
|
||||
solana-client.workspace = true
|
||||
solana-compute-budget-interface.workspace = true
|
||||
solana-rpc-client-api.workspace = true
|
||||
solana-rpc-client-types.workspace = true
|
||||
solana-sdk.workspace = true
|
||||
solana-sdk-ids.workspace = true
|
||||
solana-system-interface.workspace = true
|
||||
@@ -35,6 +36,7 @@ tokio.workspace = true
|
||||
tokio-stream.workspace = true
|
||||
tokio-tungstenite.workspace = true
|
||||
tracing.workspace = true
|
||||
tracing-appender.workspace = true
|
||||
tracing-subscriber.workspace = true
|
||||
yellowstone-grpc-client.workspace = true
|
||||
yellowstone-grpc-proto.workspace = true
|
||||
|
||||
455
kb_lib/src/config.rs
Normal file
455
kb_lib/src/config.rs
Normal file
@@ -0,0 +1,455 @@
|
||||
// file: kb_lib/src/config.rs
|
||||
|
||||
//! JSON configuration structures and loading helpers for `kb_lib`.
|
||||
|
||||
/// Root application configuration loaded from `config.json`.
|
||||
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
|
||||
pub struct KbConfig {
|
||||
/// Application-level metadata and global behavior.
|
||||
pub app: KbAppConfig,
|
||||
/// Tracing and log output configuration.
|
||||
pub logging: KbLoggingConfig,
|
||||
/// Data directory configuration.
|
||||
pub data: KbDataConfig,
|
||||
/// Solana endpoint configuration.
|
||||
pub solana: KbSolanaConfig,
|
||||
}
|
||||
|
||||
/// Generic application settings.
|
||||
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
|
||||
pub struct KbAppConfig {
|
||||
/// Human-readable application name.
|
||||
pub name: std::string::String,
|
||||
/// Current environment name such as `development` or `production`.
|
||||
pub environment: std::string::String,
|
||||
/// Default reconnection preference used by future UI settings.
|
||||
pub auto_reconnect_default: bool,
|
||||
}
|
||||
|
||||
/// Logging and tracing configuration.
|
||||
///
|
||||
/// In version `0.0.2`, the project actively uses:
|
||||
/// `level`, `console_enabled`, `console_ansi`, `file_enabled`,
|
||||
/// `directory`, `file_prefix`, and `rotation`.
|
||||
///
|
||||
/// The fields `message_format` and `time_format` are already stored in the
|
||||
/// configuration so that the format policy is stabilized early, even though
|
||||
/// their handling will be refined in later versions.
|
||||
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
|
||||
pub struct KbLoggingConfig {
|
||||
/// Global default log level.
|
||||
pub level: std::string::String,
|
||||
/// Enables console logging.
|
||||
pub console_enabled: bool,
|
||||
/// Enables ANSI colors on console output.
|
||||
pub console_ansi: bool,
|
||||
/// Enables file logging.
|
||||
pub file_enabled: bool,
|
||||
/// Directory where log files are stored.
|
||||
pub directory: std::string::String,
|
||||
/// Prefix used for log file names.
|
||||
pub file_prefix: std::string::String,
|
||||
/// File rotation strategy such as `daily`, `hourly`, or `never`.
|
||||
pub rotation: std::string::String,
|
||||
/// Preferred message formatting preset.
|
||||
pub message_format: std::string::String,
|
||||
/// Preferred time formatting preset.
|
||||
pub time_format: std::string::String,
|
||||
/// Per-target log level overrides.
|
||||
pub target_filters: std::collections::BTreeMap<std::string::String, std::string::String>,
|
||||
}
|
||||
|
||||
/// Local data paths used by the application.
|
||||
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
|
||||
pub struct KbDataConfig {
|
||||
/// SQLite database path.
|
||||
pub sqlite_path: std::string::String,
|
||||
/// Directory storing Solana wallets and related material in future versions.
|
||||
pub wallets_directory: std::string::String,
|
||||
}
|
||||
|
||||
/// Solana transport configuration.
|
||||
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
|
||||
pub struct KbSolanaConfig {
|
||||
/// Named HTTP endpoints.
|
||||
pub http_endpoints: std::vec::Vec<KbHttpEndpointConfig>,
|
||||
/// Named WebSocket endpoints.
|
||||
pub ws_endpoints: std::vec::Vec<KbWsEndpointConfig>,
|
||||
}
|
||||
|
||||
/// HTTP endpoint configuration.
|
||||
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
|
||||
pub struct KbHttpEndpointConfig {
|
||||
/// Stable internal endpoint name used by the application.
|
||||
pub name: std::string::String,
|
||||
/// Enables or disables the endpoint.
|
||||
pub enabled: bool,
|
||||
/// Provider name such as `solana-public`, `helius`, or `custom`.
|
||||
pub provider: std::string::String,
|
||||
/// Base HTTP RPC URL.
|
||||
pub url: std::string::String,
|
||||
/// Optional environment variable name used to resolve an API key later.
|
||||
pub api_key_env_var: std::option::Option<std::string::String>,
|
||||
/// Logical roles assigned to this endpoint.
|
||||
pub roles: std::vec::Vec<std::string::String>,
|
||||
/// Allowed average request rate.
|
||||
pub requests_per_second: u32,
|
||||
/// Burst capacity for future rate-limiting.
|
||||
pub burst: u32,
|
||||
/// HTTP connect timeout in milliseconds.
|
||||
pub connect_timeout_ms: u64,
|
||||
/// HTTP request timeout in milliseconds.
|
||||
pub request_timeout_ms: u64,
|
||||
}
|
||||
|
||||
/// WebSocket endpoint configuration.
|
||||
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
|
||||
pub struct KbWsEndpointConfig {
|
||||
/// Stable internal endpoint name used by the application.
|
||||
pub name: std::string::String,
|
||||
/// Enables or disables the endpoint.
|
||||
pub enabled: bool,
|
||||
/// Provider name such as `solana-public`, `helius`, or `custom`.
|
||||
pub provider: std::string::String,
|
||||
/// Base WebSocket RPC URL.
|
||||
pub url: std::string::String,
|
||||
/// Optional environment variable name used to resolve an API key later.
|
||||
pub api_key_env_var: std::option::Option<std::string::String>,
|
||||
/// Logical roles assigned to this endpoint.
|
||||
pub roles: std::vec::Vec<std::string::String>,
|
||||
/// Maximum number of subscriptions allowed on this endpoint.
|
||||
pub max_subscriptions: u32,
|
||||
/// WebSocket connect timeout in milliseconds.
|
||||
pub connect_timeout_ms: u64,
|
||||
/// Timeout for request/response round-trips in milliseconds.
|
||||
pub request_timeout_ms: u64,
|
||||
/// Timeout used during unsubscribe on disconnect in milliseconds.
|
||||
pub unsubscribe_timeout_ms: u64,
|
||||
/// Capacity of the future outgoing write channel.
|
||||
pub write_channel_capacity: usize,
|
||||
/// Capacity of the future event channel.
|
||||
pub event_channel_capacity: usize,
|
||||
/// Enables future automatic reconnection behavior.
|
||||
pub auto_reconnect: bool,
|
||||
}
|
||||
|
||||
impl KbConfig {
|
||||
/// Returns the default path of the JSON configuration file.
|
||||
pub fn default_path() -> std::path::PathBuf {
|
||||
kb_workspace_root_dir().join("config.json")
|
||||
}
|
||||
|
||||
/// Loads a configuration from a JSON file and validates it.
|
||||
pub fn load_from_path<P: AsRef<std::path::Path>>(path: P) -> Result<Self, crate::KbError> {
|
||||
let path_ref = path.as_ref();
|
||||
let content_result = std::fs::read_to_string(path_ref);
|
||||
let content = match content_result {
|
||||
Ok(content) => content,
|
||||
Err(error) => {
|
||||
return Err(crate::KbError::Io(format!(
|
||||
"cannot read configuration file '{}': {error}",
|
||||
path_ref.display()
|
||||
)));
|
||||
}
|
||||
};
|
||||
let config_result = serde_json::from_str::<Self>(&content);
|
||||
let config = match config_result {
|
||||
Ok(config) => config,
|
||||
Err(error) => {
|
||||
return Err(crate::KbError::Json(format!(
|
||||
"cannot parse configuration file '{}': {error}",
|
||||
path_ref.display()
|
||||
)));
|
||||
}
|
||||
};
|
||||
let validation_result = config.validate();
|
||||
match validation_result {
|
||||
Ok(()) => Ok(config),
|
||||
Err(error) => Err(error),
|
||||
}
|
||||
}
|
||||
|
||||
/// Validates the current configuration.
|
||||
pub fn validate(&self) -> Result<(), crate::KbError> {
|
||||
if self.app.name.trim().is_empty() {
|
||||
return Err(crate::KbError::Config(
|
||||
"app.name must not be empty".to_string(),
|
||||
));
|
||||
}
|
||||
if self.app.environment.trim().is_empty() {
|
||||
return Err(crate::KbError::Config(
|
||||
"app.environment must not be empty".to_string(),
|
||||
));
|
||||
}
|
||||
if self.logging.level.trim().is_empty() {
|
||||
return Err(crate::KbError::Config(
|
||||
"logging.level must not be empty".to_string(),
|
||||
));
|
||||
}
|
||||
if self.logging.directory.trim().is_empty() {
|
||||
return Err(crate::KbError::Config(
|
||||
"logging.directory must not be empty".to_string(),
|
||||
));
|
||||
}
|
||||
if self.logging.file_prefix.trim().is_empty() {
|
||||
return Err(crate::KbError::Config(
|
||||
"logging.file_prefix must not be empty".to_string(),
|
||||
));
|
||||
}
|
||||
if self.data.sqlite_path.trim().is_empty() {
|
||||
return Err(crate::KbError::Config(
|
||||
"data.sqlite_path must not be empty".to_string(),
|
||||
));
|
||||
}
|
||||
if self.data.wallets_directory.trim().is_empty() {
|
||||
return Err(crate::KbError::Config(
|
||||
"data.wallets_directory must not be empty".to_string(),
|
||||
));
|
||||
}
|
||||
if self.logging.rotation != "daily"
|
||||
&& self.logging.rotation != "hourly"
|
||||
&& self.logging.rotation != "never"
|
||||
{
|
||||
return Err(crate::KbError::Config(format!(
|
||||
"unsupported logging.rotation '{}'",
|
||||
self.logging.rotation
|
||||
)));
|
||||
}
|
||||
if self.logging.message_format != "full"
|
||||
&& self.logging.message_format != "compact"
|
||||
&& self.logging.message_format != "pretty"
|
||||
&& self.logging.message_format != "json"
|
||||
{
|
||||
return Err(crate::KbError::Config(format!(
|
||||
"unsupported logging.message_format '{}'",
|
||||
self.logging.message_format
|
||||
)));
|
||||
}
|
||||
if self.logging.time_format != "rfc3339"
|
||||
&& self.logging.time_format != "rfc3339_millis"
|
||||
&& self.logging.time_format != "none"
|
||||
{
|
||||
return Err(crate::KbError::Config(format!(
|
||||
"unsupported logging.time_format '{}'",
|
||||
self.logging.time_format
|
||||
)));
|
||||
}
|
||||
let mut endpoint_names: std::vec::Vec<std::string::String> = std::vec::Vec::new();
|
||||
for endpoint in &self.solana.http_endpoints {
|
||||
let validation_result = self.validate_http_endpoint(endpoint, &mut endpoint_names);
|
||||
if let Err(error) = validation_result {
|
||||
return Err(error);
|
||||
}
|
||||
}
|
||||
for endpoint in &self.solana.ws_endpoints {
|
||||
let validation_result = self.validate_ws_endpoint(endpoint, &mut endpoint_names);
|
||||
if let Err(error) = validation_result {
|
||||
return Err(error);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Creates the basic runtime directories required by the current configuration.
|
||||
pub fn prepare_filesystem(&self) -> Result<(), crate::KbError> {
|
||||
let logging_directory = self.logging.directory_path();
|
||||
let create_logs_result = std::fs::create_dir_all(&logging_directory);
|
||||
if let Err(error) = create_logs_result {
|
||||
return Err(crate::KbError::Io(format!(
|
||||
"cannot create logging directory '{}': {error}",
|
||||
logging_directory.display()
|
||||
)));
|
||||
}
|
||||
let wallets_directory = self.data.wallets_directory_path();
|
||||
let create_wallets_result = std::fs::create_dir_all(&wallets_directory);
|
||||
if let Err(error) = create_wallets_result {
|
||||
return Err(crate::KbError::Io(format!(
|
||||
"cannot create wallets directory '{}': {error}",
|
||||
wallets_directory.display()
|
||||
)));
|
||||
}
|
||||
let sqlite_path = self.data.sqlite_path_buf();
|
||||
let sqlite_parent_option = sqlite_path.parent();
|
||||
if let Some(sqlite_parent) = sqlite_parent_option {
|
||||
if !sqlite_parent.as_os_str().is_empty() {
|
||||
let create_db_parent_result = std::fs::create_dir_all(sqlite_parent);
|
||||
if let Err(error) = create_db_parent_result {
|
||||
return Err(crate::KbError::Io(format!(
|
||||
"cannot create database parent directory '{}': {error}",
|
||||
sqlite_parent.display()
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Returns a named HTTP endpoint by reference.
|
||||
pub fn find_http_endpoint(
|
||||
&self,
|
||||
endpoint_name: &str,
|
||||
) -> std::option::Option<&KbHttpEndpointConfig> {
|
||||
self.solana
|
||||
.http_endpoints
|
||||
.iter()
|
||||
.find(|endpoint| endpoint.name == endpoint_name)
|
||||
}
|
||||
|
||||
/// Returns a named WebSocket endpoint by reference.
|
||||
pub fn find_ws_endpoint(
|
||||
&self,
|
||||
endpoint_name: &str,
|
||||
) -> std::option::Option<&KbWsEndpointConfig> {
|
||||
self.solana
|
||||
.ws_endpoints
|
||||
.iter()
|
||||
.find(|endpoint| endpoint.name == endpoint_name)
|
||||
}
|
||||
|
||||
fn validate_http_endpoint(
|
||||
&self,
|
||||
endpoint: &KbHttpEndpointConfig,
|
||||
endpoint_names: &mut std::vec::Vec<std::string::String>,
|
||||
) -> Result<(), crate::KbError> {
|
||||
if endpoint.name.trim().is_empty() {
|
||||
return Err(crate::KbError::Config(
|
||||
"http endpoint name must not be empty".to_string(),
|
||||
));
|
||||
}
|
||||
if endpoint_names.iter().any(|name| name == &endpoint.name) {
|
||||
return Err(crate::KbError::Config(format!(
|
||||
"duplicated endpoint name '{}'",
|
||||
endpoint.name
|
||||
)));
|
||||
}
|
||||
if !endpoint.url.starts_with("http://") && !endpoint.url.starts_with("https://") {
|
||||
return Err(crate::KbError::Config(format!(
|
||||
"http endpoint '{}' must start with http:// or https://",
|
||||
endpoint.name
|
||||
)));
|
||||
}
|
||||
if endpoint.requests_per_second == 0 {
|
||||
return Err(crate::KbError::Config(format!(
|
||||
"http endpoint '{}' requests_per_second must be > 0",
|
||||
endpoint.name
|
||||
)));
|
||||
}
|
||||
if endpoint.burst == 0 {
|
||||
return Err(crate::KbError::Config(format!(
|
||||
"http endpoint '{}' burst must be > 0",
|
||||
endpoint.name
|
||||
)));
|
||||
}
|
||||
if endpoint.connect_timeout_ms == 0 {
|
||||
return Err(crate::KbError::Config(format!(
|
||||
"http endpoint '{}' connect_timeout_ms must be > 0",
|
||||
endpoint.name
|
||||
)));
|
||||
}
|
||||
if endpoint.request_timeout_ms == 0 {
|
||||
return Err(crate::KbError::Config(format!(
|
||||
"http endpoint '{}' request_timeout_ms must be > 0",
|
||||
endpoint.name
|
||||
)));
|
||||
}
|
||||
endpoint_names.push(endpoint.name.clone());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_ws_endpoint(
|
||||
&self,
|
||||
endpoint: &KbWsEndpointConfig,
|
||||
endpoint_names: &mut std::vec::Vec<std::string::String>,
|
||||
) -> Result<(), crate::KbError> {
|
||||
if endpoint.name.trim().is_empty() {
|
||||
return Err(crate::KbError::Config(
|
||||
"ws endpoint name must not be empty".to_string(),
|
||||
));
|
||||
}
|
||||
if endpoint_names.iter().any(|name| name == &endpoint.name) {
|
||||
return Err(crate::KbError::Config(format!(
|
||||
"duplicated endpoint name '{}'",
|
||||
endpoint.name
|
||||
)));
|
||||
}
|
||||
if !endpoint.url.starts_with("ws://") && !endpoint.url.starts_with("wss://") {
|
||||
return Err(crate::KbError::Config(format!(
|
||||
"ws endpoint '{}' must start with ws:// or wss://",
|
||||
endpoint.name
|
||||
)));
|
||||
}
|
||||
if endpoint.max_subscriptions == 0 {
|
||||
return Err(crate::KbError::Config(format!(
|
||||
"ws endpoint '{}' max_subscriptions must be > 0",
|
||||
endpoint.name
|
||||
)));
|
||||
}
|
||||
if endpoint.connect_timeout_ms == 0 {
|
||||
return Err(crate::KbError::Config(format!(
|
||||
"ws endpoint '{}' connect_timeout_ms must be > 0",
|
||||
endpoint.name
|
||||
)));
|
||||
}
|
||||
if endpoint.request_timeout_ms == 0 {
|
||||
return Err(crate::KbError::Config(format!(
|
||||
"ws endpoint '{}' request_timeout_ms must be > 0",
|
||||
endpoint.name
|
||||
)));
|
||||
}
|
||||
if endpoint.unsubscribe_timeout_ms == 0 {
|
||||
return Err(crate::KbError::Config(format!(
|
||||
"ws endpoint '{}' unsubscribe_timeout_ms must be > 0",
|
||||
endpoint.name
|
||||
)));
|
||||
}
|
||||
if endpoint.write_channel_capacity == 0 {
|
||||
return Err(crate::KbError::Config(format!(
|
||||
"ws endpoint '{}' write_channel_capacity must be > 0",
|
||||
endpoint.name
|
||||
)));
|
||||
}
|
||||
if endpoint.event_channel_capacity == 0 {
|
||||
return Err(crate::KbError::Config(format!(
|
||||
"ws endpoint '{}' event_channel_capacity must be > 0",
|
||||
endpoint.name
|
||||
)));
|
||||
}
|
||||
endpoint_names.push(endpoint.name.clone());
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn kb_workspace_root_dir() -> std::path::PathBuf {
|
||||
let manifest_dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"));
|
||||
match manifest_dir.parent() {
|
||||
Some(parent) => parent.to_path_buf(),
|
||||
None => manifest_dir,
|
||||
}
|
||||
}
|
||||
|
||||
fn kb_resolve_workspace_relative_path<P: AsRef<std::path::Path>>(path: P) -> std::path::PathBuf {
|
||||
let input_path = std::path::PathBuf::from(path.as_ref());
|
||||
if input_path.is_absolute() {
|
||||
return input_path;
|
||||
}
|
||||
kb_workspace_root_dir().join(input_path)
|
||||
}
|
||||
|
||||
impl KbLoggingConfig {
|
||||
/// Returns the resolved logging directory path.
|
||||
pub fn directory_path(&self) -> std::path::PathBuf {
|
||||
kb_resolve_workspace_relative_path(&self.directory)
|
||||
}
|
||||
}
|
||||
|
||||
impl KbDataConfig {
|
||||
/// Returns the resolved SQLite database path.
|
||||
pub fn sqlite_path_buf(&self) -> std::path::PathBuf {
|
||||
kb_resolve_workspace_relative_path(&self.sqlite_path)
|
||||
}
|
||||
|
||||
/// Returns the resolved wallets directory path.
|
||||
pub fn wallets_directory_path(&self) -> std::path::PathBuf {
|
||||
kb_resolve_workspace_relative_path(&self.wallets_directory)
|
||||
}
|
||||
}
|
||||
23
kb_lib/src/constants.rs
Normal file
23
kb_lib/src/constants.rs
Normal file
@@ -0,0 +1,23 @@
|
||||
// file: kb_lib/src/constants.rs
|
||||
|
||||
//! Solana program and mint constants reused by the project.
|
||||
|
||||
/// SPL Token program identifier.
|
||||
pub const SPL_TOKEN_PROGRAM_ID: solana_sdk::pubkey::Pubkey = spl_token_interface::ID;
|
||||
|
||||
/// SPL Token-2022 program identifier.
|
||||
pub const SPL_TOKEN_2022_PROGRAM_ID: solana_sdk::pubkey::Pubkey = spl_token_2022_interface::ID;
|
||||
|
||||
/// Associated Token Account program identifier.
|
||||
pub const ASSOCIATED_TOKEN_PROGRAM_ID: solana_sdk::pubkey::Pubkey =
|
||||
spl_associated_token_account_interface::program::ID;
|
||||
|
||||
/// Wrapped SOL mint identifier.
|
||||
pub const WSOL_MINT_ID: solana_sdk::pubkey::Pubkey = spl_token_interface::native_mint::ID;
|
||||
|
||||
/// System program identifier.
|
||||
pub const SYSTEM_PROGRAM_ID: solana_sdk::pubkey::Pubkey = solana_sdk_ids::system_program::ID;
|
||||
|
||||
/// Compute Budget program identifier.
|
||||
pub const COMPUTE_BUDGET_PROGRAM_ID: solana_sdk::pubkey::Pubkey =
|
||||
solana_sdk_ids::compute_budget::ID;
|
||||
68
kb_lib/src/error.rs
Normal file
68
kb_lib/src/error.rs
Normal file
@@ -0,0 +1,68 @@
|
||||
// file: kb_lib/src/error.rs
|
||||
|
||||
//! Shared error type for `kb_lib`.
|
||||
|
||||
/// Global error type used by the `kb_lib` crate.
|
||||
///
|
||||
/// The project intentionally avoids `anyhow` and `thiserror`, so this
|
||||
/// enum centralizes the main error families with explicit textual messages.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum KbError {
|
||||
/// Configuration error.
|
||||
Config(std::string::String),
|
||||
/// Filesystem or standard I/O error.
|
||||
Io(std::string::String),
|
||||
/// JSON serialization or deserialization error.
|
||||
Json(std::string::String),
|
||||
/// Tracing initialization or logging error.
|
||||
Tracing(std::string::String),
|
||||
/// HTTP transport error.
|
||||
Http(std::string::String),
|
||||
/// WebSocket transport error.
|
||||
Ws(std::string::String),
|
||||
/// Invalid internal state error.
|
||||
InvalidState(std::string::String),
|
||||
/// Operation requested while the client is not connected.
|
||||
NotConnected(std::string::String),
|
||||
/// Placeholder for a feature intentionally scheduled for a later version.
|
||||
NotImplemented(std::string::String),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for KbError {
|
||||
fn fmt(
|
||||
&self,
|
||||
formatter: &mut std::fmt::Formatter<'_>,
|
||||
) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::Config(message) => {
|
||||
write!(formatter, "configuration error: {message}")
|
||||
},
|
||||
Self::Io(message) => {
|
||||
write!(formatter, "io error: {message}")
|
||||
},
|
||||
Self::Json(message) => {
|
||||
write!(formatter, "json error: {message}")
|
||||
},
|
||||
Self::Tracing(message) => {
|
||||
write!(formatter, "tracing error: {message}")
|
||||
},
|
||||
Self::Http(message) => {
|
||||
write!(formatter, "http error: {message}")
|
||||
},
|
||||
Self::Ws(message) => {
|
||||
write!(formatter, "websocket error: {message}")
|
||||
},
|
||||
Self::InvalidState(message) => {
|
||||
write!(formatter, "invalid state: {message}")
|
||||
},
|
||||
Self::NotConnected(message) => {
|
||||
write!(formatter, "not connected: {message}")
|
||||
},
|
||||
Self::NotImplemented(message) => {
|
||||
write!(formatter, "not implemented: {message}")
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for KbError {}
|
||||
73
kb_lib/src/http_client.rs
Normal file
73
kb_lib/src/http_client.rs
Normal file
@@ -0,0 +1,73 @@
|
||||
// file: kb_lib/src/http_client.rs
|
||||
|
||||
//! Generic asynchronous HTTP client skeleton.
|
||||
//!
|
||||
//! The transport is intentionally minimal in `0.0.2`. Endpoint binding and
|
||||
//! client construction are stabilized now, while JSON-RPC request execution,
|
||||
//! throttling, and batching are scheduled for `0.4.x`.
|
||||
|
||||
/// Generic asynchronous HTTP client placeholder.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct HttpClient {
|
||||
endpoint: crate::KbHttpEndpointConfig,
|
||||
client: reqwest::Client,
|
||||
}
|
||||
|
||||
impl HttpClient {
|
||||
/// Creates a new HTTP client bound to a named endpoint configuration.
|
||||
pub fn new(endpoint: crate::KbHttpEndpointConfig) -> Result<Self, crate::KbError> {
|
||||
if endpoint.name.trim().is_empty() {
|
||||
return Err(crate::KbError::Config(
|
||||
"http client endpoint name must not be empty".to_string(),
|
||||
));
|
||||
}
|
||||
let builder = reqwest::Client::builder()
|
||||
.connect_timeout(std::time::Duration::from_millis(
|
||||
endpoint.connect_timeout_ms,
|
||||
))
|
||||
.timeout(std::time::Duration::from_millis(
|
||||
endpoint.request_timeout_ms,
|
||||
));
|
||||
let client_result = builder.build();
|
||||
let client = match client_result {
|
||||
Ok(client) => client,
|
||||
Err(error) => {
|
||||
return Err(crate::KbError::Http(format!(
|
||||
"cannot build reqwest client for endpoint '{}': {error}",
|
||||
endpoint.name
|
||||
)));
|
||||
}
|
||||
};
|
||||
Ok(Self { endpoint, client })
|
||||
}
|
||||
|
||||
/// Returns the endpoint name of this client.
|
||||
pub fn endpoint_name(&self) -> &str {
|
||||
&self.endpoint.name
|
||||
}
|
||||
|
||||
/// Returns the endpoint URL of this client.
|
||||
pub fn endpoint_url(&self) -> &str {
|
||||
&self.endpoint.url
|
||||
}
|
||||
|
||||
/// Returns the endpoint configuration of this client.
|
||||
pub fn endpoint_config(&self) -> &crate::KbHttpEndpointConfig {
|
||||
&self.endpoint
|
||||
}
|
||||
|
||||
/// Returns the underlying reqwest client reference.
|
||||
pub fn raw_client(&self) -> &reqwest::Client {
|
||||
&self.client
|
||||
}
|
||||
|
||||
/// Sends a JSON-RPC payload.
|
||||
pub async fn send_json_rpc_request(
|
||||
&self,
|
||||
_payload: &serde_json::Value,
|
||||
) -> Result<serde_json::Value, crate::KbError> {
|
||||
Err(crate::KbError::NotImplemented(
|
||||
"HttpClient::send_json_rpc_request is scheduled for version 0.4.x".to_string(),
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,34 @@
|
||||
pub fn add(left: u64, right: u64) -> u64 {
|
||||
left + right
|
||||
}
|
||||
// file: kb_lib/src/lib.rs
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
//! Core library of the `khadhroony-bobobot` workspace.
|
||||
//!
|
||||
//! This crate contains the reusable backend logic shared by the desktop
|
||||
//! application and future clients. The first milestone focuses on a
|
||||
//! conformant project skeleton with configuration loading, tracing setup,
|
||||
//! shared constants, and transport client placeholders.
|
||||
|
||||
#[test]
|
||||
fn it_works() {
|
||||
let result = add(2, 2);
|
||||
assert_eq!(result, 4);
|
||||
}
|
||||
}
|
||||
#![deny(unreachable_pub)]
|
||||
#![warn(missing_docs)]
|
||||
|
||||
mod config;
|
||||
mod constants;
|
||||
mod error;
|
||||
mod http_client;
|
||||
mod tracing;
|
||||
mod types;
|
||||
mod ws_client;
|
||||
|
||||
pub use crate::config::KbAppConfig;
|
||||
pub use crate::config::KbConfig;
|
||||
pub use crate::config::KbDataConfig;
|
||||
pub use crate::config::KbHttpEndpointConfig;
|
||||
pub use crate::config::KbLoggingConfig;
|
||||
pub use crate::config::KbSolanaConfig;
|
||||
pub use crate::config::KbWsEndpointConfig;
|
||||
pub use crate::constants::*;
|
||||
pub use crate::error::KbError;
|
||||
pub use crate::http_client::HttpClient;
|
||||
pub use crate::tracing::KbTracingGuard;
|
||||
pub use crate::tracing::init_tracing;
|
||||
pub use crate::types::KbConnectionState;
|
||||
pub use crate::ws_client::WsClient;
|
||||
|
||||
536
kb_lib/src/tracing.rs
Normal file
536
kb_lib/src/tracing.rs
Normal file
@@ -0,0 +1,536 @@
|
||||
// file: kb_lib/src/tracing.rs
|
||||
|
||||
//! Tracing initialization helpers for `kb_lib`.
|
||||
//!
|
||||
//! This module initializes `tracing_subscriber` using the shared JSON
|
||||
//! configuration loaded by `kb_lib::KbConfig`.
|
||||
//!
|
||||
//! The implementation intentionally avoids dynamic boxed layers because they
|
||||
//! become awkward once additional layers are stacked on the registry.
|
||||
//! Instead, it follows an explicit branch-per-output strategy similar to the
|
||||
//! reference implementation you provided.
|
||||
|
||||
use tracing_subscriber::layer::SubscriberExt;
|
||||
use tracing_subscriber::util::SubscriberInitExt;
|
||||
|
||||
/// Guard keeping non-blocking tracing workers alive.
|
||||
///
|
||||
/// The guard must remain alive during the whole application lifetime so that
|
||||
/// buffered log records are flushed correctly.
|
||||
#[derive(Debug)]
|
||||
pub struct KbTracingGuard {
|
||||
guards: std::vec::Vec<tracing_appender::non_blocking::WorkerGuard>,
|
||||
}
|
||||
|
||||
impl KbTracingGuard {
|
||||
/// Returns the number of active tracing worker guards.
|
||||
pub fn guard_count(&self) -> usize {
|
||||
self.guards.len()
|
||||
}
|
||||
}
|
||||
|
||||
/// Initializes the global tracing subscriber.
|
||||
///
|
||||
/// This function is expected to be called once at application startup.
|
||||
pub fn init_tracing(config: &crate::KbLoggingConfig) -> Result<KbTracingGuard, crate::KbError> {
|
||||
let filter_expression = build_filter_expression(config);
|
||||
let timer_format = resolve_time_format(&config.time_format);
|
||||
|
||||
match (config.console_enabled, config.file_enabled) {
|
||||
(true, false) => {
|
||||
let (stdout_non_blocking, stdout_guard) =
|
||||
tracing_appender::non_blocking(std::io::stdout());
|
||||
let init_result = init_single_layer_subscriber(
|
||||
&filter_expression,
|
||||
&timer_format,
|
||||
&config.message_format,
|
||||
config.console_ansi,
|
||||
stdout_non_blocking,
|
||||
);
|
||||
match init_result {
|
||||
Ok(()) => Ok(KbTracingGuard {
|
||||
guards: vec![stdout_guard],
|
||||
}),
|
||||
Err(error) => Err(error),
|
||||
}
|
||||
}
|
||||
(false, true) => {
|
||||
let file_writer_result = build_file_writer(config);
|
||||
let (file_non_blocking, file_guard) = match file_writer_result {
|
||||
Ok(parts) => parts,
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
let init_result = init_single_layer_subscriber(
|
||||
&filter_expression,
|
||||
&timer_format,
|
||||
&config.message_format,
|
||||
false,
|
||||
file_non_blocking,
|
||||
);
|
||||
match init_result {
|
||||
Ok(()) => Ok(KbTracingGuard {
|
||||
guards: vec![file_guard],
|
||||
}),
|
||||
Err(error) => Err(error),
|
||||
}
|
||||
}
|
||||
(true, true) => {
|
||||
let (stdout_non_blocking, stdout_guard) =
|
||||
tracing_appender::non_blocking(std::io::stdout());
|
||||
|
||||
let file_writer_result = build_file_writer(config);
|
||||
let (file_non_blocking, file_guard) = match file_writer_result {
|
||||
Ok(parts) => parts,
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
let init_result = init_dual_layer_subscriber(
|
||||
&filter_expression,
|
||||
&timer_format,
|
||||
&config.message_format,
|
||||
config.console_ansi,
|
||||
stdout_non_blocking,
|
||||
file_non_blocking,
|
||||
);
|
||||
match init_result {
|
||||
Ok(()) => Ok(KbTracingGuard {
|
||||
guards: vec![stdout_guard, file_guard],
|
||||
}),
|
||||
Err(error) => Err(error),
|
||||
}
|
||||
}
|
||||
(false, false) => {
|
||||
let env_filter_result = build_env_filter(&filter_expression);
|
||||
let env_filter = match env_filter_result {
|
||||
Ok(env_filter) => env_filter,
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
let init_result = tracing_subscriber::registry().with(env_filter).try_init();
|
||||
match init_result {
|
||||
Ok(()) => Ok(KbTracingGuard { guards: vec![] }),
|
||||
Err(error) => Err(crate::KbError::Tracing(format!(
|
||||
"cannot initialize tracing subscriber: {error}"
|
||||
))),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn build_filter_expression(config: &crate::KbLoggingConfig) -> std::string::String {
|
||||
let mut directives = std::vec::Vec::<std::string::String>::new();
|
||||
directives.push(config.level.clone());
|
||||
for (target, level) in &config.target_filters {
|
||||
directives.push(format!("{target}={level}"));
|
||||
}
|
||||
directives.join(",")
|
||||
}
|
||||
|
||||
fn build_env_filter(
|
||||
filter_expression: &str,
|
||||
) -> Result<tracing_subscriber::EnvFilter, crate::KbError> {
|
||||
let from_env_result = tracing_subscriber::EnvFilter::try_from_default_env();
|
||||
match from_env_result {
|
||||
Ok(env_filter) => Ok(env_filter),
|
||||
Err(_) => {
|
||||
let new_result = tracing_subscriber::EnvFilter::try_new(filter_expression);
|
||||
match new_result {
|
||||
Ok(env_filter) => Ok(env_filter),
|
||||
Err(error) => Err(crate::KbError::Tracing(format!(
|
||||
"cannot build env filter from '{filter_expression}': {error}"
|
||||
))),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_time_format(value: &str) -> std::string::String {
|
||||
if value == "rfc3339" {
|
||||
return "%+".to_string();
|
||||
}
|
||||
if value == "rfc3339_millis" {
|
||||
return "%Y-%m-%dT%H:%M:%S%.3f%:z".to_string();
|
||||
}
|
||||
if value == "none" {
|
||||
return "%+".to_string();
|
||||
}
|
||||
"%+".to_string()
|
||||
}
|
||||
|
||||
fn build_file_writer(
|
||||
config: &crate::KbLoggingConfig,
|
||||
) -> Result<
|
||||
(
|
||||
KbStripAnsiMakeWriter<tracing_appender::non_blocking::NonBlocking>,
|
||||
tracing_appender::non_blocking::WorkerGuard,
|
||||
),
|
||||
crate::KbError,
|
||||
> {
|
||||
let directory_path = config.directory_path();
|
||||
let create_dir_result = std::fs::create_dir_all(&directory_path);
|
||||
if let Err(error) = create_dir_result {
|
||||
return Err(crate::KbError::Tracing(format!(
|
||||
"cannot create log directory '{}': {error}",
|
||||
directory_path.display()
|
||||
)));
|
||||
}
|
||||
let rotation = if config.rotation == "hourly" {
|
||||
tracing_appender::rolling::Rotation::HOURLY
|
||||
} else if config.rotation == "never" {
|
||||
tracing_appender::rolling::Rotation::NEVER
|
||||
} else {
|
||||
tracing_appender::rolling::Rotation::DAILY
|
||||
};
|
||||
let appender_result = tracing_appender::rolling::Builder::default()
|
||||
.rotation(rotation)
|
||||
.filename_prefix(config.file_prefix.clone())
|
||||
.filename_suffix("log")
|
||||
.build(&directory_path);
|
||||
let file_appender = match appender_result {
|
||||
Ok(file_appender) => file_appender,
|
||||
Err(error) => {
|
||||
return Err(crate::KbError::Tracing(format!(
|
||||
"cannot build rolling file appender in '{}': {error}",
|
||||
directory_path.display()
|
||||
)));
|
||||
}
|
||||
};
|
||||
let (non_blocking, guard) = tracing_appender::non_blocking(file_appender);
|
||||
Ok((KbStripAnsiMakeWriter::new(non_blocking), guard))
|
||||
}
|
||||
|
||||
fn init_single_layer_subscriber<W>(
|
||||
filter_expression: &str,
|
||||
timer_format: &str,
|
||||
message_format: &str,
|
||||
ansi: bool,
|
||||
writer: W,
|
||||
) -> Result<(), crate::KbError>
|
||||
where
|
||||
W: for<'writer> tracing_subscriber::fmt::MakeWriter<'writer>
|
||||
+ std::marker::Send
|
||||
+ std::marker::Sync
|
||||
+ 'static,
|
||||
{
|
||||
let env_filter_result = build_env_filter(filter_expression);
|
||||
let env_filter = match env_filter_result {
|
||||
Ok(env_filter) => env_filter,
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
let timer = tracing_subscriber::fmt::time::ChronoLocal::new(timer_format.to_string());
|
||||
let init_result = if message_format == "compact" {
|
||||
tracing_subscriber::registry()
|
||||
.with(env_filter)
|
||||
.with(
|
||||
tracing_subscriber::fmt::layer()
|
||||
.compact()
|
||||
.with_timer(timer)
|
||||
.with_ansi(ansi)
|
||||
.with_target(true)
|
||||
.with_thread_ids(false)
|
||||
.with_thread_names(false)
|
||||
.with_writer(writer),
|
||||
)
|
||||
.try_init()
|
||||
} else if message_format == "pretty" {
|
||||
tracing_subscriber::registry()
|
||||
.with(env_filter)
|
||||
.with(
|
||||
tracing_subscriber::fmt::layer()
|
||||
.pretty()
|
||||
.with_timer(timer)
|
||||
.with_ansi(ansi)
|
||||
.with_target(true)
|
||||
.with_thread_ids(false)
|
||||
.with_thread_names(false)
|
||||
.with_writer(writer),
|
||||
)
|
||||
.try_init()
|
||||
} else if message_format == "json" {
|
||||
tracing_subscriber::registry()
|
||||
.with(env_filter)
|
||||
.with(
|
||||
tracing_subscriber::fmt::layer()
|
||||
.json()
|
||||
.with_timer(timer)
|
||||
.with_ansi(false)
|
||||
.with_target(true)
|
||||
.with_thread_ids(false)
|
||||
.with_thread_names(false)
|
||||
.with_writer(writer),
|
||||
)
|
||||
.try_init()
|
||||
} else {
|
||||
tracing_subscriber::registry()
|
||||
.with(env_filter)
|
||||
.with(
|
||||
tracing_subscriber::fmt::layer()
|
||||
.with_timer(timer)
|
||||
.with_ansi(ansi)
|
||||
.with_target(true)
|
||||
.with_thread_ids(false)
|
||||
.with_thread_names(false)
|
||||
.with_writer(writer),
|
||||
)
|
||||
.try_init()
|
||||
};
|
||||
match init_result {
|
||||
Ok(()) => Ok(()),
|
||||
Err(error) => Err(crate::KbError::Tracing(format!(
|
||||
"cannot initialize tracing subscriber: {error}"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
fn init_dual_layer_subscriber<W1, W2>(
|
||||
filter_expression: &str,
|
||||
timer_format: &str,
|
||||
message_format: &str,
|
||||
console_ansi: bool,
|
||||
stdout_writer: W1,
|
||||
file_writer: W2,
|
||||
) -> Result<(), crate::KbError>
|
||||
where
|
||||
W1: for<'writer> tracing_subscriber::fmt::MakeWriter<'writer>
|
||||
+ std::marker::Send
|
||||
+ std::marker::Sync
|
||||
+ 'static,
|
||||
W2: for<'writer> tracing_subscriber::fmt::MakeWriter<'writer>
|
||||
+ std::marker::Send
|
||||
+ std::marker::Sync
|
||||
+ 'static,
|
||||
{
|
||||
let env_filter_result = build_env_filter(filter_expression);
|
||||
let env_filter = match env_filter_result {
|
||||
Ok(env_filter) => env_filter,
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
let timer_stdout = tracing_subscriber::fmt::time::ChronoLocal::new(timer_format.to_string());
|
||||
let timer_file = tracing_subscriber::fmt::time::ChronoLocal::new(timer_format.to_string());
|
||||
let init_result = if message_format == "compact" {
|
||||
tracing_subscriber::registry()
|
||||
.with(env_filter)
|
||||
.with(
|
||||
tracing_subscriber::fmt::layer()
|
||||
.compact()
|
||||
.with_timer(timer_stdout)
|
||||
.with_ansi(console_ansi)
|
||||
.with_target(true)
|
||||
.with_thread_ids(false)
|
||||
.with_thread_names(false)
|
||||
.with_writer(stdout_writer),
|
||||
)
|
||||
.with(
|
||||
tracing_subscriber::fmt::layer()
|
||||
.compact()
|
||||
.with_timer(timer_file)
|
||||
.with_ansi(false)
|
||||
.with_target(true)
|
||||
.with_thread_ids(false)
|
||||
.with_thread_names(false)
|
||||
.with_writer(file_writer),
|
||||
)
|
||||
.try_init()
|
||||
} else if message_format == "pretty" {
|
||||
tracing_subscriber::registry()
|
||||
.with(env_filter)
|
||||
.with(
|
||||
tracing_subscriber::fmt::layer()
|
||||
.pretty()
|
||||
.with_timer(timer_stdout)
|
||||
.with_ansi(console_ansi)
|
||||
.with_target(true)
|
||||
.with_thread_ids(false)
|
||||
.with_thread_names(false)
|
||||
.with_writer(stdout_writer),
|
||||
)
|
||||
.with(
|
||||
tracing_subscriber::fmt::layer()
|
||||
.pretty()
|
||||
.with_timer(timer_file)
|
||||
.with_ansi(false)
|
||||
.with_target(true)
|
||||
.with_thread_ids(false)
|
||||
.with_thread_names(false)
|
||||
.with_writer(file_writer),
|
||||
)
|
||||
.try_init()
|
||||
} else if message_format == "json" {
|
||||
tracing_subscriber::registry()
|
||||
.with(env_filter)
|
||||
.with(
|
||||
tracing_subscriber::fmt::layer()
|
||||
.json()
|
||||
.with_timer(timer_stdout)
|
||||
.with_ansi(false)
|
||||
.with_target(true)
|
||||
.with_thread_ids(false)
|
||||
.with_thread_names(false)
|
||||
.with_writer(stdout_writer),
|
||||
)
|
||||
.with(
|
||||
tracing_subscriber::fmt::layer()
|
||||
.json()
|
||||
.with_timer(timer_file)
|
||||
.with_ansi(false)
|
||||
.with_target(true)
|
||||
.with_thread_ids(false)
|
||||
.with_thread_names(false)
|
||||
.with_writer(file_writer),
|
||||
)
|
||||
.try_init()
|
||||
} else {
|
||||
tracing_subscriber::registry()
|
||||
.with(env_filter)
|
||||
.with(
|
||||
tracing_subscriber::fmt::layer()
|
||||
.with_timer(timer_stdout)
|
||||
.with_ansi(console_ansi)
|
||||
.with_target(true)
|
||||
.with_thread_ids(false)
|
||||
.with_thread_names(false)
|
||||
.with_writer(stdout_writer),
|
||||
)
|
||||
.with(
|
||||
tracing_subscriber::fmt::layer()
|
||||
.with_timer(timer_file)
|
||||
.with_ansi(false)
|
||||
.with_target(true)
|
||||
.with_thread_ids(false)
|
||||
.with_thread_names(false)
|
||||
.with_writer(file_writer),
|
||||
)
|
||||
.try_init()
|
||||
};
|
||||
match init_result {
|
||||
Ok(()) => Ok(()),
|
||||
Err(error) => Err(crate::KbError::Tracing(format!(
|
||||
"cannot initialize tracing subscriber: {error}"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
struct KbStripAnsiMakeWriter<W> {
|
||||
inner: W,
|
||||
}
|
||||
|
||||
impl<W> KbStripAnsiMakeWriter<W> {
|
||||
fn new(inner: W) -> Self {
|
||||
Self { inner }
|
||||
}
|
||||
}
|
||||
|
||||
struct KbStripAnsiWriter<W> {
|
||||
inner: W,
|
||||
}
|
||||
|
||||
impl<'a, W> tracing_subscriber::fmt::MakeWriter<'a> for KbStripAnsiMakeWriter<W>
|
||||
where
|
||||
W: tracing_subscriber::fmt::MakeWriter<'a>,
|
||||
{
|
||||
type Writer = KbStripAnsiWriter<W::Writer>;
|
||||
|
||||
fn make_writer(&'a self) -> Self::Writer {
|
||||
KbStripAnsiWriter {
|
||||
inner: self.inner.make_writer(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<W> std::io::Write for KbStripAnsiWriter<W>
|
||||
where
|
||||
W: std::io::Write,
|
||||
{
|
||||
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
|
||||
let stripped = kb_strip_ansi_bytes(buf);
|
||||
let write_result = self.inner.write_all(&stripped);
|
||||
|
||||
match write_result {
|
||||
Ok(()) => Ok(buf.len()),
|
||||
Err(error) => Err(error),
|
||||
}
|
||||
}
|
||||
|
||||
fn flush(&mut self) -> std::io::Result<()> {
|
||||
self.inner.flush()
|
||||
}
|
||||
}
|
||||
|
||||
fn kb_strip_ansi_bytes(input: &[u8]) -> std::vec::Vec<u8> {
|
||||
let mut output = std::vec::Vec::with_capacity(input.len());
|
||||
let mut index = 0usize;
|
||||
while index < input.len() {
|
||||
let byte = input[index];
|
||||
if byte == 0x1B {
|
||||
if index + 1 < input.len() && input[index + 1] == b'[' {
|
||||
index += 2;
|
||||
while index < input.len() {
|
||||
let current = input[index];
|
||||
if (0x40..=0x7E).contains(¤t) {
|
||||
index += 1;
|
||||
break;
|
||||
}
|
||||
index += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
output.push(byte);
|
||||
index += 1;
|
||||
}
|
||||
output
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn resolve_time_format_maps_rfc3339() {
|
||||
let value = super::resolve_time_format("rfc3339");
|
||||
assert_eq!(value, "%+");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_time_format_maps_rfc3339_millis() {
|
||||
let value = super::resolve_time_format("rfc3339_millis");
|
||||
assert_eq!(value, "%Y-%m-%dT%H:%M:%S%.3f%:z");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_filter_expression_includes_default_level() {
|
||||
let config = crate::KbLoggingConfig {
|
||||
level: "debug".to_string(),
|
||||
console_enabled: true,
|
||||
console_ansi: true,
|
||||
file_enabled: false,
|
||||
directory: "./logs".to_string(),
|
||||
file_prefix: "app".to_string(),
|
||||
rotation: "daily".to_string(),
|
||||
message_format: "compact".to_string(),
|
||||
time_format: "rfc3339_millis".to_string(),
|
||||
target_filters: std::collections::BTreeMap::new(),
|
||||
};
|
||||
let value = super::build_filter_expression(&config);
|
||||
assert_eq!(value, "debug");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_filter_expression_includes_target_overrides() {
|
||||
let mut target_filters = std::collections::BTreeMap::new();
|
||||
target_filters.insert("hyper".to_string(), "warn".to_string());
|
||||
target_filters.insert("reqwest".to_string(), "error".to_string());
|
||||
let config = crate::KbLoggingConfig {
|
||||
level: "info".to_string(),
|
||||
console_enabled: true,
|
||||
console_ansi: true,
|
||||
file_enabled: true,
|
||||
directory: "./logs".to_string(),
|
||||
file_prefix: "app".to_string(),
|
||||
rotation: "daily".to_string(),
|
||||
message_format: "compact".to_string(),
|
||||
time_format: "rfc3339".to_string(),
|
||||
target_filters,
|
||||
};
|
||||
let value = super::build_filter_expression(&config);
|
||||
assert!(value.contains("info"));
|
||||
assert!(value.contains("hyper=warn"));
|
||||
assert!(value.contains("reqwest=error"));
|
||||
}
|
||||
}
|
||||
16
kb_lib/src/types.rs
Normal file
16
kb_lib/src/types.rs
Normal file
@@ -0,0 +1,16 @@
|
||||
// file: kb_lib/src/types.rs
|
||||
|
||||
//! Shared generic types for `kb_lib`.
|
||||
|
||||
/// Generic connection state used by transport clients.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
pub enum KbConnectionState {
|
||||
/// The client is fully disconnected.
|
||||
Disconnected,
|
||||
/// The client is establishing its connection.
|
||||
Connecting,
|
||||
/// The client is connected and ready.
|
||||
Connected,
|
||||
/// The client is shutting down its connection.
|
||||
Disconnecting,
|
||||
}
|
||||
91
kb_lib/src/ws_client.rs
Normal file
91
kb_lib/src/ws_client.rs
Normal file
@@ -0,0 +1,91 @@
|
||||
// file: kb_lib/src/ws_client.rs
|
||||
|
||||
//! Generic asynchronous WebSocket client skeleton.
|
||||
//!
|
||||
//! This module prepares the shape of the future Solana WebSocket transport.
|
||||
//! The actual transport loop, split read/write tasks, request tracking,
|
||||
//! subscribe registry, and notification routing are scheduled for `0.1.x`
|
||||
//! and `0.2.x` / `0.3.x`.
|
||||
|
||||
/// Generic asynchronous WebSocket client placeholder.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct WsClient {
|
||||
endpoint: crate::KbWsEndpointConfig,
|
||||
next_request_id: std::sync::Arc<std::sync::atomic::AtomicU64>,
|
||||
state: std::sync::Arc<tokio::sync::RwLock<crate::KbConnectionState>>,
|
||||
}
|
||||
|
||||
impl WsClient {
|
||||
/// Creates a new WebSocket client bound to a named endpoint configuration.
|
||||
pub fn new(endpoint: crate::KbWsEndpointConfig) -> Result<Self, crate::KbError> {
|
||||
if endpoint.name.trim().is_empty() {
|
||||
return Err(crate::KbError::Config(
|
||||
"ws client endpoint name must not be empty".to_string(),
|
||||
));
|
||||
}
|
||||
Ok(Self {
|
||||
endpoint,
|
||||
next_request_id: std::sync::Arc::new(std::sync::atomic::AtomicU64::new(1)),
|
||||
state: std::sync::Arc::new(tokio::sync::RwLock::new(
|
||||
crate::KbConnectionState::Disconnected,
|
||||
)),
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns the endpoint name of this client.
|
||||
pub fn endpoint_name(&self) -> &str {
|
||||
&self.endpoint.name
|
||||
}
|
||||
|
||||
/// Returns the endpoint URL of this client.
|
||||
pub fn endpoint_url(&self) -> &str {
|
||||
&self.endpoint.url
|
||||
}
|
||||
|
||||
/// Returns the endpoint configuration of this client.
|
||||
pub fn endpoint_config(&self) -> &crate::KbWsEndpointConfig {
|
||||
&self.endpoint
|
||||
}
|
||||
|
||||
/// Returns the next request identifier and increments the internal counter.
|
||||
pub fn next_request_id(&self) -> u64 {
|
||||
self.next_request_id
|
||||
.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Returns the current connection state.
|
||||
pub async fn connection_state(&self) -> crate::KbConnectionState {
|
||||
let state_guard = self.state.read().await;
|
||||
*state_guard
|
||||
}
|
||||
|
||||
/// Connects the client to its remote WebSocket endpoint.
|
||||
pub async fn connect(&self) -> Result<(), crate::KbError> {
|
||||
Err(crate::KbError::NotImplemented(
|
||||
"WsClient::connect is scheduled for version 0.1.x".to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
/// Sends a text frame through the WebSocket connection.
|
||||
pub async fn send_text(&self, _text: std::string::String) -> Result<(), crate::KbError> {
|
||||
Err(crate::KbError::NotImplemented(
|
||||
"WsClient::send_text is scheduled for version 0.1.x".to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
/// Sends a JSON value through the WebSocket connection.
|
||||
pub async fn send_json_value(&self, _value: &serde_json::Value) -> Result<(), crate::KbError> {
|
||||
Err(crate::KbError::NotImplemented(
|
||||
"WsClient::send_json_value is scheduled for version 0.2.x".to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
/// Disconnects the client from its remote endpoint.
|
||||
///
|
||||
/// The final implementation will unsubscribe with timeout before close.
|
||||
pub async fn disconnect(&self) -> Result<(), crate::KbError> {
|
||||
Err(crate::KbError::NotImplemented(
|
||||
"WsClient::disconnect is scheduled for version 0.1.x / 0.3.x".to_string(),
|
||||
))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user