276 lines
12 KiB
Rust
276 lines
12 KiB
Rust
// file: crates/ksp-app-config-desk/src/documents.rs
|
|
// version: 3
|
|
|
|
//! Generic Config document inventory, diagnostics and validated repair services.
|
|
|
|
use ts_rs::TS; // rust-rules: trait-import
|
|
|
|
const STAGE_EFFECTIVE: &str = "effective";
|
|
const STAGE_JSON: &str = "json";
|
|
const STAGE_OTHER: &str = "other";
|
|
const STAGE_READ: &str = "read";
|
|
const STAGE_SCHEMA: &str = "schema";
|
|
const STAGE_SEMANTIC: &str = "semantic";
|
|
const STAGE_VALID: &str = "valid";
|
|
const STATUS_INVALID: &str = "invalid";
|
|
const STATUS_VALID: &str = "valid";
|
|
|
|
/// 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<String>,
|
|
/// 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<crate::CommandErrorDto>,
|
|
}
|
|
|
|
/// 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<String>,
|
|
}
|
|
|
|
/// 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 document_inventory(state: &crate::AppState) -> std::vec::Vec<ConfigDocumentSummaryDto> {
|
|
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 document_detail(state: &crate::AppState, file_id_text: &str) -> std::result::Result<ConfigDocumentDetailDto, ConfigDocumentErrorDto> {
|
|
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_document_source(
|
|
state: &crate::AppState,
|
|
file_id_text: &str,
|
|
source: &str,
|
|
) -> std::result::Result<ConfigDocumentSaveResultDto, ConfigDocumentErrorDto> {
|
|
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 = crate::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<ksp_config_lib::ConfigFileId> {
|
|
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;
|