Files
khadhroony-solana-project/crates/ksp-logging-lib/src/settings.rs
2026-08-15 20:22:02 +02:00

510 lines
18 KiB
Rust

// file: crates/ksp-logging-lib/src/settings.rs
// version: 4
/// Runtime filter level used by KSP logging settings.
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
pub enum LogFilterLevel {
/// Disables matching logging events and spans.
Off,
/// Enables only error-level events and spans.
Error,
/// Enables warning and error events and spans.
Warn,
/// Enables informational, warning and error events and spans.
Info,
/// Enables debug and less verbose events and spans.
Debug,
/// Enables all KSP logging events and spans.
Trace,
}
/// Per-target filter override owned by Logging for the global KSP takeover policy.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct TargetFilter {
target_prefix: std::string::String,
level: crate::LogFilterLevel,
}
impl TargetFilter {
/// Creates a filter override for a KSP target prefix.
#[must_use]
pub fn new(target_prefix: impl std::convert::Into<std::string::String>, level: crate::LogFilterLevel) -> Self {
return Self { target_prefix: target_prefix.into(), level };
}
/// Returns the configured target prefix.
#[must_use]
pub fn target_prefix(&self) -> &str {
return self.target_prefix.as_str();
}
/// Returns the configured filter level.
#[must_use]
pub const fn level(&self) -> crate::LogFilterLevel {
return self.level;
}
}
/// Lifecycle events emitted for spans by the formatted subscriber.
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
pub enum SpanEvents {
/// Does not synthesize span lifecycle events.
Off,
/// Emits span creation and closure events for timing-oriented diagnostics.
NewAndClose,
/// Emits all supported span lifecycle events.
Full,
}
/// Output format requested for one Logging sink.
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
pub enum LogFormat {
/// Standard human-readable formatter with metadata.
Human,
/// Compact human-readable formatter.
Compact,
/// Expanded pretty human-readable formatter.
Pretty,
/// Structured JSON formatter.
Json,
}
/// Per-output routing filter applied in addition to the global KSP takeover policy.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct OutputFilter {
level: crate::LogFilterLevel,
targets: std::vec::Vec<std::string::String>,
domains: std::vec::Vec<std::string::String>,
}
impl OutputFilter {
/// Creates an explicit per-output filter.
///
/// `targets` contains KSP target prefixes or the single wildcard `*`. `domains` contains domain prefixes or the single wildcard `*`.
#[must_use]
pub fn new(level: crate::LogFilterLevel, targets: std::vec::Vec<std::string::String>, domains: std::vec::Vec<std::string::String>) -> Self {
return Self { level, targets, domains };
}
/// Creates an unrestricted routing filter that does not further constrain the global KSP takeover policy.
#[must_use]
pub fn unrestricted() -> Self {
return Self::new(crate::LogFilterLevel::Trace, std::vec!["*".to_string()], std::vec!["*".to_string()]);
}
/// Returns the maximum verbosity accepted by this output.
#[must_use]
pub const fn level(&self) -> crate::LogFilterLevel {
return self.level;
}
/// Returns target selectors in declaration order.
#[must_use]
pub fn targets(&self) -> &[std::string::String] {
return self.targets.as_slice();
}
/// Returns domain selectors in declaration order.
#[must_use]
pub fn domains(&self) -> &[std::string::String] {
return self.domains.as_slice();
}
}
/// Console stream selected for human-readable logs.
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
pub enum ConsoleOutput {
/// Writes console logs to standard output.
Stdout,
/// Writes console logs to standard error.
Stderr,
}
/// Runtime settings for the optional console output.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ConsoleSettings {
enabled: bool,
output: crate::ConsoleOutput,
ansi: bool,
format: crate::LogFormat,
filter: crate::OutputFilter,
}
impl ConsoleSettings {
/// Creates explicit console settings.
#[must_use]
pub fn new(enabled: bool, output: crate::ConsoleOutput, ansi: bool, format: crate::LogFormat, filter: crate::OutputFilter) -> Self {
return Self { enabled, output, ansi, format, filter };
}
/// Creates enabled standard-output settings compatible with the `0.1.2` runtime behavior.
#[must_use]
pub fn stdout() -> Self {
return Self::new(true, crate::ConsoleOutput::Stdout, false, crate::LogFormat::Human, crate::OutputFilter::unrestricted());
}
/// Creates enabled standard-error settings compatible with the `0.1.2` runtime behavior.
#[must_use]
pub fn stderr() -> Self {
return Self::new(true, crate::ConsoleOutput::Stderr, false, crate::LogFormat::Human, crate::OutputFilter::unrestricted());
}
/// Returns whether this console output is enabled.
#[must_use]
pub const fn enabled(&self) -> bool {
return self.enabled;
}
/// Returns the selected console stream.
#[must_use]
pub const fn output(&self) -> crate::ConsoleOutput {
return self.output;
}
/// Returns whether ANSI formatting is requested for the console output.
#[must_use]
pub const fn ansi(&self) -> bool {
return self.ansi;
}
/// Returns the requested console format.
#[must_use]
pub const fn format(&self) -> crate::LogFormat {
return self.format;
}
/// Returns the routing filter associated with the console output.
#[must_use]
pub const fn filter(&self) -> &crate::OutputFilter {
return &self.filter;
}
}
/// Rotation cadence for a file output.
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
pub enum FileRotation {
/// Keeps a single non-rotating file.
Never,
/// Rotates the file every hour.
Hourly,
/// Rotates the file every day.
Daily,
}
/// Runtime settings for one file output.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct FileSettings {
output_id: std::string::String,
enabled: bool,
directory: std::path::PathBuf,
file_name_prefix: std::string::String,
rotation: crate::FileRotation,
format: crate::LogFormat,
ansi: bool,
filter: crate::OutputFilter,
}
impl FileSettings {
/// Creates explicit file output settings.
#[must_use]
pub fn new(
output_id: impl std::convert::Into<std::string::String>,
enabled: bool,
directory: impl std::convert::Into<std::path::PathBuf>,
file_name_prefix: impl std::convert::Into<std::string::String>,
rotation: crate::FileRotation,
format: crate::LogFormat,
filter: crate::OutputFilter,
) -> Self {
return Self {
output_id: output_id.into(),
enabled,
directory: directory.into(),
file_name_prefix: file_name_prefix.into(),
rotation,
format,
ansi: false,
filter,
};
}
/// Sets whether ANSI formatting is requested and returns the updated settings.
///
/// Persistent file outputs are required to keep this value `false`; [`crate::LoggingSettings::validate`] rejects `true`.
#[must_use]
pub fn with_ansi(mut self, ansi: bool) -> Self {
self.ansi = ansi;
return self;
}
/// Returns the stable output identifier used for diagnostics and reload accounting.
#[must_use]
pub fn output_id(&self) -> &str {
return self.output_id.as_str();
}
/// Returns whether this file output is enabled.
#[must_use]
pub const fn enabled(&self) -> bool {
return self.enabled;
}
/// Returns the directory containing log files.
#[must_use]
pub fn directory(&self) -> &std::path::Path {
return self.directory.as_path();
}
/// Returns the file-name prefix passed to the file appender.
#[must_use]
pub fn file_name_prefix(&self) -> &str {
return self.file_name_prefix.as_str();
}
/// Returns the selected file rotation cadence.
#[must_use]
pub const fn rotation(&self) -> crate::FileRotation {
return self.rotation;
}
/// Returns the requested file format.
#[must_use]
pub const fn format(&self) -> crate::LogFormat {
return self.format;
}
/// Returns whether ANSI formatting was requested for this file output.
#[must_use]
pub const fn ansi(&self) -> bool {
return self.ansi;
}
/// Returns the routing filter associated with this file output.
#[must_use]
pub const fn filter(&self) -> &crate::OutputFilter {
return &self.filter;
}
}
/// Complete runtime settings consumed by `ksp-logging-lib` initialization and reload.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct LoggingSettings {
default_filter: crate::LogFilterLevel,
target_filters: std::vec::Vec<crate::TargetFilter>,
span_events: crate::SpanEvents,
console: std::option::Option<crate::ConsoleSettings>,
files: std::vec::Vec<crate::FileSettings>,
}
impl LoggingSettings {
/// Creates explicit Logging settings without any global target override.
#[must_use]
pub fn new(
default_filter: crate::LogFilterLevel,
span_events: crate::SpanEvents,
console: std::option::Option<crate::ConsoleSettings>,
files: std::vec::Vec<crate::FileSettings>,
) -> Self {
return Self { default_filter, target_filters: std::vec::Vec::new(), span_events, console, files };
}
/// Adds one target-prefix override to the global KSP takeover policy and returns the updated settings.
#[must_use]
pub fn with_target_filter(mut self, target_filter: crate::TargetFilter) -> Self {
self.target_filters.push(target_filter);
return self;
}
/// Returns the default level applied to KSP-owned targets by the global takeover policy.
#[must_use]
pub const fn default_filter(&self) -> crate::LogFilterLevel {
return self.default_filter;
}
/// Returns target-prefix overrides in insertion order.
#[must_use]
pub fn target_filters(&self) -> &[crate::TargetFilter] {
return self.target_filters.as_slice();
}
/// Returns the selected span lifecycle event policy.
#[must_use]
pub const fn span_events(&self) -> crate::SpanEvents {
return self.span_events;
}
/// Returns console settings when the console output is declared.
#[must_use]
pub fn console(&self) -> std::option::Option<&crate::ConsoleSettings> {
return self.console.as_ref();
}
/// Returns all declared file outputs in declaration order.
#[must_use]
pub fn files(&self) -> &[crate::FileSettings] {
return self.files.as_slice();
}
/// Validates backend-independent invariants of the runtime settings.
pub fn validate(&self) -> ksp_core_lib::Result<()> {
for target_filter in &self.target_filters {
if target_filter.target_prefix().is_empty() {
return std::result::Result::Err(
ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_SETTINGS, "target filter prefix must not be empty")
.with_context("field", "target_filters.target_prefix"),
);
}
if !target_filter.target_prefix().starts_with("ksp-") {
return std::result::Result::Err(
ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_SETTINGS, "target filter prefix must identify a KSP-owned target")
.with_context("field", "target_filters.target_prefix")
.with_context("target_prefix", target_filter.target_prefix()),
);
}
}
if let std::option::Option::Some(console) = self.console.as_ref() {
if console.ansi() && console.format() == crate::LogFormat::Json {
return std::result::Result::Err(
ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_SETTINGS, "ANSI formatting is not compatible with JSON console output")
.with_context("field", "console.ansi"),
);
}
let validation_result = validate_output_filter(console.filter(), "console.filter");
if let std::result::Result::Err(error) = validation_result {
return std::result::Result::Err(error);
}
}
for (index, file) in self.files.iter().enumerate() {
let output_id_validation = validate_output_id(file.output_id(), index);
if let std::result::Result::Err(error) = output_id_validation {
return std::result::Result::Err(error);
}
if file.directory().as_os_str().is_empty() {
return std::result::Result::Err(
ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_SETTINGS, "file output directory must not be empty")
.with_context("field", format!("files[{index}].directory"))
.with_context("output_id", file.output_id()),
);
}
if file.file_name_prefix().is_empty() {
return std::result::Result::Err(
ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_SETTINGS, "file name prefix must not be empty")
.with_context("field", format!("files[{index}].file_name_prefix"))
.with_context("output_id", file.output_id()),
);
}
if file.ansi() {
return std::result::Result::Err(
ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_SETTINGS, "ANSI sequences are not allowed in persistent file outputs")
.with_context("field", format!("files[{index}].ansi"))
.with_context("output_id", file.output_id()),
);
}
let filter_validation = validate_output_filter(file.filter(), format!("files[{index}].filter").as_str());
if let std::result::Result::Err(error) = filter_validation {
return std::result::Result::Err(error.with_context("output_id", file.output_id()));
}
for previous in &self.files[..index] {
if previous.output_id() == file.output_id() {
return std::result::Result::Err(
ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_SETTINGS, "file output identifiers must be unique")
.with_context("field", format!("files[{index}].output_id"))
.with_context("output_id", file.output_id()),
);
}
}
}
return std::result::Result::Ok(());
}
}
fn validate_output_id(output_id: &str, index: usize) -> ksp_core_lib::Result<()> {
let mut previous_was_separator = true;
if output_id.is_empty() {
return invalid_output_id(output_id, index);
}
for byte in output_id.bytes() {
if byte == b'.' {
if previous_was_separator {
return invalid_output_id(output_id, index);
}
previous_was_separator = true;
continue;
}
if !(byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_' || byte == b'-') {
return invalid_output_id(output_id, index);
}
previous_was_separator = false;
}
if previous_was_separator {
return invalid_output_id(output_id, index);
}
return std::result::Result::Ok(());
}
fn invalid_output_id(output_id: &str, index: usize) -> ksp_core_lib::Result<()> {
return std::result::Result::Err(
ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_SETTINGS, "file output identifier is invalid")
.with_context("field", format!("files[{index}].output_id"))
.with_context("output_id", output_id),
);
}
fn validate_output_filter(filter: &crate::OutputFilter, field: &str) -> ksp_core_lib::Result<()> {
let target_validation = validate_selectors(filter.targets(), true, format!("{field}.targets").as_str());
if let std::result::Result::Err(error) = target_validation {
return std::result::Result::Err(error);
}
let domain_validation = validate_selectors(filter.domains(), false, format!("{field}.domains").as_str());
if let std::result::Result::Err(error) = domain_validation {
return std::result::Result::Err(error);
}
return std::result::Result::Ok(());
}
fn validate_selectors(selectors: &[std::string::String], target_dimension: bool, field: &str) -> ksp_core_lib::Result<()> {
if selectors.is_empty() {
return std::result::Result::Err(
ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_SETTINGS, "output selector list must not be empty").with_context("field", field),
);
}
if selectors.len() > 1
&& selectors.iter().any(|selector| -> bool {
return selector == "*";
})
{
return std::result::Result::Err(
ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_SETTINGS, "wildcard selector must be used alone").with_context("field", field),
);
}
for (index, selector) in selectors.iter().enumerate() {
if selector.trim().is_empty() {
return std::result::Result::Err(
ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_SETTINGS, "output selector must not be empty")
.with_context("field", format!("{field}[{index}]")),
);
}
if target_dimension && selector != "*" && !selector.starts_with("ksp-") {
return std::result::Result::Err(
ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_SETTINGS, "output target selector must identify a KSP-owned target")
.with_context("field", format!("{field}[{index}]"))
.with_context("selector", selector),
);
}
for previous in &selectors[..index] {
if previous == selector {
return std::result::Result::Err(
ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_SETTINGS, "output selectors must be unique")
.with_context("field", format!("{field}[{index}]"))
.with_context("selector", selector),
);
}
}
}
return std::result::Result::Ok(());
}
#[cfg(test)]
#[path = "../unit_tests/settings.rs"]
mod tests;