0.5.1-pre.005

This commit is contained in:
2026-08-10 01:36:42 +02:00
parent ec07ddbd80
commit b6a286a4df
54 changed files with 6236 additions and 5569 deletions

View File

@@ -1,7 +1,7 @@
// file: ks-config/src/lib.rs
// version: 5
// version: 6
//! Khadhroony Bot3 workspace configuration contract and loading helpers.
//! Khadhroony Solana application configuration contract and loading helpers.
#![warn(missing_docs)]
#![deny(unreachable_pub)]
#![forbid(unsafe_code)]
@@ -37,12 +37,6 @@ pub use self::settings::HttpEndpointConfig;
pub use self::settings::ListenerConfig;
/// Exposes the log listener configuration type.
pub use self::settings::LogListenerConfig;
/// Exposes the logging target configuration type.
pub use self::settings::LogTargetConfig;
/// Exposes the logging target filter configuration type.
pub use self::settings::LogTargetFilterConfig;
/// Exposes the logging configuration type.
pub use self::settings::LoggingConfig;
/// Exposes the PostgreSQL configuration type.
pub use self::settings::PostgresConfig;
/// Exposes the profile configuration type.

View File

@@ -1,11 +1,11 @@
// file: ks-config/src/settings.rs
// version: 19
// version: 20
//! 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");
const CONFIG_JSON_SCHEMA: &str = include_str!("../../config/schemas/app.config.schema.json");
/// Root configuration containing every named profile.
#[derive(Clone, Debug, serde::Deserialize, Eq, PartialEq, serde::Serialize, TS)]
@@ -28,8 +28,6 @@ pub struct ProfileConfig {
pub name: std::string::String,
/// Application metadata.
pub app: AppSectionConfig,
/// Logging configuration.
pub logging: LoggingConfig,
/// Database configuration.
pub database: DatabaseConfig,
/// Local data directories.
@@ -59,61 +57,6 @@ pub struct AppSectionConfig {
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/ks_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/ks_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/ks_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(
@@ -650,10 +593,6 @@ fn validate_profile(profile: &ProfileConfig) -> ks_core::Result<()> {
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),
@@ -685,109 +624,6 @@ fn validate_app_section(config: &AppSectionConfig) -> ks_core::Result<()> {
return require_non_empty(&config.environment, "app.environment");
}
fn validate_logging(config: &LoggingConfig) -> ks_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(ks_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(ks_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(ks_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) -> ks_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(ks_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(ks_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(ks_core::Error::new(
"logging_format_invalid",
config.format.clone(),
));
}
if config.sink == "file" && config.path.trim().is_empty() {
return std::result::Result::Err(ks_core::Error::new(
"logging_file_path_empty",
config.name.clone(),
));
}
if config.sink == "console" && !config.path.trim().is_empty() {
return std::result::Result::Err(ks_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) -> ks_core::Result<()> {
if config.backend != "postgres" && config.backend != "sqlite" {
return std::result::Result::Err(ks_core::Error::new(
@@ -1179,22 +1015,6 @@ fn validate_wallet_execution_pair(
return std::result::Result::Ok(());
}
fn validate_log_level(value: &str, field_name: &str) -> ks_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(ks_core::Error::new(
"log_level_invalid",
format!("{field_name}: {value}"),
));
}
fn require_non_empty(value: &str, field_name: &str) -> ks_core::Result<()> {
if value.trim().is_empty() {
return std::result::Result::Err(ks_core::Error::new(
@@ -1225,13 +1045,14 @@ mod tests {
}
}
const EXAMPLE_CONFIG: &str = include_str!("../../config/example.config.json");
const DEFAULT_CONFIG: &str = include_str!("../../config/app.config.json");
const EXAMPLE_CONFIG: &str = include_str!("../../config/example.app.config.json");
fn parse_example_value() -> serde_json::Value {
let result = serde_json::from_str::<serde_json::Value>(EXAMPLE_CONFIG);
fn parse_default_value() -> serde_json::Value {
let result = serde_json::from_str::<serde_json::Value>(DEFAULT_CONFIG);
match result {
std::result::Result::Ok(value) => return value,
std::result::Result::Err(error) => panic!("example config must be valid JSON: {error}"),
std::result::Result::Err(error) => panic!("default app config must be valid JSON: {error}"),
}
}
@@ -1243,57 +1064,6 @@ mod tests {
}
}
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();
@@ -1301,110 +1071,18 @@ mod tests {
}
#[test]
fn example_config_validates_against_schema() {
let result = super::validate_config_json_schema(EXAMPLE_CONFIG);
assert!(result.is_ok());
fn default_and_example_app_configs_validate_against_schema() {
assert!(super::validate_config_json_schema(DEFAULT_CONFIG).is_ok());
assert!(super::validate_config_json_schema(EXAMPLE_CONFIG).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 route_directory = if crate_name == "ks-pipeline-demo-scenarios" {
"ks-pipeline"
} else {
crate_name.as_str()
};
let expected_path = format!("logs/{directory}/{route_directory}/{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.iter().any(|value| return value == crate_name);
});
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("\"ks_wallet\""));
for profile in config.profiles {
assert!(profile.logging.targets.iter().any(|target| {
return target.enabled && target.targets == std::vec!["ks-wallet".to_string()];
}));
}
}
#[test]
fn example_config_uses_canonical_pipeline_tracing_routes() {
let source = include_str!("../../config/example.config.json");
assert!(source.contains("\"ks-pipeline\""));
assert!(source.contains("logs/devnet/ks-pipeline/debug.log"));
assert!(source.contains("logs/mainnet_research/ks-pipeline/info.log"));
assert!(source.contains("logs/mainnet/ks-pipeline/error.jsonl"));
assert!(!source.contains("ks_pipeline"));
}
#[test]
fn example_config_parses_and_resolves_active_profile() {
let config_result = super::parse_config_json(EXAMPLE_CONFIG);
fn default_app_config_parses_and_resolves_active_profile() {
let config_result = super::parse_config_json(DEFAULT_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}"),
std::result::Result::Err(error) => panic!("default app config must parse: {error}"),
};
let active_result = super::active_profile(&config);
assert!(active_result.is_ok());
@@ -1417,18 +1095,18 @@ mod tests {
}
#[test]
fn example_config_serializes_and_roundtrips() {
let config_result = super::parse_config_json(EXAMPLE_CONFIG);
fn default_app_config_serializes_and_roundtrips() {
let config_result = super::parse_config_json(DEFAULT_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}"),
std::result::Result::Err(error) => panic!("default app 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}"),
std::result::Result::Err(error) => panic!("default app config must serialize: {error}"),
};
let reparsed_result = super::parse_config_json(&serialized);
assert!(reparsed_result.is_ok());
@@ -1441,7 +1119,7 @@ mod tests {
#[test]
fn parser_rejects_missing_active_profile() {
let mut value = parse_example_value();
let mut value = parse_default_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);
@@ -1449,8 +1127,8 @@ mod tests {
}
#[test]
fn example_config_separates_runtime_postgres_profiles() {
let value = parse_example_value();
fn default_app_config_separates_runtime_postgres_profiles() {
let value = parse_default_value();
assert_eq!(
value["profiles"][0]["database"]["postgres"]["url"],
serde_json::Value::String("${KS_SECRET_POSTGRES_DEVNET_URL}".to_string())
@@ -1467,7 +1145,7 @@ mod tests {
#[test]
fn schema_rejects_profile_enabled_field() {
let mut value = parse_example_value();
let mut value = parse_default_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);
@@ -1476,7 +1154,7 @@ mod tests {
#[test]
fn parser_rejects_duplicate_profile_names() {
let mut value = parse_example_value();
let mut value = parse_default_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);
@@ -1485,7 +1163,7 @@ mod tests {
#[test]
fn schema_rejects_unknown_endpoint_secret_field() {
let mut value = parse_example_value();
let mut value = parse_default_value();
value["profiles"][0]["solana"]["http_endpoints"][0]["secret_env_field"] =
serde_json::Value::String("KS_SECRET_HELIUS_API_KEY".to_string());
let raw_json = value_to_json(&value);
@@ -1495,7 +1173,7 @@ mod tests {
#[test]
fn schema_rejects_runtime_idls_directory_field() {
let mut value = parse_example_value();
let mut value = parse_default_value();
value["profiles"][0]["data"]["idls_directory"] =
serde_json::Value::String("idls".to_string());
let raw_json = value_to_json(&value);
@@ -1505,7 +1183,7 @@ mod tests {
#[test]
fn schema_rejects_unsupported_transport_surface() {
let mut value = parse_example_value();
let mut value = parse_default_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);
@@ -1514,7 +1192,7 @@ mod tests {
#[test]
fn parser_rejects_invalid_http_endpoint_url() {
let mut value = parse_example_value();
let mut value = parse_default_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);
@@ -1524,7 +1202,7 @@ mod tests {
#[test]
fn parser_rejects_invalid_ws_endpoint_url() {
let mut value = parse_example_value();
let mut value = parse_default_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);
@@ -1532,19 +1210,9 @@ mod tests {
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();
let mut value = parse_default_value();
value["profiles"][0]["wallet"]["temporary_wallet_alias"] =
serde_json::Value::String("../wallet".to_string());
let raw_json = value_to_json(&value);
@@ -1554,7 +1222,7 @@ mod tests {
#[test]
fn parser_rejects_persistent_disabled_temporary_wallet() {
let mut value = parse_example_value();
let mut value = parse_default_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);
@@ -1563,7 +1231,7 @@ mod tests {
#[test]
fn parser_rejects_enabled_cluster_without_spend_limit() {
let mut value = parse_example_value();
let mut value = parse_default_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);
@@ -1579,7 +1247,7 @@ mod tests {
"confirmation_poll_interval_ms",
"confirmation_max_attempts",
] {
let mut value = parse_example_value();
let mut value = parse_default_value();
value["profiles"][0]["execution"][field] =
serde_json::Value::Number(serde_json::Number::from(0));
let raw_json = value_to_json(&value);
@@ -1590,7 +1258,7 @@ mod tests {
#[test]
fn parser_rejects_http_role_with_subscriptions() {
let mut value = parse_example_value();
let mut value = parse_default_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);