// file: crates/ksp-app-config-desk/src/logging_editor.rs // version: 6 //! Typed Logging document projection and validated persistence for the Config Desk Logging editor. use ts_rs::TS; // rust-rules: trait-import /// One Logging sink selector/filter exposed to the editor. #[derive(Clone, Debug, serde::Deserialize, serde::Serialize, TS)] #[serde(rename_all = "camelCase")] #[ts(export, export_to = "../frontend/ts/bindings/ksp_app_config_desk/logging/LoggingOutputFilterDto.ts")] pub(crate) struct LoggingOutputFilterDto { /// Filter level source text. pub(crate) level: String, /// Target selectors in source order. pub(crate) targets: std::vec::Vec, /// Structured domain selectors in source order. pub(crate) domains: std::vec::Vec, } /// Console sink configuration exposed to the editor. #[derive(Clone, Debug, serde::Deserialize, serde::Serialize, TS)] #[serde(rename_all = "camelCase")] #[ts(export, export_to = "../frontend/ts/bindings/ksp_app_config_desk/logging/LoggingConsoleDto.ts")] pub(crate) struct LoggingConsoleDto { /// Whether the console sink is enabled. pub(crate) enabled: bool, /// `stdout` or `stderr` source text. pub(crate) output: String, /// Whether ANSI output is requested. pub(crate) ansi: bool, /// Console format source text. pub(crate) format: String, /// Sink-local selectors. pub(crate) filter: crate::LoggingOutputFilterDto, } /// One persistent file sink exposed to the editor. #[derive(Clone, Debug, serde::Deserialize, serde::Serialize, TS)] #[serde(rename_all = "camelCase")] #[ts(export, export_to = "../frontend/ts/bindings/ksp_app_config_desk/logging/LoggingFileDto.ts")] pub(crate) struct LoggingFileDto { /// Stable sink identifier. pub(crate) output_id: String, /// Whether the file sink is enabled. pub(crate) enabled: bool, /// Source path relative to the Logging root. pub(crate) path: String, /// Rotation source text. pub(crate) rotation: String, /// File format source text. pub(crate) format: String, /// Whether ANSI output is requested. pub(crate) ansi: bool, /// Sink-local selectors. pub(crate) filter: crate::LoggingOutputFilterDto, } /// One global Logging target override exposed to the editor. #[derive(Clone, Debug, serde::Deserialize, serde::Serialize, TS)] #[serde(rename_all = "camelCase")] #[ts(export, export_to = "../frontend/ts/bindings/ksp_app_config_desk/logging/LoggingTargetFilterDto.ts")] pub(crate) struct LoggingTargetFilterDto { /// KSP target prefix. pub(crate) target_prefix: String, /// Override level source text. pub(crate) level: String, } /// One typed Logging profile exposed to the editor. #[derive(Clone, Debug, serde::Deserialize, serde::Serialize, TS)] #[serde(rename_all = "camelCase")] #[ts(export, export_to = "../frontend/ts/bindings/ksp_app_config_desk/logging/LoggingProfileDto.ts")] pub(crate) struct LoggingProfileDto { /// Stable profile identifier. pub(crate) profile_id: String, /// Profile-level default filter source text. pub(crate) default_filter: String, /// Span lifecycle source text. pub(crate) span_events: String, /// Console sink configuration. pub(crate) console: crate::LoggingConsoleDto, /// Persistent file sinks in source order. pub(crate) files: std::vec::Vec, /// Global target overrides in source order. pub(crate) target_filters: std::vec::Vec, } /// Complete typed Logging document exposed to the editor. #[derive(Clone, Debug, serde::Serialize, TS)] #[serde(rename_all = "camelCase")] #[ts(export, export_to = "../frontend/ts/bindings/ksp_app_config_desk/logging/LoggingDocumentDto.ts")] pub(crate) struct LoggingDocumentDto { /// Stable Config file identifier. pub(crate) file_id: String, /// Physical path resolved by Config. pub(crate) path: String, /// Source format version. pub(crate) format_version: u32, /// Source Logging root expression/path. pub(crate) logs_directory: String, /// Autonomous default profile identifier. pub(crate) default_profile: String, /// Typed profiles in source order. pub(crate) profiles: std::vec::Vec, } /// Complete editable Logging candidate accepted from the frontend. #[derive(Clone, Debug, serde::Deserialize, TS)] #[serde(rename_all = "camelCase")] #[ts(export, export_to = "../frontend/ts/bindings/ksp_app_config_desk/logging/LoggingDocumentCandidateDto.ts")] pub(crate) struct LoggingDocumentCandidateDto { /// Source Logging root expression/path. pub(crate) logs_directory: String, /// Autonomous default profile identifier. pub(crate) default_profile: String, /// Typed profiles in source order. pub(crate) profiles: std::vec::Vec, } /// Result of one validated typed Logging persistence operation. #[derive(Clone, Debug, serde::Serialize, TS)] #[serde(rename_all = "camelCase")] #[ts(export, export_to = "../frontend/ts/bindings/ksp_app_config_desk/logging/LoggingDocumentSaveResultDto.ts")] pub(crate) struct LoggingDocumentSaveResultDto { /// Whether `std.logging.json` bytes changed. pub(crate) source_changed: bool, /// Whether Config consumers must reload the document. pub(crate) reload_required: bool, /// Whether the active KSP Logging runtime was hot-reloaded from the persisted candidate. pub(crate) runtime_applied: bool, /// Runtime generation after the successful hot reload. pub(crate) logging_generation: u32, /// Profile selected and applied to the runtime. pub(crate) active_profile: String, /// Fresh typed document loaded after successful persistence and runtime application. pub(crate) document: LoggingDocumentDto, } /// Loads the typed Logging source through ConfigManagement and projects it for the frontend. pub(crate) fn logging_document(state: &crate::AppState) -> ksp_core_lib::Result { return document_from_management(state.config_management()); } /// Validates, atomically persists and hot-reloads one typed Logging candidate through ConfigManagement and `ksp-logging-lib`. pub(crate) fn save_logging_document(state: &crate::AppState, candidate: LoggingDocumentCandidateDto) -> ksp_core_lib::Result { let file_id = ksp_config_lib::ConfigFileId::new(ksp_config_lib::FILE_ID_STD_LOGGING); let file_id = match file_id { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(error), }; let previous = state.config_management().read_source(&file_id); let previous = match previous { std::result::Result::Ok(value) => value.content().to_owned(), std::result::Result::Err(error) => return std::result::Result::Err(error), }; let source = config_candidate(candidate); let report = state.config_management().save_logging_document(&source); let report = match report { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(error), }; let applied = apply_persisted_runtime(state); let (active_profile, logging_generation) = match applied { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => { return rollback_after_runtime_failure(state.config_management(), &file_id, previous.as_str(), report.source_changed(), error); }, }; let document = document_from_management(state.config_management()); let document = match document { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(error), }; ksp_logging_lib::debug!( target: crate::TRACING_TARGET, domain = crate::TRACING_DOMAIN_LOGGING_EDITOR, source_changed = report.source_changed(), reload_required = report.reload_required(), runtime_applied = true, logging_generation, active_profile = active_profile.as_str(), profile_count = document.profiles.len(), default_profile = document.default_profile.as_str(), "typed Logging document persistence and runtime hot reload completed" ); return std::result::Result::Ok(LoggingDocumentSaveResultDto { source_changed: report.source_changed(), reload_required: report.reload_required(), runtime_applied: true, logging_generation, active_profile, document, }); } fn apply_persisted_runtime(state: &crate::AppState) -> ksp_core_lib::Result<(String, u32)> { let environment = ksp_config_lib::ConfigEnvironment::load(); let environment = match environment { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(error), }; let resolved = state.config_management().engine().load_resolved_logging_config(std::option::Option::None, &environment); let resolved = match resolved { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(error), }; let profile_id = resolved.profile_id().to_owned(); let settings = resolved.into_settings(); let generation = state.reinitialize_logging_runtime(profile_id.as_str(), "default_profile", &settings); return match generation { std::result::Result::Ok(value) => std::result::Result::Ok((profile_id, value)), std::result::Result::Err(error) => std::result::Result::Err(error), }; } fn rollback_after_runtime_failure( management: &ksp_config_lib::ConfigManagement, file_id: &ksp_config_lib::ConfigFileId, previous_source: &str, source_changed: bool, runtime_error: ksp_core_lib::Error, ) -> ksp_core_lib::Result { if !source_changed { return std::result::Result::Err(runtime_error); } let rollback = management.save_source_candidate(file_id, previous_source); return match rollback { std::result::Result::Ok(_) => std::result::Result::Err(runtime_error), std::result::Result::Err(rollback_error) => std::result::Result::Err( ksp_core_lib::Error::new( crate::ERROR_CODE_LOGGING_SOURCE_ROLLBACK_FAILED, "Logging runtime hot reload failed and the previous persisted source could not be restored", ) .with_context("runtime_error_domain", runtime_error.code().domain()) .with_context("runtime_error_code", runtime_error.code().code()) .with_source(rollback_error), ), }; } fn document_from_management(management: &ksp_config_lib::ConfigManagement) -> ksp_core_lib::Result { let source = management.load_logging_document(); let source = match source { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(error), }; let file_id = ksp_config_lib::ConfigFileId::new(ksp_config_lib::FILE_ID_STD_LOGGING); let file_id = match file_id { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(error), }; let path = management.engine().registry().resolve_path(management.engine().bootstrap(), &file_id); let path = match path { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(error), }; let mut profiles = std::vec::Vec::::with_capacity(source.profiles().len()); let mut file_count = 0usize; let mut target_filter_count = 0usize; for profile in source.profiles() { file_count += profile.files().len(); target_filter_count += profile.target_filters().len(); profiles.push(project_profile(profile)); } let result = LoggingDocumentDto { file_id: file_id.as_str().to_owned(), path: path.display().to_string(), format_version: source.format_version(), logs_directory: source.logs_directory().to_owned(), default_profile: source.default_profile().to_owned(), profiles, }; ksp_logging_lib::debug!( target: crate::TRACING_TARGET, domain = crate::TRACING_DOMAIN_LOGGING_EDITOR, file_id = result.file_id.as_str(), profile_count = result.profiles.len(), file_count, target_filter_count, "typed Logging document projected for editor" ); return std::result::Result::Ok(result); } fn config_candidate(candidate: LoggingDocumentCandidateDto) -> ksp_config_lib::LoggingConfigDocument { let mut profiles = std::vec::Vec::::with_capacity(candidate.profiles.len()); for profile in candidate.profiles { profiles.push(config_profile(profile)); } return ksp_config_lib::LoggingConfigDocument::new(candidate.logs_directory, candidate.default_profile, profiles); } fn config_profile(profile: crate::LoggingProfileDto) -> ksp_config_lib::LoggingProfileConfig { let mut files = std::vec::Vec::::with_capacity(profile.files.len()); for file in profile.files { let mut config = ksp_config_lib::LoggingFileConfig::new(file.output_id, file.enabled, file.path, file.rotation, file.format, config_output_filter(file.filter)); config.set_ansi(file.ansi); files.push(config); } let mut target_filters = std::vec::Vec::::with_capacity(profile.target_filters.len()); for target_filter in profile.target_filters { target_filters.push(ksp_config_lib::LoggingTargetFilterConfig::new(target_filter.target_prefix, target_filter.level)); } return ksp_config_lib::LoggingProfileConfig::new( profile.profile_id, profile.default_filter, profile.span_events, ksp_config_lib::LoggingConsoleConfig::new( profile.console.enabled, profile.console.output, profile.console.ansi, profile.console.format, config_output_filter(profile.console.filter), ), files, target_filters, ); } fn config_output_filter(filter: crate::LoggingOutputFilterDto) -> ksp_config_lib::LoggingOutputFilterConfig { return ksp_config_lib::LoggingOutputFilterConfig::new(filter.level, filter.targets, filter.domains); } fn project_profile(profile: &ksp_config_lib::LoggingProfileConfig) -> crate::LoggingProfileDto { let mut files = std::vec::Vec::::with_capacity(profile.files().len()); for file in profile.files() { files.push(crate::LoggingFileDto { output_id: file.output_id().to_owned(), enabled: file.enabled(), path: file.path().to_owned(), rotation: file.rotation().to_owned(), format: file.format().to_owned(), ansi: file.ansi(), filter: project_output_filter(file.filter()), }); } let mut target_filters = std::vec::Vec::::with_capacity(profile.target_filters().len()); for target_filter in profile.target_filters() { target_filters.push(crate::LoggingTargetFilterDto { target_prefix: target_filter.target_prefix().to_owned(), level: target_filter.level().to_owned() }); } return crate::LoggingProfileDto { profile_id: profile.profile_id().to_owned(), default_filter: profile.default_filter().to_owned(), span_events: profile.span_events().to_owned(), console: crate::LoggingConsoleDto { enabled: profile.console().enabled(), output: profile.console().output().to_owned(), ansi: profile.console().ansi(), format: profile.console().format().to_owned(), filter: project_output_filter(profile.console().filter()), }, files, target_filters, }; } fn project_output_filter(filter: &ksp_config_lib::LoggingOutputFilterConfig) -> crate::LoggingOutputFilterDto { return crate::LoggingOutputFilterDto { level: filter.level().to_owned(), targets: filter.targets().to_vec(), domains: filter.domains().to_vec(), }; } #[cfg(test)] #[path = "../unit_tests/logging_editor.rs"] mod tests;