// 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, 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, domains: std::vec::Vec, } 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, domains: std::vec::Vec) -> 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, enabled: bool, directory: impl std::convert::Into, file_name_prefix: impl std::convert::Into, 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, span_events: crate::SpanEvents, console: std::option::Option, files: std::vec::Vec, } 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, files: std::vec::Vec, ) -> 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;