v0.1.4-pre.015-fix.002

This commit is contained in:
2026-08-16 18:20:32 +02:00
parent 05c65a12f4
commit 9ad61b40e1
25 changed files with 776 additions and 210 deletions

View File

@@ -6,7 +6,7 @@ resolver = "3"
members = ["crates/ksp-app-config-desk", "crates/ksp-config-lib", "crates/ksp-core-lib", "crates/ksp-logging-lib"]
[workspace.package]
version = "0.1.4-pre.15.fix.1"
version = "0.1.4-pre.15.fix.2"
edition = "2024"
license = "MIT"
repository = "https://git.sasedev.com/Sasedev/khadhroony-solana-project"

View File

@@ -53,7 +53,7 @@
"format": "json",
"output_id": "file.config.error",
"path": "config/ksp-config-errors.jsonl",
"rotation": "daily"
"rotation": "hourly"
}
],
"profile_id": "local_dev",

View File

@@ -1,5 +1,5 @@
<!-- file: crates/ksp-app-config-desk/README.md -->
<!-- version: 17 -->
<!-- version: 18 -->
# `ksp-app-config-desk`
@@ -119,7 +119,7 @@ Au démarrage, l'application construit `ConfigBootstrapOptions`, `ConfigFileRegi
Si le document Logging, sa résolution environnementale ou l'initialisation du runtime configuré échoue avant installation du subscriber, Config Desk installe un fallback **transitoire** en mémoire : console stderr, niveau `Info`, aucun fichier, aucun span lifecycle. Ce fallback n'est jamais persisté et son diagnostic frontend est limité à `domain`, `code` et `message`.
Le `LoggingGuard` est conservé dans `AppState` pour préparer le hot reload ultérieur.
Le `LoggingGuard` est conservé dans `AppState` et sert désormais au hot reload transactionnel du runtime Logging après sauvegarde.
## Lifecycle des fenêtres
@@ -132,7 +132,7 @@ KSP_DESK_SPLASH_FADE_MS=300
Après la readiness frontend, Rust émet `fade_in`, attend la durée minimale, émet `fade_out`, attend la durée de fade puis `tw_main.rs` affiche/focalise `main` avant destruction du splash. Le backend journalise en `debug` les valeurs résolues, leur provenance (`process`, `dotenv` ou `fallback`), chaque attente réellement observée et la durée totale. Avec `12000/3000`, la durée backend attendue entre readiness et activation de `main` est donc d'environ `15000 ms`. Une valeur splash invalide ne bloque pas le manager : des timings de fallback sûrs restent en mémoire afin que la future surface `.env` puisse permettre la réparation.
Le shell principal expose les cinq routes de référence `Vue d'ensemble`, `Documents`, `Profils`, `Environnement / .env` et `Logging`. Le logo porte déjà l'identité KSP ; le texte du header suit donc la forme `Config Desk — <vue active>` au lieu de répéter `KSP`. Les quelques commandes principales restent des pills/tabs à droite ; un dropdown sera préféré lorsqu'une application possède trop de commandes pour conserver ce format lisible. La route Logging expose maintenant un brouillon typé éditable et sa persistence atomique ; le hot reload runtime reste réservé à `pre.016`.
Le shell principal expose les cinq routes de référence `Vue d'ensemble`, `Documents`, `Profils`, `Environnement / .env` et `Logging`. Le logo porte déjà l'identité KSP ; le texte du header suit donc la forme `Config Desk — <vue active>` au lieu de répéter `KSP`. Les quelques commandes principales restent des pills/tabs à droite ; un dropdown sera préféré lorsqu'une application possède trop de commandes pour conserver ce format lisible. La route Logging expose maintenant un brouillon typé éditable, sa persistence atomique et lapplication runtime immédiate par hot reload.
## Panneau Profils
@@ -145,7 +145,7 @@ L'inspection expose le profil par défaut ou une sélection explicite, les vues
La vue **Logging** charge le document standard exclusivement avec `ConfigManagement::load_logging_document()`. Rust mappe ensuite les types publics `LoggingConfigDocument`, `LoggingProfileConfig`, `LoggingConsoleConfig`, `LoggingFileConfig`, `LoggingOutputFilterConfig` et `LoggingTargetFilterConfig` vers des DTO TS-RS dédiés. Le frontend ne parse donc ni le JSON source ni son schema.
Le panneau expose `format_version`, `logs_directory`, `default_profile`, tous les profils, la console, les fichiers persistants, les filtres locaux, les target overrides et les listes de targets/domains. En `pre.015`, le frontend maintient un brouillon typé : create/clone/rename/delete de profils, 0/1/N file sinks et target filters restent locaux jusqu'à **Sauvegarder**. Le backend reconstruit les types publics Config et appelle `ConfigManagement::save_logging_document()`, qui valide la totalité du candidat avant remplacement atomique. **Sauvegarder** et **Recharger le document** n'agissent que sur la source persistée : le subscriber `tracing` actif conserve la configuration installée au démarrage. Un redémarrage de l'application applique la source persistée ; le hot reload sans redémarrage est réservé à `pre.016`.
Le panneau expose `format_version`, `logs_directory`, `default_profile`, tous les profils, la console, les fichiers persistants, les filtres locaux, les target overrides et les listes de targets/domains. En `pre.015`, le frontend maintient un brouillon typé : create/clone/rename/delete de profils, 0/1/N file sinks et target filters restent locaux jusqu'à **Sauvegarder et appliquer**. Le backend reconstruit les types publics Config et appelle `ConfigManagement::save_logging_document()`, qui valide la totalité du candidat avant remplacement atomique. Après persistence, Config Desk recharge un `ConfigEnvironment` frais, résout le `default_profile`, puis appelle `ksp_logging_lib::reinitialize()` sur le `LoggingGuard` actif. Le hot reload est immédiat et `logging_generation` avance uniquement après succès. Si l'application runtime échoue, l'ancien runtime reste actif et la source précédente est restaurée. **Recharger le document** ne modifie que le brouillon/source persistée.
## Traçabilité frontend

View File

@@ -1,14 +1,13 @@
<!-- file: crates/ksp-app-config-desk/TODO.md -->
<!-- version: 14 -->
<!-- version: 15 -->
# 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, le lifecycle `splash -> main`, les panneaux Documents, Profils/provenance et Environnement avec management `.env`/reveal Secret privilégié, ainsi que l'édition typée/persistence atomique du document Logging 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`, les panneaux Documents, Profils/provenance et Environnement avec management `.env`/reveal Secret privilégié, ainsi que l'édition typée/persistence atomique du document Logging et son hot reload transactionnel sont maintenant présents.
Les prochaines tranches doivent encore :
- ajouter le hot reload du Logging editor puis son panneau de test ;
- faire évoluer le runtime Logging vers des fichiers applicatifs uniques par lancement (`app-name.<start-timestamp>.<ext>`) ;
- compléter le runtime Logging par les fichiers uniques par lancement (`app-name.<start-timestamp>.<ext>`) et la sélection explicite du profil à appliquer ;
- 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 ;
- démontrer le hot reload Logging avec 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: 17 -->
<!-- version: 18 -->
# Utilisation de `ksp-app-config-desk`
## État actuel
Le gabarit Rust/Tauri et le frontend Vite/TypeScript/SCSS sont présents. Le backend initialise Config, le runtime Logging et `AppState`. Les panneaux Documents, Profils et Environnement/`.env` sont fonctionnels, y compris les mutations `.env` create/update/remove et les reveals Secrets privilégiés. Logging expose maintenant un brouillon typé éditable et une persistence atomique ; seul le hot reload runtime reste à compléter.
Le gabarit Rust/Tauri et le frontend Vite/TypeScript/SCSS sont présents. Le backend initialise Config, le runtime Logging et `AppState`. Les panneaux Documents, Profils et Environnement/`.env` sont fonctionnels, y compris les mutations `.env` create/update/remove et les reveals Secrets privilégiés. Logging expose maintenant un brouillon typé éditable, une persistence atomique et un hot reload runtime sans redémarrage.
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.
@@ -131,7 +131,7 @@ Environnement / .env
Logging
```
Les cinq vues sont fonctionnelles. Logging charge le contrat typé `std.logging.json`, permet de modifier son brouillon puis de le persister via Config ; son hot reload runtime reste désactivé jusqu'à `pre.016`. Le header affiche `Config Desk — <vue active>` : le logo fournit déjà l'identité KSP. Les commandes principales peu nombreuses utilisent des pills/tabs alignées à droite ; une application plus chargée devra préférer un dropdown. Chaque clic de tab est tracé en `trace`, l'activation utilisateur significative reste tracée en `debug`, et chaque remplacement/rendu de section en `trace`. Les appels Tauri partagés utilisent `frontend/ts/invoke.ts`, qui journalise le début et la fin d'une commande sans journaliser ses arguments.
Les cinq vues sont fonctionnelles. Logging charge le contrat typé `std.logging.json`, permet de modifier son brouillon, de le persister via Config puis de l'appliquer immédiatement au runtime KSP sans redémarrage. Le header affiche `Config Desk — <vue active>` : le logo fournit déjà l'identité KSP. Les commandes principales peu nombreuses utilisent des pills/tabs alignées à droite ; une application plus chargée devra préférer un dropdown. Chaque clic de tab est tracé en `trace`, l'activation utilisateur significative reste tracée en `debug`, et chaque remplacement/rendu de section en `trace`. Les appels Tauri partagés utilisent `frontend/ts/invoke.ts`, qui journalise le début et la fin d'une commande sans journaliser ses arguments.
## Asset font du splash
@@ -210,5 +210,5 @@ La vue **Logging** appelle `get_logging_document`. Le backend charge le document
- tous les fichiers (`output_id`, path, rotation, format, ANSI et filtre) ;
- tous les `target_filters` globaux.
Le sélecteur **Profil édité** travaille sur un brouillon local. `logs_directory`, `default_profile`, profils, console, file sinks, filtres et target overrides sont modifiables. **Créer**, **Cloner**, **Renommer** et **Supprimer** agissent d'abord sur le brouillon ; la suppression de profil est confirmée par modal Bootstrap. **Sauvegarder** envoie un candidat typé à `save_logging_document`, qui reconstruit les contrats Config et persiste atomiquement après validation. **Recharger le document** resynchronise le brouillon depuis la source persistée et demande confirmation si des changements non sauvegardés existent. Ni cette action ni **Sauvegarder** ne reconstruisent le subscriber `tracing` actif en `pre.015` : la configuration persistée prend effet après redémarrage de l'application ; le hot reload sans redémarrage est livré en `pre.016`. Les messages Cargo/Tauri/Vite affichés par `cargo tauri dev` sont externes au runtime Logging KSP et ne dépendent pas de `console.enabled`.
Le sélecteur **Profil édité** travaille sur un brouillon local. `logs_directory`, `default_profile`, profils, console, file sinks, filtres et target overrides sont modifiables. **Créer**, **Cloner**, **Renommer** et **Supprimer** agissent d'abord sur le brouillon ; la suppression de profil est confirmée par modal Bootstrap. **Sauvegarder et appliquer** envoie un candidat typé à `save_logging_document`, qui reconstruit les contrats Config, persiste atomiquement après validation, recharge un `ConfigEnvironment` frais, résout le `default_profile` et hot-reload le `LoggingGuard`. Si le runtime ne peut pas être préparé/rechargé, l'ancien runtime reste actif et la source précédente est restaurée. **Recharger le document** resynchronise uniquement le brouillon depuis la source persistée et demande confirmation si des changements non sauvegardés existent. Les messages Cargo/Tauri/Vite affichés par `cargo tauri dev` sont externes au runtime Logging KSP et ne dépendent pas de `console.enabled`.

View File

@@ -1,5 +1,5 @@
<!-- file: crates/ksp-app-config-desk/frontend/main.html -->
<!-- version: 12 -->
<!-- version: 13 -->
<!DOCTYPE html>
<html lang="fr">
@@ -300,8 +300,8 @@
<section id="loggingPanel" hidden aria-label="Logging Config">
<div class="d-flex flex-wrap gap-2 align-items-center justify-content-between mb-3">
<div>
<h2 class="h5 mb-1">Logging editor — mutations/persistence</h2>
<p class="text-body-secondary small mb-0">Brouillon typé de <code>std.logging.json</code>, validé et persisté atomiquement par <code>ksp-config-lib</code>. Sauvegarder ou recharger le document ne reconfigure pas le runtime Logging déjà actif.</p>
<h2 class="h5 mb-1">Logging editor — mutations/persistence/hot reload</h2>
<p class="text-body-secondary small mb-0">Brouillon typé de <code>std.logging.json</code>, validé et persisté atomiquement par <code>ksp-config-lib</code>. <strong>Sauvegarder et appliquer</strong> hot-reload le runtime KSP sans redémarrage ; <strong>Recharger le document</strong> ne touche qu'au brouillon.</p>
</div>
<div class="d-flex gap-2">
<button id="refreshLoggingDocument" class="btn btn-outline-primary btn-sm" type="button">
@@ -412,7 +412,7 @@
<div class="d-flex align-items-center justify-content-between gap-2 mb-2">
<div>
<h3 class="h6 mb-0">Fichiers</h3>
<div class="form-text">0, 1 ou plusieurs sinks persistants. Les suppressions restent dans le brouillon jusqu'à Sauvegarder.</div>
<div class="form-text">0, 1 ou plusieurs sinks persistants. Les suppressions restent dans le brouillon jusqu'à Sauvegarder et appliquer.</div>
</div>
<button id="addLoggingFile" class="btn btn-outline-primary btn-sm" type="button" disabled>Ajouter un fichier</button>
</div>
@@ -438,9 +438,9 @@
</section>
<div class="alert alert-warning small py-2 mb-2" role="note">
<strong>Runtime actif :</strong> le subscriber <code>tracing</code> a été construit au démarrage de l'application. Les boutons <strong>Sauvegarder</strong> et <strong>Recharger le document</strong> agissent uniquement sur <code>std.logging.json</code>. Pour appliquer la source persistée en <code>pre.015</code>, il faut redémarrer l'application ; le hot reload sans redémarrage arrive en <code>pre.016</code>.
<strong>Runtime actif :</strong> <strong>Sauvegarder et appliquer</strong> persiste le candidat puis hot-reload immédiatement le subscriber KSP avec un environnement frais. En cas d'échec d'application, l'ancien runtime reste actif et la source précédente est restaurée. <strong>Recharger le document</strong> resynchronise uniquement le brouillon.
</div>
<div id="loggingSaveReport" class="small text-body-secondary mb-2">Aucune sauvegarde effectuée pendant ce lancement ; runtime actif inchangé.</div>
<div id="loggingSaveReport" class="small text-body-secondary mb-2">Aucune sauvegarde/application effectuée pendant ce lancement.</div>
<div id="loggingStatus" class="alert alert-primary mb-0" role="status" aria-live="polite">Chargement du document Logging typé...</div>
</section>
@@ -481,7 +481,7 @@
</div>
<div class="modal-body">
<p class="mb-2">Retirer le profil <code id="loggingProfileDeleteName" class="font-monospace"></code> du brouillon Logging ?</p>
<p class="text-body-secondary small mb-0">Le fichier n'est pas modifié tant que Sauvegarder n'est pas déclenché.</p>
<p class="text-body-secondary small mb-0">Le fichier n'est pas modifié tant que Sauvegarder et appliquer n'est pas déclenché.</p>
</div>
<div class="modal-footer">
<button class="btn btn-outline-secondary" type="button" data-bs-dismiss="modal">Annuler</button>

View File

@@ -1,7 +1,7 @@
// file: crates/ksp-app-config-desk/frontend/ts/logging.ts
// version: 3
// version: 4
//! Typed Logging editor backed exclusively by ConfigManagement validation and persistence.
//! Typed Logging editor backed by ConfigManagement persistence and KSP-owned runtime hot reload.
import { Modal } from "bootstrap";
import type { LoggingDocumentCandidateDto } from "./bindings/ksp_app_config_desk/logging/LoggingDocumentCandidateDto";
@@ -503,8 +503,8 @@ async function saveLoggingDocument(): Promise<void> {
draft.logsDirectory = inputValue("#loggingLogsDirectoryInput");
draft.defaultProfile = selectedValue("#loggingDefaultProfileSelect");
setEditorEnabled(false);
setLoggingStatus("Validation et sauvegarde atomique du document Logging...");
frontendDebug("main", "Typed Logging document persistence requested", {
setLoggingStatus("Validation, sauvegarde atomique et application hot reload du Logging...");
frontendDebug("main", "Typed Logging document persistence and runtime apply requested", {
profileCount: draft.profiles.length,
defaultProfile: draft.defaultProfile,
fileCount: draft.profiles.reduce((count, profile) => count + profile.files.length, 0),
@@ -512,21 +512,28 @@ async function saveLoggingDocument(): Promise<void> {
try {
const result = await invokeKsp<LoggingDocumentSaveResultDto>("main", "save_logging_document", { candidate: draft });
renderDocument(result.document);
setText("#loggingSaveReport", `source_changed=${result.sourceChanged} · reload_required=${result.reloadRequired} · document persisté · runtime actif inchangé`);
setText(
"#loggingSaveReport",
`source_changed=${result.sourceChanged} · reload_required=${result.reloadRequired} · runtime_applied=${result.runtimeApplied} · generation=${result.loggingGeneration} · active_profile=${result.activeProfile}`,
);
setLoggingStatus(
result.sourceChanged
? "Document Logging validé et persisté. Le subscriber actif conserve la configuration du démarrage jusqu'au redémarrage de l'application ou au hot reload de pre.016."
: "Document Logging valide mais source inchangée. Le runtime actif n'a pas été reconfiguré.",
`Document Logging validé, persisté et appliqué sans redémarrage. Runtime génération ${result.loggingGeneration}, profil ${result.activeProfile}.`,
"success",
);
frontendDebug("main", "Typed Logging document persistence completed", {
frontendDebug("main", "Typed Logging document persistence and runtime apply completed", {
sourceChanged: result.sourceChanged,
reloadRequired: result.reloadRequired,
runtimeApplied: result.runtimeApplied,
loggingGeneration: result.loggingGeneration,
activeProfile: result.activeProfile,
profileCount: result.document.profiles.length,
});
} catch {
setEditorEnabled(true);
setLoggingStatus("Candidat Logging refusé. std.logging.json reste inchangé ; corrige le brouillon puis réessaie.", "danger");
setLoggingStatus(
"Sauvegarde/application Logging refusée. Le runtime précédent est conservé ; si la persistence avait commencé, la transaction restaure la source précédente ou remonte une erreur de rollback.",
"danger",
);
}
}
@@ -562,7 +569,7 @@ async function refreshLoggingDocument(source: "startup" | "user"): Promise<void>
const documentDto = await invokeKsp<LoggingDocumentDto>("main", "get_logging_document");
renderDocument(documentDto);
setLoggingStatus(
`${documentDto.profiles.length} profil(s) Logging chargé(s). Le brouillon est synchronisé avec la source persistée ; le runtime actif reste celui du démarrage.`,
`${documentDto.profiles.length} profil(s) Logging chargé(s). Le brouillon est synchronisé avec la source persistée ; cette action ne modifie pas le runtime actif.`,
"success",
);
frontendDebug("main", "Logging document reload completed", {

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-app-config-desk/src/app_state.rs
// version: 4
// version: 5
//! Shared backend state owned by the Tauri application.
@@ -110,6 +110,29 @@ impl AppState {
return &self.config_management;
}
/// Replaces the active Logging runtime with freshly resolved settings and advances the runtime generation only after success.
pub(crate) fn reinitialize_logging_runtime(&self, profile_id: &str, settings: &ksp_logging_lib::LoggingSettings) -> ksp_core_lib::Result<u32> {
let runtime = self.logging_runtime.lock();
let mut runtime = match runtime {
std::result::Result::Ok(value) => value,
std::result::Result::Err(_) => {
return std::result::Result::Err(ksp_core_lib::Error::new(
crate::ERROR_CODE_APP_STATE_LOCK_FAILED,
"Config Desk Logging runtime state lock is poisoned",
));
},
};
let reinitialized = ksp_logging_lib::reinitialize(&mut runtime.guard, settings);
if let std::result::Result::Err(error) = reinitialized {
return std::result::Result::Err(error);
}
runtime.active_profile_id = std::option::Option::Some(profile_id.to_owned());
runtime.generation = runtime.generation.saturating_add(1);
runtime.fallback_active = false;
runtime.startup_diagnostic = std::option::Option::None;
return std::result::Result::Ok(runtime.generation);
}
/// Returns the resolved splash timings captured during application bootstrap.
#[must_use]
pub(crate) const fn splash_settings(&self) -> crate::SplashSettings {

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-app-config-desk/src/errors.rs
// version: 7
// version: 8
//! Application-local error codes for the configuration desktop shell.
@@ -7,6 +7,9 @@
pub(crate) const ERROR_CODE_TAURI_RUNTIME_FAILED: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config_desk", "tauri_runtime_failed");
/// Config Desk could not install the managed Logging runtime or its safe fallback.
pub(crate) const ERROR_CODE_LOGGING_BOOTSTRAP_FAILED: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config_desk", "logging_bootstrap_failed");
/// Config Desk persisted a Logging candidate but could not restore the previous source after runtime application failed.
pub(crate) const ERROR_CODE_LOGGING_SOURCE_ROLLBACK_FAILED: ksp_core_lib::ErrorCode =
ksp_core_lib::ErrorCode::new("config_desk", "logging_source_rollback_failed");
/// Shared Config Desk application state is internally inconsistent.
pub(crate) const ERROR_CODE_APP_STATE_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config_desk", "app_state_invalid");
/// Shared Config Desk runtime state cannot be locked safely.

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-app-config-desk/src/lib.rs
// version: 11
// version: 12
//! Tauri desktop application for managing and validating KSP configuration.
@@ -55,6 +55,7 @@ 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;
pub(crate) use self::errors::ERROR_CODE_LOGGING_SOURCE_ROLLBACK_FAILED;
pub(crate) use self::errors::ERROR_CODE_PROFILE_CONTRACT_MISSING;
pub(crate) use self::errors::ERROR_CODE_PROFILE_PROJECTION_FAILED;
pub(crate) use self::errors::ERROR_CODE_SECRET_REVEAL_REQUIRES_SECRET;

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-app-config-desk/src/logging_editor.rs
// version: 2
// version: 3
//! Typed Logging document projection and validated persistence for the Config Desk Logging editor.
@@ -127,7 +127,13 @@ pub(crate) struct LoggingDocumentSaveResultDto {
pub(crate) source_changed: bool,
/// Whether Config consumers must reload the document.
pub(crate) reload_required: bool,
/// Fresh typed document loaded after successful persistence.
/// 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,
}
@@ -136,14 +142,31 @@ pub(crate) fn document(state: &crate::AppState) -> ksp_core_lib::Result<LoggingD
return document_from_management(state.config_management());
}
/// Validates and atomically persists one typed Logging candidate through ConfigManagement.
/// Validates, atomically persists and hot-reloads one typed Logging candidate through ConfigManagement and `ksp-logging-lib`.
pub(crate) fn save(state: &crate::AppState, candidate: LoggingDocumentCandidateDto) -> ksp_core_lib::Result<LoggingDocumentSaveResultDto> {
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,
@@ -154,17 +177,68 @@ pub(crate) fn save(state: &crate::AppState, candidate: LoggingDocumentCandidateD
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 completed; runtime was not reinitialized"
"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(), &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<LoggingDocumentSaveResultDto> {
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<LoggingDocumentDto> {
let source = management.load_logging_document();
let source = match source {

View File

@@ -1,36 +1,31 @@
// file: crates/ksp-app-config-desk/unit_tests/logging_editor.rs
// version: 2
// version: 3
#[test]
fn committed_logging_document_maps_complete_read_only_editor_contract() {
let management = committed_management();
assert!(management.is_ok(), "committed management should construct: {management:?}");
if let std::result::Result::Ok(management) = management {
let source = management.load_logging_document();
assert!(source.is_ok(), "typed Logging source should load: {source:?}");
let document = super::document_from_management(&management);
assert!(document.is_ok(), "Logging editor document should load: {document:?}");
if let std::result::Result::Ok(document) = document {
if let (std::result::Result::Ok(source), std::result::Result::Ok(document)) = (source, document) {
assert_eq!(document.file_id, ksp_config_lib::FILE_ID_STD_LOGGING);
assert_eq!(document.format_version, 1);
assert_eq!(document.logs_directory, "${KSP_LOGS_DIRECTORY:-logs}");
assert_eq!(document.default_profile, "local_dev");
assert_eq!(document.profiles.len(), 1);
let profile = &document.profiles[0];
assert_eq!(profile.profile_id, "local_dev");
assert_eq!(profile.default_filter, "warn");
assert_eq!(profile.span_events, "new_and_close");
assert!(profile.console.enabled);
assert_eq!(profile.console.output, "stderr");
assert_eq!(profile.console.format, "compact");
assert_eq!(profile.console.filter.level, "debug");
assert_eq!(profile.console.filter.targets, std::vec!["*".to_owned()]);
assert_eq!(profile.console.filter.domains, std::vec!["*".to_owned()]);
assert_eq!(profile.files.len(), 2);
assert_eq!(profile.files[0].output_id, "file.all.debug");
assert_eq!(profile.files[0].filter.targets, std::vec!["*".to_owned()]);
assert_eq!(profile.files[0].filter.domains, std::vec!["*".to_owned()]);
assert_eq!(profile.target_filters.len(), 3);
assert_eq!(profile.target_filters[2].target_prefix, "ksp-app-config-desk");
assert_eq!(profile.target_filters[2].level, "debug");
assert_eq!(document.format_version, source.format_version());
assert_eq!(document.logs_directory, source.logs_directory());
assert_eq!(document.default_profile, source.default_profile());
assert_eq!(document.profiles.len(), source.profiles().len());
for (profile, source_profile) in document.profiles.iter().zip(source.profiles()) {
assert_eq!(profile.profile_id, source_profile.profile_id());
assert_eq!(profile.default_filter, source_profile.default_filter());
assert_eq!(profile.span_events, source_profile.span_events());
assert_eq!(profile.console.enabled, source_profile.console().enabled());
assert_eq!(profile.console.output, source_profile.console().output());
assert_eq!(profile.console.format, source_profile.console().format());
assert_eq!(profile.files.len(), source_profile.files().len());
assert_eq!(profile.target_filters.len(), source_profile.target_filters().len());
}
}
}
}
@@ -80,7 +75,14 @@ fn editor_candidate_round_trips_through_typed_config_contract() {
assert_eq!(config.logs_directory(), document.logs_directory);
assert_eq!(config.default_profile(), document.default_profile);
assert_eq!(config.profiles().len(), document.profiles.len());
assert_eq!(config.profiles()[0].files()[0].ansi(), document.profiles[0].files[0].ansi);
for (config_profile, document_profile) in config.profiles().iter().zip(document.profiles.iter()) {
assert_eq!(config_profile.profile_id(), document_profile.profile_id);
assert_eq!(config_profile.files().len(), document_profile.files.len());
for (config_file, document_file) in config_profile.files().iter().zip(document_profile.files.iter()) {
assert_eq!(config_file.output_id(), document_file.output_id);
assert_eq!(config_file.ansi(), document_file.ansi);
}
}
}
}
}
@@ -131,3 +133,92 @@ fn profile_candidate_preserves_multi_file_and_target_filter_shape() {
assert_eq!(config.target_filters().len(), 1);
assert_eq!(config.target_filters()[0].level(), "trace");
}
#[test]
fn runtime_failure_rollback_restores_previous_logging_source() {
let fixture = rollback_fixture();
let fixture = match fixture {
std::result::Result::Ok(value) => value,
std::result::Result::Err(_) => return,
};
let (root, management, file_id, previous) = fixture;
let mut candidate = management.load_logging_document();
assert!(candidate.is_ok(), "rollback fixture should load: {candidate:?}");
if let std::result::Result::Ok(candidate) = candidate.as_mut() {
candidate.set_logs_directory(format!("temporary-runtime-failure-{}", std::process::id()));
}
if let std::result::Result::Ok(candidate) = candidate {
let saved = management.save_logging_document(&candidate);
assert!(saved.is_ok(), "rollback fixture mutation should persist: {saved:?}");
if let std::result::Result::Ok(saved) = saved {
let runtime_error = ksp_core_lib::Error::new(ksp_logging_lib::ERROR_CODE_RELOAD_FAILED, "synthetic runtime reload failure");
let rolled_back = super::rollback_after_runtime_failure(&management, &file_id, previous.as_str(), saved.source_changed(), runtime_error);
assert!(rolled_back.is_err(), "runtime failure should remain visible after successful source rollback");
if let std::result::Result::Err(error) = rolled_back {
assert_eq!(error.code(), ksp_logging_lib::ERROR_CODE_RELOAD_FAILED);
}
assert_eq!(std::fs::read_to_string(root.join("config/std.logging.json")).ok(), std::option::Option::Some(previous));
}
}
let cleanup = std::fs::remove_dir_all(root.as_path());
assert!(cleanup.is_ok(), "rollback fixture should cleanup: {cleanup:?}");
}
fn rollback_fixture() -> ksp_core_lib::Result<(std::path::PathBuf, ksp_config_lib::ConfigManagement, ksp_config_lib::ConfigFileId, String)> {
let root = std::env::temp_dir().join(format!("ksp-config-desk-logging-rollback-{}", std::process::id()));
let cleanup = std::fs::remove_dir_all(root.as_path());
if let std::result::Result::Err(error) = cleanup
&& error.kind() != std::io::ErrorKind::NotFound
{
return std::result::Result::Err(
ksp_core_lib::Error::new(crate::ERROR_CODE_APP_STATE_INVALID, "cannot cleanup previous rollback fixture").with_source(error),
);
}
let config_root = root.join("config");
let schema_root = config_root.join("schemas");
let create = std::fs::create_dir_all(schema_root.as_path());
if let std::result::Result::Err(error) = create {
return std::result::Result::Err(ksp_core_lib::Error::new(crate::ERROR_CODE_APP_STATE_INVALID, "cannot create rollback fixture").with_source(error));
}
let workspace = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..");
let source_config = workspace.join("config/std.logging.json");
let source_schema = workspace.join("config/schemas/std.logging.schema.json");
let config_path = config_root.join("std.logging.json");
let schema_path = schema_root.join("std.logging.schema.json");
let copied = std::fs::copy(source_config.as_path(), config_path.as_path());
if let std::result::Result::Err(error) = copied {
return std::result::Result::Err(
ksp_core_lib::Error::new(crate::ERROR_CODE_APP_STATE_INVALID, "cannot copy rollback Config fixture").with_source(error),
);
}
let copied = std::fs::copy(source_schema.as_path(), schema_path.as_path());
if let std::result::Result::Err(error) = copied {
return std::result::Result::Err(
ksp_core_lib::Error::new(crate::ERROR_CODE_APP_STATE_INVALID, "cannot copy rollback schema fixture").with_source(error),
);
}
let previous = std::fs::read_to_string(config_path.as_path());
let previous = match previous {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => {
return std::result::Result::Err(ksp_core_lib::Error::new(crate::ERROR_CODE_APP_STATE_INVALID, "cannot read rollback fixture").with_source(error));
},
};
let bootstrap = ksp_config_lib::ConfigBootstrapOptions::from_paths(config_root.as_path(), schema_root.as_path());
let bootstrap = match bootstrap {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let registry = ksp_config_lib::ConfigFileRegistry::defaults();
let registry = match registry {
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 management = ksp_config_lib::ConfigManagement::new(ksp_config_lib::ConfigDocumentEngine::new(bootstrap, registry));
return std::result::Result::Ok((root, management, file_id, previous));
}

View File

@@ -1,19 +1,19 @@
// file: crates/ksp-app-config-desk/unit_tests/profiles.rs
// version: 1
// version: 2
#[test]
fn profile_inventory_exposes_logging_default_and_available_profiles() {
let management = committed_management();
assert!(management.is_ok(), "committed management should construct: {management:?}");
let management = fixture_management();
assert!(management.is_ok(), "fixture management should construct: {management:?}");
if let std::result::Result::Ok(management) = management {
let inventory = super::inventory_from_management(&management);
assert!(inventory.is_ok(), "profile inventory should resolve: {inventory:?}");
if let std::result::Result::Ok(inventory) = inventory {
assert_eq!(inventory.len(), 1);
assert_eq!(inventory[0].file_id, ksp_config_lib::FILE_ID_STD_LOGGING);
assert_eq!(inventory[0].default_profile, "local_dev");
assert!(!inventory[0].default_profile.is_empty());
assert!(inventory[0].profile_ids.iter().any(|profile_id| {
return profile_id == "local_dev";
return profile_id == &inventory[0].default_profile;
}));
}
}
@@ -21,13 +21,15 @@ fn profile_inventory_exposes_logging_default_and_available_profiles() {
#[test]
fn default_profile_detail_uses_safe_effective_value_and_dotenv_provenance() {
let management = committed_management();
assert!(management.is_ok(), "committed management should construct: {management:?}");
let management = fixture_management();
assert!(management.is_ok(), "fixture management should construct: {management:?}");
if let std::result::Result::Ok(management) = management {
let source = management.load_logging_document();
assert!(source.is_ok(), "typed Logging source should load: {source:?}");
let detail = super::detail_from_management(&management, ksp_config_lib::FILE_ID_STD_LOGGING, std::option::Option::None);
assert!(detail.is_ok(), "default profile should inspect safely: {detail:?}");
if let std::result::Result::Ok(detail) = detail {
assert_eq!(detail.selected_profile, "local_dev");
if let (std::result::Result::Ok(source), std::result::Result::Ok(detail)) = (source, detail) {
assert_eq!(detail.selected_profile, source.default_profile());
assert_eq!(detail.selection_source, "default_profile");
assert!(detail.value_origins.iter().any(|entry| {
return entry.key == "logs_directory" && entry.origin == "global";
@@ -45,21 +47,27 @@ fn default_profile_detail_uses_safe_effective_value_and_dotenv_provenance() {
#[test]
fn explicit_profile_inspection_reports_explicit_selection_source() {
let management = committed_management();
assert!(management.is_ok(), "committed management should construct: {management:?}");
let management = fixture_management();
assert!(management.is_ok(), "fixture management should construct: {management:?}");
if let std::result::Result::Ok(management) = management {
let detail = super::detail_from_management(&management, ksp_config_lib::FILE_ID_STD_LOGGING, std::option::Option::Some("local_dev"));
assert!(detail.is_ok(), "explicit profile should inspect: {detail:?}");
if let std::result::Result::Ok(detail) = detail {
assert_eq!(detail.selected_profile, "local_dev");
assert_eq!(detail.selection_source, "explicit");
let source = management.load_logging_document();
assert!(source.is_ok(), "typed Logging source should load: {source:?}");
if let std::result::Result::Ok(source) = source {
let profile_id = source.default_profile().to_owned();
let detail = super::detail_from_management(&management, ksp_config_lib::FILE_ID_STD_LOGGING, std::option::Option::Some(profile_id.as_str()));
assert!(detail.is_ok(), "explicit profile should inspect: {detail:?}");
if let std::result::Result::Ok(detail) = detail {
assert_eq!(detail.selected_profile, profile_id);
assert_eq!(detail.selection_source, "explicit");
}
}
}
}
fn committed_management() -> ksp_core_lib::Result<ksp_config_lib::ConfigManagement> {
fn fixture_management() -> ksp_core_lib::Result<ksp_config_lib::ConfigManagement> {
let workspace = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..");
let bootstrap = ksp_config_lib::ConfigBootstrapOptions::from_paths(workspace.join("config"), workspace.join("config/schemas"));
let fixture_root = workspace.join("crates/ksp-config-lib/unit_tests/fixtures");
let bootstrap = ksp_config_lib::ConfigBootstrapOptions::from_paths(fixture_root, workspace.join("config/schemas"));
let bootstrap = match bootstrap {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-config-lib/tests/public_api.rs
// version: 15
// version: 16
//! Integration tests for the public `ksp-config-lib` bootstrap, registry, JSON/profile/composite, environment-resolution, sensitivity, Logging-adapter and
//! management contracts.
@@ -124,10 +124,13 @@ fn resolved_profile_contract_is_available_from_crate_root() {
assert!(file_id.is_ok(), "public Logging file_id should remain constructible: {file_id:?}");
if let (std::result::Result::Ok(bootstrap), std::result::Result::Ok(registry), std::result::Result::Ok(file_id)) = (bootstrap, registry, file_id) {
let engine = ksp_config_lib::ConfigDocumentEngine::new(bootstrap, registry);
let document = engine.load_validated_document(&file_id);
let resolved = engine.load_resolved_profile(&file_id, std::option::Option::None);
assert!(document.is_ok(), "public profile resolver should read committed source: {document:?}");
assert!(resolved.is_ok(), "public profile resolver should resolve committed default profile: {resolved:?}");
if let std::result::Result::Ok(resolved) = resolved {
assert_eq!(resolved.profile_id(), "local_dev");
if let (std::result::Result::Ok(document), std::result::Result::Ok(resolved)) = (document, resolved) {
let default_profile = document.value().get("default_profile").and_then(serde_json::Value::as_str);
assert_eq!(default_profile, std::option::Option::Some(resolved.profile_id()));
assert_eq!(resolved.selection_source(), ksp_config_lib::ConfigProfileSelectionSource::DefaultProfile);
assert_eq!(resolved.origin("logs_directory"), std::option::Option::Some(ksp_config_lib::ConfigValueOrigin::Global));
assert_eq!(resolved.origin("default_filter"), std::option::Option::Some(ksp_config_lib::ConfigValueOrigin::Profile));
@@ -212,13 +215,12 @@ fn management_contracts_are_available_from_crate_root() {
assert!(logging.is_ok(), "public typed Logging management contract should load committed source: {logging:?}");
if let std::result::Result::Ok(mut logging) = logging {
assert_eq!(logging.format_version(), 1);
assert_eq!(logging.default_profile(), "local_dev");
let default_profile = logging.default_profile().to_owned();
assert!(!default_profile.is_empty());
assert!(!logging.profiles().is_empty());
assert!(logging.profiles().iter().any(|profile| return profile.profile_id() == default_profile.as_str()));
logging.set_logs_directory("public-api-management-test");
assert_eq!(logging.logs_directory(), "public-api-management-test");
assert_eq!(logging.profiles().len(), 1);
assert_eq!(logging.profiles()[0].profile_id(), "local_dev");
assert_eq!(logging.profiles()[0].files().len(), 2);
assert!(!logging.profiles()[0].files()[0].ansi());
if let std::option::Option::Some(profile) = logging.profiles_mut().first_mut()
&& let std::option::Option::Some(file) = profile.files_mut().first_mut()
{

View File

@@ -1,18 +1,18 @@
// file: crates/ksp-config-lib/unit_tests/composite.rs
// version: 2
// version: 3
const TEST_COMPOSITE_FILE_ID: &str = "cfg.composite.test";
const TEST_COMPOSITE_FILENAME: &str = "examples/composite.example.json";
#[test]
fn committed_composite_example_resolves_default_document_profile() {
let engine = committed_engine();
fn fixture_composite_example_resolves_default_document_profile() {
let engine = fixture_example_engine();
let file_id = crate::ConfigFileId::new(TEST_COMPOSITE_FILE_ID);
assert!(engine.is_ok(), "composite test engine should be constructible: {engine:?}");
assert!(file_id.is_ok(), "composite test file_id should be valid: {file_id:?}");
if let (std::result::Result::Ok(engine), std::result::Result::Ok(file_id)) = (engine, file_id) {
let resolved = engine.load_resolved_composite(&file_id, std::option::Option::None);
assert!(resolved.is_ok(), "committed composite example should resolve: {resolved:?}");
assert!(resolved.is_ok(), "fixture composite example should resolve: {resolved:?}");
if let std::result::Result::Ok(resolved) = resolved {
assert_eq!(resolved.profile_id(), "local_default");
assert_eq!(resolved.selection_source(), crate::ConfigProfileSelectionSource::DefaultProfile);
@@ -20,7 +20,12 @@ fn committed_composite_example_resolves_default_document_profile() {
assert!(logging.is_some(), "logging component should be resolved");
if let std::option::Option::Some(logging) = logging {
assert_eq!(logging.resolved().file_id().as_str(), crate::FILE_ID_STD_LOGGING);
assert_eq!(logging.resolved().profile_id(), "local_dev");
let logging_document = engine.load_validated_document(logging.resolved().file_id());
assert!(logging_document.is_ok(), "referenced Logging document should validate: {logging_document:?}");
if let std::result::Result::Ok(logging_document) = logging_document {
let default_profile = logging_document.value().get("default_profile").and_then(serde_json::Value::as_str);
assert_eq!(default_profile, std::option::Option::Some(logging.resolved().profile_id()));
}
assert_eq!(logging.resolved().selection_source(), crate::ConfigProfileSelectionSource::DefaultProfile);
assert_eq!(logging.resolved().origin("logs_directory"), std::option::Option::Some(crate::ConfigValueOrigin::Global));
}
@@ -30,7 +35,7 @@ fn committed_composite_example_resolves_default_document_profile() {
#[test]
fn composite_profile_override_marks_referenced_profile_selection_as_composite() {
let engine = committed_engine();
let engine = fixture_example_engine();
let file_id = crate::ConfigFileId::new(TEST_COMPOSITE_FILE_ID);
assert!(engine.is_ok(), "composite test engine should be constructible: {engine:?}");
assert!(file_id.is_ok(), "composite test file_id should be valid: {file_id:?}");
@@ -51,7 +56,7 @@ fn composite_profile_override_marks_referenced_profile_selection_as_composite()
#[test]
fn unknown_composite_profile_has_profile_not_found_error() {
let engine = committed_engine();
let engine = fixture_example_engine();
let file_id = crate::ConfigFileId::new(TEST_COMPOSITE_FILE_ID);
assert!(engine.is_ok(), "composite test engine should be constructible: {engine:?}");
assert!(file_id.is_ok(), "composite test file_id should be valid: {file_id:?}");
@@ -123,9 +128,10 @@ fn duplicate_component_ids_are_rejected_inside_one_composite_profile() {
cleanup_fixture(&fixture);
}
fn committed_engine() -> ksp_core_lib::Result<crate::ConfigDocumentEngine> {
fn fixture_example_engine() -> ksp_core_lib::Result<crate::ConfigDocumentEngine> {
let workspace = workspace_root();
let bootstrap = crate::ConfigBootstrapOptions::from_paths(workspace.join("config"), workspace.join("config/schemas"));
let fixture_root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("unit_tests/fixtures");
let bootstrap = crate::ConfigBootstrapOptions::from_paths(fixture_root, workspace.join("config/schemas"));
let bootstrap = match bootstrap {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
@@ -205,7 +211,8 @@ fn prepare_fixture(fixture: &FixtureRoots, composite: &str) -> std::io::Result<(
return std::result::Result::Err(error);
}
let workspace = workspace_root();
let logging = std::fs::copy(workspace.join("config/std.logging.json"), fixture.config.join(crate::DEFAULT_STD_LOGGING_FILENAME));
let stable_logging = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("unit_tests/fixtures/std.logging.json");
let logging = std::fs::copy(stable_logging, fixture.config.join(crate::DEFAULT_STD_LOGGING_FILENAME));
if let std::result::Result::Err(error) = logging {
return std::result::Result::Err(error);
}

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-config-lib/unit_tests/document.rs
// version: 2
// version: 3
#[test]
fn committed_logging_document_passes_registered_schema_and_semantic_validation() {
@@ -17,10 +17,16 @@ fn committed_logging_document_passes_registered_schema_and_semantic_validation()
if let std::result::Result::Ok(document) = document {
assert_eq!(document.file_id(), &file_id);
assert_eq!(document.path(), workspace.join("config/std.logging.json").as_path());
let default_profile = document.value().get("default_profile");
let default_profile = document.value().get("default_profile").and_then(serde_json::Value::as_str);
assert!(default_profile.is_some(), "validated Logging document should retain default_profile");
if let std::option::Option::Some(default_profile) = default_profile {
assert_eq!(default_profile.as_str(), std::option::Option::Some("local_dev"));
let profiles = document.value().get("profiles").and_then(serde_json::Value::as_array);
assert!(profiles.is_some(), "validated Logging document should retain profiles");
if let std::option::Option::Some(profiles) = profiles {
assert!(profiles.iter().any(|profile| -> bool {
return profile.get("profile_id").and_then(serde_json::Value::as_str) == std::option::Option::Some(default_profile);
}));
}
}
}
}

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-config-lib/unit_tests/environment.rs
// version: 4
// version: 5
#[test]
fn process_environment_wins_over_dotenv_and_fallback_even_when_empty() {
@@ -174,9 +174,8 @@ fn fake_process_collection_filters_unrelated_names_without_mutating_real_environ
}
#[test]
fn committed_logging_profile_resolves_environment_fallback_without_changing_source_profile() {
let workspace = workspace_root();
let bootstrap = crate::ConfigBootstrapOptions::from_paths(workspace.join("config"), workspace.join("config/schemas"));
fn logging_fixture_profile_resolves_environment_fallback_without_changing_source_profile() {
let bootstrap = logging_fixture_bootstrap();
assert!(bootstrap.is_ok(), "bootstrap should resolve committed roots");
let bootstrap = match bootstrap {
std::result::Result::Ok(value) => value,
@@ -235,6 +234,12 @@ fn workspace_root() -> std::path::PathBuf {
return std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..");
}
fn logging_fixture_bootstrap() -> ksp_core_lib::Result<crate::ConfigBootstrapOptions> {
let fixture_root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("unit_tests/fixtures");
let workspace = workspace_root();
return crate::ConfigBootstrapOptions::from_paths(fixture_root, workspace.join("config/schemas"));
}
#[test]
fn secret_environment_value_keeps_real_value_but_redacts_safe_and_debug_views() {
let canary = "KSP_SECRET_CANARY_91b7c6";
@@ -322,9 +327,8 @@ fn detailed_json_preserves_safe_tree_sensitivity_and_pointer_provenance() {
}
#[test]
fn detailed_profile_environment_keeps_global_origin_and_adds_environment_provenance() {
let workspace = workspace_root();
let bootstrap = crate::ConfigBootstrapOptions::from_paths(workspace.join("config"), workspace.join("config/schemas"));
fn detailed_fixture_profile_environment_keeps_global_origin_and_adds_environment_provenance() {
let bootstrap = logging_fixture_bootstrap();
let bootstrap = match bootstrap {
std::result::Result::Ok(value) => value,
std::result::Result::Err(_) => return,

View File

@@ -0,0 +1,25 @@
{
"format_version": 1,
"default_profile": "local_default",
"profiles": [
{
"profile_id": "local_default",
"documents": [
{
"component_id": "logging",
"file_id": "cfg.std.logging"
}
]
},
{
"profile_id": "local_explicit",
"documents": [
{
"component_id": "logging",
"file_id": "cfg.std.logging",
"profile_id": "local_dev"
}
]
}
]
}

View File

@@ -0,0 +1,77 @@
{
"format_version": 1,
"logs_directory": "${KSP_LOGS_DIRECTORY:-logs}",
"default_profile": "local_dev",
"profiles": [
{
"profile_id": "local_dev",
"default_filter": "warn",
"span_events": "new_and_close",
"console": {
"enabled": true,
"output": "stderr",
"ansi": true,
"format": "compact",
"filter": {
"level": "debug",
"targets": [
"*"
],
"domains": [
"*"
]
}
},
"files": [
{
"output_id": "file.all.debug",
"enabled": true,
"path": "debug/ksp-debug.log",
"rotation": "daily",
"format": "human",
"ansi": false,
"filter": {
"level": "debug",
"targets": [
"*"
],
"domains": [
"*"
]
}
},
{
"output_id": "file.config.error",
"enabled": true,
"path": "config/ksp-config-errors.jsonl",
"rotation": "daily",
"format": "json",
"ansi": false,
"filter": {
"level": "error",
"targets": [
"ksp-config-lib"
],
"domains": [
"config"
]
}
}
],
"target_filters": [
{
"target_prefix": "ksp-config-lib",
"level": "trace"
},
{
"target_prefix": "ksp-logging-lib",
"level": "debug"
},
{
"target_prefix": "ksp-app-config-desk",
"level": "debug"
}
]
}
]
}

View File

@@ -1,76 +1,145 @@
// file: crates/ksp-config-lib/unit_tests/logging.rs
// version: 2
// version: 3
#[test]
fn committed_logging_profile_maps_complete_runtime_contract() {
let engine = committed_engine();
fn fixture_logging_profile_maps_complete_runtime_contract() {
let engine = fixture_engine();
let engine = match engine {
std::result::Result::Ok(value) => value,
std::result::Result::Err(_) => return,
};
let environment = crate::ConfigEnvironment::from_maps(std::collections::BTreeMap::new(), std::collections::BTreeMap::new());
let resolved = engine.load_resolved_logging_config(std::option::Option::None, &environment);
assert!(resolved.is_ok(), "committed Logging Config should map");
assert!(resolved.is_ok(), "fixture Logging Config should map");
let resolved = match resolved {
std::result::Result::Ok(value) => value,
std::result::Result::Err(_) => return,
};
assert_eq!(resolved.file_id().as_str(), crate::FILE_ID_STD_LOGGING);
assert_eq!(resolved.profile_id(), "local_dev");
assert_eq!(resolved.selection_source(), crate::ConfigProfileSelectionSource::DefaultProfile);
assert_eq!(resolved.logs_directory(), current_directory().join("logs").as_path());
assert_eq!(resolved.effective().value()["logs_directory"], serde_json::Value::String("logs".to_owned()));
let effective = resolved.effective().value();
let effective_logs_directory = effective.get("logs_directory").and_then(serde_json::Value::as_str);
assert!(effective_logs_directory.is_some(), "effective Logging Config should expose logs_directory");
if let std::option::Option::Some(effective_logs_directory) = effective_logs_directory {
let source_path = std::path::Path::new(effective_logs_directory);
let expected = if source_path.is_absolute() { source_path.to_path_buf() } else { current_directory().join(source_path) };
assert_eq!(resolved.logs_directory(), expected.as_path());
}
let settings = resolved.settings();
assert_eq!(settings.default_filter(), ksp_logging_lib::LogFilterLevel::Warn);
assert_eq!(settings.span_events(), ksp_logging_lib::SpanEvents::NewAndClose);
assert_eq!(settings.target_filters().len(), 3);
assert_eq!(settings.target_filters()[0].target_prefix(), "ksp-config-lib");
assert_eq!(settings.target_filters()[0].level(), ksp_logging_lib::LogFilterLevel::Trace);
assert_eq!(settings.target_filters()[1].target_prefix(), "ksp-logging-lib");
assert_eq!(settings.target_filters()[1].level(), ksp_logging_lib::LogFilterLevel::Debug);
assert_eq!(settings.target_filters()[2].target_prefix(), "ksp-app-config-desk");
assert_eq!(settings.target_filters()[2].level(), ksp_logging_lib::LogFilterLevel::Debug);
let default_filter = effective.get("default_filter").and_then(serde_json::Value::as_str);
let span_events = effective.get("span_events").and_then(serde_json::Value::as_str);
assert_eq!(default_filter.and_then(test_level), std::option::Option::Some(settings.default_filter()));
assert_eq!(span_events.and_then(test_span_events), std::option::Option::Some(settings.span_events()));
let source_target_filters = effective.get("target_filters").and_then(serde_json::Value::as_array);
assert!(source_target_filters.is_some(), "effective Logging Config should expose target_filters");
if let std::option::Option::Some(source_target_filters) = source_target_filters {
assert_eq!(settings.target_filters().len(), source_target_filters.len());
for (runtime, source) in settings.target_filters().iter().zip(source_target_filters) {
assert_eq!(source.get("target_prefix").and_then(serde_json::Value::as_str), std::option::Option::Some(runtime.target_prefix()));
assert_eq!(source.get("level").and_then(serde_json::Value::as_str).and_then(test_level), std::option::Option::Some(runtime.level()));
}
}
let console = settings.console();
assert!(console.is_some(), "committed Logging Config declares console settings");
let console = match console {
std::option::Option::Some(value) => value,
std::option::Option::None => return,
let source_console = effective.get("console");
assert!(console.is_some(), "effective Logging Config declares console settings");
assert!(source_console.is_some(), "effective Logging Config should expose console settings");
if let (std::option::Option::Some(console), std::option::Option::Some(source_console)) = (console, source_console) {
assert_eq!(source_console.get("enabled").and_then(serde_json::Value::as_bool), std::option::Option::Some(console.enabled()));
assert_eq!(source_console.get("ansi").and_then(serde_json::Value::as_bool), std::option::Option::Some(console.ansi()));
assert_eq!(source_console.get("output").and_then(serde_json::Value::as_str).and_then(test_console_output), std::option::Option::Some(console.output()));
assert_eq!(source_console.get("format").and_then(serde_json::Value::as_str).and_then(test_format), std::option::Option::Some(console.format()));
let filter = source_console.get("filter");
assert!(filter.is_some(), "effective console should expose filter");
if let std::option::Option::Some(filter) = filter {
assert_eq!(filter.get("level").and_then(serde_json::Value::as_str).and_then(test_level), std::option::Option::Some(console.filter().level()));
assert_eq!(json_string_array(filter.get("targets")), console.filter().targets().to_vec());
assert_eq!(json_string_array(filter.get("domains")), console.filter().domains().to_vec());
}
}
let source_files = effective.get("files").and_then(serde_json::Value::as_array);
assert!(source_files.is_some(), "effective Logging Config should expose files");
if let std::option::Option::Some(source_files) = source_files {
assert_eq!(settings.files().len(), source_files.len());
for (runtime, source) in settings.files().iter().zip(source_files) {
assert_eq!(source.get("output_id").and_then(serde_json::Value::as_str), std::option::Option::Some(runtime.output_id()));
assert_eq!(source.get("enabled").and_then(serde_json::Value::as_bool), std::option::Option::Some(runtime.enabled()));
assert_eq!(source.get("rotation").and_then(serde_json::Value::as_str).and_then(test_rotation), std::option::Option::Some(runtime.rotation()));
assert_eq!(source.get("format").and_then(serde_json::Value::as_str).and_then(test_format), std::option::Option::Some(runtime.format()));
assert_eq!(source.get("ansi").and_then(serde_json::Value::as_bool), std::option::Option::Some(runtime.ansi()));
let filter = source.get("filter");
assert!(filter.is_some(), "effective file should expose filter");
if let std::option::Option::Some(filter) = filter {
assert_eq!(filter.get("level").and_then(serde_json::Value::as_str).and_then(test_level), std::option::Option::Some(runtime.filter().level()));
assert_eq!(json_string_array(filter.get("targets")), runtime.filter().targets().to_vec());
assert_eq!(json_string_array(filter.get("domains")), runtime.filter().domains().to_vec());
}
}
}
}
fn test_level(value: &str) -> std::option::Option<ksp_logging_lib::LogFilterLevel> {
return match value {
"off" => std::option::Option::Some(ksp_logging_lib::LogFilterLevel::Off),
"error" => std::option::Option::Some(ksp_logging_lib::LogFilterLevel::Error),
"warn" => std::option::Option::Some(ksp_logging_lib::LogFilterLevel::Warn),
"info" => std::option::Option::Some(ksp_logging_lib::LogFilterLevel::Info),
"debug" => std::option::Option::Some(ksp_logging_lib::LogFilterLevel::Debug),
"trace" => std::option::Option::Some(ksp_logging_lib::LogFilterLevel::Trace),
_ => std::option::Option::None,
};
assert!(console.enabled());
assert_eq!(console.output(), ksp_logging_lib::ConsoleOutput::Stderr);
assert!(console.ansi());
assert_eq!(console.format(), ksp_logging_lib::LogFormat::Compact);
assert_eq!(console.filter().level(), ksp_logging_lib::LogFilterLevel::Debug);
assert_eq!(console.filter().targets(), &["*".to_owned()]);
assert_eq!(console.filter().domains(), &["*".to_owned()]);
assert_eq!(settings.files().len(), 2);
assert_file(
&settings.files()[0],
"file.all.debug",
current_directory().join("logs/debug").as_path(),
"ksp-debug.log",
ksp_logging_lib::FileRotation::Daily,
ksp_logging_lib::LogFormat::Human,
ksp_logging_lib::LogFilterLevel::Debug,
&["*"],
&["*"],
);
assert_file(
&settings.files()[1],
"file.config.error",
current_directory().join("logs/config").as_path(),
"ksp-config-errors.jsonl",
ksp_logging_lib::FileRotation::Daily,
ksp_logging_lib::LogFormat::Json,
ksp_logging_lib::LogFilterLevel::Error,
&["ksp-config-lib"],
&["config"],
);
}
fn test_span_events(value: &str) -> std::option::Option<ksp_logging_lib::SpanEvents> {
return match value {
"off" => std::option::Option::Some(ksp_logging_lib::SpanEvents::Off),
"new_and_close" => std::option::Option::Some(ksp_logging_lib::SpanEvents::NewAndClose),
"full" => std::option::Option::Some(ksp_logging_lib::SpanEvents::Full),
_ => std::option::Option::None,
};
}
fn test_console_output(value: &str) -> std::option::Option<ksp_logging_lib::ConsoleOutput> {
return match value {
"stdout" => std::option::Option::Some(ksp_logging_lib::ConsoleOutput::Stdout),
"stderr" => std::option::Option::Some(ksp_logging_lib::ConsoleOutput::Stderr),
_ => std::option::Option::None,
};
}
fn test_format(value: &str) -> std::option::Option<ksp_logging_lib::LogFormat> {
return match value {
"human" => std::option::Option::Some(ksp_logging_lib::LogFormat::Human),
"compact" => std::option::Option::Some(ksp_logging_lib::LogFormat::Compact),
"pretty" => std::option::Option::Some(ksp_logging_lib::LogFormat::Pretty),
"json" => std::option::Option::Some(ksp_logging_lib::LogFormat::Json),
_ => std::option::Option::None,
};
}
fn test_rotation(value: &str) -> std::option::Option<ksp_logging_lib::FileRotation> {
return match value {
"never" => std::option::Option::Some(ksp_logging_lib::FileRotation::Never),
"hourly" => std::option::Option::Some(ksp_logging_lib::FileRotation::Hourly),
"daily" => std::option::Option::Some(ksp_logging_lib::FileRotation::Daily),
_ => std::option::Option::None,
};
}
fn json_string_array(value: std::option::Option<&serde_json::Value>) -> std::vec::Vec<String> {
let mut result = std::vec::Vec::<String>::new();
if let std::option::Option::Some(values) = value.and_then(serde_json::Value::as_array) {
for value in values {
if let std::option::Option::Some(value) = value.as_str() {
result.push(value.to_owned());
}
}
}
return result;
}
#[test]
fn relative_logs_directory_is_anchored_to_process_current_directory() {
let engine = committed_engine();
let engine = fixture_engine();
let engine = match engine {
std::result::Result::Ok(value) => value,
std::result::Result::Err(_) => return,
@@ -86,7 +155,7 @@ fn relative_logs_directory_is_anchored_to_process_current_directory() {
#[test]
fn absolute_logs_directory_is_preserved() {
let engine = committed_engine();
let engine = fixture_engine();
let engine = match engine {
std::result::Result::Ok(value) => value,
std::result::Result::Err(_) => return,
@@ -111,7 +180,7 @@ fn effective_file_paths_cannot_escape_logging_root() {
#[test]
fn explicit_empty_logs_directory_is_invalid_instead_of_using_fallback() {
let engine = committed_engine();
let engine = fixture_engine();
let engine = match engine {
std::result::Result::Ok(value) => value,
std::result::Result::Err(_) => return,
@@ -130,7 +199,7 @@ fn explicit_empty_logs_directory_is_invalid_instead_of_using_fallback() {
#[test]
fn existing_non_directory_logging_root_is_rejected_without_secret_leak() {
let engine = committed_engine();
let engine = fixture_engine();
let engine = match engine {
std::result::Result::Ok(value) => value,
std::result::Result::Err(_) => return,
@@ -139,7 +208,7 @@ fn existing_non_directory_logging_root_is_rejected_without_secret_leak() {
let path = std::env::temp_dir().join(secret.as_str());
let write = std::fs::write(path.as_path(), b"not a directory");
assert!(write.is_ok(), "secret canary file should be created");
let profile = load_committed_profile(&engine);
let profile = load_fixture_profile(&engine);
let profile = match profile {
std::result::Result::Ok(value) => value,
std::result::Result::Err(_) => {
@@ -161,12 +230,12 @@ fn existing_non_directory_logging_root_is_rejected_without_secret_leak() {
#[test]
fn logging_adapter_rejects_secret_effective_values_without_exposing_canary() {
let engine = committed_engine();
let engine = fixture_engine();
let engine = match engine {
std::result::Result::Ok(value) => value,
std::result::Result::Err(_) => return,
};
let profile = load_committed_profile(&engine);
let profile = load_fixture_profile(&engine);
let profile = match profile {
std::result::Result::Ok(value) => value,
std::result::Result::Err(_) => return,
@@ -193,7 +262,7 @@ fn logging_adapter_rejects_secret_effective_values_without_exposing_canary() {
#[test]
fn mapped_logging_settings_can_initialize_and_reinitialize_runtime() {
let engine = committed_engine();
let engine = fixture_engine();
let engine = match engine {
std::result::Result::Ok(value) => value,
std::result::Result::Err(_) => return,
@@ -231,7 +300,7 @@ fn mapped_logging_settings_can_initialize_and_reinitialize_runtime() {
#[test]
fn resolved_logging_debug_uses_safe_effective_view() {
let engine = committed_engine();
let engine = fixture_engine();
let engine = match engine {
std::result::Result::Ok(value) => value,
std::result::Result::Err(_) => return,
@@ -270,9 +339,10 @@ fn assert_file(
assert_eq!(file.filter().domains().iter().map(String::as_str).collect::<std::vec::Vec<&str>>(), domains.to_vec());
}
fn committed_engine() -> ksp_core_lib::Result<crate::ConfigDocumentEngine> {
fn fixture_engine() -> ksp_core_lib::Result<crate::ConfigDocumentEngine> {
let workspace = workspace_root();
let bootstrap = crate::ConfigBootstrapOptions::from_paths(workspace.join("config"), workspace.join("config/schemas"));
let fixture_root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("unit_tests/fixtures");
let bootstrap = crate::ConfigBootstrapOptions::from_paths(fixture_root, workspace.join("config/schemas"));
let bootstrap = match bootstrap {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
@@ -285,7 +355,7 @@ fn committed_engine() -> ksp_core_lib::Result<crate::ConfigDocumentEngine> {
return std::result::Result::Ok(crate::ConfigDocumentEngine::new(bootstrap, registry));
}
fn load_committed_profile(engine: &crate::ConfigDocumentEngine) -> ksp_core_lib::Result<crate::ResolvedConfigProfile> {
fn load_fixture_profile(engine: &crate::ConfigDocumentEngine) -> ksp_core_lib::Result<crate::ResolvedConfigProfile> {
let file_id = crate::ConfigFileId::new(crate::FILE_ID_STD_LOGGING);
let file_id = match file_id {
std::result::Result::Ok(value) => value,

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-config-lib/unit_tests/management.rs
// version: 4
// version: 5
static NEXT_FIXTURE_ID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
@@ -56,7 +56,7 @@ fn valid_raw_source_candidate_is_persisted_exactly_and_reports_reload() {
return;
},
};
let candidate = original.replace(" \"logs_directory\":", " \"logs_directory\":");
let candidate = format!("{original} \n");
assert_ne!(candidate, original, "raw source candidate fixture must change the persisted bytes");
let corrupt = std::fs::write(fixture.config_path.as_path(), b"{\n");
assert!(corrupt.is_ok(), "existing managed source should be corruptible for repair test");
@@ -165,7 +165,23 @@ fn semantic_invalid_source_candidate_is_rejected_without_modifying_existing_file
return;
},
};
let candidate = before.replace("\"default_profile\": \"local_dev\"", "\"default_profile\": \"missing\"");
let candidate_value = serde_json::from_str::<serde_json::Value>(before.as_str());
let mut candidate_value = match candidate_value {
std::result::Result::Ok(value) => value,
std::result::Result::Err(_) => {
cleanup_fixture(&fixture);
return;
},
};
candidate_value["default_profile"] = serde_json::Value::String("missing".to_owned());
let candidate = serde_json::to_string_pretty(&candidate_value);
let candidate = match candidate {
std::result::Result::Ok(value) => format!("{value}\n"),
std::result::Result::Err(_) => {
cleanup_fixture(&fixture);
return;
},
};
assert_ne!(candidate, before, "semantic-invalid fixture must alter the default profile");
let file_id = crate::ConfigFileId::new(crate::FILE_ID_STD_LOGGING);
let file_id = match file_id {
@@ -237,10 +253,8 @@ fn typed_logging_document_can_be_mutated_validated_and_persisted_atomically() {
},
};
assert_eq!(document.format_version(), 1);
assert_eq!(document.default_profile(), "local_dev");
assert_eq!(document.profiles().len(), 1);
assert_eq!(document.profiles()[0].files().len(), 2);
assert!(!document.profiles()[0].files()[0].ansi());
assert!(!document.default_profile().is_empty());
assert!(!document.profiles().is_empty());
document.set_logs_directory("managed-logs");
if let std::option::Option::Some(profile) = document.profiles_mut().first_mut() {
profile.set_default_filter("info");
@@ -496,7 +510,7 @@ fn management_fixture() -> ksp_core_lib::Result<ManagementFixture> {
);
}
let workspace = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..");
let source_config = workspace.join("config/std.logging.json");
let source_config = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("unit_tests/fixtures/std.logging.json");
let source_schema = workspace.join("config/schemas/std.logging.schema.json");
let config_path = config_root.join("std.logging.json");
let schema_path = schema_root.join("std.logging.schema.json");

View File

@@ -1,23 +1,27 @@
// file: crates/ksp-config-lib/unit_tests/profile.rs
// version: 1
// version: 2
#[test]
fn committed_default_profile_resolves_globals_profile_and_provenance() {
let engine = committed_engine();
fn fixture_default_profile_resolves_globals_profile_and_provenance() {
let engine = fixture_engine();
let file_id = crate::ConfigFileId::new(crate::FILE_ID_STD_LOGGING);
assert!(engine.is_ok(), "committed Config engine should be constructible: {engine:?}");
assert!(engine.is_ok(), "fixture Config engine should be constructible: {engine:?}");
assert!(file_id.is_ok(), "logging file_id should be valid: {file_id:?}");
if let (std::result::Result::Ok(engine), std::result::Result::Ok(file_id)) = (engine, file_id) {
let document = engine.load_validated_document(&file_id);
assert!(document.is_ok(), "committed Logging document should validate: {document:?}");
let resolved = engine.load_resolved_profile(&file_id, std::option::Option::None);
assert!(resolved.is_ok(), "default profile should resolve: {resolved:?}");
if let std::result::Result::Ok(resolved) = resolved {
assert_eq!(resolved.profile_id(), "local_dev");
if let (std::result::Result::Ok(document), std::result::Result::Ok(resolved)) = (document, resolved) {
let default_profile = document.value().get("default_profile").and_then(serde_json::Value::as_str);
assert_eq!(default_profile, std::option::Option::Some(resolved.profile_id()));
assert_eq!(resolved.selection_source(), super::ConfigProfileSelectionSource::DefaultProfile);
assert_eq!(resolved.globals().get("logs_directory").and_then(serde_json::Value::as_str), std::option::Option::Some("${KSP_LOGS_DIRECTORY:-logs}"));
assert_eq!(resolved.profile().get("default_filter").and_then(serde_json::Value::as_str), std::option::Option::Some("warn"));
assert_eq!(resolved.effective().get("default_filter").and_then(serde_json::Value::as_str), std::option::Option::Some("warn"));
assert_eq!(resolved.origin("logs_directory"), std::option::Option::Some(super::ConfigValueOrigin::Global));
assert_eq!(resolved.origin("default_filter"), std::option::Option::Some(super::ConfigValueOrigin::Profile));
assert_eq!(
resolved.profile().get("default_filter").and_then(serde_json::Value::as_str),
resolved.effective().get("default_filter").and_then(serde_json::Value::as_str),
);
assert!(!resolved.effective().contains_key("default_profile"));
assert!(!resolved.effective().contains_key("profiles"));
}
@@ -26,25 +30,33 @@ fn committed_default_profile_resolves_globals_profile_and_provenance() {
#[test]
fn explicit_profile_selection_is_distinct_from_default_selection() {
let engine = committed_engine();
let engine = fixture_engine();
let file_id = crate::ConfigFileId::new(crate::FILE_ID_STD_LOGGING);
assert!(engine.is_ok(), "committed Config engine should be constructible: {engine:?}");
assert!(engine.is_ok(), "fixture Config engine should be constructible: {engine:?}");
assert!(file_id.is_ok(), "logging file_id should be valid: {file_id:?}");
if let (std::result::Result::Ok(engine), std::result::Result::Ok(file_id)) = (engine, file_id) {
let resolved = engine.load_resolved_profile(&file_id, std::option::Option::Some("local_dev"));
assert!(resolved.is_ok(), "explicit committed profile should resolve: {resolved:?}");
if let std::result::Result::Ok(resolved) = resolved {
assert_eq!(resolved.profile_id(), "local_dev");
assert_eq!(resolved.selection_source(), super::ConfigProfileSelectionSource::Explicit);
let document = engine.load_validated_document(&file_id);
assert!(document.is_ok(), "committed Logging document should validate: {document:?}");
if let std::result::Result::Ok(document) = document {
let profile_id = document.value().get("default_profile").and_then(serde_json::Value::as_str);
assert!(profile_id.is_some(), "validated Logging document should expose default_profile");
if let std::option::Option::Some(profile_id) = profile_id {
let resolved = engine.load_resolved_profile(&file_id, std::option::Option::Some(profile_id));
assert!(resolved.is_ok(), "explicit committed profile should resolve: {resolved:?}");
if let std::result::Result::Ok(resolved) = resolved {
assert_eq!(resolved.profile_id(), profile_id);
assert_eq!(resolved.selection_source(), super::ConfigProfileSelectionSource::Explicit);
}
}
}
}
}
#[test]
fn unknown_explicit_profile_has_distinct_error_code() {
let engine = committed_engine();
let engine = fixture_engine();
let file_id = crate::ConfigFileId::new(crate::FILE_ID_STD_LOGGING);
assert!(engine.is_ok(), "committed Config engine should be constructible: {engine:?}");
assert!(engine.is_ok(), "fixture Config engine should be constructible: {engine:?}");
assert!(file_id.is_ok(), "logging file_id should be valid: {file_id:?}");
if let (std::result::Result::Ok(engine), std::result::Result::Ok(file_id)) = (engine, file_id) {
let result = engine.load_resolved_profile(&file_id, std::option::Option::Some("does-not-exist"));
@@ -55,9 +67,10 @@ fn unknown_explicit_profile_has_distinct_error_code() {
}
}
fn committed_engine() -> ksp_core_lib::Result<crate::ConfigDocumentEngine> {
fn fixture_engine() -> ksp_core_lib::Result<crate::ConfigDocumentEngine> {
let workspace = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..");
let bootstrap = crate::ConfigBootstrapOptions::from_paths(workspace.join("config"), workspace.join("config/schemas"));
let fixture_root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("unit_tests/fixtures");
let bootstrap = crate::ConfigBootstrapOptions::from_paths(fixture_root, workspace.join("config/schemas"));
let bootstrap = match bootstrap {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),

View File

@@ -0,0 +1,142 @@
<!-- file: deltas/0.1.4/pre.015-fix.002.md -->
<!-- version: 1 -->
# Delta `0.1.4-pre.015-fix.002` — hot reload immédiat Logging et tests indépendants de la Config éditable
## Statut
Correctif de `0.1.4-pre.015-fix.001` livré pour validation locale.
Le correctif précédent a clarifié la différence entre rechargement du document et runtime actif, mais cette clarification ne satisfait pas le contrat fonctionnel de Config Desk : `KSP-APP-020` exige que le routage Logging soit réellement rechargeable à chaud sans redémarrage. Une sauvegarde Logging doit donc pouvoir être appliquée au subscriber déjà installé.
La validation locale a également révélé que plusieurs tests considéraient les valeurs historiques de `config/std.logging.json` (`warn`, `new_and_close`, etc.) comme une fixture immuable. Après une édition légitime depuis Config Desk vers `info` / `off`, ces tests échouaient alors que la configuration restait valide.
## Défaut 1 — persistence sans application runtime
Avant ce correctif :
```text
éditeur -> save_logging_document -> std.logging.json
X-> runtime tracing déjà actif
```
Ainsi, `console.enabled=false` était bien persisté mais les événements KSP continuaient d'être routés vers la console jusqu'au redémarrage du processus.
Ce comportement contredit l'objectif du manager Logging et `KSP-APP-020`.
## Correction runtime
`save_logging_document` devient une opération **Sauvegarder et appliquer** :
1. lecture de la source Logging précédente via `ConfigManagement::read_source()` ;
2. reconstruction du candidat typé ;
3. validation et persistence atomique via `ConfigManagement::save_logging_document()` ;
4. chargement d'un `ConfigEnvironment` frais ;
5. résolution du `default_profile` avec `load_resolved_logging_config()` ;
6. préparation/application du runtime via `ksp_logging_lib::reinitialize()` sur le `LoggingGuard` conservé dans `AppState` ;
7. mise à jour de `active_logging_profile`, `logging_generation`, `fallback_logging_active` et du diagnostic startup seulement après succès.
Le résultat Tauri expose désormais :
- `source_changed` ;
- `reload_required` ;
- `runtime_applied` ;
- `logging_generation` ;
- `active_profile` ;
- le document typé relu.
### Transaction/rollback
`ksp-logging-lib::reinitialize()` prépare les nouveaux outputs avant le swap et conserve déjà l'ancien runtime si la préparation/reload échoue.
Config Desk complète cette garantie au niveau persistence : si la résolution effective ou le hot reload échoue après modification de `std.logging.json`, la source brute précédente est restaurée via `ConfigManagement::save_source_candidate()`.
Ainsi, l'échec ne doit pas laisser :
```text
source nouvelle + runtime ancien
```
Un test applicatif spécifique vérifie la restauration de la source précédente après une erreur runtime synthétique.
## Défaut 2 — tests dépendants d'un fichier utilisateur mutable
La validation locale a produit notamment :
```text
left: "info"
right: "warn"
```
et :
```text
raw source candidate fixture must change the persisted bytes
```
Ces échecs ne signalaient pas une Config invalide : ils provenaient de tests qui supposaient que `std.logging.json` conserverait éternellement ses valeurs historiques ou une indentation particulière.
Le correctif :
- compare la projection Logging editor avec la source typée réellement chargée ;
- compare la résolution de profil avec le `default_profile` réellement présent ;
- compare l'adapter runtime avec l'effective réellement résolue au lieu d'imposer `warn/new_and_close` ;
- construit les candidats invalides par modification JSON structurée et non par remplacement du texte `local_dev` ;
- garantit une différence de bytes pour le test raw par ajout de whitespace JSON valide, indépendamment de l'indentation source ;
- réduit les assertions exactes sur la Config workspace aux invariants réellement normatifs ;
- ajoute `crates/ksp-config-lib/unit_tests/fixtures/std.logging.json` et `unit_tests/fixtures/examples/composite.example.json` pour les tests comportementaux qui ont besoin de valeurs exactes stables.
La nouvelle règle **KSP-APP-033** formalise cette frontière : un document Config éditable par Config Desk n'est jamais une fixture immuable pour les tests.
## Interface
- le bouton devient **Sauvegarder et appliquer** ;
- le statut affiche `runtime_applied`, `logging_generation` et `active_profile` ;
- **Recharger le document** reste une resynchronisation du brouillon uniquement ;
- l'avertissement UI explique le rollback source/runtime ;
- désactiver `console.enabled` puis **Sauvegarder et appliquer** doit couper immédiatement les événements KSP dans la console du processus courant ;
- la ligne de debug annonçant la demande de sauvegarde peut encore apparaître juste avant le swap, puisqu'elle est émise avec l'ancien runtime ; les événements frontend/backend suivants ne doivent plus apparaître sur ce sink si la console est désactivée.
Les messages Cargo/Tauri/Vite restent externes au runtime KSP.
## Planification
Le hot reload de base est absorbé par ce fix de `pre.015` puisqu'il est requis pour corriger le comportement livré.
`pre.016` conserve les compléments runtime :
- fichiers applicatifs uniques par lancement ;
- sélection explicite du profil runtime indépendamment du `default_profile` ;
- observabilité renforcée de la génération/runtime ;
- consolidation des scénarios transactionnels.
## Version technique
```text
0.1.4-pre.15.fix.2
```
## Validation attendue
```bash
cargo fmt --all
cargo check --workspace
cargo clippy --workspace --all-targets
cargo test -p ksp-app-config-desk
cargo test -p ksp-config-lib
cargo tauri dev -c crates/ksp-app-config-desk/tauri.conf.json
```
La Config workspace peut conserver les valeurs modifiées pendant les essais, par exemple `default_filter=info` ou `span_events=off` : les tests ne doivent plus échouer uniquement pour cette raison.
### Test hot reload console
1. démarrer avec la console Logging activée ;
2. ouvrir Logging et décocher `Console / Enabled` sur le `default_profile` ;
3. cliquer **Sauvegarder et appliquer** ;
4. vérifier le résultat `runtime_applied=true` et l'incrément de `logging_generation` ;
5. sans arrêter l'application, cliquer dans plusieurs vues ;
6. aucun nouvel événement KSP horodaté issu de ces clics ne doit apparaître dans le terminal ;
7. réactiver `Console / Enabled`, **Sauvegarder et appliquer** ;
8. les événements KSP doivent réapparaître immédiatement, toujours sans redémarrage.

View File

@@ -1,5 +1,5 @@
<!-- file: docs/plans/006-V0_1_4_CONFIG_DESKTOP_PLAN.md -->
<!-- version: 18 -->
<!-- version: 19 -->
# Plan `0.1.4` — `ksp-app-config-desk`
@@ -1243,16 +1243,15 @@ pre.015 Logging editor — mutations/persistence [en cours]
- default_profile
- mono-fichier/multi-fichiers
- save_logging_document
- UI explicite : rechargement du document != reconfiguration du runtime actif
- source persistée appliquée au prochain démarrage tant que pre.016 n'est pas livrée
- Sauvegarder et appliquer = persistence + hot reload immédiat du profil default
- Recharger le document = resynchronisation du brouillon uniquement
- rollback source + ancien runtime conservé si lapplication échoue
pre.016 Logging runtime
pre.016 Logging runtime — compléments
- fichiers applicatifs uniques par lancement avec timestamp de démarrage
- sélection profil à appliquer
- ConfigEnvironment frais
- load_resolved_logging_config
- reinitialize + metadata transactionnelle
- rollback runtime validé
- sélection explicite du profil à appliquer indépendamment du default_profile
- observabilité metadata/génération du runtime
- consolidation des scénarios de rollback/runtime
pre.017 panneau Test Logging
- champ message

View File

@@ -1,5 +1,5 @@
<!-- file: docs/rules/RULES_KSP.md -->
<!-- version: 28 -->
<!-- version: 29 -->
# Règles spécifiques à KSP
@@ -225,6 +225,7 @@
- **KSP-APP-030** — Lorsquune application KSP persiste des logs applicatifs, chaque lancement doit disposer dun fichier propre et non partagé avec un lancement précédent. Le nom encode au minimum lidentité applicative et un horodatage de démarrage, par exemple `app-name.YYYYMMDD-HHMMSS.log` / `.json` / `.jsonl` selon le format du sink ; une rotation quotidienne ne doit pas fusionner plusieurs exécutions applicatives dans le même fichier.
- **KSP-APP-031** — Le niveau de logging spécifique à une application/crate peut être élevé temporairement à `debug` ou `trace` pendant une phase de développement ou correction. Avant la clôture/release de cette application/crate, son niveau de référence est ramené à `info` ou `warn` selon le besoin opératoire ; il nest remonté que lorsquun développement/correctif est explicitement rouvert.
- **KSP-APP-032** — Les interfaces desk KSP nutilisent pas les dialogues bloquants natifs du navigateur (`window.alert`, `window.confirm`, `window.prompt`) pour les interactions applicatives normales. Les confirmations destructives ou privilégiées utilisent un modal Bootstrap intégré à lUI, instrumenté par le bridge Logging ; toute exception doit être explicitement justifiée et documentée.
- **KSP-APP-033** — Un test dune application ou dun manager qui peut modifier un document Config du workspace ne traite jamais les valeurs courantes de ce document comme une fixture immuable. Les tests de valeurs exactes utilisent une fixture isolée ; les tests qui lisent la Config workspace vérifient uniquement des invariants, la validité et la cohérence source → résolution → runtime afin de rester valides après une édition légitime par Config Desk.
## Data plane / control plane