v0.1.4-pre.009

This commit is contained in:
2026-08-16 14:39:36 +02:00
parent 675350f5bf
commit cb10fd354b
20 changed files with 940 additions and 24 deletions

View File

@@ -1,5 +1,5 @@
<!-- file: crates/ksp-app-config-desk/README.md -->
<!-- version: 8 -->
<!-- version: 9 -->
# `ksp-app-config-desk`
@@ -25,6 +25,8 @@ Le gabarit desktop actif fournit maintenant :
- le frontend Vanilla TypeScript + Vite sous `frontend/` ;
- SCSS, Bootstrap et Font Awesome ;
- SimpleBar + `resize-observer-polyfill` pour le scrolling du shell ;
- DataTables 3 + Select 4 pour linventaire interactif des documents Config ;
- le panneau Documents générique alimenté par le registre `ksp-config-lib`, avec diagnostics backend et réparation validée ;
- `tauri-plugin-tracing` côté Rust et `@fltsci/tauri-plugin-tracing` côté frontend ;
- les ports dédiés `1430` pour Vite HTTP et `1431` pour le WebSocket de développement ;
- la destination frontend externe commune à Tauri et Vite;

View File

@@ -1,13 +1,14 @@
<!-- file: crates/ksp-app-config-desk/TODO.md -->
<!-- version: 7 -->
<!-- version: 8 -->
# TODO `ksp-app-config-desk`
Le gabarit frontend, le bootstrap Config/Logging, `AppState`, le `LoggingGuard` durable, les premiers DTO TS-RS, le bridge frontend logging KSP et le lifecycle `splash -> main` sont maintenant présents.
Le gabarit frontend, le bootstrap Config/Logging, `AppState`, le `LoggingGuard` durable, les premiers DTO TS-RS, le bridge frontend logging KSP, le lifecycle `splash -> main` et le panneau Documents sont maintenant présents.
Les prochaines tranches doivent encore :
- développer les panneaux Documents, Profils, Environnement/`.env`, Secrets et Logging ;
- introduire DataTables au premier tableau qui exige tri, filtrage, sélection ou checkbox ;
- développer les panneaux Profils, Environnement/`.env`, Secrets et Logging ;
- faire évoluer le runtime Logging vers des fichiers applicatifs uniques par lancement (`app-name.<start-timestamp>.<ext>`) ;
- ramener le target applicatif de `debug` à `info` ou `warn` avant clôture de la release ;
- démontrer le hot reload Logging et le panneau de test de routage ;
- étudier puis intégrer le retour Rust -> console WebKit via `tauri_plugin_tracing::WebviewLayer` dans le subscriber possédé par `ksp-logging-lib`, afin de pouvoir utiliser `attachConsole()` sans second subscriber, double émission ni boucle avec le bridge KSP.

View File

@@ -1,11 +1,11 @@
<!-- file: crates/ksp-app-config-desk/USAGE.md -->
<!-- version: 8 -->
<!-- version: 9 -->
# Utilisation de `ksp-app-config-desk`
## État actuel
Le gabarit Rust/Tauri et le frontend Vite/TypeScript/SCSS sont présents. Le backend initialise désormais Config, le runtime Logging et `AppState`; les panneaux métier Config restent volontairement absents à ce stade.
Le gabarit Rust/Tauri et le frontend Vite/TypeScript/SCSS sont présents. Le backend initialise Config, le runtime Logging et `AppState`. Le panneau Documents est fonctionnel ; les panneaux Profils, Environnement/`.env`, Secrets et Logging restent à compléter.
La fenêtre `splash` est visible au démarrage. Après readiness du frontend et temporisation résolue par Config, l'application effectue le fade-out, affiche/focalise `main` puis détruit `splash`. La fenêtre principale expose immédiatement la navigation monofenêtre de référence.
@@ -136,3 +136,20 @@ Seule la vue d'ensemble consomme déjà `get_app_snapshot`; les autres routes re
## Asset font du splash
La police bot3 de référence est `DOS_Amazigh.ttf`. Vérifier l'asset local contre l'empreinte documentée dans `frontend/fonts/README.md` ; `frontend/sass/splash.scss` l'applique au titre du splash.
## Panneau Documents
La vue **Documents** liste les documents de kind `Config` depuis le registre `ksp-config-lib`; les schemas ne sont pas codés en dur dans le frontend. DataTables fournit tri, recherche, pagination et sélection simple.
Pour chaque document, le backend expose :
- `file_id` ;
- filename mappé ;
- schema associé ;
- path résolu par Config ;
- état valide/invalide ;
- étape de diagnostic `read`, `json`, `schema`, `semantic`, `effective` ou `other`.
La sélection charge le source brut via `ConfigManagement::read_source()`, y compris lorsque le document est invalide mais lisible. Le bouton **Valider et sauvegarder** envoie le texte à `ConfigManagement::save_source_candidate()` : aucune écriture frontend directe du fichier nest effectuée. Un candidat invalide est refusé avant persistence et le diagnostic backend reste lautorité de validation.
Les clics, sélections, chargements, remplacements DOM et sauvegardes sont tracés via le bridge frontend KSP sans journaliser le contenu du source.

View File

@@ -1,5 +1,5 @@
<!-- file: crates/ksp-app-config-desk/frontend/main.html -->
<!-- version: 3 -->
<!-- version: 4 -->
<!DOCTYPE html>
<html lang="fr">
@@ -55,6 +55,67 @@
</dl>
</section>
<section id="documentsPanel" hidden aria-label="Documents Config">
<div class="d-flex flex-wrap gap-2 align-items-center justify-content-between mb-3">
<div>
<h2 class="h5 mb-1">Documents enregistrés</h2>
<p class="text-body-secondary small mb-0">Le registre, la validation et la persistence restent exclusivement sous contrôle de ksp-config-lib.</p>
</div>
<button id="refreshDocuments" class="btn btn-outline-primary btn-sm" type="button">
<i class="fa-solid fa-rotate me-1" aria-hidden="true"></i>
Rafraîchir
</button>
</div>
<div class="table-responsive mb-4">
<table id="configDocumentsTable" class="table table-striped table-hover align-middle w-100">
<thead>
<tr>
<th scope="col">file_id</th>
<th scope="col">Fichier</th>
<th scope="col">Schema</th>
<th scope="col">État</th>
<th scope="col">Étape</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
<section id="documentDetailPanel" hidden class="border rounded p-3 mb-4 bg-body-tertiary" aria-label="Détail du document Config">
<div class="d-flex flex-wrap gap-2 justify-content-between align-items-start mb-3">
<div>
<h2 class="h5 mb-1">Détail du document</h2>
<span id="documentDetailStatus" class="badge text-bg-secondary"></span>
</div>
<div class="d-flex gap-2">
<button id="reloadDocumentSource" class="btn btn-outline-secondary btn-sm" type="button">Recharger</button>
<button id="saveDocumentSource" class="btn btn-primary btn-sm" type="button">Valider et sauvegarder</button>
</div>
</div>
<dl class="row small mb-3 app-document-metadata">
<dt class="col-lg-3">file_id</dt>
<dd id="documentDetailFileId" class="col-lg-9"></dd>
<dt class="col-lg-3">Fichier mappé</dt>
<dd id="documentDetailFilename" class="col-lg-9"></dd>
<dt class="col-lg-3">Schema</dt>
<dd id="documentDetailSchema" class="col-lg-9"></dd>
<dt class="col-lg-3">Path résolu</dt>
<dd id="documentDetailPath" class="col-lg-9"></dd>
<dt class="col-lg-3">Étape diagnostic</dt>
<dd id="documentDetailStage" class="col-lg-9"></dd>
<dt class="col-lg-3 mb-0">Diagnostic</dt>
<dd id="documentDetailDiagnostic" class="col-lg-9 mb-0"></dd>
</dl>
<label class="form-label fw-semibold" for="documentSourceEditor">Source brut Config</label>
<textarea id="documentSourceEditor" class="form-control app-document-source" rows="18" spellcheck="false" autocomplete="off"></textarea>
<div class="form-text">La sauvegarde est refusée tant que le candidat échoue au parsing JSON, au schema ou aux invariants sémantiques backend.</div>
</section>
<div id="documentsStatus" class="alert alert-primary mb-0" role="status" aria-live="polite">Chargement de l'inventaire Config...</div>
</section>
<section id="placeholderPanel" hidden aria-live="polite">
<div class="alert alert-secondary mb-4" role="status">
Cette route du shell est prête ; son contenu fonctionnel sera ajouté dans la prerelease prévue par le plan 0.1.4.

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-app-config-desk/frontend/sass/_app.scss
// version: 2
// version: 3
$app-header-height: 72px;
$app-footer-height: 42px;
@@ -52,7 +52,7 @@ body {
}
.app-shell-card {
max-width: 980px;
max-width: 1320px;
}
.app-shell-status {
@@ -72,3 +72,22 @@ body {
.app-overview-list dd {
font-family: var(--bs-font-monospace);
}
.app-document-metadata dd,
.app-document-source,
#configDocumentsTable td:first-child,
#configDocumentsTable td:nth-child(2),
#configDocumentsTable td:nth-child(3) {
font-family: var(--bs-font-monospace);
}
.app-document-source {
min-height: 320px;
resize: vertical;
white-space: pre;
tab-size: 4;
}
#configDocumentsTable tbody tr {
cursor: pointer;
}

View File

@@ -0,0 +1,279 @@
// file: crates/ksp-app-config-desk/frontend/ts/documents.ts
// version: 1
//! Documents panel backed exclusively by ksp-config-lib inventory, validation and management APIs.
import DataTable from "datatables.net-bs5";
import "datatables.net-select-bs5";
import "datatables.net-bs5/css/dataTables.bootstrap5.css";
import "datatables.net-select-bs5/css/select.bootstrap5.css";
import type { ConfigDocumentDetailDto } from "./bindings/ksp_app_config_desk/documents/ConfigDocumentDetailDto";
import type { ConfigDocumentErrorDto } from "./bindings/ksp_app_config_desk/documents/ConfigDocumentErrorDto";
import type { ConfigDocumentSaveResultDto } from "./bindings/ksp_app_config_desk/documents/ConfigDocumentSaveResultDto";
import type { ConfigDocumentSummaryDto } from "./bindings/ksp_app_config_desk/documents/ConfigDocumentSummaryDto";
import { frontendDebug, frontendTrace, frontendWarn } from "./frontend_log";
import { invokeKsp } from "./invoke";
interface DocumentsDataTable {
destroy(): unknown;
on(event: string, callback: (event: unknown, api: unknown, type: string, indexes: number[]) => void): unknown;
row(selector: number): { node(): Node | null };
}
let documentsTable: DocumentsDataTable | null = null;
let activeDocument: ConfigDocumentDetailDto | null = null;
const stageLabels: Record<string, string> = {
valid: "Valide",
read: "Lecture",
json: "JSON",
schema: "Schema",
semantic: "Sémantique",
effective: "Effective",
other: "Autre",
};
function stageLabel(stage: string): string {
return stageLabels[stage] ?? stage;
}
function setText(selector: string, value: string): void {
const element = document.querySelector<HTMLElement>(selector);
if (element) {
element.textContent = value;
}
}
function setDocumentsStatus(message: string, tone: "primary" | "success" | "warning" | "danger" = "primary"): void {
const status = document.querySelector<HTMLElement>("#documentsStatus");
if (!status) {
return;
}
status.className = `alert alert-${tone} mb-0`;
status.textContent = message;
frontendTrace("main", "Documents panel status replaced", { tone });
}
function diagnosticMessage(summary: ConfigDocumentSummaryDto): string {
if (!summary.diagnostic) {
return "Aucun diagnostic backend.";
}
return `${summary.diagnostic.domain}.${summary.diagnostic.code}${summary.diagnostic.message}`;
}
function renderDetail(detail: ConfigDocumentDetailDto): void {
activeDocument = detail;
setText("#documentDetailFileId", detail.summary.fileId);
setText("#documentDetailFilename", detail.summary.filename);
setText("#documentDetailSchema", detail.summary.schemaFileId ?? "—");
setText("#documentDetailPath", detail.summary.path);
setText("#documentDetailStage", stageLabel(detail.summary.diagnosticStage));
setText("#documentDetailDiagnostic", diagnosticMessage(detail.summary));
const badge = document.querySelector<HTMLElement>("#documentDetailStatus");
if (badge) {
const valid = detail.summary.validationStatus === "valid";
badge.className = `badge ${valid ? "text-bg-success" : "text-bg-danger"}`;
badge.textContent = valid ? "Valide" : "Invalide";
}
const editor = document.querySelector<HTMLTextAreaElement>("#documentSourceEditor");
if (editor) {
editor.value = detail.source ?? "";
editor.disabled = detail.source === null;
}
const saveButton = document.querySelector<HTMLButtonElement>("#saveDocumentSource");
if (saveButton) {
saveButton.disabled = detail.source === null;
}
const detailPanel = document.querySelector<HTMLElement>("#documentDetailPanel");
if (detailPanel) {
detailPanel.hidden = false;
}
frontendTrace("main", "Config document detail rendered", {
fileId: detail.summary.fileId,
validationStatus: detail.summary.validationStatus,
diagnosticStage: detail.summary.diagnosticStage,
sourceAvailable: detail.source !== null,
});
}
function clearDetail(): void {
activeDocument = null;
const detailPanel = document.querySelector<HTMLElement>("#documentDetailPanel");
if (detailPanel) {
detailPanel.hidden = true;
}
frontendTrace("main", "Config document detail cleared");
}
async function loadDetail(fileId: string, source: "selection" | "reload"): Promise<void> {
frontendDebug("main", "Config document detail requested", { fileId, source });
try {
const detail = await invokeKsp<ConfigDocumentDetailDto>("main", "get_config_document_detail", { fileId });
renderDetail(detail);
setDocumentsStatus(`Document ${fileId} chargé depuis Config.`, "success");
} catch (caughtError) {
const error = asDocumentError(caughtError);
const message = error
? `${stageLabel(error.diagnosticStage)} : ${error.error.domain}.${error.error.code}${error.error.message}`
: "Le détail du document n'a pas pu être chargé.";
setDocumentsStatus(message, "danger");
}
}
function populateTableRows(documents: ConfigDocumentSummaryDto[]): void {
const body = document.querySelector<HTMLTableSectionElement>("#configDocumentsTable tbody");
if (!body) {
return;
}
body.replaceChildren();
for (const summary of documents) {
const row = document.createElement("tr");
row.dataset.fileId = summary.fileId;
appendCell(row, summary.fileId);
appendCell(row, summary.filename);
appendCell(row, summary.schemaFileId ?? "—");
appendCell(row, summary.validationStatus === "valid" ? "Valide" : "Invalide");
appendCell(row, stageLabel(summary.diagnosticStage));
body.append(row);
}
frontendTrace("main", "Config document inventory table DOM replaced", { documentCount: documents.length });
}
function appendCell(row: HTMLTableRowElement, text: string): void {
const cell = document.createElement("td");
cell.textContent = text;
row.append(cell);
}
function initializeDataTable(): void {
if (documentsTable) {
documentsTable.destroy();
documentsTable = null;
}
const table = new DataTable("#configDocumentsTable", {
order: [[0, "asc"]],
pageLength: 10,
select: { style: "single" },
language: {
emptyTable: "Aucun document Config enregistré.",
info: "_START_ à _END_ sur _TOTAL_ document(s)",
infoEmpty: "0 document",
lengthMenu: "Afficher _MENU_",
search: "Filtrer :",
zeroRecords: "Aucun document correspondant.",
},
}) as unknown as DocumentsDataTable;
table.on("select", (event, api, type, indexes) => {
void event;
void api;
if (type !== "row" || indexes.length === 0) {
return;
}
const node = table.row(indexes[0]).node();
if (!(node instanceof HTMLTableRowElement)) {
return;
}
const fileId = node.dataset.fileId;
frontendTrace("main", "Config document table row selected", { fileId: fileId ?? null });
if (fileId) {
void loadDetail(fileId, "selection");
}
});
documentsTable = table;
frontendTrace("main", "DataTables Documents initialized", { selection: "single" });
}
async function refreshInventory(): Promise<void> {
frontendDebug("main", "Config document inventory refresh requested");
clearDetail();
try {
const documents = await invokeKsp<ConfigDocumentSummaryDto[]>("main", "get_config_documents");
populateTableRows(documents);
initializeDataTable();
setDocumentsStatus(`${documents.length} document(s) Config évalué(s) par le backend.`, "success");
} catch {
setDocumentsStatus("L'inventaire Config n'a pas pu être chargé.", "danger");
}
}
async function reloadActiveDocument(): Promise<void> {
if (!activeDocument) {
return;
}
frontendDebug("main", "Config document reload clicked", { fileId: activeDocument.summary.fileId });
await loadDetail(activeDocument.summary.fileId, "reload");
}
async function saveActiveDocument(): Promise<void> {
if (!activeDocument) {
return;
}
const editor = document.querySelector<HTMLTextAreaElement>("#documentSourceEditor");
if (!editor) {
return;
}
const fileId = activeDocument.summary.fileId;
frontendDebug("main", "Config document validated save clicked", { fileId });
setDocumentsStatus(`Validation et sauvegarde de ${fileId} en cours...`, "primary");
try {
const result = await invokeKsp<ConfigDocumentSaveResultDto>("main", "save_config_document_source", {
fileId,
source: editor.value,
});
renderDetail(result.document);
const outcome = result.sourceChanged ? "source modifiée" : "source inchangée";
setDocumentsStatus(`Sauvegarde validée par Config : ${outcome}; reload_required=${result.reloadRequired}.`, "success");
frontendDebug("main", "Config document validated save completed", {
fileId,
sourceChanged: result.sourceChanged,
reloadRequired: result.reloadRequired,
});
} catch (caughtError) {
const error = asDocumentError(caughtError);
if (error) {
setDocumentsStatus(
`${stageLabel(error.diagnosticStage)} : ${error.error.domain}.${error.error.code}${error.error.message}`,
"danger",
);
frontendWarn("main", "Config document candidate rejected by backend", { fileId, diagnosticStage: error.diagnosticStage });
return;
}
setDocumentsStatus("Le candidat Config a été rejeté sans diagnostic exploitable.", "danger");
}
}
function asDocumentError(value: unknown): ConfigDocumentErrorDto | null {
if (typeof value !== "object" || value === null) {
return null;
}
const candidate = value as Record<string, unknown>;
if (typeof candidate.diagnosticStage !== "string" || typeof candidate.error !== "object" || candidate.error === null) {
return null;
}
const error = candidate.error as Record<string, unknown>;
if (typeof error.domain !== "string" || typeof error.code !== "string" || typeof error.message !== "string") {
return null;
}
return value as ConfigDocumentErrorDto;
}
function bindDocumentActions(): void {
document.querySelector<HTMLButtonElement>("#refreshDocuments")?.addEventListener("click", () => {
frontendTrace("main", "Documents refresh button clicked");
void refreshInventory();
});
document.querySelector<HTMLButtonElement>("#reloadDocumentSource")?.addEventListener("click", () => {
frontendTrace("main", "Document reload button clicked");
void reloadActiveDocument();
});
document.querySelector<HTMLButtonElement>("#saveDocumentSource")?.addEventListener("click", () => {
frontendTrace("main", "Document save button clicked");
void saveActiveDocument();
});
frontendTrace("main", "Documents panel handlers installed");
}
export function initializeDocumentsPanel(): void {
bindDocumentActions();
void refreshInventory();
}

View File

@@ -7,6 +7,7 @@ import "simplebar";
import { getCurrentWindow } from "@tauri-apps/api/window";
import type { AppSnapshotDto } from "./bindings/ksp_app_config_desk/dto_common/AppSnapshotDto.ts";
import { frontendDebug, frontendInfo, frontendTrace, installFrontendConsoleBridge } from "./frontend_log";
import { initializeDocumentsPanel } from "./documents";
import { invokeKsp } from "./invoke";
(window as Window & typeof globalThis & { ResizeObserver?: typeof ResizeObserver }).ResizeObserver = ResizeObserver;
@@ -21,7 +22,7 @@ const viewCopy: Record<ViewId, { title: string; description: string }> = {
},
documents: {
title: "Documents",
description: "Inventaire, diagnostics et réparation des documents Config — surface fonctionnelle prévue en pre.009.",
description: "Inventaire, diagnostics et réparation validée des documents Config enregistrés.",
},
profiles: {
title: "Profils",
@@ -69,9 +70,13 @@ function activateView(viewId: ViewId, source: "startup" | "user"): void {
if (overview) {
overview.hidden = viewId !== "overview";
}
const documents = document.querySelector<HTMLElement>("#documentsPanel");
if (documents) {
documents.hidden = viewId !== "documents";
}
const placeholder = document.querySelector<HTMLElement>("#placeholderPanel");
if (placeholder) {
placeholder.hidden = viewId === "overview";
placeholder.hidden = viewId === "overview" || viewId === "documents";
}
frontendTrace("main", "Main view DOM updated", { viewId, source });
}
@@ -130,6 +135,7 @@ async function initializeMain(): Promise<void> {
const windowLabel = getCurrentWindow().label;
frontendInfo("main", "Config Desk main frontend loaded", { windowLabel });
bindNavigation();
initializeDocumentsPanel();
activateView("overview", "startup");
try {
await loadSnapshot();

View File

@@ -14,6 +14,8 @@
"@fortawesome/fontawesome-free": "^7.3",
"@tauri-apps/api": "^2.11",
"bootstrap": "^5.3",
"datatables.net-bs5": "^3.0",
"datatables.net-select-bs5": "^4.0",
"resize-observer-polyfill": "^1.5",
"simplebar": "^6.3"
},

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-app-config-desk/src/app_state.rs
// version: 3
// version: 4
//! Shared backend state owned by the Tauri application.
@@ -104,6 +104,12 @@ impl AppState {
});
}
/// Returns the Config management facade owned by the application state.
#[must_use]
pub(crate) const fn config_management(&self) -> &ksp_config_lib::ConfigManagement {
return &self.config_management;
}
/// Returns the resolved splash timings captured during application bootstrap.
#[must_use]
pub(crate) const fn splash_settings(&self) -> crate::SplashSettings {

View File

@@ -17,3 +17,5 @@ pub(crate) const TRACING_DOMAIN_BOOTSTRAP: &str = "config.bootstrap";
pub(crate) const TRACING_DOMAIN_FRONTEND: &str = "frontend";
/// Structured domain used by Tauri window lifecycle operations.
pub(crate) const TRACING_DOMAIN_WINDOWS: &str = "desktop.window";
/// Structured domain for Config document inventory, diagnostics and repair.
pub(crate) const TRACING_DOMAIN_DOCUMENTS: &str = "config.documents";

View File

@@ -0,0 +1,275 @@
// 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<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 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 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_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 = 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;

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-app-config-desk/src/errors.rs
// version: 4
// version: 5
//! Application-local error codes for the configuration desktop shell.
@@ -24,3 +24,6 @@ pub(crate) const ERROR_CODE_TAURI_WINDOW_MISSING: ksp_core_lib::ErrorCode = ksp_
/// A Tauri window show/focus/destroy/event operation failed.
pub(crate) const ERROR_CODE_TAURI_WINDOW_OPERATION_FAILED: ksp_core_lib::ErrorCode =
ksp_core_lib::ErrorCode::new("config_desk", "tauri_window_operation_failed");
/// A Documents command requested a registered file that is not a Config-kind document.
pub(crate) const ERROR_CODE_DOCUMENT_KIND_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config_desk", "document_kind_invalid");

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-app-config-desk/src/lib.rs
// version: 5
// version: 6
//! Tauri desktop application for managing and validating KSP configuration.
@@ -10,6 +10,7 @@
mod app_state;
mod bootstrap;
mod constants;
mod documents;
mod dto_common;
mod errors;
mod frontend_logging;
@@ -25,16 +26,22 @@ pub(crate) use self::app_state::AppState;
pub(crate) use self::bootstrap::config_management;
pub(crate) use self::bootstrap::initialize_logging;
pub(crate) use self::constants::TRACING_DOMAIN_BOOTSTRAP;
pub(crate) use self::constants::TRACING_DOMAIN_DOCUMENTS;
pub(crate) use self::constants::TRACING_DOMAIN_FRONTEND;
pub(crate) use self::constants::TRACING_DOMAIN_WINDOWS;
pub(crate) use self::constants::TRACING_TARGET;
pub(crate) use self::constants::TRACING_TARGET_FRONTEND;
pub(crate) use self::constants::TRACING_TARGET_FRONTEND_MAIN;
pub(crate) use self::constants::TRACING_TARGET_FRONTEND_SPLASH;
pub(crate) use self::documents::ConfigDocumentDetailDto;
pub(crate) use self::documents::ConfigDocumentErrorDto;
pub(crate) use self::documents::ConfigDocumentSaveResultDto;
pub(crate) use self::documents::ConfigDocumentSummaryDto;
pub(crate) use self::dto_common::AppSnapshotDto;
pub(crate) use self::dto_common::CommandErrorDto;
pub(crate) use self::errors::ERROR_CODE_APP_STATE_INVALID;
pub(crate) use self::errors::ERROR_CODE_APP_STATE_LOCK_FAILED;
pub(crate) use self::errors::ERROR_CODE_DOCUMENT_KIND_INVALID;
pub(crate) use self::errors::ERROR_CODE_FRONTEND_LOG_LEVEL_INVALID;
pub(crate) use self::errors::ERROR_CODE_FRONTEND_LOG_TARGET_INVALID;
pub(crate) use self::errors::ERROR_CODE_LOGGING_BOOTSTRAP_FAILED;

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-app-config-desk/src/tauri.rs
// version: 6
// version: 7
//! Tauri runtime assembly for the KSP configuration desktop application.
@@ -37,7 +37,14 @@ fn configure_plugins(builder: tauri::Builder<tauri::Wry>) -> tauri::Builder<taur
#[allow(clippy::question_mark_used)] // Tauri generates the question-mark operator internally for async command dispatch.
fn configure_commands(builder: tauri::Builder<tauri::Wry>) -> tauri::Builder<tauri::Wry> {
return builder.invoke_handler(tauri::generate_handler![get_app_snapshot, emit_frontend_log, splash_frontend_ready]);
return builder.invoke_handler(tauri::generate_handler![
get_app_snapshot,
get_config_documents,
get_config_document_detail,
save_config_document_source,
emit_frontend_log,
splash_frontend_ready
]);
}
fn configure_setup(builder: tauri::Builder<tauri::Wry>) -> tauri::Builder<tauri::Wry> {
@@ -63,6 +70,28 @@ fn get_app_snapshot(state: tauri::State<'_, crate::AppState>) -> std::result::Re
};
}
#[tauri::command]
fn get_config_documents(state: tauri::State<'_, crate::AppState>) -> std::vec::Vec<crate::ConfigDocumentSummaryDto> {
return crate::documents::inventory(&state);
}
#[tauri::command]
fn get_config_document_detail(
file_id: String,
state: tauri::State<'_, crate::AppState>,
) -> std::result::Result<crate::ConfigDocumentDetailDto, crate::ConfigDocumentErrorDto> {
return crate::documents::detail(&state, file_id.as_str());
}
#[tauri::command]
fn save_config_document_source(
file_id: String,
source: String,
state: tauri::State<'_, crate::AppState>,
) -> std::result::Result<crate::ConfigDocumentSaveResultDto, crate::ConfigDocumentErrorDto> {
return crate::documents::save_source(&state, file_id.as_str(), source.as_str());
}
#[tauri::command]
fn emit_frontend_log(payload: crate::FrontendLogPayloadDto) -> std::result::Result<(), crate::CommandErrorDto> {
let result = crate::emit_frontend_log_event(payload);

View File

@@ -0,0 +1,32 @@
// file: crates/ksp-app-config-desk/unit_tests/documents.rs
// version: 1
use super::*;
#[test]
fn diagnostic_classifier_distinguishes_json_schema_semantic_and_effective_errors() {
let json = ksp_core_lib::Error::new(ksp_config_lib::ERROR_CODE_JSON_SYNTAX_INVALID, "json");
assert_eq!(classify_error(&json), STAGE_JSON);
let schema = ksp_core_lib::Error::new(ksp_config_lib::ERROR_CODE_SCHEMA_VALIDATION_FAILED, "schema");
assert_eq!(classify_error(&schema), STAGE_SCHEMA);
let semantic = ksp_core_lib::Error::new(ksp_config_lib::ERROR_CODE_DOCUMENT_SEMANTIC_INVALID, "semantic");
assert_eq!(classify_error(&semantic), STAGE_SEMANTIC);
let effective = ksp_core_lib::Error::new(ksp_config_lib::ERROR_CODE_EFFECTIVE_CONFIG_INVALID, "effective");
assert_eq!(classify_error(&effective), STAGE_EFFECTIVE);
}
#[test]
fn schema_source_read_errors_are_classified_as_schema_failures() {
let error = ksp_core_lib::Error::new(ksp_config_lib::ERROR_CODE_JSON_FILE_READ_FAILED, "missing schema").with_context("file_id", "schema.std.logging");
assert_eq!(classify_error(&error), STAGE_SCHEMA);
}
#[test]
fn document_error_projection_does_not_expose_arbitrary_error_context() {
let error = ksp_core_lib::Error::new(ksp_config_lib::ERROR_CODE_JSON_SYNTAX_INVALID, "invalid source").with_context("detail", "sensitive-canary");
let projection = ConfigDocumentErrorDto::from_error(&error);
assert_eq!(projection.diagnostic_stage, STAGE_JSON);
assert_eq!(projection.error.domain, "config");
assert_eq!(projection.error.code, "json_syntax_invalid");
assert_eq!(projection.error.message, "invalid source");
}