diff --git a/Cargo.toml b/Cargo.toml index 6626f0a..6757e5e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,12 +1,12 @@ # file: Cargo.toml -# version: 63 +# version: 64 [workspace] resolver = "3" members = ["crates/ksp-config-lib", "crates/ksp-core-lib", "crates/ksp-logging-lib"] [workspace.package] -version = "0.1.4-pre.2" +version = "0.1.4-pre.3" edition = "2024" license = "MIT" repository = "https://git.sasedev.com/Sasedev/khadhroony-solana-project" diff --git a/crates/ksp-config-lib/README.md b/crates/ksp-config-lib/README.md index 0ba2756..6f0a83c 100644 --- a/crates/ksp-config-lib/README.md +++ b/crates/ksp-config-lib/README.md @@ -1,5 +1,5 @@ - + # ksp-config-lib @@ -23,7 +23,7 @@ La crate centralise les documents JSON, leurs schemas, les profils et compositio - la classification `Public`, `Internal`, `Secret` ; - les représentations réelle et sûre/redacted ainsi que la provenance des valeurs résolues ; - l'adapter du document Logging effectif vers `ksp_logging_lib::LoggingSettings` ; -- la surface de management pour inspecter les sources, modifier `std.logging.json`, consulter les rapports d'environnement, révéler explicitement une valeur réelle et modifier `.env` ; +- la surface de management pour inspecter et réparer les sources Config enregistrées, modifier `std.logging.json`, consulter les rapports d'environnement, révéler explicitement une valeur réelle et modifier `.env` ; - les écritures atomiques JSON/`.env` et la protection des permissions `.env` ; - les audits workspace empêchant les bypass d'ownership Config et les oublis dans `.env.example`. @@ -39,6 +39,8 @@ schema.composite -> config/schemas/composite.schema.json `ConfigFileRegistry::descriptors()` expose ces descripteurs en lecture seule et dans un ordre déterministe par `file_id`. Une application de management peut ainsi découvrir les fichiers connus sans maintenir une liste parallèle ni dépendre de leurs filenames physiques. +`ConfigManagement::read_source()` permet d'inspecter le texte brut d'un document Config enregistré même lorsque ce document est invalide. `save_source_candidate()` complète cette frontière : le candidat brut est parsé, validé contre son schema et les invariants sémantiques KSP, puis persisté atomiquement uniquement après validation complète. Le `file_id` doit appartenir au registre et désigner un document Config ; aucun path arbitraire n'est accepté. + `config/examples/composite.example.json` démontre le format composite sans créer de composite runtime fictif. Le fichier local d'environnement est : diff --git a/crates/ksp-config-lib/TODO.md b/crates/ksp-config-lib/TODO.md index f4155e4..7de158f 100644 --- a/crates/ksp-config-lib/TODO.md +++ b/crates/ksp-config-lib/TODO.md @@ -1,5 +1,5 @@ - + # TODO ksp-config-lib @@ -13,7 +13,7 @@ Les responsabilités prévues pour cette release sont implémentées et couverte `0.1.4-pre.002` ajoute l'inventaire public read-only de `ConfigFileRegistry`, nécessaire au shell Documents de `ksp-app-config-desk`. Le registre reste propriétaire des descripteurs ; l'application n'entretient pas de liste parallèle de `file_id`. -La seconde lacune révélée pendant `pre.001` reste à traiter dans la tranche suivante : une API Config bornée permettant de soumettre le source corrigé d'un `file_id` connu, le valider puis le persister atomiquement sans accès filesystem direct de l'application. +`0.1.4-pre.003` ferme la seconde lacune révélée pendant `pre.001` : `ConfigManagement::save_source_candidate()` permet de soumettre le texte corrigé d'un `file_id` Config connu, de le parser et de le valider entièrement, puis de le persister atomiquement sans accès filesystem direct de l'application. Les deux extensions Config préalables au shell desktop sont donc traitées. ## Validation desktop de `0.1.4` diff --git a/crates/ksp-config-lib/USAGE.md b/crates/ksp-config-lib/USAGE.md index 80708b6..a11f02b 100644 --- a/crates/ksp-config-lib/USAGE.md +++ b/crates/ksp-config-lib/USAGE.md @@ -1,5 +1,5 @@ - + # Utilisation de ksp-config-lib @@ -248,7 +248,7 @@ if saved.source_changed() && saved.reload_required() { La construction depuis zéro utilise les constructeurs publics `LoggingConfigDocument::new`, `LoggingProfileConfig::new`, `LoggingConsoleConfig::new`, `LoggingFileConfig::new`, `LoggingOutputFilterConfig::new` et `LoggingTargetFilterConfig::new`. Les mêmes contraintes schema/sémantiques sont appliquées au moment de `save_logging_document()`. -### 10.2 Inspecter un source enregistré sans contourner Config +### 10.2 Inspecter et réparer un source enregistré sans contourner Config ```rust let source = match management.read_source(&file_id) { @@ -261,7 +261,31 @@ let managed_path = source.path(); let raw_content = source.content(); ``` -Cette API est notamment destinée à une UI de réparation lorsque le document n'est plus validable. Elle n'autorise pas la lecture d'un chemin arbitraire. +Cette lecture est notamment destinée à une UI de réparation lorsque le document n'est plus validable. Elle n'autorise pas la lecture d'un chemin arbitraire. + +Après édition du texte brut, le candidat est soumis à Config : + +```rust +let saved = match management.save_source_candidate(&file_id, edited_source.as_str()) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), +}; + +if saved.source_changed() && saved.reload_required() { + // Reload the affected Config consumer through the application lifecycle. +} +``` + +`save_source_candidate()` : + +- accepte uniquement un `file_id` enregistré de kind `Config` ; +- parse le candidat comme JSON ; +- valide le schema enregistré et les invariants sémantiques KSP ; +- ne remplace aucune donnée lorsque l'une de ces validations échoue ; +- persiste atomiquement le texte brut validé sans reformattage implicite ; +- retourne `ConfigDocumentChangeReport` pour distinguer un changement réel d'un candidat identique. + +Le path reste résolu exclusivement par `ConfigFileRegistry`; l'appelant ne fournit jamais de path physique. ### 10.3 Exploiter les rapports `.env` diff --git a/crates/ksp-config-lib/src/document.rs b/crates/ksp-config-lib/src/document.rs index 3ec7e67..787ddec 100644 --- a/crates/ksp-config-lib/src/document.rs +++ b/crates/ksp-config-lib/src/document.rs @@ -1,5 +1,5 @@ // file: crates/ksp-config-lib/src/document.rs -// version: 4 +// version: 5 /// A Config-managed JSON document that has passed syntax, schema and current semantic validation. #[derive(Clone, Debug, PartialEq)] @@ -85,6 +85,20 @@ impl ConfigDocumentEngine { return self.validate_document(document, &schema_file_id); } + pub(crate) fn validate_source_candidate(&self, file_id: &crate::ConfigFileId, source: &str) -> ksp_core_lib::Result { + let path = self.registry.resolve_path(&self.bootstrap, file_id); + let path = match path { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let value = serde_json::from_str::(source); + let value = match value { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(json_syntax_error(file_id, path.as_path(), error)), + }; + return self.validate_candidate(file_id, value); + } + pub(crate) fn validate_candidate(&self, file_id: &crate::ConfigFileId, value: serde_json::Value) -> ksp_core_lib::Result { let descriptor = self.registry.descriptor(file_id); let descriptor = match descriptor { diff --git a/crates/ksp-config-lib/src/management.rs b/crates/ksp-config-lib/src/management.rs index 37b4325..650c67c 100644 --- a/crates/ksp-config-lib/src/management.rs +++ b/crates/ksp-config-lib/src/management.rs @@ -1,5 +1,5 @@ // file: crates/ksp-config-lib/src/management.rs -// version: 1 +// version: 2 /// Raw source of one registered Config document read for explicit management/correction. #[derive(Clone, Eq, PartialEq)] @@ -625,6 +625,29 @@ impl ConfigManagement { }; } + /// Validates and atomically persists a raw source candidate for one registered Config document. + /// + /// The candidate is parsed, schema-validated and checked against KSP semantic invariants before any destination bytes are replaced. The raw source text is + /// preserved exactly when persistence succeeds, allowing a management editor to repair an invalid document without bypassing Config ownership. + pub fn save_source_candidate(&self, file_id: &crate::ConfigFileId, source: &str) -> ksp_core_lib::Result { + let descriptor = self.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(error), + }; + if descriptor.kind() != crate::ConfigFileKind::Config { + return std::result::Result::Err( + management_error("management source persistence requires a Config document").with_context("file_id", file_id.as_str()), + ); + } + let validated = self.engine.validate_source_candidate(file_id, source); + let validated = match validated { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + return persist_document_source(file_id, validated.path(), source.as_bytes()); + } + /// Loads the validated `std.logging.json` source into its typed management contract. pub fn load_logging_document(&self) -> ksp_core_lib::Result { let file_id = logging_file_id(); @@ -675,27 +698,7 @@ impl ConfigManagement { }, }; serialized.push('\n'); - let existing = std::fs::read(validated.path()); - let existing = match existing { - std::result::Result::Ok(value) => value, - std::result::Result::Err(error) if error.kind() == std::io::ErrorKind::NotFound => std::vec::Vec::new(), - std::result::Result::Err(error) => { - return std::result::Result::Err( - ksp_core_lib::Error::new(crate::ERROR_CODE_JSON_FILE_READ_FAILED, "existing Config document cannot be read before persistence") - .with_context("file_id", file_id.as_str()) - .with_context("path", validated.path().to_string_lossy().into_owned()) - .with_source(error), - ); - }, - }; - if existing.as_slice() == serialized.as_bytes() { - return std::result::Result::Ok(ConfigDocumentChangeReport { source_changed: false, reload_required: false }); - } - let write = crate::persistence::atomic_write(validated.path(), serialized.as_bytes()); - if let std::result::Result::Err(error) = write { - return std::result::Result::Err(error); - } - return std::result::Result::Ok(ConfigDocumentChangeReport { source_changed: true, reload_required: true }); + return persist_document_source(&file_id, validated.path(), serialized.as_bytes()); } /// Returns safe desired/effective/shadow reports for every KSP/KSPB variable present in process or `.env` sources. @@ -999,6 +1002,30 @@ fn encode_dotenv_value(value: &str) -> String { return encoded; } +fn persist_document_source(file_id: &crate::ConfigFileId, path: &std::path::Path, source: &[u8]) -> ksp_core_lib::Result { + let existing = std::fs::read(path); + let existing = match existing { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) if error.kind() == std::io::ErrorKind::NotFound => std::vec::Vec::new(), + std::result::Result::Err(error) => { + return std::result::Result::Err( + ksp_core_lib::Error::new(crate::ERROR_CODE_JSON_FILE_READ_FAILED, "existing Config document cannot be read before persistence") + .with_context("file_id", file_id.as_str()) + .with_context("path", path.to_string_lossy().into_owned()) + .with_source(error), + ); + }, + }; + if existing.as_slice() == source { + return std::result::Result::Ok(ConfigDocumentChangeReport { source_changed: false, reload_required: false }); + } + let write = crate::persistence::atomic_write(path, source); + if let std::result::Result::Err(error) = write { + return std::result::Result::Err(error); + } + return std::result::Result::Ok(ConfigDocumentChangeReport { source_changed: true, reload_required: true }); +} + fn management_error(reason: &'static str) -> ksp_core_lib::Error { return ksp_core_lib::Error::new(crate::ERROR_CODE_MANAGEMENT_OPERATION_INVALID, "Config management operation is invalid").with_context("reason", reason); } diff --git a/crates/ksp-config-lib/tests/public_api.rs b/crates/ksp-config-lib/tests/public_api.rs index 43f85fa..f8d8505 100644 --- a/crates/ksp-config-lib/tests/public_api.rs +++ b/crates/ksp-config-lib/tests/public_api.rs @@ -1,5 +1,5 @@ // file: crates/ksp-config-lib/tests/public_api.rs -// version: 11 +// version: 12 //! Integration tests for the public `ksp-config-lib` bootstrap, registry, JSON/profile/composite, environment-resolution, sensitivity, Logging-adapter and //! management contracts. @@ -219,10 +219,15 @@ fn management_contracts_are_available_from_crate_root() { assert_eq!(logging.profiles()[0].profile_id(), "local_dev"); } } + let save_source_candidate: fn( + &ksp_config_lib::ConfigManagement, + &ksp_config_lib::ConfigFileId, + &str, + ) -> ksp_core_lib::Result = ksp_config_lib::ConfigManagement::save_source_candidate; let reveal_effective: fn(&ksp_config_lib::ConfigManagement, &str) -> ksp_core_lib::Result> = ksp_config_lib::ConfigManagement::reveal_effective_environment_value; let reveal_dotenv: fn(&ksp_config_lib::ConfigManagement, &str) -> ksp_core_lib::Result> = ksp_config_lib::ConfigManagement::reveal_dotenv_value; - let _ = (reveal_effective, reveal_dotenv); + let _ = (save_source_candidate, reveal_effective, reveal_dotenv); assert_ne!(ksp_config_lib::ERROR_CODE_MANAGEMENT_OPERATION_INVALID, ksp_config_lib::ERROR_CODE_PERSISTENCE_WRITE_FAILED); } diff --git a/crates/ksp-config-lib/unit_tests/management.rs b/crates/ksp-config-lib/unit_tests/management.rs index b3bce61..6eb90de 100644 --- a/crates/ksp-config-lib/unit_tests/management.rs +++ b/crates/ksp-config-lib/unit_tests/management.rs @@ -1,5 +1,5 @@ // file: crates/ksp-config-lib/unit_tests/management.rs -// version: 1 +// version: 2 static NEXT_FIXTURE_ID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); @@ -41,6 +41,176 @@ fn raw_management_read_remains_available_for_schema_invalid_source() { cleanup_fixture(&fixture); } +#[test] +fn valid_raw_source_candidate_is_persisted_exactly_and_reports_reload() { + let fixture = management_fixture(); + let fixture = match fixture { + std::result::Result::Ok(value) => value, + std::result::Result::Err(_) => return, + }; + let original = std::fs::read_to_string(fixture.config_path.as_path()); + let original = match original { + std::result::Result::Ok(value) => value, + std::result::Result::Err(_) => { + cleanup_fixture(&fixture); + return; + }, + }; + let candidate = original.replace(" \"logs_directory\":", " \"logs_directory\":"); + 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"); + let file_id = crate::ConfigFileId::new(crate::FILE_ID_STD_LOGGING); + let file_id = match file_id { + std::result::Result::Ok(value) => value, + std::result::Result::Err(_) => { + cleanup_fixture(&fixture); + return; + }, + }; + let saved = fixture.management.save_source_candidate(&file_id, candidate.as_str()); + assert!(saved.is_ok(), "valid raw source candidate should persist: {saved:?}"); + if let std::result::Result::Ok(saved) = saved { + assert!(saved.source_changed()); + assert!(saved.reload_required()); + } + assert_eq!(std::fs::read_to_string(fixture.config_path.as_path()).ok(), std::option::Option::Some(candidate.clone())); + let reloaded = fixture.management.load_logging_document(); + assert!(reloaded.is_ok(), "raw source persistence must leave a valid managed document: {reloaded:?}"); + let unchanged = fixture.management.save_source_candidate(&file_id, candidate.as_str()); + assert!(unchanged.is_ok(), "identical raw source candidate should succeed: {unchanged:?}"); + if let std::result::Result::Ok(unchanged) = unchanged { + assert!(!unchanged.source_changed()); + assert!(!unchanged.reload_required()); + } + cleanup_fixture(&fixture); +} + +#[test] +fn invalid_json_source_candidate_is_rejected_without_modifying_existing_file() { + let fixture = management_fixture(); + let fixture = match fixture { + std::result::Result::Ok(value) => value, + std::result::Result::Err(_) => return, + }; + let before = std::fs::read(fixture.config_path.as_path()); + let before = match before { + std::result::Result::Ok(value) => value, + std::result::Result::Err(_) => { + cleanup_fixture(&fixture); + return; + }, + }; + let file_id = crate::ConfigFileId::new(crate::FILE_ID_STD_LOGGING); + let file_id = match file_id { + std::result::Result::Ok(value) => value, + std::result::Result::Err(_) => { + cleanup_fixture(&fixture); + return; + }, + }; + let saved = fixture.management.save_source_candidate(&file_id, "{"); + assert!(saved.is_err(), "invalid JSON candidate must be rejected"); + if let std::result::Result::Err(error) = saved { + assert_eq!(error.code(), crate::ERROR_CODE_JSON_SYNTAX_INVALID); + } + assert_eq!(std::fs::read(fixture.config_path.as_path()).ok(), std::option::Option::Some(before)); + cleanup_fixture(&fixture); +} + +#[test] +fn schema_invalid_source_candidate_is_rejected_without_modifying_existing_file() { + let fixture = management_fixture(); + let fixture = match fixture { + std::result::Result::Ok(value) => value, + std::result::Result::Err(_) => return, + }; + let before = std::fs::read(fixture.config_path.as_path()); + let before = match before { + std::result::Result::Ok(value) => value, + std::result::Result::Err(_) => { + cleanup_fixture(&fixture); + return; + }, + }; + let file_id = crate::ConfigFileId::new(crate::FILE_ID_STD_LOGGING); + let file_id = match file_id { + std::result::Result::Ok(value) => value, + std::result::Result::Err(_) => { + cleanup_fixture(&fixture); + return; + }, + }; + let saved = fixture.management.save_source_candidate(&file_id, "{\"format_version\":1}"); + assert!(saved.is_err(), "schema-invalid candidate must be rejected"); + if let std::result::Result::Err(error) = saved { + assert_eq!(error.code(), crate::ERROR_CODE_SCHEMA_VALIDATION_FAILED); + } + assert_eq!(std::fs::read(fixture.config_path.as_path()).ok(), std::option::Option::Some(before)); + cleanup_fixture(&fixture); +} + +#[test] +fn semantic_invalid_source_candidate_is_rejected_without_modifying_existing_file() { + let fixture = management_fixture(); + let fixture = match fixture { + std::result::Result::Ok(value) => value, + std::result::Result::Err(_) => return, + }; + let before = std::fs::read_to_string(fixture.config_path.as_path()); + let before = match before { + std::result::Result::Ok(value) => value, + std::result::Result::Err(_) => { + cleanup_fixture(&fixture); + return; + }, + }; + let candidate = before.replace("\"default_profile\": \"local_dev\"", "\"default_profile\": \"missing\""); + 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 { + std::result::Result::Ok(value) => value, + std::result::Result::Err(_) => { + cleanup_fixture(&fixture); + return; + }, + }; + let saved = fixture.management.save_source_candidate(&file_id, candidate.as_str()); + assert!(saved.is_err(), "semantic-invalid candidate must be rejected"); + if let std::result::Result::Err(error) = saved { + assert_eq!(error.code(), crate::ERROR_CODE_DOCUMENT_SEMANTIC_INVALID); + } + assert_eq!(std::fs::read_to_string(fixture.config_path.as_path()).ok(), std::option::Option::Some(before)); + cleanup_fixture(&fixture); +} + +#[test] +fn raw_source_candidate_rejects_schema_and_unknown_file_ids_without_filesystem_escape() { + let fixture = management_fixture(); + let fixture = match fixture { + std::result::Result::Ok(value) => value, + std::result::Result::Err(_) => return, + }; + let schema_file_id = crate::ConfigFileId::new(crate::FILE_ID_SCHEMA_STD_LOGGING); + if let std::result::Result::Ok(schema_file_id) = schema_file_id { + let saved = fixture.management.save_source_candidate(&schema_file_id, "{}"); + assert!(saved.is_err(), "schema file_id must not be writable through Config source management"); + if let std::result::Result::Err(error) = saved { + assert_eq!(error.code(), crate::ERROR_CODE_MANAGEMENT_OPERATION_INVALID); + } + } + let unknown_file_id = crate::ConfigFileId::new("cfg.not.registered"); + if let std::result::Result::Ok(unknown_file_id) = unknown_file_id { + let saved = fixture.management.save_source_candidate(&unknown_file_id, "{}"); + assert!(saved.is_err(), "unknown Config file_id must not become an arbitrary filesystem write"); + if let std::result::Result::Err(error) = saved { + assert_eq!(error.code(), crate::ERROR_CODE_FILE_ID_UNKNOWN); + } + } + assert!(!fixture.root.join("not.registered").exists()); + cleanup_fixture(&fixture); +} + #[test] fn typed_logging_document_can_be_mutated_validated_and_persisted_atomically() { let fixture = management_fixture(); diff --git a/deltas/0.1.4/pre.003.md b/deltas/0.1.4/pre.003.md new file mode 100644 index 0000000..9ff4f87 --- /dev/null +++ b/deltas/0.1.4/pre.003.md @@ -0,0 +1,240 @@ + + + +# Delta 0.1.4-pre.003 — réparation validée d'un source Config + +## Base requise + +```text +0.1.4-pre.002 +workspace.package.version = "0.1.4-pre.2" +``` + +`pre.002` est considéré comme validé et commité après exécution locale de `cargo fmt`, `cargo check`, Clippy, des tests ciblés `ksp-config-lib` et de `cargo tree -p ksp-config-lib`. + +## Objectif + +Fermer la seconde lacune `ksp-config-lib` identifiée pendant `pre.001` avant la construction de `ksp-app-config-desk` : permettre à une application de management de corriger le texte brut d'un document Config invalide sans obtenir d'accès filesystem direct ni reproduire le parsing, la validation JSON Schema ou les invariants sémantiques de Config. + +Cette tranche reste bornée à la persistence d'un **document de kind Config déjà enregistré dans `ConfigFileRegistry`**. Les schemas restent read-only depuis cette surface et aucun path arbitraire n'est accepté. + +## Version Cargo + +Conformément à `VER-ID-009`, `workspace.package.version` passe de : + +```text +0.1.4-pre.2 +``` + +à : + +```text +0.1.4-pre.3 +``` + +L'identifiant de livraison est : + +```text +0.1.4-pre.003 +``` + +## 1. API publique de réparation + +`ConfigManagement` expose désormais : + +```rust +pub fn save_source_candidate( + &self, + file_id: &ConfigFileId, + source: &str, +) -> ksp_core_lib::Result +``` + +Le workflow est : + +```text +file_id enregistré de kind Config + -> résolution du path par ConfigFileRegistry + -> parsing JSON du texte candidat + -> validation du schema enregistré + -> validation des invariants sémantiques KSP + -> comparaison aux octets existants + -> persistence atomique uniquement si le candidat est valide et différent +``` + +L'appelant ne fournit jamais de path physique. + +Un `file_id` inconnu est rejeté par le registre. Un `file_id` de kind `Schema` est rejeté par la façade de management. Cette surface ne constitue donc pas une primitive de write générique. + +## 2. Conservation du source brut + +Contrairement à `save_logging_document()`, qui sérialise volontairement son contrat typé en JSON pretty-printé, `save_source_candidate()` persiste le **texte brut validé exactement tel qu'il a été soumis**. + +Cette différence est intentionnelle : le workflow Documents de Config Desk doit pouvoir afficher un fichier invalide, laisser l'opérateur corriger son source, puis le sauvegarder sans reformattage implicite supplémentaire. + +Le candidat reste néanmoins interprété et validé par `ksp-config-lib`; la conservation du formatting ne déplace aucune autorité de validation vers l'UI. + +## 3. Validation du candidat dans le moteur documentaire + +`ConfigDocumentEngine` possède maintenant une petite primitive interne `validate_source_candidate()` qui : + +- résout le path logique nécessaire aux diagnostics ; +- parse le texte via `serde_json` ; +- produit le même `ERROR_CODE_JSON_SYNTAX_INVALID` que la lecture documentaire normale ; +- délègue ensuite à la validation candidate existante pour le schema et les invariants sémantiques. + +Cette fonction reste `pub(crate)` : la surface publique de mutation est `ConfigManagement`, pas le moteur brut. + +## 4. Persistence partagée + +La comparaison du source existant et l'appel à `persistence::atomic_write()` sont regroupés dans un helper privé de management commun à : + +- `save_source_candidate()` ; +- `save_logging_document()`. + +Le contrat existant de la sauvegarde Logging ne change pas : elle continue de pretty-printer et d'ajouter un newline final avant d'utiliser la même étape de persistence. + +`ConfigDocumentChangeReport` conserve sa sémantique : + +- source identique -> `source_changed = false`, `reload_required = false` ; +- source validé et remplacé -> `source_changed = true`, `reload_required = true`. + +## 5. No-write-on-error + +Les tests couvrent séparément les échecs avant persistence : + +- JSON syntaxiquement invalide -> `ERROR_CODE_JSON_SYNTAX_INVALID` ; +- document ne satisfaisant pas le schema -> `ERROR_CODE_SCHEMA_VALIDATION_FAILED` ; +- document schema-valid mais sémantiquement invalide -> `ERROR_CODE_DOCUMENT_SEMANTIC_INVALID`. + +Dans les trois cas, les octets du fichier existant sont comparés avant/après et doivent rester strictement identiques. + +Un test de réparation part d'un fichier physiquement corrompu puis soumet un candidat valide. La sauvegarde doit réussir, conserver exactement le texte candidat et rendre le document de nouveau chargeable via `load_logging_document()`. + +## 6. Bornage par registre + +Les tests vérifient également : + +- refus d'un `schema.std.logging` par `ERROR_CODE_MANAGEMENT_OPERATION_INVALID` ; +- refus d'un `cfg.not.registered` par `ERROR_CODE_FILE_ID_UNKNOWN` ; +- absence de création d'un fichier dérivé du texte du `file_id` inconnu. + +La signature publique ne contient aucun `Path`/`PathBuf`, ce qui maintient l'ownership du mapping physique dans `ConfigFileRegistry`. + +## 7. Surface publique + +`tests/public_api.rs` compile la signature de `ConfigManagement::save_source_candidate()` depuis la racine de crate avec : + +- `ConfigManagement` ; +- `ConfigFileId` ; +- `ConfigDocumentChangeReport`. + +Aucun nouveau DTO ou type public n'est nécessaire. + +## 8. Documentation Config + +`README.md` documente maintenant la paire : + +```text +read_source() + -> inspection brute même si invalide + +save_source_candidate() + -> parse + schema + sémantique + persistence atomique +``` + +`USAGE.md` ajoute le workflow version-neutral de réparation et précise la différence entre source brut et sauvegarde Logging typée. + +`TODO.md` marque les deux extensions Config préalables au desktop comme traitées : + +- inventaire du registre par `pre.002` ; +- réparation validée du source par `pre.003`. + +## Hors scope confirmé + +Cette tranche n'ajoute pas : + +- `ksp-app-config-desk` ; +- Tauri ou dépendance frontend ; +- DTO TS-RS ; +- édition de schemas ; +- JSON Patch générique ; +- path physique fourni par l'appelant ; +- nouveau document Config/schema ; +- variable `.env` ; +- watcher filesystem ; +- reload runtime Logging. + +## Fichiers ajoutés + +```text +deltas/0.1.4/pre.003.md +``` + +## Fichiers modifiés + +| Fichier | Version précédente | Nouvelle version | +| ---------------------------------------------- | ------------------: | ---------------: | +| `Cargo.toml` | 63 | 64 | +| `crates/ksp-config-lib/src/document.rs` | 4 | 5 | +| `crates/ksp-config-lib/src/management.rs` | 1 | 2 | +| `crates/ksp-config-lib/unit_tests/management.rs` | 1 | 2 | +| `crates/ksp-config-lib/tests/public_api.rs` | 11 | 12 | +| `crates/ksp-config-lib/README.md` | 2 | 3 | +| `crates/ksp-config-lib/USAGE.md` | 3 | 4 | +| `crates/ksp-config-lib/TODO.md` | 2 | 3 | + +## Fichiers supprimés + +Aucun. + +## Validations exécutées dans l'environnement de préparation + +- contrôle statique du diff et du périmètre `pre.003` ; +- vérification de `workspace.package.version = "0.1.4-pre.3"` ; +- vérification des headers `file:` / `version:` des fichiers modifiés ; +- vérification de la limite KSP de 160 colonnes sur les fichiers Rust modifiés ; +- vérification de l'absence d'ajout de dépendance ; +- vérification que l'API publique n'accepte aucun path physique ; +- vérification des cas de tests JSON/schema/sémantique/no-write, réparation valide et bornage `file_id` ; +- vérification syntaxique TOML avec le parseur disponible ; +- contrôle des fences Markdown et des espaces de fin de ligne des fichiers livrés. + +## Validations à exécuter localement + +Le sandbox de préparation ne fournit pas les binaires Rust/Cargo. Avant validation/commit de `pre.003`, exécuter : + +```text +cargo fmt --all +cargo check --workspace +cargo clippy --workspace --all-targets +cargo test -p ksp-config-lib +cargo tree -p ksp-config-lib +``` + +`cargo test --workspace` n'est pas requis pour cette tranche ciblée ; il reste réservé aux frontières globales prévues par le plan de `0.1.4`. + +## Résultat attendu des nouveaux tests + +Par rapport à `pre.002`, cette tranche ajoute cinq tests unitaires de management : + +1. réparation/persistence exacte d'un candidat valide ; +2. rejet syntaxique sans write ; +3. rejet schema sans write ; +4. rejet sémantique sans write ; +5. refus des `file_id` schema/inconnus comme cible de persistence Config. + +Le nombre exact affiché par `cargo test` doit être relevé lors de la validation locale plutôt que codé comme invariant documentaire. + +## Décisions prises + +- le nom public retenu est `ConfigManagement::save_source_candidate()` ; +- la mutation est générique par `file_id`, mais uniquement pour les documents de kind `Config` déjà enregistrés ; +- le source brut validé est conservé tel quel ; +- les sauvegardes typées spécialisées, comme Logging, peuvent conserver leur propre normalisation ; +- schema et invariants sémantiques restent entièrement propriétaires de `ksp-config-lib` ; +- aucune surface de write de schema ou de filesystem arbitraire n'est introduite. + +## Suite + +Après validation et commit de `pre.003`, les deux prérequis Config révélés par `pre.001` sont fermés. La tranche suivante prévue par le plan est `0.1.4-pre.004` : **squelette Rust Tauri de `ksp-app-config-desk`**, package mixte `lib` + `bin`, frontière `tauri.rs`, single-instance et builder construit par étapes courtes.