// file: crates/ksp-app-config-desk/src/documents.rs // version: 1 //! Generic Config document inventory, diagnostics and validated repair services. use ts_rs::TS; // rust-rules: derive-import const STATUS_VALID: &str = "valid"; const STATUS_INVALID: &str = "invalid"; const STAGE_VALID: &str = "valid"; const STAGE_READ: &str = "read"; const STAGE_JSON: &str = "json"; const STAGE_SCHEMA: &str = "schema"; const STAGE_SEMANTIC: &str = "semantic"; const STAGE_EFFECTIVE: &str = "effective"; const STAGE_OTHER: &str = "other"; /// Safe inventory row for one Config document registered by `ksp-config-lib`. #[derive(Clone, Debug, serde::Serialize, TS)] #[serde(rename_all = "camelCase")] #[ts(export, export_to = "../frontend/ts/bindings/ksp_app_config_desk/documents/ConfigDocumentSummaryDto.ts")] pub(crate) struct ConfigDocumentSummaryDto { /// Stable logical Config identifier. pub(crate) file_id: String, /// Current Config-managed relative filename mapping. pub(crate) filename: String, /// Registered validation schema identifier. pub(crate) schema_file_id: std::option::Option, /// Resolved filesystem path controlled by Config. pub(crate) path: String, /// `valid` or `invalid` according to the backend Config authority. pub(crate) validation_status: String, /// Backend diagnostic stage: valid/read/json/schema/semantic/effective/other. pub(crate) diagnostic_stage: String, /// Safe bounded diagnostic when the document is invalid. pub(crate) diagnostic: std::option::Option, } /// Detailed management view for one registered Config document. #[derive(Clone, Debug, serde::Serialize, TS)] #[serde(rename_all = "camelCase")] #[ts(export, export_to = "../frontend/ts/bindings/ksp_app_config_desk/documents/ConfigDocumentDetailDto.ts")] pub(crate) struct ConfigDocumentDetailDto { /// Current validation/inventory summary. pub(crate) summary: ConfigDocumentSummaryDto, /// Raw Config-managed source when the file can be read, even if its JSON/schema/semantics are invalid. pub(crate) source: std::option::Option, } /// Result of one validated raw-source repair attempt that reached persistence successfully. #[derive(Clone, Debug, serde::Serialize, TS)] #[serde(rename_all = "camelCase")] #[ts(export, export_to = "../frontend/ts/bindings/ksp_app_config_desk/documents/ConfigDocumentSaveResultDto.ts")] pub(crate) struct ConfigDocumentSaveResultDto { /// Whether persisted source bytes changed. pub(crate) source_changed: bool, /// Whether Config consumers need a reload to observe the changed document. pub(crate) reload_required: bool, /// Fresh detail reloaded after persistence. pub(crate) document: ConfigDocumentDetailDto, } /// Safe document-specific command error with backend-owned diagnostic classification. #[derive(Clone, Debug, serde::Serialize, TS)] #[serde(rename_all = "camelCase")] #[ts(export, export_to = "../frontend/ts/bindings/ksp_app_config_desk/documents/ConfigDocumentErrorDto.ts")] pub(crate) struct ConfigDocumentErrorDto { /// Backend diagnostic stage associated with the failure. pub(crate) diagnostic_stage: String, /// Bounded KSP error projection without arbitrary context/source values. pub(crate) error: crate::CommandErrorDto, } impl ConfigDocumentErrorDto { fn from_error(error: &ksp_core_lib::Error) -> Self { return Self { diagnostic_stage: classify_error(error).to_owned(), error: crate::CommandErrorDto::from_error(error) }; } } /// Lists all Config-kind documents from the Config registry and evaluates their current backend validation state. pub(crate) fn inventory(state: &crate::AppState) -> std::vec::Vec { let management = state.config_management(); let engine = management.engine(); let mut documents = std::vec::Vec::new(); for descriptor in engine.registry().descriptors() { if descriptor.kind() != ksp_config_lib::ConfigFileKind::Config { continue; } documents.push(build_summary(management, descriptor)); } ksp_logging_lib::debug!( target: crate::TRACING_TARGET, domain = crate::TRACING_DOMAIN_DOCUMENTS, document_count = documents.len(), "Config document inventory evaluated" ); return documents; } /// Loads one Config document detail, including raw source when Config can read it. pub(crate) fn detail(state: &crate::AppState, file_id_text: &str) -> std::result::Result { let file_id = parse_config_file_id(file_id_text); let file_id = match file_id { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(ConfigDocumentErrorDto::from_error(&error)), }; let management = state.config_management(); let descriptor = management.engine().registry().descriptor(&file_id); let descriptor = match descriptor { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(ConfigDocumentErrorDto::from_error(&error)), }; if descriptor.kind() != ksp_config_lib::ConfigFileKind::Config { let error = ksp_core_lib::Error::new(crate::ERROR_CODE_DOCUMENT_KIND_INVALID, "requested document is not a Config-kind document"); return std::result::Result::Err(ConfigDocumentErrorDto::from_error(&error)); } let summary = build_summary(management, descriptor); let source = management.read_source(&file_id); let source = match source { std::result::Result::Ok(value) => std::option::Option::Some(value.content().to_owned()), std::result::Result::Err(_) => std::option::Option::None, }; ksp_logging_lib::debug!( target: crate::TRACING_TARGET, domain = crate::TRACING_DOMAIN_DOCUMENTS, file_id = file_id.as_str(), validation_status = summary.validation_status.as_str(), diagnostic_stage = summary.diagnostic_stage.as_str(), source_available = source.is_some(), "Config document detail loaded" ); return std::result::Result::Ok(ConfigDocumentDetailDto { summary, source }); } /// Validates and atomically persists one raw Config source candidate, then reloads the document detail. pub(crate) fn save_source( state: &crate::AppState, file_id_text: &str, source: &str, ) -> std::result::Result { let file_id = parse_config_file_id(file_id_text); let file_id = match file_id { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(ConfigDocumentErrorDto::from_error(&error)), }; let report = state.config_management().save_source_candidate(&file_id, source); let report = match report { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(ConfigDocumentErrorDto::from_error(&error)), }; let document = detail(state, file_id.as_str()); let document = match document { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(error), }; ksp_logging_lib::info!( target: crate::TRACING_TARGET, domain = crate::TRACING_DOMAIN_DOCUMENTS, file_id = file_id.as_str(), source_changed = report.source_changed(), reload_required = report.reload_required(), "validated Config document source candidate persisted" ); return std::result::Result::Ok(ConfigDocumentSaveResultDto { source_changed: report.source_changed(), reload_required: report.reload_required(), document, }); } fn parse_config_file_id(value: &str) -> ksp_core_lib::Result { let file_id = ksp_config_lib::ConfigFileId::new(value.to_owned()); let file_id = match file_id { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(error), }; return std::result::Result::Ok(file_id); } fn build_summary(management: &ksp_config_lib::ConfigManagement, descriptor: &ksp_config_lib::ConfigFileDescriptor) -> ConfigDocumentSummaryDto { let engine = management.engine(); let path = engine.registry().resolve_path(engine.bootstrap(), descriptor.file_id()); let path = match path { std::result::Result::Ok(value) => value.to_string_lossy().into_owned(), std::result::Result::Err(_) => descriptor.filename().to_string_lossy().into_owned(), }; let validation = validate_effective_document(management, descriptor.file_id()); let (validation_status, diagnostic_stage, diagnostic) = match validation { std::result::Result::Ok(()) => (STATUS_VALID.to_owned(), STAGE_VALID.to_owned(), std::option::Option::None), std::result::Result::Err(error) => { (STATUS_INVALID.to_owned(), classify_error(&error).to_owned(), std::option::Option::Some(crate::CommandErrorDto::from_error(&error))) }, }; return ConfigDocumentSummaryDto { file_id: descriptor.file_id().as_str().to_owned(), filename: descriptor.filename().to_string_lossy().into_owned(), schema_file_id: descriptor.schema_file_id().map(|value| { return value.as_str().to_owned(); }), path, validation_status, diagnostic_stage, diagnostic, }; } fn validate_effective_document(management: &ksp_config_lib::ConfigManagement, file_id: &ksp_config_lib::ConfigFileId) -> ksp_core_lib::Result<()> { let validated = management.engine().load_validated_document(file_id); match validated { std::result::Result::Ok(_) => {}, std::result::Result::Err(error) => return std::result::Result::Err(error), } if file_id.as_str() != ksp_config_lib::FILE_ID_STD_LOGGING { return std::result::Result::Ok(()); } 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 = management.engine().load_resolved_logging_config(std::option::Option::None, &environment); return match resolved { std::result::Result::Ok(_) => std::result::Result::Ok(()), std::result::Result::Err(error) => std::result::Result::Err(error), }; } fn classify_error(error: &ksp_core_lib::Error) -> &'static str { let code = error.code(); if code == ksp_config_lib::ERROR_CODE_JSON_FILE_READ_FAILED { if error_context_file_id_is_schema(error) { return STAGE_SCHEMA; } return STAGE_READ; } if code == ksp_config_lib::ERROR_CODE_JSON_SYNTAX_INVALID { if error_context_file_id_is_schema(error) { return STAGE_SCHEMA; } return STAGE_JSON; } if code == ksp_config_lib::ERROR_CODE_SCHEMA_INVALID || code == ksp_config_lib::ERROR_CODE_SCHEMA_VALIDATION_FAILED { return STAGE_SCHEMA; } if code == ksp_config_lib::ERROR_CODE_DOCUMENT_SEMANTIC_INVALID || code == ksp_config_lib::ERROR_CODE_PROFILE_NOT_FOUND || code == ksp_config_lib::ERROR_CODE_COMPOSITE_REFERENCE_INVALID { return STAGE_SEMANTIC; } if code == ksp_config_lib::ERROR_CODE_EFFECTIVE_CONFIG_INVALID || code == ksp_config_lib::ERROR_CODE_ENVIRONMENT_PLACEHOLDER_INVALID || code == ksp_config_lib::ERROR_CODE_ENVIRONMENT_VALUE_INVALID || code == ksp_config_lib::ERROR_CODE_ENVIRONMENT_VARIABLE_INVALID || code == ksp_config_lib::ERROR_CODE_ENVIRONMENT_VARIABLE_MISSING || code == ksp_config_lib::ERROR_CODE_DOTENV_FILE_READ_FAILED || code == ksp_config_lib::ERROR_CODE_DOTENV_SYNTAX_INVALID { return STAGE_EFFECTIVE; } return STAGE_OTHER; } fn error_context_file_id_is_schema(error: &ksp_core_lib::Error) -> bool { for field in error.context() { if field.key() == "file_id" { return field.value().starts_with("schema."); } } return false; } #[cfg(test)] #[path = "../unit_tests/documents.rs"] mod tests;