Files
khadhroony-bot3/kb-config/src/settings.rs
2026-07-25 18:07:51 +02:00

1557 lines
60 KiB
Rust

// file: kb-config/src/settings.rs
// version: 14
//! Typed configuration models shared by applications and workers.
use ts_rs::TS; // rust-rules: derive-import
const CONFIG_JSON_SCHEMA: &str = include_str!("../../config/schema.config.json");
/// Root configuration containing every named profile.
#[derive(Clone, Debug, serde::Deserialize, Eq, PartialEq, serde::Serialize, TS)]
#[ts(export, export_to = "../frontend/ts/bindings/kb_config/settings/AppConfig.ts")]
pub struct AppConfig {
/// Active profile name.
pub active_profile: std::string::String,
/// Named profiles available in this configuration file.
pub profiles: std::vec::Vec<ProfileConfig>,
}
/// Configuration profile selected by the root active profile name.
#[derive(Clone, Debug, serde::Deserialize, Eq, PartialEq, serde::Serialize, TS)]
#[ts(
export,
export_to = "../frontend/ts/bindings/kb_config/settings/ProfileConfig.ts"
)]
pub struct ProfileConfig {
/// Profile code.
pub name: std::string::String,
/// Application metadata.
pub app: AppSectionConfig,
/// Logging configuration.
pub logging: LoggingConfig,
/// Database configuration.
pub database: DatabaseConfig,
/// Local data directories.
pub data: DataConfig,
/// Solana endpoint and listener configuration.
pub solana: SolanaConfig,
/// Wallet configuration.
pub wallet: WalletConfig,
/// Execution safety configuration.
pub execution: ExecutionConfig,
/// Demo application configuration.
pub demo: DemoConfig,
}
/// Application metadata for a profile.
#[derive(Clone, Debug, serde::Deserialize, Eq, PartialEq, serde::Serialize, TS)]
#[ts(
export,
export_to = "../frontend/ts/bindings/kb_config/settings/AppSectionConfig.ts"
)]
pub struct AppSectionConfig {
/// Application name.
pub name: std::string::String,
/// Environment name.
pub environment: std::string::String,
/// Default auto reconnect flag used when a transport does not override it.
pub auto_reconnect_default: bool,
}
/// Logging configuration shared by apps and worker processes.
#[derive(Clone, Debug, serde::Deserialize, Eq, PartialEq, serde::Serialize, TS)]
#[ts(
export,
export_to = "../frontend/ts/bindings/kb_config/settings/LoggingConfig.ts"
)]
pub struct LoggingConfig {
/// Default log level.
pub default_level: std::string::String,
/// Output targets.
pub targets: std::vec::Vec<LogTargetConfig>,
/// Target-specific filters.
pub target_filters: std::vec::Vec<LogTargetFilterConfig>,
}
/// Single logging output target.
#[derive(Clone, Debug, serde::Deserialize, Eq, PartialEq, serde::Serialize, TS)]
#[ts(
export,
export_to = "../frontend/ts/bindings/kb_config/settings/LogTargetConfig.ts"
)]
pub struct LogTargetConfig {
/// Output target name.
pub name: std::string::String,
/// Enables this target.
pub enabled: bool,
/// Sink kind, such as console or file.
pub sink: std::string::String,
/// Minimum level for this target.
pub level: std::string::String,
/// File path for file sinks or an empty string for console sinks.
pub path: std::string::String,
/// Rotation mode for file sinks.
pub rotation: std::string::String,
/// Message format, such as human or json.
pub format: std::string::String,
/// Enables ANSI escape sequences for this target.
pub ansi: bool,
/// Included tracing targets or globs.
pub targets: std::vec::Vec<std::string::String>,
}
/// Per-target logging filter.
#[derive(Clone, Debug, serde::Deserialize, Eq, PartialEq, serde::Serialize, TS)]
#[ts(
export,
export_to = "../frontend/ts/bindings/kb_config/settings/LogTargetFilterConfig.ts"
)]
pub struct LogTargetFilterConfig {
/// Tracing target or crate prefix.
pub target: std::string::String,
/// Level assigned to the target.
pub level: std::string::String,
}
/// Database backend configuration.
#[derive(Clone, Debug, serde::Deserialize, Eq, PartialEq, serde::Serialize, TS)]
#[ts(
export,
export_to = "../frontend/ts/bindings/kb_config/settings/DatabaseConfig.ts"
)]
pub struct DatabaseConfig {
/// Enables database storage.
pub enabled: bool,
/// Selected backend code.
pub backend: std::string::String,
/// PostgreSQL configuration.
pub postgres: PostgresConfig,
/// SQLite configuration for tests or imports.
pub sqlite: SqliteConfig,
}
/// PostgreSQL backend configuration.
#[derive(Clone, Debug, serde::Deserialize, Eq, PartialEq, serde::Serialize, TS)]
#[ts(
export,
export_to = "../frontend/ts/bindings/kb_config/settings/PostgresConfig.ts"
)]
pub struct PostgresConfig {
/// PostgreSQL URL or local placeholder.
pub url: std::string::String,
/// Maximum connection count.
pub max_connections: u32,
/// Connection timeout in milliseconds.
#[ts(type = "number")]
pub connect_timeout_ms: u64,
/// Enables schema initialization at startup.
pub auto_initialize_schema: bool,
}
/// SQLite backend configuration.
#[derive(Clone, Debug, serde::Deserialize, Eq, PartialEq, serde::Serialize, TS)]
#[ts(export, export_to = "../frontend/ts/bindings/kb_config/settings/SqliteConfig.ts")]
pub struct SqliteConfig {
/// SQLite database path.
pub path: std::string::String,
/// Creates the file when missing.
pub create_if_missing: bool,
/// Busy timeout in milliseconds.
#[ts(type = "number")]
pub busy_timeout_ms: u64,
/// Maximum connection count.
pub max_connections: u32,
/// Enables schema initialization at startup.
pub auto_initialize_schema: bool,
/// Enables WAL mode.
pub use_wal: bool,
}
/// Local data directory configuration.
#[derive(Clone, Debug, serde::Deserialize, Eq, PartialEq, serde::Serialize, TS)]
#[ts(export, export_to = "../frontend/ts/bindings/kb_config/settings/DataConfig.ts")]
pub struct DataConfig {
/// Wallet directory path.
pub wallets_directory: std::string::String,
/// Logs directory path.
pub logs_directory: std::string::String,
}
/// Solana endpoints and listener configuration.
#[derive(Clone, Debug, serde::Deserialize, Eq, PartialEq, serde::Serialize, TS)]
#[ts(export, export_to = "../frontend/ts/bindings/kb_config/settings/SolanaConfig.ts")]
pub struct SolanaConfig {
/// HTTP JSON-RPC endpoints.
pub http_endpoints: std::vec::Vec<HttpEndpointConfig>,
/// Standard Solana WebSocket endpoints.
pub ws_endpoints: std::vec::Vec<WsEndpointConfig>,
/// Listener declarations.
pub listeners: ListenerConfig,
}
/// HTTP JSON-RPC endpoint configuration.
#[derive(Clone, Debug, serde::Deserialize, Eq, PartialEq, serde::Serialize, TS)]
#[ts(
export,
export_to = "../frontend/ts/bindings/kb_config/settings/HttpEndpointConfig.ts"
)]
pub struct HttpEndpointConfig {
/// Endpoint name.
pub name: std::string::String,
/// Enables this endpoint.
pub enabled: bool,
/// Provider code.
pub provider: std::string::String,
/// Cluster code.
pub cluster: std::string::String,
/// Full endpoint URL.
pub url: std::string::String,
/// Connection timeout in milliseconds.
#[ts(type = "number")]
pub connect_timeout_ms: u64,
/// Request timeout in milliseconds.
#[ts(type = "number")]
pub request_timeout_ms: u64,
/// Maximum idle connections per host.
pub max_idle_connections_per_host: u32,
/// Role-specific limits for this URL.
pub roles: std::vec::Vec<EndpointRoleConfig>,
}
/// Standard Solana WebSocket endpoint configuration.
#[derive(Clone, Debug, serde::Deserialize, Eq, PartialEq, serde::Serialize, TS)]
#[ts(
export,
export_to = "../frontend/ts/bindings/kb_config/settings/WsEndpointConfig.ts"
)]
pub struct WsEndpointConfig {
/// Endpoint name.
pub name: std::string::String,
/// Enables this endpoint.
pub enabled: bool,
/// Provider code.
pub provider: std::string::String,
/// Cluster code.
pub cluster: std::string::String,
/// Full endpoint URL.
pub url: std::string::String,
/// Connection timeout in milliseconds.
#[ts(type = "number")]
pub connect_timeout_ms: u64,
/// Request timeout in milliseconds.
#[ts(type = "number")]
pub request_timeout_ms: u64,
/// Unsubscribe timeout in milliseconds.
#[ts(type = "number")]
pub unsubscribe_timeout_ms: u64,
/// Writer channel capacity.
pub write_channel_capacity: u32,
/// Event channel capacity.
pub event_channel_capacity: u32,
/// Enables automatic reconnect for this endpoint.
pub auto_reconnect: bool,
/// Role-specific limits for this URL.
pub roles: std::vec::Vec<EndpointRoleConfig>,
}
/// Role-specific endpoint limits.
#[derive(Clone, Debug, serde::Deserialize, Eq, PartialEq, serde::Serialize, TS)]
#[ts(
export,
export_to = "../frontend/ts/bindings/kb_config/settings/EndpointRoleConfig.ts"
)]
pub struct EndpointRoleConfig {
/// Role code used by endpoint pools.
pub role: std::string::String,
/// Enables this role on the endpoint.
pub enabled: bool,
/// Request or subscription kinds handled by this role.
pub request_kinds: std::vec::Vec<std::string::String>,
/// Role priority where lower values are preferred.
pub priority: u32,
/// Requests per second allowed for this role on this URL.
pub requests_per_second: u32,
/// Burst capacity allowed for this role on this URL.
pub burst_capacity: u32,
/// Maximum concurrent requests allowed for this role on this URL.
pub max_concurrent_requests: u32,
/// Maximum subscriptions allowed for this role on this URL.
pub max_subscriptions: u32,
/// Pause after a rate limit response in milliseconds.
#[ts(type = "number")]
pub pause_after_rate_limit_ms: u64,
}
/// Listener configuration used by standard WebSocket subscriptions.
#[derive(Clone, Debug, serde::Deserialize, Eq, PartialEq, serde::Serialize, TS)]
#[ts(
export,
export_to = "../frontend/ts/bindings/kb_config/settings/ListenerConfig.ts"
)]
pub struct ListenerConfig {
/// Enables listener creation.
pub enabled: bool,
/// Default commitment used by listener subscriptions.
pub default_commitment: std::string::String,
/// Log listeners filtered by program mentions.
pub log_listeners: std::vec::Vec<LogListenerConfig>,
/// Program account listeners.
pub program_listeners: std::vec::Vec<ProgramListenerConfig>,
/// Account listeners.
pub account_listeners: std::vec::Vec<AccountListenerConfig>,
}
/// Log listener filtered by a program identifier mention.
#[derive(Clone, Debug, serde::Deserialize, Eq, PartialEq, serde::Serialize, TS)]
#[ts(
export,
export_to = "../frontend/ts/bindings/kb_config/settings/LogListenerConfig.ts"
)]
pub struct LogListenerConfig {
/// Listener name.
pub name: std::string::String,
/// Enables this listener.
pub enabled: bool,
/// Required endpoint role.
pub endpoint_role: std::string::String,
/// Program identifier used as logsSubscribe mention.
pub program_id: std::string::String,
/// Human-readable listener purpose.
pub purpose: std::string::String,
}
/// Program account listener.
#[derive(Clone, Debug, serde::Deserialize, Eq, PartialEq, serde::Serialize, TS)]
#[ts(
export,
export_to = "../frontend/ts/bindings/kb_config/settings/ProgramListenerConfig.ts"
)]
pub struct ProgramListenerConfig {
/// Listener name.
pub name: std::string::String,
/// Enables this listener.
pub enabled: bool,
/// Required endpoint role.
pub endpoint_role: std::string::String,
/// Program identifier used by programSubscribe.
pub program_id: std::string::String,
/// Human-readable listener purpose.
pub purpose: std::string::String,
}
/// Account listener.
#[derive(Clone, Debug, serde::Deserialize, Eq, PartialEq, serde::Serialize, TS)]
#[ts(
export,
export_to = "../frontend/ts/bindings/kb_config/settings/AccountListenerConfig.ts"
)]
pub struct AccountListenerConfig {
/// Listener name.
pub name: std::string::String,
/// Enables this listener.
pub enabled: bool,
/// Required endpoint role.
pub endpoint_role: std::string::String,
/// Account public key used by accountSubscribe.
pub account_pubkey: std::string::String,
/// Human-readable listener purpose.
pub purpose: std::string::String,
}
/// Wallet configuration.
#[derive(Clone, Debug, serde::Deserialize, Eq, PartialEq, serde::Serialize, TS)]
#[ts(export, export_to = "../frontend/ts/bindings/kb_config/settings/WalletConfig.ts")]
pub struct WalletConfig {
/// Wallet directory path.
pub wallet_dir: std::string::String,
/// Selected cluster name.
pub cluster: std::string::String,
/// Enables the managed temporary wallet for this profile.
pub temporary_wallet_enabled: bool,
/// Alias used for the managed temporary wallet.
pub temporary_wallet_alias: std::string::String,
/// Persists the managed temporary wallet between process restarts.
pub temporary_wallet_persist: bool,
/// Enables local validator transaction sending.
pub localnet_send_enabled: bool,
/// Enables devnet transaction sending.
pub devnet_send_enabled: bool,
/// Enables testnet transaction sending.
pub testnet_send_enabled: bool,
/// Enables mainnet transaction sending.
pub mainnet_send_enabled: bool,
}
/// Execution safety configuration.
#[derive(Clone, Debug, serde::Deserialize, Eq, PartialEq, serde::Serialize, TS)]
#[ts(
export,
export_to = "../frontend/ts/bindings/kb_config/settings/ExecutionConfig.ts"
)]
pub struct ExecutionConfig {
/// Enables dry-run for newly prepared execution policies.
pub dry_run_default: bool,
/// Requires simulation before send.
pub require_simulation: bool,
/// Requires explicit operator confirmation before send.
pub require_operator_confirmation: bool,
/// Maximum spend in lamports for local validator tests.
#[ts(type = "number")]
pub localnet_max_spend_lamports: u64,
/// Maximum spend in lamports for devnet tests.
#[ts(type = "number")]
pub devnet_max_spend_lamports: u64,
/// Maximum spend in lamports for testnet tests.
#[ts(type = "number")]
pub testnet_max_spend_lamports: u64,
/// Maximum spend in lamports for mainnet operations.
#[ts(type = "number")]
pub mainnet_max_spend_lamports: u64,
/// Maximum estimated transaction fee in lamports.
#[ts(type = "number")]
pub max_fee_lamports: u64,
/// Maximum compute-unit price in micro-lamports.
#[ts(type = "number")]
pub max_compute_unit_price_micro_lamports: u64,
/// Maximum accepted age for a recent blockhash in slots.
#[ts(type = "number")]
pub recent_blockhash_max_age_slots: u64,
/// Maximum node retransmission retries requested by `sendTransaction`.
pub send_max_retries: u32,
/// Delay between transaction confirmation polls.
#[ts(type = "number")]
pub confirmation_poll_interval_ms: u64,
/// Maximum number of transaction confirmation polls.
pub confirmation_max_attempts: u32,
/// Maximum devnet faucet airdrop allowed for a temporary wallet.
#[ts(type = "number")]
pub devnet_airdrop_max_lamports: u64,
}
/// Demo application configuration.
#[derive(Clone, Debug, serde::Deserialize, Eq, PartialEq, serde::Serialize, TS)]
#[ts(export, export_to = "../frontend/ts/bindings/kb_config/settings/DemoConfig.ts")]
pub struct DemoConfig {
/// Enables live demo pages.
pub live_demo_enabled: bool,
/// Enables trading demo pages.
pub trading_demo_enabled: bool,
}
/// Returns the embedded JSON Schema text used for configuration validation.
pub fn config_json_schema_text() -> &'static str {
return CONFIG_JSON_SCHEMA;
}
/// Parses the embedded JSON Schema into a JSON value.
pub fn config_json_schema_value() -> kb_core::Result<serde_json::Value> {
let schema_result = serde_json::from_str::<serde_json::Value>(CONFIG_JSON_SCHEMA);
return match schema_result {
std::result::Result::Ok(schema) => std::result::Result::Ok(schema),
std::result::Result::Err(error) => std::result::Result::Err(kb_core::Error::new(
"config_schema_parse_failed",
error.to_string(),
)),
};
}
/// Validates a raw JSON configuration string against the embedded JSON Schema.
pub fn validate_config_json_schema(raw_json: &str) -> kb_core::Result<()> {
let schema = match config_json_schema_value() {
std::result::Result::Ok(schema) => schema,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let instance = match serde_json::from_str::<serde_json::Value>(raw_json) {
std::result::Result::Ok(instance) => instance,
std::result::Result::Err(error) => {
return std::result::Result::Err(kb_core::Error::new(
"config_json_parse_failed",
error.to_string(),
));
},
};
let validator = match jsonschema::validator_for(&schema) {
std::result::Result::Ok(validator) => validator,
std::result::Result::Err(error) => {
return std::result::Result::Err(kb_core::Error::new(
"config_schema_compile_failed",
error.to_string(),
));
},
};
let validation_result = validator.validate(&instance);
return match validation_result {
std::result::Result::Ok(()) => std::result::Result::Ok(()),
std::result::Result::Err(error) => std::result::Result::Err(kb_core::Error::new(
"config_json_schema_validation_failed",
error.to_string(),
)),
};
}
/// Parses the application configuration from a JSON string and validates it.
pub fn parse_config_json(raw_json: &str) -> kb_core::Result<AppConfig> {
match validate_config_json_schema(raw_json) {
std::result::Result::Ok(()) => (),
std::result::Result::Err(error) => return std::result::Result::Err(error),
}
let config = match serde_json::from_str::<AppConfig>(raw_json) {
std::result::Result::Ok(config) => config,
std::result::Result::Err(error) => {
return std::result::Result::Err(kb_core::Error::new(
"config_json_decode_failed",
error.to_string(),
));
},
};
return match validate_config(&config) {
std::result::Result::Ok(()) => std::result::Result::Ok(config),
std::result::Result::Err(error) => std::result::Result::Err(error),
};
}
/// Reads and parses the application configuration from a filesystem path.
pub fn read_config_json_file(path: &std::path::Path) -> kb_core::Result<AppConfig> {
let raw_json = match std::fs::read_to_string(path) {
std::result::Result::Ok(content) => content,
std::result::Result::Err(error) => {
return std::result::Result::Err(kb_core::Error::new(
"config_file_read_failed",
error.to_string(),
));
},
};
return parse_config_json(&raw_json);
}
/// Serializes a configuration value to compact JSON.
pub fn serialize_config_json(config: &AppConfig) -> kb_core::Result<std::string::String> {
match validate_config(config) {
std::result::Result::Ok(()) => (),
std::result::Result::Err(error) => return std::result::Result::Err(error),
}
return match serde_json::to_string(config) {
std::result::Result::Ok(serialized) => std::result::Result::Ok(serialized),
std::result::Result::Err(error) => std::result::Result::Err(kb_core::Error::new(
"config_json_serialize_failed",
error.to_string(),
)),
};
}
/// Serializes a configuration value to pretty JSON.
pub fn serialize_config_json_pretty(config: &AppConfig) -> kb_core::Result<std::string::String> {
match validate_config(config) {
std::result::Result::Ok(()) => (),
std::result::Result::Err(error) => return std::result::Result::Err(error),
}
return match serde_json::to_string_pretty(config) {
std::result::Result::Ok(serialized) => std::result::Result::Ok(serialized),
std::result::Result::Err(error) => std::result::Result::Err(kb_core::Error::new(
"config_json_pretty_serialize_failed",
error.to_string(),
)),
};
}
/// Returns the active profile declared by the root configuration.
pub fn active_profile(config: &AppConfig) -> kb_core::Result<&ProfileConfig> {
for profile in &config.profiles {
if profile.name == config.active_profile {
return std::result::Result::Ok(profile);
}
}
return std::result::Result::Err(kb_core::Error::new(
"active_profile_not_found",
config.active_profile.clone(),
));
}
/// Validates a typed configuration after JSON Schema validation and deserialization.
pub fn validate_config(config: &AppConfig) -> kb_core::Result<()> {
match validate_root_config(config) {
std::result::Result::Ok(()) => (),
std::result::Result::Err(error) => return std::result::Result::Err(error),
}
for profile in &config.profiles {
match validate_profile(profile) {
std::result::Result::Ok(()) => (),
std::result::Result::Err(error) => return std::result::Result::Err(error),
}
}
return match active_profile(config) {
std::result::Result::Ok(_) => std::result::Result::Ok(()),
std::result::Result::Err(error) => std::result::Result::Err(error),
};
}
fn validate_root_config(config: &AppConfig) -> kb_core::Result<()> {
match require_non_empty(&config.active_profile, "active_profile") {
std::result::Result::Ok(()) => (),
std::result::Result::Err(error) => return std::result::Result::Err(error),
}
if config.profiles.is_empty() {
return std::result::Result::Err(kb_core::Error::new(
"config_profiles_empty",
"at least one profile is required",
));
}
let mut names = std::collections::BTreeSet::<std::string::String>::new();
let mut active_count = 0_u32;
for profile in &config.profiles {
match require_non_empty(&profile.name, "profile.name") {
std::result::Result::Ok(()) => (),
std::result::Result::Err(error) => return std::result::Result::Err(error),
}
if !names.insert(profile.name.clone()) {
return std::result::Result::Err(kb_core::Error::new(
"profile_name_duplicate",
profile.name.clone(),
));
}
if profile.name == config.active_profile {
active_count += 1;
}
}
if active_count != 1 {
return std::result::Result::Err(kb_core::Error::new(
"active_profile_count_invalid",
format!("active profile '{}' must match exactly one profile", config.active_profile),
));
}
return std::result::Result::Ok(());
}
fn validate_profile(profile: &ProfileConfig) -> kb_core::Result<()> {
match validate_app_section(&profile.app) {
std::result::Result::Ok(()) => (),
std::result::Result::Err(error) => return std::result::Result::Err(error),
}
match validate_logging(&profile.logging) {
std::result::Result::Ok(()) => (),
std::result::Result::Err(error) => return std::result::Result::Err(error),
}
match validate_database(&profile.database) {
std::result::Result::Ok(()) => (),
std::result::Result::Err(error) => return std::result::Result::Err(error),
}
match validate_data(&profile.data) {
std::result::Result::Ok(()) => (),
std::result::Result::Err(error) => return std::result::Result::Err(error),
}
match validate_solana(&profile.solana) {
std::result::Result::Ok(()) => (),
std::result::Result::Err(error) => return std::result::Result::Err(error),
}
match validate_wallet(&profile.wallet) {
std::result::Result::Ok(()) => (),
std::result::Result::Err(error) => return std::result::Result::Err(error),
}
match validate_execution(&profile.execution) {
std::result::Result::Ok(()) => (),
std::result::Result::Err(error) => return std::result::Result::Err(error),
}
return validate_wallet_execution_pair(&profile.wallet, &profile.execution);
}
fn validate_app_section(config: &AppSectionConfig) -> kb_core::Result<()> {
match require_non_empty(&config.name, "app.name") {
std::result::Result::Ok(()) => (),
std::result::Result::Err(error) => return std::result::Result::Err(error),
}
return require_non_empty(&config.environment, "app.environment");
}
fn validate_logging(config: &LoggingConfig) -> kb_core::Result<()> {
match validate_log_level(&config.default_level, "logging.default_level") {
std::result::Result::Ok(()) => (),
std::result::Result::Err(error) => return std::result::Result::Err(error),
}
if config.targets.is_empty() {
return std::result::Result::Err(kb_core::Error::new(
"logging_targets_empty",
"at least one logging target is required",
));
}
let mut enabled_count = 0_u32;
let mut names = std::collections::BTreeSet::<std::string::String>::new();
for target in &config.targets {
if target.enabled {
enabled_count += 1;
}
if !names.insert(target.name.clone()) {
return std::result::Result::Err(kb_core::Error::new(
"logging_target_duplicate",
target.name.clone(),
));
}
match validate_log_target(target) {
std::result::Result::Ok(()) => (),
std::result::Result::Err(error) => return std::result::Result::Err(error),
}
}
for filter in &config.target_filters {
match require_non_empty(&filter.target, "logging.target_filters.target") {
std::result::Result::Ok(()) => (),
std::result::Result::Err(error) => return std::result::Result::Err(error),
}
match validate_log_level(&filter.level, "logging.target_filters.level") {
std::result::Result::Ok(()) => (),
std::result::Result::Err(error) => return std::result::Result::Err(error),
}
}
if enabled_count == 0 {
return std::result::Result::Err(kb_core::Error::new(
"logging_no_enabled_targets",
"at least one logging target must be enabled",
));
}
return std::result::Result::Ok(());
}
fn validate_log_target(config: &LogTargetConfig) -> kb_core::Result<()> {
match require_non_empty(&config.name, "logging.targets.name") {
std::result::Result::Ok(()) => (),
std::result::Result::Err(error) => return std::result::Result::Err(error),
}
match validate_log_level(&config.level, "logging.targets.level") {
std::result::Result::Ok(()) => (),
std::result::Result::Err(error) => return std::result::Result::Err(error),
}
if config.sink != "console" && config.sink != "file" {
return std::result::Result::Err(kb_core::Error::new(
"logging_sink_invalid",
config.sink.clone(),
));
}
if config.rotation != "none"
&& config.rotation != "never"
&& config.rotation != "daily"
&& config.rotation != "hourly"
{
return std::result::Result::Err(kb_core::Error::new(
"logging_rotation_invalid",
config.rotation.clone(),
));
}
if config.format != "human"
&& config.format != "compact"
&& config.format != "pretty"
&& config.format != "json"
{
return std::result::Result::Err(kb_core::Error::new(
"logging_format_invalid",
config.format.clone(),
));
}
if config.sink == "file" && config.path.trim().is_empty() {
return std::result::Result::Err(kb_core::Error::new(
"logging_file_path_empty",
config.name.clone(),
));
}
if config.sink == "console" && !config.path.trim().is_empty() {
return std::result::Result::Err(kb_core::Error::new(
"logging_console_path_not_empty",
config.name.clone(),
));
}
for target in &config.targets {
match require_non_empty(target, "logging.targets.targets") {
std::result::Result::Ok(()) => (),
std::result::Result::Err(error) => return std::result::Result::Err(error),
}
}
return std::result::Result::Ok(());
}
fn validate_database(config: &DatabaseConfig) -> kb_core::Result<()> {
if config.backend != "postgres" && config.backend != "sqlite" {
return std::result::Result::Err(kb_core::Error::new(
"database_backend_invalid",
config.backend.clone(),
));
}
match require_non_empty(&config.postgres.url, "database.postgres.url") {
std::result::Result::Ok(()) => (),
std::result::Result::Err(error) => return std::result::Result::Err(error),
}
if config.postgres.max_connections == 0 || config.sqlite.max_connections == 0 {
return std::result::Result::Err(kb_core::Error::new(
"database_max_connections_zero",
"database connection limits must be greater than zero",
));
}
if config.postgres.connect_timeout_ms == 0 || config.sqlite.busy_timeout_ms == 0 {
return std::result::Result::Err(kb_core::Error::new(
"database_timeout_zero",
"database timeouts must be greater than zero",
));
}
return require_non_empty(&config.sqlite.path, "database.sqlite.path");
}
fn validate_data(config: &DataConfig) -> kb_core::Result<()> {
match require_non_empty(&config.wallets_directory, "data.wallets_directory") {
std::result::Result::Ok(()) => (),
std::result::Result::Err(error) => return std::result::Result::Err(error),
}
return require_non_empty(&config.logs_directory, "data.logs_directory");
}
fn validate_solana(config: &SolanaConfig) -> kb_core::Result<()> {
if config.http_endpoints.is_empty() {
return std::result::Result::Err(kb_core::Error::new(
"http_endpoints_empty",
"at least one HTTP JSON-RPC endpoint is required",
));
}
if config.ws_endpoints.is_empty() {
return std::result::Result::Err(kb_core::Error::new(
"ws_endpoints_empty",
"at least one standard Solana WebSocket endpoint is required",
));
}
let mut http_names = std::collections::BTreeSet::<std::string::String>::new();
for endpoint in &config.http_endpoints {
if !http_names.insert(endpoint.name.clone()) {
return std::result::Result::Err(kb_core::Error::new(
"http_endpoint_duplicate",
endpoint.name.clone(),
));
}
match validate_http_endpoint(endpoint) {
std::result::Result::Ok(()) => (),
std::result::Result::Err(error) => return std::result::Result::Err(error),
}
}
let mut ws_names = std::collections::BTreeSet::<std::string::String>::new();
for endpoint in &config.ws_endpoints {
if !ws_names.insert(endpoint.name.clone()) {
return std::result::Result::Err(kb_core::Error::new(
"ws_endpoint_duplicate",
endpoint.name.clone(),
));
}
match validate_ws_endpoint(endpoint) {
std::result::Result::Ok(()) => (),
std::result::Result::Err(error) => return std::result::Result::Err(error),
}
}
return validate_listeners(&config.listeners);
}
fn validate_http_endpoint(config: &HttpEndpointConfig) -> kb_core::Result<()> {
match validate_endpoint_common(&config.name, &config.provider, &config.cluster, &config.roles) {
std::result::Result::Ok(()) => (),
std::result::Result::Err(error) => return std::result::Result::Err(error),
}
if !config.url.starts_with("http://") && !config.url.starts_with("https://") {
return std::result::Result::Err(kb_core::Error::new(
"http_endpoint_url_invalid",
config.url.clone(),
));
}
if config.connect_timeout_ms == 0
|| config.request_timeout_ms == 0
|| config.max_idle_connections_per_host == 0
{
return std::result::Result::Err(kb_core::Error::new(
"http_endpoint_limit_zero",
config.name.clone(),
));
}
for role in &config.roles {
if role.max_subscriptions != 0 {
return std::result::Result::Err(kb_core::Error::new(
"http_endpoint_subscriptions_invalid",
role.role.clone(),
));
}
}
return std::result::Result::Ok(());
}
fn validate_ws_endpoint(config: &WsEndpointConfig) -> kb_core::Result<()> {
match validate_endpoint_common(&config.name, &config.provider, &config.cluster, &config.roles) {
std::result::Result::Ok(()) => (),
std::result::Result::Err(error) => return std::result::Result::Err(error),
}
if !config.url.starts_with("ws://") && !config.url.starts_with("wss://") {
return std::result::Result::Err(kb_core::Error::new(
"ws_endpoint_url_invalid",
config.url.clone(),
));
}
if config.connect_timeout_ms == 0
|| config.request_timeout_ms == 0
|| config.unsubscribe_timeout_ms == 0
|| config.write_channel_capacity == 0
|| config.event_channel_capacity == 0
{
return std::result::Result::Err(kb_core::Error::new(
"ws_endpoint_limit_zero",
config.name.clone(),
));
}
return std::result::Result::Ok(());
}
fn validate_endpoint_common(
name: &str,
provider: &str,
cluster: &str,
roles: &[EndpointRoleConfig],
) -> kb_core::Result<()> {
match require_non_empty(name, "endpoint.name") {
std::result::Result::Ok(()) => (),
std::result::Result::Err(error) => return std::result::Result::Err(error),
}
match require_non_empty(provider, "endpoint.provider") {
std::result::Result::Ok(()) => (),
std::result::Result::Err(error) => return std::result::Result::Err(error),
}
match require_non_empty(cluster, "endpoint.cluster") {
std::result::Result::Ok(()) => (),
std::result::Result::Err(error) => return std::result::Result::Err(error),
}
if roles.is_empty() {
return std::result::Result::Err(kb_core::Error::new(
"endpoint_roles_empty",
name.to_string(),
));
}
let mut role_names = std::collections::BTreeSet::<std::string::String>::new();
for role in roles {
if !role_names.insert(role.role.clone()) {
return std::result::Result::Err(kb_core::Error::new(
"endpoint_role_duplicate",
role.role.clone(),
));
}
match validate_endpoint_role(role) {
std::result::Result::Ok(()) => (),
std::result::Result::Err(error) => return std::result::Result::Err(error),
}
}
return std::result::Result::Ok(());
}
fn validate_endpoint_role(config: &EndpointRoleConfig) -> kb_core::Result<()> {
match require_non_empty(&config.role, "endpoint.roles.role") {
std::result::Result::Ok(()) => (),
std::result::Result::Err(error) => return std::result::Result::Err(error),
}
if config.request_kinds.is_empty() {
return std::result::Result::Err(kb_core::Error::new(
"endpoint_role_request_kinds_empty",
config.role.clone(),
));
}
for request_kind in &config.request_kinds {
match require_non_empty(request_kind, "endpoint.roles.request_kinds") {
std::result::Result::Ok(()) => (),
std::result::Result::Err(error) => return std::result::Result::Err(error),
}
}
if config.requests_per_second == 0
|| config.burst_capacity == 0
|| config.max_concurrent_requests == 0
|| config.pause_after_rate_limit_ms == 0
{
return std::result::Result::Err(kb_core::Error::new(
"endpoint_role_limit_zero",
config.role.clone(),
));
}
return std::result::Result::Ok(());
}
fn validate_listeners(config: &ListenerConfig) -> kb_core::Result<()> {
match require_non_empty(&config.default_commitment, "solana.listeners.default_commitment") {
std::result::Result::Ok(()) => (),
std::result::Result::Err(error) => return std::result::Result::Err(error),
}
for listener in &config.log_listeners {
match validate_log_listener(listener) {
std::result::Result::Ok(()) => (),
std::result::Result::Err(error) => return std::result::Result::Err(error),
}
}
for listener in &config.program_listeners {
match validate_program_listener(listener) {
std::result::Result::Ok(()) => (),
std::result::Result::Err(error) => return std::result::Result::Err(error),
}
}
for listener in &config.account_listeners {
match validate_account_listener(listener) {
std::result::Result::Ok(()) => (),
std::result::Result::Err(error) => return std::result::Result::Err(error),
}
}
return std::result::Result::Ok(());
}
fn validate_log_listener(config: &LogListenerConfig) -> kb_core::Result<()> {
match require_non_empty(&config.name, "log_listener.name") {
std::result::Result::Ok(()) => (),
std::result::Result::Err(error) => return std::result::Result::Err(error),
}
match require_non_empty(&config.endpoint_role, "log_listener.endpoint_role") {
std::result::Result::Ok(()) => (),
std::result::Result::Err(error) => return std::result::Result::Err(error),
}
return require_non_empty(&config.program_id, "log_listener.program_id");
}
fn validate_program_listener(config: &ProgramListenerConfig) -> kb_core::Result<()> {
match require_non_empty(&config.name, "program_listener.name") {
std::result::Result::Ok(()) => (),
std::result::Result::Err(error) => return std::result::Result::Err(error),
}
match require_non_empty(&config.endpoint_role, "program_listener.endpoint_role") {
std::result::Result::Ok(()) => (),
std::result::Result::Err(error) => return std::result::Result::Err(error),
}
return require_non_empty(&config.program_id, "program_listener.program_id");
}
fn validate_account_listener(config: &AccountListenerConfig) -> kb_core::Result<()> {
match require_non_empty(&config.name, "account_listener.name") {
std::result::Result::Ok(()) => (),
std::result::Result::Err(error) => return std::result::Result::Err(error),
}
match require_non_empty(&config.endpoint_role, "account_listener.endpoint_role") {
std::result::Result::Ok(()) => (),
std::result::Result::Err(error) => return std::result::Result::Err(error),
}
return require_non_empty(&config.account_pubkey, "account_listener.account_pubkey");
}
fn validate_wallet(config: &WalletConfig) -> kb_core::Result<()> {
match require_non_empty(&config.wallet_dir, "wallet.wallet_dir") {
std::result::Result::Ok(()) => (),
std::result::Result::Err(error) => return std::result::Result::Err(error),
}
match require_non_empty(&config.cluster, "wallet.cluster") {
std::result::Result::Ok(()) => (),
std::result::Result::Err(error) => return std::result::Result::Err(error),
}
match validate_wallet_alias(&config.temporary_wallet_alias) {
std::result::Result::Ok(()) => (),
std::result::Result::Err(error) => return std::result::Result::Err(error),
}
if config.temporary_wallet_persist && !config.temporary_wallet_enabled {
return std::result::Result::Err(kb_core::Error::new(
"wallet_temporary_persist_without_enablement",
"temporary wallet persistence requires temporary_wallet_enabled",
));
}
if config.temporary_wallet_enabled && config.cluster == "mainnet-beta" {
return std::result::Result::Err(kb_core::Error::new(
"wallet_temporary_mainnet_forbidden",
"the managed temporary wallet is forbidden on mainnet-beta profiles",
));
}
return std::result::Result::Ok(());
}
fn validate_wallet_alias(value: &str) -> kb_core::Result<()> {
if value.is_empty() || value.len() > 64 {
return std::result::Result::Err(kb_core::Error::new(
"wallet_alias_length_invalid",
"temporary wallet alias length must be between 1 and 64 bytes",
));
}
if !value.bytes().enumerate().all(|(index, byte)| {
return byte.is_ascii_alphanumeric() || (index > 0 && (byte == b'_' || byte == b'-'));
}) {
return std::result::Result::Err(kb_core::Error::new(
"wallet_alias_invalid",
"temporary wallet alias contains invalid characters",
));
}
return std::result::Result::Ok(());
}
fn validate_execution(config: &ExecutionConfig) -> kb_core::Result<()> {
let configured_spend = config
.localnet_max_spend_lamports
.saturating_add(config.devnet_max_spend_lamports)
.saturating_add(config.testnet_max_spend_lamports)
.saturating_add(config.mainnet_max_spend_lamports);
if configured_spend > 0 && !config.require_simulation {
return std::result::Result::Err(kb_core::Error::new(
"execution_spend_without_simulation",
"any configured spend requires simulation",
));
}
if config.mainnet_max_spend_lamports > 0 && !config.require_operator_confirmation {
return std::result::Result::Err(kb_core::Error::new(
"execution_mainnet_without_confirmation",
"mainnet spend requires operator confirmation",
));
}
if config.max_fee_lamports == 0 {
return std::result::Result::Err(kb_core::Error::new(
"execution_fee_limit_zero",
"max_fee_lamports must be greater than zero",
));
}
if config.recent_blockhash_max_age_slots == 0 {
return std::result::Result::Err(kb_core::Error::new(
"execution_blockhash_age_zero",
"recent_blockhash_max_age_slots must be greater than zero",
));
}
if config.confirmation_poll_interval_ms == 0 || config.confirmation_poll_interval_ms > 60_000 {
return std::result::Result::Err(kb_core::Error::new(
"execution_confirmation_poll_interval_invalid",
"confirmation_poll_interval_ms must be between 1 and 60000",
));
}
if config.confirmation_max_attempts == 0 || config.confirmation_max_attempts > 10_000 {
return std::result::Result::Err(kb_core::Error::new(
"execution_confirmation_attempts_invalid",
"confirmation_max_attempts must be between 1 and 10000",
));
}
return std::result::Result::Ok(());
}
fn validate_wallet_execution_pair(
wallet: &WalletConfig,
execution: &ExecutionConfig,
) -> kb_core::Result<()> {
for (enabled, limit, cluster) in [
(wallet.localnet_send_enabled, execution.localnet_max_spend_lamports, "localnet"),
(wallet.devnet_send_enabled, execution.devnet_max_spend_lamports, "devnet"),
(wallet.testnet_send_enabled, execution.testnet_max_spend_lamports, "testnet"),
(
wallet.mainnet_send_enabled,
execution.mainnet_max_spend_lamports,
"mainnet-beta",
),
] {
if enabled && limit == 0 {
return std::result::Result::Err(kb_core::Error::new(
"execution_enabled_cluster_without_spend_limit",
format!("{cluster} sending requires a positive spend limit"),
));
}
}
if wallet.mainnet_send_enabled && !execution.require_operator_confirmation {
return std::result::Result::Err(kb_core::Error::new(
"execution_mainnet_send_without_confirmation",
"mainnet sending requires operator confirmation",
));
}
if wallet.cluster != "devnet" && execution.devnet_airdrop_max_lamports > 0 {
return std::result::Result::Err(kb_core::Error::new(
"execution_devnet_airdrop_on_non_devnet_profile",
"devnet_airdrop_max_lamports must be zero outside devnet profiles",
));
}
return std::result::Result::Ok(());
}
fn validate_log_level(value: &str, field_name: &str) -> kb_core::Result<()> {
if value == "trace"
|| value == "debug"
|| value == "info"
|| value == "warn"
|| value == "error"
|| value == "off"
{
return std::result::Result::Ok(());
}
return std::result::Result::Err(kb_core::Error::new(
"log_level_invalid",
format!("{field_name}: {value}"),
));
}
fn require_non_empty(value: &str, field_name: &str) -> kb_core::Result<()> {
if value.trim().is_empty() {
return std::result::Result::Err(kb_core::Error::new(
"config_field_empty",
field_name.to_string(),
));
}
return std::result::Result::Ok(());
}
#[cfg(test)]
mod tests {
use ts_rs::TS; // rust-rules: derive-import
#[test]
fn exported_json_configuration_numbers_do_not_use_bigint() {
let config = ts_rs::Config::default();
let declarations = [
<crate::PostgresConfig as TS>::decl(&config),
<crate::SqliteConfig as TS>::decl(&config),
<crate::HttpEndpointConfig as TS>::decl(&config),
<crate::WsEndpointConfig as TS>::decl(&config),
<crate::EndpointRoleConfig as TS>::decl(&config),
<crate::ExecutionConfig as TS>::decl(&config),
];
for declaration in declarations {
assert!(!declaration.contains("bigint"));
}
}
const EXAMPLE_CONFIG: &str = include_str!("../../config/example.config.json");
fn parse_example_value() -> serde_json::Value {
let result = serde_json::from_str::<serde_json::Value>(EXAMPLE_CONFIG);
match result {
std::result::Result::Ok(value) => return value,
std::result::Result::Err(error) => panic!("example config must be valid JSON: {error}"),
}
}
fn value_to_json(value: &serde_json::Value) -> std::string::String {
let result = serde_json::to_string(value);
match result {
std::result::Result::Ok(raw_json) => return raw_json,
std::result::Result::Err(error) => panic!("test value must serialize: {error}"),
}
}
fn tracing_crate_names() -> std::vec::Vec<std::string::String> {
let manifest_dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"));
let workspace_root = match manifest_dir.parent() {
std::option::Option::Some(path) => path,
std::option::Option::None => panic!("workspace root must exist"),
};
let read_result = std::fs::read_dir(workspace_root);
let entries = match read_result {
std::result::Result::Ok(entries) => entries,
std::result::Result::Err(error) => panic!("workspace must be readable: {error}"),
};
let mut names = std::vec::Vec::<std::string::String>::new();
for entry_result in entries {
let entry = match entry_result {
std::result::Result::Ok(entry) => entry,
std::result::Result::Err(error) => {
panic!("workspace entry must be readable: {error}")
},
};
let cargo_path = entry.path().join("Cargo.toml");
if !cargo_path.is_file() {
continue;
}
let cargo_result = std::fs::read_to_string(&cargo_path);
let cargo_toml = match cargo_result {
std::result::Result::Ok(content) => content,
std::result::Result::Err(error) => panic!("Cargo.toml must be readable: {error}"),
};
if !cargo_toml.lines().any(|line| return line.trim() == "tracing.workspace = true") {
continue;
}
let mut in_package = false;
for line in cargo_toml.lines() {
let trimmed = line.trim();
if trimmed == "[package]" {
in_package = true;
continue;
}
if in_package && trimmed.starts_with('[') {
break;
}
if in_package && trimmed.starts_with("name = ") {
names.push(trimmed.trim_start_matches("name = ").trim_matches('"').to_string());
break;
}
}
}
names.sort();
return names;
}
#[test]
fn schema_text_is_valid_json() {
let result = super::config_json_schema_value();
assert!(result.is_ok());
}
#[test]
fn example_config_validates_against_schema() {
let result = super::validate_config_json_schema(EXAMPLE_CONFIG);
assert!(result.is_ok());
}
#[test]
fn example_config_routes_global_and_operational_crate_files() {
let config_result = super::parse_config_json(EXAMPLE_CONFIG);
let config = match config_result {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => panic!("example config must parse: {error}"),
};
let operational_crates = tracing_crate_names();
assert!(!operational_crates.is_empty());
for profile in config.profiles {
let directory = match profile.name.as_str() {
"local_devnet" => "devnet",
"mainnet_research" => "mainnet_research",
"mainnet" => "mainnet",
value => panic!("unexpected example profile: {value}"),
};
for (suffix, level, format) in [
("debug.log", "debug", "human"),
("info.log", "info", "human"),
("error.jsonl", "error", "json"),
] {
let expected_path = format!("logs/{directory}/{suffix}");
assert!(profile.logging.targets.iter().any(|target| {
return target.enabled
&& target.path == expected_path
&& target.level == level
&& target.format == format
&& target.targets == std::vec!["*".to_string()];
}));
}
assert!(profile.logging.targets.iter().any(|target| {
return target.enabled
&& target.path == format!("logs/{directory}/app.log")
&& target.level == "debug"
&& target.format == "human"
&& target.targets == std::vec!["kb-app-demo-desktop".to_string()];
}));
for crate_name in &operational_crates {
for (suffix, level, format) in [
("debug.log", "debug", "human"),
("info.log", "info", "human"),
("error.jsonl", "error", "json"),
] {
let expected_path = format!("logs/{directory}/{crate_name}/{suffix}");
let route_exists = profile.logging.targets.iter().any(|target| {
return target.enabled
&& target.path == expected_path
&& target.level == level
&& target.format == format
&& target.targets == std::vec![crate_name.clone()];
});
assert!(
route_exists,
"profile {} is missing the canonical {} route for {} at {}",
profile.name, level, crate_name, expected_path
);
}
}
}
}
#[test]
fn example_config_uses_canonical_wallet_tracing_routes_without_embedded_secrets() {
let config_result = super::parse_config_json(EXAMPLE_CONFIG);
let config = match config_result {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => panic!("example config must parse: {error}"),
};
assert!(!EXAMPLE_CONFIG.contains("api-key=95e73621"));
assert!(!EXAMPLE_CONFIG.contains("\"kb_wallet\""));
for profile in config.profiles {
assert!(profile.logging.targets.iter().any(|target| {
return target.enabled && target.targets == std::vec!["kb-wallet".to_string()];
}));
}
}
#[test]
fn example_config_uses_canonical_pipeline_tracing_routes() {
let source = include_str!("../../config/example.config.json");
assert!(source.contains("\"kb-pipeline\""));
assert!(source.contains("logs/devnet/kb-pipeline/debug.log"));
assert!(source.contains("logs/mainnet_research/kb-pipeline/info.log"));
assert!(source.contains("logs/mainnet/kb-pipeline/error.jsonl"));
assert!(!source.contains("kb_pipeline"));
}
#[test]
fn example_config_parses_and_resolves_active_profile() {
let config_result = super::parse_config_json(EXAMPLE_CONFIG);
assert!(config_result.is_ok());
let config = match config_result {
std::result::Result::Ok(config) => config,
std::result::Result::Err(error) => panic!("example config must parse: {error}"),
};
let active_result = super::active_profile(&config);
assert!(active_result.is_ok());
//let active =
match active_result {
std::result::Result::Ok(active) => active,
std::result::Result::Err(error) => panic!("active profile must resolve: {error}"),
};
//assert_eq!(active.name, "local_devnet");
}
#[test]
fn example_config_serializes_and_roundtrips() {
let config_result = super::parse_config_json(EXAMPLE_CONFIG);
assert!(config_result.is_ok());
let config = match config_result {
std::result::Result::Ok(config) => config,
std::result::Result::Err(error) => panic!("example config must parse: {error}"),
};
let serialized_result = super::serialize_config_json_pretty(&config);
assert!(serialized_result.is_ok());
let serialized = match serialized_result {
std::result::Result::Ok(serialized) => serialized,
std::result::Result::Err(error) => panic!("example config must serialize: {error}"),
};
let reparsed_result = super::parse_config_json(&serialized);
assert!(reparsed_result.is_ok());
let reparsed = match reparsed_result {
std::result::Result::Ok(reparsed) => reparsed,
std::result::Result::Err(error) => panic!("serialized config must parse: {error}"),
};
assert_eq!(config, reparsed);
}
#[test]
fn parser_rejects_missing_active_profile() {
let mut value = parse_example_value();
value["active_profile"] = serde_json::Value::String("missing_profile".to_string());
let raw_json = value_to_json(&value);
let result = super::parse_config_json(&raw_json);
assert!(result.is_err());
}
#[test]
fn schema_rejects_profile_enabled_field() {
let mut value = parse_example_value();
value["profiles"][0]["enabled"] = serde_json::Value::Bool(true);
let raw_json = value_to_json(&value);
let result = super::validate_config_json_schema(&raw_json);
assert!(result.is_err());
}
#[test]
fn parser_rejects_duplicate_profile_names() {
let mut value = parse_example_value();
value["profiles"][1]["name"] = serde_json::Value::String("local_devnet".to_string());
let raw_json = value_to_json(&value);
let result = super::parse_config_json(&raw_json);
assert!(result.is_err());
}
#[test]
fn schema_rejects_unknown_endpoint_secret_field() {
let mut value = parse_example_value();
value["profiles"][0]["solana"]["http_endpoints"][0]["secret_env_field"] =
serde_json::Value::String("HELIUS_API_KEY".to_string());
let raw_json = value_to_json(&value);
let result = super::validate_config_json_schema(&raw_json);
assert!(result.is_err());
}
#[test]
fn schema_rejects_runtime_idls_directory_field() {
let mut value = parse_example_value();
value["profiles"][0]["data"]["idls_directory"] =
serde_json::Value::String("idls".to_string());
let raw_json = value_to_json(&value);
let result = super::validate_config_json_schema(&raw_json);
assert!(result.is_err());
}
#[test]
fn schema_rejects_unsupported_transport_surface() {
let mut value = parse_example_value();
value["profiles"][0]["solana"]["advanced_streams"] = serde_json::json!([]);
let raw_json = value_to_json(&value);
let result = super::validate_config_json_schema(&raw_json);
assert!(result.is_err());
}
#[test]
fn parser_rejects_invalid_http_endpoint_url() {
let mut value = parse_example_value();
value["profiles"][0]["solana"]["http_endpoints"][0]["url"] =
serde_json::Value::String("wss://api.devnet.solana.com".to_string());
let raw_json = value_to_json(&value);
let result = super::parse_config_json(&raw_json);
assert!(result.is_err());
}
#[test]
fn parser_rejects_invalid_ws_endpoint_url() {
let mut value = parse_example_value();
value["profiles"][0]["solana"]["ws_endpoints"][0]["url"] =
serde_json::Value::String("https://api.devnet.solana.com".to_string());
let raw_json = value_to_json(&value);
let result = super::parse_config_json(&raw_json);
assert!(result.is_err());
}
#[test]
fn parser_rejects_file_logging_target_without_path() {
let mut value = parse_example_value();
value["profiles"][0]["logging"]["targets"][1]["path"] =
serde_json::Value::String("".to_string());
let raw_json = value_to_json(&value);
let result = super::parse_config_json(&raw_json);
assert!(result.is_err());
}
#[test]
fn parser_rejects_invalid_temporary_wallet_alias() {
let mut value = parse_example_value();
value["profiles"][0]["wallet"]["temporary_wallet_alias"] =
serde_json::Value::String("../wallet".to_string());
let raw_json = value_to_json(&value);
let result = super::parse_config_json(&raw_json);
assert!(result.is_err());
}
#[test]
fn parser_rejects_persistent_disabled_temporary_wallet() {
let mut value = parse_example_value();
value["profiles"][0]["wallet"]["temporary_wallet_enabled"] = serde_json::Value::Bool(false);
let raw_json = value_to_json(&value);
let result = super::parse_config_json(&raw_json);
assert!(result.is_err());
}
#[test]
fn parser_rejects_enabled_cluster_without_spend_limit() {
let mut value = parse_example_value();
value["profiles"][0]["execution"]["devnet_max_spend_lamports"] =
serde_json::Value::Number(serde_json::Number::from(0));
let raw_json = value_to_json(&value);
let result = super::parse_config_json(&raw_json);
assert!(result.is_err());
}
#[test]
fn parser_rejects_zero_fee_or_blockhash_limits() {
for field in [
"max_fee_lamports",
"recent_blockhash_max_age_slots",
"confirmation_poll_interval_ms",
"confirmation_max_attempts",
] {
let mut value = parse_example_value();
value["profiles"][0]["execution"][field] =
serde_json::Value::Number(serde_json::Number::from(0));
let raw_json = value_to_json(&value);
let result = super::parse_config_json(&raw_json);
assert!(result.is_err());
}
}
#[test]
fn parser_rejects_http_role_with_subscriptions() {
let mut value = parse_example_value();
value["profiles"][0]["solana"]["http_endpoints"][0]["roles"][0]["max_subscriptions"] =
serde_json::Value::Number(serde_json::Number::from(1));
let raw_json = value_to_json(&value);
let result = super::parse_config_json(&raw_json);
assert!(result.is_err());
}
}