Compare commits
28 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 513f57dd21 | |||
| 8279241144 | |||
| ee8fdedf86 | |||
| 64136c99ad | |||
| 92982667ac | |||
| 299a0cb2fc | |||
| 9549303ed5 | |||
| a9405ec7ff | |||
| 6a13cea614 | |||
| 274428bd2f | |||
| 695068acea | |||
| 1fc002c978 | |||
| 97a07ce683 | |||
| a5b4c748ea | |||
| d1196c03e5 | |||
| 96753e4ba1 | |||
| b7323fe961 | |||
| 3a479e4f43 | |||
| 063b24ee1c | |||
| 29660fd9f0 | |||
| 0629a48e97 | |||
| 9207919d47 | |||
| 8360f25f59 | |||
| ff94762b10 | |||
| 532f56eaad | |||
| 5cba2beb64 | |||
| ba384ec3d7 | |||
| ba158ad375 |
6
.env.example
Normal file
6
.env.example
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
# file: .env.example
|
||||||
|
# version: 1
|
||||||
|
|
||||||
|
# KSP Logging root directory. Used by config/std.logging.json for relative log output paths.
|
||||||
|
# The current Config document fallback is "logs" when neither the process environment nor .env defines this variable.
|
||||||
|
KSP_LOGS_DIRECTORY=logs
|
||||||
22
CHANGELOG.md
Normal file
22
CHANGELOG.md
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
<!-- file: CHANGELOG.md -->
|
||||||
|
<!-- version: 2 -->
|
||||||
|
|
||||||
|
# Changelog KSP
|
||||||
|
|
||||||
|
Ce changelog résume uniquement les releases KSP considérées comme stables, dans l'ordre chronologique décroissant. Les détails de chaque livraison restent dans `deltas/`.
|
||||||
|
|
||||||
|
## 0.1.3 — Configuration foundation — 2026-08-16
|
||||||
|
|
||||||
|
`0.1.3` stabilise `ksp-config-lib` comme propriétaire KSP unique des documents Config, schemas, profils, compositions, variables `KSP_*`/`KSPB_*`, `.env`, placeholders et persistence autorisée. La release introduit le bootstrap non récursif `cfgpath`/`schemapath`, le registre logique `file_id -> filename`, JSON Schema, globals/profils/`default_profile`, compositions par `file_id`, priorité process env > `.env` > fallback, sensibilité `Public`/`Internal`/`Secret`, représentations real/safe avec provenance, management/persistence atomique JSON et `.env`, ainsi que l'adapter vers `ksp_logging_lib::LoggingSettings` et les audits d'ownership. Elle complète également `ksp-logging-lib` avec les contrats/runtime multi-sink, routing structuré `domain` et hot reload nécessaires au premier document `std.logging.json`, puis prépare `0.1.4 — ksp-app-config-desk` comme validation desktop/Tauri extensible de cette fondation.
|
||||||
|
|
||||||
|
## 0.1.2 — Logging foundation — 2026-08-14
|
||||||
|
|
||||||
|
`0.1.2` stabilise `ksp-logging-lib` comme façade KSP unique de logging/tracing runtime. La release introduit les événements et spans KSP, le takeover des targets, le subscriber global unique, `LoggingSettings`, les sorties console/fichier non bloquantes, rotation, stripping ANSI, compteurs de lignes abandonnées, hot reload transactionnel et instrumentation async indépendante de l'executor. La stack `tracing`, `tracing-subscriber` et `tracing-appender` reste possédée exclusivement par Logging ; Tokio est limité aux tests réels d'instrumentation async.
|
||||||
|
|
||||||
|
## 0.1.1 — Core foundation — 2026-08-14
|
||||||
|
|
||||||
|
`0.1.1` stabilise `ksp-core-lib` avec le contrat commun `ErrorCode` / `ErrorContext` / `Error` / `Result<T>`, la primitive `Pubkey`, les Program IDs Solana fondamentaux possédés par KSP et leur registre canonique recherché/filtrable. La release fixe également la taxonomie de classification et la politique Cargo workspace utilisée par les crates suivantes.
|
||||||
|
|
||||||
|
## 0.0.3 — Fondation architecture et règles — 2026-08-14
|
||||||
|
|
||||||
|
`0.0.3` clôt la phase fondatrice : nomenclature KSP, règles Rust/Cargo/documentation, architecture en couches, contrats des composants, workers/jobs/pipelines/scénarios/apps, politique de versions/deltas et séquence des premières releases fonctionnelles. Elle prépare explicitement l'ouverture de `0.1.1` sans ajouter de fonctionnalité métier Solana.
|
||||||
11
Cargo.toml
11
Cargo.toml
@@ -1,12 +1,12 @@
|
|||||||
# file: Cargo.toml
|
# file: Cargo.toml
|
||||||
# version: 39
|
# version: 61
|
||||||
|
|
||||||
[workspace]
|
[workspace]
|
||||||
resolver = "3"
|
resolver = "3"
|
||||||
members = ["crates/ksp-core-lib", "crates/ksp-logging-lib"]
|
members = ["crates/ksp-config-lib", "crates/ksp-core-lib", "crates/ksp-logging-lib"]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "0.1.2"
|
version = "0.1.3"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
repository = "https://git.sasedev.com/Sasedev/khadhroony-solana-project"
|
repository = "https://git.sasedev.com/Sasedev/khadhroony-solana-project"
|
||||||
@@ -14,9 +14,12 @@ authors = ["SinuS von SifriduS <sinus@sasedev.net>"]
|
|||||||
publish = false
|
publish = false
|
||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
|
serde = { version = "^1.0", features = ["derive"] }
|
||||||
|
serde_json = { version = "^1.0" }
|
||||||
|
jsonschema = { version = "^0.49", default-features = false }
|
||||||
solana-pubkey = { version = "^4.3", default-features = false }
|
solana-pubkey = { version = "^4.3", default-features = false }
|
||||||
tracing = { version = "^0.1", default-features = false, features = ["std"] }
|
tracing = { version = "^0.1", default-features = false, features = ["std"] }
|
||||||
tracing-subscriber = { version = "^0.3", default-features = false, features = ["fmt"] }
|
tracing-subscriber = { version = "^0.3", default-features = false, features = ["fmt", "json", "ansi"] }
|
||||||
tracing-appender = { version = "^0.2", default-features = false }
|
tracing-appender = { version = "^0.2", default-features = false }
|
||||||
tokio = { version = "^1.53", default-features = false, features = ["rt", "rt-multi-thread", "macros"] }
|
tokio = { version = "^1.53", default-features = false, features = ["rt", "rt-multi-thread", "macros"] }
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<!-- file: README.md -->
|
<!-- file: README.md -->
|
||||||
<!-- version: 5 -->
|
<!-- version: 7 -->
|
||||||
|
|
||||||
# Khadhroony Solana Project
|
# Khadhroony Solana Project
|
||||||
|
|
||||||
@@ -35,7 +35,7 @@ Les besoins du trading constituent une priorité produit à court terme mais ne
|
|||||||
- Les jobs utilisent le préfixe `ksp-job-`.
|
- Les jobs utilisent le préfixe `ksp-job-`.
|
||||||
- Une application ou un outil de démonstration se termine par `-demo`.
|
- Une application ou un outil de démonstration se termine par `-demo`.
|
||||||
- Les crates Rust sont placées directement sous `crates/`, sans sous-répertoires de catégories.
|
- Les crates Rust sont placées directement sous `crates/`, sans sous-répertoires de catégories.
|
||||||
- Les applications sont placées sous `apps/` lorsqu'elles sont introduites.
|
- Les applications Tauri, workers, jobs et autres packages Rust KSP sont des crates workspace placées directement sous `crates/`; leur nom encode leur rôle (`ksp-app-*`, `ksp-worker-*`, `ksp-job-*`).
|
||||||
- Les composants réutilisables restent séparés de leurs applications de manipulation ou de démonstration.
|
- Les composants réutilisables restent séparés de leurs applications de manipulation ou de démonstration.
|
||||||
- Les applications et demos restent des interfaces/compositions ; les opérations réutilisables appartiennent aux composants KSP de niveau approprié.
|
- Les applications et demos restent des interfaces/compositions ; les opérations réutilisables appartiennent aux composants KSP de niveau approprié.
|
||||||
- Les exécutables KSP ne dépendent pas directement de crates externes relatives à Solana ou à un protocole Solana.
|
- Les exécutables KSP ne dépendent pas directement de crates externes relatives à Solana ou à un protocole Solana.
|
||||||
@@ -51,6 +51,7 @@ Les besoins du trading constituent une priorité produit à court terme mais ne
|
|||||||
|
|
||||||
- [`RULES.md`](RULES.md) — index des règles normatives ;
|
- [`RULES.md`](RULES.md) — index des règles normatives ;
|
||||||
- [`ROADMAP.md`](ROADMAP.md) — trajectoire globale du projet ;
|
- [`ROADMAP.md`](ROADMAP.md) — trajectoire globale du projet ;
|
||||||
|
- [`CHANGELOG.md`](CHANGELOG.md) — synthèse des releases stables ;
|
||||||
- [`docs/000-README.md`](docs/000-README.md) — point d'entrée de la documentation ;
|
- [`docs/000-README.md`](docs/000-README.md) — point d'entrée de la documentation ;
|
||||||
- [`docs/IDEAS.md`](docs/IDEAS.md) — idées et sujets à explorer ;
|
- [`docs/IDEAS.md`](docs/IDEAS.md) — idées et sujets à explorer ;
|
||||||
- [`prompts/000-README.md`](prompts/000-README.md) — prompts de reprise ;
|
- [`prompts/000-README.md`](prompts/000-README.md) — prompts de reprise ;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<!-- file: ROADMAP.md -->
|
<!-- file: ROADMAP.md -->
|
||||||
<!-- version: 14 -->
|
<!-- version: 16 -->
|
||||||
|
|
||||||
# Roadmap KSP
|
# Roadmap KSP
|
||||||
|
|
||||||
@@ -33,10 +33,10 @@ Regrouper les releases consacrées aux fondations N1. Chaque release concrète e
|
|||||||
|
|
||||||
- [X] `0.1.1` — Stabiliser `ksp-core-lib` : `Error`/`Result`, Program IDs fondamentaux et primitives réellement N1.
|
- [X] `0.1.1` — Stabiliser `ksp-core-lib` : `Error`/`Result`, Program IDs fondamentaux et primitives réellement N1.
|
||||||
- [X] `0.1.2` — Introduire `ksp-logging-lib` comme façade KSP de `tracing`, `tracing-appender` et `tracing-subscriber`.
|
- [X] `0.1.2` — Introduire `ksp-logging-lib` comme façade KSP de `tracing`, `tracing-appender` et `tracing-subscriber`.
|
||||||
- [ ] `0.1.3` — Introduire `ksp-config-lib` : documents, profils, résolution, validation et modifications autorisées.
|
- [X] `0.1.3` — Stabiliser `ksp-config-lib` : documents, profils, résolution, validation, environnement KSP/KSPB, management/persistence et adapter Logging.
|
||||||
- [ ] `0.1.4` — Introduire `ksp-app-config-desk` pour valider réellement Config et la frontière Tauri.
|
- [ ] `0.1.4` — Introduire `ksp-app-config-desk` pour valider réellement Config et la frontière Tauri.
|
||||||
|
|
||||||
`0.1.1` et `0.1.2` sont fixées. `0.1.3` / `0.1.4` constituent la séquence par défaut : si le `pre.001` de Config démontre que son périmètre doit être scindé, une release supplémentaire est insérée et les numéros suivants sont décalés plutôt que de surcharger une release.
|
`0.1.1`, `0.1.2` et `0.1.3` sont désormais stables. `0.1.4` constitue l'étape active suivante avec `ksp-app-config-desk`, première validation desktop/Tauri de Config et modèle des futures applications Tauri KSP.
|
||||||
|
|
||||||
Les contrats publics supplémentaires ne sont introduits que lorsqu'une release concrète en démontre le besoin.
|
Les contrats publics supplémentaires ne sont introduits que lorsqu'une release concrète en démontre le besoin.
|
||||||
|
|
||||||
|
|||||||
25
config/examples/composite.example.json
Normal file
25
config/examples/composite.example.json
Normal 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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
64
config/examples/std.logging.example.json
Normal file
64
config/examples/std.logging.example.json
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
{
|
||||||
|
"format_version": 1,
|
||||||
|
"logs_directory": "${KSP_LOGS_DIRECTORY:-logs}",
|
||||||
|
"default_profile": "example",
|
||||||
|
"profiles": [
|
||||||
|
{
|
||||||
|
"profile_id": "example",
|
||||||
|
"default_filter": "info",
|
||||||
|
"span_events": "new_and_close",
|
||||||
|
"console": {
|
||||||
|
"enabled": true,
|
||||||
|
"output": "stdout",
|
||||||
|
"ansi": true,
|
||||||
|
"format": "pretty",
|
||||||
|
"filter": {
|
||||||
|
"level": "debug",
|
||||||
|
"targets": [
|
||||||
|
"*"
|
||||||
|
],
|
||||||
|
"domains": [
|
||||||
|
"*"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
{
|
||||||
|
"output_id": "file.all.info",
|
||||||
|
"enabled": true,
|
||||||
|
"path": "ksp-info.log",
|
||||||
|
"rotation": "daily",
|
||||||
|
"format": "compact",
|
||||||
|
"ansi": false,
|
||||||
|
"filter": {
|
||||||
|
"level": "info",
|
||||||
|
"targets": [
|
||||||
|
"*"
|
||||||
|
],
|
||||||
|
"domains": [
|
||||||
|
"*"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"output_id": "file.store.trace",
|
||||||
|
"enabled": false,
|
||||||
|
"path": "store/store-trace.jsonl",
|
||||||
|
"rotation": "hourly",
|
||||||
|
"format": "json",
|
||||||
|
"ansi": false,
|
||||||
|
"filter": {
|
||||||
|
"level": "trace",
|
||||||
|
"targets": [
|
||||||
|
"*"
|
||||||
|
],
|
||||||
|
"domains": [
|
||||||
|
"store"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"target_filters": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
62
config/schemas/composite.schema.json
Normal file
62
config/schemas/composite.schema.json
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"title": "KSP composite configuration",
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": ["format_version", "default_profile", "profiles"],
|
||||||
|
"properties": {
|
||||||
|
"format_version": {
|
||||||
|
"const": 1
|
||||||
|
},
|
||||||
|
"default_profile": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
},
|
||||||
|
"profiles": {
|
||||||
|
"type": "array",
|
||||||
|
"minItems": 1,
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/$defs/composite_profile"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"$defs": {
|
||||||
|
"composite_profile": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": ["profile_id", "documents"],
|
||||||
|
"properties": {
|
||||||
|
"profile_id": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
},
|
||||||
|
"documents": {
|
||||||
|
"type": "array",
|
||||||
|
"minItems": 1,
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/$defs/document_reference"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"document_reference": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": ["component_id", "file_id"],
|
||||||
|
"properties": {
|
||||||
|
"component_id": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
},
|
||||||
|
"file_id": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^cfg\\.std\\.[a-z0-9][a-z0-9._-]*$"
|
||||||
|
},
|
||||||
|
"profile_id": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
266
config/schemas/std.logging.schema.json
Normal file
266
config/schemas/std.logging.schema.json
Normal file
@@ -0,0 +1,266 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"$id": "urn:ksp:schema:std.logging:v1",
|
||||||
|
"title": "KSP standard Logging configuration",
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": [
|
||||||
|
"format_version",
|
||||||
|
"logs_directory",
|
||||||
|
"default_profile",
|
||||||
|
"profiles"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"format_version": {
|
||||||
|
"const": 1
|
||||||
|
},
|
||||||
|
"logs_directory": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
},
|
||||||
|
"default_profile": {
|
||||||
|
"$ref": "#/$defs/profileId"
|
||||||
|
},
|
||||||
|
"profiles": {
|
||||||
|
"type": "array",
|
||||||
|
"minItems": 1,
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/$defs/profile"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"$defs": {
|
||||||
|
"profileId": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^[a-z0-9][a-z0-9._-]*$"
|
||||||
|
},
|
||||||
|
"level": {
|
||||||
|
"enum": [
|
||||||
|
"off",
|
||||||
|
"error",
|
||||||
|
"warn",
|
||||||
|
"info",
|
||||||
|
"debug",
|
||||||
|
"trace"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"format": {
|
||||||
|
"enum": [
|
||||||
|
"human",
|
||||||
|
"compact",
|
||||||
|
"pretty",
|
||||||
|
"json"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"selectorList": {
|
||||||
|
"type": "array",
|
||||||
|
"minItems": 1,
|
||||||
|
"uniqueItems": true,
|
||||||
|
"items": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
},
|
||||||
|
"allOf": [
|
||||||
|
{
|
||||||
|
"if": {
|
||||||
|
"contains": {
|
||||||
|
"const": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"then": {
|
||||||
|
"maxItems": 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"targetSelectorList": {
|
||||||
|
"allOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/$defs/selectorList"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"items": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"const": "*"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^ksp-"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"outputFilter": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": [
|
||||||
|
"level",
|
||||||
|
"targets",
|
||||||
|
"domains"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"level": {
|
||||||
|
"$ref": "#/$defs/level"
|
||||||
|
},
|
||||||
|
"targets": {
|
||||||
|
"$ref": "#/$defs/targetSelectorList"
|
||||||
|
},
|
||||||
|
"domains": {
|
||||||
|
"$ref": "#/$defs/selectorList"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"console": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": [
|
||||||
|
"enabled",
|
||||||
|
"output",
|
||||||
|
"ansi",
|
||||||
|
"format",
|
||||||
|
"filter"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"enabled": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"output": {
|
||||||
|
"enum": [
|
||||||
|
"stdout",
|
||||||
|
"stderr"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"ansi": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"format": {
|
||||||
|
"$ref": "#/$defs/format"
|
||||||
|
},
|
||||||
|
"filter": {
|
||||||
|
"$ref": "#/$defs/outputFilter"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"not": {
|
||||||
|
"properties": {
|
||||||
|
"ansi": {
|
||||||
|
"const": true
|
||||||
|
},
|
||||||
|
"format": {
|
||||||
|
"const": "json"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"ansi",
|
||||||
|
"format"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"outputId": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^[a-z0-9][a-z0-9_-]*(\\.[a-z0-9][a-z0-9_-]*)*$"
|
||||||
|
},
|
||||||
|
"file": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": [
|
||||||
|
"output_id",
|
||||||
|
"enabled",
|
||||||
|
"path",
|
||||||
|
"rotation",
|
||||||
|
"format",
|
||||||
|
"ansi",
|
||||||
|
"filter"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"output_id": {
|
||||||
|
"$ref": "#/$defs/outputId"
|
||||||
|
},
|
||||||
|
"enabled": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"path": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
},
|
||||||
|
"rotation": {
|
||||||
|
"enum": [
|
||||||
|
"never",
|
||||||
|
"hourly",
|
||||||
|
"daily"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"format": {
|
||||||
|
"$ref": "#/$defs/format"
|
||||||
|
},
|
||||||
|
"ansi": {
|
||||||
|
"const": false
|
||||||
|
},
|
||||||
|
"filter": {
|
||||||
|
"$ref": "#/$defs/outputFilter"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"targetFilter": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": [
|
||||||
|
"target_prefix",
|
||||||
|
"level"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"target_prefix": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^ksp-"
|
||||||
|
},
|
||||||
|
"level": {
|
||||||
|
"$ref": "#/$defs/level"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"profile": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": [
|
||||||
|
"profile_id",
|
||||||
|
"default_filter",
|
||||||
|
"span_events",
|
||||||
|
"console",
|
||||||
|
"files",
|
||||||
|
"target_filters"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"profile_id": {
|
||||||
|
"$ref": "#/$defs/profileId"
|
||||||
|
},
|
||||||
|
"default_filter": {
|
||||||
|
"$ref": "#/$defs/level"
|
||||||
|
},
|
||||||
|
"span_events": {
|
||||||
|
"enum": [
|
||||||
|
"off",
|
||||||
|
"new_and_close",
|
||||||
|
"full"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"console": {
|
||||||
|
"$ref": "#/$defs/console"
|
||||||
|
},
|
||||||
|
"files": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/$defs/file"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"target_filters": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/$defs/targetFilter"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
73
config/std.logging.json
Normal file
73
config/std.logging.json
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
{
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
18
crates/ksp-config-lib/Cargo.toml
Normal file
18
crates/ksp-config-lib/Cargo.toml
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# file: crates/ksp-config-lib/Cargo.toml
|
||||||
|
# version: 3
|
||||||
|
|
||||||
|
[package]
|
||||||
|
name = "ksp-config-lib"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
ksp-core-lib = { path = "../ksp-core-lib" }
|
||||||
|
ksp-logging-lib = { path = "../ksp-logging-lib" }
|
||||||
|
serde.workspace = true
|
||||||
|
serde_json.workspace = true
|
||||||
|
jsonschema.workspace = true
|
||||||
|
|
||||||
|
[lints]
|
||||||
|
workspace = true
|
||||||
82
crates/ksp-config-lib/README.md
Normal file
82
crates/ksp-config-lib/README.md
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
<!-- file: crates/ksp-config-lib/README.md -->
|
||||||
|
<!-- version: 1 -->
|
||||||
|
|
||||||
|
# ksp-config-lib
|
||||||
|
|
||||||
|
`ksp-config-lib` est le propriétaire unique de la configuration applicative KSP.
|
||||||
|
|
||||||
|
La crate centralise les documents JSON, leurs schemas, les profils et compositions, les variables d'environnement `KSP_*` / `KSPB_*`, le fichier `.env`, la résolution effective et les mutations persistantes explicitement autorisées.
|
||||||
|
|
||||||
|
## Responsabilités
|
||||||
|
|
||||||
|
`ksp-config-lib` possède :
|
||||||
|
|
||||||
|
- le bootstrap non récursif `config/` / `config/schemas/` et les overrides `--cfgpath` / `--schemapath` ;
|
||||||
|
- le registre logique `file_id -> filename` et les overrides `--filemap=<file_id>=<filename>` ;
|
||||||
|
- la lecture JSON et la validation JSON Schema Draft 2020-12 ;
|
||||||
|
- les invariants sémantiques KSP des documents connus ;
|
||||||
|
- les globals, `default_profile`, profils nommés et leur provenance ;
|
||||||
|
- les compositions génériques par `file_id`, sans dépendance à un filename physique ;
|
||||||
|
- le snapshot des variables process KSP/KSPB et la lecture de `./.env` ;
|
||||||
|
- la priorité `process > .env > fallback > missing` ;
|
||||||
|
- les placeholders `${NAME}` et `${NAME:-fallback}` ;
|
||||||
|
- 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` ;
|
||||||
|
- 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`.
|
||||||
|
|
||||||
|
## Ressources gérées dans `0.1.3`
|
||||||
|
|
||||||
|
Le registre par défaut connaît :
|
||||||
|
|
||||||
|
```text
|
||||||
|
cfg.std.logging -> config/std.logging.json
|
||||||
|
schema.std.logging -> config/schemas/std.logging.schema.json
|
||||||
|
schema.composite -> config/schemas/composite.schema.json
|
||||||
|
```
|
||||||
|
|
||||||
|
`config/examples/composite.example.json` démontre le format composite sans créer de composite runtime fictif.
|
||||||
|
|
||||||
|
Le fichier local d'environnement est :
|
||||||
|
|
||||||
|
```text
|
||||||
|
./.env
|
||||||
|
```
|
||||||
|
|
||||||
|
Il n'est ni versionné ni livré. Le dépôt maintient `/.env.example` comme inventaire versionné des variables runtime utilisées. Toute nouvelle variable KSP/KSPB concrète doit y être ajoutée avec un commentaire d'usage dans le même delta que sa première utilisation.
|
||||||
|
|
||||||
|
## Frontières
|
||||||
|
|
||||||
|
Les autres crates et applications KSP ne doivent pas :
|
||||||
|
|
||||||
|
- lire directement les variables applicatives `KSP_*` / `KSPB_*` ;
|
||||||
|
- parser ou écrire directement `.env` ;
|
||||||
|
- ouvrir directement les documents Config connus par leur filename physique ;
|
||||||
|
- réimplémenter la sélection de profils, les compositions ou les placeholders ;
|
||||||
|
- reconstruire elles-mêmes la configuration Logging depuis le JSON.
|
||||||
|
|
||||||
|
`ksp-config-lib` dépend de `ksp-core-lib` pour `Error`/`Result` et de `ksp-logging-lib` pour les événements Config utiles et le contrat `LoggingSettings`.
|
||||||
|
|
||||||
|
La dépendance inverse est interdite : `ksp-core-lib` et `ksp-logging-lib` ne dépendent pas de Config.
|
||||||
|
|
||||||
|
Config ne possède pas le `LoggingGuard`. L'application ou le service qui orchestre le runtime construit la configuration effective puis possède le lifecycle `ksp_logging_lib::initialize/reinitialize`.
|
||||||
|
|
||||||
|
Tauri et les DTO TS-RS restent hors de cette crate. La future `ksp-app-config-desk` doit rester une frontière applicative mince au-dessus des APIs Config.
|
||||||
|
|
||||||
|
## Secrets
|
||||||
|
|
||||||
|
Un secret reste accessible au runtime ou au management lorsqu'un consumer autorisé en a réellement besoin, mais les vues ordinaires utilisent la représentation sûre.
|
||||||
|
|
||||||
|
Les méthodes `reveal_*` constituent un opt-in explicite au réel. L'authentification/autorisation de l'utilisateur humain appartient à l'application appelante et les valeurs retournées par ces méthodes ne doivent jamais être journalisées.
|
||||||
|
|
||||||
|
Le document Logging refuse les valeurs de sensibilité `Secret` dans sa configuration effective.
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
- [`USAGE.md`](USAGE.md) — construction du moteur, résolution runtime et management ;
|
||||||
|
- [`TODO.md`](TODO.md) — points explicitement différés après `0.1.3` ;
|
||||||
|
- [`../../docs/plans/005-V0_1_3_CONFIG_FOUNDATION_PLAN.md`](../../docs/plans/005-V0_1_3_CONFIG_FOUNDATION_PLAN.md) — plan historique détaillé de la fondation Config ;
|
||||||
|
- [`../../config/std.logging.json`](../../config/std.logging.json) — premier document standard concret ;
|
||||||
|
- [`../../.env.example`](../../.env.example) — inventaire versionné des variables d'environnement runtime.
|
||||||
38
crates/ksp-config-lib/TODO.md
Normal file
38
crates/ksp-config-lib/TODO.md
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
<!-- file: crates/ksp-config-lib/TODO.md -->
|
||||||
|
<!-- version: 1 -->
|
||||||
|
|
||||||
|
# TODO ksp-config-lib
|
||||||
|
|
||||||
|
## État de clôture `0.1.3`
|
||||||
|
|
||||||
|
Aucun TODO fonctionnel bloquant n'est ouvert pour la fondation Config `0.1.3`.
|
||||||
|
|
||||||
|
Les responsabilités prévues pour cette release sont implémentées et couvertes par les tests : bootstrap, registre `file_id`, JSON/JSON Schema, profils, composites, environnement process/`.env`, placeholders, sensibilité/provenance, adapter Logging, management/persistence et audits d'ownership.
|
||||||
|
|
||||||
|
## Reporté explicitement à `0.1.4`
|
||||||
|
|
||||||
|
La validation applicative desktop appartient à `ksp-app-config-desk` :
|
||||||
|
|
||||||
|
- frontière Tauri et DTO TS-RS applicatifs ;
|
||||||
|
- affichage des sources et diagnostics Config ;
|
||||||
|
- sélection/inspection des profils ;
|
||||||
|
- affichage desired/effective/shadow des variables ;
|
||||||
|
- actions explicites de reveal de secrets avec contrôle d'autorisation côté application ;
|
||||||
|
- édition/sauvegarde de `std.logging.json` via `ConfigManagement` ;
|
||||||
|
- édition de `.env` via `ConfigManagement` ;
|
||||||
|
- orchestration réelle `Config -> LoggingSettings -> initialize/reinitialize` avec `LoggingGuard` possédé par l'application ;
|
||||||
|
- validation UX des erreurs de source invalide, des modifications non effectives car masquées par le process et des besoins de reload.
|
||||||
|
|
||||||
|
Ces points ne nécessitent pas de duplication de logique dans `ksp-config-lib`; toute lacune réelle révélée par l'application ouvrira un delta Config explicite.
|
||||||
|
|
||||||
|
## Futur, uniquement au besoin
|
||||||
|
|
||||||
|
Les capacités suivantes sont différées jusqu'à l'apparition de composants réels :
|
||||||
|
|
||||||
|
- nouveaux documents `std.<domain>.json` et schemas associés ;
|
||||||
|
- descriptors `cfg.composite.<consumer>` pour de vrais consumers ;
|
||||||
|
- contrats typés de management supplémentaires pour les nouveaux documents ;
|
||||||
|
- watcher filesystem/reload automatique si une application ou un service démontre le besoin ;
|
||||||
|
- intégration éventuelle d'un secrets manager externe.
|
||||||
|
|
||||||
|
Ne pas introduire par anticipation un JSON patch arbitraire, un watcher générique, un service distribué de configuration ou un chiffrement maison de `.env`.
|
||||||
287
crates/ksp-config-lib/USAGE.md
Normal file
287
crates/ksp-config-lib/USAGE.md
Normal file
@@ -0,0 +1,287 @@
|
|||||||
|
<!-- file: crates/ksp-config-lib/USAGE.md -->
|
||||||
|
<!-- version: 2 -->
|
||||||
|
|
||||||
|
# Utilisation de ksp-config-lib
|
||||||
|
|
||||||
|
## 1. Bootstrap et moteur documentaire
|
||||||
|
|
||||||
|
Config doit interpréter ses propres arguments de bootstrap avant toute lecture de document :
|
||||||
|
|
||||||
|
```rust
|
||||||
|
let args: std::vec::Vec<std::ffi::OsString> = std::env::args_os().collect();
|
||||||
|
|
||||||
|
let bootstrap = match ksp_config_lib::ConfigBootstrapOptions::from_args(args.as_slice()) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
|
||||||
|
let registry = match ksp_config_lib::ConfigFileRegistry::from_args(args.as_slice()) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
|
||||||
|
let engine = ksp_config_lib::ConfigDocumentEngine::new(bootstrap, registry);
|
||||||
|
```
|
||||||
|
|
||||||
|
Les arguments compris par Config sont :
|
||||||
|
|
||||||
|
```text
|
||||||
|
--cfgpath=/path/to/config
|
||||||
|
--schemapath=/path/to/schemas
|
||||||
|
--filemap=cfg.std.logging=my-logging.json
|
||||||
|
```
|
||||||
|
|
||||||
|
`cfgpath` et `schemapath` ne sont jamais lus depuis JSON, `.env` ou une variable KSP : cette règle évite un bootstrap récursif.
|
||||||
|
|
||||||
|
## 2. Charger et valider un document connu
|
||||||
|
|
||||||
|
Les consumers utilisent un `file_id` logique :
|
||||||
|
|
||||||
|
```rust
|
||||||
|
let file_id = match ksp_config_lib::ConfigFileId::new(ksp_config_lib::FILE_ID_STD_LOGGING) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
|
||||||
|
let document = engine.load_validated_document(&file_id);
|
||||||
|
```
|
||||||
|
|
||||||
|
Le moteur résout le path physique via le registre, charge le schema associé, valide le schema lui-même, valide l'instance puis applique les invariants sémantiques KSP.
|
||||||
|
|
||||||
|
## 3. Environnement effectif
|
||||||
|
|
||||||
|
`ConfigEnvironment::load()` capture les variables process KSP/KSPB et lit `./.env` :
|
||||||
|
|
||||||
|
```rust
|
||||||
|
let environment = match ksp_config_lib::ConfigEnvironment::load() {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
La priorité est :
|
||||||
|
|
||||||
|
```text
|
||||||
|
process environment > .env > fallback > missing
|
||||||
|
```
|
||||||
|
|
||||||
|
Une chaîne vide explicitement présente est une valeur définie ; elle ne provoque pas l'utilisation du fallback.
|
||||||
|
|
||||||
|
Exemples de placeholders :
|
||||||
|
|
||||||
|
```text
|
||||||
|
${KSP_LOGS_DIRECTORY}
|
||||||
|
${KSP_LOGS_DIRECTORY:-logs}
|
||||||
|
```
|
||||||
|
|
||||||
|
Pour conserver la sensibilité et la provenance, préférer les variantes détaillées :
|
||||||
|
|
||||||
|
```rust
|
||||||
|
let resolved = environment.resolve_text_detailed("${KSP_SECRET_EXAMPLE}");
|
||||||
|
```
|
||||||
|
|
||||||
|
`ResolvedConfigText` / `ResolvedConfigJson` séparent valeur réelle et valeur sûre. Une représentation `Debug` ne doit pas révéler le réel d'un secret.
|
||||||
|
|
||||||
|
## 4. Construire Logging depuis Config
|
||||||
|
|
||||||
|
Le chemin normal consiste à charger le profil Logging, résoudre l'environnement puis construire directement le contrat Logging public :
|
||||||
|
|
||||||
|
```rust
|
||||||
|
let resolved = match engine.load_resolved_logging_config(std::option::Option::None, &environment) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
|
||||||
|
let settings = resolved.into_settings();
|
||||||
|
let initialized = ksp_logging_lib::initialize(&settings);
|
||||||
|
let mut logging_guard = match initialized {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
L'application/service possède `logging_guard`. Config ne conserve pas de singleton Logging.
|
||||||
|
|
||||||
|
Un `logs_directory` relatif est ancré sur le current working directory du processus. Un path absolu est conservé. Une valeur explicite invalide produit une erreur effective : elle ne retombe pas silencieusement sur le fallback du placeholder.
|
||||||
|
|
||||||
|
Les `files[].path` restent relatifs sous le root Logging, y compris après interpolation.
|
||||||
|
|
||||||
|
## 5. Profils et composites
|
||||||
|
|
||||||
|
Pour un document standard profilé :
|
||||||
|
|
||||||
|
```rust
|
||||||
|
let profile = engine.load_resolved_profile(&file_id, std::option::Option::None);
|
||||||
|
```
|
||||||
|
|
||||||
|
`None` utilise le `default_profile`; `Some("profile_id")` impose un profil explicite.
|
||||||
|
|
||||||
|
Un composite référence les documents par `file_id`, jamais par filename. `load_resolved_composite(...)` conserve chaque `ResolvedConfigProfile` composant et sa provenance plutôt que d'aplatir plusieurs domaines dans une map ambiguë.
|
||||||
|
|
||||||
|
## 6. Management de `std.logging.json`
|
||||||
|
|
||||||
|
Une application de management construit la façade à partir d'un moteur :
|
||||||
|
|
||||||
|
```rust
|
||||||
|
let management = ksp_config_lib::ConfigManagement::new(engine);
|
||||||
|
|
||||||
|
let document = match management.load_logging_document() {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
Le type `LoggingConfigDocument` et ses sous-structures exposent des setters/mutators typés. Après modification, la sauvegarde :
|
||||||
|
|
||||||
|
```rust
|
||||||
|
let saved = management.save_logging_document(&document);
|
||||||
|
```
|
||||||
|
|
||||||
|
valide le candidat complet avant toute substitution du fichier. Un candidat invalide ne remplace pas la source existante.
|
||||||
|
|
||||||
|
`read_source(file_id)` reste disponible pour une UI de réparation : il peut lire le texte brut d'un document enregistré même lorsque son JSON ou son schema est invalide. Il n'ouvre pas un path arbitraire.
|
||||||
|
|
||||||
|
## 7. Management de `.env`
|
||||||
|
|
||||||
|
Les rapports ordinaires sont sûrs :
|
||||||
|
|
||||||
|
```rust
|
||||||
|
let report = management.environment_report();
|
||||||
|
```
|
||||||
|
|
||||||
|
Ils distinguent notamment valeur souhaitée `.env`, valeur effective, source et shadowing process sans exposer un secret réel.
|
||||||
|
|
||||||
|
L'accès au réel est volontairement explicite :
|
||||||
|
|
||||||
|
```rust
|
||||||
|
let effective = management.reveal_effective_environment_value("KSP_SECRET_EXAMPLE");
|
||||||
|
let persisted = management.reveal_dotenv_value("KSP_SECRET_EXAMPLE");
|
||||||
|
```
|
||||||
|
|
||||||
|
Une application doit contrôler l'autorisation de l'utilisateur avant ces appels et ne jamais journaliser les valeurs retournées.
|
||||||
|
|
||||||
|
Les mutations persistantes utilisent :
|
||||||
|
|
||||||
|
```rust
|
||||||
|
let changed = management.set_dotenv_value("KSP_LOGS_DIRECTORY", "logs");
|
||||||
|
let removed = management.remove_dotenv_value("KSP_LOGS_DIRECTORY");
|
||||||
|
```
|
||||||
|
|
||||||
|
Elles n'altèrent jamais l'environnement hérité du processus. Une valeur process peut donc masquer une modification `.env`; `ConfigEnvironmentChangeReport` distingue `source_changed`, `effective_changed`, `shadowed_by_process_environment` et `reload_required`.
|
||||||
|
|
||||||
|
Sur Unix, un nouveau `.env` est créé avec des permissions privées `0600`; les permissions existantes sont préservées lors des remplacements atomiques.
|
||||||
|
|
||||||
|
## 8. `.env.example`
|
||||||
|
|
||||||
|
`/.env.example` est l'inventaire versionné. `/.env` reste local et ignoré.
|
||||||
|
|
||||||
|
Toute nouvelle variable runtime concrète `KSP_*` / `KSPB_*` introduite dans le code ou les documents Config doit être ajoutée à `.env.example` avec un commentaire expliquant son usage. Les audits `ksp-config-lib/tests/ownership.rs` font échouer `cargo test` lorsqu'une clé concrète est oubliée.
|
||||||
|
|
||||||
|
## 9. Frontière Tauri
|
||||||
|
|
||||||
|
Une application Tauri doit appeler les APIs ci-dessus via ses commandes/DTO applicatifs. Elle ne lit ni JSON ni `.env` directement et ne résout jamais elle-même les placeholders.
|
||||||
|
|
||||||
|
Les valeurs `Secret` ne doivent pas être incluses par défaut dans les DTO publics. Une action UI explicitement autorisée peut appeler une méthode `reveal_*` et transporter le résultat par un DTO spécifique, sans log ni diagnostic contenant la valeur réelle.
|
||||||
|
|
||||||
|
## 10. Index de la surface publique
|
||||||
|
|
||||||
|
Ce guide reste volontairement indépendant des numéros de release. Les contrats publics sont regroupés ci-dessous par usage ; les constantes de noms/erreurs accompagnent les mêmes familles et ne constituent pas des workflows séparés.
|
||||||
|
|
||||||
|
| Famille publique | Contrats principaux | Exemple |
|
||||||
|
|------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------|--------------------------|
|
||||||
|
| Bootstrap | `ConfigBootstrapOptions`, `ARG_CFG_PATH`, `ARG_SCHEMA_PATH`, `DEFAULT_CFG_PATH`, `DEFAULT_SCHEMA_PATH` | §1 |
|
||||||
|
| Registre logique | `ConfigFileRegistry`, `ConfigFileId`, `ConfigFileDescriptor`, `ConfigFileKind`, `ARG_FILE_MAP`, constantes `FILE_ID_*` / `DEFAULT_*_FILENAME` | §1–2 |
|
||||||
|
| Documents | `ConfigDocumentEngine`, `ConfigJsonDocument` | §2 |
|
||||||
|
| Profils | `ResolvedConfigProfile`, `ConfigProfileSelectionSource`, `ConfigValueOrigin` | §5 |
|
||||||
|
| Composites | `ResolvedConfigComposite`, `ResolvedCompositeComponent` | §5 |
|
||||||
|
| Environnement | `ConfigEnvironment`, `ConfigEnvironmentSource`, `ConfigEnvironmentValue`, `DEFAULT_DOTENV_PATH`, `DEFAULT_DOTENV_EXAMPLE_PATH` | §3, §7–8 |
|
||||||
|
| Sensibilité/provenance | `ConfigSensitivity`, `ConfigValueProvenance`, `ResolvedConfigText`, `ResolvedConfigJson`, `REDACTED_CONFIG_VALUE` | §3 |
|
||||||
|
| Logging effectif | `ResolvedLoggingConfig` | §4 |
|
||||||
|
| Management | `ConfigManagement`, `ConfigManagedSource`, `ConfigDocumentChangeReport`, `ConfigEnvironmentReport`, `ConfigEnvironmentChangeReport` | §6–7 |
|
||||||
|
| Source Logging typée | `LoggingConfigDocument`, `LoggingProfileConfig`, `LoggingConsoleConfig`, `LoggingFileConfig`, `LoggingOutputFilterConfig`, `LoggingTargetFilterConfig` | §6 et exemple ci-dessous |
|
||||||
|
| Erreurs Config | constantes `ERROR_CODE_*` réexportées par la crate | exemple ci-dessous |
|
||||||
|
|
||||||
|
### 10.1 Modifier une configuration Logging typée
|
||||||
|
|
||||||
|
Les getters permettent d'inspecter la source ; les setters et vues `*_mut()` permettent de construire un candidat avant validation/persistence :
|
||||||
|
|
||||||
|
```rust
|
||||||
|
let mut document = match management.load_logging_document() {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
|
||||||
|
document.set_logs_directory("${KSP_LOGS_DIRECTORY:-logs}");
|
||||||
|
|
||||||
|
if let std::option::Option::Some(profile) = document.profiles_mut().first_mut() {
|
||||||
|
profile.set_default_filter("debug");
|
||||||
|
profile.console_mut().set_enabled(true);
|
||||||
|
profile.console_mut().filter_mut().set_level("info");
|
||||||
|
profile.console_mut().filter_mut().domains_mut().push("config".to_owned());
|
||||||
|
}
|
||||||
|
|
||||||
|
let saved = match management.save_logging_document(&document) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
|
||||||
|
if saved.source_changed() && saved.reload_required() {
|
||||||
|
// The application decides when/how to reload the affected runtime consumer.
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
```rust
|
||||||
|
let source = match management.read_source(&file_id) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
|
||||||
|
let logical_id = source.file_id();
|
||||||
|
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.
|
||||||
|
|
||||||
|
### 10.3 Exploiter les rapports `.env`
|
||||||
|
|
||||||
|
```rust
|
||||||
|
let reports = match management.environment_report() {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
|
||||||
|
for report in reports {
|
||||||
|
let name = report.variable_name();
|
||||||
|
let sensitivity = report.sensitivity();
|
||||||
|
let desired = report.desired_safe_value();
|
||||||
|
let effective = report.effective_safe_value();
|
||||||
|
let source = report.effective_source();
|
||||||
|
let shadowed = report.shadowed_by_process_environment();
|
||||||
|
let _ = (name, sensitivity, desired, effective, source, shadowed);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Après une mutation, `ConfigEnvironmentChangeReport` expose `source_changed()`, `effective_changed()`, `shadowed_by_process_environment()` et `reload_required()`.
|
||||||
|
|
||||||
|
### 10.4 Distinguer un code d'erreur Config
|
||||||
|
|
||||||
|
Les codes publics permettent à une UI/service de brancher sa logique sans parser le texte du message :
|
||||||
|
|
||||||
|
```rust
|
||||||
|
let loaded = engine.load_validated_document(&file_id);
|
||||||
|
|
||||||
|
if let std::result::Result::Err(error) = loaded {
|
||||||
|
if error.code() == ksp_config_lib::ERROR_CODE_SCHEMA_VALIDATION_FAILED {
|
||||||
|
// Present a schema-specific diagnostic path to the caller.
|
||||||
|
}
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Le message/context d'erreur reste destiné au diagnostic ; l'identité machine-readable passe par `ErrorCode`.
|
||||||
|
|
||||||
205
crates/ksp-config-lib/src/bootstrap.rs
Normal file
205
crates/ksp-config-lib/src/bootstrap.rs
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
// file: crates/ksp-config-lib/src/bootstrap.rs
|
||||||
|
// version: 1
|
||||||
|
|
||||||
|
/// Default root containing KSP runtime configuration documents.
|
||||||
|
pub const DEFAULT_CFG_PATH: &str = "config";
|
||||||
|
/// Default root containing KSP JSON schemas.
|
||||||
|
pub const DEFAULT_SCHEMA_PATH: &str = "config/schemas";
|
||||||
|
/// Bootstrap argument used to replace the configuration document root.
|
||||||
|
pub const ARG_CFG_PATH: &str = "--cfgpath";
|
||||||
|
/// Bootstrap argument used to replace the schema root.
|
||||||
|
pub const ARG_SCHEMA_PATH: &str = "--schemapath";
|
||||||
|
|
||||||
|
/// Non-recursive bootstrap options required before Config can resolve any managed document.
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub struct ConfigBootstrapOptions {
|
||||||
|
cfg_path: std::path::PathBuf,
|
||||||
|
schema_path: std::path::PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ConfigBootstrapOptions {
|
||||||
|
/// Creates bootstrap options using the KSP hardcoded configuration and schema roots.
|
||||||
|
pub fn defaults() -> ksp_core_lib::Result<Self> {
|
||||||
|
return Self::from_paths(crate::DEFAULT_CFG_PATH, crate::DEFAULT_SCHEMA_PATH);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates bootstrap options from explicit programmatic configuration and schema roots.
|
||||||
|
pub fn from_paths(
|
||||||
|
cfg_path: impl std::convert::Into<std::path::PathBuf>,
|
||||||
|
schema_path: impl std::convert::Into<std::path::PathBuf>,
|
||||||
|
) -> ksp_core_lib::Result<Self> {
|
||||||
|
let cfg_path = validate_bootstrap_path(crate::ARG_CFG_PATH, cfg_path.into());
|
||||||
|
let cfg_path = match cfg_path {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let schema_path = validate_bootstrap_path(crate::ARG_SCHEMA_PATH, schema_path.into());
|
||||||
|
return match schema_path {
|
||||||
|
std::result::Result::Ok(value) => std::result::Result::Ok(Self { cfg_path, schema_path: value }),
|
||||||
|
std::result::Result::Err(error) => std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parses the KSP-owned bootstrap path arguments from a raw process argument slice.
|
||||||
|
///
|
||||||
|
/// Both `--cfgpath=value` / `--cfgpath value` and `--schemapath=value` / `--schemapath value` are accepted. Unrelated arguments are ignored so an
|
||||||
|
/// application can pass its complete argument vector. When the same bootstrap path is specified more than once, the last explicit value wins.
|
||||||
|
pub fn from_args(args: &[std::ffi::OsString]) -> ksp_core_lib::Result<Self> {
|
||||||
|
let mut options = Self::defaults_unchecked();
|
||||||
|
let mut index: usize = 0;
|
||||||
|
while index < args.len() {
|
||||||
|
let argument = &args[index];
|
||||||
|
if argument.as_os_str() == std::ffi::OsStr::new(crate::ARG_CFG_PATH) {
|
||||||
|
let parsed = parse_separate_path_argument(args, index, crate::ARG_CFG_PATH);
|
||||||
|
match parsed {
|
||||||
|
std::result::Result::Ok((path, next_index)) => {
|
||||||
|
options.cfg_path = path;
|
||||||
|
index = next_index;
|
||||||
|
},
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
}
|
||||||
|
} else if argument.as_os_str() == std::ffi::OsStr::new(crate::ARG_SCHEMA_PATH) {
|
||||||
|
let parsed = parse_separate_path_argument(args, index, crate::ARG_SCHEMA_PATH);
|
||||||
|
match parsed {
|
||||||
|
std::result::Result::Ok((path, next_index)) => {
|
||||||
|
options.schema_path = path;
|
||||||
|
index = next_index;
|
||||||
|
},
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let inline = parse_inline_path_argument(argument);
|
||||||
|
match inline {
|
||||||
|
std::option::Option::Some((kind, path)) => match kind {
|
||||||
|
BootstrapPathKind::Config => options.cfg_path = path,
|
||||||
|
BootstrapPathKind::Schema => options.schema_path = path,
|
||||||
|
},
|
||||||
|
std::option::Option::None => {},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
index += 1;
|
||||||
|
}
|
||||||
|
return Self::from_paths(options.cfg_path, options.schema_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the root used for managed runtime configuration documents.
|
||||||
|
#[must_use]
|
||||||
|
pub fn cfg_path(&self) -> &std::path::Path {
|
||||||
|
return self.cfg_path.as_path();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the root used for managed JSON schemas.
|
||||||
|
#[must_use]
|
||||||
|
pub fn schema_path(&self) -> &std::path::Path {
|
||||||
|
return self.schema_path.as_path();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Replaces the configuration document root after applying bootstrap path validation.
|
||||||
|
pub fn with_cfg_path(self, path: impl std::convert::Into<std::path::PathBuf>) -> ksp_core_lib::Result<Self> {
|
||||||
|
let validated = validate_bootstrap_path(crate::ARG_CFG_PATH, path.into());
|
||||||
|
return match validated {
|
||||||
|
std::result::Result::Ok(cfg_path) => std::result::Result::Ok(Self { cfg_path, schema_path: self.schema_path }),
|
||||||
|
std::result::Result::Err(error) => std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Replaces the schema root after applying bootstrap path validation.
|
||||||
|
pub fn with_schema_path(self, path: impl std::convert::Into<std::path::PathBuf>) -> ksp_core_lib::Result<Self> {
|
||||||
|
let validated = validate_bootstrap_path(crate::ARG_SCHEMA_PATH, path.into());
|
||||||
|
return match validated {
|
||||||
|
std::result::Result::Ok(schema_path) => std::result::Result::Ok(Self { cfg_path: self.cfg_path, schema_path }),
|
||||||
|
std::result::Result::Err(error) => std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn defaults_unchecked() -> Self {
|
||||||
|
return Self {
|
||||||
|
cfg_path: std::path::PathBuf::from(crate::DEFAULT_CFG_PATH),
|
||||||
|
schema_path: std::path::PathBuf::from(crate::DEFAULT_SCHEMA_PATH),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
enum BootstrapPathKind {
|
||||||
|
Config,
|
||||||
|
Schema,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_inline_path_argument(argument: &std::ffi::OsStr) -> std::option::Option<(BootstrapPathKind, std::path::PathBuf)> {
|
||||||
|
let text = match argument.to_str() {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => return std::option::Option::None,
|
||||||
|
};
|
||||||
|
let cfg_prefix = "--cfgpath=";
|
||||||
|
let schema_prefix = "--schemapath=";
|
||||||
|
if let std::option::Option::Some(value) = text.strip_prefix(cfg_prefix) {
|
||||||
|
return std::option::Option::Some((BootstrapPathKind::Config, std::path::PathBuf::from(value)));
|
||||||
|
}
|
||||||
|
if let std::option::Option::Some(value) = text.strip_prefix(schema_prefix) {
|
||||||
|
return std::option::Option::Some((BootstrapPathKind::Schema, std::path::PathBuf::from(value)));
|
||||||
|
}
|
||||||
|
return std::option::Option::None;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_separate_path_argument(args: &[std::ffi::OsString], index: usize, argument_name: &'static str) -> ksp_core_lib::Result<(std::path::PathBuf, usize)> {
|
||||||
|
let value_index = index + 1;
|
||||||
|
if value_index >= args.len() {
|
||||||
|
return std::result::Result::Err(missing_argument_value_error(argument_name));
|
||||||
|
}
|
||||||
|
let value = &args[value_index];
|
||||||
|
let option_like = match value.to_str() {
|
||||||
|
std::option::Option::Some(text) => text.starts_with("--"),
|
||||||
|
std::option::Option::None => false,
|
||||||
|
};
|
||||||
|
if option_like {
|
||||||
|
return std::result::Result::Err(missing_argument_value_error(argument_name));
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok((std::path::PathBuf::from(value.as_os_str()), value_index));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_bootstrap_path(argument_name: &'static str, path: std::path::PathBuf) -> ksp_core_lib::Result<std::path::PathBuf> {
|
||||||
|
if path.as_os_str().is_empty() {
|
||||||
|
return std::result::Result::Err(invalid_path_error(argument_name, &path, "path is empty"));
|
||||||
|
}
|
||||||
|
let metadata = std::fs::metadata(path.as_path());
|
||||||
|
return match metadata {
|
||||||
|
std::result::Result::Ok(value) => {
|
||||||
|
if value.is_dir() {
|
||||||
|
std::result::Result::Ok(path)
|
||||||
|
} else {
|
||||||
|
std::result::Result::Err(invalid_path_error(argument_name, &path, "existing path is not a directory"))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
std::result::Result::Err(error) => {
|
||||||
|
if error.kind() == std::io::ErrorKind::NotFound {
|
||||||
|
std::result::Result::Ok(path)
|
||||||
|
} else {
|
||||||
|
std::result::Result::Err(invalid_path_source_error(argument_name, &path, error))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn missing_argument_value_error(argument_name: &'static str) -> ksp_core_lib::Error {
|
||||||
|
return ksp_core_lib::Error::new(crate::ERROR_CODE_BOOTSTRAP_ARGUMENT_MISSING_VALUE, "Config bootstrap argument requires a path value")
|
||||||
|
.with_context("argument", argument_name);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn invalid_path_error(argument_name: &'static str, path: &std::path::Path, reason: &'static str) -> ksp_core_lib::Error {
|
||||||
|
return ksp_core_lib::Error::new(crate::ERROR_CODE_BOOTSTRAP_INVALID_PATH, "Config bootstrap path is invalid")
|
||||||
|
.with_context("argument", argument_name)
|
||||||
|
.with_context("path", path.to_string_lossy().into_owned())
|
||||||
|
.with_context("reason", reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn invalid_path_source_error(argument_name: &'static str, path: &std::path::Path, source: std::io::Error) -> ksp_core_lib::Error {
|
||||||
|
return ksp_core_lib::Error::new(crate::ERROR_CODE_BOOTSTRAP_INVALID_PATH, "Config bootstrap path cannot be inspected")
|
||||||
|
.with_context("argument", argument_name)
|
||||||
|
.with_context("path", path.to_string_lossy().into_owned())
|
||||||
|
.with_source(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "../unit_tests/bootstrap.rs"]
|
||||||
|
mod tests;
|
||||||
309
crates/ksp-config-lib/src/composite.rs
Normal file
309
crates/ksp-config-lib/src/composite.rs
Normal file
@@ -0,0 +1,309 @@
|
|||||||
|
// file: crates/ksp-config-lib/src/composite.rs
|
||||||
|
// version: 1
|
||||||
|
|
||||||
|
/// One resolved document component selected by a composite profile.
|
||||||
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
|
pub struct ResolvedCompositeComponent {
|
||||||
|
component_id: String,
|
||||||
|
resolved: crate::ResolvedConfigProfile,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ResolvedCompositeComponent {
|
||||||
|
/// Returns the component identifier unique inside the selected composite profile.
|
||||||
|
#[must_use]
|
||||||
|
pub fn component_id(&self) -> &str {
|
||||||
|
return self.component_id.as_str();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the referenced standard document after global/profile resolution.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn resolved(&self) -> &crate::ResolvedConfigProfile {
|
||||||
|
return &self.resolved;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validated composite document resolved to one composite profile and all of its referenced standard documents.
|
||||||
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
|
pub struct ResolvedConfigComposite {
|
||||||
|
file_id: crate::ConfigFileId,
|
||||||
|
path: std::path::PathBuf,
|
||||||
|
profile_id: String,
|
||||||
|
selection_source: crate::ConfigProfileSelectionSource,
|
||||||
|
components: std::collections::BTreeMap<String, ResolvedCompositeComponent>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ResolvedConfigComposite {
|
||||||
|
/// Returns the logical composite file identifier.
|
||||||
|
#[must_use]
|
||||||
|
pub fn file_id(&self) -> &crate::ConfigFileId {
|
||||||
|
return &self.file_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the physical path of the validated composite source.
|
||||||
|
#[must_use]
|
||||||
|
pub fn path(&self) -> &std::path::Path {
|
||||||
|
return self.path.as_path();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the selected composite profile identifier.
|
||||||
|
#[must_use]
|
||||||
|
pub fn profile_id(&self) -> &str {
|
||||||
|
return self.profile_id.as_str();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns whether the composite profile came from `default_profile` or an explicit caller selection.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn selection_source(&self) -> crate::ConfigProfileSelectionSource {
|
||||||
|
return self.selection_source;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns all resolved components keyed by their composite-local `component_id`.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn components(&self) -> &std::collections::BTreeMap<String, ResolvedCompositeComponent> {
|
||||||
|
return &self.components;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns one resolved component by its composite-local identifier.
|
||||||
|
#[must_use]
|
||||||
|
pub fn component(&self, component_id: &str) -> std::option::Option<&ResolvedCompositeComponent> {
|
||||||
|
return self.components.get(component_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl crate::ConfigDocumentEngine {
|
||||||
|
/// Loads, validates and resolves one registered composite Config document.
|
||||||
|
///
|
||||||
|
/// Composite descriptors are introduced only when a concrete consumer exists. The generic contract and schema are available from `0.1.3-pre.009` onward.
|
||||||
|
/// Passing `None` selects the composite's `default_profile`; passing `Some(profile_id)` selects a composite profile explicitly.
|
||||||
|
pub fn load_resolved_composite(
|
||||||
|
&self,
|
||||||
|
file_id: &crate::ConfigFileId,
|
||||||
|
requested_profile: std::option::Option<&str>,
|
||||||
|
) -> ksp_core_lib::Result<ResolvedConfigComposite> {
|
||||||
|
let document = self.load_validated_document(file_id);
|
||||||
|
let document = match document {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
if !document.file_id().as_str().starts_with("cfg.composite.") {
|
||||||
|
return std::result::Result::Err(
|
||||||
|
ksp_core_lib::Error::new(crate::ERROR_CODE_COMPOSITE_REFERENCE_INVALID, "requested Config document is not a composite")
|
||||||
|
.with_context("file_id", document.file_id().as_str()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return resolve_composite_document(self, &document, requested_profile);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
struct CompositeDocumentSource {
|
||||||
|
format_version: u32,
|
||||||
|
default_profile: String,
|
||||||
|
profiles: std::vec::Vec<CompositeProfileSource>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
struct CompositeProfileSource {
|
||||||
|
profile_id: String,
|
||||||
|
documents: std::vec::Vec<CompositeDocumentReferenceSource>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
struct CompositeDocumentReferenceSource {
|
||||||
|
component_id: String,
|
||||||
|
file_id: String,
|
||||||
|
profile_id: std::option::Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn validate_composite_document_contract(engine: &crate::ConfigDocumentEngine, document: &crate::ConfigJsonDocument) -> ksp_core_lib::Result<()> {
|
||||||
|
if !document.file_id().as_str().starts_with("cfg.composite.") {
|
||||||
|
return std::result::Result::Ok(());
|
||||||
|
}
|
||||||
|
let source = parse_composite_source(document);
|
||||||
|
let source = match source {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
if source.format_version != 1 {
|
||||||
|
return std::result::Result::Err(composite_semantic_error(document, "unsupported composite format_version"));
|
||||||
|
}
|
||||||
|
if source.default_profile.trim().is_empty() {
|
||||||
|
return std::result::Result::Err(composite_semantic_error(document, "composite default_profile must not be empty"));
|
||||||
|
}
|
||||||
|
for (profile_index, profile) in source.profiles.iter().enumerate() {
|
||||||
|
if profile.profile_id.trim().is_empty() {
|
||||||
|
return std::result::Result::Err(
|
||||||
|
composite_semantic_error(document, "composite profile_id must not be empty").with_context("profile_index", profile_index.to_string()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let mut component_ids = std::collections::BTreeSet::<String>::new();
|
||||||
|
for (component_index, reference) in profile.documents.iter().enumerate() {
|
||||||
|
if component_ids.contains(reference.component_id.as_str()) {
|
||||||
|
return std::result::Result::Err(
|
||||||
|
composite_semantic_error(document, "component_id values must be unique inside one composite profile")
|
||||||
|
.with_context("profile_id", profile.profile_id.as_str())
|
||||||
|
.with_context("component_index", component_index.to_string())
|
||||||
|
.with_context("component_id", reference.component_id.as_str()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
component_ids.insert(reference.component_id.clone());
|
||||||
|
let validation = validate_reference(engine, document, profile, reference, component_index);
|
||||||
|
if let std::result::Result::Err(error) = validation {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_composite_document(
|
||||||
|
engine: &crate::ConfigDocumentEngine,
|
||||||
|
document: &crate::ConfigJsonDocument,
|
||||||
|
requested_profile: std::option::Option<&str>,
|
||||||
|
) -> ksp_core_lib::Result<ResolvedConfigComposite> {
|
||||||
|
let source = parse_composite_source(document);
|
||||||
|
let source = match source {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let (selected_profile_id, selection_source) = match requested_profile {
|
||||||
|
std::option::Option::Some(value) => (value, crate::ConfigProfileSelectionSource::Explicit),
|
||||||
|
std::option::Option::None => (source.default_profile.as_str(), crate::ConfigProfileSelectionSource::DefaultProfile),
|
||||||
|
};
|
||||||
|
let mut selected: std::option::Option<&CompositeProfileSource> = std::option::Option::None;
|
||||||
|
for profile in &source.profiles {
|
||||||
|
if profile.profile_id == selected_profile_id {
|
||||||
|
selected = std::option::Option::Some(profile);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let selected = match selected {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => {
|
||||||
|
return std::result::Result::Err(
|
||||||
|
ksp_core_lib::Error::new(crate::ERROR_CODE_PROFILE_NOT_FOUND, "requested composite Config profile does not exist")
|
||||||
|
.with_context("file_id", document.file_id().as_str())
|
||||||
|
.with_context("path", document.path().to_string_lossy().into_owned())
|
||||||
|
.with_context("profile_id", selected_profile_id),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let mut components = std::collections::BTreeMap::<String, ResolvedCompositeComponent>::new();
|
||||||
|
for reference in &selected.documents {
|
||||||
|
let file_id = crate::ConfigFileId::new(reference.file_id.as_str());
|
||||||
|
let file_id = match file_id {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let resolved = crate::profile::load_resolved_profile_with_source(
|
||||||
|
engine,
|
||||||
|
&file_id,
|
||||||
|
reference.profile_id.as_deref(),
|
||||||
|
crate::ConfigProfileSelectionSource::Composite,
|
||||||
|
);
|
||||||
|
let resolved = match resolved {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let component = ResolvedCompositeComponent { component_id: reference.component_id.clone(), resolved };
|
||||||
|
components.insert(reference.component_id.clone(), component);
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(ResolvedConfigComposite {
|
||||||
|
file_id: document.file_id().clone(),
|
||||||
|
path: document.path().to_path_buf(),
|
||||||
|
profile_id: selected_profile_id.to_owned(),
|
||||||
|
selection_source,
|
||||||
|
components,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_reference(
|
||||||
|
engine: &crate::ConfigDocumentEngine,
|
||||||
|
document: &crate::ConfigJsonDocument,
|
||||||
|
profile: &CompositeProfileSource,
|
||||||
|
reference: &CompositeDocumentReferenceSource,
|
||||||
|
component_index: usize,
|
||||||
|
) -> ksp_core_lib::Result<()> {
|
||||||
|
if reference.component_id.trim().is_empty() {
|
||||||
|
return std::result::Result::Err(composite_reference_error(document, profile, reference, component_index, "component_id must not be empty"));
|
||||||
|
}
|
||||||
|
let file_id = crate::ConfigFileId::new(reference.file_id.as_str());
|
||||||
|
let file_id = match file_id {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => {
|
||||||
|
return std::result::Result::Err(composite_reference_error(document, profile, reference, component_index, "referenced file_id is invalid"));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
if !file_id.as_str().starts_with("cfg.std.") {
|
||||||
|
return std::result::Result::Err(composite_reference_error(
|
||||||
|
document,
|
||||||
|
profile,
|
||||||
|
reference,
|
||||||
|
component_index,
|
||||||
|
"composite references must target standard Config document file_ids",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let descriptor = engine.registry().descriptor(&file_id);
|
||||||
|
let descriptor = match descriptor {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => {
|
||||||
|
return std::result::Result::Err(composite_reference_error(document, profile, reference, component_index, "referenced file_id is not registered"));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
if descriptor.kind() != crate::ConfigFileKind::Config {
|
||||||
|
return std::result::Result::Err(composite_reference_error(
|
||||||
|
document,
|
||||||
|
profile,
|
||||||
|
reference,
|
||||||
|
component_index,
|
||||||
|
"referenced file_id is not a Config document",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let resolved =
|
||||||
|
crate::profile::load_resolved_profile_with_source(engine, &file_id, reference.profile_id.as_deref(), crate::ConfigProfileSelectionSource::Composite);
|
||||||
|
if let std::result::Result::Err(error) = resolved {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_composite_source(document: &crate::ConfigJsonDocument) -> ksp_core_lib::Result<CompositeDocumentSource> {
|
||||||
|
let source = serde_json::from_value::<CompositeDocumentSource>(document.value().clone());
|
||||||
|
return match source {
|
||||||
|
std::result::Result::Ok(value) => std::result::Result::Ok(value),
|
||||||
|
std::result::Result::Err(error) => std::result::Result::Err(
|
||||||
|
composite_semantic_error(document, "schema-valid composite document cannot be decoded into the KSP source contract").with_source(error),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn composite_semantic_error(document: &crate::ConfigJsonDocument, reason: &'static str) -> ksp_core_lib::Error {
|
||||||
|
return ksp_core_lib::Error::new(crate::ERROR_CODE_DOCUMENT_SEMANTIC_INVALID, "Config composite violates KSP semantic invariants")
|
||||||
|
.with_context("file_id", document.file_id().as_str())
|
||||||
|
.with_context("path", document.path().to_string_lossy().into_owned())
|
||||||
|
.with_context("reason", reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn composite_reference_error(
|
||||||
|
document: &crate::ConfigJsonDocument,
|
||||||
|
profile: &CompositeProfileSource,
|
||||||
|
reference: &CompositeDocumentReferenceSource,
|
||||||
|
component_index: usize,
|
||||||
|
reason: &'static str,
|
||||||
|
) -> ksp_core_lib::Error {
|
||||||
|
return ksp_core_lib::Error::new(crate::ERROR_CODE_COMPOSITE_REFERENCE_INVALID, "Config composite contains an invalid document reference")
|
||||||
|
.with_context("file_id", document.file_id().as_str())
|
||||||
|
.with_context("path", document.path().to_string_lossy().into_owned())
|
||||||
|
.with_context("profile_id", profile.profile_id.as_str())
|
||||||
|
.with_context("component_index", component_index.to_string())
|
||||||
|
.with_context("component_id", reference.component_id.as_str())
|
||||||
|
.with_context("referenced_file_id", reference.file_id.as_str())
|
||||||
|
.with_context("reason", reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "../unit_tests/composite.rs"]
|
||||||
|
mod tests;
|
||||||
493
crates/ksp-config-lib/src/document.rs
Normal file
493
crates/ksp-config-lib/src/document.rs
Normal file
@@ -0,0 +1,493 @@
|
|||||||
|
// file: crates/ksp-config-lib/src/document.rs
|
||||||
|
// version: 4
|
||||||
|
|
||||||
|
/// A Config-managed JSON document that has passed syntax, schema and current semantic validation.
|
||||||
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
|
pub struct ConfigJsonDocument {
|
||||||
|
file_id: crate::ConfigFileId,
|
||||||
|
path: std::path::PathBuf,
|
||||||
|
value: serde_json::Value,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ConfigJsonDocument {
|
||||||
|
/// Returns the logical Config file identifier used to load this document.
|
||||||
|
#[must_use]
|
||||||
|
pub fn file_id(&self) -> &crate::ConfigFileId {
|
||||||
|
return &self.file_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the resolved physical path from which this document was loaded.
|
||||||
|
#[must_use]
|
||||||
|
pub fn path(&self) -> &std::path::Path {
|
||||||
|
return self.path.as_path();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the validated JSON value without transferring Config ownership of file I/O or validation.
|
||||||
|
#[must_use]
|
||||||
|
pub fn value(&self) -> &serde_json::Value {
|
||||||
|
return &self.value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generic JSON/JSON Schema engine owned by `ksp-config-lib`.
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub struct ConfigDocumentEngine {
|
||||||
|
bootstrap: crate::ConfigBootstrapOptions,
|
||||||
|
registry: crate::ConfigFileRegistry,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ConfigDocumentEngine {
|
||||||
|
/// Creates a document engine from already validated bootstrap options and a logical file registry.
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(bootstrap: crate::ConfigBootstrapOptions, registry: crate::ConfigFileRegistry) -> Self {
|
||||||
|
return Self { bootstrap, registry };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the bootstrap roots used by this engine.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn bootstrap(&self) -> &crate::ConfigBootstrapOptions {
|
||||||
|
return &self.bootstrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the logical file registry used by this engine.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn registry(&self) -> &crate::ConfigFileRegistry {
|
||||||
|
return &self.registry;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Loads one registered Config document and validates it against its registered JSON Schema and current KSP semantic invariants.
|
||||||
|
pub fn load_validated_document(&self, file_id: &crate::ConfigFileId) -> ksp_core_lib::Result<ConfigJsonDocument> {
|
||||||
|
let descriptor = self.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(
|
||||||
|
ksp_core_lib::Error::new(crate::ERROR_CODE_FILE_MAPPING_INVALID, "requested file_id does not identify a Config document")
|
||||||
|
.with_context("file_id", file_id.as_str()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let schema_file_id = match descriptor.schema_file_id() {
|
||||||
|
std::option::Option::Some(value) => value.clone(),
|
||||||
|
std::option::Option::None => {
|
||||||
|
return std::result::Result::Err(
|
||||||
|
ksp_core_lib::Error::new(crate::ERROR_CODE_FILE_MAPPING_INVALID, "Config document has no registered validation schema")
|
||||||
|
.with_context("file_id", file_id.as_str()),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let document = self.load_json(file_id);
|
||||||
|
let document = match document {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
return self.validate_document(document, &schema_file_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn validate_candidate(&self, file_id: &crate::ConfigFileId, value: serde_json::Value) -> ksp_core_lib::Result<ConfigJsonDocument> {
|
||||||
|
let descriptor = self.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(
|
||||||
|
ksp_core_lib::Error::new(crate::ERROR_CODE_FILE_MAPPING_INVALID, "requested file_id does not identify a Config document")
|
||||||
|
.with_context("file_id", file_id.as_str()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let schema_file_id = match descriptor.schema_file_id() {
|
||||||
|
std::option::Option::Some(value) => value.clone(),
|
||||||
|
std::option::Option::None => {
|
||||||
|
return std::result::Result::Err(
|
||||||
|
ksp_core_lib::Error::new(crate::ERROR_CODE_FILE_MAPPING_INVALID, "Config document has no registered validation schema")
|
||||||
|
.with_context("file_id", file_id.as_str()),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
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 document = ConfigJsonDocument { file_id: file_id.clone(), path, value };
|
||||||
|
return self.validate_document(document, &schema_file_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_document(&self, document: ConfigJsonDocument, schema_file_id: &crate::ConfigFileId) -> ksp_core_lib::Result<ConfigJsonDocument> {
|
||||||
|
let schema = self.load_json(schema_file_id);
|
||||||
|
let schema = match schema {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let schema_validation = validate_schema_document(&schema);
|
||||||
|
match schema_validation {
|
||||||
|
std::result::Result::Ok(()) => {},
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
}
|
||||||
|
let instance_validation = validate_instance(&document, &schema);
|
||||||
|
match instance_validation {
|
||||||
|
std::result::Result::Ok(()) => {},
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
}
|
||||||
|
let semantic_validation = validate_document_semantics(self, &document);
|
||||||
|
return match semantic_validation {
|
||||||
|
std::result::Result::Ok(()) => std::result::Result::Ok(document),
|
||||||
|
std::result::Result::Err(error) => std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_json(&self, file_id: &crate::ConfigFileId) -> ksp_core_lib::Result<ConfigJsonDocument> {
|
||||||
|
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 content = std::fs::read_to_string(path.as_path());
|
||||||
|
let content = match content {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(json_read_error(file_id, &path, error)),
|
||||||
|
};
|
||||||
|
let value = serde_json::from_str::<serde_json::Value>(content.as_str());
|
||||||
|
return match value {
|
||||||
|
std::result::Result::Ok(value) => std::result::Result::Ok(ConfigJsonDocument { file_id: file_id.clone(), path, value }),
|
||||||
|
std::result::Result::Err(error) => std::result::Result::Err(json_syntax_error(file_id, &path, error)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
struct LoggingDocumentSource {
|
||||||
|
format_version: u32,
|
||||||
|
logs_directory: String,
|
||||||
|
default_profile: String,
|
||||||
|
profiles: std::vec::Vec<LoggingProfileSource>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
struct LoggingProfileSource {
|
||||||
|
profile_id: String,
|
||||||
|
default_filter: String,
|
||||||
|
span_events: String,
|
||||||
|
console: LoggingConsoleSource,
|
||||||
|
files: std::vec::Vec<LoggingFileSource>,
|
||||||
|
target_filters: std::vec::Vec<LoggingTargetFilterSource>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
struct LoggingConsoleSource {
|
||||||
|
enabled: bool,
|
||||||
|
output: String,
|
||||||
|
ansi: bool,
|
||||||
|
format: String,
|
||||||
|
filter: LoggingOutputFilterSource,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
struct LoggingFileSource {
|
||||||
|
output_id: String,
|
||||||
|
enabled: bool,
|
||||||
|
path: String,
|
||||||
|
rotation: String,
|
||||||
|
format: String,
|
||||||
|
ansi: bool,
|
||||||
|
filter: LoggingOutputFilterSource,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
struct LoggingOutputFilterSource {
|
||||||
|
level: String,
|
||||||
|
targets: std::vec::Vec<String>,
|
||||||
|
domains: std::vec::Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
struct LoggingTargetFilterSource {
|
||||||
|
target_prefix: String,
|
||||||
|
level: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_schema_document(schema: &ConfigJsonDocument) -> ksp_core_lib::Result<()> {
|
||||||
|
let validation = jsonschema::meta::validate(schema.value());
|
||||||
|
return match validation {
|
||||||
|
std::result::Result::Ok(()) => std::result::Result::Ok(()),
|
||||||
|
std::result::Result::Err(error) => std::result::Result::Err(
|
||||||
|
ksp_core_lib::Error::new(crate::ERROR_CODE_SCHEMA_INVALID, "Config JSON Schema document is invalid")
|
||||||
|
.with_context("file_id", schema.file_id().as_str())
|
||||||
|
.with_context("path", schema.path().to_string_lossy().into_owned())
|
||||||
|
.with_context("detail", error.to_string()),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_instance(document: &ConfigJsonDocument, schema: &ConfigJsonDocument) -> ksp_core_lib::Result<()> {
|
||||||
|
let validation = jsonschema::draft202012::validate(schema.value(), document.value());
|
||||||
|
return match validation {
|
||||||
|
std::result::Result::Ok(()) => std::result::Result::Ok(()),
|
||||||
|
std::result::Result::Err(error) => std::result::Result::Err(
|
||||||
|
ksp_core_lib::Error::new(crate::ERROR_CODE_SCHEMA_VALIDATION_FAILED, "Config document does not satisfy its registered JSON Schema")
|
||||||
|
.with_context("file_id", document.file_id().as_str())
|
||||||
|
.with_context("schema_file_id", schema.file_id().as_str())
|
||||||
|
.with_context("path", document.path().to_string_lossy().into_owned())
|
||||||
|
.with_context("detail", error.to_string()),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_document_semantics(engine: &ConfigDocumentEngine, document: &ConfigJsonDocument) -> ksp_core_lib::Result<()> {
|
||||||
|
let profile_validation = crate::profile::validate_document_profile_contract(document);
|
||||||
|
if let std::result::Result::Err(error) = profile_validation {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
let composite_validation = crate::composite::validate_composite_document_contract(engine, document);
|
||||||
|
if let std::result::Result::Err(error) = composite_validation {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
if document.file_id().as_str() != crate::FILE_ID_STD_LOGGING {
|
||||||
|
return std::result::Result::Ok(());
|
||||||
|
}
|
||||||
|
let parsed = serde_json::from_value::<LoggingDocumentSource>(document.value().clone());
|
||||||
|
let parsed = match parsed {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => {
|
||||||
|
return std::result::Result::Err(
|
||||||
|
semantic_error(document, "schema-valid Logging document cannot be decoded into the KSP source contract").with_source(error),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return validate_logging_document(document, &parsed);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_logging_document(document: &ConfigJsonDocument, source: &LoggingDocumentSource) -> ksp_core_lib::Result<()> {
|
||||||
|
if source.format_version != 1 {
|
||||||
|
return std::result::Result::Err(semantic_error(document, "unsupported Logging document format_version"));
|
||||||
|
}
|
||||||
|
if source.logs_directory.trim().is_empty() {
|
||||||
|
return std::result::Result::Err(semantic_error(document, "logs_directory must not be empty"));
|
||||||
|
}
|
||||||
|
if source.default_profile.trim().is_empty() {
|
||||||
|
return std::result::Result::Err(semantic_error(document, "default_profile must not be empty"));
|
||||||
|
}
|
||||||
|
for (profile_index, profile) in source.profiles.iter().enumerate() {
|
||||||
|
let validation = validate_logging_profile(document, profile, profile_index);
|
||||||
|
if let std::result::Result::Err(error) = validation {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_logging_profile(document: &ConfigJsonDocument, profile: &LoggingProfileSource, profile_index: usize) -> ksp_core_lib::Result<()> {
|
||||||
|
if profile.profile_id.trim().is_empty() || profile.default_filter.trim().is_empty() || profile.span_events.trim().is_empty() {
|
||||||
|
return std::result::Result::Err(
|
||||||
|
semantic_error(document, "Logging profile identity and base settings must not be empty").with_context("profile_index", profile_index.to_string()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let console_validation = validate_logging_console(document, &profile.console, profile_index);
|
||||||
|
if let std::result::Result::Err(error) = console_validation {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
for (file_index, file) in profile.files.iter().enumerate() {
|
||||||
|
let file_validation = validate_logging_file(document, file, profile_index, file_index);
|
||||||
|
if let std::result::Result::Err(error) = file_validation {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
for previous in &profile.files[..file_index] {
|
||||||
|
if previous.output_id == file.output_id {
|
||||||
|
return std::result::Result::Err(
|
||||||
|
semantic_error(document, "Logging file output identifiers must be unique within a profile")
|
||||||
|
.with_context("profile_index", profile_index.to_string())
|
||||||
|
.with_context("output_id", file.output_id.as_str()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (target_index, target_filter) in profile.target_filters.iter().enumerate() {
|
||||||
|
if target_filter.target_prefix.trim().is_empty() || !target_filter.target_prefix.starts_with("ksp-") || target_filter.level.trim().is_empty() {
|
||||||
|
return std::result::Result::Err(
|
||||||
|
semantic_error(document, "Logging global target filter must identify a KSP-owned target")
|
||||||
|
.with_context("profile_index", profile_index.to_string())
|
||||||
|
.with_context("target_filter_index", target_index.to_string()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_logging_console(document: &ConfigJsonDocument, console: &LoggingConsoleSource, profile_index: usize) -> ksp_core_lib::Result<()> {
|
||||||
|
let _enabled = console.enabled;
|
||||||
|
if console.output.trim().is_empty() || console.format.trim().is_empty() {
|
||||||
|
return std::result::Result::Err(
|
||||||
|
semantic_error(document, "Logging console output and format must not be empty").with_context("profile_index", profile_index.to_string()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if console.ansi && console.format == "json" {
|
||||||
|
return std::result::Result::Err(
|
||||||
|
semantic_error(document, "ANSI formatting is not compatible with JSON console output").with_context("profile_index", profile_index.to_string()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return validate_logging_output_filter(document, &console.filter, profile_index, "console");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_logging_file(document: &ConfigJsonDocument, file: &LoggingFileSource, profile_index: usize, file_index: usize) -> ksp_core_lib::Result<()> {
|
||||||
|
let _enabled = file.enabled;
|
||||||
|
if !valid_output_id(file.output_id.as_str()) {
|
||||||
|
return std::result::Result::Err(
|
||||||
|
semantic_error(document, "Logging file output_id is invalid")
|
||||||
|
.with_context("profile_index", profile_index.to_string())
|
||||||
|
.with_context("file_index", file_index.to_string())
|
||||||
|
.with_context("output_id", file.output_id.as_str()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if file.path.trim().is_empty() || !relative_log_path_is_valid(file.path.as_str()) {
|
||||||
|
return std::result::Result::Err(
|
||||||
|
semantic_error(document, "Logging file path must stay relative to logs_directory without traversal")
|
||||||
|
.with_context("profile_index", profile_index.to_string())
|
||||||
|
.with_context("file_index", file_index.to_string()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if file.rotation.trim().is_empty() || file.format.trim().is_empty() {
|
||||||
|
return std::result::Result::Err(
|
||||||
|
semantic_error(document, "Logging file rotation and format must not be empty").with_context("profile_index", profile_index.to_string()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if file.ansi {
|
||||||
|
return std::result::Result::Err(
|
||||||
|
semantic_error(document, "ANSI sequences are not allowed in persistent Logging outputs").with_context("profile_index", profile_index.to_string()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return validate_logging_output_filter(document, &file.filter, profile_index, file.output_id.as_str());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_logging_output_filter(
|
||||||
|
document: &ConfigJsonDocument,
|
||||||
|
filter: &LoggingOutputFilterSource,
|
||||||
|
profile_index: usize,
|
||||||
|
output: &str,
|
||||||
|
) -> ksp_core_lib::Result<()> {
|
||||||
|
if filter.level.trim().is_empty() {
|
||||||
|
return std::result::Result::Err(
|
||||||
|
semantic_error(document, "Logging output filter level must not be empty").with_context("profile_index", profile_index.to_string()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let targets = validate_selectors(document, &filter.targets, true, profile_index, output, "targets");
|
||||||
|
if let std::result::Result::Err(error) = targets {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
return validate_selectors(document, &filter.domains, false, profile_index, output, "domains");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_selectors(
|
||||||
|
document: &ConfigJsonDocument,
|
||||||
|
selectors: &[String],
|
||||||
|
target_dimension: bool,
|
||||||
|
profile_index: usize,
|
||||||
|
output: &str,
|
||||||
|
dimension: &'static str,
|
||||||
|
) -> ksp_core_lib::Result<()> {
|
||||||
|
if selectors.is_empty() {
|
||||||
|
return std::result::Result::Err(selector_error(document, profile_index, output, dimension, "selector list must not be empty"));
|
||||||
|
}
|
||||||
|
if selectors.len() > 1
|
||||||
|
&& selectors.iter().any(|selector| -> bool {
|
||||||
|
return selector == "*";
|
||||||
|
})
|
||||||
|
{
|
||||||
|
return std::result::Result::Err(selector_error(document, profile_index, output, dimension, "wildcard selector must be used alone"));
|
||||||
|
}
|
||||||
|
for (index, selector) in selectors.iter().enumerate() {
|
||||||
|
if selector.trim().is_empty() {
|
||||||
|
return std::result::Result::Err(
|
||||||
|
selector_error(document, profile_index, output, dimension, "selector must not be empty").with_context("selector_index", index.to_string()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if target_dimension && selector != "*" && !selector.starts_with("ksp-") {
|
||||||
|
return std::result::Result::Err(
|
||||||
|
selector_error(document, profile_index, output, dimension, "target selector must identify a KSP-owned target")
|
||||||
|
.with_context("selector_index", index.to_string()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for previous in &selectors[..index] {
|
||||||
|
if previous == selector {
|
||||||
|
return std::result::Result::Err(
|
||||||
|
selector_error(document, profile_index, output, dimension, "selectors must be unique").with_context("selector_index", index.to_string()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn valid_output_id(output_id: &str) -> bool {
|
||||||
|
let mut previous_was_separator = true;
|
||||||
|
if output_id.is_empty() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
for byte in output_id.bytes() {
|
||||||
|
if byte == b'.' {
|
||||||
|
if previous_was_separator {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
previous_was_separator = true;
|
||||||
|
} else if byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_' || byte == b'-' {
|
||||||
|
previous_was_separator = false;
|
||||||
|
} else {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return !previous_was_separator;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn relative_log_path_is_valid(value: &str) -> bool {
|
||||||
|
let path = std::path::Path::new(value);
|
||||||
|
if path.is_absolute() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let mut has_normal_component = false;
|
||||||
|
for component in path.components() {
|
||||||
|
match component {
|
||||||
|
std::path::Component::Normal(_) => has_normal_component = true,
|
||||||
|
std::path::Component::CurDir | std::path::Component::ParentDir | std::path::Component::RootDir | std::path::Component::Prefix(_) => return false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return has_normal_component;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn selector_error(document: &ConfigJsonDocument, profile_index: usize, output: &str, dimension: &'static str, reason: &'static str) -> ksp_core_lib::Error {
|
||||||
|
return semantic_error(document, reason)
|
||||||
|
.with_context("profile_index", profile_index.to_string())
|
||||||
|
.with_context("output", output)
|
||||||
|
.with_context("dimension", dimension);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn semantic_error(document: &ConfigJsonDocument, reason: &'static str) -> ksp_core_lib::Error {
|
||||||
|
return ksp_core_lib::Error::new(crate::ERROR_CODE_DOCUMENT_SEMANTIC_INVALID, "Config document violates KSP semantic invariants")
|
||||||
|
.with_context("file_id", document.file_id().as_str())
|
||||||
|
.with_context("path", document.path().to_string_lossy().into_owned())
|
||||||
|
.with_context("reason", reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn json_read_error(file_id: &crate::ConfigFileId, path: &std::path::Path, source: std::io::Error) -> ksp_core_lib::Error {
|
||||||
|
return ksp_core_lib::Error::new(crate::ERROR_CODE_JSON_FILE_READ_FAILED, "Config-managed JSON file cannot be read")
|
||||||
|
.with_context("file_id", file_id.as_str())
|
||||||
|
.with_context("path", path.to_string_lossy().into_owned())
|
||||||
|
.with_source(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn json_syntax_error(file_id: &crate::ConfigFileId, path: &std::path::Path, source: serde_json::Error) -> ksp_core_lib::Error {
|
||||||
|
return ksp_core_lib::Error::new(crate::ERROR_CODE_JSON_SYNTAX_INVALID, "Config-managed file contains invalid JSON syntax")
|
||||||
|
.with_context("file_id", file_id.as_str())
|
||||||
|
.with_context("path", path.to_string_lossy().into_owned())
|
||||||
|
.with_source(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "../unit_tests/document.rs"]
|
||||||
|
mod tests;
|
||||||
605
crates/ksp-config-lib/src/environment.rs
Normal file
605
crates/ksp-config-lib/src/environment.rs
Normal file
@@ -0,0 +1,605 @@
|
|||||||
|
// file: crates/ksp-config-lib/src/environment.rs
|
||||||
|
// version: 4
|
||||||
|
|
||||||
|
/// Default local environment file read by Config from the process launch directory.
|
||||||
|
pub const DEFAULT_DOTENV_PATH: &str = ".env";
|
||||||
|
|
||||||
|
/// Versioned environment contract template expected at the repository/runtime root.
|
||||||
|
pub const DEFAULT_DOTENV_EXAMPLE_PATH: &str = ".env.example";
|
||||||
|
|
||||||
|
const LOGGING_TARGET: &str = "ksp-config-lib";
|
||||||
|
const LOGGING_DOMAIN: &str = "config.environment";
|
||||||
|
|
||||||
|
/// Source that supplied one resolved Config environment variable.
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub enum ConfigEnvironmentSource {
|
||||||
|
/// Value was present in the environment inherited by the current process.
|
||||||
|
Process,
|
||||||
|
/// Value was absent from the process environment and came from the local `.env` file.
|
||||||
|
DotEnv,
|
||||||
|
/// Value was absent from both external sources and came from the placeholder/API fallback.
|
||||||
|
Fallback,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One resolved Config environment variable with real/safe values, sensitivity and its winning source.
|
||||||
|
#[derive(Clone, Eq, PartialEq)]
|
||||||
|
pub struct ConfigEnvironmentValue {
|
||||||
|
variable_name: String,
|
||||||
|
value: String,
|
||||||
|
safe_value: String,
|
||||||
|
sensitivity: crate::ConfigSensitivity,
|
||||||
|
source: ConfigEnvironmentSource,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ConfigEnvironmentValue {
|
||||||
|
/// Returns the resolved variable name.
|
||||||
|
#[must_use]
|
||||||
|
pub fn variable_name(&self) -> &str {
|
||||||
|
return self.variable_name.as_str();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the real resolved value.
|
||||||
|
#[must_use]
|
||||||
|
pub fn value(&self) -> &str {
|
||||||
|
return self.value.as_str();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the representation safe for ordinary diagnostics.
|
||||||
|
#[must_use]
|
||||||
|
pub fn safe_value(&self) -> &str {
|
||||||
|
return self.safe_value.as_str();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the sensitivity derived from the variable namespace.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn sensitivity(&self) -> crate::ConfigSensitivity {
|
||||||
|
return self.sensitivity;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the source that won process > `.env` > fallback resolution.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn source(&self) -> ConfigEnvironmentSource {
|
||||||
|
return self.source;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns provenance without embedding the resolved value.
|
||||||
|
#[must_use]
|
||||||
|
pub fn provenance(&self) -> crate::ConfigValueProvenance {
|
||||||
|
return environment_provenance(self.variable_name.as_str(), self.source);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Debug for ConfigEnvironmentValue {
|
||||||
|
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
return formatter
|
||||||
|
.debug_struct("ConfigEnvironmentValue")
|
||||||
|
.field("variable_name", &self.variable_name)
|
||||||
|
.field("safe_value", &self.safe_value)
|
||||||
|
.field("sensitivity", &self.sensitivity)
|
||||||
|
.field("source", &self.source)
|
||||||
|
.finish();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Config-owned snapshot of KSP/KSPB process environment values and the local `.env` file.
|
||||||
|
///
|
||||||
|
/// The process environment is captured first and always has priority over `.env`. An absent `.env` file is equivalent to an empty local environment source.
|
||||||
|
/// Config never mutates the parent/process environment through this type.
|
||||||
|
#[derive(Clone, Eq, PartialEq)]
|
||||||
|
pub struct ConfigEnvironment {
|
||||||
|
process: std::collections::BTreeMap<String, String>,
|
||||||
|
dotenv: std::collections::BTreeMap<String, String>,
|
||||||
|
dotenv_path: std::path::PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ConfigEnvironment {
|
||||||
|
/// Captures supported variables from the current process and reads `./.env` when it exists.
|
||||||
|
pub fn load() -> ksp_core_lib::Result<Self> {
|
||||||
|
return Self::load_from_dotenv_path(std::path::Path::new(DEFAULT_DOTENV_PATH));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the local `.env` path used by this environment snapshot.
|
||||||
|
#[must_use]
|
||||||
|
pub fn dotenv_path(&self) -> &std::path::Path {
|
||||||
|
return self.dotenv_path.as_path();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolves one KSP/KSPB variable using process > `.env` > fallback priority.
|
||||||
|
///
|
||||||
|
/// The fallback is used only when the variable is absent. An explicitly defined empty string is a real value and therefore wins over the fallback.
|
||||||
|
pub fn resolve_variable(&self, variable_name: &str, fallback: std::option::Option<&str>) -> ksp_core_lib::Result<ConfigEnvironmentValue> {
|
||||||
|
let validation = validate_supported_variable_name(variable_name);
|
||||||
|
if let std::result::Result::Err(error) = validation {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
if let std::option::Option::Some(value) = self.process.get(variable_name) {
|
||||||
|
return resolved_environment_value(variable_name, value.as_str(), ConfigEnvironmentSource::Process);
|
||||||
|
}
|
||||||
|
if let std::option::Option::Some(value) = self.dotenv.get(variable_name) {
|
||||||
|
return resolved_environment_value(variable_name, value.as_str(), ConfigEnvironmentSource::DotEnv);
|
||||||
|
}
|
||||||
|
if let std::option::Option::Some(value) = fallback {
|
||||||
|
return resolved_environment_value(variable_name, value, ConfigEnvironmentSource::Fallback);
|
||||||
|
}
|
||||||
|
emit_missing_variable_warning(variable_name);
|
||||||
|
return std::result::Result::Err(missing_variable_error(variable_name));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolves `${NAME}` and `${NAME:-fallback}` placeholders embedded in one UTF-8 string.
|
||||||
|
///
|
||||||
|
/// This compatibility helper returns only the real runtime string. Use [`Self::resolve_text_detailed`] when safe value, sensitivity or provenance are
|
||||||
|
/// needed.
|
||||||
|
pub fn resolve_text(&self, source: &str) -> ksp_core_lib::Result<String> {
|
||||||
|
let resolved = self.resolve_text_detailed(source);
|
||||||
|
return match resolved {
|
||||||
|
std::result::Result::Ok(value) => std::result::Result::Ok(value.value().to_owned()),
|
||||||
|
std::result::Result::Err(error) => std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolves one UTF-8 string while preserving real/safe representations, strongest sensitivity and ordered provenance.
|
||||||
|
///
|
||||||
|
/// Multiple placeholders are supported. Fallback text is literal and inherits the sensitivity of the referenced variable. Literal-only strings are
|
||||||
|
/// classified as `Internal`; when placeholders are present, the result sensitivity is the strongest placeholder sensitivity.
|
||||||
|
pub fn resolve_text_detailed(&self, source: &str) -> ksp_core_lib::Result<crate::ResolvedConfigText> {
|
||||||
|
let mut value = String::new();
|
||||||
|
let mut safe_value = String::new();
|
||||||
|
let mut provenance = std::vec::Vec::<crate::ConfigValueProvenance>::new();
|
||||||
|
let mut sensitivity = crate::ConfigSensitivity::Public;
|
||||||
|
let mut saw_placeholder = false;
|
||||||
|
let mut remaining = source;
|
||||||
|
loop {
|
||||||
|
let start = remaining.find("${");
|
||||||
|
let start = match start {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => {
|
||||||
|
if !remaining.is_empty() {
|
||||||
|
value.push_str(remaining);
|
||||||
|
safe_value.push_str(remaining);
|
||||||
|
provenance.push(crate::ConfigValueProvenance::DocumentLiteral);
|
||||||
|
}
|
||||||
|
if !saw_placeholder {
|
||||||
|
sensitivity = crate::ConfigSensitivity::Internal;
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(crate::ResolvedConfigText::new(value, safe_value, sensitivity, provenance));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let literal = &remaining[..start];
|
||||||
|
if !literal.is_empty() {
|
||||||
|
value.push_str(literal);
|
||||||
|
safe_value.push_str(literal);
|
||||||
|
provenance.push(crate::ConfigValueProvenance::DocumentLiteral);
|
||||||
|
}
|
||||||
|
let expression_and_tail = &remaining[start + 2..];
|
||||||
|
let end = expression_and_tail.find('}');
|
||||||
|
let end = match end {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => return std::result::Result::Err(invalid_placeholder_error("placeholder is missing its closing '}'")),
|
||||||
|
};
|
||||||
|
let expression = &expression_and_tail[..end];
|
||||||
|
let parsed = parse_placeholder_expression(expression);
|
||||||
|
let (variable_name, fallback) = match parsed {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let resolved = self.resolve_variable(variable_name, fallback);
|
||||||
|
let resolved = match resolved {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
saw_placeholder = true;
|
||||||
|
sensitivity = sensitivity.strongest(resolved.sensitivity());
|
||||||
|
value.push_str(resolved.value());
|
||||||
|
safe_value.push_str(resolved.safe_value());
|
||||||
|
provenance.push(resolved.provenance());
|
||||||
|
remaining = &expression_and_tail[end + 1..];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Recursively resolves environment placeholders in JSON values and returns only the real runtime tree.
|
||||||
|
///
|
||||||
|
/// Use [`Self::resolve_json_detailed`] when safe value, sensitivity or per-location provenance are needed.
|
||||||
|
pub fn resolve_json(&self, source: &serde_json::Value) -> ksp_core_lib::Result<serde_json::Value> {
|
||||||
|
let resolved = self.resolve_json_detailed(source);
|
||||||
|
return match resolved {
|
||||||
|
std::result::Result::Ok(value) => std::result::Result::Ok(value.value().clone()),
|
||||||
|
std::result::Result::Err(error) => std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Recursively resolves environment placeholders in JSON values while preserving real/safe trees, strongest sensitivity and JSON-Pointer provenance.
|
||||||
|
pub fn resolve_json_detailed(&self, source: &serde_json::Value) -> ksp_core_lib::Result<crate::ResolvedConfigJson> {
|
||||||
|
let mut provenance = std::collections::BTreeMap::<String, std::vec::Vec<crate::ConfigValueProvenance>>::new();
|
||||||
|
let resolved = resolve_json_node(self, source, "", &mut provenance);
|
||||||
|
let (value, safe_value, sensitivity) = match resolved {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
return std::result::Result::Ok(crate::ResolvedConfigJson::new(value, safe_value, sensitivity, provenance));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Recursively resolves environment placeholders in one JSON object map while leaving map keys unchanged.
|
||||||
|
pub fn resolve_map(&self, source: &serde_json::Map<String, serde_json::Value>) -> ksp_core_lib::Result<serde_json::Map<String, serde_json::Value>> {
|
||||||
|
let resolved = self.resolve_json(&serde_json::Value::Object(source.clone()));
|
||||||
|
let resolved = match resolved {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
return match resolved {
|
||||||
|
serde_json::Value::Object(value) => std::result::Result::Ok(value),
|
||||||
|
_ => std::result::Result::Err(invalid_placeholder_error("resolved JSON object changed shape")),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn load_from_dotenv_path(dotenv_path: &std::path::Path) -> ksp_core_lib::Result<Self> {
|
||||||
|
let process = collect_process_environment(std::env::vars_os());
|
||||||
|
let process = match process {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let dotenv = load_dotenv_file(dotenv_path);
|
||||||
|
let dotenv = match dotenv {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
return std::result::Result::Ok(Self { process, dotenv, dotenv_path: dotenv_path.to_path_buf() });
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) const fn process_values(&self) -> &std::collections::BTreeMap<String, String> {
|
||||||
|
return &self.process;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) const fn dotenv_values(&self) -> &std::collections::BTreeMap<String, String> {
|
||||||
|
return &self.dotenv;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(crate) fn from_maps(process: std::collections::BTreeMap<String, String>, dotenv: std::collections::BTreeMap<String, String>) -> Self {
|
||||||
|
return Self { process, dotenv, dotenv_path: std::path::PathBuf::from(DEFAULT_DOTENV_PATH) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn collect_process_environment<I>(values: I) -> ksp_core_lib::Result<std::collections::BTreeMap<String, String>>
|
||||||
|
where
|
||||||
|
I: std::iter::IntoIterator<Item = (std::ffi::OsString, std::ffi::OsString)>,
|
||||||
|
{
|
||||||
|
let mut output = std::collections::BTreeMap::<String, String>::new();
|
||||||
|
for (name, value) in values {
|
||||||
|
let name = match name.to_str() {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => continue,
|
||||||
|
};
|
||||||
|
if !has_supported_namespace(name) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let validation = validate_supported_variable_name(name);
|
||||||
|
if let std::result::Result::Err(error) = validation {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
let value = value.into_string();
|
||||||
|
let value = match value {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return std::result::Result::Err(invalid_environment_value_error(name)),
|
||||||
|
};
|
||||||
|
output.insert(name.to_owned(), value);
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(output);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_dotenv_file(path: &std::path::Path) -> ksp_core_lib::Result<std::collections::BTreeMap<String, String>> {
|
||||||
|
let content = std::fs::read_to_string(path);
|
||||||
|
let content = match content {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) if error.kind() == std::io::ErrorKind::NotFound => return std::result::Result::Ok(std::collections::BTreeMap::new()),
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(dotenv_read_error(path, error)),
|
||||||
|
};
|
||||||
|
return parse_dotenv_content(path, content.as_str());
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn parse_dotenv_content(path: &std::path::Path, content: &str) -> ksp_core_lib::Result<std::collections::BTreeMap<String, String>> {
|
||||||
|
let mut output = std::collections::BTreeMap::<String, String>::new();
|
||||||
|
for (line_index, raw_line) in content.lines().enumerate() {
|
||||||
|
let raw_line = if line_index == 0 { raw_line.trim_start_matches('\u{feff}') } else { raw_line };
|
||||||
|
let line = raw_line.trim();
|
||||||
|
if line.is_empty() || line.starts_with('#') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let assignment = match line.strip_prefix("export ") {
|
||||||
|
std::option::Option::Some(value) => value.trim_start(),
|
||||||
|
std::option::Option::None => line,
|
||||||
|
};
|
||||||
|
let separator = assignment.find('=');
|
||||||
|
let separator = match separator {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => return std::result::Result::Err(dotenv_syntax_error(path, line_index + 1, "assignment is missing '='")),
|
||||||
|
};
|
||||||
|
let variable_name = assignment[..separator].trim();
|
||||||
|
if !is_generic_dotenv_name(variable_name) {
|
||||||
|
return std::result::Result::Err(dotenv_syntax_error(path, line_index + 1, "variable name is invalid"));
|
||||||
|
}
|
||||||
|
let raw_value = assignment[separator + 1..].trim();
|
||||||
|
let value = parse_dotenv_value(path, line_index + 1, raw_value);
|
||||||
|
let value = match value {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
if !has_supported_namespace(variable_name) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let validation = validate_supported_variable_name(variable_name);
|
||||||
|
if let std::result::Result::Err(error) = validation {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
let entry = output.entry(variable_name.to_owned());
|
||||||
|
match entry {
|
||||||
|
std::collections::btree_map::Entry::Occupied(_) => {
|
||||||
|
return std::result::Result::Err(dotenv_duplicate_error(path, line_index + 1, variable_name));
|
||||||
|
},
|
||||||
|
std::collections::btree_map::Entry::Vacant(entry) => {
|
||||||
|
entry.insert(value);
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(output);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_dotenv_value(path: &std::path::Path, line_number: usize, raw_value: &str) -> ksp_core_lib::Result<String> {
|
||||||
|
if raw_value.starts_with('\'') && (raw_value.len() < 2 || !raw_value.ends_with('\'')) {
|
||||||
|
return std::result::Result::Err(dotenv_syntax_error(path, line_number, "single-quoted value is not terminated"));
|
||||||
|
}
|
||||||
|
if raw_value.starts_with('\'') {
|
||||||
|
return std::result::Result::Ok(raw_value[1..raw_value.len() - 1].to_owned());
|
||||||
|
}
|
||||||
|
if raw_value.starts_with('"') && (raw_value.len() < 2 || !raw_value.ends_with('"')) {
|
||||||
|
return std::result::Result::Err(dotenv_syntax_error(path, line_number, "double-quoted value is not terminated"));
|
||||||
|
}
|
||||||
|
if raw_value.starts_with('"') {
|
||||||
|
return parse_double_quoted_value(path, line_number, &raw_value[1..raw_value.len() - 1]);
|
||||||
|
}
|
||||||
|
let inline_comment = raw_value.find(" #");
|
||||||
|
let value = match inline_comment {
|
||||||
|
std::option::Option::Some(index) => raw_value[..index].trim_end(),
|
||||||
|
std::option::Option::None => raw_value,
|
||||||
|
};
|
||||||
|
return std::result::Result::Ok(value.to_owned());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_double_quoted_value(path: &std::path::Path, line_number: usize, source: &str) -> ksp_core_lib::Result<String> {
|
||||||
|
let mut output = String::new();
|
||||||
|
let mut escaped = false;
|
||||||
|
for character in source.chars() {
|
||||||
|
if escaped {
|
||||||
|
let mapped = match character {
|
||||||
|
'n' => '\n',
|
||||||
|
'r' => '\r',
|
||||||
|
't' => '\t',
|
||||||
|
'\\' => '\\',
|
||||||
|
'"' => '"',
|
||||||
|
_ => return std::result::Result::Err(dotenv_syntax_error(path, line_number, "double-quoted value contains an unsupported escape")),
|
||||||
|
};
|
||||||
|
output.push(mapped);
|
||||||
|
escaped = false;
|
||||||
|
} else if character == '\\' {
|
||||||
|
escaped = true;
|
||||||
|
} else {
|
||||||
|
output.push(character);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if escaped {
|
||||||
|
return std::result::Result::Err(dotenv_syntax_error(path, line_number, "double-quoted value ends with an incomplete escape"));
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(output);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_placeholder_expression(expression: &str) -> ksp_core_lib::Result<(&str, std::option::Option<&str>)> {
|
||||||
|
if expression.is_empty() || expression.contains("${") {
|
||||||
|
return std::result::Result::Err(invalid_placeholder_error("placeholder expression is empty or nested"));
|
||||||
|
}
|
||||||
|
let fallback_separator = expression.find(":-");
|
||||||
|
let (variable_name, fallback) = match fallback_separator {
|
||||||
|
std::option::Option::Some(index) => (&expression[..index], std::option::Option::Some(&expression[index + 2..])),
|
||||||
|
std::option::Option::None => (expression, std::option::Option::None),
|
||||||
|
};
|
||||||
|
let validation = validate_supported_variable_name(variable_name);
|
||||||
|
if let std::result::Result::Err(error) = validation {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok((variable_name, fallback));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolved_environment_value(variable_name: &str, value: &str, source: ConfigEnvironmentSource) -> ksp_core_lib::Result<ConfigEnvironmentValue> {
|
||||||
|
let sensitivity = crate::ConfigSensitivity::from_variable_name(variable_name);
|
||||||
|
let sensitivity = match sensitivity {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let safe_value = if sensitivity.is_secret() { crate::REDACTED_CONFIG_VALUE.to_owned() } else { value.to_owned() };
|
||||||
|
return std::result::Result::Ok(ConfigEnvironmentValue {
|
||||||
|
variable_name: variable_name.to_owned(),
|
||||||
|
value: value.to_owned(),
|
||||||
|
safe_value,
|
||||||
|
sensitivity,
|
||||||
|
source,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn environment_provenance(variable_name: &str, source: ConfigEnvironmentSource) -> crate::ConfigValueProvenance {
|
||||||
|
return match source {
|
||||||
|
ConfigEnvironmentSource::Process => crate::ConfigValueProvenance::EnvironmentProcess { variable_name: variable_name.to_owned() },
|
||||||
|
ConfigEnvironmentSource::DotEnv => crate::ConfigValueProvenance::EnvironmentDotEnv { variable_name: variable_name.to_owned() },
|
||||||
|
ConfigEnvironmentSource::Fallback => crate::ConfigValueProvenance::EnvironmentFallback { variable_name: variable_name.to_owned() },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_json_node(
|
||||||
|
environment: &ConfigEnvironment,
|
||||||
|
source: &serde_json::Value,
|
||||||
|
pointer: &str,
|
||||||
|
provenance: &mut std::collections::BTreeMap<String, std::vec::Vec<crate::ConfigValueProvenance>>,
|
||||||
|
) -> ksp_core_lib::Result<(serde_json::Value, serde_json::Value, crate::ConfigSensitivity)> {
|
||||||
|
return match source {
|
||||||
|
serde_json::Value::Null | serde_json::Value::Bool(_) | serde_json::Value::Number(_) => {
|
||||||
|
provenance.insert(pointer.to_owned(), vec![crate::ConfigValueProvenance::DocumentLiteral]);
|
||||||
|
std::result::Result::Ok((source.clone(), source.clone(), crate::ConfigSensitivity::Internal))
|
||||||
|
},
|
||||||
|
serde_json::Value::String(value) => {
|
||||||
|
let resolved = environment.resolve_text_detailed(value.as_str());
|
||||||
|
let resolved = match resolved {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
provenance.insert(pointer.to_owned(), resolved.provenance().to_vec());
|
||||||
|
std::result::Result::Ok((
|
||||||
|
serde_json::Value::String(resolved.value().to_owned()),
|
||||||
|
serde_json::Value::String(resolved.safe_value().to_owned()),
|
||||||
|
resolved.sensitivity(),
|
||||||
|
))
|
||||||
|
},
|
||||||
|
serde_json::Value::Array(values) => resolve_json_array(environment, values, pointer, provenance),
|
||||||
|
serde_json::Value::Object(values) => resolve_json_object(environment, values, pointer, provenance),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_json_array(
|
||||||
|
environment: &ConfigEnvironment,
|
||||||
|
source: &[serde_json::Value],
|
||||||
|
pointer: &str,
|
||||||
|
provenance: &mut std::collections::BTreeMap<String, std::vec::Vec<crate::ConfigValueProvenance>>,
|
||||||
|
) -> ksp_core_lib::Result<(serde_json::Value, serde_json::Value, crate::ConfigSensitivity)> {
|
||||||
|
let mut value = std::vec::Vec::<serde_json::Value>::with_capacity(source.len());
|
||||||
|
let mut safe_value = std::vec::Vec::<serde_json::Value>::with_capacity(source.len());
|
||||||
|
let mut sensitivity = crate::ConfigSensitivity::Public;
|
||||||
|
if source.is_empty() {
|
||||||
|
sensitivity = crate::ConfigSensitivity::Internal;
|
||||||
|
}
|
||||||
|
for (index, item) in source.iter().enumerate() {
|
||||||
|
let child_pointer = format!("{pointer}/{index}");
|
||||||
|
let resolved = resolve_json_node(environment, item, child_pointer.as_str(), provenance);
|
||||||
|
let (resolved_value, resolved_safe_value, resolved_sensitivity) = match resolved {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
value.push(resolved_value);
|
||||||
|
safe_value.push(resolved_safe_value);
|
||||||
|
sensitivity = sensitivity.strongest(resolved_sensitivity);
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok((serde_json::Value::Array(value), serde_json::Value::Array(safe_value), sensitivity));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_json_object(
|
||||||
|
environment: &ConfigEnvironment,
|
||||||
|
source: &serde_json::Map<String, serde_json::Value>,
|
||||||
|
pointer: &str,
|
||||||
|
provenance: &mut std::collections::BTreeMap<String, std::vec::Vec<crate::ConfigValueProvenance>>,
|
||||||
|
) -> ksp_core_lib::Result<(serde_json::Value, serde_json::Value, crate::ConfigSensitivity)> {
|
||||||
|
let mut value = serde_json::Map::<String, serde_json::Value>::new();
|
||||||
|
let mut safe_value = serde_json::Map::<String, serde_json::Value>::new();
|
||||||
|
let mut sensitivity = crate::ConfigSensitivity::Public;
|
||||||
|
if source.is_empty() {
|
||||||
|
sensitivity = crate::ConfigSensitivity::Internal;
|
||||||
|
}
|
||||||
|
for (key, item) in source {
|
||||||
|
let escaped_key = escape_json_pointer_token(key.as_str());
|
||||||
|
let child_pointer = format!("{pointer}/{escaped_key}");
|
||||||
|
let resolved = resolve_json_node(environment, item, child_pointer.as_str(), provenance);
|
||||||
|
let (resolved_value, resolved_safe_value, resolved_sensitivity) = match resolved {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
value.insert(key.clone(), resolved_value);
|
||||||
|
safe_value.insert(key.clone(), resolved_safe_value);
|
||||||
|
sensitivity = sensitivity.strongest(resolved_sensitivity);
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok((serde_json::Value::Object(value), serde_json::Value::Object(safe_value), sensitivity));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn escape_json_pointer_token(value: &str) -> String {
|
||||||
|
return value.replace('~', "~0").replace('/', "~1");
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn validate_supported_variable_name(variable_name: &str) -> ksp_core_lib::Result<()> {
|
||||||
|
if !has_supported_namespace(variable_name) {
|
||||||
|
return std::result::Result::Err(invalid_variable_error(variable_name, "variable must use the KSP_ or KSPB_ namespace"));
|
||||||
|
}
|
||||||
|
let prefix_length = if variable_name.starts_with("KSPB_") { 5 } else { 4 };
|
||||||
|
if variable_name.len() <= prefix_length {
|
||||||
|
return std::result::Result::Err(invalid_variable_error(variable_name, "variable namespace must be followed by a name"));
|
||||||
|
}
|
||||||
|
for byte in variable_name.bytes() {
|
||||||
|
let valid = byte.is_ascii_uppercase() || byte.is_ascii_digit() || byte == b'_';
|
||||||
|
if !valid {
|
||||||
|
return std::result::Result::Err(invalid_variable_error(variable_name, "variable names use uppercase ASCII letters, digits and underscores"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn has_supported_namespace(variable_name: &str) -> bool {
|
||||||
|
return variable_name.starts_with("KSP_") || variable_name.starts_with("KSPB_");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_generic_dotenv_name(variable_name: &str) -> bool {
|
||||||
|
let mut bytes = variable_name.bytes();
|
||||||
|
let first = match bytes.next() {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => return false,
|
||||||
|
};
|
||||||
|
if !(first.is_ascii_alphabetic() || first == b'_') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
for byte in bytes {
|
||||||
|
if !(byte.is_ascii_alphanumeric() || byte == b'_') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn emit_missing_variable_warning(variable_name: &str) {
|
||||||
|
ksp_logging_lib::warn!(target: LOGGING_TARGET, domain = LOGGING_DOMAIN, variable_name = variable_name, "Config environment variable is missing");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn missing_variable_error(variable_name: &str) -> ksp_core_lib::Error {
|
||||||
|
return ksp_core_lib::Error::new(crate::ERROR_CODE_ENVIRONMENT_VARIABLE_MISSING, "required Config environment variable is missing")
|
||||||
|
.with_context("variable_name", variable_name);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn invalid_variable_error(variable_name: &str, reason: &'static str) -> ksp_core_lib::Error {
|
||||||
|
return ksp_core_lib::Error::new(crate::ERROR_CODE_ENVIRONMENT_VARIABLE_INVALID, "Config environment variable name is invalid")
|
||||||
|
.with_context("variable_name", variable_name)
|
||||||
|
.with_context("reason", reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn invalid_environment_value_error(variable_name: &str) -> ksp_core_lib::Error {
|
||||||
|
return ksp_core_lib::Error::new(crate::ERROR_CODE_ENVIRONMENT_VALUE_INVALID, "Config environment variable value is not valid UTF-8")
|
||||||
|
.with_context("variable_name", variable_name);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn invalid_placeholder_error(reason: &'static str) -> ksp_core_lib::Error {
|
||||||
|
return ksp_core_lib::Error::new(crate::ERROR_CODE_ENVIRONMENT_PLACEHOLDER_INVALID, "Config environment placeholder is invalid")
|
||||||
|
.with_context("reason", reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn dotenv_read_error(path: &std::path::Path, source: std::io::Error) -> ksp_core_lib::Error {
|
||||||
|
return ksp_core_lib::Error::new(crate::ERROR_CODE_DOTENV_FILE_READ_FAILED, "Config cannot read the local .env file")
|
||||||
|
.with_context("path", path.to_string_lossy().into_owned())
|
||||||
|
.with_source(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn dotenv_syntax_error(path: &std::path::Path, line_number: usize, reason: &'static str) -> ksp_core_lib::Error {
|
||||||
|
return ksp_core_lib::Error::new(crate::ERROR_CODE_DOTENV_SYNTAX_INVALID, "Config local .env syntax is invalid")
|
||||||
|
.with_context("path", path.to_string_lossy().into_owned())
|
||||||
|
.with_context("line", line_number.to_string())
|
||||||
|
.with_context("reason", reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn dotenv_duplicate_error(path: &std::path::Path, line_number: usize, variable_name: &str) -> ksp_core_lib::Error {
|
||||||
|
return ksp_core_lib::Error::new(crate::ERROR_CODE_DOTENV_SYNTAX_INVALID, "Config local .env contains a duplicate KSP variable")
|
||||||
|
.with_context("path", path.to_string_lossy().into_owned())
|
||||||
|
.with_context("line", line_number.to_string())
|
||||||
|
.with_context("variable_name", variable_name);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "../unit_tests/environment.rs"]
|
||||||
|
mod tests;
|
||||||
68
crates/ksp-config-lib/src/error.rs
Normal file
68
crates/ksp-config-lib/src/error.rs
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
// file: crates/ksp-config-lib/src/error.rs
|
||||||
|
// version: 8
|
||||||
|
|
||||||
|
/// Error code used when a Config bootstrap argument is missing its value.
|
||||||
|
pub const ERROR_CODE_BOOTSTRAP_ARGUMENT_MISSING_VALUE: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config", "bootstrap_argument_missing_value");
|
||||||
|
|
||||||
|
/// Error code used when a Config bootstrap path is empty, inaccessible, or resolves to an existing non-directory path.
|
||||||
|
pub const ERROR_CODE_BOOTSTRAP_INVALID_PATH: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config", "bootstrap_invalid_path");
|
||||||
|
|
||||||
|
/// Error code used when a logical Config file identifier is malformed.
|
||||||
|
pub const ERROR_CODE_FILE_ID_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config", "file_id_invalid");
|
||||||
|
|
||||||
|
/// Error code used when a requested logical Config file identifier is not registered.
|
||||||
|
pub const ERROR_CODE_FILE_ID_UNKNOWN: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config", "file_id_unknown");
|
||||||
|
|
||||||
|
/// Error code used when the same logical Config file identifier is registered more than once.
|
||||||
|
pub const ERROR_CODE_FILE_ID_DUPLICATE: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config", "file_id_duplicate");
|
||||||
|
|
||||||
|
/// Error code used when a Config filename mapping or descriptor relation is invalid.
|
||||||
|
pub const ERROR_CODE_FILE_MAPPING_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config", "file_mapping_invalid");
|
||||||
|
|
||||||
|
/// Error code used when a Config-managed JSON document or schema cannot be read from its resolved path.
|
||||||
|
pub const ERROR_CODE_JSON_FILE_READ_FAILED: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config", "json_file_read_failed");
|
||||||
|
|
||||||
|
/// Error code used when a Config-managed file contains invalid JSON syntax.
|
||||||
|
pub const ERROR_CODE_JSON_SYNTAX_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config", "json_syntax_invalid");
|
||||||
|
|
||||||
|
/// Error code used when a JSON Schema document is itself invalid for the selected JSON Schema draft.
|
||||||
|
pub const ERROR_CODE_SCHEMA_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config", "schema_invalid");
|
||||||
|
|
||||||
|
/// Error code used when a Config document does not satisfy its registered JSON Schema.
|
||||||
|
pub const ERROR_CODE_SCHEMA_VALIDATION_FAILED: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config", "schema_validation_failed");
|
||||||
|
|
||||||
|
/// Error code used when a schema-valid Config document violates KSP semantic invariants for its document type.
|
||||||
|
pub const ERROR_CODE_DOCUMENT_SEMANTIC_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config", "document_semantic_invalid");
|
||||||
|
|
||||||
|
/// Error code used when an explicitly requested Config profile does not exist in a validated document.
|
||||||
|
pub const ERROR_CODE_PROFILE_NOT_FOUND: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config", "profile_not_found");
|
||||||
|
|
||||||
|
/// Error code used when a composite document references an invalid, unknown, or unsupported Config document.
|
||||||
|
pub const ERROR_CODE_COMPOSITE_REFERENCE_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config", "composite_reference_invalid");
|
||||||
|
|
||||||
|
/// Error code used when the local `.env` file cannot be read for a reason other than absence.
|
||||||
|
pub const ERROR_CODE_DOTENV_FILE_READ_FAILED: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config", "dotenv_file_read_failed");
|
||||||
|
|
||||||
|
/// Error code used when the local `.env` file contains syntax Config cannot interpret safely.
|
||||||
|
pub const ERROR_CODE_DOTENV_SYNTAX_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config", "dotenv_syntax_invalid");
|
||||||
|
|
||||||
|
/// Error code used when a Config environment variable name is malformed or outside the KSP/KSPB namespaces.
|
||||||
|
pub const ERROR_CODE_ENVIRONMENT_VARIABLE_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config", "environment_variable_invalid");
|
||||||
|
|
||||||
|
/// Error code used when a referenced Config environment variable is absent and has no fallback.
|
||||||
|
pub const ERROR_CODE_ENVIRONMENT_VARIABLE_MISSING: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config", "environment_variable_missing");
|
||||||
|
|
||||||
|
/// Error code used when a supported process environment variable has a value that cannot become a JSON UTF-8 string.
|
||||||
|
pub const ERROR_CODE_ENVIRONMENT_VALUE_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config", "environment_value_invalid");
|
||||||
|
|
||||||
|
/// Error code used when a `${NAME}` / `${NAME:-fallback}` expression is malformed.
|
||||||
|
pub const ERROR_CODE_ENVIRONMENT_PLACEHOLDER_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config", "environment_placeholder_invalid");
|
||||||
|
|
||||||
|
/// Error code used when an environment-resolved Config cannot be mapped safely to a runtime consumer contract.
|
||||||
|
pub const ERROR_CODE_EFFECTIVE_CONFIG_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config", "effective_config_invalid");
|
||||||
|
|
||||||
|
/// Error code used when an explicit Config management operation is unsupported or targets the wrong managed resource kind.
|
||||||
|
pub const ERROR_CODE_MANAGEMENT_OPERATION_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config", "management_operation_invalid");
|
||||||
|
|
||||||
|
/// Error code used when an atomic managed Config or `.env` persistence operation fails before commit.
|
||||||
|
pub const ERROR_CODE_PERSISTENCE_WRITE_FAILED: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config", "persistence_write_failed");
|
||||||
159
crates/ksp-config-lib/src/lib.rs
Normal file
159
crates/ksp-config-lib/src/lib.rs
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
// file: crates/ksp-config-lib/src/lib.rs
|
||||||
|
// version: 10
|
||||||
|
#![warn(missing_docs)]
|
||||||
|
#![deny(unreachable_pub)]
|
||||||
|
#![forbid(unsafe_code)]
|
||||||
|
|
||||||
|
//! KSP-owned application configuration facade.
|
||||||
|
//!
|
||||||
|
//! The `0.1.3` surface owns bootstrap roots, the logical file registry, JSON/JSON Schema validation, standard-document profiles, generic composites and
|
||||||
|
//! KSP/KSPB environment resolution through process + `.env` + fallback precedence. Resolved values preserve real/safe representations, sensitivity and
|
||||||
|
//! provenance. The standard Logging document maps explicitly to `ksp_logging_lib::LoggingSettings`, while the management surface provides typed Logging
|
||||||
|
//! mutation, safe environment reports, explicit privileged reveal calls and atomic JSON/`.env` persistence.
|
||||||
|
|
||||||
|
mod bootstrap;
|
||||||
|
mod composite;
|
||||||
|
mod document;
|
||||||
|
mod environment;
|
||||||
|
mod error;
|
||||||
|
mod logging;
|
||||||
|
mod management;
|
||||||
|
mod persistence;
|
||||||
|
mod profile;
|
||||||
|
mod registry;
|
||||||
|
mod sensitivity;
|
||||||
|
|
||||||
|
/// Bootstrap argument used to replace the configuration document root.
|
||||||
|
pub use self::bootstrap::ARG_CFG_PATH;
|
||||||
|
/// Bootstrap argument used to replace the schema root.
|
||||||
|
pub use self::bootstrap::ARG_SCHEMA_PATH;
|
||||||
|
/// Non-recursive bootstrap options required before Config can resolve any managed document.
|
||||||
|
pub use self::bootstrap::ConfigBootstrapOptions;
|
||||||
|
/// Default root containing KSP runtime configuration documents.
|
||||||
|
pub use self::bootstrap::DEFAULT_CFG_PATH;
|
||||||
|
/// Default root containing KSP JSON schemas.
|
||||||
|
pub use self::bootstrap::DEFAULT_SCHEMA_PATH;
|
||||||
|
/// One resolved document component selected by a composite profile.
|
||||||
|
pub use self::composite::ResolvedCompositeComponent;
|
||||||
|
/// Validated composite document resolved to one profile and its referenced standard documents.
|
||||||
|
pub use self::composite::ResolvedConfigComposite;
|
||||||
|
/// Generic JSON/JSON Schema engine owned by Config.
|
||||||
|
pub use self::document::ConfigDocumentEngine;
|
||||||
|
/// A Config-managed JSON document after syntax, schema and current semantic validation.
|
||||||
|
pub use self::document::ConfigJsonDocument;
|
||||||
|
/// Config-owned snapshot of KSP/KSPB process environment values and the local `.env` file.
|
||||||
|
pub use self::environment::ConfigEnvironment;
|
||||||
|
/// Source that supplied one resolved Config environment variable.
|
||||||
|
pub use self::environment::ConfigEnvironmentSource;
|
||||||
|
/// One resolved Config environment variable with real/safe values, sensitivity and its winning source.
|
||||||
|
pub use self::environment::ConfigEnvironmentValue;
|
||||||
|
/// Versioned environment contract template expected at the repository/runtime root.
|
||||||
|
pub use self::environment::DEFAULT_DOTENV_EXAMPLE_PATH;
|
||||||
|
/// Default local environment file read by Config from the process launch directory.
|
||||||
|
pub use self::environment::DEFAULT_DOTENV_PATH;
|
||||||
|
/// Error code used when a Config bootstrap argument is missing its value.
|
||||||
|
pub use self::error::ERROR_CODE_BOOTSTRAP_ARGUMENT_MISSING_VALUE;
|
||||||
|
/// Error code used when a Config bootstrap path is empty, inaccessible, or resolves to an existing non-directory path.
|
||||||
|
pub use self::error::ERROR_CODE_BOOTSTRAP_INVALID_PATH;
|
||||||
|
/// Error code used when a composite document contains an invalid or unsupported document reference.
|
||||||
|
pub use self::error::ERROR_CODE_COMPOSITE_REFERENCE_INVALID;
|
||||||
|
/// Error code used when a schema-valid Config document violates KSP semantic invariants.
|
||||||
|
pub use self::error::ERROR_CODE_DOCUMENT_SEMANTIC_INVALID;
|
||||||
|
/// Error code used when the local `.env` file cannot be read for a reason other than absence.
|
||||||
|
pub use self::error::ERROR_CODE_DOTENV_FILE_READ_FAILED;
|
||||||
|
/// Error code used when the local `.env` file contains invalid syntax.
|
||||||
|
pub use self::error::ERROR_CODE_DOTENV_SYNTAX_INVALID;
|
||||||
|
/// Error code used when an environment-resolved Config cannot map safely to a runtime consumer contract.
|
||||||
|
pub use self::error::ERROR_CODE_EFFECTIVE_CONFIG_INVALID;
|
||||||
|
/// Error code used when a Config environment placeholder is malformed.
|
||||||
|
pub use self::error::ERROR_CODE_ENVIRONMENT_PLACEHOLDER_INVALID;
|
||||||
|
/// Error code used when a supported Config environment variable has a non-UTF-8 process value.
|
||||||
|
pub use self::error::ERROR_CODE_ENVIRONMENT_VALUE_INVALID;
|
||||||
|
/// Error code used when a Config environment variable name is invalid or outside KSP/KSPB namespaces.
|
||||||
|
pub use self::error::ERROR_CODE_ENVIRONMENT_VARIABLE_INVALID;
|
||||||
|
/// Error code used when a referenced Config environment variable is absent and has no fallback.
|
||||||
|
pub use self::error::ERROR_CODE_ENVIRONMENT_VARIABLE_MISSING;
|
||||||
|
/// Error code used when the same logical Config file identifier is registered more than once.
|
||||||
|
pub use self::error::ERROR_CODE_FILE_ID_DUPLICATE;
|
||||||
|
/// Error code used when a logical Config file identifier is malformed.
|
||||||
|
pub use self::error::ERROR_CODE_FILE_ID_INVALID;
|
||||||
|
/// Error code used when a requested logical Config file identifier is not registered.
|
||||||
|
pub use self::error::ERROR_CODE_FILE_ID_UNKNOWN;
|
||||||
|
/// Error code used when a Config filename mapping or descriptor relation is invalid.
|
||||||
|
pub use self::error::ERROR_CODE_FILE_MAPPING_INVALID;
|
||||||
|
/// Error code used when a Config-managed JSON document or schema cannot be read.
|
||||||
|
pub use self::error::ERROR_CODE_JSON_FILE_READ_FAILED;
|
||||||
|
/// Error code used when a Config-managed file contains invalid JSON syntax.
|
||||||
|
pub use self::error::ERROR_CODE_JSON_SYNTAX_INVALID;
|
||||||
|
/// Error code used when an explicit management operation is unsupported or targets the wrong managed resource kind.
|
||||||
|
pub use self::error::ERROR_CODE_MANAGEMENT_OPERATION_INVALID;
|
||||||
|
/// Error code used when atomic managed Config or `.env` persistence fails before commit.
|
||||||
|
pub use self::error::ERROR_CODE_PERSISTENCE_WRITE_FAILED;
|
||||||
|
/// Error code used when an explicitly requested Config profile does not exist.
|
||||||
|
pub use self::error::ERROR_CODE_PROFILE_NOT_FOUND;
|
||||||
|
/// Error code used when a JSON Schema document is itself invalid.
|
||||||
|
pub use self::error::ERROR_CODE_SCHEMA_INVALID;
|
||||||
|
/// Error code used when a Config document fails its registered JSON Schema validation.
|
||||||
|
pub use self::error::ERROR_CODE_SCHEMA_VALIDATION_FAILED;
|
||||||
|
/// Effective standard Logging configuration mapped to `ksp_logging_lib::LoggingSettings`.
|
||||||
|
pub use self::logging::ResolvedLoggingConfig;
|
||||||
|
/// Result of one validated Config document persistence operation.
|
||||||
|
pub use self::management::ConfigDocumentChangeReport;
|
||||||
|
/// Result of one persistent `.env` mutation.
|
||||||
|
pub use self::management::ConfigEnvironmentChangeReport;
|
||||||
|
/// Safe desired/effective/shadow view of one KSP/KSPB environment variable.
|
||||||
|
pub use self::management::ConfigEnvironmentReport;
|
||||||
|
/// Raw source of one registered Config document read through the explicit management surface.
|
||||||
|
pub use self::management::ConfigManagedSource;
|
||||||
|
/// Explicit Config management facade for source inspection and validated persistent mutations.
|
||||||
|
pub use self::management::ConfigManagement;
|
||||||
|
/// Typed source contract for `config/std.logging.json`.
|
||||||
|
pub use self::management::LoggingConfigDocument;
|
||||||
|
/// Typed source contract for the standard Logging console output.
|
||||||
|
pub use self::management::LoggingConsoleConfig;
|
||||||
|
/// Typed source contract for one persistent Logging file output.
|
||||||
|
pub use self::management::LoggingFileConfig;
|
||||||
|
/// Typed source contract for one Logging sink selector/filter.
|
||||||
|
pub use self::management::LoggingOutputFilterConfig;
|
||||||
|
/// Typed source contract for one profile in `std.logging.json`.
|
||||||
|
pub use self::management::LoggingProfileConfig;
|
||||||
|
/// Typed source contract for one global Logging target override.
|
||||||
|
pub use self::management::LoggingTargetFilterConfig;
|
||||||
|
/// Source that selected an effective standard Config profile.
|
||||||
|
pub use self::profile::ConfigProfileSelectionSource;
|
||||||
|
/// Origin of one top-level value in a resolved standard Config profile.
|
||||||
|
pub use self::profile::ConfigValueOrigin;
|
||||||
|
/// Validated standard Config document resolved to one profile with global/profile provenance.
|
||||||
|
pub use self::profile::ResolvedConfigProfile;
|
||||||
|
/// Bootstrap argument used to replace a known Config filename mapping.
|
||||||
|
pub use self::registry::ARG_FILE_MAP;
|
||||||
|
/// Logical descriptor associating a stable file identifier with its physical filename and validation schema.
|
||||||
|
pub use self::registry::ConfigFileDescriptor;
|
||||||
|
/// Stable logical identifier for a Config-managed file.
|
||||||
|
pub use self::registry::ConfigFileId;
|
||||||
|
/// Physical root category used to resolve a Config-managed file.
|
||||||
|
pub use self::registry::ConfigFileKind;
|
||||||
|
/// Registry of KSP-known logical Config files and their replaceable physical filenames.
|
||||||
|
pub use self::registry::ConfigFileRegistry;
|
||||||
|
/// Default physical filename for the generic composite JSON Schema document.
|
||||||
|
pub use self::registry::DEFAULT_COMPOSITE_SCHEMA_FILENAME;
|
||||||
|
/// Default physical filename for the standard Logging configuration document.
|
||||||
|
pub use self::registry::DEFAULT_STD_LOGGING_FILENAME;
|
||||||
|
/// Default physical filename for the standard Logging JSON Schema document.
|
||||||
|
pub use self::registry::DEFAULT_STD_LOGGING_SCHEMA_FILENAME;
|
||||||
|
/// Logical file identifier for the generic composite JSON Schema document.
|
||||||
|
pub use self::registry::FILE_ID_SCHEMA_COMPOSITE;
|
||||||
|
/// Logical file identifier for the standard Logging JSON Schema document.
|
||||||
|
pub use self::registry::FILE_ID_SCHEMA_STD_LOGGING;
|
||||||
|
/// Logical file identifier for the standard Logging configuration document.
|
||||||
|
pub use self::registry::FILE_ID_STD_LOGGING;
|
||||||
|
/// Sensitivity assigned to one Config value after environment resolution.
|
||||||
|
pub use self::sensitivity::ConfigSensitivity;
|
||||||
|
/// Provenance segment participating in one resolved Config value.
|
||||||
|
pub use self::sensitivity::ConfigValueProvenance;
|
||||||
|
/// Replacement used for secret environment fragments in safe diagnostic representations.
|
||||||
|
pub use self::sensitivity::REDACTED_CONFIG_VALUE;
|
||||||
|
/// Recursively resolved JSON value preserving real/safe trees and provenance.
|
||||||
|
pub use self::sensitivity::ResolvedConfigJson;
|
||||||
|
/// One resolved Config string preserving real/safe representations and provenance.
|
||||||
|
pub use self::sensitivity::ResolvedConfigText;
|
||||||
437
crates/ksp-config-lib/src/logging.rs
Normal file
437
crates/ksp-config-lib/src/logging.rs
Normal file
@@ -0,0 +1,437 @@
|
|||||||
|
// file: crates/ksp-config-lib/src/logging.rs
|
||||||
|
// version: 1
|
||||||
|
|
||||||
|
/// Effective standard Logging configuration resolved from Config and mapped to the Logging runtime contract.
|
||||||
|
#[derive(Clone, Eq, PartialEq)]
|
||||||
|
pub struct ResolvedLoggingConfig {
|
||||||
|
file_id: crate::ConfigFileId,
|
||||||
|
source_path: std::path::PathBuf,
|
||||||
|
profile_id: String,
|
||||||
|
selection_source: crate::ConfigProfileSelectionSource,
|
||||||
|
effective: crate::ResolvedConfigJson,
|
||||||
|
logs_directory: std::path::PathBuf,
|
||||||
|
settings: ksp_logging_lib::LoggingSettings,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ResolvedLoggingConfig {
|
||||||
|
/// Returns the logical Config document identifier used by this runtime configuration.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn file_id(&self) -> &crate::ConfigFileId {
|
||||||
|
return &self.file_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the physical source Config document path.
|
||||||
|
#[must_use]
|
||||||
|
pub fn source_path(&self) -> &std::path::Path {
|
||||||
|
return self.source_path.as_path();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the selected standard Logging profile identifier.
|
||||||
|
#[must_use]
|
||||||
|
pub fn profile_id(&self) -> &str {
|
||||||
|
return self.profile_id.as_str();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the source that selected the standard Logging profile.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn selection_source(&self) -> crate::ConfigProfileSelectionSource {
|
||||||
|
return self.selection_source;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the detailed environment-resolved effective Config view.
|
||||||
|
///
|
||||||
|
/// The real tree is available to legitimate runtime consumers and the safe tree is suitable for ordinary diagnostics.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn effective(&self) -> &crate::ResolvedConfigJson {
|
||||||
|
return &self.effective;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the validated real Logging root directory.
|
||||||
|
///
|
||||||
|
/// Relative Config values are anchored to the process current working directory when this adapter runs. Absolute Config values are preserved.
|
||||||
|
#[must_use]
|
||||||
|
pub fn logs_directory(&self) -> &std::path::Path {
|
||||||
|
return self.logs_directory.as_path();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the mapped runtime Logging settings.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn settings(&self) -> &ksp_logging_lib::LoggingSettings {
|
||||||
|
return &self.settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Consumes this resolved Config and returns the mapped runtime Logging settings.
|
||||||
|
#[must_use]
|
||||||
|
pub fn into_settings(self) -> ksp_logging_lib::LoggingSettings {
|
||||||
|
return self.settings;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Debug for ResolvedLoggingConfig {
|
||||||
|
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
return formatter
|
||||||
|
.debug_struct("ResolvedLoggingConfig")
|
||||||
|
.field("file_id", &self.file_id)
|
||||||
|
.field("source_path", &self.source_path)
|
||||||
|
.field("profile_id", &self.profile_id)
|
||||||
|
.field("selection_source", &self.selection_source)
|
||||||
|
.field("effective", &self.effective)
|
||||||
|
.finish_non_exhaustive();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl crate::ConfigDocumentEngine {
|
||||||
|
/// Loads the standard Logging document, selects a profile, resolves environment placeholders and maps the effective result to `LoggingSettings`.
|
||||||
|
///
|
||||||
|
/// `requested_profile = None` uses the document `default_profile`; `Some(profile_id)` requests an explicit profile. Source JSON validation remains distinct
|
||||||
|
/// from effective runtime validation: an environment value that resolves to an invalid Logging setting returns
|
||||||
|
/// [`crate::ERROR_CODE_EFFECTIVE_CONFIG_INVALID`] and does not silently fall back to the placeholder fallback.
|
||||||
|
pub fn load_resolved_logging_config(
|
||||||
|
&self,
|
||||||
|
requested_profile: std::option::Option<&str>,
|
||||||
|
environment: &crate::ConfigEnvironment,
|
||||||
|
) -> ksp_core_lib::Result<ResolvedLoggingConfig> {
|
||||||
|
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(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let profile = self.load_resolved_profile(&file_id, requested_profile);
|
||||||
|
let profile = match profile {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
return resolve_logging_profile(&profile, environment);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
struct EffectiveLoggingSource {
|
||||||
|
format_version: u32,
|
||||||
|
logs_directory: String,
|
||||||
|
profile_id: String,
|
||||||
|
default_filter: String,
|
||||||
|
span_events: String,
|
||||||
|
console: EffectiveConsoleSource,
|
||||||
|
files: std::vec::Vec<EffectiveFileSource>,
|
||||||
|
target_filters: std::vec::Vec<EffectiveTargetFilterSource>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
struct EffectiveConsoleSource {
|
||||||
|
enabled: bool,
|
||||||
|
output: String,
|
||||||
|
ansi: bool,
|
||||||
|
format: String,
|
||||||
|
filter: EffectiveOutputFilterSource,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
struct EffectiveFileSource {
|
||||||
|
output_id: String,
|
||||||
|
enabled: bool,
|
||||||
|
path: String,
|
||||||
|
rotation: String,
|
||||||
|
format: String,
|
||||||
|
ansi: bool,
|
||||||
|
filter: EffectiveOutputFilterSource,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
struct EffectiveOutputFilterSource {
|
||||||
|
level: String,
|
||||||
|
targets: std::vec::Vec<String>,
|
||||||
|
domains: std::vec::Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
struct EffectiveTargetFilterSource {
|
||||||
|
target_prefix: String,
|
||||||
|
level: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_logging_profile(profile: &crate::ResolvedConfigProfile, environment: &crate::ConfigEnvironment) -> ksp_core_lib::Result<ResolvedLoggingConfig> {
|
||||||
|
let effective = profile.resolve_effective_environment_detailed(environment);
|
||||||
|
let effective = match effective {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let sensitivity_validation = validate_logging_sensitivity(profile, &effective);
|
||||||
|
if let std::result::Result::Err(error) = sensitivity_validation {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
let source = serde_json::from_value::<EffectiveLoggingSource>(effective.value().clone());
|
||||||
|
let source = match source {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => {
|
||||||
|
return std::result::Result::Err(
|
||||||
|
effective_error(profile, "effective Logging Config cannot be decoded into the runtime adapter contract").with_source(error),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
if source.format_version != 1 {
|
||||||
|
return std::result::Result::Err(effective_error(profile, "effective Logging format_version is unsupported"));
|
||||||
|
}
|
||||||
|
if source.profile_id != profile.profile_id() {
|
||||||
|
return std::result::Result::Err(effective_error(profile, "effective Logging profile_id differs from the selected source profile"));
|
||||||
|
}
|
||||||
|
let safe_logs_directory = safe_string_at(effective.safe_value(), "/logs_directory");
|
||||||
|
let logs_directory = resolve_logs_directory(source.logs_directory.as_str(), safe_logs_directory.as_str(), profile);
|
||||||
|
let logs_directory = match logs_directory {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let default_filter = map_level(source.default_filter.as_str(), "default_filter", profile);
|
||||||
|
let default_filter = match default_filter {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let span_events = map_span_events(source.span_events.as_str(), profile);
|
||||||
|
let span_events = match span_events {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let console = map_console(source.console, profile);
|
||||||
|
let console = match console {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let files = map_files(source.files, logs_directory.as_path(), profile);
|
||||||
|
let files = match files {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let mut settings = ksp_logging_lib::LoggingSettings::new(default_filter, span_events, std::option::Option::Some(console), files);
|
||||||
|
for target_filter in source.target_filters {
|
||||||
|
let level = map_level(target_filter.level.as_str(), "target_filters.level", profile);
|
||||||
|
let level = match level {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
settings = settings.with_target_filter(ksp_logging_lib::TargetFilter::new(target_filter.target_prefix, level));
|
||||||
|
}
|
||||||
|
let validation = settings.validate();
|
||||||
|
if let std::result::Result::Err(error) = validation {
|
||||||
|
return std::result::Result::Err(
|
||||||
|
effective_error(profile, "effective Logging settings fail the Logging runtime contract")
|
||||||
|
.with_context("logging_error_domain", error.code().domain())
|
||||||
|
.with_context("logging_error_code", error.code().code()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(ResolvedLoggingConfig {
|
||||||
|
file_id: profile.file_id().clone(),
|
||||||
|
source_path: profile.path().to_path_buf(),
|
||||||
|
profile_id: profile.profile_id().to_owned(),
|
||||||
|
selection_source: profile.selection_source(),
|
||||||
|
effective,
|
||||||
|
logs_directory,
|
||||||
|
settings,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_logging_sensitivity(profile: &crate::ResolvedConfigProfile, effective: &crate::ResolvedConfigJson) -> ksp_core_lib::Result<()> {
|
||||||
|
if effective.sensitivity().is_secret() {
|
||||||
|
return std::result::Result::Err(effective_error(profile, "standard Logging configuration must not consume Secret environment values"));
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map_console(source: EffectiveConsoleSource, profile: &crate::ResolvedConfigProfile) -> ksp_core_lib::Result<ksp_logging_lib::ConsoleSettings> {
|
||||||
|
let output = match source.output.as_str() {
|
||||||
|
"stdout" => ksp_logging_lib::ConsoleOutput::Stdout,
|
||||||
|
"stderr" => ksp_logging_lib::ConsoleOutput::Stderr,
|
||||||
|
_ => return std::result::Result::Err(effective_field_error(profile, "console.output", "effective Logging console output is unsupported")),
|
||||||
|
};
|
||||||
|
let format = map_format(source.format.as_str(), "console.format", profile);
|
||||||
|
let format = match format {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let filter = map_output_filter(source.filter, "console.filter", profile);
|
||||||
|
let filter = match filter {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
return std::result::Result::Ok(ksp_logging_lib::ConsoleSettings::new(source.enabled, output, source.ansi, format, filter));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map_files(
|
||||||
|
sources: std::vec::Vec<EffectiveFileSource>,
|
||||||
|
logs_directory: &std::path::Path,
|
||||||
|
profile: &crate::ResolvedConfigProfile,
|
||||||
|
) -> ksp_core_lib::Result<std::vec::Vec<ksp_logging_lib::FileSettings>> {
|
||||||
|
let mut files = std::vec::Vec::<ksp_logging_lib::FileSettings>::with_capacity(sources.len());
|
||||||
|
for source in sources {
|
||||||
|
if !relative_file_path_is_valid(source.path.as_str()) {
|
||||||
|
return std::result::Result::Err(
|
||||||
|
effective_field_error(profile, "files.path", "effective Logging file path must stay relative to logs_directory without traversal")
|
||||||
|
.with_context("output_id", source.output_id.as_str()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let file_path = std::path::Path::new(source.path.as_str());
|
||||||
|
let file_name = match file_path.file_name().and_then(std::ffi::OsStr::to_str) {
|
||||||
|
std::option::Option::Some(value) if !value.is_empty() => value.to_owned(),
|
||||||
|
_ => return std::result::Result::Err(effective_field_error(profile, "files.path", "effective Logging file path has no UTF-8 file name")),
|
||||||
|
};
|
||||||
|
let relative_directory = match file_path.parent() {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => std::path::Path::new(""),
|
||||||
|
};
|
||||||
|
let directory = logs_directory.join(relative_directory);
|
||||||
|
let rotation = map_rotation(source.rotation.as_str(), profile);
|
||||||
|
let rotation = match rotation {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let format = map_format(source.format.as_str(), "files.format", profile);
|
||||||
|
let format = match format {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let filter = map_output_filter(source.filter, "files.filter", profile);
|
||||||
|
let filter = match filter {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let file = ksp_logging_lib::FileSettings::new(source.output_id, source.enabled, directory, file_name, rotation, format, filter).with_ansi(source.ansi);
|
||||||
|
files.push(file);
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(files);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map_output_filter(
|
||||||
|
source: EffectiveOutputFilterSource,
|
||||||
|
field: &'static str,
|
||||||
|
profile: &crate::ResolvedConfigProfile,
|
||||||
|
) -> ksp_core_lib::Result<ksp_logging_lib::OutputFilter> {
|
||||||
|
let level = map_level(source.level.as_str(), field, profile);
|
||||||
|
let level = match level {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
return std::result::Result::Ok(ksp_logging_lib::OutputFilter::new(level, source.targets, source.domains));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map_level(value: &str, field: &'static str, profile: &crate::ResolvedConfigProfile) -> ksp_core_lib::Result<ksp_logging_lib::LogFilterLevel> {
|
||||||
|
return match value {
|
||||||
|
"off" => std::result::Result::Ok(ksp_logging_lib::LogFilterLevel::Off),
|
||||||
|
"error" => std::result::Result::Ok(ksp_logging_lib::LogFilterLevel::Error),
|
||||||
|
"warn" => std::result::Result::Ok(ksp_logging_lib::LogFilterLevel::Warn),
|
||||||
|
"info" => std::result::Result::Ok(ksp_logging_lib::LogFilterLevel::Info),
|
||||||
|
"debug" => std::result::Result::Ok(ksp_logging_lib::LogFilterLevel::Debug),
|
||||||
|
"trace" => std::result::Result::Ok(ksp_logging_lib::LogFilterLevel::Trace),
|
||||||
|
_ => std::result::Result::Err(effective_field_error(profile, field, "effective Logging level is unsupported")),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map_span_events(value: &str, profile: &crate::ResolvedConfigProfile) -> ksp_core_lib::Result<ksp_logging_lib::SpanEvents> {
|
||||||
|
return match value {
|
||||||
|
"off" => std::result::Result::Ok(ksp_logging_lib::SpanEvents::Off),
|
||||||
|
"new_and_close" => std::result::Result::Ok(ksp_logging_lib::SpanEvents::NewAndClose),
|
||||||
|
"full" => std::result::Result::Ok(ksp_logging_lib::SpanEvents::Full),
|
||||||
|
_ => std::result::Result::Err(effective_field_error(profile, "span_events", "effective Logging span_events value is unsupported")),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map_format(value: &str, field: &'static str, profile: &crate::ResolvedConfigProfile) -> ksp_core_lib::Result<ksp_logging_lib::LogFormat> {
|
||||||
|
return match value {
|
||||||
|
"human" => std::result::Result::Ok(ksp_logging_lib::LogFormat::Human),
|
||||||
|
"compact" => std::result::Result::Ok(ksp_logging_lib::LogFormat::Compact),
|
||||||
|
"pretty" => std::result::Result::Ok(ksp_logging_lib::LogFormat::Pretty),
|
||||||
|
"json" => std::result::Result::Ok(ksp_logging_lib::LogFormat::Json),
|
||||||
|
_ => std::result::Result::Err(effective_field_error(profile, field, "effective Logging format is unsupported")),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map_rotation(value: &str, profile: &crate::ResolvedConfigProfile) -> ksp_core_lib::Result<ksp_logging_lib::FileRotation> {
|
||||||
|
return match value {
|
||||||
|
"never" => std::result::Result::Ok(ksp_logging_lib::FileRotation::Never),
|
||||||
|
"hourly" => std::result::Result::Ok(ksp_logging_lib::FileRotation::Hourly),
|
||||||
|
"daily" => std::result::Result::Ok(ksp_logging_lib::FileRotation::Daily),
|
||||||
|
_ => std::result::Result::Err(effective_field_error(profile, "files.rotation", "effective Logging rotation is unsupported")),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_logs_directory(value: &str, safe_value: &str, profile: &crate::ResolvedConfigProfile) -> ksp_core_lib::Result<std::path::PathBuf> {
|
||||||
|
if value.trim().is_empty() {
|
||||||
|
return std::result::Result::Err(
|
||||||
|
effective_field_error(profile, "logs_directory", "effective Logging logs_directory must not be empty").with_context("safe_value", safe_value),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let configured = std::path::PathBuf::from(value);
|
||||||
|
let resolved = if configured.is_absolute() {
|
||||||
|
configured
|
||||||
|
} else {
|
||||||
|
let current_directory = std::env::current_dir();
|
||||||
|
let current_directory = match current_directory {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => {
|
||||||
|
return std::result::Result::Err(
|
||||||
|
effective_field_error(profile, "logs_directory", "process current working directory cannot be resolved").with_source(error),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
current_directory.join(configured)
|
||||||
|
};
|
||||||
|
let metadata = std::fs::metadata(resolved.as_path());
|
||||||
|
match metadata {
|
||||||
|
std::result::Result::Ok(value) if !value.is_dir() => {
|
||||||
|
return std::result::Result::Err(
|
||||||
|
effective_field_error(profile, "logs_directory", "effective Logging logs_directory resolves to an existing non-directory path")
|
||||||
|
.with_context("safe_value", safe_value),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
std::result::Result::Ok(_) => {},
|
||||||
|
std::result::Result::Err(error) if error.kind() == std::io::ErrorKind::NotFound => {},
|
||||||
|
std::result::Result::Err(error) => {
|
||||||
|
return std::result::Result::Err(
|
||||||
|
effective_field_error(profile, "logs_directory", "effective Logging logs_directory cannot be inspected")
|
||||||
|
.with_context("safe_value", safe_value)
|
||||||
|
.with_source(error),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(resolved);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn relative_file_path_is_valid(value: &str) -> bool {
|
||||||
|
let path = std::path::Path::new(value);
|
||||||
|
if path.is_absolute() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let mut has_normal_component = false;
|
||||||
|
for component in path.components() {
|
||||||
|
match component {
|
||||||
|
std::path::Component::Normal(_) => has_normal_component = true,
|
||||||
|
std::path::Component::CurDir | std::path::Component::ParentDir | std::path::Component::RootDir | std::path::Component::Prefix(_) => return false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return has_normal_component;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn safe_string_at(value: &serde_json::Value, pointer: &str) -> String {
|
||||||
|
return match value.pointer(pointer).and_then(serde_json::Value::as_str) {
|
||||||
|
std::option::Option::Some(value) => value.to_owned(),
|
||||||
|
std::option::Option::None => "<unavailable>".to_owned(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn effective_field_error(profile: &crate::ResolvedConfigProfile, field: &'static str, reason: &'static str) -> ksp_core_lib::Error {
|
||||||
|
return effective_error(profile, reason).with_context("field", field);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn effective_error(profile: &crate::ResolvedConfigProfile, reason: &'static str) -> ksp_core_lib::Error {
|
||||||
|
return ksp_core_lib::Error::new(crate::ERROR_CODE_EFFECTIVE_CONFIG_INVALID, "effective Config cannot be mapped to the requested runtime contract")
|
||||||
|
.with_context("file_id", profile.file_id().as_str())
|
||||||
|
.with_context("profile_id", profile.profile_id())
|
||||||
|
.with_context("reason", reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "../unit_tests/logging.rs"]
|
||||||
|
mod tests;
|
||||||
1008
crates/ksp-config-lib/src/management.rs
Normal file
1008
crates/ksp-config-lib/src/management.rs
Normal file
File diff suppressed because it is too large
Load Diff
119
crates/ksp-config-lib/src/persistence.rs
Normal file
119
crates/ksp-config-lib/src/persistence.rs
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
// file: crates/ksp-config-lib/src/persistence.rs
|
||||||
|
// version: 2
|
||||||
|
|
||||||
|
static NEXT_TEMPORARY_FILE_ID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
|
||||||
|
|
||||||
|
pub(crate) fn atomic_write(path: &std::path::Path, content: &[u8]) -> ksp_core_lib::Result<()> {
|
||||||
|
return atomic_write_with_policy(path, content, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn atomic_write_private(path: &std::path::Path, content: &[u8]) -> ksp_core_lib::Result<()> {
|
||||||
|
return atomic_write_with_policy(path, content, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn atomic_write_with_policy(path: &std::path::Path, content: &[u8], private_when_new: bool) -> ksp_core_lib::Result<()> {
|
||||||
|
let parent = match path.parent() {
|
||||||
|
std::option::Option::Some(value) if !value.as_os_str().is_empty() => value,
|
||||||
|
_ => std::path::Path::new("."),
|
||||||
|
};
|
||||||
|
let filename = match path.file_name().and_then(std::ffi::OsStr::to_str) {
|
||||||
|
std::option::Option::Some(value) if !value.is_empty() => value,
|
||||||
|
_ => return std::result::Result::Err(persistence_error(path, "managed Config path has no UTF-8 file name")),
|
||||||
|
};
|
||||||
|
let existing_permissions = destination_permissions(path);
|
||||||
|
let existing_permissions = match existing_permissions {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let temporary_id = NEXT_TEMPORARY_FILE_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||||
|
let temporary_name = format!(".{filename}.ksp-tmp-{}-{temporary_id}", std::process::id());
|
||||||
|
let temporary_path = parent.join(temporary_name);
|
||||||
|
let opened = std::fs::OpenOptions::new().write(true).create_new(true).open(temporary_path.as_path());
|
||||||
|
let mut file = match opened {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(persistence_io_error(path, "temporary Config file cannot be created", error)),
|
||||||
|
};
|
||||||
|
let permissions = apply_temporary_permissions(&file, existing_permissions, private_when_new);
|
||||||
|
if let std::result::Result::Err(error) = permissions {
|
||||||
|
cleanup_temporary_file(temporary_path.as_path());
|
||||||
|
return std::result::Result::Err(persistence_io_error(path, "temporary Config file permissions cannot be applied", error));
|
||||||
|
}
|
||||||
|
let write = std::io::Write::write_all(&mut file, content);
|
||||||
|
if let std::result::Result::Err(error) = write {
|
||||||
|
cleanup_temporary_file(temporary_path.as_path());
|
||||||
|
return std::result::Result::Err(persistence_io_error(path, "temporary Config file cannot be written", error));
|
||||||
|
}
|
||||||
|
let sync = file.sync_all();
|
||||||
|
if let std::result::Result::Err(error) = sync {
|
||||||
|
cleanup_temporary_file(temporary_path.as_path());
|
||||||
|
return std::result::Result::Err(persistence_io_error(path, "temporary Config file cannot be synchronized", error));
|
||||||
|
}
|
||||||
|
drop(file);
|
||||||
|
let rename = std::fs::rename(temporary_path.as_path(), path);
|
||||||
|
if let std::result::Result::Err(error) = rename {
|
||||||
|
cleanup_temporary_file(temporary_path.as_path());
|
||||||
|
return std::result::Result::Err(persistence_io_error(path, "atomic Config file replacement failed", error));
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn destination_permissions(path: &std::path::Path) -> ksp_core_lib::Result<std::option::Option<std::fs::Permissions>> {
|
||||||
|
let metadata = std::fs::metadata(path);
|
||||||
|
return match metadata {
|
||||||
|
std::result::Result::Ok(value) => std::result::Result::Ok(std::option::Option::Some(value.permissions())),
|
||||||
|
std::result::Result::Err(error) if error.kind() == std::io::ErrorKind::NotFound => std::result::Result::Ok(std::option::Option::None),
|
||||||
|
std::result::Result::Err(error) => {
|
||||||
|
std::result::Result::Err(persistence_io_error(path, "managed Config file metadata cannot be read before replacement", error))
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply_temporary_permissions(
|
||||||
|
file: &std::fs::File,
|
||||||
|
existing_permissions: std::option::Option<std::fs::Permissions>,
|
||||||
|
private_when_new: bool,
|
||||||
|
) -> std::io::Result<()> {
|
||||||
|
if let std::option::Option::Some(permissions) = existing_permissions {
|
||||||
|
return file.set_permissions(permissions);
|
||||||
|
}
|
||||||
|
return apply_new_file_permissions(file, private_when_new);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
fn apply_new_file_permissions(file: &std::fs::File, private_when_new: bool) -> std::io::Result<()> {
|
||||||
|
if private_when_new {
|
||||||
|
let permissions = <std::fs::Permissions as std::os::unix::fs::PermissionsExt>::from_mode(0o600);
|
||||||
|
return file.set_permissions(permissions);
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(unix))]
|
||||||
|
fn apply_new_file_permissions(_file: &std::fs::File, _private_when_new: bool) -> std::io::Result<()> {
|
||||||
|
return std::result::Result::Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cleanup_temporary_file(path: &std::path::Path) {
|
||||||
|
let removal = std::fs::remove_file(path);
|
||||||
|
if let std::result::Result::Err(error) = removal
|
||||||
|
&& error.kind() != std::io::ErrorKind::NotFound
|
||||||
|
{
|
||||||
|
ksp_logging_lib::warn!(
|
||||||
|
target: "ksp-config-lib",
|
||||||
|
domain = "config.persistence",
|
||||||
|
path = %path.to_string_lossy(),
|
||||||
|
error = %error,
|
||||||
|
"unable to cleanup temporary Config file"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn persistence_error(path: &std::path::Path, reason: &'static str) -> ksp_core_lib::Error {
|
||||||
|
return ksp_core_lib::Error::new(crate::ERROR_CODE_PERSISTENCE_WRITE_FAILED, "Config persistence failed")
|
||||||
|
.with_context("path", path.to_string_lossy().into_owned())
|
||||||
|
.with_context("reason", reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn persistence_io_error(path: &std::path::Path, reason: &'static str, source: std::io::Error) -> ksp_core_lib::Error {
|
||||||
|
return persistence_error(path, reason).with_source(source);
|
||||||
|
}
|
||||||
292
crates/ksp-config-lib/src/profile.rs
Normal file
292
crates/ksp-config-lib/src/profile.rs
Normal file
@@ -0,0 +1,292 @@
|
|||||||
|
// file: crates/ksp-config-lib/src/profile.rs
|
||||||
|
// version: 4
|
||||||
|
|
||||||
|
/// Origin of one top-level value in a resolved standard Config profile.
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub enum ConfigValueOrigin {
|
||||||
|
/// Value comes from the global section of the specialized document.
|
||||||
|
Global,
|
||||||
|
/// Value comes from the selected profile object.
|
||||||
|
Profile,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Source that selected the effective profile.
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub enum ConfigProfileSelectionSource {
|
||||||
|
/// The document's autonomous `default_profile` selected the profile.
|
||||||
|
DefaultProfile,
|
||||||
|
/// A caller explicitly requested the profile by `profile_id`.
|
||||||
|
Explicit,
|
||||||
|
/// A composite document selected the referenced standard document profile.
|
||||||
|
Composite,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validated standard document resolved to one profile while retaining global/profile provenance.
|
||||||
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
|
pub struct ResolvedConfigProfile {
|
||||||
|
file_id: crate::ConfigFileId,
|
||||||
|
path: std::path::PathBuf,
|
||||||
|
profile_id: String,
|
||||||
|
selection_source: ConfigProfileSelectionSource,
|
||||||
|
globals: serde_json::Map<String, serde_json::Value>,
|
||||||
|
profile: serde_json::Map<String, serde_json::Value>,
|
||||||
|
effective: serde_json::Map<String, serde_json::Value>,
|
||||||
|
origins: std::collections::BTreeMap<String, ConfigValueOrigin>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ResolvedConfigProfile {
|
||||||
|
/// Returns the logical document identifier from which this profile was resolved.
|
||||||
|
#[must_use]
|
||||||
|
pub fn file_id(&self) -> &crate::ConfigFileId {
|
||||||
|
return &self.file_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the physical path of the validated source document.
|
||||||
|
#[must_use]
|
||||||
|
pub fn path(&self) -> &std::path::Path {
|
||||||
|
return self.path.as_path();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the selected unique profile identifier.
|
||||||
|
#[must_use]
|
||||||
|
pub fn profile_id(&self) -> &str {
|
||||||
|
return self.profile_id.as_str();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns whether selection came from `default_profile` or an explicit caller request.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn selection_source(&self) -> ConfigProfileSelectionSource {
|
||||||
|
return self.selection_source;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns document-global values, excluding the reserved `default_profile` and `profiles` keys.
|
||||||
|
#[must_use]
|
||||||
|
pub fn globals(&self) -> &serde_json::Map<String, serde_json::Value> {
|
||||||
|
return &self.globals;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the selected profile object including its `profile_id`.
|
||||||
|
#[must_use]
|
||||||
|
pub fn profile(&self) -> &serde_json::Map<String, serde_json::Value> {
|
||||||
|
return &self.profile;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns a deterministic top-level effective view in which selected profile keys override same-named global keys.
|
||||||
|
#[must_use]
|
||||||
|
pub fn effective(&self) -> &serde_json::Map<String, serde_json::Value> {
|
||||||
|
return &self.effective;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the top-level provenance for an effective key.
|
||||||
|
#[must_use]
|
||||||
|
pub fn origin(&self, key: &str) -> std::option::Option<ConfigValueOrigin> {
|
||||||
|
return self.origins.get(key).copied();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolves environment placeholders in the effective view and returns only the real runtime map.
|
||||||
|
///
|
||||||
|
/// Use [`Self::resolve_effective_environment_detailed`] when safe value, sensitivity and environment provenance are required. Global/Profile provenance on
|
||||||
|
/// this source profile remains unchanged in both cases.
|
||||||
|
pub fn resolve_effective_environment(&self, environment: &crate::ConfigEnvironment) -> ksp_core_lib::Result<serde_json::Map<String, serde_json::Value>> {
|
||||||
|
let resolved = self.resolve_effective_environment_detailed(environment);
|
||||||
|
let resolved = match resolved {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
return match resolved.value() {
|
||||||
|
serde_json::Value::Object(value) => std::result::Result::Ok(value.clone()),
|
||||||
|
_ => std::result::Result::Err(ksp_core_lib::Error::new(
|
||||||
|
crate::ERROR_CODE_DOCUMENT_SEMANTIC_INVALID,
|
||||||
|
"resolved Config profile effective view changed JSON shape",
|
||||||
|
)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolves environment placeholders while preserving real/safe JSON trees, strongest sensitivity and JSON-Pointer environment provenance.
|
||||||
|
///
|
||||||
|
/// Top-level Global/Profile provenance remains available through [`Self::origin`]; the returned value adds literal/process/`.env`/fallback provenance for
|
||||||
|
/// the environment-resolution stage.
|
||||||
|
pub fn resolve_effective_environment_detailed(&self, environment: &crate::ConfigEnvironment) -> ksp_core_lib::Result<crate::ResolvedConfigJson> {
|
||||||
|
return environment.resolve_json_detailed(&serde_json::Value::Object(self.effective.clone()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl crate::ConfigDocumentEngine {
|
||||||
|
/// Loads, validates and resolves one standard Config document to its default or explicitly requested profile.
|
||||||
|
///
|
||||||
|
/// Passing `None` selects the autonomous `default_profile` declared by the document. Passing `Some(profile_id)` selects that profile explicitly.
|
||||||
|
/// Environment interpolation is intentionally not applied implicitly by profile selection. Call `ResolvedConfigProfile::resolve_effective_environment` for
|
||||||
|
/// a real runtime map or `ResolvedConfigProfile::resolve_effective_environment_detailed` when safe value, sensitivity and provenance are also required.
|
||||||
|
pub fn load_resolved_profile(
|
||||||
|
&self,
|
||||||
|
file_id: &crate::ConfigFileId,
|
||||||
|
requested_profile: std::option::Option<&str>,
|
||||||
|
) -> ksp_core_lib::Result<ResolvedConfigProfile> {
|
||||||
|
let document = self.load_validated_document(file_id);
|
||||||
|
let document = match document {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let source = match requested_profile {
|
||||||
|
std::option::Option::Some(_) => ConfigProfileSelectionSource::Explicit,
|
||||||
|
std::option::Option::None => ConfigProfileSelectionSource::DefaultProfile,
|
||||||
|
};
|
||||||
|
return resolve_document_profile(&document, requested_profile, source);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn load_resolved_profile_with_source(
|
||||||
|
engine: &crate::ConfigDocumentEngine,
|
||||||
|
file_id: &crate::ConfigFileId,
|
||||||
|
requested_profile: std::option::Option<&str>,
|
||||||
|
explicit_source: ConfigProfileSelectionSource,
|
||||||
|
) -> ksp_core_lib::Result<ResolvedConfigProfile> {
|
||||||
|
let document = engine.load_validated_document(file_id);
|
||||||
|
let document = match document {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let source = match requested_profile {
|
||||||
|
std::option::Option::Some(_) => explicit_source,
|
||||||
|
std::option::Option::None => ConfigProfileSelectionSource::DefaultProfile,
|
||||||
|
};
|
||||||
|
return resolve_document_profile(&document, requested_profile, source);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn validate_document_profile_contract(document: &crate::ConfigJsonDocument) -> ksp_core_lib::Result<()> {
|
||||||
|
let root = match document.value().as_object() {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => return std::result::Result::Ok(()),
|
||||||
|
};
|
||||||
|
let default_profile = root.get("default_profile");
|
||||||
|
let profiles = root.get("profiles");
|
||||||
|
if default_profile.is_none() && profiles.is_none() {
|
||||||
|
return std::result::Result::Ok(());
|
||||||
|
}
|
||||||
|
let default_profile = match default_profile.and_then(serde_json::Value::as_str) {
|
||||||
|
std::option::Option::Some(value) if !value.trim().is_empty() => value,
|
||||||
|
_ => return std::result::Result::Err(profile_semantic_error(document, "default_profile must identify a non-empty profile_id")),
|
||||||
|
};
|
||||||
|
let profiles = match profiles.and_then(serde_json::Value::as_array) {
|
||||||
|
std::option::Option::Some(value) if !value.is_empty() => value,
|
||||||
|
_ => return std::result::Result::Err(profile_semantic_error(document, "profiles must contain at least one profile object")),
|
||||||
|
};
|
||||||
|
let mut ids = std::collections::BTreeSet::<String>::new();
|
||||||
|
let mut default_found = false;
|
||||||
|
for (index, profile) in profiles.iter().enumerate() {
|
||||||
|
let object = match profile.as_object() {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => {
|
||||||
|
return std::result::Result::Err(
|
||||||
|
profile_semantic_error(document, "profile entry must be an object").with_context("profile_index", index.to_string()),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let profile_id = match object.get("profile_id").and_then(serde_json::Value::as_str) {
|
||||||
|
std::option::Option::Some(value) if !value.trim().is_empty() => value,
|
||||||
|
_ => {
|
||||||
|
return std::result::Result::Err(
|
||||||
|
profile_semantic_error(document, "profile entry must declare a non-empty profile_id").with_context("profile_index", index.to_string()),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
if ids.contains(profile_id) {
|
||||||
|
return std::result::Result::Err(
|
||||||
|
profile_semantic_error(document, "profile_id values must be unique")
|
||||||
|
.with_context("profile_index", index.to_string())
|
||||||
|
.with_context("profile_id", profile_id),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
ids.insert(profile_id.to_owned());
|
||||||
|
if profile_id == default_profile {
|
||||||
|
default_found = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !default_found {
|
||||||
|
return std::result::Result::Err(
|
||||||
|
profile_semantic_error(document, "default_profile must reference an existing profile_id").with_context("default_profile", default_profile),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_document_profile(
|
||||||
|
document: &crate::ConfigJsonDocument,
|
||||||
|
requested_profile: std::option::Option<&str>,
|
||||||
|
explicit_source: ConfigProfileSelectionSource,
|
||||||
|
) -> ksp_core_lib::Result<ResolvedConfigProfile> {
|
||||||
|
let root = match document.value().as_object() {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => return std::result::Result::Err(profile_semantic_error(document, "profile resolution requires an object document")),
|
||||||
|
};
|
||||||
|
let default_profile = match root.get("default_profile").and_then(serde_json::Value::as_str) {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => return std::result::Result::Err(profile_semantic_error(document, "profile resolution requires default_profile")),
|
||||||
|
};
|
||||||
|
let (selected_profile, selection_source) = match requested_profile {
|
||||||
|
std::option::Option::Some(value) => (value, explicit_source),
|
||||||
|
std::option::Option::None => (default_profile, ConfigProfileSelectionSource::DefaultProfile),
|
||||||
|
};
|
||||||
|
let profiles = match root.get("profiles").and_then(serde_json::Value::as_array) {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => return std::result::Result::Err(profile_semantic_error(document, "profile resolution requires profiles")),
|
||||||
|
};
|
||||||
|
let mut selected: std::option::Option<&serde_json::Map<String, serde_json::Value>> = std::option::Option::None;
|
||||||
|
for profile in profiles {
|
||||||
|
let object = match profile.as_object() {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => continue,
|
||||||
|
};
|
||||||
|
if object.get("profile_id").and_then(serde_json::Value::as_str) == std::option::Option::Some(selected_profile) {
|
||||||
|
selected = std::option::Option::Some(object);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let selected = match selected {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => {
|
||||||
|
return std::result::Result::Err(
|
||||||
|
ksp_core_lib::Error::new(crate::ERROR_CODE_PROFILE_NOT_FOUND, "requested Config profile does not exist")
|
||||||
|
.with_context("file_id", document.file_id().as_str())
|
||||||
|
.with_context("path", document.path().to_string_lossy().into_owned())
|
||||||
|
.with_context("profile_id", selected_profile),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let mut globals = serde_json::Map::<String, serde_json::Value>::new();
|
||||||
|
let mut effective = serde_json::Map::<String, serde_json::Value>::new();
|
||||||
|
let mut origins = std::collections::BTreeMap::<String, ConfigValueOrigin>::new();
|
||||||
|
for (key, value) in root {
|
||||||
|
if key != "default_profile" && key != "profiles" {
|
||||||
|
globals.insert(key.clone(), value.clone());
|
||||||
|
effective.insert(key.clone(), value.clone());
|
||||||
|
origins.insert(key.clone(), ConfigValueOrigin::Global);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let profile = (*selected).clone();
|
||||||
|
for (key, value) in &profile {
|
||||||
|
effective.insert(key.clone(), value.clone());
|
||||||
|
origins.insert(key.clone(), ConfigValueOrigin::Profile);
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(ResolvedConfigProfile {
|
||||||
|
file_id: document.file_id().clone(),
|
||||||
|
path: document.path().to_path_buf(),
|
||||||
|
profile_id: selected_profile.to_owned(),
|
||||||
|
selection_source,
|
||||||
|
globals,
|
||||||
|
profile,
|
||||||
|
effective,
|
||||||
|
origins,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn profile_semantic_error(document: &crate::ConfigJsonDocument, reason: &'static str) -> ksp_core_lib::Error {
|
||||||
|
return ksp_core_lib::Error::new(crate::ERROR_CODE_DOCUMENT_SEMANTIC_INVALID, "Config document violates KSP profile invariants")
|
||||||
|
.with_context("file_id", document.file_id().as_str())
|
||||||
|
.with_context("path", document.path().to_string_lossy().into_owned())
|
||||||
|
.with_context("reason", reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "../unit_tests/profile.rs"]
|
||||||
|
mod tests;
|
||||||
408
crates/ksp-config-lib/src/registry.rs
Normal file
408
crates/ksp-config-lib/src/registry.rs
Normal file
@@ -0,0 +1,408 @@
|
|||||||
|
// file: crates/ksp-config-lib/src/registry.rs
|
||||||
|
// version: 3
|
||||||
|
|
||||||
|
/// Bootstrap argument used to replace a known Config filename mapping.
|
||||||
|
pub const ARG_FILE_MAP: &str = "--filemap";
|
||||||
|
/// Logical file identifier for the standard Logging configuration document.
|
||||||
|
pub const FILE_ID_STD_LOGGING: &str = "cfg.std.logging";
|
||||||
|
/// Logical file identifier for the standard Logging JSON Schema document.
|
||||||
|
pub const FILE_ID_SCHEMA_STD_LOGGING: &str = "schema.std.logging";
|
||||||
|
/// Logical file identifier for the generic composite JSON Schema document.
|
||||||
|
pub const FILE_ID_SCHEMA_COMPOSITE: &str = "schema.composite";
|
||||||
|
/// Default physical filename for the standard Logging configuration document.
|
||||||
|
pub const DEFAULT_STD_LOGGING_FILENAME: &str = "std.logging.json";
|
||||||
|
/// Default physical filename for the standard Logging JSON Schema document.
|
||||||
|
pub const DEFAULT_STD_LOGGING_SCHEMA_FILENAME: &str = "std.logging.schema.json";
|
||||||
|
/// Default physical filename for the generic composite JSON Schema document.
|
||||||
|
pub const DEFAULT_COMPOSITE_SCHEMA_FILENAME: &str = "composite.schema.json";
|
||||||
|
|
||||||
|
/// Stable logical identifier for a Config-managed file.
|
||||||
|
#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
|
||||||
|
pub struct ConfigFileId(String);
|
||||||
|
|
||||||
|
impl ConfigFileId {
|
||||||
|
/// Creates and validates a logical Config file identifier.
|
||||||
|
pub fn new(value: impl std::convert::Into<String>) -> ksp_core_lib::Result<Self> {
|
||||||
|
let value = value.into();
|
||||||
|
let validated = validate_file_id(value.as_str());
|
||||||
|
return match validated {
|
||||||
|
std::result::Result::Ok(()) => std::result::Result::Ok(Self(value)),
|
||||||
|
std::result::Result::Err(error) => std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the logical identifier as text.
|
||||||
|
#[must_use]
|
||||||
|
pub fn as_str(&self) -> &str {
|
||||||
|
return self.0.as_str();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Physical root category used to resolve a Config-managed file.
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub enum ConfigFileKind {
|
||||||
|
/// Runtime configuration document resolved below `cfgpath`.
|
||||||
|
Config,
|
||||||
|
/// JSON Schema document resolved below `schemapath`.
|
||||||
|
Schema,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Logical descriptor associating a stable file identifier with its physical filename, root category and optional validation schema.
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub struct ConfigFileDescriptor {
|
||||||
|
file_id: ConfigFileId,
|
||||||
|
kind: ConfigFileKind,
|
||||||
|
filename: std::path::PathBuf,
|
||||||
|
schema_file_id: std::option::Option<ConfigFileId>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ConfigFileDescriptor {
|
||||||
|
/// Returns the stable logical identifier.
|
||||||
|
#[must_use]
|
||||||
|
pub fn file_id(&self) -> &ConfigFileId {
|
||||||
|
return &self.file_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the root category used when resolving the file.
|
||||||
|
#[must_use]
|
||||||
|
pub fn kind(&self) -> ConfigFileKind {
|
||||||
|
return self.kind;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the relative physical filename currently mapped to the identifier.
|
||||||
|
#[must_use]
|
||||||
|
pub fn filename(&self) -> &std::path::Path {
|
||||||
|
return self.filename.as_path();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the logical schema identifier associated with this Config document when one is declared.
|
||||||
|
#[must_use]
|
||||||
|
pub fn schema_file_id(&self) -> std::option::Option<&ConfigFileId> {
|
||||||
|
return self.schema_file_id.as_ref();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn new(
|
||||||
|
file_id: &'static str,
|
||||||
|
kind: ConfigFileKind,
|
||||||
|
filename: &'static str,
|
||||||
|
schema_file_id: std::option::Option<&'static str>,
|
||||||
|
) -> ksp_core_lib::Result<Self> {
|
||||||
|
let file_id = ConfigFileId::new(file_id);
|
||||||
|
let file_id = match file_id {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let kind_validation = validate_kind_prefix(&file_id, kind);
|
||||||
|
match kind_validation {
|
||||||
|
std::result::Result::Ok(()) => {},
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
}
|
||||||
|
let filename = validate_relative_filename(&file_id, std::path::PathBuf::from(filename));
|
||||||
|
let filename = match filename {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let schema_file_id = parse_schema_file_id(&file_id, kind, schema_file_id);
|
||||||
|
return match schema_file_id {
|
||||||
|
std::result::Result::Ok(value) => std::result::Result::Ok(Self { file_id, kind, filename, schema_file_id: value }),
|
||||||
|
std::result::Result::Err(error) => std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Registry of KSP-known logical Config files and their replaceable physical filenames.
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub struct ConfigFileRegistry {
|
||||||
|
descriptors: std::collections::BTreeMap<ConfigFileId, ConfigFileDescriptor>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ConfigFileRegistry {
|
||||||
|
/// Creates the registry containing the KSP default file mappings known in the current release.
|
||||||
|
pub fn defaults() -> ksp_core_lib::Result<Self> {
|
||||||
|
let logging = ConfigFileDescriptor::new(
|
||||||
|
FILE_ID_STD_LOGGING,
|
||||||
|
ConfigFileKind::Config,
|
||||||
|
DEFAULT_STD_LOGGING_FILENAME,
|
||||||
|
std::option::Option::Some(FILE_ID_SCHEMA_STD_LOGGING),
|
||||||
|
);
|
||||||
|
let logging = match logging {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let logging_schema =
|
||||||
|
ConfigFileDescriptor::new(FILE_ID_SCHEMA_STD_LOGGING, ConfigFileKind::Schema, DEFAULT_STD_LOGGING_SCHEMA_FILENAME, std::option::Option::None);
|
||||||
|
let logging_schema = match logging_schema {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let composite_schema =
|
||||||
|
ConfigFileDescriptor::new(FILE_ID_SCHEMA_COMPOSITE, ConfigFileKind::Schema, DEFAULT_COMPOSITE_SCHEMA_FILENAME, std::option::Option::None);
|
||||||
|
let composite_schema = match composite_schema {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
return build_registry([logging, logging_schema, composite_schema]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates the default registry and applies repeatable `--filemap=<file_id>=<filename>` overrides from raw process arguments.
|
||||||
|
///
|
||||||
|
/// Unrelated arguments are ignored. A repeated mapping for the same known `file_id` is accepted and the last mapping wins.
|
||||||
|
pub fn from_args(args: &[std::ffi::OsString]) -> ksp_core_lib::Result<Self> {
|
||||||
|
let registry = Self::defaults();
|
||||||
|
let mut registry = match registry {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let mut index: usize = 0;
|
||||||
|
while index < args.len() {
|
||||||
|
let application = apply_file_map_argument(&mut registry, &args[index]);
|
||||||
|
match application {
|
||||||
|
std::result::Result::Ok(()) => {},
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
}
|
||||||
|
index += 1;
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(registry);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the descriptor associated with a known logical file identifier.
|
||||||
|
pub fn descriptor(&self, file_id: &ConfigFileId) -> ksp_core_lib::Result<&ConfigFileDescriptor> {
|
||||||
|
return match self.descriptors.get(file_id) {
|
||||||
|
std::option::Option::Some(descriptor) => std::result::Result::Ok(descriptor),
|
||||||
|
std::option::Option::None => std::result::Result::Err(unknown_file_id_error(file_id.as_str())),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolves a known logical file identifier below the bootstrap root selected by its descriptor kind.
|
||||||
|
pub fn resolve_path(&self, bootstrap: &crate::ConfigBootstrapOptions, file_id: &ConfigFileId) -> ksp_core_lib::Result<std::path::PathBuf> {
|
||||||
|
let descriptor = self.descriptor(file_id);
|
||||||
|
let descriptor = match descriptor {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let root = match descriptor.kind() {
|
||||||
|
ConfigFileKind::Config => bootstrap.cfg_path(),
|
||||||
|
ConfigFileKind::Schema => bootstrap.schema_path(),
|
||||||
|
};
|
||||||
|
return std::result::Result::Ok(root.join(descriptor.filename()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Replaces the physical filename of one known logical identifier while preserving its kind, schema association and logical identity.
|
||||||
|
pub fn with_filename_override(mut self, file_id: &ConfigFileId, filename: impl std::convert::Into<std::path::PathBuf>) -> ksp_core_lib::Result<Self> {
|
||||||
|
let update = self.set_filename_override(file_id, filename.into());
|
||||||
|
return match update {
|
||||||
|
std::result::Result::Ok(()) => std::result::Result::Ok(self),
|
||||||
|
std::result::Result::Err(error) => std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_filename_override(&mut self, file_id: &ConfigFileId, filename: std::path::PathBuf) -> ksp_core_lib::Result<()> {
|
||||||
|
let descriptor = self.descriptors.get(file_id);
|
||||||
|
let descriptor = match descriptor {
|
||||||
|
std::option::Option::Some(value) => value.clone(),
|
||||||
|
std::option::Option::None => return std::result::Result::Err(unknown_file_id_error(file_id.as_str())),
|
||||||
|
};
|
||||||
|
let filename = validate_relative_filename(file_id, filename);
|
||||||
|
let filename = match filename {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let updated = ConfigFileDescriptor {
|
||||||
|
file_id: descriptor.file_id.clone(),
|
||||||
|
kind: descriptor.kind,
|
||||||
|
filename,
|
||||||
|
schema_file_id: descriptor.schema_file_id.clone(),
|
||||||
|
};
|
||||||
|
self.descriptors.insert(file_id.clone(), updated);
|
||||||
|
return std::result::Result::Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn build_registry<const N: usize>(descriptors: [ConfigFileDescriptor; N]) -> ksp_core_lib::Result<ConfigFileRegistry> {
|
||||||
|
let mut registry = ConfigFileRegistry { descriptors: std::collections::BTreeMap::new() };
|
||||||
|
for descriptor in descriptors {
|
||||||
|
let file_id = descriptor.file_id.clone();
|
||||||
|
let duplicate_id = file_id.clone();
|
||||||
|
let previous = registry.descriptors.insert(file_id, descriptor);
|
||||||
|
if previous.is_some() {
|
||||||
|
return std::result::Result::Err(duplicate_file_id_error(duplicate_id.as_str()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let associations = validate_schema_associations(®istry);
|
||||||
|
return match associations {
|
||||||
|
std::result::Result::Ok(()) => std::result::Result::Ok(registry),
|
||||||
|
std::result::Result::Err(error) => std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_schema_file_id(
|
||||||
|
file_id: &ConfigFileId,
|
||||||
|
kind: ConfigFileKind,
|
||||||
|
schema_file_id: std::option::Option<&'static str>,
|
||||||
|
) -> ksp_core_lib::Result<std::option::Option<ConfigFileId>> {
|
||||||
|
return match (kind, schema_file_id) {
|
||||||
|
(ConfigFileKind::Schema, std::option::Option::Some(_)) => {
|
||||||
|
std::result::Result::Err(invalid_file_mapping_with_id_error(file_id.as_str(), "schema descriptors cannot declare another validation schema"))
|
||||||
|
},
|
||||||
|
(ConfigFileKind::Schema, std::option::Option::None) | (ConfigFileKind::Config, std::option::Option::None) => {
|
||||||
|
std::result::Result::Ok(std::option::Option::None)
|
||||||
|
},
|
||||||
|
(ConfigFileKind::Config, std::option::Option::Some(value)) => {
|
||||||
|
let schema_id = ConfigFileId::new(value);
|
||||||
|
match schema_id {
|
||||||
|
std::result::Result::Ok(schema_id) => {
|
||||||
|
if schema_id.as_str().starts_with("schema.") {
|
||||||
|
std::result::Result::Ok(std::option::Option::Some(schema_id))
|
||||||
|
} else {
|
||||||
|
std::result::Result::Err(invalid_file_mapping_with_id_error(
|
||||||
|
file_id.as_str(),
|
||||||
|
"validation schema file_id must use the schema namespace",
|
||||||
|
))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
std::result::Result::Err(error) => std::result::Result::Err(error),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_schema_associations(registry: &ConfigFileRegistry) -> ksp_core_lib::Result<()> {
|
||||||
|
for descriptor in registry.descriptors.values() {
|
||||||
|
let schema_file_id = match descriptor.schema_file_id() {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => continue,
|
||||||
|
};
|
||||||
|
let schema = registry.descriptors.get(schema_file_id);
|
||||||
|
let schema = match schema {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => {
|
||||||
|
return std::result::Result::Err(invalid_file_mapping_with_id_error(
|
||||||
|
descriptor.file_id().as_str(),
|
||||||
|
"validation schema file_id is not registered",
|
||||||
|
));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
if schema.kind() != ConfigFileKind::Schema {
|
||||||
|
return std::result::Result::Err(invalid_file_mapping_with_id_error(
|
||||||
|
descriptor.file_id().as_str(),
|
||||||
|
"validation schema descriptor must have schema kind",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply_file_map_argument(registry: &mut ConfigFileRegistry, argument: &std::ffi::OsStr) -> ksp_core_lib::Result<()> {
|
||||||
|
let text = match argument.to_str() {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => return std::result::Result::Ok(()),
|
||||||
|
};
|
||||||
|
if text == ARG_FILE_MAP {
|
||||||
|
return std::result::Result::Err(invalid_file_mapping_error("--filemap requires the inline form --filemap=<file_id>=<filename>"));
|
||||||
|
}
|
||||||
|
let prefix = "--filemap=";
|
||||||
|
let mapping = match text.strip_prefix(prefix) {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => return std::result::Result::Ok(()),
|
||||||
|
};
|
||||||
|
let separator = mapping.find('=');
|
||||||
|
let separator = match separator {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => {
|
||||||
|
return std::result::Result::Err(invalid_file_mapping_error("file mapping must contain a file_id and filename separated by '='"));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let file_id_text = &mapping[..separator];
|
||||||
|
let filename_text = &mapping[separator + 1..];
|
||||||
|
if file_id_text.is_empty() || filename_text.is_empty() {
|
||||||
|
return std::result::Result::Err(invalid_file_mapping_error("file mapping requires non-empty file_id and filename values"));
|
||||||
|
}
|
||||||
|
let file_id = ConfigFileId::new(file_id_text);
|
||||||
|
let file_id = match file_id {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
return registry.set_filename_override(&file_id, std::path::PathBuf::from(filename_text));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_file_id(value: &str) -> ksp_core_lib::Result<()> {
|
||||||
|
if value.is_empty() || value.starts_with('.') || value.ends_with('.') || value.contains("..") {
|
||||||
|
return std::result::Result::Err(invalid_file_id_error(value));
|
||||||
|
}
|
||||||
|
let mut valid = true;
|
||||||
|
for byte in value.bytes() {
|
||||||
|
let allowed = byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'.' || byte == b'_' || byte == b'-';
|
||||||
|
if !allowed {
|
||||||
|
valid = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !valid {
|
||||||
|
return std::result::Result::Err(invalid_file_id_error(value));
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_kind_prefix(file_id: &ConfigFileId, kind: ConfigFileKind) -> ksp_core_lib::Result<()> {
|
||||||
|
let valid = match kind {
|
||||||
|
ConfigFileKind::Config => file_id.as_str().starts_with("cfg."),
|
||||||
|
ConfigFileKind::Schema => file_id.as_str().starts_with("schema."),
|
||||||
|
};
|
||||||
|
if !valid {
|
||||||
|
return std::result::Result::Err(invalid_file_mapping_with_id_error(file_id.as_str(), "file_id prefix does not match descriptor kind"));
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_relative_filename(file_id: &ConfigFileId, filename: std::path::PathBuf) -> ksp_core_lib::Result<std::path::PathBuf> {
|
||||||
|
if filename.as_os_str().is_empty() || filename.is_absolute() {
|
||||||
|
return std::result::Result::Err(invalid_filename_error(file_id.as_str(), &filename));
|
||||||
|
}
|
||||||
|
let mut has_normal_component = false;
|
||||||
|
for component in filename.components() {
|
||||||
|
match component {
|
||||||
|
std::path::Component::Normal(_) => has_normal_component = true,
|
||||||
|
std::path::Component::CurDir | std::path::Component::ParentDir | std::path::Component::RootDir | std::path::Component::Prefix(_) => {
|
||||||
|
return std::result::Result::Err(invalid_filename_error(file_id.as_str(), &filename));
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !has_normal_component {
|
||||||
|
return std::result::Result::Err(invalid_filename_error(file_id.as_str(), &filename));
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(filename);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn invalid_file_id_error(file_id: &str) -> ksp_core_lib::Error {
|
||||||
|
return ksp_core_lib::Error::new(crate::ERROR_CODE_FILE_ID_INVALID, "Config file_id is invalid")
|
||||||
|
.with_context("file_id", file_id)
|
||||||
|
.with_context("reason", "expected lowercase ASCII segments separated by single dots");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn unknown_file_id_error(file_id: &str) -> ksp_core_lib::Error {
|
||||||
|
return ksp_core_lib::Error::new(crate::ERROR_CODE_FILE_ID_UNKNOWN, "Config file_id is not registered").with_context("file_id", file_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn duplicate_file_id_error(file_id: &str) -> ksp_core_lib::Error {
|
||||||
|
return ksp_core_lib::Error::new(crate::ERROR_CODE_FILE_ID_DUPLICATE, "Config file_id is registered more than once").with_context("file_id", file_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn invalid_file_mapping_error(reason: &'static str) -> ksp_core_lib::Error {
|
||||||
|
return ksp_core_lib::Error::new(crate::ERROR_CODE_FILE_MAPPING_INVALID, "Config file mapping is invalid").with_context("reason", reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn invalid_file_mapping_with_id_error(file_id: &str, reason: &'static str) -> ksp_core_lib::Error {
|
||||||
|
return ksp_core_lib::Error::new(crate::ERROR_CODE_FILE_MAPPING_INVALID, "Config file mapping is invalid")
|
||||||
|
.with_context("file_id", file_id)
|
||||||
|
.with_context("reason", reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn invalid_filename_error(file_id: &str, filename: &std::path::Path) -> ksp_core_lib::Error {
|
||||||
|
return ksp_core_lib::Error::new(crate::ERROR_CODE_FILE_MAPPING_INVALID, "Config mapped filename is invalid")
|
||||||
|
.with_context("file_id", file_id)
|
||||||
|
.with_context("filename", filename.to_string_lossy().into_owned())
|
||||||
|
.with_context("reason", "filename must stay relative to its Config-owned root without traversal components");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "../unit_tests/registry.rs"]
|
||||||
|
mod tests;
|
||||||
209
crates/ksp-config-lib/src/sensitivity.rs
Normal file
209
crates/ksp-config-lib/src/sensitivity.rs
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
// file: crates/ksp-config-lib/src/sensitivity.rs
|
||||||
|
// version: 1
|
||||||
|
|
||||||
|
/// Replacement used for secret environment fragments in safe diagnostic representations.
|
||||||
|
pub const REDACTED_CONFIG_VALUE: &str = "********";
|
||||||
|
|
||||||
|
/// Sensitivity assigned to one Config value after environment resolution.
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
|
||||||
|
pub enum ConfigSensitivity {
|
||||||
|
/// Value may be exposed by a public projection when the consumer contract allows it.
|
||||||
|
Public,
|
||||||
|
/// Value is available to the runtime but is not generically public.
|
||||||
|
Internal,
|
||||||
|
/// Value must remain available to legitimate runtime/management consumers while being redacted from ordinary diagnostics.
|
||||||
|
Secret,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ConfigSensitivity {
|
||||||
|
/// Classifies one supported KSP/KSPB environment variable by its namespace.
|
||||||
|
pub fn from_variable_name(variable_name: &str) -> ksp_core_lib::Result<Self> {
|
||||||
|
let validation = crate::environment::validate_supported_variable_name(variable_name);
|
||||||
|
if let std::result::Result::Err(error) = validation {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
if variable_name.starts_with("KSP_SECRET_") || variable_name.starts_with("KSPB_SECRET_") {
|
||||||
|
return std::result::Result::Ok(Self::Secret);
|
||||||
|
}
|
||||||
|
if variable_name.starts_with("KSP_PUBLIC_") || variable_name.starts_with("KSPB_PUBLIC_") {
|
||||||
|
return std::result::Result::Ok(Self::Public);
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(Self::Internal);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the strongest of two sensitivities using `Secret > Internal > Public`.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn strongest(self, other: Self) -> Self {
|
||||||
|
if self as u8 >= other as u8 {
|
||||||
|
return self;
|
||||||
|
}
|
||||||
|
return other;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns whether this sensitivity requires ordinary diagnostic redaction.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn is_secret(self) -> bool {
|
||||||
|
return matches!(self, Self::Secret);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Provenance segment participating in one resolved Config value.
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub enum ConfigValueProvenance {
|
||||||
|
/// Literal text/value came directly from the Config document.
|
||||||
|
DocumentLiteral,
|
||||||
|
/// Environment substitution came from the inherited process environment.
|
||||||
|
EnvironmentProcess {
|
||||||
|
/// Referenced variable name; never its value.
|
||||||
|
variable_name: String,
|
||||||
|
},
|
||||||
|
/// Environment substitution came from the local `.env` file.
|
||||||
|
EnvironmentDotEnv {
|
||||||
|
/// Referenced variable name; never its value.
|
||||||
|
variable_name: String,
|
||||||
|
},
|
||||||
|
/// Environment substitution used the placeholder/API fallback.
|
||||||
|
EnvironmentFallback {
|
||||||
|
/// Referenced variable name; never its value.
|
||||||
|
variable_name: String,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ConfigValueProvenance {
|
||||||
|
/// Returns the referenced variable name for environment provenance.
|
||||||
|
#[must_use]
|
||||||
|
pub fn variable_name(&self) -> std::option::Option<&str> {
|
||||||
|
return match self {
|
||||||
|
Self::DocumentLiteral => std::option::Option::None,
|
||||||
|
Self::EnvironmentProcess { variable_name } | Self::EnvironmentDotEnv { variable_name } | Self::EnvironmentFallback { variable_name } => {
|
||||||
|
std::option::Option::Some(variable_name.as_str())
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the environment source represented by this provenance segment when applicable.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn environment_source(&self) -> std::option::Option<crate::ConfigEnvironmentSource> {
|
||||||
|
return match self {
|
||||||
|
Self::DocumentLiteral => std::option::Option::None,
|
||||||
|
Self::EnvironmentProcess { .. } => std::option::Option::Some(crate::ConfigEnvironmentSource::Process),
|
||||||
|
Self::EnvironmentDotEnv { .. } => std::option::Option::Some(crate::ConfigEnvironmentSource::DotEnv),
|
||||||
|
Self::EnvironmentFallback { .. } => std::option::Option::Some(crate::ConfigEnvironmentSource::Fallback),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One resolved Config string with real/safe representations, sensitivity and ordered provenance.
|
||||||
|
#[derive(Clone, Eq, PartialEq)]
|
||||||
|
pub struct ResolvedConfigText {
|
||||||
|
value: String,
|
||||||
|
safe_value: String,
|
||||||
|
sensitivity: ConfigSensitivity,
|
||||||
|
provenance: std::vec::Vec<ConfigValueProvenance>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ResolvedConfigText {
|
||||||
|
pub(crate) fn new(value: String, safe_value: String, sensitivity: ConfigSensitivity, provenance: std::vec::Vec<ConfigValueProvenance>) -> Self {
|
||||||
|
return Self { value, safe_value, sensitivity, provenance };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the real runtime value.
|
||||||
|
#[must_use]
|
||||||
|
pub fn value(&self) -> &str {
|
||||||
|
return self.value.as_str();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the representation safe for ordinary diagnostics.
|
||||||
|
#[must_use]
|
||||||
|
pub fn safe_value(&self) -> &str {
|
||||||
|
return self.safe_value.as_str();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the strongest sensitivity contributed by referenced environment placeholders.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn sensitivity(&self) -> ConfigSensitivity {
|
||||||
|
return self.sensitivity;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns ordered provenance segments participating in the resolved string.
|
||||||
|
#[must_use]
|
||||||
|
pub fn provenance(&self) -> &[ConfigValueProvenance] {
|
||||||
|
return self.provenance.as_slice();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Debug for ResolvedConfigText {
|
||||||
|
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
return formatter
|
||||||
|
.debug_struct("ResolvedConfigText")
|
||||||
|
.field("safe_value", &self.safe_value)
|
||||||
|
.field("sensitivity", &self.sensitivity)
|
||||||
|
.field("provenance", &self.provenance)
|
||||||
|
.finish();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Recursively resolved JSON value preserving a real tree, a safe tree and provenance indexed by JSON Pointer.
|
||||||
|
#[derive(Clone, Eq, PartialEq)]
|
||||||
|
pub struct ResolvedConfigJson {
|
||||||
|
value: serde_json::Value,
|
||||||
|
safe_value: serde_json::Value,
|
||||||
|
sensitivity: ConfigSensitivity,
|
||||||
|
provenance: std::collections::BTreeMap<String, std::vec::Vec<ConfigValueProvenance>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ResolvedConfigJson {
|
||||||
|
pub(crate) fn new(
|
||||||
|
value: serde_json::Value,
|
||||||
|
safe_value: serde_json::Value,
|
||||||
|
sensitivity: ConfigSensitivity,
|
||||||
|
provenance: std::collections::BTreeMap<String, std::vec::Vec<ConfigValueProvenance>>,
|
||||||
|
) -> Self {
|
||||||
|
return Self { value, safe_value, sensitivity, provenance };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the real JSON tree intended for legitimate runtime consumers.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn value(&self) -> &serde_json::Value {
|
||||||
|
return &self.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the JSON tree safe for ordinary diagnostics.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn safe_value(&self) -> &serde_json::Value {
|
||||||
|
return &self.safe_value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the strongest sensitivity found anywhere in the resolved JSON tree.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn sensitivity(&self) -> ConfigSensitivity {
|
||||||
|
return self.sensitivity;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns provenance indexed by RFC 6901 JSON Pointer strings.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn provenance(&self) -> &std::collections::BTreeMap<String, std::vec::Vec<ConfigValueProvenance>> {
|
||||||
|
return &self.provenance;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns provenance for one JSON Pointer when the resolved value recorded that location.
|
||||||
|
#[must_use]
|
||||||
|
pub fn provenance_at(&self, json_pointer: &str) -> std::option::Option<&[ConfigValueProvenance]> {
|
||||||
|
return self.provenance.get(json_pointer).map(std::vec::Vec::as_slice);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Debug for ResolvedConfigJson {
|
||||||
|
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
return formatter
|
||||||
|
.debug_struct("ResolvedConfigJson")
|
||||||
|
.field("safe_value", &self.safe_value)
|
||||||
|
.field("sensitivity", &self.sensitivity)
|
||||||
|
.field("provenance", &self.provenance)
|
||||||
|
.finish();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "../unit_tests/sensitivity.rs"]
|
||||||
|
mod tests;
|
||||||
304
crates/ksp-config-lib/tests/ownership.rs
Normal file
304
crates/ksp-config-lib/tests/ownership.rs
Normal file
@@ -0,0 +1,304 @@
|
|||||||
|
// file: crates/ksp-config-lib/tests/ownership.rs
|
||||||
|
// version: 1
|
||||||
|
|
||||||
|
//! Workspace ownership audits for KSP application configuration boundaries.
|
||||||
|
|
||||||
|
fn workspace_root() -> std::path::PathBuf {
|
||||||
|
let manifest_directory = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"));
|
||||||
|
let root = manifest_directory.parent().and_then(std::path::Path::parent);
|
||||||
|
return match root {
|
||||||
|
std::option::Option::Some(value) => value.to_path_buf(),
|
||||||
|
std::option::Option::None => manifest_directory,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn collect_rust_files(directory: &std::path::Path, files: &mut std::vec::Vec<std::path::PathBuf>) {
|
||||||
|
let entries = std::fs::read_dir(directory);
|
||||||
|
let entries = match entries {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
for entry in entries {
|
||||||
|
let entry = match entry {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => continue,
|
||||||
|
};
|
||||||
|
let path = entry.path();
|
||||||
|
if path.is_dir() {
|
||||||
|
collect_rust_files(path.as_path(), files);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if path.extension().and_then(std::ffi::OsStr::to_str) == std::option::Option::Some("rs") {
|
||||||
|
files.push(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn collect_json_files(directory: &std::path::Path, files: &mut std::vec::Vec<std::path::PathBuf>) {
|
||||||
|
let entries = std::fs::read_dir(directory);
|
||||||
|
let entries = match entries {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
for entry in entries {
|
||||||
|
let entry = match entry {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => continue,
|
||||||
|
};
|
||||||
|
let path = entry.path();
|
||||||
|
if path.is_dir() {
|
||||||
|
collect_json_files(path.as_path(), files);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if path.extension().and_then(std::ffi::OsStr::to_str) == std::option::Option::Some("json") {
|
||||||
|
files.push(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn non_comment_source(source: &str) -> String {
|
||||||
|
let mut filtered = String::new();
|
||||||
|
for line in source.lines() {
|
||||||
|
let trimmed = line.trim_start();
|
||||||
|
if trimmed.starts_with("//") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
filtered.push_str(line);
|
||||||
|
filtered.push('\n');
|
||||||
|
}
|
||||||
|
return filtered;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn collect_environment_names(source: &str, names: &mut std::collections::BTreeSet<String>) {
|
||||||
|
let bytes = source.as_bytes();
|
||||||
|
let mut index: usize = 0;
|
||||||
|
while index < bytes.len() {
|
||||||
|
let prefix_length = if bytes[index..].starts_with(b"KSPB_") {
|
||||||
|
5
|
||||||
|
} else if bytes[index..].starts_with(b"KSP_") {
|
||||||
|
4
|
||||||
|
} else {
|
||||||
|
index += 1;
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let mut end = index + prefix_length;
|
||||||
|
while end < bytes.len() {
|
||||||
|
let byte = bytes[end];
|
||||||
|
if byte.is_ascii_uppercase() || byte.is_ascii_digit() || byte == b'_' {
|
||||||
|
end += 1;
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let candidate = std::str::from_utf8(&bytes[index..end]);
|
||||||
|
if let std::result::Result::Ok(candidate) = candidate
|
||||||
|
&& candidate.len() > prefix_length
|
||||||
|
&& !candidate.ends_with('_')
|
||||||
|
{
|
||||||
|
names.insert(candidate.to_owned());
|
||||||
|
}
|
||||||
|
index = end;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn dotenv_example_assignments(source: &str) -> std::collections::BTreeMap<String, usize> {
|
||||||
|
let mut assignments = std::collections::BTreeMap::<String, usize>::new();
|
||||||
|
for (line_index, line) in source.lines().enumerate() {
|
||||||
|
let mut candidate = line.trim();
|
||||||
|
if let std::option::Option::Some(commented) = candidate.strip_prefix('#') {
|
||||||
|
candidate = commented.trim_start();
|
||||||
|
}
|
||||||
|
let separator = candidate.find('=');
|
||||||
|
let separator = match separator {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => continue,
|
||||||
|
};
|
||||||
|
let name = candidate[..separator].trim();
|
||||||
|
if (name.starts_with("KSP_") || name.starts_with("KSPB_")) && !name.ends_with('_') {
|
||||||
|
assignments.insert(name.to_owned(), line_index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return assignments;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn has_preceding_explanatory_comment(source: &str, assignment_line_index: usize) -> bool {
|
||||||
|
let lines: std::vec::Vec<&str> = source.lines().collect();
|
||||||
|
if assignment_line_index == 0 || assignment_line_index > lines.len() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let mut index = assignment_line_index;
|
||||||
|
while index > 0 {
|
||||||
|
index -= 1;
|
||||||
|
let trimmed = lines[index].trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if !trimmed.starts_with('#') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let comment = trimmed.trim_start_matches('#').trim_start();
|
||||||
|
if comment.starts_with("file:") || comment.starts_with("version:") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if comment.contains('=') && (comment.starts_with("KSP_") || comment.starts_with("KSPB_")) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn foundational_dependency_direction_does_not_point_back_to_config() {
|
||||||
|
let root = workspace_root();
|
||||||
|
for crate_name in ["ksp-core-lib", "ksp-logging-lib"] {
|
||||||
|
let manifest_path = root.join("crates").join(crate_name).join("Cargo.toml");
|
||||||
|
let manifest = std::fs::read_to_string(manifest_path.as_path());
|
||||||
|
assert!(manifest.is_ok(), "unable to read {}", manifest_path.display());
|
||||||
|
if let std::result::Result::Ok(manifest) = manifest {
|
||||||
|
assert!(!manifest.contains("ksp-config-lib"), "{} must not depend on ksp-config-lib", manifest_path.display());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn workspace_crates_do_not_read_ksp_environment_directly() {
|
||||||
|
let root = workspace_root();
|
||||||
|
let crates_directory = root.join("crates");
|
||||||
|
let entries = std::fs::read_dir(crates_directory.as_path());
|
||||||
|
assert!(entries.is_ok(), "unable to inspect workspace crates at {}", crates_directory.display());
|
||||||
|
let entries = match entries {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
for entry in entries {
|
||||||
|
let entry = match entry {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => continue,
|
||||||
|
};
|
||||||
|
if !entry.path().is_dir() || entry.file_name() == std::ffi::OsStr::new("ksp-config-lib") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let mut rust_files = std::vec::Vec::new();
|
||||||
|
collect_rust_files(entry.path().as_path(), &mut rust_files);
|
||||||
|
for rust_file in rust_files {
|
||||||
|
let source = std::fs::read_to_string(rust_file.as_path());
|
||||||
|
assert!(source.is_ok(), "unable to read {}", rust_file.display());
|
||||||
|
let source = match source {
|
||||||
|
std::result::Result::Ok(value) => non_comment_source(value.as_str()),
|
||||||
|
std::result::Result::Err(_) => continue,
|
||||||
|
};
|
||||||
|
let forbidden = [
|
||||||
|
"std::env::var(\"KSP_",
|
||||||
|
"std::env::var(\"KSPB_",
|
||||||
|
"std::env::var_os(\"KSP_",
|
||||||
|
"std::env::var_os(\"KSPB_",
|
||||||
|
"env::var(\"KSP_",
|
||||||
|
"env::var(\"KSPB_",
|
||||||
|
"env::var_os(\"KSP_",
|
||||||
|
"env::var_os(\"KSPB_",
|
||||||
|
"std::env::vars()",
|
||||||
|
"std::env::vars_os()",
|
||||||
|
];
|
||||||
|
for token in forbidden {
|
||||||
|
assert!(!source.contains(token), "{} bypasses ksp-config-lib for KSP/KSPB environment access via {token}", rust_file.display());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn workspace_crates_do_not_hardcode_config_managed_physical_files() {
|
||||||
|
let root = workspace_root();
|
||||||
|
let crates_directory = root.join("crates");
|
||||||
|
let entries = std::fs::read_dir(crates_directory.as_path());
|
||||||
|
assert!(entries.is_ok(), "unable to inspect workspace crates at {}", crates_directory.display());
|
||||||
|
let entries = match entries {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
for entry in entries {
|
||||||
|
let entry = match entry {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => continue,
|
||||||
|
};
|
||||||
|
if !entry.path().is_dir() || entry.file_name() == std::ffi::OsStr::new("ksp-config-lib") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let mut rust_files = std::vec::Vec::new();
|
||||||
|
collect_rust_files(entry.path().as_path(), &mut rust_files);
|
||||||
|
for rust_file in rust_files {
|
||||||
|
let source = std::fs::read_to_string(rust_file.as_path());
|
||||||
|
assert!(source.is_ok(), "unable to read {}", rust_file.display());
|
||||||
|
let source = match source {
|
||||||
|
std::result::Result::Ok(value) => non_comment_source(value.as_str()),
|
||||||
|
std::result::Result::Err(_) => continue,
|
||||||
|
};
|
||||||
|
for token in ["\".env\"", "\"std.logging.json\"", "\"std.logging.schema.json\"", "\"composite.schema.json\""] {
|
||||||
|
assert!(!source.contains(token), "{} hardcodes Config-managed physical resource {token}; use ksp-config-lib contracts", rust_file.display());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dotenv_example_covers_runtime_environment_names_with_comments() {
|
||||||
|
let root = workspace_root();
|
||||||
|
let mut runtime_names = std::collections::BTreeSet::<String>::new();
|
||||||
|
let config_directory = root.join("config");
|
||||||
|
let mut json_files = std::vec::Vec::new();
|
||||||
|
collect_json_files(config_directory.as_path(), &mut json_files);
|
||||||
|
for json_file in json_files {
|
||||||
|
let source = std::fs::read_to_string(json_file.as_path());
|
||||||
|
assert!(source.is_ok(), "unable to read {}", json_file.display());
|
||||||
|
if let std::result::Result::Ok(source) = source {
|
||||||
|
collect_environment_names(source.as_str(), &mut runtime_names);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let crates_directory = root.join("crates");
|
||||||
|
let crate_entries = std::fs::read_dir(crates_directory.as_path());
|
||||||
|
assert!(crate_entries.is_ok(), "unable to inspect workspace crates at {}", crates_directory.display());
|
||||||
|
let crate_entries = match crate_entries {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
for entry in crate_entries {
|
||||||
|
let entry = match entry {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => continue,
|
||||||
|
};
|
||||||
|
let source_directory = entry.path().join("src");
|
||||||
|
if !source_directory.is_dir() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let mut rust_files = std::vec::Vec::new();
|
||||||
|
collect_rust_files(source_directory.as_path(), &mut rust_files);
|
||||||
|
for rust_file in rust_files {
|
||||||
|
let source = std::fs::read_to_string(rust_file.as_path());
|
||||||
|
assert!(source.is_ok(), "unable to read {}", rust_file.display());
|
||||||
|
if let std::result::Result::Ok(source) = source {
|
||||||
|
let source = non_comment_source(source.as_str());
|
||||||
|
collect_environment_names(source.as_str(), &mut runtime_names);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let example_path = root.join(ksp_config_lib::DEFAULT_DOTENV_EXAMPLE_PATH);
|
||||||
|
let example = std::fs::read_to_string(example_path.as_path());
|
||||||
|
assert!(example.is_ok(), "unable to read canonical environment inventory {}", example_path.display());
|
||||||
|
let example = match example {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
let assignments = dotenv_example_assignments(example.as_str());
|
||||||
|
assert!(!runtime_names.is_empty(), "runtime environment audit should discover at least one concrete KSP/KSPB variable");
|
||||||
|
for variable_name in runtime_names {
|
||||||
|
let assignment_line = assignments.get(variable_name.as_str());
|
||||||
|
assert!(assignment_line.is_some(), ".env.example is missing runtime variable {variable_name}");
|
||||||
|
if let std::option::Option::Some(line_index) = assignment_line {
|
||||||
|
assert!(
|
||||||
|
has_preceding_explanatory_comment(example.as_str(), *line_index),
|
||||||
|
".env.example variable {variable_name} must be preceded by an explanatory comment",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
209
crates/ksp-config-lib/tests/public_api.rs
Normal file
209
crates/ksp-config-lib/tests/public_api.rs
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
// file: crates/ksp-config-lib/tests/public_api.rs
|
||||||
|
// version: 10
|
||||||
|
|
||||||
|
//! Integration tests for the public `ksp-config-lib` bootstrap, registry, JSON/profile/composite, environment-resolution, sensitivity, Logging-adapter and
|
||||||
|
//! management contracts.
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bootstrap_contract_is_available_from_crate_root() {
|
||||||
|
let result = ksp_config_lib::ConfigBootstrapOptions::defaults();
|
||||||
|
assert!(result.is_ok(), "default bootstrap options should be available: {result:?}");
|
||||||
|
if let std::result::Result::Ok(options) = result {
|
||||||
|
assert_eq!(options.cfg_path(), std::path::Path::new(ksp_config_lib::DEFAULT_CFG_PATH));
|
||||||
|
assert_eq!(options.schema_path(), std::path::Path::new(ksp_config_lib::DEFAULT_SCHEMA_PATH));
|
||||||
|
assert_eq!(ksp_config_lib::ARG_CFG_PATH, "--cfgpath");
|
||||||
|
assert_eq!(ksp_config_lib::ARG_SCHEMA_PATH, "--schemapath");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn programmatic_bootstrap_paths_are_independent() {
|
||||||
|
let result = ksp_config_lib::ConfigBootstrapOptions::from_paths("runtime-config", "runtime-schemas");
|
||||||
|
assert!(result.is_ok(), "programmatic bootstrap paths should be valid: {result:?}");
|
||||||
|
if let std::result::Result::Ok(options) = result {
|
||||||
|
assert_eq!(options.cfg_path(), std::path::Path::new("runtime-config"));
|
||||||
|
assert_eq!(options.schema_path(), std::path::Path::new("runtime-schemas"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cli_bootstrap_parser_is_available_from_crate_root() {
|
||||||
|
let args = [
|
||||||
|
std::ffi::OsString::from("consumer"),
|
||||||
|
std::ffi::OsString::from("--cfgpath=consumer-config"),
|
||||||
|
std::ffi::OsString::from("--schemapath"),
|
||||||
|
std::ffi::OsString::from("consumer-schemas"),
|
||||||
|
];
|
||||||
|
let result = ksp_config_lib::ConfigBootstrapOptions::from_args(&args);
|
||||||
|
assert!(result.is_ok(), "public bootstrap parser should accept KSP path arguments: {result:?}");
|
||||||
|
if let std::result::Result::Ok(options) = result {
|
||||||
|
assert_eq!(options.cfg_path(), std::path::Path::new("consumer-config"));
|
||||||
|
assert_eq!(options.schema_path(), std::path::Path::new("consumer-schemas"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn logical_file_registry_is_available_from_crate_root() {
|
||||||
|
let args = [
|
||||||
|
std::ffi::OsString::from("consumer"),
|
||||||
|
std::ffi::OsString::from("--filemap=cfg.std.logging=consumer.logging.json"),
|
||||||
|
std::ffi::OsString::from("--filemap=schema.std.logging=consumer.logging.schema.json"),
|
||||||
|
];
|
||||||
|
let registry = ksp_config_lib::ConfigFileRegistry::from_args(&args);
|
||||||
|
let bootstrap = ksp_config_lib::ConfigBootstrapOptions::from_paths("consumer-config", "consumer-schemas");
|
||||||
|
let logging_id = ksp_config_lib::ConfigFileId::new(ksp_config_lib::FILE_ID_STD_LOGGING);
|
||||||
|
let schema_id = ksp_config_lib::ConfigFileId::new(ksp_config_lib::FILE_ID_SCHEMA_STD_LOGGING);
|
||||||
|
assert!(registry.is_ok(), "public registry should accept known filemap overrides: {registry:?}");
|
||||||
|
assert!(bootstrap.is_ok(), "bootstrap paths should remain available: {bootstrap:?}");
|
||||||
|
assert!(logging_id.is_ok(), "public logging file_id should be valid: {logging_id:?}");
|
||||||
|
assert!(schema_id.is_ok(), "public schema file_id should be valid: {schema_id:?}");
|
||||||
|
if let (std::result::Result::Ok(registry), std::result::Result::Ok(bootstrap), std::result::Result::Ok(logging_id), std::result::Result::Ok(schema_id)) =
|
||||||
|
(registry, bootstrap, logging_id, schema_id)
|
||||||
|
{
|
||||||
|
let logging = registry.resolve_path(&bootstrap, &logging_id);
|
||||||
|
let schema = registry.resolve_path(&bootstrap, &schema_id);
|
||||||
|
assert!(logging.is_ok(), "public logging path should resolve: {logging:?}");
|
||||||
|
assert!(schema.is_ok(), "public schema path should resolve: {schema:?}");
|
||||||
|
if let std::result::Result::Ok(logging) = logging {
|
||||||
|
assert_eq!(logging, std::path::PathBuf::from("consumer-config/consumer.logging.json"));
|
||||||
|
}
|
||||||
|
if let std::result::Result::Ok(schema) = schema {
|
||||||
|
assert_eq!(schema, std::path::PathBuf::from("consumer-schemas/consumer.logging.schema.json"));
|
||||||
|
}
|
||||||
|
assert_eq!(ksp_config_lib::ARG_FILE_MAP, "--filemap");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validated_json_document_engine_is_available_from_crate_root() {
|
||||||
|
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 registry = ksp_config_lib::ConfigFileRegistry::defaults();
|
||||||
|
let file_id = ksp_config_lib::ConfigFileId::new(ksp_config_lib::FILE_ID_STD_LOGGING);
|
||||||
|
assert!(bootstrap.is_ok(), "public bootstrap should accept committed Config roots: {bootstrap:?}");
|
||||||
|
assert!(registry.is_ok(), "public registry should remain constructible: {registry:?}");
|
||||||
|
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);
|
||||||
|
assert!(document.is_ok(), "public document engine should validate committed Logging configuration: {document:?}");
|
||||||
|
if let std::result::Result::Ok(document) = document {
|
||||||
|
assert_eq!(document.file_id(), &file_id);
|
||||||
|
assert_eq!(document.value().get("format_version"), std::option::Option::Some(&serde_json::Value::from(1)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolved_profile_contract_is_available_from_crate_root() {
|
||||||
|
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 registry = ksp_config_lib::ConfigFileRegistry::defaults();
|
||||||
|
let file_id = ksp_config_lib::ConfigFileId::new(ksp_config_lib::FILE_ID_STD_LOGGING);
|
||||||
|
assert!(bootstrap.is_ok(), "public bootstrap should accept committed Config roots: {bootstrap:?}");
|
||||||
|
assert!(registry.is_ok(), "public registry should remain constructible: {registry:?}");
|
||||||
|
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 resolved = engine.load_resolved_profile(&file_id, std::option::Option::None);
|
||||||
|
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");
|
||||||
|
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));
|
||||||
|
assert!(!resolved.effective().contains_key("default_profile"));
|
||||||
|
assert!(!resolved.effective().contains_key("profiles"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn composite_schema_and_provenance_contracts_are_available_from_crate_root() {
|
||||||
|
let registry = ksp_config_lib::ConfigFileRegistry::defaults();
|
||||||
|
let schema_id = ksp_config_lib::ConfigFileId::new(ksp_config_lib::FILE_ID_SCHEMA_COMPOSITE);
|
||||||
|
assert!(registry.is_ok(), "public registry should remain constructible: {registry:?}");
|
||||||
|
assert!(schema_id.is_ok(), "public composite schema file_id should be valid: {schema_id:?}");
|
||||||
|
if let (std::result::Result::Ok(registry), std::result::Result::Ok(schema_id)) = (registry, schema_id) {
|
||||||
|
let descriptor = registry.descriptor(&schema_id);
|
||||||
|
assert!(descriptor.is_ok(), "public composite schema descriptor should exist: {descriptor:?}");
|
||||||
|
if let std::result::Result::Ok(descriptor) = descriptor {
|
||||||
|
assert_eq!(descriptor.filename(), std::path::Path::new(ksp_config_lib::DEFAULT_COMPOSITE_SCHEMA_FILENAME));
|
||||||
|
assert_eq!(descriptor.kind(), ksp_config_lib::ConfigFileKind::Schema);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert_ne!(ksp_config_lib::ConfigProfileSelectionSource::Composite, ksp_config_lib::ConfigProfileSelectionSource::Explicit);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn environment_resolution_contract_is_available_from_crate_root() {
|
||||||
|
let loader: fn() -> ksp_core_lib::Result<ksp_config_lib::ConfigEnvironment> = ksp_config_lib::ConfigEnvironment::load;
|
||||||
|
let _ = loader;
|
||||||
|
assert_eq!(ksp_config_lib::DEFAULT_DOTENV_PATH, ".env");
|
||||||
|
assert_eq!(ksp_config_lib::DEFAULT_DOTENV_EXAMPLE_PATH, ".env.example");
|
||||||
|
assert_ne!(ksp_config_lib::ConfigEnvironmentSource::Process, ksp_config_lib::ConfigEnvironmentSource::DotEnv);
|
||||||
|
assert_ne!(ksp_config_lib::ConfigEnvironmentSource::DotEnv, ksp_config_lib::ConfigEnvironmentSource::Fallback);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sensitivity_and_safe_resolution_contracts_are_available_from_crate_root() {
|
||||||
|
assert_eq!(
|
||||||
|
ksp_config_lib::ConfigSensitivity::from_variable_name("KSP_PUBLIC_HOST").ok(),
|
||||||
|
std::option::Option::Some(ksp_config_lib::ConfigSensitivity::Public),
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
ksp_config_lib::ConfigSensitivity::from_variable_name("KSP_MODE").ok(),
|
||||||
|
std::option::Option::Some(ksp_config_lib::ConfigSensitivity::Internal)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
ksp_config_lib::ConfigSensitivity::from_variable_name("KSP_SECRET_TOKEN").ok(),
|
||||||
|
std::option::Option::Some(ksp_config_lib::ConfigSensitivity::Secret),
|
||||||
|
);
|
||||||
|
assert_eq!(ksp_config_lib::REDACTED_CONFIG_VALUE, "********");
|
||||||
|
let detailed_text: fn(&ksp_config_lib::ConfigEnvironment, &str) -> ksp_core_lib::Result<ksp_config_lib::ResolvedConfigText> =
|
||||||
|
ksp_config_lib::ConfigEnvironment::resolve_text_detailed;
|
||||||
|
let detailed_json: fn(&ksp_config_lib::ConfigEnvironment, &serde_json::Value) -> ksp_core_lib::Result<ksp_config_lib::ResolvedConfigJson> =
|
||||||
|
ksp_config_lib::ConfigEnvironment::resolve_json_detailed;
|
||||||
|
let _ = (detailed_text, detailed_json);
|
||||||
|
let provenance = ksp_config_lib::ConfigValueProvenance::EnvironmentFallback { variable_name: "KSP_SECRET_TOKEN".to_owned() };
|
||||||
|
assert_eq!(provenance.variable_name(), std::option::Option::Some("KSP_SECRET_TOKEN"));
|
||||||
|
assert_eq!(provenance.environment_source(), std::option::Option::Some(ksp_config_lib::ConfigEnvironmentSource::Fallback));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn logging_adapter_contract_is_available_from_crate_root() {
|
||||||
|
let adapter = ksp_config_lib::ConfigDocumentEngine::load_resolved_logging_config;
|
||||||
|
let _ = adapter;
|
||||||
|
assert_eq!(ksp_config_lib::ERROR_CODE_EFFECTIVE_CONFIG_INVALID.domain(), "config");
|
||||||
|
assert_eq!(ksp_config_lib::ERROR_CODE_EFFECTIVE_CONFIG_INVALID.code(), "effective_config_invalid");
|
||||||
|
assert!(std::mem::size_of::<ksp_config_lib::ResolvedLoggingConfig>() > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn management_contracts_are_available_from_crate_root() {
|
||||||
|
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 registry = ksp_config_lib::ConfigFileRegistry::defaults();
|
||||||
|
assert!(bootstrap.is_ok(), "public bootstrap should accept committed Config roots: {bootstrap:?}");
|
||||||
|
assert!(registry.is_ok(), "public registry should remain constructible: {registry:?}");
|
||||||
|
if let (std::result::Result::Ok(bootstrap), std::result::Result::Ok(registry)) = (bootstrap, registry) {
|
||||||
|
let engine = ksp_config_lib::ConfigDocumentEngine::new(bootstrap, registry);
|
||||||
|
let management = ksp_config_lib::ConfigManagement::new(engine);
|
||||||
|
let logging = management.load_logging_document();
|
||||||
|
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");
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let reveal_effective: fn(&ksp_config_lib::ConfigManagement, &str) -> ksp_core_lib::Result<std::option::Option<String>> =
|
||||||
|
ksp_config_lib::ConfigManagement::reveal_effective_environment_value;
|
||||||
|
let reveal_dotenv: fn(&ksp_config_lib::ConfigManagement, &str) -> ksp_core_lib::Result<std::option::Option<String>> =
|
||||||
|
ksp_config_lib::ConfigManagement::reveal_dotenv_value;
|
||||||
|
let _ = (reveal_effective, reveal_dotenv);
|
||||||
|
assert_ne!(ksp_config_lib::ERROR_CODE_MANAGEMENT_OPERATION_INVALID, ksp_config_lib::ERROR_CODE_PERSISTENCE_WRITE_FAILED);
|
||||||
|
}
|
||||||
142
crates/ksp-config-lib/unit_tests/bootstrap.rs
Normal file
142
crates/ksp-config-lib/unit_tests/bootstrap.rs
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
// file: crates/ksp-config-lib/unit_tests/bootstrap.rs
|
||||||
|
// version: 1
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn defaults_use_hardcoded_ksp_roots() {
|
||||||
|
let result = super::ConfigBootstrapOptions::defaults();
|
||||||
|
assert!(result.is_ok(), "default bootstrap paths should be valid: {result:?}");
|
||||||
|
if let std::result::Result::Ok(options) = result {
|
||||||
|
assert_eq!(options.cfg_path(), std::path::Path::new(super::DEFAULT_CFG_PATH));
|
||||||
|
assert_eq!(options.schema_path(), std::path::Path::new(super::DEFAULT_SCHEMA_PATH));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cfg_path_override_keeps_schema_default() {
|
||||||
|
let defaults = super::ConfigBootstrapOptions::defaults();
|
||||||
|
assert!(defaults.is_ok(), "default bootstrap paths should be valid: {defaults:?}");
|
||||||
|
if let std::result::Result::Ok(options) = defaults {
|
||||||
|
let result = options.with_cfg_path("custom-config");
|
||||||
|
assert!(result.is_ok(), "cfg path override should be valid: {result:?}");
|
||||||
|
if let std::result::Result::Ok(options) = result {
|
||||||
|
assert_eq!(options.cfg_path(), std::path::Path::new("custom-config"));
|
||||||
|
assert_eq!(options.schema_path(), std::path::Path::new(super::DEFAULT_SCHEMA_PATH));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn schema_path_override_keeps_cfg_default() {
|
||||||
|
let defaults = super::ConfigBootstrapOptions::defaults();
|
||||||
|
assert!(defaults.is_ok(), "default bootstrap paths should be valid: {defaults:?}");
|
||||||
|
if let std::result::Result::Ok(options) = defaults {
|
||||||
|
let result = options.with_schema_path("custom-schemas");
|
||||||
|
assert!(result.is_ok(), "schema path override should be valid: {result:?}");
|
||||||
|
if let std::result::Result::Ok(options) = result {
|
||||||
|
assert_eq!(options.cfg_path(), std::path::Path::new(super::DEFAULT_CFG_PATH));
|
||||||
|
assert_eq!(options.schema_path(), std::path::Path::new("custom-schemas"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cfg_cli_override_keeps_schema_default() {
|
||||||
|
let args = [std::ffi::OsString::from("ksp-app"), std::ffi::OsString::from("--cfgpath=cli-config")];
|
||||||
|
let result = super::ConfigBootstrapOptions::from_args(&args);
|
||||||
|
assert!(result.is_ok(), "cfg CLI override should parse: {result:?}");
|
||||||
|
if let std::result::Result::Ok(options) = result {
|
||||||
|
assert_eq!(options.cfg_path(), std::path::Path::new("cli-config"));
|
||||||
|
assert_eq!(options.schema_path(), std::path::Path::new(super::DEFAULT_SCHEMA_PATH));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn schema_cli_override_keeps_cfg_default() {
|
||||||
|
let args = [std::ffi::OsString::from("ksp-app"), std::ffi::OsString::from("--schemapath=cli-schemas")];
|
||||||
|
let result = super::ConfigBootstrapOptions::from_args(&args);
|
||||||
|
assert!(result.is_ok(), "schema CLI override should parse: {result:?}");
|
||||||
|
if let std::result::Result::Ok(options) = result {
|
||||||
|
assert_eq!(options.cfg_path(), std::path::Path::new(super::DEFAULT_CFG_PATH));
|
||||||
|
assert_eq!(options.schema_path(), std::path::Path::new("cli-schemas"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parser_accepts_inline_and_separate_forms_and_last_value_wins() {
|
||||||
|
let args = [
|
||||||
|
std::ffi::OsString::from("ksp-app"),
|
||||||
|
std::ffi::OsString::from("--cfgpath=first-config"),
|
||||||
|
std::ffi::OsString::from("--unrelated"),
|
||||||
|
std::ffi::OsString::from("--cfgpath"),
|
||||||
|
std::ffi::OsString::from("second-config"),
|
||||||
|
std::ffi::OsString::from("--schemapath=first-schemas"),
|
||||||
|
std::ffi::OsString::from("--schemapath"),
|
||||||
|
std::ffi::OsString::from("second-schemas"),
|
||||||
|
];
|
||||||
|
let result = super::ConfigBootstrapOptions::from_args(&args);
|
||||||
|
assert!(result.is_ok(), "bootstrap arguments should parse: {result:?}");
|
||||||
|
if let std::result::Result::Ok(options) = result {
|
||||||
|
assert_eq!(options.cfg_path(), std::path::Path::new("second-config"));
|
||||||
|
assert_eq!(options.schema_path(), std::path::Path::new("second-schemas"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parser_reports_missing_separate_value() {
|
||||||
|
let args = [std::ffi::OsString::from("ksp-app"), std::ffi::OsString::from("--cfgpath")];
|
||||||
|
let result = super::ConfigBootstrapOptions::from_args(&args);
|
||||||
|
assert!(result.is_err(), "missing value must be rejected");
|
||||||
|
if let std::result::Result::Err(error) = result {
|
||||||
|
assert_eq!(error.code(), crate::ERROR_CODE_BOOTSTRAP_ARGUMENT_MISSING_VALUE);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parser_reports_another_option_as_missing_separate_value() {
|
||||||
|
let args = [std::ffi::OsString::from("ksp-app"), std::ffi::OsString::from("--cfgpath"), std::ffi::OsString::from("--other-option")];
|
||||||
|
let result = super::ConfigBootstrapOptions::from_args(&args);
|
||||||
|
assert!(result.is_err(), "another option must not become a bootstrap path value");
|
||||||
|
if let std::result::Result::Err(error) = result {
|
||||||
|
assert_eq!(error.code(), crate::ERROR_CODE_BOOTSTRAP_ARGUMENT_MISSING_VALUE);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty_inline_path_is_rejected() {
|
||||||
|
let args = [std::ffi::OsString::from("ksp-app"), std::ffi::OsString::from("--schemapath=")];
|
||||||
|
let result = super::ConfigBootstrapOptions::from_args(&args);
|
||||||
|
assert!(result.is_err(), "empty path must be rejected");
|
||||||
|
if let std::result::Result::Err(error) = result {
|
||||||
|
assert_eq!(error.code(), crate::ERROR_CODE_BOOTSTRAP_INVALID_PATH);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn explicit_programmatic_paths_do_not_depend_on_default_roots() {
|
||||||
|
let result = super::ConfigBootstrapOptions::from_paths("programmatic-config", "programmatic-schemas");
|
||||||
|
assert!(result.is_ok(), "explicit programmatic paths should be valid: {result:?}");
|
||||||
|
if let std::result::Result::Ok(options) = result {
|
||||||
|
assert_eq!(options.cfg_path(), std::path::Path::new("programmatic-config"));
|
||||||
|
assert_eq!(options.schema_path(), std::path::Path::new("programmatic-schemas"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn existing_non_directory_path_is_rejected() {
|
||||||
|
let fixture = unique_fixture_path("existing-file");
|
||||||
|
let create = std::fs::write(fixture.as_path(), b"fixture");
|
||||||
|
assert!(create.is_ok(), "fixture file should be creatable: {create:?}");
|
||||||
|
let result = super::ConfigBootstrapOptions::from_paths(fixture.as_path(), "programmatic-schemas");
|
||||||
|
let remove = std::fs::remove_file(fixture.as_path());
|
||||||
|
assert!(remove.is_ok(), "fixture file should be removable: {remove:?}");
|
||||||
|
assert!(result.is_err(), "existing file must not be accepted as a bootstrap directory");
|
||||||
|
if let std::result::Result::Err(error) = result {
|
||||||
|
assert_eq!(error.code(), crate::ERROR_CODE_BOOTSTRAP_INVALID_PATH);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn unique_fixture_path(name: &str) -> std::path::PathBuf {
|
||||||
|
let mut path = std::env::temp_dir();
|
||||||
|
path.push(format!("ksp-config-lib-{name}-{}", std::process::id()));
|
||||||
|
return path;
|
||||||
|
}
|
||||||
247
crates/ksp-config-lib/unit_tests/composite.rs
Normal file
247
crates/ksp-config-lib/unit_tests/composite.rs
Normal file
@@ -0,0 +1,247 @@
|
|||||||
|
// file: crates/ksp-config-lib/unit_tests/composite.rs
|
||||||
|
// version: 2
|
||||||
|
|
||||||
|
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();
|
||||||
|
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:?}");
|
||||||
|
if let std::result::Result::Ok(resolved) = resolved {
|
||||||
|
assert_eq!(resolved.profile_id(), "local_default");
|
||||||
|
assert_eq!(resolved.selection_source(), crate::ConfigProfileSelectionSource::DefaultProfile);
|
||||||
|
let logging = resolved.component("logging");
|
||||||
|
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");
|
||||||
|
assert_eq!(logging.resolved().selection_source(), crate::ConfigProfileSelectionSource::DefaultProfile);
|
||||||
|
assert_eq!(logging.resolved().origin("logs_directory"), std::option::Option::Some(crate::ConfigValueOrigin::Global));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn composite_profile_override_marks_referenced_profile_selection_as_composite() {
|
||||||
|
let engine = committed_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::Some("local_explicit"));
|
||||||
|
assert!(resolved.is_ok(), "explicit composite profile should resolve: {resolved:?}");
|
||||||
|
if let std::result::Result::Ok(resolved) = resolved {
|
||||||
|
assert_eq!(resolved.selection_source(), crate::ConfigProfileSelectionSource::Explicit);
|
||||||
|
let logging = resolved.component("logging");
|
||||||
|
assert!(logging.is_some(), "logging component should be resolved");
|
||||||
|
if let std::option::Option::Some(logging) = logging {
|
||||||
|
assert_eq!(logging.resolved().profile_id(), "local_dev");
|
||||||
|
assert_eq!(logging.resolved().selection_source(), crate::ConfigProfileSelectionSource::Composite);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unknown_composite_profile_has_profile_not_found_error() {
|
||||||
|
let engine = committed_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 result = engine.load_resolved_composite(&file_id, std::option::Option::Some("missing"));
|
||||||
|
assert!(result.is_err(), "unknown composite profile must be rejected: {result:?}");
|
||||||
|
if let std::result::Result::Err(error) = result {
|
||||||
|
assert_eq!(error.code(), crate::ERROR_CODE_PROFILE_NOT_FOUND);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unknown_referenced_file_id_is_rejected_during_composite_validation() {
|
||||||
|
let fixture = fixture_roots("unknown-reference");
|
||||||
|
let composite = r#"{
|
||||||
|
"format_version": 1,
|
||||||
|
"default_profile": "test",
|
||||||
|
"profiles": [{
|
||||||
|
"profile_id": "test",
|
||||||
|
"documents": [{"component_id": "missing", "file_id": "cfg.std.missing"}]
|
||||||
|
}]
|
||||||
|
}"#;
|
||||||
|
let prepared = prepare_fixture(&fixture, composite);
|
||||||
|
assert!(prepared.is_ok(), "composite fixture should be writable: {prepared:?}");
|
||||||
|
if prepared.is_ok() {
|
||||||
|
let engine = fixture_engine(&fixture);
|
||||||
|
let file_id = crate::ConfigFileId::new(TEST_COMPOSITE_FILE_ID);
|
||||||
|
assert!(engine.is_ok(), "fixture engine should be constructible: {engine:?}");
|
||||||
|
if let (std::result::Result::Ok(engine), std::result::Result::Ok(file_id)) = (engine, file_id) {
|
||||||
|
let result = engine.load_validated_document(&file_id);
|
||||||
|
assert!(result.is_err(), "unknown composite reference must be rejected: {result:?}");
|
||||||
|
if let std::result::Result::Err(error) = result {
|
||||||
|
assert_eq!(error.code(), crate::ERROR_CODE_COMPOSITE_REFERENCE_INVALID);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cleanup_fixture(&fixture);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn duplicate_component_ids_are_rejected_inside_one_composite_profile() {
|
||||||
|
let fixture = fixture_roots("duplicate-component");
|
||||||
|
let composite = r#"{
|
||||||
|
"format_version": 1,
|
||||||
|
"default_profile": "test",
|
||||||
|
"profiles": [{
|
||||||
|
"profile_id": "test",
|
||||||
|
"documents": [
|
||||||
|
{"component_id": "logging", "file_id": "cfg.std.logging"},
|
||||||
|
{"component_id": "logging", "file_id": "cfg.std.logging"}
|
||||||
|
]
|
||||||
|
}]
|
||||||
|
}"#;
|
||||||
|
let prepared = prepare_fixture(&fixture, composite);
|
||||||
|
assert!(prepared.is_ok(), "composite fixture should be writable: {prepared:?}");
|
||||||
|
if prepared.is_ok() {
|
||||||
|
let engine = fixture_engine(&fixture);
|
||||||
|
let file_id = crate::ConfigFileId::new(TEST_COMPOSITE_FILE_ID);
|
||||||
|
assert!(engine.is_ok(), "fixture engine should be constructible: {engine:?}");
|
||||||
|
if let (std::result::Result::Ok(engine), std::result::Result::Ok(file_id)) = (engine, file_id) {
|
||||||
|
let result = engine.load_validated_document(&file_id);
|
||||||
|
assert!(result.is_err(), "duplicate component_id must be rejected: {result:?}");
|
||||||
|
if let std::result::Result::Err(error) = result {
|
||||||
|
assert_eq!(error.code(), crate::ERROR_CODE_DOCUMENT_SEMANTIC_INVALID);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cleanup_fixture(&fixture);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn committed_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 bootstrap = match bootstrap {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let registry = test_registry(TEST_COMPOSITE_FILENAME);
|
||||||
|
return match registry {
|
||||||
|
std::result::Result::Ok(value) => std::result::Result::Ok(crate::ConfigDocumentEngine::new(bootstrap, value)),
|
||||||
|
std::result::Result::Err(error) => std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fixture_engine(fixture: &FixtureRoots) -> ksp_core_lib::Result<crate::ConfigDocumentEngine> {
|
||||||
|
let bootstrap = crate::ConfigBootstrapOptions::from_paths(fixture.config.as_path(), fixture.schemas.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 = test_registry("composite.test.json");
|
||||||
|
return match registry {
|
||||||
|
std::result::Result::Ok(value) => std::result::Result::Ok(crate::ConfigDocumentEngine::new(bootstrap, value)),
|
||||||
|
std::result::Result::Err(error) => std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn test_registry(composite_filename: &'static str) -> ksp_core_lib::Result<crate::ConfigFileRegistry> {
|
||||||
|
let logging = crate::registry::ConfigFileDescriptor::new(
|
||||||
|
crate::FILE_ID_STD_LOGGING,
|
||||||
|
crate::ConfigFileKind::Config,
|
||||||
|
crate::DEFAULT_STD_LOGGING_FILENAME,
|
||||||
|
std::option::Option::Some(crate::FILE_ID_SCHEMA_STD_LOGGING),
|
||||||
|
);
|
||||||
|
let logging = match logging {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let logging_schema = crate::registry::ConfigFileDescriptor::new(
|
||||||
|
crate::FILE_ID_SCHEMA_STD_LOGGING,
|
||||||
|
crate::ConfigFileKind::Schema,
|
||||||
|
crate::DEFAULT_STD_LOGGING_SCHEMA_FILENAME,
|
||||||
|
std::option::Option::None,
|
||||||
|
);
|
||||||
|
let logging_schema = match logging_schema {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let composite_schema = crate::registry::ConfigFileDescriptor::new(
|
||||||
|
crate::FILE_ID_SCHEMA_COMPOSITE,
|
||||||
|
crate::ConfigFileKind::Schema,
|
||||||
|
crate::DEFAULT_COMPOSITE_SCHEMA_FILENAME,
|
||||||
|
std::option::Option::None,
|
||||||
|
);
|
||||||
|
let composite_schema = match composite_schema {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let composite = crate::registry::ConfigFileDescriptor::new(
|
||||||
|
TEST_COMPOSITE_FILE_ID,
|
||||||
|
crate::ConfigFileKind::Config,
|
||||||
|
composite_filename,
|
||||||
|
std::option::Option::Some(crate::FILE_ID_SCHEMA_COMPOSITE),
|
||||||
|
);
|
||||||
|
let composite = match composite {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
return crate::registry::build_registry([logging, logging_schema, composite_schema, composite]);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn prepare_fixture(fixture: &FixtureRoots, composite: &str) -> std::io::Result<()> {
|
||||||
|
cleanup_fixture(fixture);
|
||||||
|
let config = std::fs::create_dir_all(fixture.config.as_path());
|
||||||
|
if let std::result::Result::Err(error) = config {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
let schemas = std::fs::create_dir_all(fixture.schemas.as_path());
|
||||||
|
if let std::result::Result::Err(error) = schemas {
|
||||||
|
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));
|
||||||
|
if let std::result::Result::Err(error) = logging {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
let logging_schema =
|
||||||
|
std::fs::copy(workspace.join("config/schemas/std.logging.schema.json"), fixture.schemas.join(crate::DEFAULT_STD_LOGGING_SCHEMA_FILENAME));
|
||||||
|
if let std::result::Result::Err(error) = logging_schema {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
let composite_schema =
|
||||||
|
std::fs::copy(workspace.join("config/schemas/composite.schema.json"), fixture.schemas.join(crate::DEFAULT_COMPOSITE_SCHEMA_FILENAME));
|
||||||
|
if let std::result::Result::Err(error) = composite_schema {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
return std::fs::write(fixture.config.join("composite.test.json"), composite);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn workspace_root() -> std::path::PathBuf {
|
||||||
|
return std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..");
|
||||||
|
}
|
||||||
|
|
||||||
|
struct FixtureRoots {
|
||||||
|
root: std::path::PathBuf,
|
||||||
|
config: std::path::PathBuf,
|
||||||
|
schemas: std::path::PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fixture_roots(name: &str) -> FixtureRoots {
|
||||||
|
let root = std::env::temp_dir().join(format!("ksp-config-composite-{name}-{}", std::process::id()));
|
||||||
|
return FixtureRoots { config: root.join("config"), schemas: root.join("schemas"), root };
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cleanup_fixture(fixture: &FixtureRoots) {
|
||||||
|
let removal = std::fs::remove_dir_all(fixture.root.as_path());
|
||||||
|
if let std::result::Result::Err(error) = removal
|
||||||
|
&& error.kind() != std::io::ErrorKind::NotFound
|
||||||
|
{
|
||||||
|
eprintln!("unable to cleanup Config composite fixture {}: {error}", fixture.root.display());
|
||||||
|
}
|
||||||
|
}
|
||||||
303
crates/ksp-config-lib/unit_tests/document.rs
Normal file
303
crates/ksp-config-lib/unit_tests/document.rs
Normal file
@@ -0,0 +1,303 @@
|
|||||||
|
// file: crates/ksp-config-lib/unit_tests/document.rs
|
||||||
|
// version: 2
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn committed_logging_document_passes_registered_schema_and_semantic_validation() {
|
||||||
|
let workspace = workspace_root();
|
||||||
|
let bootstrap = crate::ConfigBootstrapOptions::from_paths(workspace.join("config"), workspace.join("config/schemas"));
|
||||||
|
let registry = crate::ConfigFileRegistry::defaults();
|
||||||
|
let file_id = crate::ConfigFileId::new(crate::FILE_ID_STD_LOGGING);
|
||||||
|
assert!(bootstrap.is_ok(), "workspace Config paths should be valid: {bootstrap:?}");
|
||||||
|
assert!(registry.is_ok(), "default registry should be valid: {registry:?}");
|
||||||
|
assert!(file_id.is_ok(), "logging file_id should be valid: {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 = super::ConfigDocumentEngine::new(bootstrap, registry);
|
||||||
|
let document = engine.load_validated_document(&file_id);
|
||||||
|
assert!(document.is_ok(), "committed std.logging.json should validate: {document:?}");
|
||||||
|
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");
|
||||||
|
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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn missing_document_is_reported_with_file_read_error() {
|
||||||
|
let fixture = fixture_roots("missing-document");
|
||||||
|
let prepared = prepare_fixture(&fixture, std::option::Option::None, valid_minimal_logging_schema());
|
||||||
|
assert!(prepared.is_ok(), "fixture should be writable: {prepared:?}");
|
||||||
|
if prepared.is_ok() {
|
||||||
|
let result = load_fixture(&fixture);
|
||||||
|
assert_error_code(result, crate::ERROR_CODE_JSON_FILE_READ_FAILED);
|
||||||
|
}
|
||||||
|
cleanup_fixture(&fixture);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn malformed_json_is_reported_before_schema_validation() {
|
||||||
|
let fixture = fixture_roots("malformed-json");
|
||||||
|
let prepared = prepare_fixture(&fixture, std::option::Option::Some("{ invalid-json"), valid_minimal_logging_schema());
|
||||||
|
assert!(prepared.is_ok(), "fixture should be writable: {prepared:?}");
|
||||||
|
if prepared.is_ok() {
|
||||||
|
let result = load_fixture(&fixture);
|
||||||
|
assert_error_code(result, crate::ERROR_CODE_JSON_SYNTAX_INVALID);
|
||||||
|
}
|
||||||
|
cleanup_fixture(&fixture);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn invalid_schema_document_is_reported_before_instance_validation() {
|
||||||
|
let fixture = fixture_roots("invalid-schema");
|
||||||
|
let schema = r#"{
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"type": "definitely-not-a-json-schema-type"
|
||||||
|
}"#;
|
||||||
|
let prepared = prepare_fixture(&fixture, std::option::Option::Some("{}"), schema);
|
||||||
|
assert!(prepared.is_ok(), "fixture should be writable: {prepared:?}");
|
||||||
|
if prepared.is_ok() {
|
||||||
|
let result = load_fixture(&fixture);
|
||||||
|
assert_error_code(result, crate::ERROR_CODE_SCHEMA_INVALID);
|
||||||
|
}
|
||||||
|
cleanup_fixture(&fixture);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn schema_violation_is_distinct_from_json_syntax_failure() {
|
||||||
|
let fixture = fixture_roots("schema-violation");
|
||||||
|
let schema = r#"{
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"type": "object",
|
||||||
|
"required": ["required_field"],
|
||||||
|
"properties": {
|
||||||
|
"required_field": {"type": "string"}
|
||||||
|
}
|
||||||
|
}"#;
|
||||||
|
let prepared = prepare_fixture(&fixture, std::option::Option::Some("{}"), schema);
|
||||||
|
assert!(prepared.is_ok(), "fixture should be writable: {prepared:?}");
|
||||||
|
if prepared.is_ok() {
|
||||||
|
let result = load_fixture(&fixture);
|
||||||
|
assert_error_code(result, crate::ERROR_CODE_SCHEMA_VALIDATION_FAILED);
|
||||||
|
}
|
||||||
|
cleanup_fixture(&fixture);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn schema_valid_logging_document_can_still_fail_ksp_semantics() {
|
||||||
|
let fixture = fixture_roots("semantic-invalid");
|
||||||
|
let workspace = workspace_root();
|
||||||
|
let schema_source = std::fs::read_to_string(workspace.join("config/schemas/std.logging.schema.json"));
|
||||||
|
assert!(schema_source.is_ok(), "committed Logging schema should be readable: {schema_source:?}");
|
||||||
|
if let std::result::Result::Ok(schema_source) = schema_source {
|
||||||
|
let document = r#"{
|
||||||
|
"format_version": 1,
|
||||||
|
"logs_directory": "logs",
|
||||||
|
"default_profile": "duplicate-output",
|
||||||
|
"profiles": [
|
||||||
|
{
|
||||||
|
"profile_id": "duplicate-output",
|
||||||
|
"default_filter": "info",
|
||||||
|
"span_events": "off",
|
||||||
|
"console": {
|
||||||
|
"enabled": false,
|
||||||
|
"output": "stderr",
|
||||||
|
"ansi": false,
|
||||||
|
"format": "human",
|
||||||
|
"filter": {"level": "trace", "targets": ["*"], "domains": ["*"]}
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
{
|
||||||
|
"output_id": "file.same",
|
||||||
|
"enabled": true,
|
||||||
|
"path": "first.log",
|
||||||
|
"rotation": "daily",
|
||||||
|
"format": "human",
|
||||||
|
"ansi": false,
|
||||||
|
"filter": {"level": "info", "targets": ["*"], "domains": ["*"]}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"output_id": "file.same",
|
||||||
|
"enabled": true,
|
||||||
|
"path": "second.log",
|
||||||
|
"rotation": "daily",
|
||||||
|
"format": "human",
|
||||||
|
"ansi": false,
|
||||||
|
"filter": {"level": "info", "targets": ["*"], "domains": ["*"]}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"target_filters": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}"#;
|
||||||
|
let prepared = prepare_fixture(&fixture, std::option::Option::Some(document), schema_source.as_str());
|
||||||
|
assert!(prepared.is_ok(), "fixture should be writable: {prepared:?}");
|
||||||
|
if prepared.is_ok() {
|
||||||
|
let result = load_fixture(&fixture);
|
||||||
|
assert_error_code(result, crate::ERROR_CODE_DOCUMENT_SEMANTIC_INVALID);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cleanup_fixture(&fixture);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn duplicate_profile_ids_are_semantically_invalid() {
|
||||||
|
let fixture = fixture_roots("duplicate-profile-id");
|
||||||
|
let workspace = workspace_root();
|
||||||
|
let schema_source = std::fs::read_to_string(workspace.join("config/schemas/std.logging.schema.json"));
|
||||||
|
assert!(schema_source.is_ok(), "committed Logging schema should be readable: {schema_source:?}");
|
||||||
|
if let std::result::Result::Ok(schema_source) = schema_source {
|
||||||
|
let source = valid_logging_source_with_profiles("first", "first", "first");
|
||||||
|
let prepared = prepare_fixture(&fixture, std::option::Option::Some(source.as_str()), schema_source.as_str());
|
||||||
|
assert!(prepared.is_ok(), "duplicate profile fixture should be writable: {prepared:?}");
|
||||||
|
if prepared.is_ok() {
|
||||||
|
let result = load_fixture(&fixture);
|
||||||
|
assert_error_code(result, crate::ERROR_CODE_DOCUMENT_SEMANTIC_INVALID);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cleanup_fixture(&fixture);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn missing_default_profile_target_is_semantically_invalid() {
|
||||||
|
let fixture = fixture_roots("missing-default-profile");
|
||||||
|
let workspace = workspace_root();
|
||||||
|
let schema_source = std::fs::read_to_string(workspace.join("config/schemas/std.logging.schema.json"));
|
||||||
|
assert!(schema_source.is_ok(), "committed Logging schema should be readable: {schema_source:?}");
|
||||||
|
if let std::result::Result::Ok(schema_source) = schema_source {
|
||||||
|
let source = valid_logging_source_with_profiles("missing", "first", "second");
|
||||||
|
let prepared = prepare_fixture(&fixture, std::option::Option::Some(source.as_str()), schema_source.as_str());
|
||||||
|
assert!(prepared.is_ok(), "missing default profile fixture should be writable: {prepared:?}");
|
||||||
|
if prepared.is_ok() {
|
||||||
|
let result = load_fixture(&fixture);
|
||||||
|
assert_error_code(result, crate::ERROR_CODE_DOCUMENT_SEMANTIC_INVALID);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cleanup_fixture(&fixture);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn valid_logging_source_with_profiles(default_profile: &str, first_profile: &str, second_profile: &str) -> String {
|
||||||
|
return format!(
|
||||||
|
r#"{{
|
||||||
|
"format_version": 1,
|
||||||
|
"logs_directory": "logs",
|
||||||
|
"default_profile": "{default_profile}",
|
||||||
|
"profiles": [
|
||||||
|
{},
|
||||||
|
{}
|
||||||
|
]
|
||||||
|
}}"#,
|
||||||
|
valid_logging_profile(first_profile, "file.first"),
|
||||||
|
valid_logging_profile(second_profile, "file.second")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn valid_logging_profile(profile_id: &str, output_id: &str) -> String {
|
||||||
|
return format!(
|
||||||
|
r#"{{
|
||||||
|
"profile_id": "{profile_id}",
|
||||||
|
"default_filter": "info",
|
||||||
|
"span_events": "off",
|
||||||
|
"console": {{
|
||||||
|
"enabled": false,
|
||||||
|
"output": "stderr",
|
||||||
|
"ansi": false,
|
||||||
|
"format": "human",
|
||||||
|
"filter": {{"level": "trace", "targets": ["*"], "domains": ["*"]}}
|
||||||
|
}},
|
||||||
|
"files": [{{
|
||||||
|
"output_id": "{output_id}",
|
||||||
|
"enabled": true,
|
||||||
|
"path": "output.log",
|
||||||
|
"rotation": "daily",
|
||||||
|
"format": "human",
|
||||||
|
"ansi": false,
|
||||||
|
"filter": {{"level": "info", "targets": ["*"], "domains": ["*"]}}
|
||||||
|
}}],
|
||||||
|
"target_filters": []
|
||||||
|
}}"#
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_fixture(fixture: &FixtureRoots) -> ksp_core_lib::Result<super::ConfigJsonDocument> {
|
||||||
|
let bootstrap = crate::ConfigBootstrapOptions::from_paths(fixture.config.as_path(), fixture.schemas.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 = crate::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 = crate::ConfigFileId::new(crate::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 engine = super::ConfigDocumentEngine::new(bootstrap, registry);
|
||||||
|
return engine.load_validated_document(&file_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn prepare_fixture(fixture: &FixtureRoots, document: std::option::Option<&str>, schema: &str) -> std::io::Result<()> {
|
||||||
|
cleanup_fixture(fixture);
|
||||||
|
let config = std::fs::create_dir_all(fixture.config.as_path());
|
||||||
|
if let std::result::Result::Err(error) = config {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
let schemas = std::fs::create_dir_all(fixture.schemas.as_path());
|
||||||
|
if let std::result::Result::Err(error) = schemas {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
let schema_write = std::fs::write(fixture.schemas.join(crate::DEFAULT_STD_LOGGING_SCHEMA_FILENAME), schema);
|
||||||
|
if let std::result::Result::Err(error) = schema_write {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
if let std::option::Option::Some(document) = document {
|
||||||
|
let document_write = std::fs::write(fixture.config.join(crate::DEFAULT_STD_LOGGING_FILENAME), document);
|
||||||
|
if let std::result::Result::Err(error) = document_write {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn valid_minimal_logging_schema() -> &'static str {
|
||||||
|
return r#"{
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"type": "object"
|
||||||
|
}"#;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn assert_error_code(result: ksp_core_lib::Result<super::ConfigJsonDocument>, expected: ksp_core_lib::ErrorCode) {
|
||||||
|
assert!(result.is_err(), "fixture should fail with {expected:?}: {result:?}");
|
||||||
|
if let std::result::Result::Err(error) = result {
|
||||||
|
assert_eq!(error.code(), expected);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn workspace_root() -> std::path::PathBuf {
|
||||||
|
return std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..");
|
||||||
|
}
|
||||||
|
|
||||||
|
struct FixtureRoots {
|
||||||
|
root: std::path::PathBuf,
|
||||||
|
config: std::path::PathBuf,
|
||||||
|
schemas: std::path::PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fixture_roots(name: &str) -> FixtureRoots {
|
||||||
|
let mut root = std::env::temp_dir();
|
||||||
|
root.push(format!("ksp-config-lib-pre008-{name}-{}", std::process::id()));
|
||||||
|
return FixtureRoots { config: root.join("config"), schemas: root.join("schemas"), root };
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cleanup_fixture(fixture: &FixtureRoots) {
|
||||||
|
let result = std::fs::remove_dir_all(fixture.root.as_path());
|
||||||
|
if let std::result::Result::Err(error) = result {
|
||||||
|
assert_eq!(error.kind(), std::io::ErrorKind::NotFound, "fixture cleanup should only ignore missing directories: {error}");
|
||||||
|
}
|
||||||
|
}
|
||||||
362
crates/ksp-config-lib/unit_tests/environment.rs
Normal file
362
crates/ksp-config-lib/unit_tests/environment.rs
Normal file
@@ -0,0 +1,362 @@
|
|||||||
|
// file: crates/ksp-config-lib/unit_tests/environment.rs
|
||||||
|
// version: 4
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn process_environment_wins_over_dotenv_and_fallback_even_when_empty() {
|
||||||
|
let mut process = std::collections::BTreeMap::<String, String>::new();
|
||||||
|
process.insert("KSP_LOGS_DIRECTORY".to_owned(), String::new());
|
||||||
|
let mut dotenv = std::collections::BTreeMap::<String, String>::new();
|
||||||
|
dotenv.insert("KSP_LOGS_DIRECTORY".to_owned(), "dotenv-logs".to_owned());
|
||||||
|
let environment = super::ConfigEnvironment::from_maps(process, dotenv);
|
||||||
|
let resolved = environment.resolve_variable("KSP_LOGS_DIRECTORY", std::option::Option::Some("fallback-logs"));
|
||||||
|
assert!(resolved.is_ok(), "process value should resolve");
|
||||||
|
let resolved = match resolved {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
assert_eq!(resolved.value(), "");
|
||||||
|
assert_eq!(resolved.source(), super::ConfigEnvironmentSource::Process);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dotenv_wins_over_fallback_when_process_value_is_absent() {
|
||||||
|
let process = std::collections::BTreeMap::<String, String>::new();
|
||||||
|
let mut dotenv = std::collections::BTreeMap::<String, String>::new();
|
||||||
|
dotenv.insert("KSP_LOGS_DIRECTORY".to_owned(), "dotenv-logs".to_owned());
|
||||||
|
let environment = super::ConfigEnvironment::from_maps(process, dotenv);
|
||||||
|
let resolved = environment.resolve_variable("KSP_LOGS_DIRECTORY", std::option::Option::Some("fallback-logs"));
|
||||||
|
assert!(resolved.is_ok(), "dotenv value should resolve");
|
||||||
|
let resolved = match resolved {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
assert_eq!(resolved.value(), "dotenv-logs");
|
||||||
|
assert_eq!(resolved.source(), super::ConfigEnvironmentSource::DotEnv);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty_dotenv_value_is_defined_and_beats_fallback() {
|
||||||
|
let mut dotenv = std::collections::BTreeMap::<String, String>::new();
|
||||||
|
dotenv.insert("KSP_LOGS_DIRECTORY".to_owned(), String::new());
|
||||||
|
let environment = super::ConfigEnvironment::from_maps(std::collections::BTreeMap::new(), dotenv);
|
||||||
|
let resolved = environment.resolve_variable("KSP_LOGS_DIRECTORY", std::option::Option::Some("fallback-logs"));
|
||||||
|
assert!(resolved.is_ok(), "empty dotenv value should resolve");
|
||||||
|
if let std::result::Result::Ok(resolved) = resolved {
|
||||||
|
assert_eq!(resolved.value(), "");
|
||||||
|
assert_eq!(resolved.source(), super::ConfigEnvironmentSource::DotEnv);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fallback_is_used_only_when_external_sources_are_absent() {
|
||||||
|
let environment = super::ConfigEnvironment::from_maps(std::collections::BTreeMap::new(), std::collections::BTreeMap::new());
|
||||||
|
let resolved = environment.resolve_variable("KSP_LOGS_DIRECTORY", std::option::Option::Some("fallback-logs"));
|
||||||
|
assert!(resolved.is_ok(), "fallback should resolve");
|
||||||
|
let resolved = match resolved {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
assert_eq!(resolved.value(), "fallback-logs");
|
||||||
|
assert_eq!(resolved.source(), super::ConfigEnvironmentSource::Fallback);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn missing_variable_without_fallback_is_a_distinct_error() {
|
||||||
|
let environment = super::ConfigEnvironment::from_maps(std::collections::BTreeMap::new(), std::collections::BTreeMap::new());
|
||||||
|
let resolved = environment.resolve_variable("KSP_LOGS_DIRECTORY", std::option::Option::None);
|
||||||
|
let error = match resolved {
|
||||||
|
std::result::Result::Ok(_) => return,
|
||||||
|
std::result::Result::Err(error) => error,
|
||||||
|
};
|
||||||
|
assert_eq!(error.code(), crate::ERROR_CODE_ENVIRONMENT_VARIABLE_MISSING);
|
||||||
|
assert!(!error.to_string().contains("fallback-logs"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ksp_and_kspb_namespaces_are_supported_but_external_names_are_rejected() {
|
||||||
|
let mut process = std::collections::BTreeMap::<String, String>::new();
|
||||||
|
process.insert("KSP_LOGS_DIRECTORY".to_owned(), "logs".to_owned());
|
||||||
|
let bot_variable = ["KSPB_", "TEST_KEY"].concat();
|
||||||
|
process.insert(bot_variable.clone(), "hidden".to_owned());
|
||||||
|
let environment = super::ConfigEnvironment::from_maps(process, std::collections::BTreeMap::new());
|
||||||
|
assert!(environment.resolve_variable("KSP_LOGS_DIRECTORY", std::option::Option::None).is_ok());
|
||||||
|
assert!(environment.resolve_variable(bot_variable.as_str(), std::option::Option::None).is_ok());
|
||||||
|
let external = environment.resolve_variable("OTHER_NETWORK", std::option::Option::None);
|
||||||
|
let error = match external {
|
||||||
|
std::result::Result::Ok(_) => return,
|
||||||
|
std::result::Result::Err(error) => error,
|
||||||
|
};
|
||||||
|
assert_eq!(error.code(), crate::ERROR_CODE_ENVIRONMENT_VARIABLE_INVALID);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn text_resolver_supports_multiple_placeholders_and_literal_fallbacks() {
|
||||||
|
let environment = super::ConfigEnvironment::from_maps(std::collections::BTreeMap::new(), std::collections::BTreeMap::new());
|
||||||
|
let resolved = environment.resolve_text("logs=${KSP_LOGS_DIRECTORY:-logs};second=${KSP_LOGS_DIRECTORY:-other}");
|
||||||
|
assert!(resolved.is_ok(), "multiple placeholders should resolve");
|
||||||
|
if let std::result::Result::Ok(resolved) = resolved {
|
||||||
|
assert_eq!(resolved, "logs=logs;second=other");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn malformed_or_nested_placeholders_are_rejected() {
|
||||||
|
let environment = super::ConfigEnvironment::from_maps(std::collections::BTreeMap::new(), std::collections::BTreeMap::new());
|
||||||
|
let unclosed = environment.resolve_text("${KSP_LOGS_DIRECTORY");
|
||||||
|
let unclosed = match unclosed {
|
||||||
|
std::result::Result::Ok(_) => return,
|
||||||
|
std::result::Result::Err(error) => error,
|
||||||
|
};
|
||||||
|
assert_eq!(unclosed.code(), crate::ERROR_CODE_ENVIRONMENT_PLACEHOLDER_INVALID);
|
||||||
|
let nested = environment.resolve_text("${KSP_LOGS_DIRECTORY:-${KSP_LOGS_DIRECTORY}}}");
|
||||||
|
let nested = match nested {
|
||||||
|
std::result::Result::Ok(_) => return,
|
||||||
|
std::result::Result::Err(error) => error,
|
||||||
|
};
|
||||||
|
assert_eq!(nested.code(), crate::ERROR_CODE_ENVIRONMENT_PLACEHOLDER_INVALID);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn json_resolver_walks_objects_and_arrays_without_changing_keys() {
|
||||||
|
let mut dotenv = std::collections::BTreeMap::<String, String>::new();
|
||||||
|
dotenv.insert("KSP_LOGS_DIRECTORY".to_owned(), "runtime-logs".to_owned());
|
||||||
|
let environment = super::ConfigEnvironment::from_maps(std::collections::BTreeMap::new(), dotenv);
|
||||||
|
let source = serde_json::json!({"path": "${KSP_LOGS_DIRECTORY}", "items": [1, "${KSP_LOGS_DIRECTORY}"], "enabled": true});
|
||||||
|
let resolved = environment.resolve_json(&source);
|
||||||
|
assert!(resolved.is_ok(), "recursive JSON resolution should succeed");
|
||||||
|
let resolved = match resolved {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
assert_eq!(resolved["path"], serde_json::Value::String("runtime-logs".to_owned()));
|
||||||
|
assert_eq!(resolved["items"][1], serde_json::Value::String("runtime-logs".to_owned()));
|
||||||
|
assert_eq!(resolved["enabled"], serde_json::Value::Bool(true));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dotenv_parser_supports_comments_export_quotes_empty_values_and_ignores_external_keys() {
|
||||||
|
let path = std::path::Path::new("fixture.env");
|
||||||
|
let content = "# comment\nexport KSP_LOGS_DIRECTORY = 'quoted logs'\nOTHER_TOOL=value\n";
|
||||||
|
let parsed = super::parse_dotenv_content(path, content);
|
||||||
|
assert!(parsed.is_ok(), "dotenv fixture should parse");
|
||||||
|
let parsed = match parsed {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
assert_eq!(parsed.get("KSP_LOGS_DIRECTORY").map(String::as_str), std::option::Option::Some("quoted logs"));
|
||||||
|
assert!(!parsed.contains_key("OTHER_TOOL"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dotenv_duplicate_ksp_key_is_rejected() {
|
||||||
|
let parsed = super::parse_dotenv_content(std::path::Path::new("fixture.env"), "KSP_LOGS_DIRECTORY=one\nKSP_LOGS_DIRECTORY=two\n");
|
||||||
|
let error = match parsed {
|
||||||
|
std::result::Result::Ok(_) => return,
|
||||||
|
std::result::Result::Err(error) => error,
|
||||||
|
};
|
||||||
|
assert_eq!(error.code(), crate::ERROR_CODE_DOTENV_SYNTAX_INVALID);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fake_process_collection_filters_unrelated_names_without_mutating_real_environment() {
|
||||||
|
let values = vec![
|
||||||
|
(std::ffi::OsString::from("KSP_LOGS_DIRECTORY"), std::ffi::OsString::from("process-logs")),
|
||||||
|
(std::ffi::OsString::from("OTHER_TOOL"), std::ffi::OsString::from("ignored")),
|
||||||
|
];
|
||||||
|
let collected = super::collect_process_environment(values);
|
||||||
|
assert!(collected.is_ok(), "fake process environment should collect");
|
||||||
|
let collected = match collected {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
assert_eq!(collected.len(), 1);
|
||||||
|
assert_eq!(collected.get("KSP_LOGS_DIRECTORY").map(String::as_str), std::option::Option::Some("process-logs"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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"));
|
||||||
|
assert!(bootstrap.is_ok(), "bootstrap should resolve committed roots");
|
||||||
|
let bootstrap = match bootstrap {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
let registry = crate::ConfigFileRegistry::defaults();
|
||||||
|
assert!(registry.is_ok(), "default registry should build");
|
||||||
|
let registry = match registry {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
let engine = crate::ConfigDocumentEngine::new(bootstrap, registry);
|
||||||
|
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(_) => return,
|
||||||
|
};
|
||||||
|
let profile = engine.load_resolved_profile(&file_id, std::option::Option::None);
|
||||||
|
assert!(profile.is_ok(), "committed Logging profile should resolve");
|
||||||
|
let profile = match profile {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
let environment = super::ConfigEnvironment::from_maps(std::collections::BTreeMap::new(), std::collections::BTreeMap::new());
|
||||||
|
let effective = profile.resolve_effective_environment(&environment);
|
||||||
|
assert!(effective.is_ok(), "committed Logging environment fallback should resolve");
|
||||||
|
let effective = match effective {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
assert_eq!(profile.effective().get("logs_directory").and_then(serde_json::Value::as_str), std::option::Option::Some("${KSP_LOGS_DIRECTORY:-logs}"));
|
||||||
|
assert_eq!(effective.get("logs_directory").and_then(serde_json::Value::as_str), std::option::Option::Some("logs"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn env_example_inventory_contains_current_runtime_variable_with_preceding_comment() {
|
||||||
|
let content = std::fs::read_to_string(workspace_root().join(crate::DEFAULT_DOTENV_EXAMPLE_PATH));
|
||||||
|
assert!(content.is_ok(), ".env.example must be committed at workspace root");
|
||||||
|
let content = match content {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
let lines = content.lines().collect::<std::vec::Vec<&str>>();
|
||||||
|
let mut found = false;
|
||||||
|
for index in 0..lines.len() {
|
||||||
|
if lines[index].starts_with("KSP_LOGS_DIRECTORY=") {
|
||||||
|
found = true;
|
||||||
|
assert!(index > 0, "environment entry must have a preceding comment");
|
||||||
|
assert!(lines[index - 1].trim_start().starts_with('#'), "environment entry must be immediately preceded by a comment");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert!(found, "current Config environment variable must appear in .env.example");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn workspace_root() -> std::path::PathBuf {
|
||||||
|
return std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn secret_environment_value_keeps_real_value_but_redacts_safe_and_debug_views() {
|
||||||
|
let canary = "KSP_SECRET_CANARY_91b7c6";
|
||||||
|
let mut process = std::collections::BTreeMap::<String, String>::new();
|
||||||
|
process.insert("KSP_SECRET_TEST_TOKEN".to_owned(), canary.to_owned());
|
||||||
|
let environment = super::ConfigEnvironment::from_maps(process, std::collections::BTreeMap::new());
|
||||||
|
let resolved = environment.resolve_variable("KSP_SECRET_TEST_TOKEN", std::option::Option::None);
|
||||||
|
assert!(resolved.is_ok(), "secret process value should resolve");
|
||||||
|
let resolved = match resolved {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
assert_eq!(resolved.value(), canary);
|
||||||
|
assert_eq!(resolved.safe_value(), crate::REDACTED_CONFIG_VALUE);
|
||||||
|
assert_eq!(resolved.sensitivity(), crate::ConfigSensitivity::Secret);
|
||||||
|
assert_eq!(resolved.source(), super::ConfigEnvironmentSource::Process);
|
||||||
|
let debug = format!("{resolved:?}");
|
||||||
|
assert!(!debug.contains(canary), "Debug must not reveal the secret canary");
|
||||||
|
assert!(debug.contains(crate::REDACTED_CONFIG_VALUE));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn detailed_text_redacts_only_secret_segments_and_keeps_ordered_provenance() {
|
||||||
|
let secret = "secret-canary-4a62";
|
||||||
|
let mut process = std::collections::BTreeMap::<String, String>::new();
|
||||||
|
process.insert("KSP_PUBLIC_HOST".to_owned(), "rpc.example.test".to_owned());
|
||||||
|
process.insert("KSP_SECRET_TOKEN".to_owned(), secret.to_owned());
|
||||||
|
let environment = super::ConfigEnvironment::from_maps(process, std::collections::BTreeMap::new());
|
||||||
|
let resolved = environment.resolve_text_detailed("https://${KSP_PUBLIC_HOST}/?token=${KSP_SECRET_TOKEN}");
|
||||||
|
assert!(resolved.is_ok(), "composed secret URL should resolve");
|
||||||
|
let resolved = match resolved {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
assert_eq!(resolved.value(), "https://rpc.example.test/?token=secret-canary-4a62");
|
||||||
|
assert_eq!(resolved.safe_value(), "https://rpc.example.test/?token=********");
|
||||||
|
assert_eq!(resolved.sensitivity(), crate::ConfigSensitivity::Secret);
|
||||||
|
assert_eq!(resolved.provenance().len(), 4);
|
||||||
|
assert_eq!(resolved.provenance()[0], crate::ConfigValueProvenance::DocumentLiteral);
|
||||||
|
assert_eq!(resolved.provenance()[1].variable_name(), std::option::Option::Some("KSP_PUBLIC_HOST"));
|
||||||
|
assert_eq!(resolved.provenance()[2], crate::ConfigValueProvenance::DocumentLiteral);
|
||||||
|
assert_eq!(resolved.provenance()[3].variable_name(), std::option::Option::Some("KSP_SECRET_TOKEN"));
|
||||||
|
let debug = format!("{resolved:?}");
|
||||||
|
assert!(!debug.contains(secret), "resolved text Debug must not reveal a secret segment");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn secret_fallback_inherits_secret_sensitivity_and_is_redacted() {
|
||||||
|
let environment = super::ConfigEnvironment::from_maps(std::collections::BTreeMap::new(), std::collections::BTreeMap::new());
|
||||||
|
let resolved = environment.resolve_text_detailed("token=${KSP_SECRET_TOKEN:-false-secret}");
|
||||||
|
assert!(resolved.is_ok(), "secret fallback should resolve");
|
||||||
|
let resolved = match resolved {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
assert_eq!(resolved.value(), "token=false-secret");
|
||||||
|
assert_eq!(resolved.safe_value(), "token=********");
|
||||||
|
assert_eq!(resolved.sensitivity(), crate::ConfigSensitivity::Secret);
|
||||||
|
assert_eq!(resolved.provenance()[1].environment_source(), std::option::Option::Some(super::ConfigEnvironmentSource::Fallback));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn detailed_json_preserves_safe_tree_sensitivity_and_pointer_provenance() {
|
||||||
|
let secret = "nested-secret-canary-2d11";
|
||||||
|
let mut dotenv = std::collections::BTreeMap::<String, String>::new();
|
||||||
|
dotenv.insert("KSP_SECRET_TOKEN".to_owned(), secret.to_owned());
|
||||||
|
let environment = super::ConfigEnvironment::from_maps(std::collections::BTreeMap::new(), dotenv);
|
||||||
|
let source = serde_json::json!({"transport": {"url": "https://host/?token=${KSP_SECRET_TOKEN}"}, "items": ["plain", 7]});
|
||||||
|
let resolved = environment.resolve_json_detailed(&source);
|
||||||
|
assert!(resolved.is_ok(), "detailed JSON should resolve");
|
||||||
|
let resolved = match resolved {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
assert_eq!(resolved.value()["transport"]["url"], serde_json::Value::String(format!("https://host/?token={secret}")));
|
||||||
|
assert_eq!(resolved.safe_value()["transport"]["url"], serde_json::Value::String("https://host/?token=********".to_owned()));
|
||||||
|
assert_eq!(resolved.sensitivity(), crate::ConfigSensitivity::Secret);
|
||||||
|
let provenance = resolved.provenance_at("/transport/url");
|
||||||
|
assert!(provenance.is_some(), "JSON pointer provenance should exist");
|
||||||
|
if let std::option::Option::Some(provenance) = provenance {
|
||||||
|
assert_eq!(provenance.last().and_then(crate::ConfigValueProvenance::variable_name), std::option::Option::Some("KSP_SECRET_TOKEN"));
|
||||||
|
}
|
||||||
|
let debug = format!("{resolved:?}");
|
||||||
|
assert!(!debug.contains(secret), "resolved JSON Debug must not reveal a secret canary");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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"));
|
||||||
|
let bootstrap = match bootstrap {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
let registry = crate::ConfigFileRegistry::defaults();
|
||||||
|
let registry = match registry {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => 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(_) => return,
|
||||||
|
};
|
||||||
|
let engine = crate::ConfigDocumentEngine::new(bootstrap, registry);
|
||||||
|
let profile = engine.load_resolved_profile(&file_id, std::option::Option::None);
|
||||||
|
let profile = match profile {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
let environment = super::ConfigEnvironment::from_maps(std::collections::BTreeMap::new(), std::collections::BTreeMap::new());
|
||||||
|
let effective = profile.resolve_effective_environment_detailed(&environment);
|
||||||
|
assert!(effective.is_ok(), "detailed committed Logging profile should resolve");
|
||||||
|
let effective = match effective {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
assert_eq!(profile.origin("logs_directory"), std::option::Option::Some(crate::ConfigValueOrigin::Global));
|
||||||
|
assert_eq!(effective.value()["logs_directory"], serde_json::Value::String("logs".to_owned()));
|
||||||
|
assert_eq!(effective.safe_value()["logs_directory"], serde_json::Value::String("logs".to_owned()));
|
||||||
|
assert_eq!(
|
||||||
|
effective.provenance_at("/logs_directory").and_then(|items| return items.last()).and_then(crate::ConfigValueProvenance::variable_name),
|
||||||
|
std::option::Option::Some("KSP_LOGS_DIRECTORY"),
|
||||||
|
);
|
||||||
|
}
|
||||||
329
crates/ksp-config-lib/unit_tests/logging.rs
Normal file
329
crates/ksp-config-lib/unit_tests/logging.rs
Normal file
@@ -0,0 +1,329 @@
|
|||||||
|
// file: crates/ksp-config-lib/unit_tests/logging.rs
|
||||||
|
// version: 1
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn committed_logging_profile_maps_complete_runtime_contract() {
|
||||||
|
let engine = committed_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");
|
||||||
|
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 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(), 2);
|
||||||
|
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);
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
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"],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn relative_logs_directory_is_anchored_to_process_current_directory() {
|
||||||
|
let engine = committed_engine();
|
||||||
|
let engine = match engine {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
let environment = environment_with_logs_directory("relative-ksp-logs");
|
||||||
|
let resolved = engine.load_resolved_logging_config(std::option::Option::None, &environment);
|
||||||
|
assert!(resolved.is_ok(), "relative Logging root should map");
|
||||||
|
if let std::result::Result::Ok(resolved) = resolved {
|
||||||
|
assert_eq!(resolved.logs_directory(), current_directory().join("relative-ksp-logs").as_path());
|
||||||
|
assert_eq!(resolved.settings().files()[0].directory(), current_directory().join("relative-ksp-logs/debug").as_path());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn absolute_logs_directory_is_preserved() {
|
||||||
|
let engine = committed_engine();
|
||||||
|
let engine = match engine {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
let absolute = std::env::temp_dir().join(format!("ksp-pre012-absolute-{}", std::process::id()));
|
||||||
|
let environment = environment_with_logs_directory(absolute.to_string_lossy().as_ref());
|
||||||
|
let resolved = engine.load_resolved_logging_config(std::option::Option::None, &environment);
|
||||||
|
assert!(resolved.is_ok(), "absolute Logging root should map even when it does not exist yet");
|
||||||
|
if let std::result::Result::Ok(resolved) = resolved {
|
||||||
|
assert_eq!(resolved.logs_directory(), absolute.as_path());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn effective_file_paths_cannot_escape_logging_root() {
|
||||||
|
assert!(super::relative_file_path_is_valid("debug/ksp.log"));
|
||||||
|
assert!(super::relative_file_path_is_valid("ksp.log"));
|
||||||
|
assert!(!super::relative_file_path_is_valid("../ksp.log"));
|
||||||
|
assert!(!super::relative_file_path_is_valid("./ksp.log"));
|
||||||
|
assert!(!super::relative_file_path_is_valid("/var/log/ksp.log"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn explicit_empty_logs_directory_is_invalid_instead_of_using_fallback() {
|
||||||
|
let engine = committed_engine();
|
||||||
|
let engine = match engine {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
let environment = environment_with_logs_directory("");
|
||||||
|
let resolved = engine.load_resolved_logging_config(std::option::Option::None, &environment);
|
||||||
|
let error = match resolved {
|
||||||
|
std::result::Result::Ok(_) => return,
|
||||||
|
std::result::Result::Err(error) => error,
|
||||||
|
};
|
||||||
|
assert_eq!(error.code(), crate::ERROR_CODE_EFFECTIVE_CONFIG_INVALID);
|
||||||
|
assert!(error.context().iter().any(|item| -> bool {
|
||||||
|
return item.key() == "field" && item.value() == "logs_directory";
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn existing_non_directory_logging_root_is_rejected_without_secret_leak() {
|
||||||
|
let engine = committed_engine();
|
||||||
|
let engine = match engine {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
let secret = format!("ksp-secret-path-canary-{}", std::process::id());
|
||||||
|
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 = match profile {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => {
|
||||||
|
cleanup_file(path.as_path());
|
||||||
|
return;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let resolved = super::resolve_logs_directory(path.to_string_lossy().as_ref(), crate::REDACTED_CONFIG_VALUE, &profile);
|
||||||
|
cleanup_file(path.as_path());
|
||||||
|
let error = match resolved {
|
||||||
|
std::result::Result::Ok(_) => return,
|
||||||
|
std::result::Result::Err(error) => error,
|
||||||
|
};
|
||||||
|
assert_eq!(error.code(), crate::ERROR_CODE_EFFECTIVE_CONFIG_INVALID);
|
||||||
|
let debug = format!("{error:?}");
|
||||||
|
assert!(!debug.contains(secret.as_str()), "effective Config diagnostics must not reveal real secret-derived paths");
|
||||||
|
assert!(debug.contains(crate::REDACTED_CONFIG_VALUE));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn logging_adapter_rejects_secret_effective_values_without_exposing_canary() {
|
||||||
|
let engine = committed_engine();
|
||||||
|
let engine = match engine {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
let profile = load_committed_profile(&engine);
|
||||||
|
let profile = match profile {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
let canary = "ksp-pre012-secret-canary-c3e4";
|
||||||
|
let mut process = std::collections::BTreeMap::<String, String>::new();
|
||||||
|
process.insert("KSP_SECRET_LOGGING_CANARY".to_owned(), canary.to_owned());
|
||||||
|
let environment = crate::ConfigEnvironment::from_maps(process, std::collections::BTreeMap::new());
|
||||||
|
let effective = environment.resolve_json_detailed(&serde_json::json!({"canary": "${KSP_SECRET_LOGGING_CANARY}"}));
|
||||||
|
assert!(effective.is_ok(), "secret canary fixture should resolve");
|
||||||
|
let effective = match effective {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
let validation = super::validate_logging_sensitivity(&profile, &effective);
|
||||||
|
let error = match validation {
|
||||||
|
std::result::Result::Ok(()) => return,
|
||||||
|
std::result::Result::Err(error) => error,
|
||||||
|
};
|
||||||
|
assert_eq!(error.code(), crate::ERROR_CODE_EFFECTIVE_CONFIG_INVALID);
|
||||||
|
let debug = format!("{error:?}");
|
||||||
|
assert!(!debug.contains(canary), "Logging adapter diagnostics must not reveal secret canaries");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mapped_logging_settings_can_initialize_and_reinitialize_runtime() {
|
||||||
|
let engine = committed_engine();
|
||||||
|
let engine = match engine {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
let root = std::env::temp_dir().join(format!("ksp-pre012-runtime-{}", std::process::id()));
|
||||||
|
cleanup_directory(root.as_path());
|
||||||
|
let environment = environment_with_logs_directory(root.to_string_lossy().as_ref());
|
||||||
|
let resolved = engine.load_resolved_logging_config(std::option::Option::None, &environment);
|
||||||
|
assert!(resolved.is_ok(), "runtime Logging Config should map");
|
||||||
|
let resolved = match resolved {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
let guard = ksp_logging_lib::initialize(resolved.settings());
|
||||||
|
assert!(guard.is_ok(), "mapped Logging settings should initialize the Logging runtime");
|
||||||
|
let mut guard = match guard {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
assert!(root.join("debug").is_dir(), "Logging initialization should create the first configured file directory");
|
||||||
|
assert!(root.join("config").is_dir(), "Logging initialization should create the second configured file directory");
|
||||||
|
let reload = ksp_logging_lib::reinitialize(&mut guard, resolved.settings());
|
||||||
|
assert!(reload.is_ok(), "mapped Logging settings should support hot reload");
|
||||||
|
let disabled = ksp_logging_lib::LoggingSettings::new(
|
||||||
|
ksp_logging_lib::LogFilterLevel::Off,
|
||||||
|
ksp_logging_lib::SpanEvents::Off,
|
||||||
|
std::option::Option::None,
|
||||||
|
std::vec::Vec::new(),
|
||||||
|
);
|
||||||
|
let disable = ksp_logging_lib::reinitialize(&mut guard, &disabled);
|
||||||
|
assert!(disable.is_ok(), "test Logging runtime should disable outputs before cleanup");
|
||||||
|
drop(guard);
|
||||||
|
cleanup_directory(root.as_path());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolved_logging_debug_uses_safe_effective_view() {
|
||||||
|
let engine = committed_engine();
|
||||||
|
let engine = match engine {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
let root = std::env::temp_dir().join(format!("ksp-pre012-debug-{}", std::process::id()));
|
||||||
|
let environment = environment_with_logs_directory(root.to_string_lossy().as_ref());
|
||||||
|
let resolved = engine.load_resolved_logging_config(std::option::Option::None, &environment);
|
||||||
|
assert!(resolved.is_ok(), "Logging Config should map for Debug contract");
|
||||||
|
if let std::result::Result::Ok(resolved) = resolved {
|
||||||
|
let debug = format!("{resolved:?}");
|
||||||
|
assert!(debug.contains("ResolvedLoggingConfig"));
|
||||||
|
assert!(debug.contains("effective"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn assert_file(
|
||||||
|
file: &ksp_logging_lib::FileSettings,
|
||||||
|
output_id: &str,
|
||||||
|
directory: &std::path::Path,
|
||||||
|
file_name: &str,
|
||||||
|
rotation: ksp_logging_lib::FileRotation,
|
||||||
|
format: ksp_logging_lib::LogFormat,
|
||||||
|
level: ksp_logging_lib::LogFilterLevel,
|
||||||
|
targets: &[&str],
|
||||||
|
domains: &[&str],
|
||||||
|
) {
|
||||||
|
assert_eq!(file.output_id(), output_id);
|
||||||
|
assert!(file.enabled());
|
||||||
|
assert_eq!(file.directory(), directory);
|
||||||
|
assert_eq!(file.file_name_prefix(), file_name);
|
||||||
|
assert_eq!(file.rotation(), rotation);
|
||||||
|
assert_eq!(file.format(), format);
|
||||||
|
assert!(!file.ansi());
|
||||||
|
assert_eq!(file.filter().level(), level);
|
||||||
|
assert_eq!(file.filter().targets().iter().map(String::as_str).collect::<std::vec::Vec<&str>>(), targets.to_vec());
|
||||||
|
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> {
|
||||||
|
let workspace = workspace_root();
|
||||||
|
let bootstrap = crate::ConfigBootstrapOptions::from_paths(workspace.join("config"), 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),
|
||||||
|
};
|
||||||
|
let registry = crate::ConfigFileRegistry::defaults();
|
||||||
|
let registry = match registry {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
return std::result::Result::Ok(crate::ConfigDocumentEngine::new(bootstrap, registry));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_committed_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,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
return engine.load_resolved_profile(&file_id, std::option::Option::None);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn environment_with_logs_directory(value: &str) -> crate::ConfigEnvironment {
|
||||||
|
let mut process = std::collections::BTreeMap::<String, String>::new();
|
||||||
|
process.insert("KSP_LOGS_DIRECTORY".to_owned(), value.to_owned());
|
||||||
|
return crate::ConfigEnvironment::from_maps(process, std::collections::BTreeMap::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn workspace_root() -> std::path::PathBuf {
|
||||||
|
return std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn current_directory() -> std::path::PathBuf {
|
||||||
|
let current = std::env::current_dir();
|
||||||
|
return match current {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => std::path::PathBuf::new(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cleanup_file(path: &std::path::Path) {
|
||||||
|
let removal = std::fs::remove_file(path);
|
||||||
|
if let std::result::Result::Err(error) = removal
|
||||||
|
&& error.kind() != std::io::ErrorKind::NotFound
|
||||||
|
{
|
||||||
|
eprintln!("unable to cleanup Config Logging adapter file {}: {error}", path.display());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cleanup_directory(path: &std::path::Path) {
|
||||||
|
let removal = std::fs::remove_dir_all(path);
|
||||||
|
if let std::result::Result::Err(error) = removal
|
||||||
|
&& error.kind() != std::io::ErrorKind::NotFound
|
||||||
|
{
|
||||||
|
eprintln!("unable to cleanup Config Logging adapter directory {}: {error}", path.display());
|
||||||
|
}
|
||||||
|
}
|
||||||
357
crates/ksp-config-lib/unit_tests/management.rs
Normal file
357
crates/ksp-config-lib/unit_tests/management.rs
Normal file
@@ -0,0 +1,357 @@
|
|||||||
|
// file: crates/ksp-config-lib/unit_tests/management.rs
|
||||||
|
// version: 1
|
||||||
|
|
||||||
|
static NEXT_FIXTURE_ID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
|
||||||
|
|
||||||
|
struct ManagementFixture {
|
||||||
|
root: std::path::PathBuf,
|
||||||
|
config_path: std::path::PathBuf,
|
||||||
|
dotenv_path: std::path::PathBuf,
|
||||||
|
management: crate::ConfigManagement,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn raw_management_read_remains_available_for_schema_invalid_source() {
|
||||||
|
let fixture = management_fixture();
|
||||||
|
let fixture = match fixture {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
let invalid = "{\n \"format_version\": 1\n}\n";
|
||||||
|
let write = std::fs::write(fixture.config_path.as_path(), invalid.as_bytes());
|
||||||
|
assert!(write.is_ok(), "schema-invalid management fixture should be written");
|
||||||
|
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 raw = fixture.management.read_source(&file_id);
|
||||||
|
assert!(raw.is_ok(), "raw management read should not require schema validity: {raw:?}");
|
||||||
|
if let std::result::Result::Ok(raw) = raw {
|
||||||
|
assert_eq!(raw.content(), invalid);
|
||||||
|
assert_eq!(raw.file_id(), &file_id);
|
||||||
|
let debug = format!("{raw:?}");
|
||||||
|
assert!(!debug.contains("format_version"), "raw source content must not be exposed by Debug");
|
||||||
|
}
|
||||||
|
let typed = fixture.management.load_logging_document();
|
||||||
|
assert!(typed.is_err(), "typed management load must still require a valid source document");
|
||||||
|
cleanup_fixture(&fixture);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn typed_logging_document_can_be_mutated_validated_and_persisted_atomically() {
|
||||||
|
let fixture = management_fixture();
|
||||||
|
let fixture = match fixture {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
let document = fixture.management.load_logging_document();
|
||||||
|
assert!(document.is_ok(), "committed Logging document should load through management: {document:?}");
|
||||||
|
let mut document = match document {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => {
|
||||||
|
cleanup_fixture(&fixture);
|
||||||
|
return;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
assert_eq!(document.format_version(), 1);
|
||||||
|
assert_eq!(document.default_profile(), "local_dev");
|
||||||
|
assert_eq!(document.profiles().len(), 1);
|
||||||
|
document.set_logs_directory("managed-logs");
|
||||||
|
if let std::option::Option::Some(profile) = document.profiles_mut().first_mut() {
|
||||||
|
profile.set_default_filter("info");
|
||||||
|
}
|
||||||
|
let report = fixture.management.save_logging_document(&document);
|
||||||
|
assert!(report.is_ok(), "valid typed Logging mutation should persist: {report:?}");
|
||||||
|
if let std::result::Result::Ok(report) = report {
|
||||||
|
assert!(report.source_changed());
|
||||||
|
assert!(report.reload_required());
|
||||||
|
}
|
||||||
|
let persisted = std::fs::read_to_string(fixture.config_path.as_path());
|
||||||
|
assert!(persisted.is_ok(), "persisted Logging document should remain readable");
|
||||||
|
if let std::result::Result::Ok(persisted) = persisted {
|
||||||
|
assert!(persisted.ends_with('\n'));
|
||||||
|
assert!(persisted.contains("\"logs_directory\": \"managed-logs\""));
|
||||||
|
assert!(persisted.contains("\"default_filter\": \"info\""));
|
||||||
|
}
|
||||||
|
let reloaded = fixture.management.load_logging_document();
|
||||||
|
assert!(reloaded.is_ok(), "persisted Logging document should remain valid: {reloaded:?}");
|
||||||
|
if let std::result::Result::Ok(reloaded) = reloaded {
|
||||||
|
assert_eq!(reloaded.logs_directory(), "managed-logs");
|
||||||
|
assert_eq!(reloaded.profiles()[0].default_filter(), "info");
|
||||||
|
}
|
||||||
|
cleanup_fixture(&fixture);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn invalid_logging_candidate_does_not_modify_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 document = fixture.management.load_logging_document();
|
||||||
|
let mut document = match document {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => {
|
||||||
|
cleanup_fixture(&fixture);
|
||||||
|
return;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
document.set_logs_directory("");
|
||||||
|
let save = fixture.management.save_logging_document(&document);
|
||||||
|
assert!(save.is_err(), "schema-invalid candidate must be rejected before persistence");
|
||||||
|
let after = std::fs::read(fixture.config_path.as_path());
|
||||||
|
assert_eq!(after.ok(), std::option::Option::Some(before));
|
||||||
|
cleanup_fixture(&fixture);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unchanged_logging_candidate_reports_no_reload() {
|
||||||
|
let fixture = management_fixture();
|
||||||
|
let fixture = match fixture {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
let document = fixture.management.load_logging_document();
|
||||||
|
let document = match document {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => {
|
||||||
|
cleanup_fixture(&fixture);
|
||||||
|
return;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let first = fixture.management.save_logging_document(&document);
|
||||||
|
assert!(first.is_ok(), "normalization save should succeed: {first:?}");
|
||||||
|
let second = fixture.management.save_logging_document(&document);
|
||||||
|
assert!(second.is_ok(), "second identical save should succeed: {second:?}");
|
||||||
|
if let std::result::Result::Ok(second) = second {
|
||||||
|
assert!(!second.source_changed());
|
||||||
|
assert!(!second.reload_required());
|
||||||
|
}
|
||||||
|
cleanup_fixture(&fixture);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dotenv_create_update_and_remove_round_trip_through_management() {
|
||||||
|
let fixture = management_fixture();
|
||||||
|
let fixture = match fixture {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
let create = fixture.management.set_dotenv_value("KSP_PRE013_MANAGED_TEST_VALUE", "alpha");
|
||||||
|
assert!(create.is_ok(), "managed .env create should succeed: {create:?}");
|
||||||
|
if let std::result::Result::Ok(create) = create {
|
||||||
|
assert!(create.source_changed());
|
||||||
|
assert!(create.effective_changed());
|
||||||
|
assert!(!create.shadowed_by_process_environment());
|
||||||
|
assert!(create.reload_required());
|
||||||
|
}
|
||||||
|
let update = fixture.management.set_dotenv_value("KSP_PRE013_MANAGED_TEST_VALUE", "hello world # 2");
|
||||||
|
assert!(update.is_ok(), "managed .env update should support quoting: {update:?}");
|
||||||
|
let revealed = fixture.management.reveal_dotenv_value("KSP_PRE013_MANAGED_TEST_VALUE");
|
||||||
|
assert_eq!(revealed.ok(), std::option::Option::Some(std::option::Option::Some("hello world # 2".to_owned())));
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
let metadata = std::fs::metadata(fixture.dotenv_path.as_path());
|
||||||
|
assert!(metadata.is_ok(), "new managed .env permissions should be inspectable");
|
||||||
|
if let std::result::Result::Ok(metadata) = metadata {
|
||||||
|
let mode = <std::fs::Permissions as std::os::unix::fs::PermissionsExt>::mode(&metadata.permissions());
|
||||||
|
assert_eq!(mode & 0o777, 0o600, "new .env must be private on Unix");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let content = std::fs::read_to_string(fixture.dotenv_path.as_path());
|
||||||
|
assert!(content.is_ok(), "managed .env should be readable");
|
||||||
|
if let std::result::Result::Ok(content) = content {
|
||||||
|
assert!(content.contains("KSP_PRE013_MANAGED_TEST_VALUE=\"hello world # 2\""));
|
||||||
|
}
|
||||||
|
let remove = fixture.management.remove_dotenv_value("KSP_PRE013_MANAGED_TEST_VALUE");
|
||||||
|
assert!(remove.is_ok(), "managed .env remove should succeed: {remove:?}");
|
||||||
|
if let std::result::Result::Ok(remove) = remove {
|
||||||
|
assert!(remove.source_changed());
|
||||||
|
assert!(remove.effective_changed());
|
||||||
|
assert!(remove.reload_required());
|
||||||
|
}
|
||||||
|
assert_eq!(fixture.management.reveal_dotenv_value("KSP_PRE013_MANAGED_TEST_VALUE").ok(), std::option::Option::Some(std::option::Option::None));
|
||||||
|
cleanup_fixture(&fixture);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dotenv_comments_and_unrelated_entries_survive_targeted_mutation() {
|
||||||
|
let fixture = management_fixture();
|
||||||
|
let fixture = match fixture {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
let source = "# local header\nEXTERNAL_VALUE=keep\n# managed comment\nKSP_PRE013_MANAGED_TEST_VALUE=before\n";
|
||||||
|
let write = std::fs::write(fixture.dotenv_path.as_path(), source.as_bytes());
|
||||||
|
assert!(write.is_ok(), "dotenv preservation fixture should be written");
|
||||||
|
let update = fixture.management.set_dotenv_value("KSP_PRE013_MANAGED_TEST_VALUE", "after");
|
||||||
|
assert!(update.is_ok(), "targeted .env update should succeed: {update:?}");
|
||||||
|
let content = std::fs::read_to_string(fixture.dotenv_path.as_path());
|
||||||
|
assert!(content.is_ok(), "updated .env should remain readable");
|
||||||
|
if let std::result::Result::Ok(content) = content {
|
||||||
|
assert!(content.contains("# local header"));
|
||||||
|
assert!(content.contains("EXTERNAL_VALUE=keep"));
|
||||||
|
assert!(content.contains("# managed comment"));
|
||||||
|
assert!(content.contains("KSP_PRE013_MANAGED_TEST_VALUE=after"));
|
||||||
|
}
|
||||||
|
cleanup_fixture(&fixture);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn invalid_existing_dotenv_is_not_modified_by_management() {
|
||||||
|
let fixture = management_fixture();
|
||||||
|
let fixture = match fixture {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
let invalid = "KSP_BROKEN\n";
|
||||||
|
let write = std::fs::write(fixture.dotenv_path.as_path(), invalid.as_bytes());
|
||||||
|
assert!(write.is_ok(), "invalid .env fixture should be written");
|
||||||
|
let update = fixture.management.set_dotenv_value("KSP_PRE013_MANAGED_TEST_VALUE", "value");
|
||||||
|
assert!(update.is_err(), "management must reject mutation when existing .env syntax is invalid");
|
||||||
|
assert_eq!(std::fs::read_to_string(fixture.dotenv_path.as_path()).ok(), std::option::Option::Some(invalid.to_owned()));
|
||||||
|
cleanup_fixture(&fixture);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unsupported_environment_name_is_rejected_without_creating_dotenv() {
|
||||||
|
let fixture = management_fixture();
|
||||||
|
let fixture = match fixture {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
let update = fixture.management.set_dotenv_value("OTHER_TOKEN", "value");
|
||||||
|
assert!(update.is_err(), "non-KSP variable must be rejected");
|
||||||
|
assert!(!fixture.dotenv_path.exists(), "rejected mutation must not create .env");
|
||||||
|
cleanup_fixture(&fixture);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn management_report_redacts_secret_but_explicit_reveal_returns_real_value() {
|
||||||
|
let fixture = management_fixture();
|
||||||
|
let fixture = match fixture {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
let canary = "SECRET-CANARY-PRE013";
|
||||||
|
let write = std::fs::write(fixture.dotenv_path.as_path(), format!("KSP_SECRET_PRE013_MANAGED_TEST_TOKEN={canary}\n"));
|
||||||
|
assert!(write.is_ok(), "secret management fixture should be written");
|
||||||
|
let reports = fixture.management.environment_report();
|
||||||
|
assert!(reports.is_ok(), "safe environment report should load: {reports:?}");
|
||||||
|
if let std::result::Result::Ok(reports) = reports {
|
||||||
|
let report = reports.iter().find(|report| -> bool {
|
||||||
|
return report.variable_name() == "KSP_SECRET_PRE013_MANAGED_TEST_TOKEN";
|
||||||
|
});
|
||||||
|
assert!(report.is_some(), "secret entry should appear in management report");
|
||||||
|
if let std::option::Option::Some(report) = report {
|
||||||
|
assert_eq!(report.sensitivity(), crate::ConfigSensitivity::Secret);
|
||||||
|
assert_eq!(report.desired_safe_value(), std::option::Option::Some(crate::REDACTED_CONFIG_VALUE));
|
||||||
|
assert_eq!(report.effective_safe_value(), std::option::Option::Some(crate::REDACTED_CONFIG_VALUE));
|
||||||
|
assert!(!report.shadowed_by_process_environment());
|
||||||
|
assert!(!format!("{report:?}").contains(canary));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let reveal = fixture.management.reveal_effective_environment_value("KSP_SECRET_PRE013_MANAGED_TEST_TOKEN");
|
||||||
|
assert_eq!(reveal.ok(), std::option::Option::Some(std::option::Option::Some(canary.to_owned())));
|
||||||
|
cleanup_fixture(&fixture);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn process_shadowing_report_distinguishes_desired_and_effective_changes() {
|
||||||
|
let mut process_before = std::collections::BTreeMap::<String, String>::new();
|
||||||
|
process_before.insert("KSP_MODE".to_owned(), "process".to_owned());
|
||||||
|
let mut dotenv_before = std::collections::BTreeMap::<String, String>::new();
|
||||||
|
dotenv_before.insert("KSP_MODE".to_owned(), "desired-a".to_owned());
|
||||||
|
let before = crate::ConfigEnvironment::from_maps(process_before.clone(), dotenv_before);
|
||||||
|
let mut dotenv_after = std::collections::BTreeMap::<String, String>::new();
|
||||||
|
dotenv_after.insert("KSP_MODE".to_owned(), "desired-b".to_owned());
|
||||||
|
let after = crate::ConfigEnvironment::from_maps(process_before, dotenv_after);
|
||||||
|
let report = super::environment_change_report("KSP_MODE", &before, &after);
|
||||||
|
assert!(report.source_changed());
|
||||||
|
assert!(!report.effective_changed());
|
||||||
|
assert!(report.shadowed_by_process_environment());
|
||||||
|
assert!(!report.reload_required());
|
||||||
|
let reports = super::build_environment_reports(&after);
|
||||||
|
assert!(reports.is_ok(), "shadow report fixture should build: {reports:?}");
|
||||||
|
if let std::result::Result::Ok(reports) = reports {
|
||||||
|
assert_eq!(reports.len(), 1);
|
||||||
|
assert_eq!(reports[0].desired_safe_value(), std::option::Option::Some("desired-b"));
|
||||||
|
assert_eq!(reports[0].effective_safe_value(), std::option::Option::Some("process"));
|
||||||
|
assert_eq!(reports[0].effective_source(), std::option::Option::Some(crate::ConfigEnvironmentSource::Process));
|
||||||
|
assert!(reports[0].shadowed_by_process_environment());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn management_fixture() -> ksp_core_lib::Result<ManagementFixture> {
|
||||||
|
let fixture_id = NEXT_FIXTURE_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||||
|
let root = std::env::temp_dir().join(format!("ksp-pre013-management-{}-{fixture_id}", 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_PERSISTENCE_WRITE_FAILED, "unable to cleanup previous management 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_PERSISTENCE_WRITE_FAILED, "unable to create management 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 copy_config = std::fs::copy(source_config.as_path(), config_path.as_path());
|
||||||
|
if let std::result::Result::Err(error) = copy_config {
|
||||||
|
return std::result::Result::Err(
|
||||||
|
ksp_core_lib::Error::new(crate::ERROR_CODE_PERSISTENCE_WRITE_FAILED, "unable to copy management Config fixture").with_source(error),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let copy_schema = std::fs::copy(source_schema.as_path(), schema_path.as_path());
|
||||||
|
if let std::result::Result::Err(error) = copy_schema {
|
||||||
|
return std::result::Result::Err(
|
||||||
|
ksp_core_lib::Error::new(crate::ERROR_CODE_PERSISTENCE_WRITE_FAILED, "unable to copy management schema fixture").with_source(error),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let bootstrap = crate::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 = crate::ConfigFileRegistry::defaults();
|
||||||
|
let registry = match registry {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let engine = crate::ConfigDocumentEngine::new(bootstrap, registry);
|
||||||
|
let dotenv_path = root.join(".env");
|
||||||
|
let management = crate::ConfigManagement::with_dotenv_path(engine, dotenv_path.clone());
|
||||||
|
return std::result::Result::Ok(ManagementFixture { root, config_path, dotenv_path, management });
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cleanup_fixture(fixture: &ManagementFixture) {
|
||||||
|
let cleanup = std::fs::remove_dir_all(fixture.root.as_path());
|
||||||
|
if let std::result::Result::Err(error) = cleanup
|
||||||
|
&& error.kind() != std::io::ErrorKind::NotFound
|
||||||
|
{
|
||||||
|
eprintln!("unable to cleanup Config management fixture {}: {error}", fixture.root.display());
|
||||||
|
}
|
||||||
|
}
|
||||||
70
crates/ksp-config-lib/unit_tests/profile.rs
Normal file
70
crates/ksp-config-lib/unit_tests/profile.rs
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
// file: crates/ksp-config-lib/unit_tests/profile.rs
|
||||||
|
// version: 1
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn committed_default_profile_resolves_globals_profile_and_provenance() {
|
||||||
|
let engine = committed_engine();
|
||||||
|
let file_id = crate::ConfigFileId::new(crate::FILE_ID_STD_LOGGING);
|
||||||
|
assert!(engine.is_ok(), "committed 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::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");
|
||||||
|
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!(!resolved.effective().contains_key("default_profile"));
|
||||||
|
assert!(!resolved.effective().contains_key("profiles"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn explicit_profile_selection_is_distinct_from_default_selection() {
|
||||||
|
let engine = committed_engine();
|
||||||
|
let file_id = crate::ConfigFileId::new(crate::FILE_ID_STD_LOGGING);
|
||||||
|
assert!(engine.is_ok(), "committed 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unknown_explicit_profile_has_distinct_error_code() {
|
||||||
|
let engine = committed_engine();
|
||||||
|
let file_id = crate::ConfigFileId::new(crate::FILE_ID_STD_LOGGING);
|
||||||
|
assert!(engine.is_ok(), "committed 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"));
|
||||||
|
assert!(result.is_err(), "unknown explicit profile must be rejected: {result:?}");
|
||||||
|
if let std::result::Result::Err(error) = result {
|
||||||
|
assert_eq!(error.code(), crate::ERROR_CODE_PROFILE_NOT_FOUND);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn committed_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 bootstrap = match bootstrap {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let registry = crate::ConfigFileRegistry::defaults();
|
||||||
|
return match registry {
|
||||||
|
std::result::Result::Ok(value) => std::result::Result::Ok(crate::ConfigDocumentEngine::new(bootstrap, value)),
|
||||||
|
std::result::Result::Err(error) => std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
}
|
||||||
220
crates/ksp-config-lib/unit_tests/registry.rs
Normal file
220
crates/ksp-config-lib/unit_tests/registry.rs
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
// file: crates/ksp-config-lib/unit_tests/registry.rs
|
||||||
|
// version: 3
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn defaults_register_logging_document_and_schema_with_distinct_roots() {
|
||||||
|
let registry = super::ConfigFileRegistry::defaults();
|
||||||
|
assert!(registry.is_ok(), "default registry should be valid: {registry:?}");
|
||||||
|
if let std::result::Result::Ok(registry) = registry {
|
||||||
|
let logging_id = super::ConfigFileId::new(super::FILE_ID_STD_LOGGING);
|
||||||
|
let schema_id = super::ConfigFileId::new(super::FILE_ID_SCHEMA_STD_LOGGING);
|
||||||
|
assert!(logging_id.is_ok(), "logging file_id should be valid: {logging_id:?}");
|
||||||
|
assert!(schema_id.is_ok(), "logging schema file_id should be valid: {schema_id:?}");
|
||||||
|
if let (std::result::Result::Ok(logging_id), std::result::Result::Ok(schema_id)) = (logging_id, schema_id) {
|
||||||
|
let logging = registry.descriptor(&logging_id);
|
||||||
|
let schema = registry.descriptor(&schema_id);
|
||||||
|
assert!(logging.is_ok(), "logging descriptor should exist: {logging:?}");
|
||||||
|
assert!(schema.is_ok(), "logging schema descriptor should exist: {schema:?}");
|
||||||
|
if let (std::result::Result::Ok(logging), std::result::Result::Ok(schema)) = (logging, schema) {
|
||||||
|
assert_eq!(logging.kind(), super::ConfigFileKind::Config);
|
||||||
|
assert_eq!(logging.filename(), std::path::Path::new(super::DEFAULT_STD_LOGGING_FILENAME));
|
||||||
|
let logging_schema = logging.schema_file_id();
|
||||||
|
assert!(logging_schema.is_some(), "logging document should declare its validation schema");
|
||||||
|
if let std::option::Option::Some(logging_schema) = logging_schema {
|
||||||
|
assert_eq!(logging_schema, &schema_id);
|
||||||
|
}
|
||||||
|
assert_eq!(schema.kind(), super::ConfigFileKind::Schema);
|
||||||
|
assert_eq!(schema.filename(), std::path::Path::new(super::DEFAULT_STD_LOGGING_SCHEMA_FILENAME));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_path_uses_descriptor_kind_to_select_bootstrap_root() {
|
||||||
|
let registry = super::ConfigFileRegistry::defaults();
|
||||||
|
let bootstrap = crate::ConfigBootstrapOptions::from_paths("runtime-config", "runtime-schemas");
|
||||||
|
assert!(registry.is_ok(), "default registry should be valid: {registry:?}");
|
||||||
|
assert!(bootstrap.is_ok(), "bootstrap paths should be valid: {bootstrap:?}");
|
||||||
|
if let (std::result::Result::Ok(registry), std::result::Result::Ok(bootstrap)) = (registry, bootstrap) {
|
||||||
|
let logging_id = super::ConfigFileId::new(super::FILE_ID_STD_LOGGING);
|
||||||
|
let schema_id = super::ConfigFileId::new(super::FILE_ID_SCHEMA_STD_LOGGING);
|
||||||
|
if let (std::result::Result::Ok(logging_id), std::result::Result::Ok(schema_id)) = (logging_id, schema_id) {
|
||||||
|
let logging = registry.resolve_path(&bootstrap, &logging_id);
|
||||||
|
let schema = registry.resolve_path(&bootstrap, &schema_id);
|
||||||
|
assert!(logging.is_ok(), "logging path should resolve: {logging:?}");
|
||||||
|
assert!(schema.is_ok(), "schema path should resolve: {schema:?}");
|
||||||
|
if let std::result::Result::Ok(logging) = logging {
|
||||||
|
assert_eq!(logging, std::path::PathBuf::from("runtime-config/std.logging.json"));
|
||||||
|
}
|
||||||
|
if let std::result::Result::Ok(schema) = schema {
|
||||||
|
assert_eq!(schema, std::path::PathBuf::from("runtime-schemas/std.logging.schema.json"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cli_filemap_override_replaces_filename_and_last_value_wins() {
|
||||||
|
let args = [
|
||||||
|
std::ffi::OsString::from("ksp-app"),
|
||||||
|
std::ffi::OsString::from("--filemap=cfg.std.logging=first.logging.json"),
|
||||||
|
std::ffi::OsString::from("--other-option"),
|
||||||
|
std::ffi::OsString::from("--filemap=cfg.std.logging=profiles/custom.logging.json"),
|
||||||
|
];
|
||||||
|
let registry = super::ConfigFileRegistry::from_args(&args);
|
||||||
|
assert!(registry.is_ok(), "filemap overrides should parse: {registry:?}");
|
||||||
|
if let std::result::Result::Ok(registry) = registry {
|
||||||
|
let file_id = super::ConfigFileId::new(super::FILE_ID_STD_LOGGING);
|
||||||
|
if let std::result::Result::Ok(file_id) = file_id {
|
||||||
|
let descriptor = registry.descriptor(&file_id);
|
||||||
|
assert!(descriptor.is_ok(), "logging descriptor should remain registered: {descriptor:?}");
|
||||||
|
if let std::result::Result::Ok(descriptor) = descriptor {
|
||||||
|
assert_eq!(descriptor.filename(), std::path::Path::new("profiles/custom.logging.json"));
|
||||||
|
assert_eq!(descriptor.kind(), super::ConfigFileKind::Config);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn programmatic_override_preserves_file_id_and_kind() {
|
||||||
|
let registry = super::ConfigFileRegistry::defaults();
|
||||||
|
let file_id = super::ConfigFileId::new(super::FILE_ID_SCHEMA_STD_LOGGING);
|
||||||
|
assert!(registry.is_ok(), "default registry should be valid: {registry:?}");
|
||||||
|
assert!(file_id.is_ok(), "schema file_id should be valid: {file_id:?}");
|
||||||
|
if let (std::result::Result::Ok(registry), std::result::Result::Ok(file_id)) = (registry, file_id) {
|
||||||
|
let overridden = registry.with_filename_override(&file_id, "alternate/logging.schema.json");
|
||||||
|
assert!(overridden.is_ok(), "programmatic override should be valid: {overridden:?}");
|
||||||
|
if let std::result::Result::Ok(overridden) = overridden {
|
||||||
|
let descriptor = overridden.descriptor(&file_id);
|
||||||
|
if let std::result::Result::Ok(descriptor) = descriptor {
|
||||||
|
assert_eq!(descriptor.file_id(), &file_id);
|
||||||
|
assert_eq!(descriptor.kind(), super::ConfigFileKind::Schema);
|
||||||
|
assert_eq!(descriptor.filename(), std::path::Path::new("alternate/logging.schema.json"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unknown_file_id_override_is_rejected() {
|
||||||
|
let args = [std::ffi::OsString::from("ksp-app"), std::ffi::OsString::from("--filemap=cfg.unknown=unknown.json")];
|
||||||
|
let result = super::ConfigFileRegistry::from_args(&args);
|
||||||
|
assert!(result.is_err(), "unknown logical files must not be introduced by CLI override");
|
||||||
|
if let std::result::Result::Err(error) = result {
|
||||||
|
assert_eq!(error.code(), crate::ERROR_CODE_FILE_ID_UNKNOWN);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn invalid_file_ids_are_rejected() {
|
||||||
|
for value in ["", ".cfg", "cfg.", "cfg..logging", "CFG.logging", "cfg/logging"] {
|
||||||
|
let result = super::ConfigFileId::new(value);
|
||||||
|
assert!(result.is_err(), "invalid file_id must be rejected: {value}");
|
||||||
|
if let std::result::Result::Err(error) = result {
|
||||||
|
assert_eq!(error.code(), crate::ERROR_CODE_FILE_ID_INVALID);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn absolute_and_traversing_filenames_are_rejected() {
|
||||||
|
let registry = super::ConfigFileRegistry::defaults();
|
||||||
|
let file_id = super::ConfigFileId::new(super::FILE_ID_STD_LOGGING);
|
||||||
|
assert!(registry.is_ok(), "default registry should be valid: {registry:?}");
|
||||||
|
assert!(file_id.is_ok(), "logging file_id should be valid: {file_id:?}");
|
||||||
|
if let (std::result::Result::Ok(registry), std::result::Result::Ok(file_id)) = (registry, file_id) {
|
||||||
|
let traversal = registry.clone().with_filename_override(&file_id, "../outside.json");
|
||||||
|
let current = registry.clone().with_filename_override(&file_id, "./logging.json");
|
||||||
|
let absolute = registry.with_filename_override(&file_id, absolute_fixture_path());
|
||||||
|
assert_mapping_invalid(traversal);
|
||||||
|
assert_mapping_invalid(current);
|
||||||
|
assert_mapping_invalid(absolute);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn malformed_filemap_arguments_are_rejected() {
|
||||||
|
for argument in ["--filemap", "--filemap=cfg.std.logging", "--filemap==logging.json", "--filemap=cfg.std.logging="] {
|
||||||
|
let args = [std::ffi::OsString::from("ksp-app"), std::ffi::OsString::from(argument)];
|
||||||
|
let result = super::ConfigFileRegistry::from_args(&args);
|
||||||
|
assert!(result.is_err(), "malformed filemap argument must be rejected: {argument}");
|
||||||
|
if let std::result::Result::Err(error) = result {
|
||||||
|
assert_eq!(error.code(), crate::ERROR_CODE_FILE_MAPPING_INVALID);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn duplicate_registry_ids_are_rejected() {
|
||||||
|
let first = super::ConfigFileDescriptor::new("cfg.duplicate", super::ConfigFileKind::Config, "first.json", std::option::Option::None);
|
||||||
|
let second = super::ConfigFileDescriptor::new("cfg.duplicate", super::ConfigFileKind::Config, "second.json", std::option::Option::None);
|
||||||
|
assert!(first.is_ok(), "first descriptor should be valid: {first:?}");
|
||||||
|
assert!(second.is_ok(), "second descriptor should be valid: {second:?}");
|
||||||
|
if let (std::result::Result::Ok(first), std::result::Result::Ok(second)) = (first, second) {
|
||||||
|
let result = super::build_registry([first, second]);
|
||||||
|
assert!(result.is_err(), "duplicate file_ids must be rejected");
|
||||||
|
if let std::result::Result::Err(error) = result {
|
||||||
|
assert_eq!(error.code(), crate::ERROR_CODE_FILE_ID_DUPLICATE);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn descriptor_kind_must_match_file_id_namespace() {
|
||||||
|
let result = super::ConfigFileDescriptor::new("schema.invalid-kind", super::ConfigFileKind::Config, "invalid.json", std::option::Option::None);
|
||||||
|
assert!(result.is_err(), "descriptor kind mismatch must be rejected");
|
||||||
|
if let std::result::Result::Err(error) = result {
|
||||||
|
assert_eq!(error.code(), crate::ERROR_CODE_FILE_MAPPING_INVALID);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn config_schema_association_must_reference_registered_schema_descriptor() {
|
||||||
|
let config = super::ConfigFileDescriptor::new("cfg.test", super::ConfigFileKind::Config, "test.json", std::option::Option::Some("schema.test"));
|
||||||
|
assert!(config.is_ok(), "config descriptor should be valid before registry association validation: {config:?}");
|
||||||
|
if let std::result::Result::Ok(config) = config {
|
||||||
|
let result = super::build_registry([config]);
|
||||||
|
assert!(result.is_err(), "registry must reject a missing schema association");
|
||||||
|
if let std::result::Result::Err(error) = result {
|
||||||
|
assert_eq!(error.code(), crate::ERROR_CODE_FILE_MAPPING_INVALID);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn assert_mapping_invalid<T: std::fmt::Debug>(result: ksp_core_lib::Result<T>) {
|
||||||
|
assert!(result.is_err(), "invalid filename must be rejected: {result:?}");
|
||||||
|
if let std::result::Result::Err(error) = result {
|
||||||
|
assert_eq!(error.code(), crate::ERROR_CODE_FILE_MAPPING_INVALID);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn absolute_fixture_path() -> std::path::PathBuf {
|
||||||
|
let mut path = std::env::temp_dir();
|
||||||
|
path.push("ksp-config-lib-absolute-mapping.json");
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn defaults_register_generic_composite_schema_without_runtime_composite() {
|
||||||
|
let registry = super::ConfigFileRegistry::defaults();
|
||||||
|
let schema_id = super::ConfigFileId::new(super::FILE_ID_SCHEMA_COMPOSITE);
|
||||||
|
let runtime_id = super::ConfigFileId::new("cfg.composite.ksp-app-wallet-desk");
|
||||||
|
assert!(registry.is_ok(), "default registry should be valid: {registry:?}");
|
||||||
|
assert!(schema_id.is_ok(), "composite schema file_id should be valid: {schema_id:?}");
|
||||||
|
assert!(runtime_id.is_ok(), "future composite runtime file_id syntax should be valid: {runtime_id:?}");
|
||||||
|
if let (std::result::Result::Ok(registry), std::result::Result::Ok(schema_id), std::result::Result::Ok(runtime_id)) = (registry, schema_id, runtime_id) {
|
||||||
|
let schema = registry.descriptor(&schema_id);
|
||||||
|
let runtime = registry.descriptor(&runtime_id);
|
||||||
|
assert!(schema.is_ok(), "generic composite schema should be registered: {schema:?}");
|
||||||
|
assert!(runtime.is_err(), "no fictitious runtime composite should be registered");
|
||||||
|
if let std::result::Result::Ok(schema) = schema {
|
||||||
|
assert_eq!(schema.kind(), super::ConfigFileKind::Schema);
|
||||||
|
assert_eq!(schema.filename(), std::path::Path::new(super::DEFAULT_COMPOSITE_SCHEMA_FILENAME));
|
||||||
|
}
|
||||||
|
if let std::result::Result::Err(error) = runtime {
|
||||||
|
assert_eq!(error.code(), crate::ERROR_CODE_FILE_ID_UNKNOWN);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
31
crates/ksp-config-lib/unit_tests/sensitivity.rs
Normal file
31
crates/ksp-config-lib/unit_tests/sensitivity.rs
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
// file: crates/ksp-config-lib/unit_tests/sensitivity.rs
|
||||||
|
// version: 1
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn environment_names_map_to_expected_sensitivity() {
|
||||||
|
assert_eq!(super::ConfigSensitivity::from_variable_name("KSP_PUBLIC_ENDPOINT").ok(), std::option::Option::Some(super::ConfigSensitivity::Public));
|
||||||
|
assert_eq!(super::ConfigSensitivity::from_variable_name("KSP_MODE").ok(), std::option::Option::Some(super::ConfigSensitivity::Internal));
|
||||||
|
assert_eq!(super::ConfigSensitivity::from_variable_name("KSP_SECRET_PASSWORD").ok(), std::option::Option::Some(super::ConfigSensitivity::Secret));
|
||||||
|
assert_eq!(super::ConfigSensitivity::from_variable_name("KSPB_PUBLIC_ENDPOINT").ok(), std::option::Option::Some(super::ConfigSensitivity::Public));
|
||||||
|
assert_eq!(super::ConfigSensitivity::from_variable_name("KSPB_MODE").ok(), std::option::Option::Some(super::ConfigSensitivity::Internal));
|
||||||
|
assert_eq!(super::ConfigSensitivity::from_variable_name("KSPB_SECRET_PASSWORD").ok(), std::option::Option::Some(super::ConfigSensitivity::Secret));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn strongest_sensitivity_follows_secret_internal_public_order() {
|
||||||
|
assert_eq!(super::ConfigSensitivity::Public.strongest(super::ConfigSensitivity::Internal), super::ConfigSensitivity::Internal);
|
||||||
|
assert_eq!(super::ConfigSensitivity::Internal.strongest(super::ConfigSensitivity::Secret), super::ConfigSensitivity::Secret);
|
||||||
|
assert_eq!(super::ConfigSensitivity::Secret.strongest(super::ConfigSensitivity::Public), super::ConfigSensitivity::Secret);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn provenance_exposes_names_and_sources_without_values() {
|
||||||
|
let process = super::ConfigValueProvenance::EnvironmentProcess { variable_name: "KSP_SECRET_TOKEN".to_owned() };
|
||||||
|
let dotenv = super::ConfigValueProvenance::EnvironmentDotEnv { variable_name: "KSP_MODE".to_owned() };
|
||||||
|
let fallback = super::ConfigValueProvenance::EnvironmentFallback { variable_name: "KSP_PUBLIC_HOST".to_owned() };
|
||||||
|
assert_eq!(process.variable_name(), std::option::Option::Some("KSP_SECRET_TOKEN"));
|
||||||
|
assert_eq!(process.environment_source(), std::option::Option::Some(crate::ConfigEnvironmentSource::Process));
|
||||||
|
assert_eq!(dotenv.environment_source(), std::option::Option::Some(crate::ConfigEnvironmentSource::DotEnv));
|
||||||
|
assert_eq!(fallback.environment_source(), std::option::Option::Some(crate::ConfigEnvironmentSource::Fallback));
|
||||||
|
assert_eq!(super::ConfigValueProvenance::DocumentLiteral.variable_name(), std::option::Option::None);
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<!-- file: crates/ksp-logging-lib/README.md -->
|
<!-- file: crates/ksp-logging-lib/README.md -->
|
||||||
<!-- version: 2 -->
|
<!-- version: 5 -->
|
||||||
|
|
||||||
# ksp-logging-lib
|
# ksp-logging-lib
|
||||||
|
|
||||||
@@ -11,16 +11,43 @@ La crate possède :
|
|||||||
|
|
||||||
- les cinq niveaux KSP `error`, `warn`, `info`, `debug` et `trace` ;
|
- les cinq niveaux KSP `error`, `warn`, `info`, `debug` et `trace` ;
|
||||||
- les macros d'événements et de spans qui préservent le callsite du consommateur ;
|
- les macros d'événements et de spans qui préservent le callsite du consommateur ;
|
||||||
- `LoggingSettings` et les settings console/fichier indépendants de Config ;
|
- `LoggingSettings`, la console explicite et les settings fichier indépendants de Config ;
|
||||||
|
- les formats runtime `Human/Compact/Pretty/Json` ;
|
||||||
|
- zéro, un ou plusieurs outputs fichier actifs simultanément, identifiés par `output_id` unique ;
|
||||||
|
- le routing par output sur niveau, target KSP et champ structuré `domain` ;
|
||||||
|
- l'héritage du `domain` effectif à travers les spans, avec possibilité pour un event ou un span enfant de le remplacer explicitement ;
|
||||||
- l'installation unique du subscriber global ;
|
- l'installation unique du subscriber global ;
|
||||||
- le hot reload via `reinitialize` sans second subscriber global ;
|
- le hot reload via `reinitialize` sans second subscriber global ;
|
||||||
- le takeover des logs : les targets externes sont silencieux par défaut ;
|
- le takeover des logs : les targets externes sont silencieux par défaut ;
|
||||||
- les sorties console et fichier non bloquantes ;
|
- les writers non bloquants console/fichier et leurs `WorkerGuard` ;
|
||||||
- les `WorkerGuard`, compteurs de lignes abandonnées, rotation fichier et stripping ANSI ;
|
- les compteurs agrégés de lignes abandonnées et le compteur cumulatif par `output_id` fichier ;
|
||||||
|
- la rotation fichier, le stripping ANSI persistant et l'ANSI configurable pour la console ;
|
||||||
- l'instrumentation de scopes synchrones et de `Future` async.
|
- l'instrumentation de scopes synchrones et de `Future` async.
|
||||||
|
|
||||||
L'API async de production reste indépendante de tout executor. Tokio est utilisé uniquement comme `dev-dependency` afin de valider `instrument(...)` sur un executor réel en mode current-thread et multi-thread ; il ne fait pas partie des dépendances runtime de la crate.
|
L'API async de production reste indépendante de tout executor. Tokio est utilisé uniquement comme `dev-dependency` afin de valider `instrument(...)` sur un executor réel en mode current-thread et multi-thread ; il ne fait pas partie des dépendances runtime de la crate.
|
||||||
|
|
||||||
|
## Routing `domain`
|
||||||
|
|
||||||
|
`OutputFilter` applique désormais les trois dimensions :
|
||||||
|
|
||||||
|
```text
|
||||||
|
level
|
||||||
|
targets[]
|
||||||
|
domains[]
|
||||||
|
```
|
||||||
|
|
||||||
|
Le `domain` reste un champ structuré distinct du target. Sa résolution runtime suit ces règles :
|
||||||
|
|
||||||
|
- le `domain` porté directement par un event est prioritaire ;
|
||||||
|
- sinon l'event hérite du `domain` effectif de son span ;
|
||||||
|
- un span qui porte son propre `domain` remplace celui de son parent ;
|
||||||
|
- un span sans `domain` hérite de celui de son parent au moment de sa création ;
|
||||||
|
- les événements de lifecycle de span utilisent le `domain` effectif du span concerné ;
|
||||||
|
- un selector `domains = ["*"]` accepte aussi les événements sans `domain` ;
|
||||||
|
- un selector nommé correspond par préfixe et ne sélectionne pas un événement sans `domain`.
|
||||||
|
|
||||||
|
Le routing `domain` est appliqué en plus du niveau et du target de l'output. Il ne modifie ni le target propriétaire KSP ni les champs formatés.
|
||||||
|
|
||||||
## Frontières
|
## Frontières
|
||||||
|
|
||||||
Une crate KSP comportementale qui journalise son activité dépend de `ksp-logging-lib` et n'utilise pas directement `tracing`, `tracing-subscriber` ou `tracing-appender`.
|
Une crate KSP comportementale qui journalise son activité dépend de `ksp-logging-lib` et n'utilise pas directement `tracing`, `tracing-subscriber` ou `tracing-appender`.
|
||||||
|
|||||||
@@ -1,22 +1,37 @@
|
|||||||
<!-- file: crates/ksp-logging-lib/TODO.md -->
|
<!-- file: crates/ksp-logging-lib/TODO.md -->
|
||||||
<!-- version: 2 -->
|
<!-- version: 5 -->
|
||||||
|
|
||||||
# TODO ksp-logging-lib
|
# TODO ksp-logging-lib
|
||||||
|
|
||||||
## À fermer avant la stable 0.1.2
|
## À fermer pendant `0.1.3`
|
||||||
|
|
||||||
- exécuter les validations Cargo complètes de `pre.006`, y compris les tests Tokio current-thread/multi-thread ;
|
Les trois tranches Logging nécessaires à la fondation Config sont maintenant couvertes fonctionnellement :
|
||||||
- vérifier que `cargo tree -p ksp-logging-lib -e normal` ne contient pas Tokio et que Tokio apparaît uniquement dans le graphe dev attendu ;
|
|
||||||
- refaire l'audit final du graphe/features et de l'ownership de la stack tracing ;
|
- `pre.004` : contrats/settings multi-output ;
|
||||||
- après validation de la prerelease finale, préparer `rel.001`, publier `workspace.package.version = "0.1.2"` et taguer `v0.1.2` conformément aux règles de release.
|
- `pre.005` : runtime multi-sink, formats et routing level/target ;
|
||||||
|
- `pre.006` : routing structuré `domain`, héritage de spans et lifecycle.
|
||||||
|
|
||||||
|
Avant le gel de `std.logging.schema.json`, il reste uniquement à faire valider `pre.006` par les commandes workspace usuelles. Les travaux suivants de `0.1.3` reviennent ensuite à `ksp-config-lib`.
|
||||||
|
|
||||||
|
## Capacités désormais actives
|
||||||
|
|
||||||
|
Le runtime supporte maintenant :
|
||||||
|
|
||||||
|
- zéro, un ou plusieurs outputs fichier simultanés ;
|
||||||
|
- une console indépendante ;
|
||||||
|
- routing par niveau, target et `domain` structuré pour chaque output ;
|
||||||
|
- `Human`, `Compact`, `Pretty` et `Json` ;
|
||||||
|
- ANSI console configurable et fichiers persistants sans ANSI ;
|
||||||
|
- guards non bloquants indépendants ;
|
||||||
|
- compteurs agrégés et compteurs cumulatifs par `output_id` fichier ;
|
||||||
|
- hot reload transactionnel du groupe de sinks ;
|
||||||
|
- héritage du `domain` effectif pour les events/spans et lifecycle de spans.
|
||||||
|
|
||||||
## Capacités différées
|
## Capacités différées
|
||||||
|
|
||||||
Ces éléments ne font pas partie du contrat `0.1.2` et ne doivent être ajoutés qu'après besoin concret :
|
Ces éléments ne sont pas requis par la fondation Config `0.1.3` :
|
||||||
|
|
||||||
- plusieurs routes fichier indépendantes ;
|
|
||||||
- rotation par taille, rétention/compression et symlink `latest` ;
|
- rotation par taille, rétention/compression et symlink `latest` ;
|
||||||
- formats JSON ou autres formats structurés alternatifs ;
|
|
||||||
- OpenTelemetry/export réseau ;
|
- OpenTelemetry/export réseau ;
|
||||||
- watcher de fichiers de configuration, qui appartient à Config ou à une couche supérieure ;
|
- watcher de fichiers de configuration, qui appartient à Config ou à une couche supérieure ;
|
||||||
- benchmark/profiling de précision destiné aux chemins de trading sensibles à la latence.
|
- benchmark/profiling de précision destiné aux chemins de trading sensibles à la latence.
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<!-- file: crates/ksp-logging-lib/USAGE.md -->
|
<!-- file: crates/ksp-logging-lib/USAGE.md -->
|
||||||
<!-- version: 2 -->
|
<!-- version: 5 -->
|
||||||
|
|
||||||
# Utilisation de ksp-logging-lib
|
# Utilisation de ksp-logging-lib
|
||||||
|
|
||||||
@@ -21,20 +21,66 @@ ksp_logging_lib::trace!(
|
|||||||
|
|
||||||
Les champs `domain`, `component`, `operation` et autres champs structurés sont ajoutés par le caller lorsqu'ils sont utiles ; ils ne remplacent pas le target propriétaire.
|
Les champs `domain`, `component`, `operation` et autres champs structurés sont ajoutés par le caller lorsqu'ils sont utiles ; ils ne remplacent pas le target propriétaire.
|
||||||
|
|
||||||
|
## Contrat des outputs
|
||||||
|
|
||||||
|
`LoggingSettings` possède :
|
||||||
|
|
||||||
|
- le `default_filter` global de takeover KSP ;
|
||||||
|
- les `TargetFilter` globaux ;
|
||||||
|
- la politique `SpanEvents` ;
|
||||||
|
- une console déclarée optionnellement ;
|
||||||
|
- zéro, un ou plusieurs `FileSettings`.
|
||||||
|
|
||||||
|
Chaque output possède un `OutputFilter` indépendant. Son niveau, ses targets et ses domains s'appliquent **en plus** de la politique globale. `*` signifie « tous » dans la dimension concernée.
|
||||||
|
|
||||||
|
Les formats publics sont :
|
||||||
|
|
||||||
|
```text
|
||||||
|
Human
|
||||||
|
Compact
|
||||||
|
Pretty
|
||||||
|
Json
|
||||||
|
```
|
||||||
|
|
||||||
|
Une sortie fichier possède un `output_id` stable et unique dans `LoggingSettings`. Cet identifiant appartient au runtime Logging et ne doit pas être confondu avec les `file_id` utilisés par `ksp-config-lib` pour identifier ses documents.
|
||||||
|
|
||||||
|
Les fichiers persistants interdisent `ansi = true`.
|
||||||
|
|
||||||
|
## Runtime multi-output et routing `domain`
|
||||||
|
|
||||||
|
`0.1.3-pre.005` active le multi-sink, les formats et le routing niveau/target ; `0.1.3-pre.006` complète le routing structuré `domain`. Le runtime supporte donc réellement :
|
||||||
|
|
||||||
|
- plusieurs fichiers simultanés ;
|
||||||
|
- les formats `Human`, `Compact`, `Pretty` et `Json` ;
|
||||||
|
- l'ANSI console ;
|
||||||
|
- le routing par niveau, target et `domain` pour chaque output ;
|
||||||
|
- le comptage cumulatif des lignes abandonnées par `output_id` fichier ;
|
||||||
|
- le hot reload de ces sorties sans réinstaller le subscriber global.
|
||||||
|
|
||||||
|
Le `domain` n'est jamais transformé en target. Un event portant directement `domain` utilise cette valeur ; sinon il hérite du `domain` effectif de son span. Un span explicite remplace le `domain` de son parent, tandis qu'un span sans `domain` l'hérite. Les lifecycle events d'un span utilisent le même `domain` effectif.
|
||||||
|
|
||||||
|
Pour `domains[]`, `["*"]` accepte tous les événements, y compris ceux sans `domain`. Un selector nommé correspond par préfixe et ne sélectionne pas une entrée sans `domain`.
|
||||||
|
|
||||||
|
Les helpers `ConsoleSettings::stdout()` et `ConsoleSettings::stderr()` restent des raccourcis `Human`, sans ANSI et sans restriction supplémentaire par output.
|
||||||
|
|
||||||
## Initialisation
|
## Initialisation
|
||||||
|
|
||||||
`initialize` installe le subscriber global KSP une seule fois et retourne le `LoggingGuard` qui doit rester vivant pendant la durée du runtime :
|
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
|
let file = ksp_logging_lib::FileSettings::new(
|
||||||
|
"file.worker",
|
||||||
|
true,
|
||||||
|
"logs",
|
||||||
|
"worker.log",
|
||||||
|
ksp_logging_lib::FileRotation::Daily,
|
||||||
|
ksp_logging_lib::LogFormat::Human,
|
||||||
|
ksp_logging_lib::OutputFilter::unrestricted(),
|
||||||
|
);
|
||||||
|
|
||||||
let settings = ksp_logging_lib::LoggingSettings::new(
|
let settings = ksp_logging_lib::LoggingSettings::new(
|
||||||
ksp_logging_lib::LogFilterLevel::Info,
|
ksp_logging_lib::LogFilterLevel::Info,
|
||||||
ksp_logging_lib::SpanEvents::NewAndClose,
|
ksp_logging_lib::SpanEvents::NewAndClose,
|
||||||
std::option::Option::Some(ksp_logging_lib::ConsoleSettings::stderr()),
|
std::option::Option::Some(ksp_logging_lib::ConsoleSettings::stderr()),
|
||||||
std::option::Option::Some(ksp_logging_lib::FileSettings::new(
|
std::vec![file],
|
||||||
"logs",
|
|
||||||
"worker.log",
|
|
||||||
ksp_logging_lib::FileRotation::Daily,
|
|
||||||
)),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
let initialize_result = ksp_logging_lib::initialize(&settings);
|
let initialize_result = ksp_logging_lib::initialize(&settings);
|
||||||
@@ -44,18 +90,73 @@ let mut logging_guard = match initialize_result {
|
|||||||
};
|
};
|
||||||
```
|
```
|
||||||
|
|
||||||
Une configuration sans console ni fichier est valide et installe une infrastructure initialement silencieuse qui pourra être activée plus tard par hot reload.
|
Une configuration sans output actif est valide et installe une infrastructure initialement silencieuse qui pourra être activée plus tard par hot reload.
|
||||||
|
|
||||||
|
## Construction d'un runtime multi-output
|
||||||
|
|
||||||
|
Le runtime peut activer plusieurs sorties ayant des formats et filtres niveau/target/domain distincts :
|
||||||
|
|
||||||
|
```rust
|
||||||
|
let console = ksp_logging_lib::ConsoleSettings::new(
|
||||||
|
true,
|
||||||
|
ksp_logging_lib::ConsoleOutput::Stderr,
|
||||||
|
true,
|
||||||
|
ksp_logging_lib::LogFormat::Compact,
|
||||||
|
ksp_logging_lib::OutputFilter::new(
|
||||||
|
ksp_logging_lib::LogFilterLevel::Debug,
|
||||||
|
std::vec!["*".to_string()],
|
||||||
|
std::vec!["*".to_string()],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
let error_file = ksp_logging_lib::FileSettings::new(
|
||||||
|
"file.config.error",
|
||||||
|
true,
|
||||||
|
"logs/config",
|
||||||
|
"error.jsonl",
|
||||||
|
ksp_logging_lib::FileRotation::Daily,
|
||||||
|
ksp_logging_lib::LogFormat::Json,
|
||||||
|
ksp_logging_lib::OutputFilter::new(
|
||||||
|
ksp_logging_lib::LogFilterLevel::Error,
|
||||||
|
std::vec!["ksp-config-lib".to_string()],
|
||||||
|
std::vec!["*".to_string()],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
let settings = ksp_logging_lib::LoggingSettings::new(
|
||||||
|
ksp_logging_lib::LogFilterLevel::Warn,
|
||||||
|
ksp_logging_lib::SpanEvents::NewAndClose,
|
||||||
|
std::option::Option::Some(console),
|
||||||
|
std::vec![error_file],
|
||||||
|
);
|
||||||
|
|
||||||
|
let validation = settings.validate();
|
||||||
|
```
|
||||||
|
|
||||||
|
`validate()` vérifie le contrat structurel. `initialize/reinitialize` appliquent ensuite conjointement niveau, target et `domain` par output.
|
||||||
|
|
||||||
|
Par exemple, un fichier réservé au domaine Store peut utiliser :
|
||||||
|
|
||||||
|
```rust
|
||||||
|
ksp_logging_lib::OutputFilter::new(
|
||||||
|
ksp_logging_lib::LogFilterLevel::Debug,
|
||||||
|
std::vec!["ksp-store-lib".to_string()],
|
||||||
|
std::vec!["store".to_string()],
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
Un event `domain = "store.postgres"` correspond au selector `store`; un event sans `domain` n'y correspond pas.
|
||||||
|
|
||||||
## Hot reload
|
## Hot reload
|
||||||
|
|
||||||
Une nouvelle configuration peut être appliquée sans redémarrer le processus ou le worker :
|
Une configuration peut être appliquée sans redémarrer le processus ou le worker :
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
let debug_settings = ksp_logging_lib::LoggingSettings::new(
|
let debug_settings = ksp_logging_lib::LoggingSettings::new(
|
||||||
ksp_logging_lib::LogFilterLevel::Info,
|
ksp_logging_lib::LogFilterLevel::Info,
|
||||||
ksp_logging_lib::SpanEvents::NewAndClose,
|
ksp_logging_lib::SpanEvents::NewAndClose,
|
||||||
std::option::Option::Some(ksp_logging_lib::ConsoleSettings::stderr()),
|
std::option::Option::Some(ksp_logging_lib::ConsoleSettings::stderr()),
|
||||||
std::option::Option::None,
|
std::vec::Vec::new(),
|
||||||
)
|
)
|
||||||
.with_target_filter(ksp_logging_lib::TargetFilter::new(
|
.with_target_filter(ksp_logging_lib::TargetFilter::new(
|
||||||
"ksp-store-lib",
|
"ksp-store-lib",
|
||||||
@@ -104,7 +205,7 @@ La future instrumentée entre/sort du span pendant ses polls et lors de son `Dro
|
|||||||
|
|
||||||
## Lignes abandonnées
|
## Lignes abandonnées
|
||||||
|
|
||||||
Les sorties utilisent des queues lossy afin de ne pas appliquer de backpressure au hot path. Les pertes restent observables :
|
Les sorties utilisent des queues lossy afin de ne pas appliquer de backpressure au hot path. La vue agrégée console/fichier reste disponible :
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
let dropped = logging_guard.dropped_lines();
|
let dropped = logging_guard.dropped_lines();
|
||||||
@@ -117,14 +218,14 @@ ksp_logging_lib::warn!(
|
|||||||
);
|
);
|
||||||
```
|
```
|
||||||
|
|
||||||
## Instrumentation async et executor
|
Pour un fichier précis :
|
||||||
|
|
||||||
`instrument(span, future)` accepte une `Future` standard et ne dépend d'aucun executor particulier :
|
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
let span = ksp_logging_lib::trace_span!(target: LOGGING_TARGET, "load_transactions");
|
let dropped_for_output = logging_guard.dropped_file_lines("file.worker");
|
||||||
let result = ksp_logging_lib::instrument(span, async_operation()).await;
|
|
||||||
```
|
```
|
||||||
|
|
||||||
La crate ne requiert pas Tokio en production. Tokio n'est présent qu'en `dev-dependency` pour valider la surface sur un executor réel, y compris après plusieurs suspensions et sur un runtime multi-thread. Un consumer peut donc utiliser l'executor adapté à son propre contexte sans que Logging lui en impose un.
|
Le compteur par `output_id` reste cumulatif à travers les hot reloads tant que le même `LoggingGuard` est conservé.
|
||||||
|
|
||||||
|
## Instrumentation async et executor
|
||||||
|
|
||||||
|
`instrument(span, future)` accepte une `Future` standard et ne dépend d'aucun executor particulier. Tokio n'est présent qu'en `dev-dependency` pour valider la surface sur un executor réel, y compris après plusieurs suspensions et sur un runtime multi-thread.
|
||||||
|
|||||||
181
crates/ksp-logging-lib/src/domain.rs
Normal file
181
crates/ksp-logging-lib/src/domain.rs
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
// file: crates/ksp-logging-lib/src/domain.rs
|
||||||
|
// version: 1
|
||||||
|
|
||||||
|
std::thread_local! {
|
||||||
|
static CURRENT_DOMAIN: std::cell::RefCell<std::option::Option<std::string::String>> = const { std::cell::RefCell::new(std::option::Option::None) };
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Default, Eq, PartialEq)]
|
||||||
|
struct SpanDomain {
|
||||||
|
value: std::option::Option<std::string::String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct DomainVisitor {
|
||||||
|
value: std::option::Option<std::string::String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl tracing::field::Visit for DomainVisitor {
|
||||||
|
fn record_str(&mut self, field: &tracing::field::Field, value: &str) {
|
||||||
|
if field.name() == "domain" {
|
||||||
|
self.value = std::option::Option::Some(value.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) {
|
||||||
|
if field.name() == "domain" {
|
||||||
|
self.value = std::option::Option::Some(format!("{value:?}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) struct DomainContextLayer;
|
||||||
|
|
||||||
|
impl DomainContextLayer {
|
||||||
|
pub(crate) const fn new() -> Self {
|
||||||
|
return Self;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<S> tracing_subscriber::Layer<S> for DomainContextLayer
|
||||||
|
where
|
||||||
|
S: tracing::Subscriber + for<'lookup> tracing_subscriber::registry::LookupSpan<'lookup>,
|
||||||
|
{
|
||||||
|
fn on_new_span(&self, attrs: &tracing::span::Attributes<'_>, id: &tracing::span::Id, ctx: tracing_subscriber::layer::Context<'_, S>) {
|
||||||
|
let mut visitor = DomainVisitor::default();
|
||||||
|
attrs.record(&mut visitor);
|
||||||
|
let effective_domain = match visitor.value {
|
||||||
|
std::option::Option::Some(domain) => std::option::Option::Some(domain),
|
||||||
|
std::option::Option::None => span_parent_domain(id, &ctx),
|
||||||
|
};
|
||||||
|
if let std::option::Option::Some(span) = ctx.span(id) {
|
||||||
|
span.extensions_mut().insert(SpanDomain { value: effective_domain.clone() });
|
||||||
|
}
|
||||||
|
set_current_domain(effective_domain.as_deref());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn on_record(&self, id: &tracing::span::Id, values: &tracing::span::Record<'_>, ctx: tracing_subscriber::layer::Context<'_, S>) {
|
||||||
|
let mut visitor = DomainVisitor::default();
|
||||||
|
values.record(&mut visitor);
|
||||||
|
if let std::option::Option::Some(domain) = visitor.value {
|
||||||
|
if let std::option::Option::Some(span) = ctx.span(id) {
|
||||||
|
let mut extensions = span.extensions_mut();
|
||||||
|
if let std::option::Option::Some(stored) = extensions.get_mut::<SpanDomain>() {
|
||||||
|
stored.value = std::option::Option::Some(domain.clone());
|
||||||
|
} else {
|
||||||
|
extensions.insert(SpanDomain { value: std::option::Option::Some(domain.clone()) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
set_current_domain(std::option::Option::Some(domain.as_str()));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let domain = span_domain(id, &ctx);
|
||||||
|
set_current_domain(domain.as_deref());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn on_event(&self, event: &tracing::Event<'_>, ctx: tracing_subscriber::layer::Context<'_, S>) {
|
||||||
|
let mut visitor = DomainVisitor::default();
|
||||||
|
event.record(&mut visitor);
|
||||||
|
let effective_domain = match visitor.value {
|
||||||
|
std::option::Option::Some(domain) => std::option::Option::Some(domain),
|
||||||
|
std::option::Option::None => event_parent_domain(event, &ctx),
|
||||||
|
};
|
||||||
|
set_current_domain(effective_domain.as_deref());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn on_enter(&self, id: &tracing::span::Id, ctx: tracing_subscriber::layer::Context<'_, S>) {
|
||||||
|
let domain = span_domain(id, &ctx);
|
||||||
|
set_current_domain(domain.as_deref());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn on_exit(&self, id: &tracing::span::Id, ctx: tracing_subscriber::layer::Context<'_, S>) {
|
||||||
|
let domain = span_domain(id, &ctx);
|
||||||
|
set_current_domain(domain.as_deref());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn on_close(&self, id: tracing::span::Id, ctx: tracing_subscriber::layer::Context<'_, S>) {
|
||||||
|
let domain = span_domain(&id, &ctx);
|
||||||
|
set_current_domain(domain.as_deref());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn current_domain_matches(selectors: &[std::string::String]) -> bool {
|
||||||
|
if let [selector] = selectors
|
||||||
|
&& selector == "*"
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return CURRENT_DOMAIN.with(|current| -> bool {
|
||||||
|
let borrow_result = current.try_borrow();
|
||||||
|
let current = match borrow_result {
|
||||||
|
std::result::Result::Ok(current) => current,
|
||||||
|
std::result::Result::Err(_) => return false,
|
||||||
|
};
|
||||||
|
let domain = match current.as_ref() {
|
||||||
|
std::option::Option::Some(domain) => domain,
|
||||||
|
std::option::Option::None => return false,
|
||||||
|
};
|
||||||
|
return selectors.iter().any(|selector| -> bool {
|
||||||
|
return domain.starts_with(selector.as_str());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_current_domain(domain: std::option::Option<&str>) {
|
||||||
|
CURRENT_DOMAIN.with(|current| {
|
||||||
|
let borrow_result = current.try_borrow_mut();
|
||||||
|
if let std::result::Result::Ok(mut current) = borrow_result {
|
||||||
|
*current = domain.map(std::borrow::ToOwned::to_owned);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn span_parent_domain<S>(id: &tracing::span::Id, ctx: &tracing_subscriber::layer::Context<'_, S>) -> std::option::Option<std::string::String>
|
||||||
|
where
|
||||||
|
S: tracing::Subscriber + for<'lookup> tracing_subscriber::registry::LookupSpan<'lookup>,
|
||||||
|
{
|
||||||
|
let span = match ctx.span(id) {
|
||||||
|
std::option::Option::Some(span) => span,
|
||||||
|
std::option::Option::None => return std::option::Option::None,
|
||||||
|
};
|
||||||
|
let parent = match span.parent() {
|
||||||
|
std::option::Option::Some(parent) => parent,
|
||||||
|
std::option::Option::None => return std::option::Option::None,
|
||||||
|
};
|
||||||
|
let extensions = parent.extensions();
|
||||||
|
return extensions.get::<SpanDomain>().and_then(|domain| -> std::option::Option<std::string::String> {
|
||||||
|
return domain.value.clone();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn span_domain<S>(id: &tracing::span::Id, ctx: &tracing_subscriber::layer::Context<'_, S>) -> std::option::Option<std::string::String>
|
||||||
|
where
|
||||||
|
S: tracing::Subscriber + for<'lookup> tracing_subscriber::registry::LookupSpan<'lookup>,
|
||||||
|
{
|
||||||
|
let span = match ctx.span(id) {
|
||||||
|
std::option::Option::Some(span) => span,
|
||||||
|
std::option::Option::None => return std::option::Option::None,
|
||||||
|
};
|
||||||
|
let extensions = span.extensions();
|
||||||
|
return extensions.get::<SpanDomain>().and_then(|domain| -> std::option::Option<std::string::String> {
|
||||||
|
return domain.value.clone();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn event_parent_domain<S>(event: &tracing::Event<'_>, ctx: &tracing_subscriber::layer::Context<'_, S>) -> std::option::Option<std::string::String>
|
||||||
|
where
|
||||||
|
S: tracing::Subscriber + for<'lookup> tracing_subscriber::registry::LookupSpan<'lookup>,
|
||||||
|
{
|
||||||
|
let parent = match ctx.event_span(event) {
|
||||||
|
std::option::Option::Some(parent) => parent,
|
||||||
|
std::option::Option::None => return std::option::Option::None,
|
||||||
|
};
|
||||||
|
let extensions = parent.extensions();
|
||||||
|
return extensions.get::<SpanDomain>().and_then(|domain| -> std::option::Option<std::string::String> {
|
||||||
|
return domain.value.clone();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "../unit_tests/domain.rs"]
|
||||||
|
mod tests;
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
// file: crates/ksp-logging-lib/src/lib.rs
|
// file: crates/ksp-logging-lib/src/lib.rs
|
||||||
// version: 4
|
// version: 7
|
||||||
#![warn(missing_docs)]
|
#![warn(missing_docs)]
|
||||||
#![deny(unreachable_pub)]
|
#![deny(unreachable_pub)]
|
||||||
#![forbid(unsafe_code)]
|
#![forbid(unsafe_code)]
|
||||||
@@ -7,10 +7,11 @@
|
|||||||
//! KSP-owned logging and tracing facade.
|
//! KSP-owned logging and tracing facade.
|
||||||
//!
|
//!
|
||||||
//! This crate owns the KSP runtime logging contract. Behavioral KSP crates emit events and spans through this facade rather than depending directly on the
|
//! This crate owns the KSP runtime logging contract. Behavioral KSP crates emit events and spans through this facade rather than depending directly on the
|
||||||
//! `tracing` stack. `0.1.2-pre.005` owns the single global subscriber, KSP takeover filtering, hot reload, non-blocking console/file outputs, rolling file
|
//! `tracing` stack. The crate owns the single global subscriber, KSP takeover filtering, hot reload and non-blocking outputs. `0.1.3-pre.006` supports
|
||||||
//! appenders, ANSI stripping, dropped-line counters and the worker guards required to flush active queues. The integration surface is hardened by
|
//! multiple simultaneous outputs with per-output level/target/domain routing, selectable formats, console ANSI and per-file dropped-line accounting. Structured
|
||||||
//! deterministic saturation, concurrent reload and ownership audits before final release validation.
|
//! `domain` routing remains distinct from targets and follows explicit event domains or inherited span domains.
|
||||||
|
|
||||||
|
mod domain;
|
||||||
mod error;
|
mod error;
|
||||||
mod macros;
|
mod macros;
|
||||||
mod runtime;
|
mod runtime;
|
||||||
@@ -38,14 +39,18 @@ pub use self::runtime::reinitialize;
|
|||||||
pub use self::settings::ConsoleOutput;
|
pub use self::settings::ConsoleOutput;
|
||||||
/// Runtime settings for the optional console output.
|
/// Runtime settings for the optional console output.
|
||||||
pub use self::settings::ConsoleSettings;
|
pub use self::settings::ConsoleSettings;
|
||||||
/// Rotation cadence for the optional file output.
|
/// Rotation cadence for one file output.
|
||||||
pub use self::settings::FileRotation;
|
pub use self::settings::FileRotation;
|
||||||
/// Runtime settings for the optional file output.
|
/// Runtime settings for one file output.
|
||||||
pub use self::settings::FileSettings;
|
pub use self::settings::FileSettings;
|
||||||
/// Runtime filter level used by KSP logging settings.
|
/// Runtime filter level used by KSP logging settings.
|
||||||
pub use self::settings::LogFilterLevel;
|
pub use self::settings::LogFilterLevel;
|
||||||
|
/// Output format requested for one Logging sink.
|
||||||
|
pub use self::settings::LogFormat;
|
||||||
/// Complete runtime settings consumed by Logging initialization and reload.
|
/// Complete runtime settings consumed by Logging initialization and reload.
|
||||||
pub use self::settings::LoggingSettings;
|
pub use self::settings::LoggingSettings;
|
||||||
|
/// Per-output routing filter applied in addition to the global KSP takeover policy.
|
||||||
|
pub use self::settings::OutputFilter;
|
||||||
/// Lifecycle events emitted for spans by the formatted subscriber.
|
/// Lifecycle events emitted for spans by the formatted subscriber.
|
||||||
pub use self::settings::SpanEvents;
|
pub use self::settings::SpanEvents;
|
||||||
/// Per-target filter override owned by Logging.
|
/// Per-target filter override owned by Logging.
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// file: crates/ksp-logging-lib/src/runtime.rs
|
// file: crates/ksp-logging-lib/src/runtime.rs
|
||||||
// version: 8
|
// version: 11
|
||||||
|
|
||||||
use tracing_subscriber::Layer; // rust-rules: trait-import
|
use tracing_subscriber::Layer; // rust-rules: trait-import
|
||||||
use tracing_subscriber::layer::SubscriberExt; // rust-rules: trait-import
|
use tracing_subscriber::layer::SubscriberExt; // rust-rules: trait-import
|
||||||
@@ -47,6 +47,7 @@ pub struct LoggingGuard {
|
|||||||
settings: crate::LoggingSettings,
|
settings: crate::LoggingSettings,
|
||||||
outputs: RuntimeOutputs,
|
outputs: RuntimeOutputs,
|
||||||
retired_dropped_lines: crate::DroppedLines,
|
retired_dropped_lines: crate::DroppedLines,
|
||||||
|
retired_file_dropped_lines: std::collections::HashMap<std::string::String, usize>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl LoggingGuard {
|
impl LoggingGuard {
|
||||||
@@ -61,6 +62,19 @@ impl LoggingGuard {
|
|||||||
pub fn dropped_lines(&self) -> crate::DroppedLines {
|
pub fn dropped_lines(&self) -> crate::DroppedLines {
|
||||||
return self.retired_dropped_lines.saturating_add(self.outputs.dropped_lines());
|
return self.retired_dropped_lines.saturating_add(self.outputs.dropped_lines());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns cumulative dropped-line counters for one file `output_id` when that output has existed in the runtime.
|
||||||
|
#[must_use]
|
||||||
|
pub fn dropped_file_lines(&self, output_id: &str) -> std::option::Option<usize> {
|
||||||
|
let retired = self.retired_file_dropped_lines.get(output_id).copied();
|
||||||
|
let active = self.outputs.file_dropped_lines(output_id);
|
||||||
|
return match (retired, active) {
|
||||||
|
(std::option::Option::Some(retired), std::option::Option::Some(active)) => std::option::Option::Some(retired.saturating_add(active)),
|
||||||
|
(std::option::Option::Some(retired), std::option::Option::None) => std::option::Option::Some(retired),
|
||||||
|
(std::option::Option::None, std::option::Option::Some(active)) => std::option::Option::Some(active),
|
||||||
|
(std::option::Option::None, std::option::Option::None) => std::option::Option::None,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type BoxedRuntimeLayer = std::boxed::Box<dyn tracing_subscriber::Layer<tracing_subscriber::Registry> + std::marker::Send + std::marker::Sync + 'static>;
|
type BoxedRuntimeLayer = std::boxed::Box<dyn tracing_subscriber::Layer<tracing_subscriber::Registry> + std::marker::Send + std::marker::Sync + 'static>;
|
||||||
@@ -75,7 +89,7 @@ struct PreparedRuntime {
|
|||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
struct RuntimeOutputs {
|
struct RuntimeOutputs {
|
||||||
console: std::option::Option<RuntimeOutput>,
|
console: std::option::Option<RuntimeOutput>,
|
||||||
file: std::option::Option<RuntimeOutput>,
|
files: std::vec::Vec<RuntimeFileOutput>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl RuntimeOutputs {
|
impl RuntimeOutputs {
|
||||||
@@ -84,12 +98,44 @@ impl RuntimeOutputs {
|
|||||||
std::option::Option::Some(output) => output.dropped_lines(),
|
std::option::Option::Some(output) => output.dropped_lines(),
|
||||||
std::option::Option::None => 0,
|
std::option::Option::None => 0,
|
||||||
};
|
};
|
||||||
let file = match self.file.as_ref() {
|
let mut file = 0_usize;
|
||||||
std::option::Option::Some(output) => output.dropped_lines(),
|
for output in &self.files {
|
||||||
std::option::Option::None => 0,
|
file = file.saturating_add(output.output.dropped_lines());
|
||||||
};
|
}
|
||||||
return crate::DroppedLines { console, file };
|
return crate::DroppedLines { console, file };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn file_dropped_lines(&self, output_id: &str) -> std::option::Option<usize> {
|
||||||
|
return self
|
||||||
|
.files
|
||||||
|
.iter()
|
||||||
|
.find(|output| -> bool {
|
||||||
|
return output.output_id == output_id;
|
||||||
|
})
|
||||||
|
.map(|output| -> usize {
|
||||||
|
return output.output.dropped_lines();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn accumulate_file_dropped_lines(&self, destination: &mut std::collections::HashMap<std::string::String, usize>) {
|
||||||
|
for output in &self.files {
|
||||||
|
let dropped = output.output.dropped_lines();
|
||||||
|
match destination.entry(output.output_id.clone()) {
|
||||||
|
std::collections::hash_map::Entry::Occupied(mut entry) => {
|
||||||
|
let cumulative = entry.get().saturating_add(dropped);
|
||||||
|
*entry.get_mut() = cumulative;
|
||||||
|
},
|
||||||
|
std::collections::hash_map::Entry::Vacant(entry) => {
|
||||||
|
entry.insert(dropped);
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct RuntimeFileOutput {
|
||||||
|
output_id: std::string::String,
|
||||||
|
output: RuntimeOutput,
|
||||||
}
|
}
|
||||||
|
|
||||||
struct RuntimeOutput {
|
struct RuntimeOutput {
|
||||||
@@ -108,6 +154,12 @@ struct PreparedOutput {
|
|||||||
output: RuntimeOutput,
|
output: RuntimeOutput,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct PreparedFileOutput {
|
||||||
|
output_id: std::string::String,
|
||||||
|
layer: BoxedRuntimeLayer,
|
||||||
|
output: RuntimeOutput,
|
||||||
|
}
|
||||||
|
|
||||||
/// Installs the global KSP tracing subscriber.
|
/// Installs the global KSP tracing subscriber.
|
||||||
///
|
///
|
||||||
/// This function may succeed only once for the lifetime of the process. The returned guard owns all non-blocking writer guards and is then used by
|
/// This function may succeed only once for the lifetime of the process. The returned guard owns all non-blocking writer guards and is then used by
|
||||||
@@ -124,6 +176,7 @@ pub fn initialize(settings: &crate::LoggingSettings) -> ksp_core_lib::Result<cra
|
|||||||
settings: settings.clone(),
|
settings: settings.clone(),
|
||||||
outputs,
|
outputs,
|
||||||
retired_dropped_lines: crate::DroppedLines::zero(),
|
retired_dropped_lines: crate::DroppedLines::zero(),
|
||||||
|
retired_file_dropped_lines: std::collections::HashMap::new(),
|
||||||
}),
|
}),
|
||||||
std::result::Result::Err(error) => std::result::Result::Err(
|
std::result::Result::Err(error) => std::result::Result::Err(
|
||||||
ksp_core_lib::Error::new(crate::ERROR_CODE_ALREADY_INITIALIZED, "the global KSP tracing subscriber is already installed").with_source(error),
|
ksp_core_lib::Error::new(crate::ERROR_CODE_ALREADY_INITIALIZED, "the global KSP tracing subscriber is already installed").with_source(error),
|
||||||
@@ -147,6 +200,7 @@ pub fn reinitialize(guard: &mut crate::LoggingGuard, settings: &crate::LoggingSe
|
|||||||
return match reload_result {
|
return match reload_result {
|
||||||
std::result::Result::Ok(()) => {
|
std::result::Result::Ok(()) => {
|
||||||
guard.retired_dropped_lines = guard.retired_dropped_lines.saturating_add(guard.outputs.dropped_lines());
|
guard.retired_dropped_lines = guard.retired_dropped_lines.saturating_add(guard.outputs.dropped_lines());
|
||||||
|
guard.outputs.accumulate_file_dropped_lines(&mut guard.retired_file_dropped_lines);
|
||||||
let retired_outputs = std::mem::replace(&mut guard.outputs, outputs);
|
let retired_outputs = std::mem::replace(&mut guard.outputs, outputs);
|
||||||
guard.settings = settings.clone();
|
guard.settings = settings.clone();
|
||||||
drop(retired_layers);
|
drop(retired_layers);
|
||||||
@@ -165,42 +219,60 @@ fn prepare_runtime(settings: &crate::LoggingSettings) -> ksp_core_lib::Result<Pr
|
|||||||
if let std::option::Option::Some(error) = validation_error {
|
if let std::option::Option::Some(error) = validation_error {
|
||||||
return std::result::Result::Err(error);
|
return std::result::Result::Err(error);
|
||||||
}
|
}
|
||||||
if settings.console().is_none() && settings.file().is_none() {
|
let enabled_console = settings.console().filter(|console| -> bool {
|
||||||
return std::result::Result::Ok(PreparedRuntime { layers: RuntimeLayers::new(), outputs: RuntimeOutputs::default() });
|
return console.enabled();
|
||||||
}
|
});
|
||||||
let prepared_file = match settings.file() {
|
let enabled_files = settings.files().iter().filter(|file| -> bool {
|
||||||
std::option::Option::Some(file) => match build_file_output(file, settings) {
|
return file.enabled();
|
||||||
std::result::Result::Ok(output) => std::option::Option::Some(output),
|
|
||||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
|
||||||
},
|
|
||||||
std::option::Option::None => std::option::Option::None,
|
|
||||||
};
|
|
||||||
let prepared_console = settings.console().map(|console| -> PreparedOutput {
|
|
||||||
return build_console_output(console, settings);
|
|
||||||
});
|
});
|
||||||
let mut output_layers = RuntimeLayers::new();
|
let mut output_layers = RuntimeLayers::new();
|
||||||
let mut outputs = RuntimeOutputs::default();
|
let mut outputs = RuntimeOutputs::default();
|
||||||
if let std::option::Option::Some(console) = prepared_console {
|
if let std::option::Option::Some(console) = enabled_console {
|
||||||
output_layers.push(console.layer);
|
let prepared_console = build_console_output(console, settings);
|
||||||
outputs.console = std::option::Option::Some(console.output);
|
output_layers.push(prepared_console.layer);
|
||||||
|
outputs.console = std::option::Option::Some(prepared_console.output);
|
||||||
}
|
}
|
||||||
if let std::option::Option::Some(file) = prepared_file {
|
for file in enabled_files {
|
||||||
output_layers.push(file.layer);
|
let prepared_file_result = build_file_output(file, settings);
|
||||||
outputs.file = std::option::Option::Some(file.output);
|
let prepared_file = match prepared_file_result {
|
||||||
|
std::result::Result::Ok(output) => output,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
output_layers.push(prepared_file.layer);
|
||||||
|
outputs.files.push(RuntimeFileOutput { output_id: prepared_file.output_id, output: prepared_file.output });
|
||||||
}
|
}
|
||||||
|
if output_layers.is_empty() {
|
||||||
|
return std::result::Result::Ok(PreparedRuntime { layers: RuntimeLayers::new(), outputs });
|
||||||
|
}
|
||||||
|
output_layers.insert(0, crate::domain::DomainContextLayer::new().boxed());
|
||||||
let takeover_layer = build_target_filter(settings).and_then(output_layers).boxed();
|
let takeover_layer = build_target_filter(settings).and_then(output_layers).boxed();
|
||||||
let layers = vec![takeover_layer];
|
return std::result::Result::Ok(PreparedRuntime { layers: vec![takeover_layer], outputs });
|
||||||
return std::result::Result::Ok(PreparedRuntime { layers, outputs });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn build_console_output(console: &crate::ConsoleSettings, settings: &crate::LoggingSettings) -> PreparedOutput {
|
fn build_console_output(console: &crate::ConsoleSettings, settings: &crate::LoggingSettings) -> PreparedOutput {
|
||||||
return match console.output() {
|
return match console.output() {
|
||||||
crate::ConsoleOutput::Stdout => build_non_blocking_output(std::io::stdout(), "ksp-logging-console", settings, true),
|
crate::ConsoleOutput::Stdout => build_non_blocking_output(
|
||||||
crate::ConsoleOutput::Stderr => build_non_blocking_output(std::io::stderr(), "ksp-logging-console", settings, true),
|
std::io::stdout(),
|
||||||
|
"ksp-logging-console",
|
||||||
|
settings.span_events(),
|
||||||
|
true,
|
||||||
|
console.ansi(),
|
||||||
|
console.format(),
|
||||||
|
console.filter(),
|
||||||
|
),
|
||||||
|
crate::ConsoleOutput::Stderr => build_non_blocking_output(
|
||||||
|
std::io::stderr(),
|
||||||
|
"ksp-logging-console",
|
||||||
|
settings.span_events(),
|
||||||
|
true,
|
||||||
|
console.ansi(),
|
||||||
|
console.format(),
|
||||||
|
console.filter(),
|
||||||
|
),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
fn build_file_output(file: &crate::FileSettings, settings: &crate::LoggingSettings) -> ksp_core_lib::Result<PreparedOutput> {
|
fn build_file_output(file: &crate::FileSettings, settings: &crate::LoggingSettings) -> ksp_core_lib::Result<PreparedFileOutput> {
|
||||||
let appender_result = tracing_appender::rolling::RollingFileAppender::builder()
|
let appender_result = tracing_appender::rolling::RollingFileAppender::builder()
|
||||||
.rotation(map_file_rotation(file.rotation()))
|
.rotation(map_file_rotation(file.rotation()))
|
||||||
.filename_prefix(file.file_name_prefix())
|
.filename_prefix(file.file_name_prefix())
|
||||||
@@ -210,6 +282,7 @@ fn build_file_output(file: &crate::FileSettings, settings: &crate::LoggingSettin
|
|||||||
std::result::Result::Err(error) => {
|
std::result::Result::Err(error) => {
|
||||||
return std::result::Result::Err(
|
return std::result::Result::Err(
|
||||||
ksp_core_lib::Error::new(crate::ERROR_CODE_FILE_OUTPUT_INITIALIZATION_FAILED, "unable to initialize the KSP rolling file appender")
|
ksp_core_lib::Error::new(crate::ERROR_CODE_FILE_OUTPUT_INITIALIZATION_FAILED, "unable to initialize the KSP rolling file appender")
|
||||||
|
.with_context("output_id", file.output_id())
|
||||||
.with_context("directory", file.directory().display().to_string())
|
.with_context("directory", file.directory().display().to_string())
|
||||||
.with_context("file_name_prefix", file.file_name_prefix())
|
.with_context("file_name_prefix", file.file_name_prefix())
|
||||||
.with_source(error),
|
.with_source(error),
|
||||||
@@ -217,16 +290,26 @@ fn build_file_output(file: &crate::FileSettings, settings: &crate::LoggingSettin
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
let stripped_writer = crate::writer::StripAnsiWriter::new(appender);
|
let stripped_writer = crate::writer::StripAnsiWriter::new(appender);
|
||||||
return std::result::Result::Ok(build_non_blocking_output(stripped_writer, "ksp-logging-file", settings, false));
|
let thread_name = format!("ksp-logging-{}", file.output_id());
|
||||||
|
let prepared = build_non_blocking_output(stripped_writer, thread_name.as_str(), settings.span_events(), false, false, file.format(), file.filter());
|
||||||
|
return std::result::Result::Ok(PreparedFileOutput { output_id: file.output_id().to_string(), layer: prepared.layer, output: prepared.output });
|
||||||
}
|
}
|
||||||
|
|
||||||
fn build_non_blocking_output<W>(writer: W, thread_name: &str, settings: &crate::LoggingSettings, ansi_sanitization: bool) -> PreparedOutput
|
fn build_non_blocking_output<W>(
|
||||||
|
writer: W,
|
||||||
|
thread_name: &str,
|
||||||
|
span_events: crate::SpanEvents,
|
||||||
|
ansi_sanitization: bool,
|
||||||
|
ansi: bool,
|
||||||
|
format: crate::LogFormat,
|
||||||
|
filter: &crate::OutputFilter,
|
||||||
|
) -> PreparedOutput
|
||||||
where
|
where
|
||||||
W: std::io::Write + std::marker::Send + 'static,
|
W: std::io::Write + std::marker::Send + 'static,
|
||||||
{
|
{
|
||||||
let (non_blocking, worker_guard) = non_blocking_builder(thread_name).finish(writer);
|
let (non_blocking, worker_guard) = non_blocking_builder(thread_name).finish(writer);
|
||||||
let error_counter = non_blocking.error_counter();
|
let error_counter = non_blocking.error_counter();
|
||||||
let layer = build_format_layer(non_blocking, settings, ansi_sanitization);
|
let layer = build_format_layer(non_blocking, span_events, ansi_sanitization, ansi, format, filter);
|
||||||
return PreparedOutput { layer, output: RuntimeOutput { _worker_guard: worker_guard, error_counter } };
|
return PreparedOutput { layer, output: RuntimeOutput { _worker_guard: worker_guard, error_counter } };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -234,16 +317,55 @@ fn non_blocking_builder(thread_name: &str) -> tracing_appender::non_blocking::No
|
|||||||
return tracing_appender::non_blocking::NonBlockingBuilder::default().lossy(true).thread_name(thread_name);
|
return tracing_appender::non_blocking::NonBlockingBuilder::default().lossy(true).thread_name(thread_name);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn build_format_layer(writer: tracing_appender::non_blocking::NonBlocking, settings: &crate::LoggingSettings, ansi_sanitization: bool) -> BoxedRuntimeLayer {
|
fn build_format_layer(
|
||||||
return tracing_subscriber::fmt::layer()
|
writer: tracing_appender::non_blocking::NonBlocking,
|
||||||
.with_writer(writer)
|
span_events: crate::SpanEvents,
|
||||||
.with_ansi(false)
|
ansi_sanitization: bool,
|
||||||
.with_ansi_sanitization(ansi_sanitization)
|
ansi: bool,
|
||||||
.with_target(true)
|
format: crate::LogFormat,
|
||||||
.with_file(true)
|
filter: &crate::OutputFilter,
|
||||||
.with_line_number(true)
|
) -> BoxedRuntimeLayer {
|
||||||
.with_span_events(map_span_events(settings.span_events()))
|
let span_events = map_span_events(span_events);
|
||||||
.boxed();
|
return match format {
|
||||||
|
crate::LogFormat::Human => tracing_subscriber::fmt::layer()
|
||||||
|
.with_writer(crate::writer::RouteMakeWriter::new(writer, filter.clone()))
|
||||||
|
.with_ansi(ansi)
|
||||||
|
.with_ansi_sanitization(ansi_sanitization)
|
||||||
|
.with_target(true)
|
||||||
|
.with_file(true)
|
||||||
|
.with_line_number(true)
|
||||||
|
.with_span_events(span_events)
|
||||||
|
.boxed(),
|
||||||
|
crate::LogFormat::Compact => tracing_subscriber::fmt::layer()
|
||||||
|
.compact()
|
||||||
|
.with_writer(crate::writer::RouteMakeWriter::new(writer, filter.clone()))
|
||||||
|
.with_ansi(ansi)
|
||||||
|
.with_ansi_sanitization(ansi_sanitization)
|
||||||
|
.with_target(true)
|
||||||
|
.with_file(true)
|
||||||
|
.with_line_number(true)
|
||||||
|
.with_span_events(span_events)
|
||||||
|
.boxed(),
|
||||||
|
crate::LogFormat::Pretty => tracing_subscriber::fmt::layer()
|
||||||
|
.pretty()
|
||||||
|
.with_writer(crate::writer::RouteMakeWriter::new(writer, filter.clone()))
|
||||||
|
.with_ansi(ansi)
|
||||||
|
.with_ansi_sanitization(ansi_sanitization)
|
||||||
|
.with_target(true)
|
||||||
|
.with_file(true)
|
||||||
|
.with_line_number(true)
|
||||||
|
.with_span_events(span_events)
|
||||||
|
.boxed(),
|
||||||
|
crate::LogFormat::Json => tracing_subscriber::fmt::layer()
|
||||||
|
.json()
|
||||||
|
.with_writer(crate::writer::RouteMakeWriter::new(writer, filter.clone()))
|
||||||
|
.with_ansi(false)
|
||||||
|
.with_target(true)
|
||||||
|
.with_file(true)
|
||||||
|
.with_line_number(true)
|
||||||
|
.with_span_events(span_events)
|
||||||
|
.boxed(),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
fn build_target_filter(settings: &crate::LoggingSettings) -> tracing_subscriber::filter::Targets {
|
fn build_target_filter(settings: &crate::LoggingSettings) -> tracing_subscriber::filter::Targets {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// file: crates/ksp-logging-lib/src/settings.rs
|
// file: crates/ksp-logging-lib/src/settings.rs
|
||||||
// version: 2
|
// version: 4
|
||||||
|
|
||||||
/// Runtime filter level used by KSP logging settings.
|
/// Runtime filter level used by KSP logging settings.
|
||||||
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
||||||
@@ -18,7 +18,7 @@ pub enum LogFilterLevel {
|
|||||||
Trace,
|
Trace,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Per-target filter override owned by Logging.
|
/// Per-target filter override owned by Logging for the global KSP takeover policy.
|
||||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
pub struct TargetFilter {
|
pub struct TargetFilter {
|
||||||
target_prefix: std::string::String,
|
target_prefix: std::string::String,
|
||||||
@@ -56,6 +56,61 @@ pub enum SpanEvents {
|
|||||||
Full,
|
Full,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Output format requested for one Logging sink.
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
||||||
|
pub enum LogFormat {
|
||||||
|
/// Standard human-readable formatter with metadata.
|
||||||
|
Human,
|
||||||
|
/// Compact human-readable formatter.
|
||||||
|
Compact,
|
||||||
|
/// Expanded pretty human-readable formatter.
|
||||||
|
Pretty,
|
||||||
|
/// Structured JSON formatter.
|
||||||
|
Json,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Per-output routing filter applied in addition to the global KSP takeover policy.
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub struct OutputFilter {
|
||||||
|
level: crate::LogFilterLevel,
|
||||||
|
targets: std::vec::Vec<std::string::String>,
|
||||||
|
domains: std::vec::Vec<std::string::String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OutputFilter {
|
||||||
|
/// Creates an explicit per-output filter.
|
||||||
|
///
|
||||||
|
/// `targets` contains KSP target prefixes or the single wildcard `*`. `domains` contains domain prefixes or the single wildcard `*`.
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(level: crate::LogFilterLevel, targets: std::vec::Vec<std::string::String>, domains: std::vec::Vec<std::string::String>) -> Self {
|
||||||
|
return Self { level, targets, domains };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates an unrestricted routing filter that does not further constrain the global KSP takeover policy.
|
||||||
|
#[must_use]
|
||||||
|
pub fn unrestricted() -> Self {
|
||||||
|
return Self::new(crate::LogFilterLevel::Trace, std::vec!["*".to_string()], std::vec!["*".to_string()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the maximum verbosity accepted by this output.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn level(&self) -> crate::LogFilterLevel {
|
||||||
|
return self.level;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns target selectors in declaration order.
|
||||||
|
#[must_use]
|
||||||
|
pub fn targets(&self) -> &[std::string::String] {
|
||||||
|
return self.targets.as_slice();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns domain selectors in declaration order.
|
||||||
|
#[must_use]
|
||||||
|
pub fn domains(&self) -> &[std::string::String] {
|
||||||
|
return self.domains.as_slice();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Console stream selected for human-readable logs.
|
/// Console stream selected for human-readable logs.
|
||||||
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
||||||
pub enum ConsoleOutput {
|
pub enum ConsoleOutput {
|
||||||
@@ -66,22 +121,38 @@ pub enum ConsoleOutput {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Runtime settings for the optional console output.
|
/// Runtime settings for the optional console output.
|
||||||
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
pub struct ConsoleSettings {
|
pub struct ConsoleSettings {
|
||||||
|
enabled: bool,
|
||||||
output: crate::ConsoleOutput,
|
output: crate::ConsoleOutput,
|
||||||
|
ansi: bool,
|
||||||
|
format: crate::LogFormat,
|
||||||
|
filter: crate::OutputFilter,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ConsoleSettings {
|
impl ConsoleSettings {
|
||||||
/// Creates console settings targeting standard output.
|
/// Creates explicit console settings.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub const fn stdout() -> Self {
|
pub fn new(enabled: bool, output: crate::ConsoleOutput, ansi: bool, format: crate::LogFormat, filter: crate::OutputFilter) -> Self {
|
||||||
return Self { output: crate::ConsoleOutput::Stdout };
|
return Self { enabled, output, ansi, format, filter };
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Creates console settings targeting standard error.
|
/// Creates enabled standard-output settings compatible with the `0.1.2` runtime behavior.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub const fn stderr() -> Self {
|
pub fn stdout() -> Self {
|
||||||
return Self { output: crate::ConsoleOutput::Stderr };
|
return Self::new(true, crate::ConsoleOutput::Stdout, false, crate::LogFormat::Human, crate::OutputFilter::unrestricted());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates enabled standard-error settings compatible with the `0.1.2` runtime behavior.
|
||||||
|
#[must_use]
|
||||||
|
pub fn stderr() -> Self {
|
||||||
|
return Self::new(true, crate::ConsoleOutput::Stderr, false, crate::LogFormat::Human, crate::OutputFilter::unrestricted());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns whether this console output is enabled.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn enabled(&self) -> bool {
|
||||||
|
return self.enabled;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns the selected console stream.
|
/// Returns the selected console stream.
|
||||||
@@ -89,9 +160,27 @@ impl ConsoleSettings {
|
|||||||
pub const fn output(&self) -> crate::ConsoleOutput {
|
pub const fn output(&self) -> crate::ConsoleOutput {
|
||||||
return self.output;
|
return self.output;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns whether ANSI formatting is requested for the console output.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn ansi(&self) -> bool {
|
||||||
|
return self.ansi;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the requested console format.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn format(&self) -> crate::LogFormat {
|
||||||
|
return self.format;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the routing filter associated with the console output.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn filter(&self) -> &crate::OutputFilter {
|
||||||
|
return &self.filter;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Rotation cadence for the optional file output.
|
/// Rotation cadence for a file output.
|
||||||
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
||||||
pub enum FileRotation {
|
pub enum FileRotation {
|
||||||
/// Keeps a single non-rotating file.
|
/// Keeps a single non-rotating file.
|
||||||
@@ -102,23 +191,62 @@ pub enum FileRotation {
|
|||||||
Daily,
|
Daily,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Runtime settings for the optional file output.
|
/// Runtime settings for one file output.
|
||||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
pub struct FileSettings {
|
pub struct FileSettings {
|
||||||
|
output_id: std::string::String,
|
||||||
|
enabled: bool,
|
||||||
directory: std::path::PathBuf,
|
directory: std::path::PathBuf,
|
||||||
file_name_prefix: std::string::String,
|
file_name_prefix: std::string::String,
|
||||||
rotation: crate::FileRotation,
|
rotation: crate::FileRotation,
|
||||||
|
format: crate::LogFormat,
|
||||||
|
ansi: bool,
|
||||||
|
filter: crate::OutputFilter,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl FileSettings {
|
impl FileSettings {
|
||||||
/// Creates file output settings.
|
/// Creates explicit file output settings.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn new(
|
pub fn new(
|
||||||
|
output_id: impl std::convert::Into<std::string::String>,
|
||||||
|
enabled: bool,
|
||||||
directory: impl std::convert::Into<std::path::PathBuf>,
|
directory: impl std::convert::Into<std::path::PathBuf>,
|
||||||
file_name_prefix: impl std::convert::Into<std::string::String>,
|
file_name_prefix: impl std::convert::Into<std::string::String>,
|
||||||
rotation: crate::FileRotation,
|
rotation: crate::FileRotation,
|
||||||
|
format: crate::LogFormat,
|
||||||
|
filter: crate::OutputFilter,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
return Self { directory: directory.into(), file_name_prefix: file_name_prefix.into(), rotation };
|
return Self {
|
||||||
|
output_id: output_id.into(),
|
||||||
|
enabled,
|
||||||
|
directory: directory.into(),
|
||||||
|
file_name_prefix: file_name_prefix.into(),
|
||||||
|
rotation,
|
||||||
|
format,
|
||||||
|
ansi: false,
|
||||||
|
filter,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets whether ANSI formatting is requested and returns the updated settings.
|
||||||
|
///
|
||||||
|
/// Persistent file outputs are required to keep this value `false`; [`crate::LoggingSettings::validate`] rejects `true`.
|
||||||
|
#[must_use]
|
||||||
|
pub fn with_ansi(mut self, ansi: bool) -> Self {
|
||||||
|
self.ansi = ansi;
|
||||||
|
return self;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the stable output identifier used for diagnostics and reload accounting.
|
||||||
|
#[must_use]
|
||||||
|
pub fn output_id(&self) -> &str {
|
||||||
|
return self.output_id.as_str();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns whether this file output is enabled.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn enabled(&self) -> bool {
|
||||||
|
return self.enabled;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns the directory containing log files.
|
/// Returns the directory containing log files.
|
||||||
@@ -138,6 +266,24 @@ impl FileSettings {
|
|||||||
pub const fn rotation(&self) -> crate::FileRotation {
|
pub const fn rotation(&self) -> crate::FileRotation {
|
||||||
return self.rotation;
|
return self.rotation;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns the requested file format.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn format(&self) -> crate::LogFormat {
|
||||||
|
return self.format;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns whether ANSI formatting was requested for this file output.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn ansi(&self) -> bool {
|
||||||
|
return self.ansi;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the routing filter associated with this file output.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn filter(&self) -> &crate::OutputFilter {
|
||||||
|
return &self.filter;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Complete runtime settings consumed by `ksp-logging-lib` initialization and reload.
|
/// Complete runtime settings consumed by `ksp-logging-lib` initialization and reload.
|
||||||
@@ -147,29 +293,29 @@ pub struct LoggingSettings {
|
|||||||
target_filters: std::vec::Vec<crate::TargetFilter>,
|
target_filters: std::vec::Vec<crate::TargetFilter>,
|
||||||
span_events: crate::SpanEvents,
|
span_events: crate::SpanEvents,
|
||||||
console: std::option::Option<crate::ConsoleSettings>,
|
console: std::option::Option<crate::ConsoleSettings>,
|
||||||
file: std::option::Option<crate::FileSettings>,
|
files: std::vec::Vec<crate::FileSettings>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl LoggingSettings {
|
impl LoggingSettings {
|
||||||
/// Creates explicit Logging settings without any target override.
|
/// Creates explicit Logging settings without any global target override.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn new(
|
pub fn new(
|
||||||
default_filter: crate::LogFilterLevel,
|
default_filter: crate::LogFilterLevel,
|
||||||
span_events: crate::SpanEvents,
|
span_events: crate::SpanEvents,
|
||||||
console: std::option::Option<crate::ConsoleSettings>,
|
console: std::option::Option<crate::ConsoleSettings>,
|
||||||
file: std::option::Option<crate::FileSettings>,
|
files: std::vec::Vec<crate::FileSettings>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
return Self { default_filter, target_filters: std::vec::Vec::new(), span_events, console, file };
|
return Self { default_filter, target_filters: std::vec::Vec::new(), span_events, console, files };
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Adds one target-prefix override and returns the updated settings.
|
/// Adds one target-prefix override to the global KSP takeover policy and returns the updated settings.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn with_target_filter(mut self, target_filter: crate::TargetFilter) -> Self {
|
pub fn with_target_filter(mut self, target_filter: crate::TargetFilter) -> Self {
|
||||||
self.target_filters.push(target_filter);
|
self.target_filters.push(target_filter);
|
||||||
return self;
|
return self;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns the default level applied to KSP-owned targets.
|
/// Returns the default level applied to KSP-owned targets by the global takeover policy.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub const fn default_filter(&self) -> crate::LogFilterLevel {
|
pub const fn default_filter(&self) -> crate::LogFilterLevel {
|
||||||
return self.default_filter;
|
return self.default_filter;
|
||||||
@@ -187,16 +333,16 @@ impl LoggingSettings {
|
|||||||
return self.span_events;
|
return self.span_events;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns console settings when console output is enabled.
|
/// Returns console settings when the console output is declared.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn console(&self) -> std::option::Option<&crate::ConsoleSettings> {
|
pub fn console(&self) -> std::option::Option<&crate::ConsoleSettings> {
|
||||||
return self.console.as_ref();
|
return self.console.as_ref();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns file settings when file output is enabled.
|
/// Returns all declared file outputs in declaration order.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn file(&self) -> std::option::Option<&crate::FileSettings> {
|
pub fn files(&self) -> &[crate::FileSettings] {
|
||||||
return self.file.as_ref();
|
return self.files.as_slice();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Validates backend-independent invariants of the runtime settings.
|
/// Validates backend-independent invariants of the runtime settings.
|
||||||
@@ -216,18 +362,148 @@ impl LoggingSettings {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if let std::option::Option::Some(file) = self.file.as_ref()
|
if let std::option::Option::Some(console) = self.console.as_ref() {
|
||||||
&& file.file_name_prefix().is_empty()
|
if console.ansi() && console.format() == crate::LogFormat::Json {
|
||||||
{
|
return std::result::Result::Err(
|
||||||
return std::result::Result::Err(
|
ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_SETTINGS, "ANSI formatting is not compatible with JSON console output")
|
||||||
ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_SETTINGS, "file name prefix must not be empty")
|
.with_context("field", "console.ansi"),
|
||||||
.with_context("field", "file.file_name_prefix"),
|
);
|
||||||
);
|
}
|
||||||
|
let validation_result = validate_output_filter(console.filter(), "console.filter");
|
||||||
|
if let std::result::Result::Err(error) = validation_result {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (index, file) in self.files.iter().enumerate() {
|
||||||
|
let output_id_validation = validate_output_id(file.output_id(), index);
|
||||||
|
if let std::result::Result::Err(error) = output_id_validation {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
if file.directory().as_os_str().is_empty() {
|
||||||
|
return std::result::Result::Err(
|
||||||
|
ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_SETTINGS, "file output directory must not be empty")
|
||||||
|
.with_context("field", format!("files[{index}].directory"))
|
||||||
|
.with_context("output_id", file.output_id()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if file.file_name_prefix().is_empty() {
|
||||||
|
return std::result::Result::Err(
|
||||||
|
ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_SETTINGS, "file name prefix must not be empty")
|
||||||
|
.with_context("field", format!("files[{index}].file_name_prefix"))
|
||||||
|
.with_context("output_id", file.output_id()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if file.ansi() {
|
||||||
|
return std::result::Result::Err(
|
||||||
|
ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_SETTINGS, "ANSI sequences are not allowed in persistent file outputs")
|
||||||
|
.with_context("field", format!("files[{index}].ansi"))
|
||||||
|
.with_context("output_id", file.output_id()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let filter_validation = validate_output_filter(file.filter(), format!("files[{index}].filter").as_str());
|
||||||
|
if let std::result::Result::Err(error) = filter_validation {
|
||||||
|
return std::result::Result::Err(error.with_context("output_id", file.output_id()));
|
||||||
|
}
|
||||||
|
for previous in &self.files[..index] {
|
||||||
|
if previous.output_id() == file.output_id() {
|
||||||
|
return std::result::Result::Err(
|
||||||
|
ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_SETTINGS, "file output identifiers must be unique")
|
||||||
|
.with_context("field", format!("files[{index}].output_id"))
|
||||||
|
.with_context("output_id", file.output_id()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return std::result::Result::Ok(());
|
return std::result::Result::Ok(());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn validate_output_id(output_id: &str, index: usize) -> ksp_core_lib::Result<()> {
|
||||||
|
let mut previous_was_separator = true;
|
||||||
|
if output_id.is_empty() {
|
||||||
|
return invalid_output_id(output_id, index);
|
||||||
|
}
|
||||||
|
for byte in output_id.bytes() {
|
||||||
|
if byte == b'.' {
|
||||||
|
if previous_was_separator {
|
||||||
|
return invalid_output_id(output_id, index);
|
||||||
|
}
|
||||||
|
previous_was_separator = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if !(byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_' || byte == b'-') {
|
||||||
|
return invalid_output_id(output_id, index);
|
||||||
|
}
|
||||||
|
previous_was_separator = false;
|
||||||
|
}
|
||||||
|
if previous_was_separator {
|
||||||
|
return invalid_output_id(output_id, index);
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn invalid_output_id(output_id: &str, index: usize) -> ksp_core_lib::Result<()> {
|
||||||
|
return std::result::Result::Err(
|
||||||
|
ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_SETTINGS, "file output identifier is invalid")
|
||||||
|
.with_context("field", format!("files[{index}].output_id"))
|
||||||
|
.with_context("output_id", output_id),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_output_filter(filter: &crate::OutputFilter, field: &str) -> ksp_core_lib::Result<()> {
|
||||||
|
let target_validation = validate_selectors(filter.targets(), true, format!("{field}.targets").as_str());
|
||||||
|
if let std::result::Result::Err(error) = target_validation {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
let domain_validation = validate_selectors(filter.domains(), false, format!("{field}.domains").as_str());
|
||||||
|
if let std::result::Result::Err(error) = domain_validation {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_selectors(selectors: &[std::string::String], target_dimension: bool, field: &str) -> ksp_core_lib::Result<()> {
|
||||||
|
if selectors.is_empty() {
|
||||||
|
return std::result::Result::Err(
|
||||||
|
ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_SETTINGS, "output selector list must not be empty").with_context("field", field),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if selectors.len() > 1
|
||||||
|
&& selectors.iter().any(|selector| -> bool {
|
||||||
|
return selector == "*";
|
||||||
|
})
|
||||||
|
{
|
||||||
|
return std::result::Result::Err(
|
||||||
|
ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_SETTINGS, "wildcard selector must be used alone").with_context("field", field),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (index, selector) in selectors.iter().enumerate() {
|
||||||
|
if selector.trim().is_empty() {
|
||||||
|
return std::result::Result::Err(
|
||||||
|
ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_SETTINGS, "output selector must not be empty")
|
||||||
|
.with_context("field", format!("{field}[{index}]")),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if target_dimension && selector != "*" && !selector.starts_with("ksp-") {
|
||||||
|
return std::result::Result::Err(
|
||||||
|
ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_SETTINGS, "output target selector must identify a KSP-owned target")
|
||||||
|
.with_context("field", format!("{field}[{index}]"))
|
||||||
|
.with_context("selector", selector),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for previous in &selectors[..index] {
|
||||||
|
if previous == selector {
|
||||||
|
return std::result::Result::Err(
|
||||||
|
ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_SETTINGS, "output selectors must be unique")
|
||||||
|
.with_context("field", format!("{field}[{index}]"))
|
||||||
|
.with_context("selector", selector),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
#[path = "../unit_tests/settings.rs"]
|
#[path = "../unit_tests/settings.rs"]
|
||||||
mod tests;
|
mod tests;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// file: crates/ksp-logging-lib/src/writer.rs
|
// file: crates/ksp-logging-lib/src/writer.rs
|
||||||
// version: 1
|
// version: 3
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
enum StripAnsiState {
|
enum StripAnsiState {
|
||||||
@@ -100,6 +100,99 @@ impl<W> StripAnsiWriter<W> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub(crate) struct RouteMakeWriter<W> {
|
||||||
|
inner: W,
|
||||||
|
filter: crate::OutputFilter,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<W> RouteMakeWriter<W> {
|
||||||
|
pub(crate) fn new(inner: W, filter: crate::OutputFilter) -> Self {
|
||||||
|
return Self { inner, filter };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) enum RoutedWriter<W> {
|
||||||
|
Enabled(W),
|
||||||
|
Disabled,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<W> std::io::Write for RoutedWriter<W>
|
||||||
|
where
|
||||||
|
W: std::io::Write,
|
||||||
|
{
|
||||||
|
fn write(&mut self, buffer: &[u8]) -> std::io::Result<usize> {
|
||||||
|
return match self {
|
||||||
|
Self::Enabled(writer) => std::io::Write::write(writer, buffer),
|
||||||
|
Self::Disabled => std::result::Result::Ok(buffer.len()),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn flush(&mut self) -> std::io::Result<()> {
|
||||||
|
return match self {
|
||||||
|
Self::Enabled(writer) => std::io::Write::flush(writer),
|
||||||
|
Self::Disabled => std::result::Result::Ok(()),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'writer, W> tracing_subscriber::fmt::MakeWriter<'writer> for RouteMakeWriter<W>
|
||||||
|
where
|
||||||
|
W: tracing_subscriber::fmt::MakeWriter<'writer>,
|
||||||
|
{
|
||||||
|
type Writer = RoutedWriter<W::Writer>;
|
||||||
|
|
||||||
|
fn make_writer(&'writer self) -> Self::Writer {
|
||||||
|
return RoutedWriter::Enabled(tracing_subscriber::fmt::MakeWriter::make_writer(&self.inner));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn make_writer_for(&'writer self, metadata: &tracing::Metadata<'_>) -> Self::Writer {
|
||||||
|
if metadata_matches_filter(metadata, &self.filter) {
|
||||||
|
return RoutedWriter::Enabled(tracing_subscriber::fmt::MakeWriter::make_writer_for(&self.inner, metadata));
|
||||||
|
}
|
||||||
|
return RoutedWriter::Disabled;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn metadata_matches_filter(metadata: &tracing::Metadata<'_>, filter: &crate::OutputFilter) -> bool {
|
||||||
|
if !level_is_enabled(metadata.level(), filter.level()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if !filter.targets().iter().any(|selector| -> bool {
|
||||||
|
return selector == "*" || metadata.target().starts_with(selector.as_str());
|
||||||
|
}) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return crate::domain::current_domain_matches(filter.domains());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn level_is_enabled(level: &tracing::Level, filter: crate::LogFilterLevel) -> bool {
|
||||||
|
return match filter {
|
||||||
|
crate::LogFilterLevel::Off => false,
|
||||||
|
crate::LogFilterLevel::Error => level_rank(level) <= 1,
|
||||||
|
crate::LogFilterLevel::Warn => level_rank(level) <= 2,
|
||||||
|
crate::LogFilterLevel::Info => level_rank(level) <= 3,
|
||||||
|
crate::LogFilterLevel::Debug => level_rank(level) <= 4,
|
||||||
|
crate::LogFilterLevel::Trace => level_rank(level) <= 5,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn level_rank(level: &tracing::Level) -> u8 {
|
||||||
|
if level == &tracing::Level::ERROR {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
if level == &tracing::Level::WARN {
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
if level == &tracing::Level::INFO {
|
||||||
|
return 3;
|
||||||
|
}
|
||||||
|
if level == &tracing::Level::DEBUG {
|
||||||
|
return 4;
|
||||||
|
}
|
||||||
|
return 5;
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
#[path = "../unit_tests/writer.rs"]
|
#[path = "../unit_tests/writer.rs"]
|
||||||
mod tests;
|
mod tests;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// file: crates/ksp-logging-lib/tests/public_api.rs
|
// file: crates/ksp-logging-lib/tests/public_api.rs
|
||||||
// version: 4
|
// version: 6
|
||||||
|
|
||||||
//! Integration tests for the public crate-root surface of `ksp-logging-lib`.
|
//! Integration tests for the public crate-root surface of `ksp-logging-lib`.
|
||||||
|
|
||||||
@@ -7,16 +7,41 @@ const TEST_TARGET: &str = "ksp-logging-lib";
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn public_settings_surface_is_usable() {
|
fn public_settings_surface_is_usable() {
|
||||||
|
let console_filter =
|
||||||
|
ksp_logging_lib::OutputFilter::new(ksp_logging_lib::LogFilterLevel::Debug, std::vec![TEST_TARGET.to_string()], std::vec!["logging".to_string()]);
|
||||||
|
let file_filter = ksp_logging_lib::OutputFilter::new(
|
||||||
|
ksp_logging_lib::LogFilterLevel::Error,
|
||||||
|
std::vec![TEST_TARGET.to_string()],
|
||||||
|
std::vec!["logging.runtime".to_string()],
|
||||||
|
);
|
||||||
let settings = ksp_logging_lib::LoggingSettings::new(
|
let settings = ksp_logging_lib::LoggingSettings::new(
|
||||||
ksp_logging_lib::LogFilterLevel::Info,
|
ksp_logging_lib::LogFilterLevel::Info,
|
||||||
ksp_logging_lib::SpanEvents::NewAndClose,
|
ksp_logging_lib::SpanEvents::NewAndClose,
|
||||||
std::option::Option::Some(ksp_logging_lib::ConsoleSettings::stdout()),
|
std::option::Option::Some(ksp_logging_lib::ConsoleSettings::new(
|
||||||
std::option::Option::Some(ksp_logging_lib::FileSettings::new("logs", "ksp", ksp_logging_lib::FileRotation::Daily)),
|
true,
|
||||||
|
ksp_logging_lib::ConsoleOutput::Stdout,
|
||||||
|
true,
|
||||||
|
ksp_logging_lib::LogFormat::Compact,
|
||||||
|
console_filter,
|
||||||
|
)),
|
||||||
|
std::vec![ksp_logging_lib::FileSettings::new(
|
||||||
|
"file.logging.error",
|
||||||
|
true,
|
||||||
|
"logs",
|
||||||
|
"error.jsonl",
|
||||||
|
ksp_logging_lib::FileRotation::Daily,
|
||||||
|
ksp_logging_lib::LogFormat::Json,
|
||||||
|
file_filter,
|
||||||
|
)],
|
||||||
)
|
)
|
||||||
.with_target_filter(ksp_logging_lib::TargetFilter::new(TEST_TARGET, ksp_logging_lib::LogFilterLevel::Trace));
|
.with_target_filter(ksp_logging_lib::TargetFilter::new(TEST_TARGET, ksp_logging_lib::LogFilterLevel::Trace));
|
||||||
assert!(settings.validate().is_ok());
|
assert!(settings.validate().is_ok());
|
||||||
assert_eq!(settings.default_filter(), ksp_logging_lib::LogFilterLevel::Info);
|
assert_eq!(settings.default_filter(), ksp_logging_lib::LogFilterLevel::Info);
|
||||||
assert_eq!(settings.console().map(ksp_logging_lib::ConsoleSettings::output), std::option::Option::Some(ksp_logging_lib::ConsoleOutput::Stdout));
|
assert_eq!(settings.console().map(ksp_logging_lib::ConsoleSettings::output), std::option::Option::Some(ksp_logging_lib::ConsoleOutput::Stdout));
|
||||||
|
assert_eq!(settings.console().map(ksp_logging_lib::ConsoleSettings::format), std::option::Option::Some(ksp_logging_lib::LogFormat::Compact));
|
||||||
|
assert_eq!(settings.files().len(), 1);
|
||||||
|
assert_eq!(settings.files()[0].output_id(), "file.logging.error");
|
||||||
|
assert_eq!(settings.files()[0].format(), ksp_logging_lib::LogFormat::Json);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -60,6 +85,7 @@ fn public_runtime_surface_is_addressable_without_installing_it() {
|
|||||||
let _already_initialized = ksp_logging_lib::ERROR_CODE_ALREADY_INITIALIZED;
|
let _already_initialized = ksp_logging_lib::ERROR_CODE_ALREADY_INITIALIZED;
|
||||||
let _reload_failed = ksp_logging_lib::ERROR_CODE_RELOAD_FAILED;
|
let _reload_failed = ksp_logging_lib::ERROR_CODE_RELOAD_FAILED;
|
||||||
let _file_initialization_failed = ksp_logging_lib::ERROR_CODE_FILE_OUTPUT_INITIALIZATION_FAILED;
|
let _file_initialization_failed = ksp_logging_lib::ERROR_CODE_FILE_OUTPUT_INITIALIZATION_FAILED;
|
||||||
|
let _per_file_counter = ksp_logging_lib::LoggingGuard::dropped_file_lines;
|
||||||
let dropped = ksp_logging_lib::DroppedLines::zero();
|
let dropped = ksp_logging_lib::DroppedLines::zero();
|
||||||
assert_eq!(dropped.console(), 0);
|
assert_eq!(dropped.console(), 0);
|
||||||
assert_eq!(dropped.file(), 0);
|
assert_eq!(dropped.file(), 0);
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
// file: crates/ksp-logging-lib/tests/runtime.rs
|
// file: crates/ksp-logging-lib/tests/runtime.rs
|
||||||
// version: 4
|
// version: 8
|
||||||
|
|
||||||
//! Integration tests for global initialization, takeover filtering, non-blocking outputs and hot reload.
|
//! Integration tests for global initialization, takeover filtering, non-blocking outputs and hot reload.
|
||||||
|
|
||||||
const LOGGING_TARGET: &str = "ksp-logging-lib";
|
const LOGGING_TARGET: &str = "ksp-logging-lib";
|
||||||
const OTHER_KSP_TARGET: &str = "ksp-store-lib";
|
const OTHER_KSP_TARGET: &str = "ksp-store-lib";
|
||||||
|
const JSON_KSP_TARGET: &str = "ksp-logging-json-test";
|
||||||
const EXTERNAL_TARGET: &str = "sqlx";
|
const EXTERNAL_TARGET: &str = "sqlx";
|
||||||
|
|
||||||
fn logging_trace_enabled() -> bool {
|
fn logging_trace_enabled() -> bool {
|
||||||
@@ -67,7 +68,7 @@ fn exercise_concurrent_reload(guard: &mut ksp_logging_lib::LoggingGuard, disable
|
|||||||
ksp_logging_lib::LogFilterLevel::Off,
|
ksp_logging_lib::LogFilterLevel::Off,
|
||||||
ksp_logging_lib::SpanEvents::Off,
|
ksp_logging_lib::SpanEvents::Off,
|
||||||
std::option::Option::Some(ksp_logging_lib::ConsoleSettings::stderr()),
|
std::option::Option::Some(ksp_logging_lib::ConsoleSettings::stderr()),
|
||||||
std::option::Option::None,
|
std::vec::Vec::new(),
|
||||||
);
|
);
|
||||||
let quiet_reload = ksp_logging_lib::reinitialize(guard, &quiet_console);
|
let quiet_reload = ksp_logging_lib::reinitialize(guard, &quiet_console);
|
||||||
assert!(quiet_reload.is_ok());
|
assert!(quiet_reload.is_ok());
|
||||||
@@ -116,7 +117,7 @@ fn global_runtime_supports_takeover_non_blocking_outputs_hot_reload_and_single_i
|
|||||||
ksp_logging_lib::LogFilterLevel::Info,
|
ksp_logging_lib::LogFilterLevel::Info,
|
||||||
ksp_logging_lib::SpanEvents::Off,
|
ksp_logging_lib::SpanEvents::Off,
|
||||||
std::option::Option::None,
|
std::option::Option::None,
|
||||||
std::option::Option::None,
|
std::vec::Vec::new(),
|
||||||
);
|
);
|
||||||
let initialize_result = ksp_logging_lib::initialize(&disabled);
|
let initialize_result = ksp_logging_lib::initialize(&disabled);
|
||||||
assert!(initialize_result.is_ok());
|
assert!(initialize_result.is_ok());
|
||||||
@@ -132,7 +133,7 @@ fn global_runtime_supports_takeover_non_blocking_outputs_hot_reload_and_single_i
|
|||||||
ksp_logging_lib::LogFilterLevel::Info,
|
ksp_logging_lib::LogFilterLevel::Info,
|
||||||
ksp_logging_lib::SpanEvents::NewAndClose,
|
ksp_logging_lib::SpanEvents::NewAndClose,
|
||||||
std::option::Option::Some(ksp_logging_lib::ConsoleSettings::stderr()),
|
std::option::Option::Some(ksp_logging_lib::ConsoleSettings::stderr()),
|
||||||
std::option::Option::None,
|
std::vec::Vec::new(),
|
||||||
)
|
)
|
||||||
.with_target_filter(ksp_logging_lib::TargetFilter::new(LOGGING_TARGET, ksp_logging_lib::LogFilterLevel::Trace));
|
.with_target_filter(ksp_logging_lib::TargetFilter::new(LOGGING_TARGET, ksp_logging_lib::LogFilterLevel::Trace));
|
||||||
let reload_result = ksp_logging_lib::reinitialize(&mut guard, &console_enabled);
|
let reload_result = ksp_logging_lib::reinitialize(&mut guard, &console_enabled);
|
||||||
@@ -151,7 +152,15 @@ fn global_runtime_supports_takeover_non_blocking_outputs_hot_reload_and_single_i
|
|||||||
ksp_logging_lib::LogFilterLevel::Error,
|
ksp_logging_lib::LogFilterLevel::Error,
|
||||||
ksp_logging_lib::SpanEvents::Full,
|
ksp_logging_lib::SpanEvents::Full,
|
||||||
std::option::Option::None,
|
std::option::Option::None,
|
||||||
std::option::Option::Some(ksp_logging_lib::FileSettings::new(blocked_directory.as_path(), "invalid", ksp_logging_lib::FileRotation::Daily)),
|
std::vec![ksp_logging_lib::FileSettings::new(
|
||||||
|
"file.invalid",
|
||||||
|
true,
|
||||||
|
blocked_directory.as_path(),
|
||||||
|
"invalid",
|
||||||
|
ksp_logging_lib::FileRotation::Daily,
|
||||||
|
ksp_logging_lib::LogFormat::Human,
|
||||||
|
ksp_logging_lib::OutputFilter::unrestricted(),
|
||||||
|
)],
|
||||||
);
|
);
|
||||||
let failed_reload = ksp_logging_lib::reinitialize(&mut guard, &invalid_file);
|
let failed_reload = ksp_logging_lib::reinitialize(&mut guard, &invalid_file);
|
||||||
assert!(failed_reload.is_err());
|
assert!(failed_reload.is_err());
|
||||||
@@ -164,25 +173,203 @@ fn global_runtime_supports_takeover_non_blocking_outputs_hot_reload_and_single_i
|
|||||||
assert!(logging_trace_enabled());
|
assert!(logging_trace_enabled());
|
||||||
assert!(!external_error_enabled());
|
assert!(!external_error_enabled());
|
||||||
exercise_concurrent_reload(&mut guard, &disabled);
|
exercise_concurrent_reload(&mut guard, &disabled);
|
||||||
let log_directory = root.join("logs");
|
let human_directory = root.join("human");
|
||||||
let file_enabled = ksp_logging_lib::LoggingSettings::new(
|
let compact_directory = root.join("compact");
|
||||||
ksp_logging_lib::LogFilterLevel::Info,
|
let pretty_directory = root.join("pretty");
|
||||||
|
let json_directory = root.join("json");
|
||||||
|
let files_enabled = ksp_logging_lib::LoggingSettings::new(
|
||||||
|
ksp_logging_lib::LogFilterLevel::Trace,
|
||||||
ksp_logging_lib::SpanEvents::Off,
|
ksp_logging_lib::SpanEvents::Off,
|
||||||
std::option::Option::None,
|
std::option::Option::None,
|
||||||
std::option::Option::Some(ksp_logging_lib::FileSettings::new(log_directory.as_path(), "runtime-test.log", ksp_logging_lib::FileRotation::Never)),
|
std::vec![
|
||||||
|
ksp_logging_lib::FileSettings::new(
|
||||||
|
"file.human.logging",
|
||||||
|
true,
|
||||||
|
human_directory.as_path(),
|
||||||
|
"human.log",
|
||||||
|
ksp_logging_lib::FileRotation::Never,
|
||||||
|
ksp_logging_lib::LogFormat::Human,
|
||||||
|
ksp_logging_lib::OutputFilter::new(ksp_logging_lib::LogFilterLevel::Info, std::vec![LOGGING_TARGET.to_string()], std::vec!["*".to_string()],),
|
||||||
|
),
|
||||||
|
ksp_logging_lib::FileSettings::new(
|
||||||
|
"file.compact.store",
|
||||||
|
true,
|
||||||
|
compact_directory.as_path(),
|
||||||
|
"compact.log",
|
||||||
|
ksp_logging_lib::FileRotation::Never,
|
||||||
|
ksp_logging_lib::LogFormat::Compact,
|
||||||
|
ksp_logging_lib::OutputFilter::new(ksp_logging_lib::LogFilterLevel::Warn, std::vec![OTHER_KSP_TARGET.to_string()], std::vec!["*".to_string()],),
|
||||||
|
),
|
||||||
|
ksp_logging_lib::FileSettings::new(
|
||||||
|
"file.pretty.error",
|
||||||
|
true,
|
||||||
|
pretty_directory.as_path(),
|
||||||
|
"pretty.log",
|
||||||
|
ksp_logging_lib::FileRotation::Never,
|
||||||
|
ksp_logging_lib::LogFormat::Pretty,
|
||||||
|
ksp_logging_lib::OutputFilter::new(ksp_logging_lib::LogFilterLevel::Error, std::vec![LOGGING_TARGET.to_string()], std::vec!["*".to_string()],),
|
||||||
|
),
|
||||||
|
ksp_logging_lib::FileSettings::new(
|
||||||
|
"file.json.logging",
|
||||||
|
true,
|
||||||
|
json_directory.as_path(),
|
||||||
|
"runtime.jsonl",
|
||||||
|
ksp_logging_lib::FileRotation::Never,
|
||||||
|
ksp_logging_lib::LogFormat::Json,
|
||||||
|
ksp_logging_lib::OutputFilter::new(ksp_logging_lib::LogFilterLevel::Trace, std::vec![JSON_KSP_TARGET.to_string()], std::vec!["*".to_string()],),
|
||||||
|
),
|
||||||
|
],
|
||||||
);
|
);
|
||||||
let file_reload = ksp_logging_lib::reinitialize(&mut guard, &file_enabled);
|
let file_reload = ksp_logging_lib::reinitialize(&mut guard, &files_enabled);
|
||||||
assert!(file_reload.is_ok());
|
assert!(file_reload.is_ok());
|
||||||
ksp_logging_lib::info!(target: LOGGING_TARGET, "file \x1b[31moutput\x1b[0m marker");
|
ksp_logging_lib::info!(target: LOGGING_TARGET, "logging info \x1b[31mmarker\x1b[0m");
|
||||||
|
ksp_logging_lib::error!(target: LOGGING_TARGET, "logging error marker");
|
||||||
|
ksp_logging_lib::info!(target: JSON_KSP_TARGET, "json info marker");
|
||||||
|
ksp_logging_lib::error!(target: JSON_KSP_TARGET, "json error marker");
|
||||||
|
ksp_logging_lib::warn!(target: OTHER_KSP_TARGET, "store warning marker");
|
||||||
|
ksp_logging_lib::info!(target: OTHER_KSP_TARGET, "store info must be filtered");
|
||||||
tracing::error!(target: EXTERNAL_TARGET, "external marker must remain silent");
|
tracing::error!(target: EXTERNAL_TARGET, "external marker must remain silent");
|
||||||
let disable_after_file = ksp_logging_lib::reinitialize(&mut guard, &disabled);
|
let disable_after_file = ksp_logging_lib::reinitialize(&mut guard, &disabled);
|
||||||
assert!(disable_after_file.is_ok());
|
assert!(disable_after_file.is_ok());
|
||||||
let file_text = read_directory_text(log_directory.as_path());
|
let human_text = read_directory_text(human_directory.as_path());
|
||||||
assert!(file_text.contains("file output marker"));
|
let compact_text = read_directory_text(compact_directory.as_path());
|
||||||
assert!(file_text.contains(LOGGING_TARGET));
|
let pretty_text = read_directory_text(pretty_directory.as_path());
|
||||||
assert!(file_text.contains("runtime.rs"));
|
let json_text = read_directory_text(json_directory.as_path());
|
||||||
assert!(!file_text.contains("\x1b["));
|
assert!(human_text.contains("logging info marker"));
|
||||||
assert!(!file_text.contains("external marker must remain silent"));
|
assert!(human_text.contains("logging error marker"));
|
||||||
|
assert!(!human_text.contains("store warning marker"));
|
||||||
|
assert!(!human_text.contains("\x1b["));
|
||||||
|
assert!(compact_text.contains("store warning marker"));
|
||||||
|
assert!(!compact_text.contains("store info must be filtered"));
|
||||||
|
assert!(!compact_text.contains("logging info marker"));
|
||||||
|
assert!(pretty_text.contains("logging error marker"));
|
||||||
|
assert!(!pretty_text.contains("logging info marker"));
|
||||||
|
assert!(json_text.contains("json info marker"));
|
||||||
|
assert!(json_text.contains("json error marker"));
|
||||||
|
assert!(json_text.contains(JSON_KSP_TARGET));
|
||||||
|
assert!(!json_text.contains("store warning marker"));
|
||||||
|
assert!(!human_text.contains("external marker must remain silent"));
|
||||||
|
assert!(!compact_text.contains("external marker must remain silent"));
|
||||||
|
assert!(!pretty_text.contains("external marker must remain silent"));
|
||||||
|
assert!(!json_text.contains("external marker must remain silent"));
|
||||||
|
assert_eq!(guard.dropped_file_lines("file.human.logging"), std::option::Option::Some(0));
|
||||||
|
assert_eq!(guard.dropped_file_lines("file.compact.store"), std::option::Option::Some(0));
|
||||||
|
assert_eq!(guard.dropped_file_lines("file.pretty.error"), std::option::Option::Some(0));
|
||||||
|
assert_eq!(guard.dropped_file_lines("file.json.logging"), std::option::Option::Some(0));
|
||||||
|
assert_eq!(guard.dropped_file_lines("file.unknown"), std::option::Option::None);
|
||||||
|
let domain_logging_directory = root.join("domain-logging");
|
||||||
|
let domain_store_directory = root.join("domain-store");
|
||||||
|
let domain_any_directory = root.join("domain-any");
|
||||||
|
let domain_settings = ksp_logging_lib::LoggingSettings::new(
|
||||||
|
ksp_logging_lib::LogFilterLevel::Trace,
|
||||||
|
ksp_logging_lib::SpanEvents::Full,
|
||||||
|
std::option::Option::None,
|
||||||
|
std::vec![
|
||||||
|
ksp_logging_lib::FileSettings::new(
|
||||||
|
"file.domain.logging",
|
||||||
|
true,
|
||||||
|
domain_logging_directory.as_path(),
|
||||||
|
"logging.log",
|
||||||
|
ksp_logging_lib::FileRotation::Never,
|
||||||
|
ksp_logging_lib::LogFormat::Human,
|
||||||
|
ksp_logging_lib::OutputFilter::new(
|
||||||
|
ksp_logging_lib::LogFilterLevel::Trace,
|
||||||
|
std::vec![LOGGING_TARGET.to_string()],
|
||||||
|
std::vec!["logging".to_string()],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
ksp_logging_lib::FileSettings::new(
|
||||||
|
"file.domain.store",
|
||||||
|
true,
|
||||||
|
domain_store_directory.as_path(),
|
||||||
|
"store.log",
|
||||||
|
ksp_logging_lib::FileRotation::Never,
|
||||||
|
ksp_logging_lib::LogFormat::Human,
|
||||||
|
ksp_logging_lib::OutputFilter::new(
|
||||||
|
ksp_logging_lib::LogFilterLevel::Trace,
|
||||||
|
std::vec![LOGGING_TARGET.to_string()],
|
||||||
|
std::vec!["store".to_string()],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
ksp_logging_lib::FileSettings::new(
|
||||||
|
"file.domain.any",
|
||||||
|
true,
|
||||||
|
domain_any_directory.as_path(),
|
||||||
|
"any.log",
|
||||||
|
ksp_logging_lib::FileRotation::Never,
|
||||||
|
ksp_logging_lib::LogFormat::Human,
|
||||||
|
ksp_logging_lib::OutputFilter::new(ksp_logging_lib::LogFilterLevel::Trace, std::vec![LOGGING_TARGET.to_string()], std::vec!["*".to_string()],),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
let domain_reload = ksp_logging_lib::reinitialize(&mut guard, &domain_settings);
|
||||||
|
assert!(domain_reload.is_ok());
|
||||||
|
ksp_logging_lib::info!(target: LOGGING_TARGET, domain = "logging.runtime", "direct logging domain marker");
|
||||||
|
ksp_logging_lib::info!(target: LOGGING_TARGET, domain = "store", "direct store domain marker");
|
||||||
|
ksp_logging_lib::info!(target: LOGGING_TARGET, "undomained marker");
|
||||||
|
let logging_lifecycle = ksp_logging_lib::info_span!(target: LOGGING_TARGET, "logging_lifecycle_span", domain = "logging");
|
||||||
|
logging_lifecycle.in_scope(|| {
|
||||||
|
return;
|
||||||
|
});
|
||||||
|
let store_lifecycle = ksp_logging_lib::info_span!(target: LOGGING_TARGET, "store_lifecycle_span", domain = "store");
|
||||||
|
store_lifecycle.in_scope(|| {
|
||||||
|
return;
|
||||||
|
});
|
||||||
|
let logging_parent = ksp_logging_lib::info_span!(target: LOGGING_TARGET, "logging_parent_span", domain = "logging");
|
||||||
|
logging_parent.in_scope(|| {
|
||||||
|
ksp_logging_lib::info!(target: LOGGING_TARGET, "inherited logging marker");
|
||||||
|
ksp_logging_lib::info!(target: LOGGING_TARGET, domain = "store", "event override store marker");
|
||||||
|
let inherited_child = ksp_logging_lib::info_span!(target: LOGGING_TARGET, "inherited_child_span");
|
||||||
|
inherited_child.in_scope(|| {
|
||||||
|
ksp_logging_lib::info!(target: LOGGING_TARGET, "child inherited logging marker");
|
||||||
|
});
|
||||||
|
let store_child = ksp_logging_lib::info_span!(target: LOGGING_TARGET, "store_child_span", domain = "store");
|
||||||
|
store_child.in_scope(|| {
|
||||||
|
ksp_logging_lib::info!(target: LOGGING_TARGET, "child explicit store marker");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
let store_parent = ksp_logging_lib::info_span!(target: LOGGING_TARGET, "store_parent_span", domain = "store");
|
||||||
|
store_parent.in_scope(|| {
|
||||||
|
ksp_logging_lib::info!(target: LOGGING_TARGET, "inherited store marker");
|
||||||
|
});
|
||||||
|
let disable_after_domain = ksp_logging_lib::reinitialize(&mut guard, &disabled);
|
||||||
|
assert!(disable_after_domain.is_ok());
|
||||||
|
let domain_logging_text = read_directory_text(domain_logging_directory.as_path());
|
||||||
|
let domain_store_text = read_directory_text(domain_store_directory.as_path());
|
||||||
|
let domain_any_text = read_directory_text(domain_any_directory.as_path());
|
||||||
|
assert!(domain_logging_text.contains("direct logging domain marker"));
|
||||||
|
assert!(domain_logging_text.contains("inherited logging marker"));
|
||||||
|
assert!(domain_logging_text.contains("child inherited logging marker"));
|
||||||
|
assert!(domain_logging_text.contains("logging_lifecycle_span"));
|
||||||
|
assert!(!domain_logging_text.contains("direct store domain marker"));
|
||||||
|
assert!(!domain_logging_text.contains("event override store marker"));
|
||||||
|
assert!(!domain_logging_text.contains("child explicit store marker"));
|
||||||
|
assert!(!domain_logging_text.contains("inherited store marker"));
|
||||||
|
assert!(!domain_logging_text.contains("store_lifecycle_span"));
|
||||||
|
assert!(!domain_logging_text.contains("undomained marker"));
|
||||||
|
assert!(domain_store_text.contains("direct store domain marker"));
|
||||||
|
assert!(domain_store_text.contains("event override store marker"));
|
||||||
|
assert!(domain_store_text.contains("child explicit store marker"));
|
||||||
|
assert!(domain_store_text.contains("inherited store marker"));
|
||||||
|
assert!(domain_store_text.contains("store_lifecycle_span"));
|
||||||
|
assert!(!domain_store_text.contains("direct logging domain marker"));
|
||||||
|
assert!(!domain_store_text.contains("inherited logging marker"));
|
||||||
|
assert!(!domain_store_text.contains("child inherited logging marker"));
|
||||||
|
assert!(!domain_store_text.contains("logging_lifecycle_span"));
|
||||||
|
assert!(!domain_store_text.contains("undomained marker"));
|
||||||
|
assert!(domain_any_text.contains("direct logging domain marker"));
|
||||||
|
assert!(domain_any_text.contains("direct store domain marker"));
|
||||||
|
assert!(domain_any_text.contains("undomained marker"));
|
||||||
|
assert!(domain_any_text.contains("inherited logging marker"));
|
||||||
|
assert!(domain_any_text.contains("event override store marker"));
|
||||||
|
assert!(domain_any_text.contains("child inherited logging marker"));
|
||||||
|
assert!(domain_any_text.contains("child explicit store marker"));
|
||||||
|
assert!(domain_any_text.contains("inherited store marker"));
|
||||||
|
assert!(domain_any_text.contains("logging_lifecycle_span"));
|
||||||
|
assert!(domain_any_text.contains("store_lifecycle_span"));
|
||||||
|
assert_eq!(guard.dropped_file_lines("file.domain.logging"), std::option::Option::Some(0));
|
||||||
|
assert_eq!(guard.dropped_file_lines("file.domain.store"), std::option::Option::Some(0));
|
||||||
|
assert_eq!(guard.dropped_file_lines("file.domain.any"), std::option::Option::Some(0));
|
||||||
let dropped = guard.dropped_lines();
|
let dropped = guard.dropped_lines();
|
||||||
assert_eq!(dropped.total(), dropped.console().saturating_add(dropped.file()));
|
assert_eq!(dropped.total(), dropped.console().saturating_add(dropped.file()));
|
||||||
let second_initialize = ksp_logging_lib::initialize(&disabled);
|
let second_initialize = ksp_logging_lib::initialize(&disabled);
|
||||||
|
|||||||
20
crates/ksp-logging-lib/unit_tests/domain.rs
Normal file
20
crates/ksp-logging-lib/unit_tests/domain.rs
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
// file: crates/ksp-logging-lib/unit_tests/domain.rs
|
||||||
|
// version: 1
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn wildcard_domain_matches_with_or_without_current_domain() {
|
||||||
|
super::set_current_domain(std::option::Option::None);
|
||||||
|
assert!(super::current_domain_matches(&["*".to_string()]));
|
||||||
|
super::set_current_domain(std::option::Option::Some("logging"));
|
||||||
|
assert!(super::current_domain_matches(&["*".to_string()]));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn named_domain_requires_current_matching_prefix() {
|
||||||
|
super::set_current_domain(std::option::Option::None);
|
||||||
|
assert!(!super::current_domain_matches(&["logging".to_string()]));
|
||||||
|
super::set_current_domain(std::option::Option::Some("logging.runtime"));
|
||||||
|
assert!(super::current_domain_matches(&["logging".to_string()]));
|
||||||
|
assert!(super::current_domain_matches(&["store".to_string(), "logging.runtime".to_string()]));
|
||||||
|
assert!(!super::current_domain_matches(&["store".to_string()]));
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
// file: crates/ksp-logging-lib/unit_tests/runtime.rs
|
// file: crates/ksp-logging-lib/unit_tests/runtime.rs
|
||||||
// version: 5
|
// version: 9
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn level_mapping_covers_all_ksp_levels() {
|
fn level_mapping_covers_all_ksp_levels() {
|
||||||
@@ -17,7 +17,7 @@ fn takeover_filter_silences_external_targets_and_applies_ksp_overrides() {
|
|||||||
crate::LogFilterLevel::Info,
|
crate::LogFilterLevel::Info,
|
||||||
crate::SpanEvents::Off,
|
crate::SpanEvents::Off,
|
||||||
std::option::Option::Some(crate::ConsoleSettings::stdout()),
|
std::option::Option::Some(crate::ConsoleSettings::stdout()),
|
||||||
std::option::Option::None,
|
std::vec::Vec::new(),
|
||||||
)
|
)
|
||||||
.with_target_filter(crate::TargetFilter::new("ksp-logging-lib", crate::LogFilterLevel::Trace));
|
.with_target_filter(crate::TargetFilter::new("ksp-logging-lib", crate::LogFilterLevel::Trace));
|
||||||
let filter = super::build_target_filter(&settings);
|
let filter = super::build_target_filter(&settings);
|
||||||
@@ -47,7 +47,7 @@ fn file_rotation_mapping_covers_supported_cadences() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn disabled_runtime_has_no_layers_or_outputs() {
|
fn disabled_runtime_has_no_layers_or_outputs() {
|
||||||
let settings = crate::LoggingSettings::new(crate::LogFilterLevel::Info, crate::SpanEvents::Off, std::option::Option::None, std::option::Option::None);
|
let settings = crate::LoggingSettings::new(crate::LogFilterLevel::Info, crate::SpanEvents::Off, std::option::Option::None, std::vec::Vec::new());
|
||||||
let result = super::prepare_runtime(&settings);
|
let result = super::prepare_runtime(&settings);
|
||||||
assert!(result.is_ok());
|
assert!(result.is_ok());
|
||||||
let prepared = match result {
|
let prepared = match result {
|
||||||
@@ -56,7 +56,7 @@ fn disabled_runtime_has_no_layers_or_outputs() {
|
|||||||
};
|
};
|
||||||
assert!(prepared.layers.is_empty());
|
assert!(prepared.layers.is_empty());
|
||||||
assert!(prepared.outputs.console.is_none());
|
assert!(prepared.outputs.console.is_none());
|
||||||
assert!(prepared.outputs.file.is_none());
|
assert!(prepared.outputs.files.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -65,7 +65,7 @@ fn console_runtime_composes_takeover_filter_before_formatter_and_owns_guard() {
|
|||||||
crate::LogFilterLevel::Info,
|
crate::LogFilterLevel::Info,
|
||||||
crate::SpanEvents::Off,
|
crate::SpanEvents::Off,
|
||||||
std::option::Option::Some(crate::ConsoleSettings::stdout()),
|
std::option::Option::Some(crate::ConsoleSettings::stdout()),
|
||||||
std::option::Option::None,
|
std::vec::Vec::new(),
|
||||||
);
|
);
|
||||||
let result = super::prepare_runtime(&settings);
|
let result = super::prepare_runtime(&settings);
|
||||||
assert!(result.is_ok());
|
assert!(result.is_ok());
|
||||||
@@ -75,7 +75,7 @@ fn console_runtime_composes_takeover_filter_before_formatter_and_owns_guard() {
|
|||||||
};
|
};
|
||||||
assert_eq!(prepared.layers.len(), 1);
|
assert_eq!(prepared.layers.len(), 1);
|
||||||
assert!(prepared.outputs.console.is_some());
|
assert!(prepared.outputs.console.is_some());
|
||||||
assert!(prepared.outputs.file.is_none());
|
assert!(prepared.outputs.files.is_empty());
|
||||||
assert_eq!(prepared.outputs.dropped_lines(), crate::DroppedLines::zero());
|
assert_eq!(prepared.outputs.dropped_lines(), crate::DroppedLines::zero());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,7 +91,7 @@ fn dropped_line_snapshots_add_saturating_by_sink() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn takeover_filter_prefers_more_specific_ksp_prefixes_and_supports_off() {
|
fn takeover_filter_prefers_more_specific_ksp_prefixes_and_supports_off() {
|
||||||
let settings = crate::LoggingSettings::new(crate::LogFilterLevel::Info, crate::SpanEvents::Off, std::option::Option::None, std::option::Option::None)
|
let settings = crate::LoggingSettings::new(crate::LogFilterLevel::Info, crate::SpanEvents::Off, std::option::Option::None, std::vec::Vec::new())
|
||||||
.with_target_filter(crate::TargetFilter::new("ksp-store-", crate::LogFilterLevel::Debug))
|
.with_target_filter(crate::TargetFilter::new("ksp-store-", crate::LogFilterLevel::Debug))
|
||||||
.with_target_filter(crate::TargetFilter::new("ksp-store-lib", crate::LogFilterLevel::Trace))
|
.with_target_filter(crate::TargetFilter::new("ksp-store-lib", crate::LogFilterLevel::Trace))
|
||||||
.with_target_filter(crate::TargetFilter::new("ksp-wallet-lib", crate::LogFilterLevel::Off));
|
.with_target_filter(crate::TargetFilter::new("ksp-wallet-lib", crate::LogFilterLevel::Off));
|
||||||
@@ -102,6 +102,72 @@ fn takeover_filter_prefers_more_specific_ksp_prefixes_and_supports_off() {
|
|||||||
assert!(!filter.would_enable("ksp-wallet-lib", &tracing::Level::ERROR));
|
assert!(!filter.would_enable("ksp-wallet-lib", &tracing::Level::ERROR));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn multi_sink_runtime_accepts_metadata_routing_formats_and_console_ansi() {
|
||||||
|
let root = std::env::temp_dir().join(format!("ksp-pre005-unit-{}", std::process::id()));
|
||||||
|
let _cleanup_before = std::fs::remove_dir_all(root.as_path());
|
||||||
|
let console = crate::ConsoleSettings::new(
|
||||||
|
true,
|
||||||
|
crate::ConsoleOutput::Stdout,
|
||||||
|
true,
|
||||||
|
crate::LogFormat::Compact,
|
||||||
|
crate::OutputFilter::new(crate::LogFilterLevel::Debug, std::vec!["ksp-logging-lib".to_string()], std::vec!["*".to_string()]),
|
||||||
|
);
|
||||||
|
let first_file = crate::FileSettings::new(
|
||||||
|
"file.first",
|
||||||
|
true,
|
||||||
|
root.join("first"),
|
||||||
|
"first.log",
|
||||||
|
crate::FileRotation::Never,
|
||||||
|
crate::LogFormat::Pretty,
|
||||||
|
crate::OutputFilter::new(crate::LogFilterLevel::Info, std::vec!["ksp-logging-lib".to_string()], std::vec!["*".to_string()]),
|
||||||
|
);
|
||||||
|
let second_file = crate::FileSettings::new(
|
||||||
|
"file.second",
|
||||||
|
true,
|
||||||
|
root.join("second"),
|
||||||
|
"second.jsonl",
|
||||||
|
crate::FileRotation::Never,
|
||||||
|
crate::LogFormat::Json,
|
||||||
|
crate::OutputFilter::new(crate::LogFilterLevel::Error, std::vec!["*".to_string()], std::vec!["*".to_string()]),
|
||||||
|
);
|
||||||
|
let settings = crate::LoggingSettings::new(
|
||||||
|
crate::LogFilterLevel::Trace,
|
||||||
|
crate::SpanEvents::Off,
|
||||||
|
std::option::Option::Some(console),
|
||||||
|
std::vec![first_file, second_file],
|
||||||
|
);
|
||||||
|
let result = super::prepare_runtime(&settings);
|
||||||
|
assert!(result.is_ok());
|
||||||
|
let prepared = match result {
|
||||||
|
std::result::Result::Ok(prepared) => prepared,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
assert_eq!(prepared.layers.len(), 1);
|
||||||
|
assert!(prepared.outputs.console.is_some());
|
||||||
|
assert_eq!(prepared.outputs.files.len(), 2);
|
||||||
|
assert_eq!(prepared.outputs.file_dropped_lines("file.first"), std::option::Option::Some(0));
|
||||||
|
assert_eq!(prepared.outputs.file_dropped_lines("file.second"), std::option::Option::Some(0));
|
||||||
|
drop(prepared);
|
||||||
|
let cleanup_after = std::fs::remove_dir_all(root.as_path());
|
||||||
|
assert!(cleanup_after.is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn domain_routing_is_accepted_by_runtime_preparation() {
|
||||||
|
let console = crate::ConsoleSettings::new(
|
||||||
|
true,
|
||||||
|
crate::ConsoleOutput::Stdout,
|
||||||
|
false,
|
||||||
|
crate::LogFormat::Human,
|
||||||
|
crate::OutputFilter::new(crate::LogFilterLevel::Debug, std::vec!["*".to_string()], std::vec!["logging".to_string()]),
|
||||||
|
);
|
||||||
|
let settings = crate::LoggingSettings::new(crate::LogFilterLevel::Info, crate::SpanEvents::Off, std::option::Option::Some(console), std::vec::Vec::new());
|
||||||
|
assert!(settings.validate().is_ok());
|
||||||
|
let result = super::prepare_runtime(&settings);
|
||||||
|
assert!(result.is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
struct BlockingWriter {
|
struct BlockingWriter {
|
||||||
first_write: bool,
|
first_write: bool,
|
||||||
started: std::sync::mpsc::SyncSender<()>,
|
started: std::sync::mpsc::SyncSender<()>,
|
||||||
|
|||||||
@@ -1,5 +1,17 @@
|
|||||||
// file: crates/ksp-logging-lib/unit_tests/settings.rs
|
// file: crates/ksp-logging-lib/unit_tests/settings.rs
|
||||||
// version: 1
|
// version: 3
|
||||||
|
|
||||||
|
fn unrestricted_file(output_id: &str) -> crate::FileSettings {
|
||||||
|
return crate::FileSettings::new(
|
||||||
|
output_id,
|
||||||
|
true,
|
||||||
|
"logs",
|
||||||
|
format!("{output_id}.log"),
|
||||||
|
crate::FileRotation::Daily,
|
||||||
|
crate::LogFormat::Human,
|
||||||
|
crate::OutputFilter::unrestricted(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn level_variants_are_distinct() {
|
fn level_variants_are_distinct() {
|
||||||
@@ -10,6 +22,13 @@ fn level_variants_are_distinct() {
|
|||||||
assert_ne!(crate::LogFilterLevel::Debug, crate::LogFilterLevel::Trace);
|
assert_ne!(crate::LogFilterLevel::Debug, crate::LogFilterLevel::Trace);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn format_variants_are_distinct() {
|
||||||
|
assert_ne!(crate::LogFormat::Human, crate::LogFormat::Compact);
|
||||||
|
assert_ne!(crate::LogFormat::Compact, crate::LogFormat::Pretty);
|
||||||
|
assert_ne!(crate::LogFormat::Pretty, crate::LogFormat::Json);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn target_filter_preserves_prefix_and_level() {
|
fn target_filter_preserves_prefix_and_level() {
|
||||||
let filter = crate::TargetFilter::new("ksp-store-lib", crate::LogFilterLevel::Trace);
|
let filter = crate::TargetFilter::new("ksp-store-lib", crate::LogFilterLevel::Trace);
|
||||||
@@ -18,85 +37,190 @@ fn target_filter_preserves_prefix_and_level() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn console_settings_select_requested_stream() {
|
fn output_filter_preserves_level_targets_and_domains() {
|
||||||
assert_eq!(crate::ConsoleSettings::stdout().output(), crate::ConsoleOutput::Stdout);
|
let filter = crate::OutputFilter::new(
|
||||||
assert_eq!(crate::ConsoleSettings::stderr().output(), crate::ConsoleOutput::Stderr);
|
crate::LogFilterLevel::Debug,
|
||||||
|
std::vec!["ksp-config-lib".to_string(), "ksp-logging-lib".to_string()],
|
||||||
|
std::vec!["config".to_string(), "logging.runtime".to_string()],
|
||||||
|
);
|
||||||
|
assert_eq!(filter.level(), crate::LogFilterLevel::Debug);
|
||||||
|
assert_eq!(filter.targets().len(), 2);
|
||||||
|
assert_eq!(filter.targets()[0], "ksp-config-lib");
|
||||||
|
assert_eq!(filter.targets()[1], "ksp-logging-lib");
|
||||||
|
assert_eq!(filter.domains().len(), 2);
|
||||||
|
assert_eq!(filter.domains()[0], "config");
|
||||||
|
assert_eq!(filter.domains()[1], "logging.runtime");
|
||||||
|
let unrestricted = crate::OutputFilter::unrestricted();
|
||||||
|
assert_eq!(unrestricted.level(), crate::LogFilterLevel::Trace);
|
||||||
|
assert_eq!(unrestricted.targets(), &["*".to_string()]);
|
||||||
|
assert_eq!(unrestricted.domains(), &["*".to_string()]);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn file_settings_preserve_values() {
|
fn console_settings_preserve_enabled_stream_ansi_format_and_filter() {
|
||||||
let settings = crate::FileSettings::new("logs", "ksp", crate::FileRotation::Daily);
|
let filter = crate::OutputFilter::new(crate::LogFilterLevel::Debug, std::vec!["*".to_string()], std::vec!["config".to_string()]);
|
||||||
assert_eq!(settings.directory(), std::path::Path::new("logs"));
|
let settings = crate::ConsoleSettings::new(true, crate::ConsoleOutput::Stderr, true, crate::LogFormat::Compact, filter.clone());
|
||||||
assert_eq!(settings.file_name_prefix(), "ksp");
|
assert!(settings.enabled());
|
||||||
|
assert_eq!(settings.output(), crate::ConsoleOutput::Stderr);
|
||||||
|
assert!(settings.ansi());
|
||||||
|
assert_eq!(settings.format(), crate::LogFormat::Compact);
|
||||||
|
assert_eq!(settings.filter(), &filter);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn compatibility_console_constructors_are_unrestricted_human_and_non_ansi() {
|
||||||
|
let stdout = crate::ConsoleSettings::stdout();
|
||||||
|
let stderr = crate::ConsoleSettings::stderr();
|
||||||
|
assert_eq!(stdout.output(), crate::ConsoleOutput::Stdout);
|
||||||
|
assert_eq!(stderr.output(), crate::ConsoleOutput::Stderr);
|
||||||
|
assert!(stdout.enabled());
|
||||||
|
assert!(!stdout.ansi());
|
||||||
|
assert_eq!(stdout.format(), crate::LogFormat::Human);
|
||||||
|
assert_eq!(stdout.filter(), &crate::OutputFilter::unrestricted());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn file_settings_preserve_multi_output_contract() {
|
||||||
|
let filter = crate::OutputFilter::new(crate::LogFilterLevel::Error, std::vec!["ksp-config-lib".to_string()], std::vec!["config".to_string()]);
|
||||||
|
let settings =
|
||||||
|
crate::FileSettings::new("file.config.error", true, "logs/config", "error.jsonl", crate::FileRotation::Daily, crate::LogFormat::Json, filter.clone());
|
||||||
|
assert_eq!(settings.output_id(), "file.config.error");
|
||||||
|
assert!(settings.enabled());
|
||||||
|
assert_eq!(settings.directory(), std::path::Path::new("logs/config"));
|
||||||
|
assert_eq!(settings.file_name_prefix(), "error.jsonl");
|
||||||
assert_eq!(settings.rotation(), crate::FileRotation::Daily);
|
assert_eq!(settings.rotation(), crate::FileRotation::Daily);
|
||||||
|
assert_eq!(settings.format(), crate::LogFormat::Json);
|
||||||
|
assert!(!settings.ansi());
|
||||||
|
assert_eq!(settings.filter(), &filter);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn logging_settings_preserve_explicit_values() {
|
fn logging_settings_represent_multiple_file_outputs() {
|
||||||
|
let first = unrestricted_file("file.debug");
|
||||||
|
let second = crate::FileSettings::new(
|
||||||
|
"file.config.error",
|
||||||
|
false,
|
||||||
|
"logs/config",
|
||||||
|
"error.jsonl",
|
||||||
|
crate::FileRotation::Hourly,
|
||||||
|
crate::LogFormat::Json,
|
||||||
|
crate::OutputFilter::new(crate::LogFilterLevel::Error, std::vec!["ksp-config-lib".to_string()], std::vec!["config".to_string()]),
|
||||||
|
);
|
||||||
let settings = crate::LoggingSettings::new(
|
let settings = crate::LoggingSettings::new(
|
||||||
crate::LogFilterLevel::Info,
|
crate::LogFilterLevel::Info,
|
||||||
crate::SpanEvents::NewAndClose,
|
crate::SpanEvents::NewAndClose,
|
||||||
std::option::Option::Some(crate::ConsoleSettings::stdout()),
|
std::option::Option::Some(crate::ConsoleSettings::stdout()),
|
||||||
std::option::Option::Some(crate::FileSettings::new("logs", "ksp", crate::FileRotation::Hourly)),
|
std::vec![first, second],
|
||||||
)
|
)
|
||||||
.with_target_filter(crate::TargetFilter::new("ksp-store-lib", crate::LogFilterLevel::Trace));
|
.with_target_filter(crate::TargetFilter::new("ksp-store-lib", crate::LogFilterLevel::Trace));
|
||||||
assert_eq!(settings.default_filter(), crate::LogFilterLevel::Info);
|
assert_eq!(settings.default_filter(), crate::LogFilterLevel::Info);
|
||||||
assert_eq!(settings.span_events(), crate::SpanEvents::NewAndClose);
|
assert_eq!(settings.span_events(), crate::SpanEvents::NewAndClose);
|
||||||
assert_eq!(settings.target_filters().len(), 1);
|
assert_eq!(settings.target_filters().len(), 1);
|
||||||
assert_eq!(settings.target_filters()[0].target_prefix(), "ksp-store-lib");
|
assert_eq!(settings.files().len(), 2);
|
||||||
assert_eq!(settings.console(), std::option::Option::Some(&crate::ConsoleSettings::stdout()));
|
assert_eq!(settings.files()[0].output_id(), "file.debug");
|
||||||
assert_eq!(settings.file().map(crate::FileSettings::rotation), std::option::Option::Some(crate::FileRotation::Hourly));
|
assert_eq!(settings.files()[1].format(), crate::LogFormat::Json);
|
||||||
|
assert_eq!(settings.console().map(crate::ConsoleSettings::output), std::option::Option::Some(crate::ConsoleOutput::Stdout));
|
||||||
|
assert!(settings.validate().is_ok());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn validation_rejects_empty_target_prefix() {
|
fn validation_rejects_empty_or_external_global_target_prefix() {
|
||||||
let settings = crate::LoggingSettings::new(
|
let empty = crate::LoggingSettings::new(crate::LogFilterLevel::Info, crate::SpanEvents::Off, std::option::Option::None, std::vec::Vec::new())
|
||||||
crate::LogFilterLevel::Info,
|
.with_target_filter(crate::TargetFilter::new("", crate::LogFilterLevel::Debug));
|
||||||
crate::SpanEvents::Off,
|
let external = crate::LoggingSettings::new(crate::LogFilterLevel::Info, crate::SpanEvents::Off, std::option::Option::None, std::vec::Vec::new())
|
||||||
std::option::Option::Some(crate::ConsoleSettings::stdout()),
|
.with_target_filter(crate::TargetFilter::new("sqlx", crate::LogFilterLevel::Debug));
|
||||||
std::option::Option::None,
|
assert!(empty.validate().is_err());
|
||||||
)
|
assert!(external.validate().is_err());
|
||||||
.with_target_filter(crate::TargetFilter::new("", crate::LogFilterLevel::Debug));
|
|
||||||
assert!(settings.validate().is_err());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn validation_rejects_external_target_prefix() {
|
fn validation_rejects_invalid_file_identity_and_duplicate_output_ids() {
|
||||||
let settings = crate::LoggingSettings::new(
|
let invalid_id =
|
||||||
crate::LogFilterLevel::Info,
|
crate::LoggingSettings::new(crate::LogFilterLevel::Info, crate::SpanEvents::Off, std::option::Option::None, std::vec![unrestricted_file("File.Debug")]);
|
||||||
crate::SpanEvents::Off,
|
let duplicate_id = crate::LoggingSettings::new(
|
||||||
std::option::Option::Some(crate::ConsoleSettings::stdout()),
|
|
||||||
std::option::Option::None,
|
|
||||||
)
|
|
||||||
.with_target_filter(crate::TargetFilter::new("sqlx", crate::LogFilterLevel::Debug));
|
|
||||||
assert!(settings.validate().is_err());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn validation_rejects_empty_file_prefix() {
|
|
||||||
let settings = crate::LoggingSettings::new(
|
|
||||||
crate::LogFilterLevel::Info,
|
crate::LogFilterLevel::Info,
|
||||||
crate::SpanEvents::Off,
|
crate::SpanEvents::Off,
|
||||||
std::option::Option::None,
|
std::option::Option::None,
|
||||||
std::option::Option::Some(crate::FileSettings::new("logs", "", crate::FileRotation::Never)),
|
std::vec![unrestricted_file("file.debug"), unrestricted_file("file.debug")],
|
||||||
);
|
);
|
||||||
|
assert!(invalid_id.validate().is_err());
|
||||||
|
assert!(duplicate_id.validate().is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validation_rejects_empty_file_paths_and_persistent_ansi() {
|
||||||
|
let empty_directory = crate::FileSettings::new(
|
||||||
|
"file.empty.dir",
|
||||||
|
true,
|
||||||
|
"",
|
||||||
|
"debug.log",
|
||||||
|
crate::FileRotation::Never,
|
||||||
|
crate::LogFormat::Human,
|
||||||
|
crate::OutputFilter::unrestricted(),
|
||||||
|
);
|
||||||
|
let empty_prefix = crate::FileSettings::new(
|
||||||
|
"file.empty.prefix",
|
||||||
|
true,
|
||||||
|
"logs",
|
||||||
|
"",
|
||||||
|
crate::FileRotation::Never,
|
||||||
|
crate::LogFormat::Human,
|
||||||
|
crate::OutputFilter::unrestricted(),
|
||||||
|
);
|
||||||
|
let ansi_file = crate::FileSettings::new(
|
||||||
|
"file.ansi",
|
||||||
|
true,
|
||||||
|
"logs",
|
||||||
|
"ansi.log",
|
||||||
|
crate::FileRotation::Never,
|
||||||
|
crate::LogFormat::Human,
|
||||||
|
crate::OutputFilter::unrestricted(),
|
||||||
|
)
|
||||||
|
.with_ansi(true);
|
||||||
|
let empty_directory_settings =
|
||||||
|
crate::LoggingSettings::new(crate::LogFilterLevel::Info, crate::SpanEvents::Off, std::option::Option::None, std::vec![empty_directory]);
|
||||||
|
let empty_prefix_settings =
|
||||||
|
crate::LoggingSettings::new(crate::LogFilterLevel::Info, crate::SpanEvents::Off, std::option::Option::None, std::vec![empty_prefix]);
|
||||||
|
let ansi_file_settings = crate::LoggingSettings::new(crate::LogFilterLevel::Info, crate::SpanEvents::Off, std::option::Option::None, std::vec![ansi_file]);
|
||||||
|
assert!(empty_directory_settings.validate().is_err());
|
||||||
|
assert!(empty_prefix_settings.validate().is_err());
|
||||||
|
assert!(ansi_file_settings.validate().is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validation_rejects_ansi_json_console() {
|
||||||
|
let console = crate::ConsoleSettings::new(true, crate::ConsoleOutput::Stdout, true, crate::LogFormat::Json, crate::OutputFilter::unrestricted());
|
||||||
|
let settings = crate::LoggingSettings::new(crate::LogFilterLevel::Info, crate::SpanEvents::Off, std::option::Option::Some(console), std::vec::Vec::new());
|
||||||
assert!(settings.validate().is_err());
|
assert!(settings.validate().is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn validation_accepts_ksp_outputs_and_filters() {
|
fn validation_rejects_invalid_output_selectors() {
|
||||||
let settings = crate::LoggingSettings::new(
|
let external_target = crate::OutputFilter::new(crate::LogFilterLevel::Debug, std::vec!["sqlx".to_string()], std::vec!["*".to_string()]);
|
||||||
crate::LogFilterLevel::Info,
|
let wildcard_mix =
|
||||||
crate::SpanEvents::Full,
|
crate::OutputFilter::new(crate::LogFilterLevel::Debug, std::vec!["*".to_string(), "ksp-config-lib".to_string()], std::vec!["*".to_string()]);
|
||||||
std::option::Option::Some(crate::ConsoleSettings::stderr()),
|
let duplicate_domain =
|
||||||
std::option::Option::Some(crate::FileSettings::new("logs", "worker", crate::FileRotation::Daily)),
|
crate::OutputFilter::new(crate::LogFilterLevel::Debug, std::vec!["*".to_string()], std::vec!["config".to_string(), "config".to_string()]);
|
||||||
)
|
for filter in [external_target, wildcard_mix, duplicate_domain] {
|
||||||
.with_target_filter(crate::TargetFilter::new("ksp-worker-", crate::LogFilterLevel::Debug));
|
let console = crate::ConsoleSettings::new(true, crate::ConsoleOutput::Stdout, false, crate::LogFormat::Human, filter);
|
||||||
assert!(settings.validate().is_ok());
|
let settings =
|
||||||
|
crate::LoggingSettings::new(crate::LogFilterLevel::Info, crate::SpanEvents::Off, std::option::Option::Some(console), std::vec::Vec::new());
|
||||||
|
assert!(settings.validate().is_err());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn settings_allow_logging_to_be_disabled() {
|
fn settings_allow_all_outputs_to_be_disabled() {
|
||||||
let settings = crate::LoggingSettings::new(crate::LogFilterLevel::Off, crate::SpanEvents::Off, std::option::Option::None, std::option::Option::None);
|
let console = crate::ConsoleSettings::new(false, crate::ConsoleOutput::Stderr, true, crate::LogFormat::Pretty, crate::OutputFilter::unrestricted());
|
||||||
|
let file = crate::FileSettings::new(
|
||||||
|
"file.disabled",
|
||||||
|
false,
|
||||||
|
"logs",
|
||||||
|
"disabled.jsonl",
|
||||||
|
crate::FileRotation::Daily,
|
||||||
|
crate::LogFormat::Json,
|
||||||
|
crate::OutputFilter::new(crate::LogFilterLevel::Error, std::vec!["ksp-config-lib".to_string()], std::vec!["config".to_string()]),
|
||||||
|
);
|
||||||
|
let settings = crate::LoggingSettings::new(crate::LogFilterLevel::Off, crate::SpanEvents::Off, std::option::Option::Some(console), std::vec![file]);
|
||||||
assert!(settings.validate().is_ok());
|
assert!(settings.validate().is_ok());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// file: crates/ksp-logging-lib/unit_tests/writer.rs
|
// file: crates/ksp-logging-lib/unit_tests/writer.rs
|
||||||
// version: 1
|
// version: 2
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn ansi_writer_strips_csi_sequences() {
|
fn ansi_writer_strips_csi_sequences() {
|
||||||
@@ -28,3 +28,24 @@ fn ansi_writer_strips_osc_sequences_terminated_by_bell_or_st() {
|
|||||||
assert!(second.is_ok());
|
assert!(second.is_ok());
|
||||||
assert_eq!(writer.into_inner(), b"abcd");
|
assert_eq!(writer.into_inner(), b"abcd");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn output_level_routing_covers_all_filter_levels() {
|
||||||
|
assert!(!super::level_is_enabled(&tracing::Level::ERROR, crate::LogFilterLevel::Off));
|
||||||
|
assert!(super::level_is_enabled(&tracing::Level::ERROR, crate::LogFilterLevel::Error));
|
||||||
|
assert!(!super::level_is_enabled(&tracing::Level::WARN, crate::LogFilterLevel::Error));
|
||||||
|
assert!(super::level_is_enabled(&tracing::Level::WARN, crate::LogFilterLevel::Warn));
|
||||||
|
assert!(!super::level_is_enabled(&tracing::Level::INFO, crate::LogFilterLevel::Warn));
|
||||||
|
assert!(super::level_is_enabled(&tracing::Level::INFO, crate::LogFilterLevel::Info));
|
||||||
|
assert!(super::level_is_enabled(&tracing::Level::DEBUG, crate::LogFilterLevel::Debug));
|
||||||
|
assert!(super::level_is_enabled(&tracing::Level::TRACE, crate::LogFilterLevel::Trace));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn disabled_routed_writer_discards_bytes_without_error() {
|
||||||
|
let mut writer = super::RoutedWriter::<std::vec::Vec<u8>>::Disabled;
|
||||||
|
let write_result = std::io::Write::write_all(&mut writer, b"discarded");
|
||||||
|
let flush_result = std::io::Write::flush(&mut writer);
|
||||||
|
assert!(write_result.is_ok());
|
||||||
|
assert!(flush_result.is_ok());
|
||||||
|
}
|
||||||
|
|||||||
273
deltas/0.1.3/pre.001-fix.001.md
Normal file
273
deltas/0.1.3/pre.001-fix.001.md
Normal file
@@ -0,0 +1,273 @@
|
|||||||
|
<!-- file: deltas/0.1.3/pre.001-fix.001.md -->
|
||||||
|
<!-- version: 1 -->
|
||||||
|
|
||||||
|
# Delta 0.1.3-pre.001-fix.001
|
||||||
|
|
||||||
|
## Base requise
|
||||||
|
|
||||||
|
Livraison précédente :
|
||||||
|
|
||||||
|
```text
|
||||||
|
0.1.3-pre.001
|
||||||
|
```
|
||||||
|
|
||||||
|
Ce correctif reste documentaire. Il corrige le plan de `pre.001` avant tout développement fonctionnel de `ksp-config-lib` et ne réécrit pas le delta historique `pre.001.md`.
|
||||||
|
|
||||||
|
## Objectif
|
||||||
|
|
||||||
|
Aligner le plan Config sur le contrat fonctionnel validé après `pre.001` :
|
||||||
|
|
||||||
|
- `ksp-config-lib` devient l'unique propriétaire KSP de la lecture/résolution/validation/mutation de la configuration applicative ;
|
||||||
|
- les variables applicatives sont résolues par Config depuis l'environnement réel du processus et un `.env` conventionnel ;
|
||||||
|
- la priorité est `process env > .env > fallback` ;
|
||||||
|
- les fallbacks sont déclarés au point d'usage par `${NAME:-fallback}` ;
|
||||||
|
- les références `${KSP_*}` / `${KSPB_*}` dans les documents JSON sont elles-mêmes les déclarations d'usage, sans table de bindings centrale ;
|
||||||
|
- les valeurs dérivées de `*_SECRET_*` conservent une valeur runtime réelle et une représentation sûre/redacted ;
|
||||||
|
- les variables absentes sans fallback produisent diagnostic + warning et empêchent une résolution runtime complète ;
|
||||||
|
- Config peut créer/modifier/supprimer les entrées du `.env` ;
|
||||||
|
- la première surface réelle est renommée `config/std.logging.json` ;
|
||||||
|
- les futurs documents et composites suivent `std.<domain>.json` et `composite.<consumer>.json`.
|
||||||
|
|
||||||
|
Aucune implémentation `pre.002` ne doit commencer avant validation utilisateur du plan corrigé.
|
||||||
|
|
||||||
|
## Corrections apportées au plan
|
||||||
|
|
||||||
|
### `.env` conventionnel
|
||||||
|
|
||||||
|
La décision initiale :
|
||||||
|
|
||||||
|
```text
|
||||||
|
config/environment.env
|
||||||
|
```
|
||||||
|
|
||||||
|
est annulée.
|
||||||
|
|
||||||
|
La source persistante locale par défaut devient :
|
||||||
|
|
||||||
|
```text
|
||||||
|
.env
|
||||||
|
```
|
||||||
|
|
||||||
|
à la racine runtime/workspace fournie à Config.
|
||||||
|
|
||||||
|
Le fichier reste ignoré par Git et peut être manipulé exclusivement via `ksp-config-lib` dans l'écosystème KSP.
|
||||||
|
|
||||||
|
### Priorité des variables
|
||||||
|
|
||||||
|
Pour une variable utilisée :
|
||||||
|
|
||||||
|
```text
|
||||||
|
process environment
|
||||||
|
> .env
|
||||||
|
> fallback déclaré au point d'usage
|
||||||
|
> missing
|
||||||
|
```
|
||||||
|
|
||||||
|
Exemple :
|
||||||
|
|
||||||
|
```text
|
||||||
|
export KSP_PUBLIC_FOO=console
|
||||||
|
.env: KSP_PUBLIC_FOO=dotenv
|
||||||
|
JSON: ${KSP_PUBLIC_FOO:-fallback}
|
||||||
|
```
|
||||||
|
|
||||||
|
La valeur effective est `console`.
|
||||||
|
|
||||||
|
Une chaîne vide explicitement définie est considérée comme définie et ne déclenche pas le fallback.
|
||||||
|
|
||||||
|
### Placeholders Config
|
||||||
|
|
||||||
|
Le rejet initial de l'interpolation `${...}` est annulé.
|
||||||
|
|
||||||
|
Les syntaxes initiales sont :
|
||||||
|
|
||||||
|
```text
|
||||||
|
${KSP_VAR}
|
||||||
|
${KSP_VAR:-fallback}
|
||||||
|
```
|
||||||
|
|
||||||
|
et leurs équivalents `KSPB_*`.
|
||||||
|
|
||||||
|
Le resolver appartient à Config et conserve provenance/sensibilité. Il n'est pas délégué à un consumer ni à une expansion dotenv opaque.
|
||||||
|
|
||||||
|
### Suppression des bindings statiques
|
||||||
|
|
||||||
|
La table prédéfinie :
|
||||||
|
|
||||||
|
```text
|
||||||
|
clé Config -> nom de variable
|
||||||
|
```
|
||||||
|
|
||||||
|
n'est plus retenue.
|
||||||
|
|
||||||
|
Une référence comme :
|
||||||
|
|
||||||
|
```json
|
||||||
|
"logs_directory": "${KSP_LOGS_DIRECTORY:-logs}"
|
||||||
|
```
|
||||||
|
|
||||||
|
constitue directement la déclaration d'usage de `KSP_LOGS_DIRECTORY`.
|
||||||
|
|
||||||
|
Config peut aussi exposer une requête directe nom + fallback optionnel pour les rares variables utilisées hors document JSON, toujours sans accès `std::env` direct dans les consumers.
|
||||||
|
|
||||||
|
### Missing sans fallback
|
||||||
|
|
||||||
|
Lorsqu'une référence `${KSP_VAR}` n'est définie ni dans le process ni dans `.env` :
|
||||||
|
|
||||||
|
- Config produit un diagnostic structuré ;
|
||||||
|
- Config émet un warning sous le target `ksp-config-lib` lorsque Logging est disponible ;
|
||||||
|
- le diagnostic indique nom/document/path mais aucune valeur secrète ;
|
||||||
|
- la construction d'un runtime complet échoue tant que la référence reste non résolue ;
|
||||||
|
- une application de management peut néanmoins charger le document source pour le corriger.
|
||||||
|
|
||||||
|
### Secrets et valeurs composées
|
||||||
|
|
||||||
|
Une chaîne telle que :
|
||||||
|
|
||||||
|
```text
|
||||||
|
https://mainnet.helius-rpc.com/?api-key=${KSP_SECRET_HELIUS_API_KEY}
|
||||||
|
```
|
||||||
|
|
||||||
|
doit conserver conceptuellement :
|
||||||
|
|
||||||
|
```text
|
||||||
|
real = https://mainnet.helius-rpc.com/?api-key=<real-secret>
|
||||||
|
safe = https://mainnet.helius-rpc.com/?api-key=********
|
||||||
|
sensitivity = Secret
|
||||||
|
provenance = variable + source effective
|
||||||
|
```
|
||||||
|
|
||||||
|
Le runtime légitime peut utiliser `real`; les logs/diagnostics utilisent `safe`.
|
||||||
|
|
||||||
|
Une application de management Config peut explicitement révéler/modifier un secret. Cette exception de visualisation n'autorise jamais le secret dans les logs.
|
||||||
|
|
||||||
|
### Nomenclature des documents
|
||||||
|
|
||||||
|
La première surface réelle devient :
|
||||||
|
|
||||||
|
```text
|
||||||
|
config/std.logging.json
|
||||||
|
config/schemas/std.logging.schema.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Nomenclature future :
|
||||||
|
|
||||||
|
```text
|
||||||
|
config/std.<domain>.json
|
||||||
|
config/composite.<consumer>.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Exemple futur :
|
||||||
|
|
||||||
|
```text
|
||||||
|
config/composite.ksp-app-wallet-desk.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Les autres documents spécialisés ne sont pas créés avant leurs composants.
|
||||||
|
|
||||||
|
### Globals, profils et composites
|
||||||
|
|
||||||
|
Le contrat maintient :
|
||||||
|
|
||||||
|
```text
|
||||||
|
paramètres globaux
|
||||||
|
+ default_profile
|
||||||
|
+ profiles
|
||||||
|
```
|
||||||
|
|
||||||
|
Une composition assemble les documents spécialisés et choisit éventuellement leurs profils sans recopier leur contenu. Les globals du document restent automatiquement partie de la configuration effective.
|
||||||
|
|
||||||
|
### Mutation de l'environnement
|
||||||
|
|
||||||
|
Le contrat distingue :
|
||||||
|
|
||||||
|
- environnement réel du processus : source read-only et prioritaire ;
|
||||||
|
- `.env` : source persistante read/write possédée par Config.
|
||||||
|
|
||||||
|
Config peut créer/modifier/supprimer des entrées `.env` et doit signaler si une valeur process continue à shadow la valeur persistée.
|
||||||
|
|
||||||
|
Il ne prétend pas modifier le shell parent, systemd, Docker ou un autre processus.
|
||||||
|
|
||||||
|
## Découpage prerelease corrigé
|
||||||
|
|
||||||
|
```text
|
||||||
|
pre.002 fondation crate + contrats source
|
||||||
|
pre.003 JSON Schema + config/std.logging.json
|
||||||
|
pre.004 profils + composition
|
||||||
|
pre.005 .env + resolver ${...} + fallback + warnings missing
|
||||||
|
pre.006 sensibilité + real/safe + Logging adapter
|
||||||
|
pre.007 management + persistence JSON/.env
|
||||||
|
pre.008 ownership audits + robustesse
|
||||||
|
pre.009 clôture
|
||||||
|
```
|
||||||
|
|
||||||
|
Le périmètre `0.1.3` reste une release unique et `0.1.4` reste `ksp-app-config-desk`.
|
||||||
|
|
||||||
|
## Fichiers ajoutés
|
||||||
|
|
||||||
|
- `deltas/0.1.3/pre.001-fix.001.md`
|
||||||
|
|
||||||
|
## Fichiers modifiés
|
||||||
|
|
||||||
|
- `docs/plans/005-V0_1_3_CONFIG_FOUNDATION_PLAN.md`
|
||||||
|
- `docs/plans/002-FUNCTIONAL_RELEASE_SEQUENCE.md`
|
||||||
|
- `docs/plans/000-README.md`
|
||||||
|
|
||||||
|
## Fichiers supprimés
|
||||||
|
|
||||||
|
Aucun.
|
||||||
|
|
||||||
|
## Version Cargo
|
||||||
|
|
||||||
|
Aucune modification de `Cargo.toml`.
|
||||||
|
|
||||||
|
Ce fix est uniquement documentaire et respecte `VER-ID-008`. La version workspace reste :
|
||||||
|
|
||||||
|
```text
|
||||||
|
0.1.3-pre.1
|
||||||
|
```
|
||||||
|
|
||||||
|
L'identifiant de livraison est :
|
||||||
|
|
||||||
|
```text
|
||||||
|
0.1.3-pre.001-fix.001
|
||||||
|
```
|
||||||
|
|
||||||
|
## Dépendances
|
||||||
|
|
||||||
|
Aucune dépendance ajoutée ou modifiée.
|
||||||
|
|
||||||
|
Le plan ne choisit pas encore de bibliothèque dotenv. Le choix sera audité au delta qui implémente réellement `.env`.
|
||||||
|
|
||||||
|
## Validations exécutées
|
||||||
|
|
||||||
|
- comparaison de la livraison `0.1.3-pre.001` avec la base `v0.1.2` ;
|
||||||
|
- relecture de `docs/rules/VERSION_WORKFLOW.md`, notamment `VER-ID-008` et `VER-ARCHIVE-004` ;
|
||||||
|
- réaudit ciblé de la référence bot3 pour `.env`, `${NAME:-fallback}`, secrets composés et composition ;
|
||||||
|
- contrôle des headers `file:` / `version:` des fichiers modifiés/ajoutés ;
|
||||||
|
- recherche des anciennes décisions `environment.env`, interdiction d'interpolation et bindings statiques dans le plan corrigé ;
|
||||||
|
- contrôle de la nomenclature `std.logging.json` / `std.<domain>.json` / `composite.<consumer>.json` ;
|
||||||
|
- contrôle que le correctif ne modifie aucun code Rust, aucun manifest Cargo et aucun fichier runtime Config ;
|
||||||
|
- contrôle du contenu de l'archive delta selon `VER-ARCHIVE-004`.
|
||||||
|
|
||||||
|
## Validations non exécutées
|
||||||
|
|
||||||
|
Aucune validation Cargo n'est déclarée réussie dans ce correctif documentaire.
|
||||||
|
|
||||||
|
Aucun code Config n'existe encore et aucun manifest/code Rust n'est modifié par le fix. Les validations Cargo de `0.1.3` commenceront avec les tranches de développement applicables.
|
||||||
|
|
||||||
|
## Questions ouvertes avant `pre.002`
|
||||||
|
|
||||||
|
Le plan corrigé est soumis à validation utilisateur.
|
||||||
|
|
||||||
|
Les détails volontairement différés à l'implémentation sont :
|
||||||
|
|
||||||
|
- bibliothèque dotenv éventuelle ou parser borné possédé par Config ;
|
||||||
|
- grammaire précise des quotes/escapes du `.env` ;
|
||||||
|
- type Rust exact de la valeur `real/safe/provenance/sensitivity` ;
|
||||||
|
- primitive exacte d'écriture atomique ;
|
||||||
|
- noms finaux des APIs runtime/diagnostic/management.
|
||||||
|
|
||||||
|
Ces choix ne doivent pas modifier les invariants fonctionnels fixés par le plan.
|
||||||
|
|
||||||
|
Après validation de ce fix, la prochaine tranche est `0.1.3-pre.002`.
|
||||||
213
deltas/0.1.3/pre.001-fix.002.md
Normal file
213
deltas/0.1.3/pre.001-fix.002.md
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
<!-- file: deltas/0.1.3/pre.001-fix.002.md -->
|
||||||
|
<!-- version: 1 -->
|
||||||
|
|
||||||
|
# Delta 0.1.3-pre.001-fix.002
|
||||||
|
|
||||||
|
## Base requise
|
||||||
|
|
||||||
|
Livraison précédente appliquée et commitée :
|
||||||
|
|
||||||
|
```text
|
||||||
|
0.1.3-pre.001-fix.001
|
||||||
|
```
|
||||||
|
|
||||||
|
Ce correctif reste documentaire et doit être validé avant toute ouverture de `pre.002`.
|
||||||
|
|
||||||
|
## Type de livraison
|
||||||
|
|
||||||
|
```text
|
||||||
|
ksp-doc-0.1.3-pre.001-fix.002.zip
|
||||||
|
```
|
||||||
|
|
||||||
|
## Objectif
|
||||||
|
|
||||||
|
Compléter le plan Config avec les décisions de bootstrap/fichiers et corriger le modèle Logging trop pauvre du fix précédent.
|
||||||
|
|
||||||
|
Le correctif fixe notamment :
|
||||||
|
|
||||||
|
- un registre KSP des fichiers connus avec `file_id` unique et mapping `file_id -> filename` ;
|
||||||
|
- des mappings distincts pour documents Config et schemas, tous identifiés dans un namespace logique unique ;
|
||||||
|
- des filenames par défaut codés dans `ksp-config-lib` mais surchargeables au démarrage ;
|
||||||
|
- deux chemins bootstrap non récursifs avec défauts codés en dur : `config` et `config/schemas` ;
|
||||||
|
- surcharge de ces chemins uniquement par `--cfgpath` / `--schemapath` ou options programmatiques explicites ;
|
||||||
|
- surcharge répétable d'un filename connu par `--filemap=<file_id>=<filename>` ;
|
||||||
|
- références des composites par `file_id` et jamais par filename ;
|
||||||
|
- sélection/remplacement indépendant du filename d'un schema ;
|
||||||
|
- choix de `serde`, `serde_json` et `jsonschema` comme base candidate du moteur JSON/schema ;
|
||||||
|
- modèle `std.logging.json` enrichi avec profils identifiés, console configurable et plusieurs sinks fichier ;
|
||||||
|
- routing Logging attendu par niveau, target et domain, avec rotation/format/ANSI selon le sink ;
|
||||||
|
- constat explicite que `ksp-logging-lib 0.1.2` ne couvre pas encore toute cette surface ;
|
||||||
|
- décision de compléter cette surface dans `ksp-logging-lib` sans déplacer le routing dans Config et sans créer de dépendance Logging -> Config.
|
||||||
|
|
||||||
|
## Version Cargo
|
||||||
|
|
||||||
|
Ce correctif modifie uniquement la documentation de planification.
|
||||||
|
|
||||||
|
Conformément à `VER-ID-008`, `workspace.package.version` reste :
|
||||||
|
|
||||||
|
```text
|
||||||
|
0.1.3-pre.1
|
||||||
|
```
|
||||||
|
|
||||||
|
Identifiant de livraison/commit :
|
||||||
|
|
||||||
|
```text
|
||||||
|
0.1.3-pre.001-fix.002
|
||||||
|
```
|
||||||
|
|
||||||
|
## Fichiers ajoutés
|
||||||
|
|
||||||
|
- `deltas/0.1.3/pre.001-fix.002.md`
|
||||||
|
|
||||||
|
## Fichiers modifiés
|
||||||
|
|
||||||
|
- `docs/plans/005-V0_1_3_CONFIG_FOUNDATION_PLAN.md` — version documentaire 2 -> 3 ;
|
||||||
|
- `docs/plans/002-FUNCTIONAL_RELEASE_SEQUENCE.md` — version documentaire 7 -> 8 ;
|
||||||
|
- `docs/plans/000-README.md` — version documentaire 9 -> 10.
|
||||||
|
|
||||||
|
## Registre logique des fichiers
|
||||||
|
|
||||||
|
Le plan retient un namespace unique de `file_id` :
|
||||||
|
|
||||||
|
```text
|
||||||
|
cfg.std.logging
|
||||||
|
schema.std.logging
|
||||||
|
schema.composite
|
||||||
|
cfg.composite.<consumer>
|
||||||
|
```
|
||||||
|
|
||||||
|
Premier mapping :
|
||||||
|
|
||||||
|
```text
|
||||||
|
cfg.std.logging -> std.logging.json
|
||||||
|
schema.std.logging -> std.logging.schema.json
|
||||||
|
schema.composite -> composite.schema.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Un override ne change jamais le `file_id`, uniquement son filename physique.
|
||||||
|
|
||||||
|
Exemples :
|
||||||
|
|
||||||
|
```text
|
||||||
|
--filemap=cfg.std.logging=my.logging.json
|
||||||
|
--filemap=schema.std.logging=my.logging.schema.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Un composite référence donc :
|
||||||
|
|
||||||
|
```text
|
||||||
|
cfg.std.logging
|
||||||
|
```
|
||||||
|
|
||||||
|
et continue de fonctionner sans modification si le filename est remplacé au bootstrap.
|
||||||
|
|
||||||
|
## Bootstrap hors documents Config
|
||||||
|
|
||||||
|
Les seules racines nécessaires avant le chargement de Config sont :
|
||||||
|
|
||||||
|
```text
|
||||||
|
cfgpath = config
|
||||||
|
schemapath = config/schemas
|
||||||
|
```
|
||||||
|
|
||||||
|
Elles ne sont résolues ni depuis JSON, ni depuis `.env`, ni depuis `KSP_*`/`KSPB_*`.
|
||||||
|
|
||||||
|
Seules les interfaces bootstrap suivantes peuvent les remplacer :
|
||||||
|
|
||||||
|
```text
|
||||||
|
--cfgpath=/path/to/configs
|
||||||
|
--schemapath=/path/to/schemas
|
||||||
|
```
|
||||||
|
|
||||||
|
ou leur équivalent programmatique explicite possédé par `ksp-config-lib`.
|
||||||
|
|
||||||
|
## Audit Logging
|
||||||
|
|
||||||
|
L'audit du code stable `v0.1.2` confirme actuellement :
|
||||||
|
|
||||||
|
```text
|
||||||
|
1 console optionnelle
|
||||||
|
1 fichier optionnel
|
||||||
|
rotation never/hourly/daily
|
||||||
|
filtre global
|
||||||
|
TargetFilter par préfixe de target
|
||||||
|
initialize/reinitialize + LoggingGuard
|
||||||
|
```
|
||||||
|
|
||||||
|
Il ne fournit pas encore :
|
||||||
|
|
||||||
|
```text
|
||||||
|
plusieurs fichiers simultanés
|
||||||
|
console_ansi configurable
|
||||||
|
format configurable par sink
|
||||||
|
routing indépendant par sink/target/domain/level
|
||||||
|
```
|
||||||
|
|
||||||
|
La configuration historique bot3 possédait déjà plusieurs sorties console/fichier, niveaux, targets, rotation, format et ANSI.
|
||||||
|
|
||||||
|
`0.1.3` ne doit pas figer une configuration Logging régressive. Les capacités manquantes sont donc prévues comme une complétion bornée du domaine `ksp-logging-lib` avant gel du schema Logging. La propriété du subscriber, des layers, writers, settings, guard et du lifecycle reste intégralement dans Logging.
|
||||||
|
|
||||||
|
## Dépendances candidates vérifiées
|
||||||
|
|
||||||
|
Vérification documentaire effectuée le 15 août 2026 :
|
||||||
|
|
||||||
|
```text
|
||||||
|
serde 1.0.229 -> ^1.0
|
||||||
|
serde_json 1.0.151 -> ^1.0
|
||||||
|
jsonschema 0.49.6 -> ^0.49
|
||||||
|
```
|
||||||
|
|
||||||
|
Aucune de ces dépendances n'est ajoutée par ce correctif. Elles devront être revérifiées au delta qui les introduit réellement puis déclarées sous `[workspace.dependencies]`.
|
||||||
|
|
||||||
|
## Découpage révisé
|
||||||
|
|
||||||
|
La prévision devient :
|
||||||
|
|
||||||
|
```text
|
||||||
|
pre.002 bootstrap Config + registre file_id
|
||||||
|
pre.003 complétion bornée du contrat Logging
|
||||||
|
pre.004 serde/serde_json/jsonschema + std.logging.json
|
||||||
|
pre.005 profils + composition par file_id
|
||||||
|
pre.006 .env + resolver ${...}
|
||||||
|
pre.007 secrets real/safe + adapter Logging
|
||||||
|
pre.008 management + persistence JSON/.env
|
||||||
|
pre.009 audits ownership + robustesse
|
||||||
|
pre.010 clôture
|
||||||
|
```
|
||||||
|
|
||||||
|
Le découpage reste souple ; aucune tranche fonctionnelle n'est ouverte avant validation du plan corrigé.
|
||||||
|
|
||||||
|
## Validations exécutées
|
||||||
|
|
||||||
|
Sur le delta documentaire :
|
||||||
|
|
||||||
|
- comparaison statique avec `pre.001-fix.001` ;
|
||||||
|
- contrôle des headers `file:` / `version:` des quatre fichiers livrés ;
|
||||||
|
- contrôle de la présence du nouveau delta ;
|
||||||
|
- contrôle que l'archive ne contient que les trois documents modifiés et le delta ajouté ;
|
||||||
|
- contrôle que `Cargo.toml` n'est pas livré par ce fix ;
|
||||||
|
- contrôle des références `file_id`, `cfgpath`, `schemapath`, `--filemap` et du découpage `pre.002` -> `pre.010` dans le plan ;
|
||||||
|
- contrôle de l'absence de secret réel dans le delta.
|
||||||
|
|
||||||
|
## Validations non exécutées
|
||||||
|
|
||||||
|
Aucune commande Cargo n'est déclarée réussie pour ce correctif documentaire :
|
||||||
|
|
||||||
|
- le delta ne modifie ni code Rust, ni manifest, ni configuration runtime ;
|
||||||
|
- `cargo` n'est pas disponible dans le sandbox de préparation utilisé pour cette livraison.
|
||||||
|
|
||||||
|
Les validations Cargo restent obligatoires dès les tranches fonctionnelles applicables.
|
||||||
|
|
||||||
|
## Décisions prises
|
||||||
|
|
||||||
|
- `file_id` est l'identité logique stable d'un fichier Config/schema ;
|
||||||
|
- le filename est une localisation physique remplaçable ;
|
||||||
|
- les composites dépendent de `file_id`, pas d'un nom de fichier ;
|
||||||
|
- `cfgpath`/`schemapath` sont des paramètres bootstrap hors graphe Config ;
|
||||||
|
- `serde_json` + `jsonschema` seront utilisés pour éviter une validation JSON maison ;
|
||||||
|
- le modèle Logging doit rester au moins aussi expressif que les besoins utiles déjà présents dans bot3 ;
|
||||||
|
- Config ne compense jamais un manque du backend Logging par un routing parallèle.
|
||||||
|
|
||||||
|
## Questions ouvertes avant `pre.002`
|
||||||
|
|
||||||
|
Aucune question bloquante n'est conservée par défaut. Le plan corrigé doit néanmoins être validé par le user avant ouverture de `pre.002`.
|
||||||
165
deltas/0.1.3/pre.001-fix.003.md
Normal file
165
deltas/0.1.3/pre.001-fix.003.md
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
<!-- file: deltas/0.1.3/pre.001-fix.003.md -->
|
||||||
|
<!-- version: 1 -->
|
||||||
|
|
||||||
|
# Delta 0.1.3-pre.001-fix.003
|
||||||
|
|
||||||
|
## Base requise
|
||||||
|
|
||||||
|
Livraison précédente appliquée et commitée :
|
||||||
|
|
||||||
|
```text
|
||||||
|
0.1.3-pre.001-fix.002
|
||||||
|
```
|
||||||
|
|
||||||
|
Ce correctif reste documentaire et doit être validé avant toute ouverture de `pre.002`.
|
||||||
|
|
||||||
|
## Type de livraison
|
||||||
|
|
||||||
|
```text
|
||||||
|
ksp-doc-0.1.3-pre.001-fix.003.zip
|
||||||
|
```
|
||||||
|
|
||||||
|
## Objectif
|
||||||
|
|
||||||
|
Corriger le dimensionnement du plan `0.1.3` afin que les prereleases prévues restent compatibles avec la règle KSP de petites tranches d'environ 15–20 minutes de travail effectif, et rendre explicites les prereleases qui modifient `ksp-logging-lib` pour la non-régression multi-sink/routing.
|
||||||
|
|
||||||
|
Aucune décision fonctionnelle validée dans `pre.001-fix.001/.002` n'est annulée.
|
||||||
|
|
||||||
|
## Version Cargo
|
||||||
|
|
||||||
|
Ce correctif modifie uniquement la documentation de planification.
|
||||||
|
|
||||||
|
Conformément à `VER-ID-008`, `workspace.package.version` reste :
|
||||||
|
|
||||||
|
```text
|
||||||
|
0.1.3-pre.1
|
||||||
|
```
|
||||||
|
|
||||||
|
Identifiant de livraison/commit :
|
||||||
|
|
||||||
|
```text
|
||||||
|
0.1.3-pre.001-fix.003
|
||||||
|
```
|
||||||
|
|
||||||
|
## Fichiers ajoutés
|
||||||
|
|
||||||
|
- `deltas/0.1.3/pre.001-fix.003.md`
|
||||||
|
|
||||||
|
## Fichiers modifiés
|
||||||
|
|
||||||
|
- `docs/plans/005-V0_1_3_CONFIG_FOUNDATION_PLAN.md` — version documentaire 3 -> 4 ;
|
||||||
|
- `docs/plans/002-FUNCTIONAL_RELEASE_SEQUENCE.md` — version documentaire 8 -> 9 ;
|
||||||
|
- `docs/plans/000-README.md` — version documentaire 10 -> 11.
|
||||||
|
|
||||||
|
## Correction de `pre.002`
|
||||||
|
|
||||||
|
`pre.002` n'agrège plus le bootstrap et le registre de fichiers.
|
||||||
|
|
||||||
|
Elle est limitée à :
|
||||||
|
|
||||||
|
```text
|
||||||
|
création ksp-config-lib
|
||||||
|
ConfigBootstrapOptions
|
||||||
|
default cfgpath = config
|
||||||
|
default schemapath = config/schemas
|
||||||
|
--cfgpath
|
||||||
|
--schemapath
|
||||||
|
validation/tests bootstrap
|
||||||
|
```
|
||||||
|
|
||||||
|
Le registre logique est déplacé en `pre.003`.
|
||||||
|
|
||||||
|
## Nouvelle `pre.003` — registre `file_id`
|
||||||
|
|
||||||
|
Cette tranche possède exclusivement :
|
||||||
|
|
||||||
|
```text
|
||||||
|
ConfigFileId
|
||||||
|
ConfigFileKind
|
||||||
|
ConfigFileDescriptor
|
||||||
|
ConfigFileRegistry
|
||||||
|
file_id -> filename
|
||||||
|
--filemap=<file_id>=<filename>
|
||||||
|
validation des overrides
|
||||||
|
```
|
||||||
|
|
||||||
|
Aucune lecture JSON/schema n'est requise dans cette tranche.
|
||||||
|
|
||||||
|
## Tranches `ksp-logging-lib` explicites
|
||||||
|
|
||||||
|
Les ajouts/modifications Logging nécessaires ne sont plus regroupés dans une seule prerelease vague.
|
||||||
|
|
||||||
|
### `pre.004` — contrats/settings multi-output
|
||||||
|
|
||||||
|
- console configurable ;
|
||||||
|
- plusieurs outputs fichier ;
|
||||||
|
- `output_id` unique ;
|
||||||
|
- level/targets/domains par output ;
|
||||||
|
- format/ANSI/rotation ;
|
||||||
|
- validation publique des settings ;
|
||||||
|
- aucune dépendance Logging -> Config.
|
||||||
|
|
||||||
|
### `pre.005` — runtime multi-sink + routing
|
||||||
|
|
||||||
|
- création réelle de 0..N sinks fichier ;
|
||||||
|
- console indépendante ;
|
||||||
|
- routing/filter par output, level, target, domain ;
|
||||||
|
- non-blocking/guards ;
|
||||||
|
- conservation du subscriber unique, takeover et hot reload transactionnel ;
|
||||||
|
- tests de non-régression.
|
||||||
|
|
||||||
|
Le schema `std.logging.json` n'est gelé qu'ensuite, en `pre.006`, sur la surface Logging effectivement stabilisée.
|
||||||
|
|
||||||
|
## Découpage révisé
|
||||||
|
|
||||||
|
```text
|
||||||
|
pre.002 crate Config + bootstrap cfgpath/schemapath
|
||||||
|
pre.003 registre file_id -> filename + --filemap
|
||||||
|
pre.004 Logging : contrats/settings multi-output
|
||||||
|
pre.005 Logging : runtime multi-sink + routing/filter
|
||||||
|
pre.006 JSON/JSON Schema + std.logging.json
|
||||||
|
pre.007 globals + profils + default_profile
|
||||||
|
pre.008 compositions génériques par file_id
|
||||||
|
pre.009 .env + process env + resolver ${...}
|
||||||
|
pre.010 sensibilité + real/safe/provenance
|
||||||
|
pre.011 adapter Config -> Logging
|
||||||
|
pre.012 management + persistence JSON/.env
|
||||||
|
pre.013 ownership audits + robustesse
|
||||||
|
pre.014 clôture
|
||||||
|
```
|
||||||
|
|
||||||
|
Le nombre de prereleases n'est pas une cible à minimiser. Toute tranche qui devient manifestement supérieure au budget d'environ 15–20 minutes doit être scindée explicitement.
|
||||||
|
|
||||||
|
## Invariants conservés
|
||||||
|
|
||||||
|
- `ksp-config-lib` reste l'unique manager KSP des fichiers Config et variables applicatives ;
|
||||||
|
- `cfgpath`/`schemapath` restent bootstrap-only ;
|
||||||
|
- `file_id` reste l'identité logique stable, filename reste remplaçable ;
|
||||||
|
- process env > `.env` > fallback ;
|
||||||
|
- `${KSP_VAR}` / `${KSP_VAR:-fallback}` restent le modèle d'interpolation ;
|
||||||
|
- secrets conservent valeur réelle + représentation sûre/redacted ;
|
||||||
|
- Config construit les contrats Logging sans posséder le routing Logging ;
|
||||||
|
- `ksp-logging-lib` conserve subscriber/layers/writers/lifecycle/guards ;
|
||||||
|
- `LoggingGuard` reste orchestration-owned ;
|
||||||
|
- `0.1.4` reste la release prévue pour `ksp-app-config-desk`.
|
||||||
|
|
||||||
|
## Validations exécutées
|
||||||
|
|
||||||
|
Sur le delta documentaire :
|
||||||
|
|
||||||
|
- contrôle des headers `file:` / `version:` ;
|
||||||
|
- contrôle que les versions documentaires progressent d'une unité ;
|
||||||
|
- contrôle que `pre.002` ne contient plus le registre `file_id` ;
|
||||||
|
- contrôle que `pre.003` possède le registre et `--filemap` ;
|
||||||
|
- contrôle que `pre.004` et `pre.005` mentionnent explicitement les modifications `ksp-logging-lib` ;
|
||||||
|
- contrôle que `std.logging.json`/son schema arrivent après ces tranches Logging ;
|
||||||
|
- contrôle du nouveau découpage jusqu'à `pre.014` ;
|
||||||
|
- contrôle que `Cargo.toml` n'est pas livré par ce fix.
|
||||||
|
|
||||||
|
## Validations non exécutées
|
||||||
|
|
||||||
|
Aucune commande Cargo n'est déclarée réussie pour ce correctif documentaire. Aucun code Rust, manifest ou fichier runtime n'est modifié.
|
||||||
|
|
||||||
|
## Question ouverte avant `pre.002`
|
||||||
|
|
||||||
|
Aucune question bloquante n'est conservée par défaut. Le plan regranularisé doit être validé par le user avant ouverture de `pre.002`.
|
||||||
333
deltas/0.1.3/pre.001.md
Normal file
333
deltas/0.1.3/pre.001.md
Normal file
@@ -0,0 +1,333 @@
|
|||||||
|
<!-- file: deltas/0.1.3/pre.001.md -->
|
||||||
|
<!-- version: 1 -->
|
||||||
|
|
||||||
|
# Delta 0.1.3-pre.001
|
||||||
|
|
||||||
|
## Base requise
|
||||||
|
|
||||||
|
Release stable/taguée attendue :
|
||||||
|
|
||||||
|
```text
|
||||||
|
v0.1.2
|
||||||
|
```
|
||||||
|
|
||||||
|
L'archive fournie `khadhroony-solana-project-v0.1.2.zip` contient bien :
|
||||||
|
|
||||||
|
- `workspace.package.version = "0.1.2"` ;
|
||||||
|
- `ksp-core-lib` ;
|
||||||
|
- `ksp-logging-lib` ;
|
||||||
|
- `deltas/0.1.2/rel.001.md` ;
|
||||||
|
- `prompts/003-V0_1_3_START_PROMPT.md` ;
|
||||||
|
- le plan Logging clôturé `docs/plans/004-V0_1_2_LOGGING_FOUNDATION_PLAN.md`.
|
||||||
|
|
||||||
|
## Objectif
|
||||||
|
|
||||||
|
Ouvrir `0.1.3` par la tranche obligatoire de brainstorming, audit et planification sans commencer une implémentation large de Config.
|
||||||
|
|
||||||
|
Le détail des décisions est consigné dans :
|
||||||
|
|
||||||
|
```text
|
||||||
|
docs/plans/005-V0_1_3_CONFIG_FOUNDATION_PLAN.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## Version Cargo
|
||||||
|
|
||||||
|
`workspace.package.version` passe de :
|
||||||
|
|
||||||
|
```text
|
||||||
|
0.1.2
|
||||||
|
```
|
||||||
|
|
||||||
|
à :
|
||||||
|
|
||||||
|
```text
|
||||||
|
0.1.3-pre.1
|
||||||
|
```
|
||||||
|
|
||||||
|
L'identifiant Cargo respecte SemVer sans zéro initial ; l'identifiant de livraison reste `0.1.3-pre.001`.
|
||||||
|
|
||||||
|
Aucune dépendance Config n'est ajoutée dans cette tranche de planification.
|
||||||
|
|
||||||
|
## Audit du workspace stable
|
||||||
|
|
||||||
|
État de la base :
|
||||||
|
|
||||||
|
```text
|
||||||
|
workspace members
|
||||||
|
├── crates/ksp-core-lib
|
||||||
|
└── crates/ksp-logging-lib
|
||||||
|
```
|
||||||
|
|
||||||
|
Aucun `ksp-config-lib` et aucun répertoire runtime `config/` n'existent encore.
|
||||||
|
|
||||||
|
Core expose déjà :
|
||||||
|
|
||||||
|
```text
|
||||||
|
Error
|
||||||
|
ErrorCode
|
||||||
|
ErrorContext
|
||||||
|
Result<T>
|
||||||
|
```
|
||||||
|
|
||||||
|
Logging expose déjà les types/lifecycles que Config devra consommer :
|
||||||
|
|
||||||
|
```text
|
||||||
|
LoggingSettings
|
||||||
|
LoggingGuard
|
||||||
|
initialize(...)
|
||||||
|
reinitialize(...)
|
||||||
|
```
|
||||||
|
|
||||||
|
La direction retenue est :
|
||||||
|
|
||||||
|
```text
|
||||||
|
ksp-config-lib -> ksp-core-lib
|
||||||
|
ksp-config-lib -> ksp-logging-lib
|
||||||
|
ksp-core-lib -X-> ksp-config-lib
|
||||||
|
ksp-logging-lib -X-> ksp-config-lib
|
||||||
|
```
|
||||||
|
|
||||||
|
## Référence historique bot3
|
||||||
|
|
||||||
|
L'archive bot3 fournie a été auditée comme référence uniquement.
|
||||||
|
|
||||||
|
Sont conservés comme principes :
|
||||||
|
|
||||||
|
- documents spécialisés ;
|
||||||
|
- globals hors profils ;
|
||||||
|
- `default_profile` autonome ;
|
||||||
|
- compositions propres aux binaires ;
|
||||||
|
- overrides de profils spécialisés ;
|
||||||
|
- schémas séparés ;
|
||||||
|
- classification de sensibilité ;
|
||||||
|
- séparation source/runtime/public/diagnostic ;
|
||||||
|
- DTO Tauri possédés par l'application.
|
||||||
|
|
||||||
|
Ne sont pas repris :
|
||||||
|
|
||||||
|
- `AppConfig/ProfileConfig` monolithique/transitoire ;
|
||||||
|
- les documents de composants non encore développés ;
|
||||||
|
- l'interpolation générique `${VAR}` dans les chaînes JSON ;
|
||||||
|
- la mutation globale de l'environnement du processus ;
|
||||||
|
- la sérialisation d'un runtime secret suivie d'un camouflage a posteriori.
|
||||||
|
|
||||||
|
## Décisions principales
|
||||||
|
|
||||||
|
### Première surface de documents
|
||||||
|
|
||||||
|
Runtime réel prévu :
|
||||||
|
|
||||||
|
```text
|
||||||
|
config/logging.config.json
|
||||||
|
config/environment.env
|
||||||
|
```
|
||||||
|
|
||||||
|
Schémas :
|
||||||
|
|
||||||
|
```text
|
||||||
|
config/schemas/logging.config.schema.json
|
||||||
|
config/schemas/composition.config.schema.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Exemples :
|
||||||
|
|
||||||
|
```text
|
||||||
|
config/examples/example.logging.config.json
|
||||||
|
config/examples/example.composition.config.json
|
||||||
|
config/examples/example.environment.env
|
||||||
|
```
|
||||||
|
|
||||||
|
Aucun document Transport/Wallet/Store/Execution n'est créé prématurément.
|
||||||
|
|
||||||
|
### Composition
|
||||||
|
|
||||||
|
Nomenclature future :
|
||||||
|
|
||||||
|
```text
|
||||||
|
config/<executable>.default.config.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Le composite possède `owner_namespace = ksp|kspb`, son propre `default_profile`, des sources de documents par identifiant et des overrides de profils documentaires.
|
||||||
|
|
||||||
|
Le champ historique `active_profile` n'est pas retenu comme état persisté : le profil effectivement actif est un résultat de résolution runtime.
|
||||||
|
|
||||||
|
Aucune composition runtime concrète n'est créée en `0.1.3`, faute d'exécutable consommateur ; le contrat sera testé par schema/exemple/fixtures avant `ksp-app-config-desk`.
|
||||||
|
|
||||||
|
### Résolution
|
||||||
|
|
||||||
|
Ordre fixé :
|
||||||
|
|
||||||
|
```text
|
||||||
|
locator
|
||||||
|
-> document source
|
||||||
|
-> schema/source validation
|
||||||
|
-> composition
|
||||||
|
-> composition profile
|
||||||
|
-> document profile
|
||||||
|
-> globals + profile
|
||||||
|
-> env bindings
|
||||||
|
-> effective validation
|
||||||
|
-> component runtime contract
|
||||||
|
```
|
||||||
|
|
||||||
|
Pour les overrides de valeurs :
|
||||||
|
|
||||||
|
```text
|
||||||
|
process environment
|
||||||
|
> config/environment.env
|
||||||
|
> document/profile
|
||||||
|
```
|
||||||
|
|
||||||
|
### Environnement
|
||||||
|
|
||||||
|
Namespaces :
|
||||||
|
|
||||||
|
```text
|
||||||
|
KSP_SECRET_* / KSP_PUBLIC_* / KSP_*
|
||||||
|
KSPB_SECRET_* / KSPB_PUBLIC_* / KSPB_*
|
||||||
|
```
|
||||||
|
|
||||||
|
Les overrides sont déclarés explicitement par binding clé Config -> variable. Aucun mapping automatique par transformation de chemin JSON et aucune interpolation générique ne sont retenus.
|
||||||
|
|
||||||
|
Le fichier géré prévu est :
|
||||||
|
|
||||||
|
```text
|
||||||
|
config/environment.env
|
||||||
|
```
|
||||||
|
|
||||||
|
Il utilise une grammaire KSP v1 stricte (`# ksp-env-format: 1`), sans expansion `$VAR`/`${VAR}`, sans syntaxe shell et avec refus des doublons/noms non enregistrés. L'audit a conduit à **ne pas retenir `dotenvy`**, car son parser 0.15.7 effectue des substitutions même via l'iterator.
|
||||||
|
|
||||||
|
Les contrôles initiaux sont `KSP_ENV_FILE` (process-only), `KSP_CONFIG_PROFILE` et le futur `KSPB_CONFIG_PROFILE`.
|
||||||
|
|
||||||
|
En Rust 2024, `std::env::set_var/remove_var` sont `unsafe`. Comme KSP interdit `unsafe`, `ksp-config-lib` ne modifie jamais l'environnement global du processus. La future surface management modifie le fichier d'environnement géré et signale lorsqu'une valeur du processus continue à la shadow.
|
||||||
|
|
||||||
|
### Sensibilité
|
||||||
|
|
||||||
|
Classes :
|
||||||
|
|
||||||
|
```text
|
||||||
|
Public
|
||||||
|
Internal
|
||||||
|
Secret
|
||||||
|
```
|
||||||
|
|
||||||
|
Un secret peut être lu par un consumer runtime qui en a réellement besoin ou par une surface de management explicitement privilégiée. Il n'est pas exposé dans les logs, diagnostics ordinaires ou DTO publics génériques.
|
||||||
|
|
||||||
|
Cette séparation est une barrière d'API et de non-divulgation ; l'authentification d'un utilisateur final appartient à l'application.
|
||||||
|
|
||||||
|
### Mutation/persistence
|
||||||
|
|
||||||
|
`0.1.3` conserve la mutation dans son périmètre, mais uniquement pour les sources Config connues.
|
||||||
|
|
||||||
|
La persistence doit être atomique : ancien fichier complet ou nouveau fichier complet, jamais un fichier destination partiel. Les sources gérées sont bornées par un `ConfigRoot` explicite ; une composition ne peut pas référencer un chemin absolu ou sortir de cette racine. Une destination writable qui est un symlink est refusée initialement afin de ne pas remplacer le lien ni suivre implicitement une cible hors frontière Config.
|
||||||
|
|
||||||
|
`atomic-write-file` est retenue comme dépendance candidate à auditer au moment de l'introduction réelle.
|
||||||
|
|
||||||
|
Le résultat d'une mutation doit distinguer source souhaitée et valeur effective, notamment en présence d'un override process.
|
||||||
|
|
||||||
|
### Logging
|
||||||
|
|
||||||
|
Config produit :
|
||||||
|
|
||||||
|
```text
|
||||||
|
ResolvedLoggingConfig -> ksp_logging_lib::LoggingSettings
|
||||||
|
```
|
||||||
|
|
||||||
|
L'orchestration possède `LoggingGuard` et décide d'appeler `initialize` ou `reinitialize`. Dans `0.1.3`, le harness d’intégration possède localement guard + session Config ; dans `0.1.4`, ce sera l’état backend de `ksp-app-config-desk`. Config ne possède jamais le guard et n'introduit pas de singleton global.
|
||||||
|
|
||||||
|
## Dépendances candidates auditées
|
||||||
|
|
||||||
|
Aucune dépendance ajoutée dans `pre.001`.
|
||||||
|
|
||||||
|
Générations candidates à revérifier au moment de l'ajout :
|
||||||
|
|
||||||
|
```text
|
||||||
|
serde ^1.0
|
||||||
|
serde_json ^1.0
|
||||||
|
jsonschema ^0.49 default-features = false
|
||||||
|
atomic-write-file ^0.3
|
||||||
|
```
|
||||||
|
|
||||||
|
Le plan rejette initialement toute dépendance Config à Tokio, Tauri, TS-RS, watcher filesystem, `anyhow` ou `thiserror`.
|
||||||
|
|
||||||
|
## Découpage prévu
|
||||||
|
|
||||||
|
```text
|
||||||
|
pre.001 audit + brainstorming + plan
|
||||||
|
pre.002 crate foundation + erreurs + modèles source
|
||||||
|
pre.003 schemas + logging.config.json
|
||||||
|
pre.004 composition + profils
|
||||||
|
pre.005 environnement + sensibilité
|
||||||
|
pre.006 adapter Logging + lifecycle integration
|
||||||
|
pre.007 management + persistence atomique
|
||||||
|
pre.008 ownership audits + robustesse
|
||||||
|
pre.009 validation finale + docs/cleanup + prompt 0.1.4
|
||||||
|
```
|
||||||
|
|
||||||
|
Le découpage reste souple.
|
||||||
|
|
||||||
|
## Fichiers ajoutés
|
||||||
|
|
||||||
|
- `docs/plans/005-V0_1_3_CONFIG_FOUNDATION_PLAN.md`
|
||||||
|
- `deltas/0.1.3/pre.001.md`
|
||||||
|
|
||||||
|
## Fichiers modifiés
|
||||||
|
|
||||||
|
- `Cargo.toml`
|
||||||
|
- `ROADMAP.md`
|
||||||
|
- `docs/plans/000-README.md`
|
||||||
|
- `docs/plans/002-FUNCTIONAL_RELEASE_SEQUENCE.md`
|
||||||
|
|
||||||
|
## Fichiers supprimés
|
||||||
|
|
||||||
|
Aucun.
|
||||||
|
|
||||||
|
## Hors scope confirmé
|
||||||
|
|
||||||
|
- application desktop Config ;
|
||||||
|
- Tauri/TS-RS dans Config ;
|
||||||
|
- Wallet ;
|
||||||
|
- Store/PostgreSQL ;
|
||||||
|
- RPC/WS/provider ;
|
||||||
|
- Program/decoder/execution ;
|
||||||
|
- workers/jobs/pipelines ;
|
||||||
|
- watcher filesystem ;
|
||||||
|
- service distribué Config ;
|
||||||
|
- secrets manager distant ;
|
||||||
|
- configuration de composants inexistants.
|
||||||
|
|
||||||
|
## Validations de livraison
|
||||||
|
|
||||||
|
Ce delta ne modifie aucun source Rust mais modifie la version Cargo.
|
||||||
|
|
||||||
|
Les quatre validations Cargo applicables ont été tentées dans l'environnement de préparation :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo fmt --all
|
||||||
|
cargo check --workspace
|
||||||
|
cargo clippy --workspace --all-targets
|
||||||
|
cargo test --workspace
|
||||||
|
```
|
||||||
|
|
||||||
|
Résultat : **non exécutées**, car cet environnement ne fournit pas l'exécutable `cargo` (`cargo: command not found`). Aucune de ces commandes n'est déclarée réussie. Elles restent à exécuter sur le workspace utilisateur avant validation/commit du delta.
|
||||||
|
|
||||||
|
Les commandes suivantes ne sont pas applicables au `pre.001`, car la crate `ksp-config-lib` n'est volontairement pas encore créée :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo tree -p ksp-config-lib
|
||||||
|
cargo tree -p ksp-config-lib -d
|
||||||
|
cargo tree -p ksp-config-lib -e features
|
||||||
|
```
|
||||||
|
|
||||||
|
Contrôles statiques réellement exécutés :
|
||||||
|
|
||||||
|
- liens Markdown locaux des fichiers modifiés/ajoutés : résolus ;
|
||||||
|
- fences Markdown du plan et du delta : équilibrées ;
|
||||||
|
- diff `[workspace.dependencies]` contre `v0.1.2` : aucun changement ;
|
||||||
|
- `Cargo.lock` : absent ;
|
||||||
|
- `target/` : absent ;
|
||||||
|
- `crates/ksp-config-lib/` : absent, conformément au scope `pre.001` ;
|
||||||
|
- `config/` runtime : absent, conformément au scope `pre.001` ;
|
||||||
|
- répertoire/script d'audit dans la base fournie : aucun trouvé au niveau workspace.
|
||||||
|
|
||||||
|
Le scan statique de la base confirme également que les usages `tracing*` existants restent dans `ksp-logging-lib`; le seul `std::env` relevé dans la base Rust stable auditée est un `temp_dir()` de test Logging, pas une lecture de variable applicative.
|
||||||
144
deltas/0.1.3/pre.002-fix.001.md
Normal file
144
deltas/0.1.3/pre.002-fix.001.md
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
<!-- file: deltas/0.1.3/pre.002-fix.001.md -->
|
||||||
|
<!-- version: 1 -->
|
||||||
|
|
||||||
|
# Delta 0.1.3-pre.002-fix.001
|
||||||
|
|
||||||
|
## Base requise
|
||||||
|
|
||||||
|
Livraison précédente :
|
||||||
|
|
||||||
|
```text
|
||||||
|
0.1.3-pre.002
|
||||||
|
```
|
||||||
|
|
||||||
|
Version technique de cette base :
|
||||||
|
|
||||||
|
```text
|
||||||
|
workspace.package.version = "0.1.3-pre.2"
|
||||||
|
```
|
||||||
|
|
||||||
|
Le correctif ne change pas le périmètre fonctionnel de `pre.002`.
|
||||||
|
|
||||||
|
## Objectif
|
||||||
|
|
||||||
|
Supprimer le warning `missing_docs` émis pour la crate d'intégration :
|
||||||
|
|
||||||
|
```text
|
||||||
|
crates/ksp-config-lib/tests/public_api.rs
|
||||||
|
```
|
||||||
|
|
||||||
|
Le workspace active `missing_docs = "warn"`. Un fichier sous `tests/` est compilé comme une crate d'intégration autonome et doit donc posséder sa propre documentation de crate.
|
||||||
|
|
||||||
|
## Correction
|
||||||
|
|
||||||
|
`crates/ksp-config-lib/tests/public_api.rs` reçoit une Rustdoc de crate :
|
||||||
|
|
||||||
|
```rust
|
||||||
|
//! Integration tests for the public `ksp-config-lib` bootstrap contract.
|
||||||
|
```
|
||||||
|
|
||||||
|
Aucun `allow(missing_docs)` n'est ajouté : le test respecte directement la règle documentaire du workspace.
|
||||||
|
|
||||||
|
## Version technique
|
||||||
|
|
||||||
|
Le correctif modifie un source Rust. Conformément à `VER-ID-007` et `VER-ID-010`, la version Cargo devient :
|
||||||
|
|
||||||
|
```text
|
||||||
|
workspace.package.version = "0.1.3-pre.2.fix.1"
|
||||||
|
```
|
||||||
|
|
||||||
|
Le header du manifest racine passe :
|
||||||
|
|
||||||
|
```text
|
||||||
|
Cargo.toml version 41 -> 42
|
||||||
|
```
|
||||||
|
|
||||||
|
Le header du test passe :
|
||||||
|
|
||||||
|
```text
|
||||||
|
crates/ksp-config-lib/tests/public_api.rs version 1 -> 2
|
||||||
|
```
|
||||||
|
|
||||||
|
## Fichiers ajoutés
|
||||||
|
|
||||||
|
```text
|
||||||
|
deltas/0.1.3/pre.002-fix.001.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## Fichiers modifiés
|
||||||
|
|
||||||
|
```text
|
||||||
|
Cargo.toml
|
||||||
|
crates/ksp-config-lib/tests/public_api.rs
|
||||||
|
```
|
||||||
|
|
||||||
|
## Fichiers supprimés
|
||||||
|
|
||||||
|
Aucun.
|
||||||
|
|
||||||
|
## Dépendances
|
||||||
|
|
||||||
|
Aucune dépendance ajoutée ou modifiée.
|
||||||
|
|
||||||
|
## Validations de `pre.002` fournies par l'utilisateur
|
||||||
|
|
||||||
|
Sur `0.1.3-pre.002`, l'utilisateur a exécuté :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo fmt --all
|
||||||
|
cargo check --workspace
|
||||||
|
cargo clippy --workspace --all-targets
|
||||||
|
cargo test --workspace
|
||||||
|
cargo tree -p ksp-config-lib
|
||||||
|
cargo tree -p ksp-config-lib -d
|
||||||
|
cargo tree -p ksp-config-lib -e features
|
||||||
|
```
|
||||||
|
|
||||||
|
Résultats observés avant ce fix :
|
||||||
|
|
||||||
|
- `cargo fmt --all` : réussi ;
|
||||||
|
- `cargo check --workspace` : réussi ;
|
||||||
|
- `cargo clippy --workspace --all-targets` : réussi avec un warning `missing_docs` limité à `tests/public_api.rs` ;
|
||||||
|
- `cargo test --workspace` : tous les tests exécutés ont réussi, avec le même warning `missing_docs` ;
|
||||||
|
- `cargo tree -p ksp-config-lib` : `ksp-core-lib` est la seule dépendance directe ;
|
||||||
|
- `cargo tree -p ksp-config-lib -d` : aucune dépendance dupliquée à afficher ;
|
||||||
|
- `cargo tree -p ksp-config-lib -e features` : aucune feature Config supplémentaire ni dépendance inattendue.
|
||||||
|
|
||||||
|
## Validations exécutées sur ce correctif
|
||||||
|
|
||||||
|
Contrôles statiques effectués pendant la préparation :
|
||||||
|
|
||||||
|
- le delta ne contient que les deux fichiers modifiés et ce fichier de delta ;
|
||||||
|
- `workspace.package.version` vaut `0.1.3-pre.2.fix.1` ;
|
||||||
|
- les headers de fichiers progressent correctement ;
|
||||||
|
- `tests/public_api.rs` possède une Rustdoc de crate ;
|
||||||
|
- aucun `allow(missing_docs)` n'est introduit ;
|
||||||
|
- aucune dépendance n'est modifiée ;
|
||||||
|
- aucune surface `file_id`, JSON, schema, `.env` ou Logging n'est ouverte par ce fix.
|
||||||
|
|
||||||
|
## Validations non exécutées après correction
|
||||||
|
|
||||||
|
L'environnement de préparation ne fournit pas `cargo`, `rustc` ou `rustfmt`. Les commandes suivantes restent donc à réexécuter par l'utilisateur sur le correctif :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo fmt --all
|
||||||
|
cargo check --workspace
|
||||||
|
cargo clippy --workspace --all-targets
|
||||||
|
cargo test --workspace
|
||||||
|
cargo tree -p ksp-config-lib
|
||||||
|
cargo tree -p ksp-config-lib -d
|
||||||
|
cargo tree -p ksp-config-lib -e features
|
||||||
|
```
|
||||||
|
|
||||||
|
Aucune commande non exécutée après application du fix n'est déclarée réussie.
|
||||||
|
|
||||||
|
## Décisions prises
|
||||||
|
|
||||||
|
- les crates d'intégration respectent `missing_docs` par une Rustdoc de crate explicite ;
|
||||||
|
- aucune exception lint n'est ajoutée pour masquer ce warning ;
|
||||||
|
- le correctif reste strictement borné à la conformité documentaire du test ;
|
||||||
|
- le périmètre de `pre.003` reste inchangé : registre logique `file_id -> filename` et `--filemap`.
|
||||||
|
|
||||||
|
## Questions ouvertes
|
||||||
|
|
||||||
|
Aucune question bloquante pour ce correctif.
|
||||||
233
deltas/0.1.3/pre.002.md
Normal file
233
deltas/0.1.3/pre.002.md
Normal file
@@ -0,0 +1,233 @@
|
|||||||
|
<!-- file: deltas/0.1.3/pre.002.md -->
|
||||||
|
<!-- version: 1 -->
|
||||||
|
|
||||||
|
# Delta 0.1.3-pre.002
|
||||||
|
|
||||||
|
## Base requise
|
||||||
|
|
||||||
|
Livraison documentaire précédente validée :
|
||||||
|
|
||||||
|
```text
|
||||||
|
0.1.3-pre.001-fix.003
|
||||||
|
```
|
||||||
|
|
||||||
|
La base porte :
|
||||||
|
|
||||||
|
```text
|
||||||
|
workspace.package.version = "0.1.3-pre.1"
|
||||||
|
Cargo.toml header version = 40
|
||||||
|
```
|
||||||
|
|
||||||
|
Le plan `docs/plans/005-V0_1_3_CONFIG_FOUNDATION_PLAN.md` est en version `4` et borne `pre.002` à la création de `ksp-config-lib` et au bootstrap `cfgpath` / `schemapath` uniquement.
|
||||||
|
|
||||||
|
## Objet de pre.002
|
||||||
|
|
||||||
|
Cette tranche ouvre le développement fonctionnel de Config sans anticiper les tranches suivantes.
|
||||||
|
|
||||||
|
Elle :
|
||||||
|
|
||||||
|
- crée `ksp-config-lib` ;
|
||||||
|
- ajoute la crate au workspace ;
|
||||||
|
- fixe les deux racines bootstrap non récursives ;
|
||||||
|
- expose leur équivalent programmatique ;
|
||||||
|
- possède le parsing des deux arguments CLI correspondants ;
|
||||||
|
- valide les chemins bootstrap ;
|
||||||
|
- introduit uniquement les erreurs Config nécessaires à cette surface ;
|
||||||
|
- ajoute les tests unitaires et d'intégration de cette API publique.
|
||||||
|
|
||||||
|
Elle n'introduit pas encore :
|
||||||
|
|
||||||
|
- le registre `file_id -> filename` ;
|
||||||
|
- `--filemap` ;
|
||||||
|
- `serde`, `serde_json` ou `jsonschema` ;
|
||||||
|
- les documents JSON runtime ;
|
||||||
|
- les profils/composites ;
|
||||||
|
- `.env` ou `std::env::var` ;
|
||||||
|
- l'interpolation `${...}` ;
|
||||||
|
- les secrets ;
|
||||||
|
- la persistence ;
|
||||||
|
- une dépendance directe à `ksp-logging-lib` tant qu'aucun événement Config ne l'utilise réellement.
|
||||||
|
|
||||||
|
## Crate `ksp-config-lib`
|
||||||
|
|
||||||
|
La nouvelle crate hérite de la version, de l'édition, du repository et des lints du workspace.
|
||||||
|
|
||||||
|
Sa seule dépendance est actuellement :
|
||||||
|
|
||||||
|
```toml
|
||||||
|
ksp-core-lib = { path = "../ksp-core-lib" }
|
||||||
|
```
|
||||||
|
|
||||||
|
Cela respecte la règle d'ajout des dépendances uniquement lorsqu'elles sont réellement utilisées. La direction architecturale future reste `ksp-config-lib -> ksp-logging-lib`, mais cette dépendance n'est pas ajoutée prématurément dans `pre.002`.
|
||||||
|
|
||||||
|
## Bootstrap non récursif
|
||||||
|
|
||||||
|
Les défauts KSP sont codés dans Config :
|
||||||
|
|
||||||
|
```text
|
||||||
|
DEFAULT_CFG_PATH = "config"
|
||||||
|
DEFAULT_SCHEMA_PATH = "config/schemas"
|
||||||
|
```
|
||||||
|
|
||||||
|
Ils ne dépendent d'aucun document Config, `.env` ou variable applicative.
|
||||||
|
|
||||||
|
La surface publique introduite est :
|
||||||
|
|
||||||
|
```text
|
||||||
|
ConfigBootstrapOptions::defaults()
|
||||||
|
ConfigBootstrapOptions::from_paths(...)
|
||||||
|
ConfigBootstrapOptions::from_args(...)
|
||||||
|
ConfigBootstrapOptions::cfg_path()
|
||||||
|
ConfigBootstrapOptions::schema_path()
|
||||||
|
ConfigBootstrapOptions::with_cfg_path(...)
|
||||||
|
ConfigBootstrapOptions::with_schema_path(...)
|
||||||
|
```
|
||||||
|
|
||||||
|
Les arguments possédés par Config sont :
|
||||||
|
|
||||||
|
```text
|
||||||
|
--cfgpath
|
||||||
|
--schemapath
|
||||||
|
```
|
||||||
|
|
||||||
|
Le parser accepte les deux formes :
|
||||||
|
|
||||||
|
```text
|
||||||
|
--cfgpath=/path/to/configs
|
||||||
|
--cfgpath /path/to/configs
|
||||||
|
|
||||||
|
--schemapath=/path/to/schemas
|
||||||
|
--schemapath /path/to/schemas
|
||||||
|
```
|
||||||
|
|
||||||
|
Les arguments étrangers sont ignorés afin qu'une application puisse transmettre son vecteur d'arguments complet à Config. Si un même path est fourni plusieurs fois, le dernier override explicite gagne.
|
||||||
|
|
||||||
|
Les deux roots restent indépendants : remplacer `cfgpath` ne modifie pas `schemapath`, et inversement.
|
||||||
|
|
||||||
|
## Validation des chemins
|
||||||
|
|
||||||
|
`pre.002` applique seulement les garanties qui sont valides avant la création des premiers documents runtime :
|
||||||
|
|
||||||
|
- path vide : refusé ;
|
||||||
|
- path existant et répertoire : accepté ;
|
||||||
|
- path existant mais non répertoire : refusé ;
|
||||||
|
- path inexistant : accepté, car `config/` et `config/schemas/` ne sont créés que dans une tranche ultérieure ;
|
||||||
|
- path relatif ou absolu : accepté.
|
||||||
|
|
||||||
|
Un argument séparé sans valeur, ou immédiatement suivi d'une autre option `--...`, produit une erreur dédiée.
|
||||||
|
|
||||||
|
## Erreurs Config initiales
|
||||||
|
|
||||||
|
Les codes restent possédés par `ksp-config-lib` avec le domaine `config` :
|
||||||
|
|
||||||
|
```text
|
||||||
|
config.bootstrap_argument_missing_value
|
||||||
|
config.bootstrap_invalid_path
|
||||||
|
```
|
||||||
|
|
||||||
|
Ils utilisent les contrats existants de Core :
|
||||||
|
|
||||||
|
```text
|
||||||
|
ksp_core_lib::Error
|
||||||
|
ksp_core_lib::ErrorCode
|
||||||
|
ksp_core_lib::Result<T>
|
||||||
|
```
|
||||||
|
|
||||||
|
Core ne reçoit aucune connaissance métier Config.
|
||||||
|
|
||||||
|
## Tests ajoutés
|
||||||
|
|
||||||
|
Les tests unitaires couvrent notamment :
|
||||||
|
|
||||||
|
- les deux défauts hardcodés ;
|
||||||
|
- l'indépendance des overrides cfg/schema ;
|
||||||
|
- les formes CLI inline et séparées ;
|
||||||
|
- la règle du dernier override ;
|
||||||
|
- l'ignorance des arguments étrangers ;
|
||||||
|
- l'absence de valeur ;
|
||||||
|
- le refus d'un path vide ;
|
||||||
|
- l'acceptation d'un path programmatique inexistant ;
|
||||||
|
- le refus d'un path existant qui est un fichier.
|
||||||
|
|
||||||
|
Les tests d'intégration vérifient la façade publique au crate-root et le parsing consommable par une crate externe.
|
||||||
|
|
||||||
|
## Version technique
|
||||||
|
|
||||||
|
La prerelease devient :
|
||||||
|
|
||||||
|
```text
|
||||||
|
workspace.package.version = "0.1.3-pre.2"
|
||||||
|
```
|
||||||
|
|
||||||
|
Le manifest racine devient :
|
||||||
|
|
||||||
|
```text
|
||||||
|
# version: 41
|
||||||
|
```
|
||||||
|
|
||||||
|
Le plan Config devient :
|
||||||
|
|
||||||
|
```text
|
||||||
|
<!-- version: 5 -->
|
||||||
|
```
|
||||||
|
|
||||||
|
## Fichiers ajoutés
|
||||||
|
|
||||||
|
```text
|
||||||
|
crates/ksp-config-lib/Cargo.toml
|
||||||
|
crates/ksp-config-lib/src/bootstrap.rs
|
||||||
|
crates/ksp-config-lib/src/error.rs
|
||||||
|
crates/ksp-config-lib/src/lib.rs
|
||||||
|
crates/ksp-config-lib/unit_tests/bootstrap.rs
|
||||||
|
crates/ksp-config-lib/tests/public_api.rs
|
||||||
|
deltas/0.1.3/pre.002.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## Fichiers modifiés
|
||||||
|
|
||||||
|
```text
|
||||||
|
Cargo.toml
|
||||||
|
docs/plans/005-V0_1_3_CONFIG_FOUNDATION_PLAN.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## Fichiers supprimés
|
||||||
|
|
||||||
|
Aucun.
|
||||||
|
|
||||||
|
## Validations non exécutées à faire dans l'environnement utilisateur
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo fmt --all
|
||||||
|
cargo check --workspace
|
||||||
|
cargo clippy --workspace --all-targets
|
||||||
|
cargo test --workspace
|
||||||
|
cargo tree -p ksp-config-lib
|
||||||
|
cargo tree -p ksp-config-lib -d
|
||||||
|
cargo tree -p ksp-config-lib -e features
|
||||||
|
```
|
||||||
|
|
||||||
|
Aucun script d'audit Rust/KSP exécutable n'est présent dans la base reconstruite de cette tranche.
|
||||||
|
|
||||||
|
L'environnement de génération du delta ne fournit pas `cargo`, `rustc` ni `rustfmt`. Aucune commande Cargo ci-dessus n'est donc déclarée réussie avant la validation dans l'environnement utilisateur.
|
||||||
|
|
||||||
|
## Validations exécutées avant livraison
|
||||||
|
|
||||||
|
Des contrôles statiques ont vérifié avant livraison :
|
||||||
|
|
||||||
|
- headers `file:` / `version:` présents sur les nouveaux fichiers ;
|
||||||
|
- aucune ligne Rust supérieure à 160 colonnes avant formatage ;
|
||||||
|
- aucun `unsafe`, `unwrap`, `expect`, `panic!` ou opérateur `?` dans `src/` ;
|
||||||
|
- aucun `use` de non-trait ;
|
||||||
|
- aucune lecture de variable applicative par `std::env` dans `src/` ;
|
||||||
|
- aucune dépendance externe nouvelle ;
|
||||||
|
- aucun `Cargo.lock` ajouté au delta.
|
||||||
|
|
||||||
|
## Suite après validation
|
||||||
|
|
||||||
|
Si `pre.002` est validée, la prochaine tranche prévue est :
|
||||||
|
|
||||||
|
```text
|
||||||
|
0.1.3-pre.003 — registre logique file_id -> filename + --filemap
|
||||||
|
```
|
||||||
|
|
||||||
|
Elle ne doit pas encore lire les documents JSON ou leurs schemas.
|
||||||
311
deltas/0.1.3/pre.003.md
Normal file
311
deltas/0.1.3/pre.003.md
Normal file
@@ -0,0 +1,311 @@
|
|||||||
|
<!-- file: deltas/0.1.3/pre.003.md -->
|
||||||
|
<!-- version: 1 -->
|
||||||
|
|
||||||
|
# Delta 0.1.3-pre.003
|
||||||
|
|
||||||
|
## Base requise
|
||||||
|
|
||||||
|
Livraison précédente validée :
|
||||||
|
|
||||||
|
```text
|
||||||
|
0.1.3-pre.002-fix.001
|
||||||
|
```
|
||||||
|
|
||||||
|
Version technique de cette base :
|
||||||
|
|
||||||
|
```text
|
||||||
|
workspace.package.version = "0.1.3-pre.2.fix.1"
|
||||||
|
Cargo.toml header version = 42
|
||||||
|
```
|
||||||
|
|
||||||
|
Les validations utilisateur de cette base sont propres : formatage, check, Clippy sans warning, tests workspace et vues `cargo tree` de `ksp-config-lib`.
|
||||||
|
|
||||||
|
## Objet de pre.003
|
||||||
|
|
||||||
|
Cette tranche introduit uniquement le registre logique des fichiers possédés par Config et le remplacement bootstrap de leurs noms physiques.
|
||||||
|
|
||||||
|
Elle ajoute :
|
||||||
|
|
||||||
|
- `ConfigFileId` ;
|
||||||
|
- `ConfigFileKind` ;
|
||||||
|
- `ConfigFileDescriptor` ;
|
||||||
|
- `ConfigFileRegistry` ;
|
||||||
|
- les mappings par défaut actuellement connus ;
|
||||||
|
- `--filemap=<file_id>=<filename>` répétable ;
|
||||||
|
- l'override programmatique équivalent ;
|
||||||
|
- la résolution d'un `file_id` sous `cfgpath` ou `schemapath` ;
|
||||||
|
- les validations d'identité, d'unicité, de kind et de filename relatif ;
|
||||||
|
- les erreurs Config et tests strictement nécessaires à cette surface.
|
||||||
|
|
||||||
|
Elle n'ajoute pas encore :
|
||||||
|
|
||||||
|
- `serde`, `serde_json` ou `jsonschema` ;
|
||||||
|
- lecture ou écriture JSON ;
|
||||||
|
- fichiers runtime `config/` ;
|
||||||
|
- validation de schema ;
|
||||||
|
- profils ou composites ;
|
||||||
|
- `.env` ou environnement du processus ;
|
||||||
|
- interpolation `${...}` ;
|
||||||
|
- secrets ;
|
||||||
|
- persistence ;
|
||||||
|
- modification de `ksp-logging-lib`.
|
||||||
|
|
||||||
|
## Identité logique et mappings par défaut
|
||||||
|
|
||||||
|
Les deux seuls fichiers que Config connaît actuellement sont :
|
||||||
|
|
||||||
|
```text
|
||||||
|
cfg.std.logging -> std.logging.json
|
||||||
|
schema.std.logging -> std.logging.schema.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Les fichiers physiques ne sont pas encore créés ni lus dans cette tranche. Leur identité logique est introduite maintenant afin que les futurs consumers et composites ne dépendent jamais directement de leur filename.
|
||||||
|
|
||||||
|
Les constantes publiques associées sont :
|
||||||
|
|
||||||
|
```text
|
||||||
|
FILE_ID_STD_LOGGING
|
||||||
|
FILE_ID_SCHEMA_STD_LOGGING
|
||||||
|
DEFAULT_STD_LOGGING_FILENAME
|
||||||
|
DEFAULT_STD_LOGGING_SCHEMA_FILENAME
|
||||||
|
```
|
||||||
|
|
||||||
|
## `ConfigFileId`
|
||||||
|
|
||||||
|
`ConfigFileId` est une valeur typée possédée par Config.
|
||||||
|
|
||||||
|
La syntaxe initiale accepte uniquement :
|
||||||
|
|
||||||
|
- ASCII minuscule ;
|
||||||
|
- chiffres ;
|
||||||
|
- `_` et `-` dans les segments ;
|
||||||
|
- `.` comme séparateur de segments ;
|
||||||
|
- aucun segment vide.
|
||||||
|
|
||||||
|
Exemples valides :
|
||||||
|
|
||||||
|
```text
|
||||||
|
cfg.std.logging
|
||||||
|
schema.std.logging
|
||||||
|
cfg.composite.ksp-app-wallet-desk
|
||||||
|
```
|
||||||
|
|
||||||
|
Exemples refusés :
|
||||||
|
|
||||||
|
```text
|
||||||
|
CFG.std.logging
|
||||||
|
cfg..logging
|
||||||
|
cfg/logging
|
||||||
|
.cfg.logging
|
||||||
|
```
|
||||||
|
|
||||||
|
## Kind et racines
|
||||||
|
|
||||||
|
`ConfigFileKind` distingue :
|
||||||
|
|
||||||
|
```text
|
||||||
|
Config -> ConfigBootstrapOptions::cfg_path()
|
||||||
|
Schema -> ConfigBootstrapOptions::schema_path()
|
||||||
|
```
|
||||||
|
|
||||||
|
Un descriptor `Config` doit appartenir au namespace `cfg.*` et un descriptor `Schema` au namespace `schema.*`.
|
||||||
|
|
||||||
|
Cette relation est fixée au registre ; un override de filename ne peut jamais changer le kind ni déplacer un schema sous `cfgpath` ou un document Config sous `schemapath`.
|
||||||
|
|
||||||
|
## Override CLI `--filemap`
|
||||||
|
|
||||||
|
Le contrat initial est volontairement atomique :
|
||||||
|
|
||||||
|
```text
|
||||||
|
--filemap=<file_id>=<filename>
|
||||||
|
```
|
||||||
|
|
||||||
|
Exemples :
|
||||||
|
|
||||||
|
```text
|
||||||
|
--filemap=cfg.std.logging=my.logging.json
|
||||||
|
--filemap=schema.std.logging=my.logging.schema.json
|
||||||
|
```
|
||||||
|
|
||||||
|
L'argument est répétable. Lorsque le même `file_id` est fourni plusieurs fois, le dernier filename gagne.
|
||||||
|
|
||||||
|
Les arguments étrangers sont ignorés, comme pour le bootstrap des roots.
|
||||||
|
|
||||||
|
La forme séparée suivante n'est pas supportée :
|
||||||
|
|
||||||
|
```text
|
||||||
|
--filemap cfg.std.logging=my.logging.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Un `--filemap` sans valeur inline est donc une erreur de mapping plutôt qu'un argument ignoré.
|
||||||
|
|
||||||
|
Un override ne peut pas enregistrer un nouveau `file_id`. Le `file_id` doit déjà appartenir au registre KSP ; seul son filename physique est remplaçable.
|
||||||
|
|
||||||
|
## Filenames et confinement lexical
|
||||||
|
|
||||||
|
Un filename mappé :
|
||||||
|
|
||||||
|
- doit être non vide ;
|
||||||
|
- doit être relatif ;
|
||||||
|
- ne peut contenir ni `.` ni `..` comme composants ;
|
||||||
|
- ne peut contenir de root/prefix absolu ;
|
||||||
|
- peut contenir des sous-répertoires relatifs normaux.
|
||||||
|
|
||||||
|
Ainsi :
|
||||||
|
|
||||||
|
```text
|
||||||
|
std.logging.json
|
||||||
|
profiles/my.logging.json
|
||||||
|
```
|
||||||
|
|
||||||
|
sont acceptés, tandis que :
|
||||||
|
|
||||||
|
```text
|
||||||
|
../std.logging.json
|
||||||
|
./std.logging.json
|
||||||
|
/opt/ksp/std.logging.json
|
||||||
|
```
|
||||||
|
|
||||||
|
sont refusés.
|
||||||
|
|
||||||
|
Cette tranche garantit le confinement **lexical** sous `cfgpath`/`schemapath`. Les garanties filesystem/canonicalisation/symlink nécessaires à la lecture et à la persistence seront ajoutées avec les opérations I/O correspondantes, pas simulées avant leur existence.
|
||||||
|
|
||||||
|
## API publique
|
||||||
|
|
||||||
|
La surface publique ajoute notamment :
|
||||||
|
|
||||||
|
```text
|
||||||
|
ConfigFileId::new(...)
|
||||||
|
ConfigFileId::as_str()
|
||||||
|
|
||||||
|
ConfigFileRegistry::defaults()
|
||||||
|
ConfigFileRegistry::from_args(...)
|
||||||
|
ConfigFileRegistry::descriptor(...)
|
||||||
|
ConfigFileRegistry::resolve_path(...)
|
||||||
|
ConfigFileRegistry::with_filename_override(...)
|
||||||
|
```
|
||||||
|
|
||||||
|
`resolve_path(...)` ne lit rien : il sélectionne simplement la bonne root bootstrap selon le kind et y joint le filename validé.
|
||||||
|
|
||||||
|
## Erreurs Config ajoutées
|
||||||
|
|
||||||
|
Les codes restent possédés par `ksp-config-lib` :
|
||||||
|
|
||||||
|
```text
|
||||||
|
config.file_id_invalid
|
||||||
|
config.file_id_unknown
|
||||||
|
config.file_id_duplicate
|
||||||
|
config.file_mapping_invalid
|
||||||
|
```
|
||||||
|
|
||||||
|
Ils réutilisent toujours `ksp_core_lib::Error`, `ErrorCode` et `Result<T>` sans introduire de connaissance Config dans Core.
|
||||||
|
|
||||||
|
## Dépendances
|
||||||
|
|
||||||
|
Aucune dépendance ajoutée ou modifiée.
|
||||||
|
|
||||||
|
`ksp-config-lib` dépend toujours directement uniquement de :
|
||||||
|
|
||||||
|
```text
|
||||||
|
ksp-core-lib
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tests ajoutés
|
||||||
|
|
||||||
|
Les tests unitaires couvrent notamment :
|
||||||
|
|
||||||
|
- les deux mappings par défaut ;
|
||||||
|
- la séparation `cfgpath` / `schemapath` par kind ;
|
||||||
|
- l'override CLI ;
|
||||||
|
- la règle du dernier override ;
|
||||||
|
- l'override programmatique ;
|
||||||
|
- la conservation du `file_id` et du kind ;
|
||||||
|
- le refus d'un ID inconnu ;
|
||||||
|
- la syntaxe des IDs ;
|
||||||
|
- le refus des paths absolus et traversants ;
|
||||||
|
- le refus des arguments `--filemap` mal formés ;
|
||||||
|
- le refus des IDs dupliqués ;
|
||||||
|
- la cohérence namespace/kind.
|
||||||
|
|
||||||
|
Le test d'intégration public vérifie également que le registre, les deux IDs Logging, les overrides de document/schema et leur résolution sont consommables depuis le crate-root.
|
||||||
|
|
||||||
|
## Version technique
|
||||||
|
|
||||||
|
La prerelease devient :
|
||||||
|
|
||||||
|
```text
|
||||||
|
workspace.package.version = "0.1.3-pre.3"
|
||||||
|
```
|
||||||
|
|
||||||
|
Le manifest racine devient :
|
||||||
|
|
||||||
|
```text
|
||||||
|
# version: 43
|
||||||
|
```
|
||||||
|
|
||||||
|
Le plan Config devient :
|
||||||
|
|
||||||
|
```text
|
||||||
|
<!-- version: 6 -->
|
||||||
|
```
|
||||||
|
|
||||||
|
## Fichiers ajoutés
|
||||||
|
|
||||||
|
```text
|
||||||
|
crates/ksp-config-lib/src/registry.rs
|
||||||
|
crates/ksp-config-lib/unit_tests/registry.rs
|
||||||
|
deltas/0.1.3/pre.003.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## Fichiers modifiés
|
||||||
|
|
||||||
|
```text
|
||||||
|
Cargo.toml
|
||||||
|
crates/ksp-config-lib/src/error.rs
|
||||||
|
crates/ksp-config-lib/src/lib.rs
|
||||||
|
crates/ksp-config-lib/tests/public_api.rs
|
||||||
|
docs/plans/005-V0_1_3_CONFIG_FOUNDATION_PLAN.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## Fichiers supprimés
|
||||||
|
|
||||||
|
Aucun.
|
||||||
|
|
||||||
|
## Validations utilisateur attendues
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo fmt --all
|
||||||
|
cargo check --workspace
|
||||||
|
cargo clippy --workspace --all-targets
|
||||||
|
cargo test --workspace
|
||||||
|
cargo tree -p ksp-config-lib
|
||||||
|
cargo tree -p ksp-config-lib -d
|
||||||
|
cargo tree -p ksp-config-lib -e features
|
||||||
|
```
|
||||||
|
|
||||||
|
Exécuter également tout script d'audit réellement présent dans le dépôt utilisateur.
|
||||||
|
|
||||||
|
## Validations exécutées avant livraison
|
||||||
|
|
||||||
|
L'environnement de génération ne fournit pas `cargo`, `rustc` ou `rustfmt`; aucune validation Cargo n'est donc déclarée réussie sur `pre.003` avant les tests utilisateur.
|
||||||
|
|
||||||
|
Les contrôles statiques de préparation vérifient :
|
||||||
|
|
||||||
|
- headers `file:` / `version:` ;
|
||||||
|
- version Cargo `0.1.3-pre.3` ;
|
||||||
|
- aucune ligne Rust supérieure à 160 colonnes avant formatage ;
|
||||||
|
- aucun `unsafe`, `unwrap`, `expect`, `panic!` ou opérateur `?` dans le nouveau code ;
|
||||||
|
- aucune lecture de variable applicative ;
|
||||||
|
- aucune dépendance externe nouvelle ;
|
||||||
|
- aucune surface JSON/schema I/O, `.env` ou Logging ouverte ;
|
||||||
|
- delta limité aux fichiers ajoutés/modifiés de cette tranche.
|
||||||
|
|
||||||
|
## Suite après validation
|
||||||
|
|
||||||
|
Si `pre.003` est validée, la tranche suivante reste :
|
||||||
|
|
||||||
|
```text
|
||||||
|
0.1.3-pre.004 — ksp-logging-lib : modèle public multi-output
|
||||||
|
```
|
||||||
|
|
||||||
|
Elle modifiera les contrats/settings Logging avant que `std.logging.schema.json` ne soit figé en `pre.006`.
|
||||||
337
deltas/0.1.3/pre.004.md
Normal file
337
deltas/0.1.3/pre.004.md
Normal file
@@ -0,0 +1,337 @@
|
|||||||
|
<!-- file: deltas/0.1.3/pre.004.md -->
|
||||||
|
<!-- version: 1 -->
|
||||||
|
|
||||||
|
# Delta 0.1.3-pre.004
|
||||||
|
|
||||||
|
## Base requise
|
||||||
|
|
||||||
|
Livraison précédente validée :
|
||||||
|
|
||||||
|
```text
|
||||||
|
0.1.3-pre.003
|
||||||
|
```
|
||||||
|
|
||||||
|
Version technique de cette base :
|
||||||
|
|
||||||
|
```text
|
||||||
|
workspace.package.version = "0.1.3-pre.3"
|
||||||
|
Cargo.toml header version = 43
|
||||||
|
```
|
||||||
|
|
||||||
|
Validations utilisateur de `pre.003` :
|
||||||
|
|
||||||
|
```text
|
||||||
|
cargo fmt --all OK
|
||||||
|
cargo check --workspace OK
|
||||||
|
cargo clippy --workspace --all-targets OK
|
||||||
|
cargo test --workspace OK
|
||||||
|
```
|
||||||
|
|
||||||
|
Les tests Config livrés en `pre.003` réussissent : 21 tests unitaires et 4 tests d'API publique.
|
||||||
|
|
||||||
|
## Objet de pre.004
|
||||||
|
|
||||||
|
Cette tranche complète uniquement les **contrats/settings publics** de `ksp-logging-lib` nécessaires au futur `config/std.logging.json`.
|
||||||
|
|
||||||
|
Elle ne livre pas encore le runtime multi-sink complet. Le but est de stabiliser ce que Logging sait représenter avant de figer le schema JSON Config.
|
||||||
|
|
||||||
|
## Modèle public ajouté
|
||||||
|
|
||||||
|
### `LogFormat`
|
||||||
|
|
||||||
|
Formats publics représentables :
|
||||||
|
|
||||||
|
```text
|
||||||
|
Human
|
||||||
|
Compact
|
||||||
|
Pretty
|
||||||
|
Json
|
||||||
|
```
|
||||||
|
|
||||||
|
L'activation backend de tous ces formats appartient à la tranche runtime suivante.
|
||||||
|
|
||||||
|
### `OutputFilter`
|
||||||
|
|
||||||
|
Chaque sink peut maintenant déclarer un filtre propre contenant :
|
||||||
|
|
||||||
|
```text
|
||||||
|
level
|
||||||
|
targets[]
|
||||||
|
domains[]
|
||||||
|
```
|
||||||
|
|
||||||
|
Les selectors `targets` sont des préfixes de targets KSP ou le wildcard `*`.
|
||||||
|
|
||||||
|
Les selectors `domains` sont des préfixes de domain ou le wildcard `*`.
|
||||||
|
|
||||||
|
Le wildcard doit être utilisé seul dans sa dimension. Les listes vides, selectors vides, doublons et targets externes à KSP sont refusés par la validation publique.
|
||||||
|
|
||||||
|
`OutputFilter::unrestricted()` représente l'absence de restriction supplémentaire par rapport au takeover global :
|
||||||
|
|
||||||
|
```text
|
||||||
|
level = Trace
|
||||||
|
targets = ["*"]
|
||||||
|
domains = ["*"]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Console explicite
|
||||||
|
|
||||||
|
`ConsoleSettings` représente désormais explicitement :
|
||||||
|
|
||||||
|
```text
|
||||||
|
enabled
|
||||||
|
output = stdout | stderr
|
||||||
|
ansi
|
||||||
|
format
|
||||||
|
filter
|
||||||
|
```
|
||||||
|
|
||||||
|
Les helpers :
|
||||||
|
|
||||||
|
```text
|
||||||
|
ConsoleSettings::stdout()
|
||||||
|
ConsoleSettings::stderr()
|
||||||
|
```
|
||||||
|
|
||||||
|
restent disponibles et produisent un contrat compatible avec le runtime `0.1.2` : console activée, format `Human`, ANSI désactivé et `OutputFilter::unrestricted()`.
|
||||||
|
|
||||||
|
## Plusieurs outputs fichier
|
||||||
|
|
||||||
|
`LoggingSettings` ne contient plus un unique :
|
||||||
|
|
||||||
|
```text
|
||||||
|
Option<FileSettings>
|
||||||
|
```
|
||||||
|
|
||||||
|
mais :
|
||||||
|
|
||||||
|
```text
|
||||||
|
Vec<FileSettings>
|
||||||
|
```
|
||||||
|
|
||||||
|
Chaque `FileSettings` porte :
|
||||||
|
|
||||||
|
```text
|
||||||
|
output_id
|
||||||
|
enabled
|
||||||
|
directory
|
||||||
|
file_name_prefix
|
||||||
|
rotation
|
||||||
|
format
|
||||||
|
ansi
|
||||||
|
filter
|
||||||
|
```
|
||||||
|
|
||||||
|
`output_id` est une identité Logging stable, unique parmi les fichiers d'un même `LoggingSettings`.
|
||||||
|
|
||||||
|
Il ne doit pas être confondu avec le `file_id` de `ksp-config-lib` :
|
||||||
|
|
||||||
|
```text
|
||||||
|
file_id -> identité d'un document/schema Config
|
||||||
|
output_id -> identité d'une sortie Logging
|
||||||
|
```
|
||||||
|
|
||||||
|
La syntaxe initiale d'un `output_id` accepte l'ASCII minuscule, chiffres, `_`, `-` et des segments séparés par `.` sans segment vide.
|
||||||
|
|
||||||
|
Exemples :
|
||||||
|
|
||||||
|
```text
|
||||||
|
file.debug
|
||||||
|
file.config.error
|
||||||
|
file.ksp-config-lib.error
|
||||||
|
```
|
||||||
|
|
||||||
|
## ANSI fichier
|
||||||
|
|
||||||
|
Une sortie persistante avec :
|
||||||
|
|
||||||
|
```text
|
||||||
|
ansi = true
|
||||||
|
```
|
||||||
|
|
||||||
|
est refusée par `LoggingSettings::validate()`.
|
||||||
|
|
||||||
|
Le contrat KSP conserve donc l'invariant qu'un fichier de logs ne persiste pas de séquences ANSI.
|
||||||
|
|
||||||
|
## Deux niveaux de filtrage conservés
|
||||||
|
|
||||||
|
Le nouveau filtre par sink ne remplace pas la politique existante.
|
||||||
|
|
||||||
|
`LoggingSettings` conserve :
|
||||||
|
|
||||||
|
```text
|
||||||
|
default_filter
|
||||||
|
target_filters[]
|
||||||
|
```
|
||||||
|
|
||||||
|
qui définissent le takeover global KSP.
|
||||||
|
|
||||||
|
Puis chaque console/fichier possède son `OutputFilter`, appliqué conceptuellement **en plus** du takeover global.
|
||||||
|
|
||||||
|
Cette séparation permet de conserver les overrides généraux tout en préparant un routing indépendant par sink, target, domain et niveau.
|
||||||
|
|
||||||
|
## Compatibilité runtime transitoire
|
||||||
|
|
||||||
|
Cette tranche ne doit jamais accepter silencieusement un contrat que le runtime `0.1.2` ne sait pas encore appliquer.
|
||||||
|
|
||||||
|
Après la validation structurelle, `initialize/reinitialize` refusent donc temporairement :
|
||||||
|
|
||||||
|
- plusieurs fichiers **actifs** simultanément ;
|
||||||
|
- ANSI console ;
|
||||||
|
- format console différent de `Human` ;
|
||||||
|
- format fichier différent de `Human` ;
|
||||||
|
- filtre propre à un output différent de `OutputFilter::unrestricted()`.
|
||||||
|
|
||||||
|
Les outputs désactivés peuvent déjà porter leur future configuration sans influencer le runtime actif.
|
||||||
|
|
||||||
|
Le refus utilise actuellement `logging.invalid_settings` avec un contexte `runtime_contract = single-output-compatibility`.
|
||||||
|
|
||||||
|
Ce garde-fou transitoire sera supprimé/remplacé lorsque `pre.005` implémentera réellement les capacités représentées.
|
||||||
|
|
||||||
|
## Lifecycle Logging préservé
|
||||||
|
|
||||||
|
Cette tranche ne modifie pas les responsabilités suivantes :
|
||||||
|
|
||||||
|
```text
|
||||||
|
initialize(...)
|
||||||
|
reinitialize(...)
|
||||||
|
LoggingGuard
|
||||||
|
subscriber global unique
|
||||||
|
takeover KSP
|
||||||
|
hot reload transactionnel
|
||||||
|
writers non bloquants
|
||||||
|
DroppedLines agrégé console/fichier
|
||||||
|
```
|
||||||
|
|
||||||
|
`DroppedLines` reste temporairement agrégé par type de sink ; sa généralisation éventuelle par `output_id` appartient à `pre.005`.
|
||||||
|
|
||||||
|
## Frontière Config préservée
|
||||||
|
|
||||||
|
Aucune dépendance inverse n'est introduite :
|
||||||
|
|
||||||
|
```text
|
||||||
|
ksp-logging-lib -X-> ksp-config-lib
|
||||||
|
```
|
||||||
|
|
||||||
|
`ksp-logging-lib` ne lit toujours aucun fichier JSON, aucun profil, aucun `.env` et aucune variable applicative.
|
||||||
|
|
||||||
|
Le futur `ksp-config-lib` construira ces contrats publics après résolution de `std.logging.json`.
|
||||||
|
|
||||||
|
## Dépendances
|
||||||
|
|
||||||
|
Aucune dépendance externe ou KSP supplémentaire n'est ajoutée.
|
||||||
|
|
||||||
|
Le graphe direct de `ksp-config-lib` reste inchangé dans cette tranche.
|
||||||
|
|
||||||
|
## Tests modifiés/ajoutés
|
||||||
|
|
||||||
|
Les tests settings couvrent notamment :
|
||||||
|
|
||||||
|
- les quatre formats ;
|
||||||
|
- niveau/targets/domains de `OutputFilter` ;
|
||||||
|
- wildcard unrestricted ;
|
||||||
|
- console enabled/output/ANSI/format/filter ;
|
||||||
|
- plusieurs `FileSettings` ;
|
||||||
|
- unicité et syntaxe des `output_id` ;
|
||||||
|
- fichiers sans ANSI ;
|
||||||
|
- selectors invalides, doublons et targets externes ;
|
||||||
|
- outputs déclarés mais désactivés.
|
||||||
|
|
||||||
|
Les tests runtime couvrent également le garde-fou transitoire :
|
||||||
|
|
||||||
|
- une console utilisant ANSI/format/routing enrichi est structurellement valide mais refusée par le backend courant ;
|
||||||
|
- plusieurs fichiers actifs sont structurellement valides mais refusés par le backend courant ;
|
||||||
|
- les contrats historiques compatibles continuent d'être préparés normalement.
|
||||||
|
|
||||||
|
Le test d'intégration d'API publique construit un contrat avec console enrichie et fichier JSON filtré afin de vérifier que la nouvelle surface est bien accessible depuis le crate-root.
|
||||||
|
|
||||||
|
## Documentation Logging
|
||||||
|
|
||||||
|
`README.md`, `USAGE.md` et `TODO.md` sont alignés sur la séparation :
|
||||||
|
|
||||||
|
```text
|
||||||
|
pre.004 = contrat public multi-output
|
||||||
|
pre.005 = activation runtime multi-sink/routing
|
||||||
|
```
|
||||||
|
|
||||||
|
## Version technique
|
||||||
|
|
||||||
|
La prerelease devient :
|
||||||
|
|
||||||
|
```text
|
||||||
|
workspace.package.version = "0.1.3-pre.4"
|
||||||
|
```
|
||||||
|
|
||||||
|
Le manifest racine devient :
|
||||||
|
|
||||||
|
```text
|
||||||
|
# version: 44
|
||||||
|
```
|
||||||
|
|
||||||
|
Le plan Config devient :
|
||||||
|
|
||||||
|
```text
|
||||||
|
<!-- version: 7 -->
|
||||||
|
```
|
||||||
|
|
||||||
|
## Fichiers ajoutés
|
||||||
|
|
||||||
|
```text
|
||||||
|
deltas/0.1.3/pre.004.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## Fichiers modifiés
|
||||||
|
|
||||||
|
```text
|
||||||
|
Cargo.toml
|
||||||
|
crates/ksp-logging-lib/README.md
|
||||||
|
crates/ksp-logging-lib/TODO.md
|
||||||
|
crates/ksp-logging-lib/USAGE.md
|
||||||
|
crates/ksp-logging-lib/src/lib.rs
|
||||||
|
crates/ksp-logging-lib/src/runtime.rs
|
||||||
|
crates/ksp-logging-lib/src/settings.rs
|
||||||
|
crates/ksp-logging-lib/tests/public_api.rs
|
||||||
|
crates/ksp-logging-lib/tests/runtime.rs
|
||||||
|
crates/ksp-logging-lib/unit_tests/runtime.rs
|
||||||
|
crates/ksp-logging-lib/unit_tests/settings.rs
|
||||||
|
docs/plans/005-V0_1_3_CONFIG_FOUNDATION_PLAN.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## Hors scope confirmé
|
||||||
|
|
||||||
|
Cette tranche n'implémente pas :
|
||||||
|
|
||||||
|
- le runtime multi-sink complet ;
|
||||||
|
- le routing réel par target/domain de chaque sink ;
|
||||||
|
- les formatters Compact/Pretty/JSON runtime ;
|
||||||
|
- l'ANSI console runtime ;
|
||||||
|
- la généralisation complète des guards/drop counters par `output_id` ;
|
||||||
|
- `serde`, `serde_json` ou `jsonschema` ;
|
||||||
|
- `std.logging.json` ou son schema ;
|
||||||
|
- profils Config ;
|
||||||
|
- `.env` ou interpolation ;
|
||||||
|
- modification de `ksp-config-lib`.
|
||||||
|
|
||||||
|
## Validations à exécuter par l'utilisateur
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo fmt --all
|
||||||
|
cargo check --workspace
|
||||||
|
cargo clippy --workspace --all-targets
|
||||||
|
cargo test --workspace
|
||||||
|
cargo tree -p ksp-logging-lib
|
||||||
|
cargo tree -p ksp-logging-lib -d
|
||||||
|
cargo tree -p ksp-logging-lib -e features
|
||||||
|
```
|
||||||
|
|
||||||
|
Les commandes Cargo ne sont pas déclarées réussies dans le delta tant qu'elles n'ont pas été exécutées sur l'environnement utilisateur.
|
||||||
|
|
||||||
|
## Suite
|
||||||
|
|
||||||
|
Après validation de cette tranche :
|
||||||
|
|
||||||
|
```text
|
||||||
|
0.1.3-pre.005 — ksp-logging-lib : runtime multi-sink + routing
|
||||||
|
```
|
||||||
|
|
||||||
|
Cette tranche suivante devra activer réellement les contrats stabilisés ici sans réintroduire Config dans Logging.
|
||||||
117
deltas/0.1.3/pre.005-fix.001.md
Normal file
117
deltas/0.1.3/pre.005-fix.001.md
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
<!-- file: deltas/0.1.3/pre.005-fix.001.md -->
|
||||||
|
<!-- version: 1 -->
|
||||||
|
|
||||||
|
# Delta 0.1.3-pre.005-fix.001
|
||||||
|
|
||||||
|
## Base requise
|
||||||
|
|
||||||
|
Livraison précédente :
|
||||||
|
|
||||||
|
```text
|
||||||
|
0.1.3-pre.005
|
||||||
|
```
|
||||||
|
|
||||||
|
Version technique de cette base :
|
||||||
|
|
||||||
|
```text
|
||||||
|
workspace.package.version = "0.1.3-pre.5"
|
||||||
|
Cargo.toml header version = 45
|
||||||
|
```
|
||||||
|
|
||||||
|
Validations utilisateur exécutées le 2026-08-15 :
|
||||||
|
|
||||||
|
```text
|
||||||
|
cargo fmt --all OK
|
||||||
|
cargo check --workspace OK
|
||||||
|
cargo clippy --workspace --all-targets OK
|
||||||
|
cargo test --workspace FAILED — 1 test d'intégration Logging
|
||||||
|
cargo tree -p ksp-logging-lib OK
|
||||||
|
cargo tree -p ksp-logging-lib -d OK — aucune duplication
|
||||||
|
cargo tree -p ksp-logging-lib -e features OK
|
||||||
|
```
|
||||||
|
|
||||||
|
Échec isolé :
|
||||||
|
|
||||||
|
```text
|
||||||
|
global_runtime_supports_takeover_non_blocking_outputs_hot_reload_and_single_initialization
|
||||||
|
assertion failed: json_text.contains("logging info marker")
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cause
|
||||||
|
|
||||||
|
Le test utilisait le même event pour vérifier deux comportements différents :
|
||||||
|
|
||||||
|
```text
|
||||||
|
logging info <ANSI>marker</ANSI>
|
||||||
|
```
|
||||||
|
|
||||||
|
Cet event était envoyé à la fois au sink `Human` et au sink `Json`.
|
||||||
|
|
||||||
|
Pour le sink humain, `StripAnsiWriter` reçoit les octets ANSI bruts après formatage et les supprime, ce qui produit bien :
|
||||||
|
|
||||||
|
```text
|
||||||
|
logging info marker
|
||||||
|
```
|
||||||
|
|
||||||
|
Pour le formatter JSON, les caractères de contrôle présents dans la valeur structurée sont échappés par le JSON avant l'écriture. La représentation persistée contient donc une forme JSON échappée de l'ESC plutôt qu'une séquence terminal brute ; la sous-chaîne littérale `logging info marker` n'est alors plus contiguë.
|
||||||
|
|
||||||
|
L'échec provenait donc d'une hypothèse incorrecte du test et non d'une perte de l'event par le routing multi-sink.
|
||||||
|
|
||||||
|
## Correction
|
||||||
|
|
||||||
|
Le test d'intégration sépare désormais les responsabilités :
|
||||||
|
|
||||||
|
- `LOGGING_TARGET` continue de vérifier le sink `Human` et le stripping ANSI persistant ;
|
||||||
|
- un target KSP dédié `JSON_KSP_TARGET = "ksp-logging-json-test"` alimente le sink JSON ;
|
||||||
|
- les events JSON utilisent des messages neutres : `json info marker` et `json error marker` ;
|
||||||
|
- les assertions JSON vérifient ces marqueurs et le target JSON dédié ;
|
||||||
|
- le routing indépendant par target reste ainsi testé explicitement sans dépendre de la représentation d'échappement JSON d'un caractère de contrôle.
|
||||||
|
|
||||||
|
Aucun code runtime de `ksp-logging-lib` n'est modifié.
|
||||||
|
|
||||||
|
## Version technique
|
||||||
|
|
||||||
|
Comme un fichier Rust de test est modifié :
|
||||||
|
|
||||||
|
```text
|
||||||
|
workspace.package.version = "0.1.3-pre.5.fix.1"
|
||||||
|
Cargo.toml header version = 46
|
||||||
|
crates/ksp-logging-lib/tests/runtime.rs header version = 7
|
||||||
|
```
|
||||||
|
|
||||||
|
## Fichiers modifiés
|
||||||
|
|
||||||
|
```text
|
||||||
|
Cargo.toml
|
||||||
|
crates/ksp-logging-lib/tests/runtime.rs
|
||||||
|
```
|
||||||
|
|
||||||
|
## Fichier ajouté
|
||||||
|
|
||||||
|
```text
|
||||||
|
deltas/0.1.3/pre.005-fix.001.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## Hors scope
|
||||||
|
|
||||||
|
Ce fix n'ouvre pas `pre.006` et ne modifie pas :
|
||||||
|
|
||||||
|
- le runtime multi-sink ;
|
||||||
|
- le routing `level`/`target` ;
|
||||||
|
- le futur routing structuré `domain` ;
|
||||||
|
- Config, JSON Config ou JSON Schema ;
|
||||||
|
- les dépendances ou features Cargo.
|
||||||
|
|
||||||
|
## Validations à exécuter
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo fmt --all
|
||||||
|
cargo check --workspace
|
||||||
|
cargo clippy --workspace --all-targets
|
||||||
|
cargo test --workspace
|
||||||
|
cargo tree -p ksp-logging-lib
|
||||||
|
cargo tree -p ksp-logging-lib -d
|
||||||
|
cargo tree -p ksp-logging-lib -e features
|
||||||
|
```
|
||||||
|
|
||||||
|
Aucune validation Cargo n'est déclarée réussie tant qu'elle n'a pas été exécutée sur le workspace utilisateur.
|
||||||
313
deltas/0.1.3/pre.005.md
Normal file
313
deltas/0.1.3/pre.005.md
Normal file
@@ -0,0 +1,313 @@
|
|||||||
|
<!-- file: deltas/0.1.3/pre.005.md -->
|
||||||
|
<!-- version: 1 -->
|
||||||
|
|
||||||
|
# Delta 0.1.3-pre.005
|
||||||
|
|
||||||
|
## Base requise
|
||||||
|
|
||||||
|
Livraison précédente validée :
|
||||||
|
|
||||||
|
```text
|
||||||
|
0.1.3-pre.004
|
||||||
|
```
|
||||||
|
|
||||||
|
Version technique de cette base :
|
||||||
|
|
||||||
|
```text
|
||||||
|
workspace.package.version = "0.1.3-pre.4"
|
||||||
|
Cargo.toml header version = 44
|
||||||
|
```
|
||||||
|
|
||||||
|
Validations utilisateur de `pre.004` exécutées le 2026-08-15 :
|
||||||
|
|
||||||
|
```text
|
||||||
|
cargo fmt --all OK
|
||||||
|
cargo check --workspace OK
|
||||||
|
cargo clippy --workspace --all-targets OK
|
||||||
|
cargo test --workspace OK
|
||||||
|
cargo tree -p ksp-logging-lib OK
|
||||||
|
cargo tree -p ksp-logging-lib -d OK — aucune duplication
|
||||||
|
cargo tree -p ksp-logging-lib -e features OK
|
||||||
|
```
|
||||||
|
|
||||||
|
## Objet de pre.005
|
||||||
|
|
||||||
|
Cette tranche active le runtime multi-sink derrière les contrats publics stabilisés en `pre.004`, mais reste bornée aux dimensions de routing directement portées par les métadonnées des events/spans :
|
||||||
|
|
||||||
|
```text
|
||||||
|
level
|
||||||
|
target
|
||||||
|
```
|
||||||
|
|
||||||
|
Le routing par champ structuré `domain` est scindé en `pre.006` plutôt que d'être approximé ou assimilé au target.
|
||||||
|
|
||||||
|
## Multi-sink runtime
|
||||||
|
|
||||||
|
`RuntimeOutputs` possède maintenant :
|
||||||
|
|
||||||
|
```text
|
||||||
|
console: Option<RuntimeOutput>
|
||||||
|
files: Vec<RuntimeFileOutput>
|
||||||
|
```
|
||||||
|
|
||||||
|
Tous les `FileSettings` actifs sont préparés et installés simultanément.
|
||||||
|
|
||||||
|
Chaque fichier conserve :
|
||||||
|
|
||||||
|
```text
|
||||||
|
output_id
|
||||||
|
WorkerGuard
|
||||||
|
ErrorCounter
|
||||||
|
```
|
||||||
|
|
||||||
|
Le lifecycle de hot reload reste celui stabilisé en `0.1.2` :
|
||||||
|
|
||||||
|
1. validation et préparation complète des nouvelles sorties ;
|
||||||
|
2. swap du groupe de layers via le handle reload existant ;
|
||||||
|
3. conservation des compteurs des sorties retirées ;
|
||||||
|
4. destruction des anciens layers ;
|
||||||
|
5. destruction des anciens guards/writers.
|
||||||
|
|
||||||
|
Un échec de préparation avant le swap laisse la configuration active intacte.
|
||||||
|
|
||||||
|
## Routing par output : level + target
|
||||||
|
|
||||||
|
Chaque formatter utilise désormais un `RouteMakeWriter` interne.
|
||||||
|
|
||||||
|
Lors de `make_writer_for(metadata)` :
|
||||||
|
|
||||||
|
- `OutputFilter.level` décide si le niveau est accepté ;
|
||||||
|
- `OutputFilter.targets[]` accepte le wildcard `*` ou un préfixe de target KSP ;
|
||||||
|
- un writer désactivé absorbe la sortie sans écrire dans le sink concerné.
|
||||||
|
|
||||||
|
Le takeover global existant reste distinct :
|
||||||
|
|
||||||
|
```text
|
||||||
|
default_filter + TargetFilter[]
|
||||||
|
↓
|
||||||
|
groupe de sinks
|
||||||
|
↓
|
||||||
|
OutputFilter level/target propre à chaque sink
|
||||||
|
```
|
||||||
|
|
||||||
|
Le routing par output ne transforme pas les layers en `Filtered` reloadables. La composition `Targets.and_then(output_layers)` stabilisée pendant `0.1.2` est conservée.
|
||||||
|
|
||||||
|
## Scission du routing `domain`
|
||||||
|
|
||||||
|
`domain` est un champ structuré déclaré par les callers, par exemple :
|
||||||
|
|
||||||
|
```rust
|
||||||
|
ksp_logging_lib::info!(
|
||||||
|
target: "ksp-config-lib",
|
||||||
|
domain = "config",
|
||||||
|
"configuration event"
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
Il n'est pas une propriété des métadonnées de target.
|
||||||
|
|
||||||
|
Pour respecter le budget de tranche et éviter une sémantique incorrecte, `pre.005` refuse explicitement un output **actif** dont :
|
||||||
|
|
||||||
|
```text
|
||||||
|
domains != ["*"]
|
||||||
|
```
|
||||||
|
|
||||||
|
Le refus utilise `logging.invalid_settings` avec le contexte :
|
||||||
|
|
||||||
|
```text
|
||||||
|
runtime_contract = metadata-routing-before-domain-routing
|
||||||
|
```
|
||||||
|
|
||||||
|
Les contrats publics de `pre.004` restent inchangés : un output désactivé peut déjà porter son futur filtre domain et `LoggingSettings::validate()` continue de le valider structurellement.
|
||||||
|
|
||||||
|
`pre.006` est insérée pour traiter :
|
||||||
|
|
||||||
|
- domain porté directement par un event ;
|
||||||
|
- domain d'un span ;
|
||||||
|
- héritage vers les events sans champ direct ;
|
||||||
|
- spans imbriqués ;
|
||||||
|
- lifecycle `SpanEvents` ;
|
||||||
|
- interaction avec les quatre formats et plusieurs sinks.
|
||||||
|
|
||||||
|
## Formats runtime
|
||||||
|
|
||||||
|
Les quatre formats publics sont maintenant actifs :
|
||||||
|
|
||||||
|
```text
|
||||||
|
Human
|
||||||
|
Compact
|
||||||
|
Pretty
|
||||||
|
Json
|
||||||
|
```
|
||||||
|
|
||||||
|
Le manifest racine active pour `tracing-subscriber` :
|
||||||
|
|
||||||
|
```toml
|
||||||
|
features = ["fmt", "json", "ansi"]
|
||||||
|
```
|
||||||
|
|
||||||
|
Aucune nouvelle dépendance directe n'est ajoutée au workspace.
|
||||||
|
|
||||||
|
## ANSI
|
||||||
|
|
||||||
|
La console applique désormais réellement `ConsoleSettings::ansi()` pour les formats humains.
|
||||||
|
|
||||||
|
Les fichiers restent construits derrière `StripAnsiWriter` et persistent sans ANSI.
|
||||||
|
|
||||||
|
La combinaison :
|
||||||
|
|
||||||
|
```text
|
||||||
|
console.format = Json
|
||||||
|
console.ansi = true
|
||||||
|
```
|
||||||
|
|
||||||
|
est refusée par `LoggingSettings::validate()` afin de ne pas accepter une option qui serait sans effet.
|
||||||
|
|
||||||
|
## Compteurs par output fichier
|
||||||
|
|
||||||
|
La vue agrégée existante reste disponible :
|
||||||
|
|
||||||
|
```rust
|
||||||
|
LoggingGuard::dropped_lines() -> DroppedLines
|
||||||
|
```
|
||||||
|
|
||||||
|
avec :
|
||||||
|
|
||||||
|
```text
|
||||||
|
console
|
||||||
|
file
|
||||||
|
total
|
||||||
|
```
|
||||||
|
|
||||||
|
`pre.005` ajoute :
|
||||||
|
|
||||||
|
```rust
|
||||||
|
LoggingGuard::dropped_file_lines(output_id: &str) -> Option<usize>
|
||||||
|
```
|
||||||
|
|
||||||
|
Le compteur est cumulatif à travers les reloads pour un même `LoggingGuard` et un même `output_id`, y compris après retrait du sink.
|
||||||
|
|
||||||
|
Un `output_id` qui n'a jamais été actif retourne `None`.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
Les tests unitaires couvrent notamment :
|
||||||
|
|
||||||
|
- préparation de plusieurs fichiers simultanés ;
|
||||||
|
- console ANSI + format Compact ;
|
||||||
|
- fichiers Pretty et Json ;
|
||||||
|
- refus explicite d'un domain spécifique ;
|
||||||
|
- mapping des niveaux de routing ;
|
||||||
|
- writer désactivé ;
|
||||||
|
- rejet JSON + ANSI console ;
|
||||||
|
- conservation des tests de takeover, rotation, saturation et span lifecycle.
|
||||||
|
|
||||||
|
Le test global runtime est étendu pour vérifier en un seul subscriber global :
|
||||||
|
|
||||||
|
- échec transactionnel d'une configuration domain non encore supportée ;
|
||||||
|
- quatre fichiers simultanés ;
|
||||||
|
- formats Human, Compact, Pretty et Json ;
|
||||||
|
- routing indépendant par target et niveau ;
|
||||||
|
- silence des targets externes ;
|
||||||
|
- stripping ANSI fichier ;
|
||||||
|
- counters par `output_id` après retrait des sinks ;
|
||||||
|
- hot reload concurrent existant ;
|
||||||
|
- impossibilité d'une seconde initialisation globale.
|
||||||
|
|
||||||
|
## Plan de release regranularisé
|
||||||
|
|
||||||
|
La scission explicite décale le reste du cycle :
|
||||||
|
|
||||||
|
```text
|
||||||
|
pre.005 Logging : runtime multi-sink + level/target/formats
|
||||||
|
pre.006 Logging : routing structuré domain
|
||||||
|
pre.007 JSON/JSON Schema + std.logging.json
|
||||||
|
pre.008 globals + profils + default_profile
|
||||||
|
pre.009 compositions génériques par file_id
|
||||||
|
pre.010 .env + process env + resolver ${...}
|
||||||
|
pre.011 sensibilité + real/safe/provenance
|
||||||
|
pre.012 adapter Config -> Logging
|
||||||
|
pre.013 management + persistence JSON/.env
|
||||||
|
pre.014 ownership audits + robustesse
|
||||||
|
pre.015 clôture
|
||||||
|
```
|
||||||
|
|
||||||
|
Le schema `std.logging.schema.json` n'est donc pas figé avant validation du routing domain.
|
||||||
|
|
||||||
|
## Version technique
|
||||||
|
|
||||||
|
La prerelease devient :
|
||||||
|
|
||||||
|
```text
|
||||||
|
workspace.package.version = "0.1.3-pre.5"
|
||||||
|
```
|
||||||
|
|
||||||
|
Le manifest racine devient :
|
||||||
|
|
||||||
|
```text
|
||||||
|
# version: 45
|
||||||
|
```
|
||||||
|
|
||||||
|
## Fichier ajouté
|
||||||
|
|
||||||
|
```text
|
||||||
|
deltas/0.1.3/pre.005.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## Fichiers modifiés
|
||||||
|
|
||||||
|
```text
|
||||||
|
Cargo.toml
|
||||||
|
crates/ksp-logging-lib/README.md
|
||||||
|
crates/ksp-logging-lib/TODO.md
|
||||||
|
crates/ksp-logging-lib/USAGE.md
|
||||||
|
crates/ksp-logging-lib/src/lib.rs
|
||||||
|
crates/ksp-logging-lib/src/runtime.rs
|
||||||
|
crates/ksp-logging-lib/src/settings.rs
|
||||||
|
crates/ksp-logging-lib/src/writer.rs
|
||||||
|
crates/ksp-logging-lib/tests/public_api.rs
|
||||||
|
crates/ksp-logging-lib/tests/runtime.rs
|
||||||
|
crates/ksp-logging-lib/unit_tests/runtime.rs
|
||||||
|
crates/ksp-logging-lib/unit_tests/settings.rs
|
||||||
|
crates/ksp-logging-lib/unit_tests/writer.rs
|
||||||
|
docs/plans/000-README.md
|
||||||
|
docs/plans/002-FUNCTIONAL_RELEASE_SEQUENCE.md
|
||||||
|
docs/plans/005-V0_1_3_CONFIG_FOUNDATION_PLAN.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## Hors scope confirmé
|
||||||
|
|
||||||
|
Cette tranche n'implémente pas :
|
||||||
|
|
||||||
|
- le routing runtime par champ structuré `domain` ;
|
||||||
|
- JSON Config ou JSON Schema ;
|
||||||
|
- `std.logging.json` ;
|
||||||
|
- profils/composites Config ;
|
||||||
|
- `.env` ou interpolation ;
|
||||||
|
- secrets/redaction Config ;
|
||||||
|
- modification de `ksp-config-lib` ;
|
||||||
|
- rotation par taille/rétention/compression.
|
||||||
|
|
||||||
|
## Validations à exécuter par l'utilisateur
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo fmt --all
|
||||||
|
cargo check --workspace
|
||||||
|
cargo clippy --workspace --all-targets
|
||||||
|
cargo test --workspace
|
||||||
|
cargo tree -p ksp-logging-lib
|
||||||
|
cargo tree -p ksp-logging-lib -d
|
||||||
|
cargo tree -p ksp-logging-lib -e features
|
||||||
|
```
|
||||||
|
|
||||||
|
Les commandes Cargo ne sont pas déclarées réussies dans ce delta tant qu'elles n'ont pas été exécutées sur l'environnement utilisateur.
|
||||||
|
|
||||||
|
## Suite
|
||||||
|
|
||||||
|
Après validation de cette tranche :
|
||||||
|
|
||||||
|
```text
|
||||||
|
0.1.3-pre.006 — ksp-logging-lib : routing structuré domain
|
||||||
|
```
|
||||||
|
|
||||||
|
Cette tranche devra fermer la dernière dimension du contrat `OutputFilter` avant l'introduction de `std.logging.schema.json`.
|
||||||
226
deltas/0.1.3/pre.006.md
Normal file
226
deltas/0.1.3/pre.006.md
Normal file
@@ -0,0 +1,226 @@
|
|||||||
|
<!-- file: deltas/0.1.3/pre.006.md -->
|
||||||
|
<!-- version: 1 -->
|
||||||
|
|
||||||
|
# Delta 0.1.3-pre.006
|
||||||
|
|
||||||
|
## Base requise
|
||||||
|
|
||||||
|
Livraison précédente validée :
|
||||||
|
|
||||||
|
```text
|
||||||
|
0.1.3-pre.005-fix.001
|
||||||
|
```
|
||||||
|
|
||||||
|
Version technique de cette base :
|
||||||
|
|
||||||
|
```text
|
||||||
|
workspace.package.version = "0.1.3-pre.5.fix.1"
|
||||||
|
Cargo.toml header version = 46
|
||||||
|
```
|
||||||
|
|
||||||
|
Validations utilisateur exécutées le 2026-08-15 :
|
||||||
|
|
||||||
|
```text
|
||||||
|
cargo fmt --all OK
|
||||||
|
cargo check --workspace OK
|
||||||
|
cargo clippy --workspace --all-targets OK
|
||||||
|
cargo test --workspace OK
|
||||||
|
cargo tree -p ksp-logging-lib OK
|
||||||
|
cargo tree -p ksp-logging-lib -d OK — aucune duplication
|
||||||
|
cargo tree -p ksp-logging-lib -e features OK
|
||||||
|
```
|
||||||
|
|
||||||
|
Le fix JSON de `pre.005` est donc clos avant ouverture de cette tranche.
|
||||||
|
|
||||||
|
## Objet de pre.006
|
||||||
|
|
||||||
|
Fermer la troisième dimension de routing déjà représentée par `OutputFilter` :
|
||||||
|
|
||||||
|
```text
|
||||||
|
level
|
||||||
|
target
|
||||||
|
domain
|
||||||
|
```
|
||||||
|
|
||||||
|
`domain` reste un champ structuré indépendant du target. Cette tranche ne change pas les contrats Config et ne crée encore aucun document JSON Config.
|
||||||
|
|
||||||
|
## Domain effectif
|
||||||
|
|
||||||
|
Le runtime applique la sémantique suivante :
|
||||||
|
|
||||||
|
1. un event portant directement `domain` utilise cette valeur ;
|
||||||
|
2. sinon l'event hérite du domain effectif de son span ;
|
||||||
|
3. un span portant directement `domain` définit son domain effectif ;
|
||||||
|
4. un span sans `domain` hérite du domain effectif de son parent au moment de sa création ;
|
||||||
|
5. un span enfant explicite peut donc remplacer le domain hérité ;
|
||||||
|
6. les lifecycle events `NEW`, `ENTER`, `EXIT` et `CLOSE` utilisent le domain effectif du span concerné.
|
||||||
|
|
||||||
|
Un domain direct d'event ne modifie pas le domain mémorisé du span.
|
||||||
|
|
||||||
|
## Routing par output
|
||||||
|
|
||||||
|
Le writer routé applique maintenant conjointement :
|
||||||
|
|
||||||
|
```text
|
||||||
|
OutputFilter.level
|
||||||
|
OutputFilter.targets[]
|
||||||
|
OutputFilter.domains[]
|
||||||
|
```
|
||||||
|
|
||||||
|
Pour `domains[]` :
|
||||||
|
|
||||||
|
- `["*"]` signifie aucune restriction et accepte aussi un event/span sans domain ;
|
||||||
|
- un selector nommé correspond par préfixe ;
|
||||||
|
- un selector nommé ne correspond jamais à une entrée sans domain.
|
||||||
|
|
||||||
|
La politique globale existante reste distincte :
|
||||||
|
|
||||||
|
```text
|
||||||
|
default_filter + TargetFilter[]
|
||||||
|
↓
|
||||||
|
DomainContextLayer + groupe de sinks
|
||||||
|
↓
|
||||||
|
OutputFilter level/target/domain de chaque sink
|
||||||
|
```
|
||||||
|
|
||||||
|
Aucune convention de pseudo-target domain n'est introduite.
|
||||||
|
|
||||||
|
## Couche interne de contexte
|
||||||
|
|
||||||
|
`DomainContextLayer` est une couche privée de `ksp-logging-lib` installée avant les formatters lorsque le runtime possède au moins un output actif.
|
||||||
|
|
||||||
|
Elle :
|
||||||
|
|
||||||
|
- capture le champ `domain` des events et spans ;
|
||||||
|
- conserve le domain effectif d'un span dans ses extensions runtime ;
|
||||||
|
- expose uniquement au routing interne le domain effectif correspondant au callback en cours ;
|
||||||
|
- conserve les champs originaux pour les formatters Human/Compact/Pretty/Json ;
|
||||||
|
- n'ajoute aucun type public et ne change pas la façade consommateur.
|
||||||
|
|
||||||
|
Le contexte courant utilisé par le routing reste interne au thread de dispatch Logging et n'est pas une variable globale Config/applicative.
|
||||||
|
|
||||||
|
## Runtime préservé
|
||||||
|
|
||||||
|
Cette tranche conserve sans changement de contrat :
|
||||||
|
|
||||||
|
- subscriber global unique ;
|
||||||
|
- takeover des targets externes ;
|
||||||
|
- multi-sink ;
|
||||||
|
- formats Human/Compact/Pretty/Json ;
|
||||||
|
- ANSI console et stripping fichier ;
|
||||||
|
- writers non bloquants ;
|
||||||
|
- guards et compteurs par `output_id` ;
|
||||||
|
- hot reload transactionnel ;
|
||||||
|
- comportement de saturation.
|
||||||
|
|
||||||
|
Le refus transitoire `domains != ["*"]` introduit en `pre.005` est supprimé puisque la dimension domain est maintenant exécutée réellement.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
Les tests unitaires couvrent :
|
||||||
|
|
||||||
|
- wildcard domain avec ou sans domain courant ;
|
||||||
|
- selector nommé absent sans domain ;
|
||||||
|
- correspondance par préfixe ;
|
||||||
|
- acceptation runtime d'un filtre domain spécifique.
|
||||||
|
|
||||||
|
Le test d'intégration global ajoute trois sinks simultanés :
|
||||||
|
|
||||||
|
```text
|
||||||
|
file.domain.logging -> domains = ["logging"]
|
||||||
|
file.domain.store -> domains = ["store"]
|
||||||
|
file.domain.any -> domains = ["*"]
|
||||||
|
```
|
||||||
|
|
||||||
|
Il couvre :
|
||||||
|
|
||||||
|
- domain direct d'event ;
|
||||||
|
- event sans domain ;
|
||||||
|
- héritage depuis un span parent ;
|
||||||
|
- override direct d'event ;
|
||||||
|
- enfant sans domain ;
|
||||||
|
- enfant avec domain explicite ;
|
||||||
|
- lifecycle de spans Logging/Store ;
|
||||||
|
- maintien des compteurs à zéro dans le scénario nominal.
|
||||||
|
|
||||||
|
## Dépendances
|
||||||
|
|
||||||
|
Aucune nouvelle dépendance et aucune nouvelle feature Cargo ne sont ajoutées.
|
||||||
|
|
||||||
|
`ksp-logging-lib` reste le seul propriétaire direct de la stack `tracing*` et ne dépend toujours pas de `ksp-config-lib`.
|
||||||
|
|
||||||
|
## Plan
|
||||||
|
|
||||||
|
Les trois tranches Logging nécessaires avant le premier schema Config sont désormais fonctionnellement couvertes :
|
||||||
|
|
||||||
|
```text
|
||||||
|
pre.004 contrats/settings multi-output
|
||||||
|
pre.005 runtime multi-sink + level/target/formats
|
||||||
|
pre.006 routing structuré domain
|
||||||
|
```
|
||||||
|
|
||||||
|
Après validation utilisateur de `pre.006`, la prochaine tranche est :
|
||||||
|
|
||||||
|
```text
|
||||||
|
pre.007 JSON/JSON Schema + std.logging.json
|
||||||
|
```
|
||||||
|
|
||||||
|
## Version technique
|
||||||
|
|
||||||
|
La prerelease devient :
|
||||||
|
|
||||||
|
```text
|
||||||
|
workspace.package.version = "0.1.3-pre.6"
|
||||||
|
Cargo.toml header version = 47
|
||||||
|
```
|
||||||
|
|
||||||
|
## Fichiers ajoutés
|
||||||
|
|
||||||
|
```text
|
||||||
|
crates/ksp-logging-lib/src/domain.rs
|
||||||
|
crates/ksp-logging-lib/unit_tests/domain.rs
|
||||||
|
deltas/0.1.3/pre.006.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## Fichiers modifiés
|
||||||
|
|
||||||
|
```text
|
||||||
|
Cargo.toml
|
||||||
|
crates/ksp-logging-lib/README.md
|
||||||
|
crates/ksp-logging-lib/TODO.md
|
||||||
|
crates/ksp-logging-lib/USAGE.md
|
||||||
|
crates/ksp-logging-lib/src/lib.rs
|
||||||
|
crates/ksp-logging-lib/src/runtime.rs
|
||||||
|
crates/ksp-logging-lib/src/writer.rs
|
||||||
|
crates/ksp-logging-lib/tests/runtime.rs
|
||||||
|
crates/ksp-logging-lib/unit_tests/runtime.rs
|
||||||
|
docs/plans/000-README.md
|
||||||
|
docs/plans/002-FUNCTIONAL_RELEASE_SEQUENCE.md
|
||||||
|
docs/plans/005-V0_1_3_CONFIG_FOUNDATION_PLAN.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## Hors scope
|
||||||
|
|
||||||
|
Cette tranche ne modifie pas :
|
||||||
|
|
||||||
|
- `ksp-config-lib` ;
|
||||||
|
- le registre `file_id` ;
|
||||||
|
- JSON/JSON Schema ;
|
||||||
|
- `std.logging.json` ;
|
||||||
|
- profils/composites ;
|
||||||
|
- `.env` ou les variables d'environnement ;
|
||||||
|
- persistence Config.
|
||||||
|
|
||||||
|
## Validations à exécuter
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo fmt --all
|
||||||
|
cargo check --workspace
|
||||||
|
cargo clippy --workspace --all-targets
|
||||||
|
cargo test --workspace
|
||||||
|
cargo tree -p ksp-logging-lib
|
||||||
|
cargo tree -p ksp-logging-lib -d
|
||||||
|
cargo tree -p ksp-logging-lib -e features
|
||||||
|
```
|
||||||
|
|
||||||
|
Aucune validation Cargo n'est déclarée réussie tant qu'elle n'a pas été exécutée sur le workspace utilisateur.
|
||||||
347
deltas/0.1.3/pre.007.md
Normal file
347
deltas/0.1.3/pre.007.md
Normal file
@@ -0,0 +1,347 @@
|
|||||||
|
<!-- file: deltas/0.1.3/pre.007.md -->
|
||||||
|
<!-- version: 1 -->
|
||||||
|
|
||||||
|
# Delta 0.1.3-pre.007
|
||||||
|
|
||||||
|
## Base requise
|
||||||
|
|
||||||
|
Livraison précédente validée :
|
||||||
|
|
||||||
|
```text
|
||||||
|
0.1.3-pre.006
|
||||||
|
```
|
||||||
|
|
||||||
|
Version technique de cette base :
|
||||||
|
|
||||||
|
```text
|
||||||
|
workspace.package.version = "0.1.3-pre.6"
|
||||||
|
Cargo.toml header version = 47
|
||||||
|
```
|
||||||
|
|
||||||
|
Validations utilisateur exécutées le 2026-08-15 :
|
||||||
|
|
||||||
|
```text
|
||||||
|
cargo fmt --all OK
|
||||||
|
cargo check --workspace OK
|
||||||
|
cargo clippy --workspace --all-targets OK
|
||||||
|
cargo test --workspace OK
|
||||||
|
cargo tree -p ksp-logging-lib OK
|
||||||
|
cargo tree -p ksp-logging-lib -d OK — aucune duplication
|
||||||
|
cargo tree -p ksp-logging-lib -e features OK
|
||||||
|
```
|
||||||
|
|
||||||
|
Les tranches Logging préalables au premier schema Config sont donc closes.
|
||||||
|
|
||||||
|
## Objet de pre.007
|
||||||
|
|
||||||
|
Introduire la première surface JSON/JSON Schema réelle de `ksp-config-lib` sans ouvrir encore la résolution de profils, les composites ou l'environnement :
|
||||||
|
|
||||||
|
```text
|
||||||
|
registry file_id
|
||||||
|
-> resolved Config path
|
||||||
|
-> JSON parse
|
||||||
|
-> registered schema file_id
|
||||||
|
-> schema parse + meta-schema validation
|
||||||
|
-> document validation
|
||||||
|
-> bounded document semantics
|
||||||
|
```
|
||||||
|
|
||||||
|
Le premier document concret est :
|
||||||
|
|
||||||
|
```text
|
||||||
|
cfg.std.logging -> config/std.logging.json
|
||||||
|
```
|
||||||
|
|
||||||
|
validé par :
|
||||||
|
|
||||||
|
```text
|
||||||
|
schema.std.logging -> config/schemas/std.logging.schema.json
|
||||||
|
```
|
||||||
|
|
||||||
|
## Dépendances
|
||||||
|
|
||||||
|
Versions vérifiées au moment de l'ajout depuis les sources officielles `crates.io` / `docs.rs` :
|
||||||
|
|
||||||
|
```text
|
||||||
|
serde 1.0.229 -> workspace constraint ^1.0
|
||||||
|
serde_json 1.0.151 -> workspace constraint ^1.0
|
||||||
|
jsonschema 0.49.9 -> workspace constraint ^0.49
|
||||||
|
```
|
||||||
|
|
||||||
|
`jsonschema` est déclaré avec :
|
||||||
|
|
||||||
|
```text
|
||||||
|
default-features = false
|
||||||
|
```
|
||||||
|
|
||||||
|
La première surface KSP utilise un schema autonome avec références JSON Pointer internes uniquement ; aucune récupération HTTP/file de schemas distants n'est nécessaire.
|
||||||
|
|
||||||
|
Les trois dépendances sont déclarées uniquement sous `[workspace.dependencies]`, puis consommées par `ksp-config-lib` avec `.workspace = true`.
|
||||||
|
|
||||||
|
## Association document -> schema
|
||||||
|
|
||||||
|
`ConfigFileDescriptor` possède maintenant :
|
||||||
|
|
||||||
|
```text
|
||||||
|
schema_file_id: Option<ConfigFileId>
|
||||||
|
```
|
||||||
|
|
||||||
|
Le mapping par défaut devient conceptuellement :
|
||||||
|
|
||||||
|
```text
|
||||||
|
cfg.std.logging
|
||||||
|
kind = Config
|
||||||
|
filename = std.logging.json
|
||||||
|
schema_file_id = schema.std.logging
|
||||||
|
|
||||||
|
schema.std.logging
|
||||||
|
kind = Schema
|
||||||
|
filename = std.logging.schema.json
|
||||||
|
schema_file_id = none
|
||||||
|
```
|
||||||
|
|
||||||
|
Le registre valide :
|
||||||
|
|
||||||
|
- qu'un schema référencé utilise le namespace `schema.*` ;
|
||||||
|
- que son descriptor existe réellement ;
|
||||||
|
- qu'il possède `ConfigFileKind::Schema` ;
|
||||||
|
- qu'un descriptor Schema ne référence pas lui-même un schema Config.
|
||||||
|
|
||||||
|
Un `--filemap` change uniquement le filename physique et conserve cette association logique.
|
||||||
|
|
||||||
|
Aucun `schema.composite` n'est encore créé : il sera ajouté avec la tranche composite qui en a réellement besoin.
|
||||||
|
|
||||||
|
## Moteur JSON générique
|
||||||
|
|
||||||
|
Nouvelle façade :
|
||||||
|
|
||||||
|
```text
|
||||||
|
ConfigDocumentEngine
|
||||||
|
ConfigJsonDocument
|
||||||
|
```
|
||||||
|
|
||||||
|
`ConfigDocumentEngine::load_validated_document(file_id)` :
|
||||||
|
|
||||||
|
1. vérifie que le `file_id` désigne un document Config ;
|
||||||
|
2. résout son path via `ConfigBootstrapOptions` + `ConfigFileRegistry` ;
|
||||||
|
3. lit et parse le JSON ;
|
||||||
|
4. charge le schema associé par son propre `file_id` ;
|
||||||
|
5. valide le schema contre son meta-schema ;
|
||||||
|
6. valide le document contre le schema Draft 2020-12 ;
|
||||||
|
7. exécute les invariants sémantiques actuellement connus pour ce type de document ;
|
||||||
|
8. retourne le JSON validé sans transférer aux consumers la responsabilité de lecture/validation filesystem.
|
||||||
|
|
||||||
|
`ConfigJsonDocument` expose uniquement :
|
||||||
|
|
||||||
|
```text
|
||||||
|
file_id
|
||||||
|
resolved path
|
||||||
|
validated serde_json::Value
|
||||||
|
```
|
||||||
|
|
||||||
|
Les autres crates restent interdites de lecture directe des fichiers Config.
|
||||||
|
|
||||||
|
## Diagnostics
|
||||||
|
|
||||||
|
Les nouveaux codes Config distinguent :
|
||||||
|
|
||||||
|
```text
|
||||||
|
config.json_file_read_failed
|
||||||
|
config.json_syntax_invalid
|
||||||
|
config.schema_invalid
|
||||||
|
config.schema_validation_failed
|
||||||
|
config.document_semantic_invalid
|
||||||
|
```
|
||||||
|
|
||||||
|
Les erreurs enregistrent le `file_id` et le path concernés, mais jamais le contenu JSON complet.
|
||||||
|
|
||||||
|
## Premier schema Logging
|
||||||
|
|
||||||
|
`config/schemas/std.logging.schema.json` utilise JSON Schema Draft 2020-12 et couvre la surface Logging stabilisée en `pre.004/.005/.006` :
|
||||||
|
|
||||||
|
```text
|
||||||
|
format_version
|
||||||
|
logs_directory
|
||||||
|
default_profile
|
||||||
|
profiles[]
|
||||||
|
profile_id
|
||||||
|
default_filter
|
||||||
|
span_events
|
||||||
|
console
|
||||||
|
enabled
|
||||||
|
output
|
||||||
|
ansi
|
||||||
|
format
|
||||||
|
filter.level
|
||||||
|
filter.targets[]
|
||||||
|
filter.domains[]
|
||||||
|
files[]
|
||||||
|
output_id
|
||||||
|
enabled
|
||||||
|
path
|
||||||
|
rotation
|
||||||
|
format
|
||||||
|
ansi = false
|
||||||
|
filter.level
|
||||||
|
filter.targets[]
|
||||||
|
filter.domains[]
|
||||||
|
target_filters[]
|
||||||
|
```
|
||||||
|
|
||||||
|
Le schema encode notamment :
|
||||||
|
|
||||||
|
- niveaux `off/error/warn/info/debug/trace` ;
|
||||||
|
- lifecycle `off/new_and_close/full` ;
|
||||||
|
- formats `human/compact/pretty/json` ;
|
||||||
|
- rotation `never/hourly/daily` ;
|
||||||
|
- console `stdout/stderr` ;
|
||||||
|
- targets KSP ou wildcard `*` ;
|
||||||
|
- selectors uniques, wildcard utilisé seul ;
|
||||||
|
- ANSI interdit pour les fichiers ;
|
||||||
|
- ANSI + JSON console interdit.
|
||||||
|
|
||||||
|
## Document runtime et exemple
|
||||||
|
|
||||||
|
Ajouts :
|
||||||
|
|
||||||
|
```text
|
||||||
|
config/std.logging.json
|
||||||
|
config/schemas/std.logging.schema.json
|
||||||
|
config/examples/std.logging.example.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Le runtime utilise déjà :
|
||||||
|
|
||||||
|
```text
|
||||||
|
"logs_directory": "${KSP_LOGS_DIRECTORY:-logs}"
|
||||||
|
```
|
||||||
|
|
||||||
|
mais `pre.007` ne résout encore aucune variable. Le placeholder reste une valeur source string valide jusqu'au resolver de `pre.010`.
|
||||||
|
|
||||||
|
Le runtime démontre :
|
||||||
|
|
||||||
|
- console configurable ;
|
||||||
|
- plusieurs fichiers ;
|
||||||
|
- formats humain et JSON ;
|
||||||
|
- filtres par level/target/domain ;
|
||||||
|
- target overrides globaux ;
|
||||||
|
- `output_id` distinct du `file_id` Config.
|
||||||
|
|
||||||
|
## Validation sémantique de base
|
||||||
|
|
||||||
|
Après JSON Schema, Config vérifie déjà les invariants directement liés à la surface Logging :
|
||||||
|
|
||||||
|
- `format_version = 1` ;
|
||||||
|
- chaînes globales requises non vides ;
|
||||||
|
- `output_id` fichier conforme ;
|
||||||
|
- `output_id` uniques dans un même profil ;
|
||||||
|
- path fichier relatif à `logs_directory`, sans traversal ;
|
||||||
|
- ANSI fichier interdit ;
|
||||||
|
- ANSI + JSON console interdit ;
|
||||||
|
- selectors non vides/uniques ;
|
||||||
|
- wildcard seul ;
|
||||||
|
- target selector et global `target_prefix` limités aux targets `ksp-*`.
|
||||||
|
|
||||||
|
Ne sont volontairement **pas encore** traités dans cette tranche :
|
||||||
|
|
||||||
|
- unicité des `profile_id` ;
|
||||||
|
- résolution de `default_profile` ;
|
||||||
|
- sélection explicite d'un profil ;
|
||||||
|
- construction d'une configuration effective globals + profil.
|
||||||
|
|
||||||
|
Ces responsabilités appartiennent à `pre.008`.
|
||||||
|
|
||||||
|
## Tests ajoutés
|
||||||
|
|
||||||
|
Les tests unitaires Config couvrent :
|
||||||
|
|
||||||
|
- document Logging runtime commité valide ;
|
||||||
|
- fichier Config absent ;
|
||||||
|
- syntaxe JSON invalide ;
|
||||||
|
- schema lui-même invalide ;
|
||||||
|
- document ne satisfaisant pas son schema ;
|
||||||
|
- document schema-valide mais sémantiquement invalide ;
|
||||||
|
- association document -> schema présente dans le registre ;
|
||||||
|
- association vers schema absent refusée.
|
||||||
|
|
||||||
|
Le test d'API publique vérifie que `ConfigDocumentEngine` charge le vrai `std.logging.json` depuis les racines Config du workspace.
|
||||||
|
|
||||||
|
## Hors scope
|
||||||
|
|
||||||
|
Cette tranche ne fait pas encore :
|
||||||
|
|
||||||
|
- résolution globals/profils/`default_profile` ;
|
||||||
|
- composite ;
|
||||||
|
- `.env` ;
|
||||||
|
- `std::env` ;
|
||||||
|
- interpolation `${...}` ;
|
||||||
|
- classification secret/public/internal ;
|
||||||
|
- redaction ;
|
||||||
|
- adaptation vers `ksp_logging_lib::LoggingSettings` ;
|
||||||
|
- mutation/persistence.
|
||||||
|
|
||||||
|
`ksp-config-lib` ne dépend donc toujours pas de `ksp-logging-lib` dans `pre.007`.
|
||||||
|
|
||||||
|
## Version technique
|
||||||
|
|
||||||
|
La prerelease devient :
|
||||||
|
|
||||||
|
```text
|
||||||
|
workspace.package.version = "0.1.3-pre.7"
|
||||||
|
Cargo.toml header version = 48
|
||||||
|
```
|
||||||
|
|
||||||
|
## Fichiers ajoutés
|
||||||
|
|
||||||
|
```text
|
||||||
|
config/std.logging.json
|
||||||
|
config/schemas/std.logging.schema.json
|
||||||
|
config/examples/std.logging.example.json
|
||||||
|
crates/ksp-config-lib/src/document.rs
|
||||||
|
crates/ksp-config-lib/unit_tests/document.rs
|
||||||
|
deltas/0.1.3/pre.007.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## Fichiers modifiés
|
||||||
|
|
||||||
|
```text
|
||||||
|
Cargo.toml
|
||||||
|
crates/ksp-config-lib/Cargo.toml
|
||||||
|
crates/ksp-config-lib/src/error.rs
|
||||||
|
crates/ksp-config-lib/src/lib.rs
|
||||||
|
crates/ksp-config-lib/src/registry.rs
|
||||||
|
crates/ksp-config-lib/tests/public_api.rs
|
||||||
|
crates/ksp-config-lib/unit_tests/registry.rs
|
||||||
|
docs/plans/000-README.md
|
||||||
|
docs/plans/002-FUNCTIONAL_RELEASE_SEQUENCE.md
|
||||||
|
docs/plans/005-V0_1_3_CONFIG_FOUNDATION_PLAN.md
|
||||||
|
docs/rules/FILE_CONTRACTS.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## Contrôles exécutés dans l'environnement de génération
|
||||||
|
|
||||||
|
Les fichiers JSON ont été parsés avec `python -m json.tool`.
|
||||||
|
|
||||||
|
Le schema Draft 2020-12 et le document runtime ont également été validés avec l'implémentation Python `jsonschema` disponible dans l'environnement de génération.
|
||||||
|
|
||||||
|
Aucune commande Cargo n'est disponible dans cet environnement ; aucune validation Rust n'est donc déclarée réussie ici.
|
||||||
|
|
||||||
|
## Validations utilisateur à exécuter
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo fmt --all
|
||||||
|
cargo check --workspace
|
||||||
|
cargo clippy --workspace --all-targets
|
||||||
|
cargo test --workspace
|
||||||
|
cargo tree -p ksp-config-lib
|
||||||
|
cargo tree -p ksp-config-lib -d
|
||||||
|
cargo tree -p ksp-config-lib -e features
|
||||||
|
```
|
||||||
|
|
||||||
|
Une attention particulière doit être portée au graphe `jsonschema` avec `default-features = false` afin de confirmer qu'aucun stack HTTP/TLS de résolution distante n'est introduit inutilement.
|
||||||
|
|
||||||
|
Après validation de `pre.007`, la prochaine tranche est :
|
||||||
|
|
||||||
|
```text
|
||||||
|
0.1.3-pre.008 — globals + profils + default_profile
|
||||||
|
```
|
||||||
268
deltas/0.1.3/pre.008.md
Normal file
268
deltas/0.1.3/pre.008.md
Normal file
@@ -0,0 +1,268 @@
|
|||||||
|
<!-- file: deltas/0.1.3/pre.008.md -->
|
||||||
|
<!-- version: 1 -->
|
||||||
|
|
||||||
|
# Delta 0.1.3-pre.008
|
||||||
|
|
||||||
|
## Base requise
|
||||||
|
|
||||||
|
Livraison précédente validée :
|
||||||
|
|
||||||
|
```text
|
||||||
|
0.1.3-pre.007
|
||||||
|
```
|
||||||
|
|
||||||
|
Version technique de cette base :
|
||||||
|
|
||||||
|
```text
|
||||||
|
workspace.package.version = "0.1.3-pre.7"
|
||||||
|
Cargo.toml header version = 48
|
||||||
|
```
|
||||||
|
|
||||||
|
Validations utilisateur exécutées le 2026-08-15 :
|
||||||
|
|
||||||
|
```text
|
||||||
|
cargo fmt --all OK
|
||||||
|
cargo check --workspace OK
|
||||||
|
cargo clippy --workspace --all-targets OK
|
||||||
|
cargo test --workspace OK
|
||||||
|
cargo tree -p ksp-config-lib OK
|
||||||
|
cargo tree -p ksp-config-lib -e features OK
|
||||||
|
```
|
||||||
|
|
||||||
|
`cargo tree -p ksp-config-lib -d` ne signale qu'une coexistence transitive `syn 2.0.119` / `syn 3.0.3`, portée par l'écosystème `jsonschema`/ICU/proc-macros. Le graphe observé ne contient pas de stack HTTP/TLS de résolution distante avec `jsonschema default-features = false`; aucun changement de dépendance n'est donc justifié dans cette tranche.
|
||||||
|
|
||||||
|
## Objet de pre.008
|
||||||
|
|
||||||
|
Fermer la résolution des paramètres globaux, profils et `default_profile` avant d'introduire les composites :
|
||||||
|
|
||||||
|
```text
|
||||||
|
validated standard document
|
||||||
|
-> validate profile identities/default_profile
|
||||||
|
-> select default or explicit profile
|
||||||
|
-> retain globals
|
||||||
|
-> retain selected profile
|
||||||
|
-> build effective top-level view
|
||||||
|
-> preserve Global/Profile provenance
|
||||||
|
```
|
||||||
|
|
||||||
|
Cette tranche reste générique : le moteur de profils n'est pas un type Logging. `std.logging.json` est uniquement le premier document standard qui l'exerce.
|
||||||
|
|
||||||
|
## Contrat générique des profils
|
||||||
|
|
||||||
|
Un document standard profilé possède deux clés structurelles réservées :
|
||||||
|
|
||||||
|
```text
|
||||||
|
default_profile
|
||||||
|
profiles
|
||||||
|
```
|
||||||
|
|
||||||
|
Toutes les autres propriétés top-level sont considérées comme des paramètres globaux du document.
|
||||||
|
|
||||||
|
Garanties désormais validées après JSON Schema :
|
||||||
|
|
||||||
|
- `profiles` contient au moins un objet ;
|
||||||
|
- chaque profil possède un `profile_id` non vide ;
|
||||||
|
- les `profile_id` sont uniques dans le document ;
|
||||||
|
- `default_profile` est autonome au niveau global ;
|
||||||
|
- `default_profile` référence obligatoirement un `profile_id` existant.
|
||||||
|
|
||||||
|
Un document qui viole ces invariants retourne :
|
||||||
|
|
||||||
|
```text
|
||||||
|
config.document_semantic_invalid
|
||||||
|
```
|
||||||
|
|
||||||
|
## Résolution publique
|
||||||
|
|
||||||
|
`ConfigDocumentEngine` expose maintenant :
|
||||||
|
|
||||||
|
```text
|
||||||
|
load_resolved_profile(file_id, None)
|
||||||
|
-> sélectionne default_profile
|
||||||
|
|
||||||
|
load_resolved_profile(file_id, Some(profile_id))
|
||||||
|
-> sélection explicite
|
||||||
|
```
|
||||||
|
|
||||||
|
Un profil explicitement demandé mais absent retourne un diagnostic distinct :
|
||||||
|
|
||||||
|
```text
|
||||||
|
config.profile_not_found
|
||||||
|
```
|
||||||
|
|
||||||
|
Cette erreur ne signifie pas que le document source est invalide : le document peut être parfaitement valide tout en ne contenant pas le profil demandé par le caller.
|
||||||
|
|
||||||
|
## `ResolvedConfigProfile`
|
||||||
|
|
||||||
|
La nouvelle surface publique conserve :
|
||||||
|
|
||||||
|
```text
|
||||||
|
file_id
|
||||||
|
source path
|
||||||
|
profile_id sélectionné
|
||||||
|
selection_source = DefaultProfile | Explicit
|
||||||
|
globals
|
||||||
|
profile
|
||||||
|
effective
|
||||||
|
origin(key) = Global | Profile
|
||||||
|
```
|
||||||
|
|
||||||
|
`globals` exclut volontairement :
|
||||||
|
|
||||||
|
```text
|
||||||
|
default_profile
|
||||||
|
profiles
|
||||||
|
```
|
||||||
|
|
||||||
|
`profile` conserve l'objet du profil sélectionné, y compris son `profile_id`.
|
||||||
|
|
||||||
|
`effective` est une vue top-level déterministe :
|
||||||
|
|
||||||
|
1. insertion des globals ;
|
||||||
|
2. insertion des propriétés du profil sélectionné.
|
||||||
|
|
||||||
|
Si un futur schema permet une même clé aux deux niveaux, la valeur du profil est prioritaire et sa provenance devient `Profile`. Les schemas spécialisés restent libres d'interdire ce cas lorsqu'une propriété doit rester strictement globale.
|
||||||
|
|
||||||
|
La provenance documentaire commune reste disponible par :
|
||||||
|
|
||||||
|
```text
|
||||||
|
file_id
|
||||||
|
path
|
||||||
|
```
|
||||||
|
|
||||||
|
et la provenance de chaque valeur top-level par :
|
||||||
|
|
||||||
|
```text
|
||||||
|
ConfigValueOrigin::Global
|
||||||
|
ConfigValueOrigin::Profile
|
||||||
|
```
|
||||||
|
|
||||||
|
Les futures couches composite/env/sensibilité devront enrichir cette provenance sans la perdre.
|
||||||
|
|
||||||
|
## `std.logging.json`
|
||||||
|
|
||||||
|
Le fichier runtime et son schema ne nécessitent aucun changement dans cette tranche.
|
||||||
|
|
||||||
|
Le document commité :
|
||||||
|
|
||||||
|
```text
|
||||||
|
default_profile = local_dev
|
||||||
|
```
|
||||||
|
|
||||||
|
résout maintenant réellement le profil `local_dev`.
|
||||||
|
|
||||||
|
Exemples de provenance :
|
||||||
|
|
||||||
|
```text
|
||||||
|
logs_directory -> Global
|
||||||
|
default_filter -> Profile
|
||||||
|
```
|
||||||
|
|
||||||
|
Le placeholder :
|
||||||
|
|
||||||
|
```text
|
||||||
|
${KSP_LOGS_DIRECTORY:-logs}
|
||||||
|
```
|
||||||
|
|
||||||
|
reste non interprété en `pre.008`; il sera résolu par la tranche environnement prévue plus tard.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
Les tests ajoutés couvrent :
|
||||||
|
|
||||||
|
- résolution du `default_profile` commité ;
|
||||||
|
- sélection explicite d'un profil existant ;
|
||||||
|
- distinction `DefaultProfile` / `Explicit` ;
|
||||||
|
- conservation séparée des globals et du profil ;
|
||||||
|
- construction de la vue effective ;
|
||||||
|
- provenance `Global` / `Profile` ;
|
||||||
|
- profil explicite inconnu -> `config.profile_not_found` ;
|
||||||
|
- `profile_id` dupliqués -> document sémantiquement invalide ;
|
||||||
|
- `default_profile` ne référençant aucun profil -> document sémantiquement invalide ;
|
||||||
|
- consommation de la nouvelle surface depuis le crate-root dans le test d'intégration public.
|
||||||
|
|
||||||
|
## Hors scope
|
||||||
|
|
||||||
|
Cette tranche n'introduit pas encore :
|
||||||
|
|
||||||
|
- document composite ;
|
||||||
|
- `schema.composite` ;
|
||||||
|
- override de profil depuis un composite ;
|
||||||
|
- `.env` ;
|
||||||
|
- lecture `std::env` applicative ;
|
||||||
|
- interpolation `${...}` ;
|
||||||
|
- `Public/Internal/Secret` ;
|
||||||
|
- redaction ;
|
||||||
|
- adaptation vers `ksp_logging_lib::LoggingSettings` ;
|
||||||
|
- mutation/persistence.
|
||||||
|
|
||||||
|
## Dépendances
|
||||||
|
|
||||||
|
Aucune dépendance externe ou feature Cargo supplémentaire n'est ajoutée.
|
||||||
|
|
||||||
|
`ksp-config-lib` ne dépend toujours pas de `ksp-logging-lib` dans cette tranche.
|
||||||
|
|
||||||
|
## Version technique
|
||||||
|
|
||||||
|
La prerelease devient :
|
||||||
|
|
||||||
|
```text
|
||||||
|
workspace.package.version = "0.1.3-pre.8"
|
||||||
|
Cargo.toml header version = 49
|
||||||
|
```
|
||||||
|
|
||||||
|
## Fichiers ajoutés
|
||||||
|
|
||||||
|
```text
|
||||||
|
crates/ksp-config-lib/src/profile.rs
|
||||||
|
crates/ksp-config-lib/unit_tests/profile.rs
|
||||||
|
deltas/0.1.3/pre.008.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## Fichiers modifiés
|
||||||
|
|
||||||
|
```text
|
||||||
|
Cargo.toml
|
||||||
|
crates/ksp-config-lib/src/document.rs
|
||||||
|
crates/ksp-config-lib/src/error.rs
|
||||||
|
crates/ksp-config-lib/src/lib.rs
|
||||||
|
crates/ksp-config-lib/tests/public_api.rs
|
||||||
|
crates/ksp-config-lib/unit_tests/document.rs
|
||||||
|
docs/plans/000-README.md
|
||||||
|
docs/plans/002-FUNCTIONAL_RELEASE_SEQUENCE.md
|
||||||
|
docs/plans/005-V0_1_3_CONFIG_FOUNDATION_PLAN.md
|
||||||
|
docs/rules/FILE_CONTRACTS.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## Fichiers supprimés
|
||||||
|
|
||||||
|
Aucun.
|
||||||
|
|
||||||
|
## Contrôles exécutés dans l'environnement de génération
|
||||||
|
|
||||||
|
- comparaison du delta avec la base `pre.007` ;
|
||||||
|
- contrôle des headers/version modifiés ;
|
||||||
|
- contrôle de l'absence de nouvelle dépendance Cargo ;
|
||||||
|
- contrôle statique des interdictions `unsafe`, `unwrap`, `expect`, opérateur `?` dans les nouveaux sources de production ;
|
||||||
|
- contrôle de la composition exacte de l'archive delta ;
|
||||||
|
- réapplication du delta sur une copie de `pre.007` pour vérifier la reproduction de l'arbre livré.
|
||||||
|
|
||||||
|
`cargo`, `rustc` et `rustfmt` ne sont pas disponibles dans l'environnement de génération. Aucune validation Rust n'est déclarée réussie ici.
|
||||||
|
|
||||||
|
## Validations utilisateur à exécuter
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo fmt --all
|
||||||
|
cargo check --workspace
|
||||||
|
cargo clippy --workspace --all-targets
|
||||||
|
cargo test --workspace
|
||||||
|
cargo tree -p ksp-config-lib
|
||||||
|
cargo tree -p ksp-config-lib -d
|
||||||
|
cargo tree -p ksp-config-lib -e features
|
||||||
|
```
|
||||||
|
|
||||||
|
Après validation de `pre.008`, la prochaine tranche planifiée est :
|
||||||
|
|
||||||
|
```text
|
||||||
|
0.1.3-pre.009 — compositions génériques par file_id
|
||||||
|
```
|
||||||
25
deltas/0.1.3/pre.009-fix.001.md
Normal file
25
deltas/0.1.3/pre.009-fix.001.md
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
<!-- file: deltas/0.1.3/pre.009-fix.001.md -->
|
||||||
|
<!-- version: 1 -->
|
||||||
|
|
||||||
|
# Delta 0.1.3-pre.009-fix.001
|
||||||
|
|
||||||
|
## Objectif
|
||||||
|
|
||||||
|
Corriger le warning Clippy `collapsible_if` introduit dans le nettoyage des fixtures composites de `ksp-config-lib`, sans modifier le comportement fonctionnel de Config.
|
||||||
|
|
||||||
|
## Modifications
|
||||||
|
|
||||||
|
- `workspace.package.version` passe de `0.1.3-pre.9` à `0.1.3-pre.9.fix.1` ;
|
||||||
|
- `crates/ksp-config-lib/unit_tests/composite.rs` replie le double `if` de `cleanup_fixture` en une seule condition `if let ... && ...` conforme à Clippy ;
|
||||||
|
- aucun contrat public, schema, fichier Config, dépendance ou comportement runtime n'est modifié.
|
||||||
|
|
||||||
|
## Validation attendue
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo fmt --all
|
||||||
|
cargo check --workspace
|
||||||
|
cargo clippy --workspace --all-targets
|
||||||
|
cargo test --workspace
|
||||||
|
```
|
||||||
|
|
||||||
|
Le résultat attendu est l'absence du warning `clippy::collapsible_if` observé sur `unit_tests/composite.rs`.
|
||||||
307
deltas/0.1.3/pre.009.md
Normal file
307
deltas/0.1.3/pre.009.md
Normal file
@@ -0,0 +1,307 @@
|
|||||||
|
<!-- file: deltas/0.1.3/pre.009.md -->
|
||||||
|
<!-- version: 1 -->
|
||||||
|
|
||||||
|
# Delta 0.1.3-pre.009
|
||||||
|
|
||||||
|
## Base requise
|
||||||
|
|
||||||
|
Livraison précédente validée :
|
||||||
|
|
||||||
|
```text
|
||||||
|
0.1.3-pre.008
|
||||||
|
```
|
||||||
|
|
||||||
|
Version technique de cette base :
|
||||||
|
|
||||||
|
```text
|
||||||
|
workspace.package.version = "0.1.3-pre.8"
|
||||||
|
Cargo.toml header version = 49
|
||||||
|
```
|
||||||
|
|
||||||
|
Validations utilisateur exécutées le 2026-08-15 :
|
||||||
|
|
||||||
|
```text
|
||||||
|
cargo fmt --all OK
|
||||||
|
cargo check --workspace OK
|
||||||
|
cargo clippy --workspace --all-targets OK
|
||||||
|
cargo test --workspace OK
|
||||||
|
cargo tree -p ksp-config-lib OK
|
||||||
|
cargo tree -p ksp-config-lib -e features OK
|
||||||
|
```
|
||||||
|
|
||||||
|
`cargo test --workspace` confirme notamment 33 tests unitaires + 6 tests publics pour `ksp-config-lib`. `cargo tree -p ksp-config-lib -d` ne signale que la coexistence transitive déjà connue `syn 2.0.119` / `syn 3.0.3` portée par l'écosystème `jsonschema`; aucune correction de dépendance KSP n'est justifiée dans cette tranche.
|
||||||
|
|
||||||
|
## Objet de pre.009
|
||||||
|
|
||||||
|
Introduire le contrat de composition générique avant la résolution environnement :
|
||||||
|
|
||||||
|
```text
|
||||||
|
composite file_id
|
||||||
|
-> schema.composite
|
||||||
|
-> composite profile
|
||||||
|
-> document references by file_id
|
||||||
|
-> default or composite-selected document profile
|
||||||
|
-> ResolvedConfigProfile per component
|
||||||
|
-> preserve Global/Profile provenance
|
||||||
|
```
|
||||||
|
|
||||||
|
Aucun composite runtime d'application fictif n'est créé.
|
||||||
|
|
||||||
|
## `schema.composite`
|
||||||
|
|
||||||
|
Le registre Config connaît désormais :
|
||||||
|
|
||||||
|
```text
|
||||||
|
schema.composite -> composite.schema.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Le mapping est résolu sous `schemapath` et peut être remplacé comme les autres fichiers connus :
|
||||||
|
|
||||||
|
```text
|
||||||
|
--filemap=schema.composite=my.composite.schema.json
|
||||||
|
```
|
||||||
|
|
||||||
|
En revanche, le registre par défaut n'introduit aucun :
|
||||||
|
|
||||||
|
```text
|
||||||
|
cfg.composite.<consumer>
|
||||||
|
```
|
||||||
|
|
||||||
|
Un tel descriptor sera ajouté seulement lorsqu'un consumer concret existera.
|
||||||
|
|
||||||
|
## Contrat composite
|
||||||
|
|
||||||
|
Le schema générique versionné est :
|
||||||
|
|
||||||
|
```text
|
||||||
|
config/schemas/composite.schema.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Un exemple non runtime est fourni sous :
|
||||||
|
|
||||||
|
```text
|
||||||
|
config/examples/composite.example.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Structure :
|
||||||
|
|
||||||
|
```text
|
||||||
|
format_version
|
||||||
|
default_profile
|
||||||
|
profiles[]
|
||||||
|
profile_id
|
||||||
|
documents[]
|
||||||
|
component_id
|
||||||
|
file_id
|
||||||
|
profile_id? # optionnel
|
||||||
|
```
|
||||||
|
|
||||||
|
Règles :
|
||||||
|
|
||||||
|
- le composite utilise le même modèle `default_profile` / `profiles` que les documents standards ;
|
||||||
|
- `component_id` est unique à l'intérieur d'un profil composite ;
|
||||||
|
- une référence cible exclusivement un `cfg.std.*` connu du registre ;
|
||||||
|
- une référence ne contient jamais de filename physique ;
|
||||||
|
- l'imbrication de composites n'est pas ouverte dans cette première surface ;
|
||||||
|
- `profile_id` absent conserve le `default_profile` autonome du document référencé ;
|
||||||
|
- `profile_id` présent impose ce profil depuis la composition.
|
||||||
|
|
||||||
|
## Résolution publique
|
||||||
|
|
||||||
|
`ConfigDocumentEngine` expose maintenant :
|
||||||
|
|
||||||
|
```text
|
||||||
|
load_resolved_composite(file_id, requested_profile)
|
||||||
|
```
|
||||||
|
|
||||||
|
Le caller peut sélectionner :
|
||||||
|
|
||||||
|
```text
|
||||||
|
None -> default_profile du composite
|
||||||
|
Some(profile_id) -> profil composite explicite
|
||||||
|
```
|
||||||
|
|
||||||
|
Le résultat `ResolvedConfigComposite` conserve :
|
||||||
|
|
||||||
|
```text
|
||||||
|
file_id du composite
|
||||||
|
path source
|
||||||
|
profile_id composite sélectionné
|
||||||
|
selection_source
|
||||||
|
components par component_id
|
||||||
|
```
|
||||||
|
|
||||||
|
Chaque `ResolvedCompositeComponent` conserve le `ResolvedConfigProfile` du document standard référencé.
|
||||||
|
|
||||||
|
Ainsi les informations déjà stabilisées en `pre.008` restent disponibles :
|
||||||
|
|
||||||
|
```text
|
||||||
|
globals
|
||||||
|
profile
|
||||||
|
effective
|
||||||
|
origin(key) = Global | Profile
|
||||||
|
```
|
||||||
|
|
||||||
|
## Provenance de sélection
|
||||||
|
|
||||||
|
`ConfigProfileSelectionSource` possède désormais :
|
||||||
|
|
||||||
|
```text
|
||||||
|
DefaultProfile
|
||||||
|
Explicit
|
||||||
|
Composite
|
||||||
|
```
|
||||||
|
|
||||||
|
Lorsqu'un composite déclare explicitement :
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"file_id": "cfg.std.logging",
|
||||||
|
"profile_id": "local_dev"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
le `ResolvedConfigProfile` correspondant porte :
|
||||||
|
|
||||||
|
```text
|
||||||
|
selection_source = Composite
|
||||||
|
```
|
||||||
|
|
||||||
|
Si `profile_id` est absent, le document référencé conserve :
|
||||||
|
|
||||||
|
```text
|
||||||
|
selection_source = DefaultProfile
|
||||||
|
```
|
||||||
|
|
||||||
|
La provenance `Global/Profile` de chaque valeur top-level n'est pas remplacée par cette provenance de sélection.
|
||||||
|
|
||||||
|
## Validation sémantique
|
||||||
|
|
||||||
|
Un composite enregistré est soumis à :
|
||||||
|
|
||||||
|
1. JSON syntax ;
|
||||||
|
2. `schema.composite` ;
|
||||||
|
3. contrat générique `default_profile` / `profiles` ;
|
||||||
|
4. unicité des `component_id` par profil ;
|
||||||
|
5. validité et enregistrement des `file_id` référencés ;
|
||||||
|
6. restriction aux documents standards `cfg.std.*` ;
|
||||||
|
7. validation/résolution du profil référencé, y compris le `default_profile` lorsqu'aucun override n'est fourni.
|
||||||
|
|
||||||
|
Un document référencé invalide ou inconnu utilise le nouveau diagnostic :
|
||||||
|
|
||||||
|
```text
|
||||||
|
config.composite_reference_invalid
|
||||||
|
```
|
||||||
|
|
||||||
|
Un profil document ou composite demandé mais absent conserve :
|
||||||
|
|
||||||
|
```text
|
||||||
|
config.profile_not_found
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
Les nouveaux tests couvrent notamment :
|
||||||
|
|
||||||
|
- enregistrement de `schema.composite` sans composite runtime fictif ;
|
||||||
|
- validation du schema/example composite versionné ;
|
||||||
|
- résolution du profil composite par défaut ;
|
||||||
|
- utilisation du `default_profile` du document référencé ;
|
||||||
|
- sélection explicite d'un profil composite ;
|
||||||
|
- provenance `Composite` lorsqu'un profil documentaire est imposé par le composite ;
|
||||||
|
- profil composite inconnu ;
|
||||||
|
- référence vers un `file_id` standard inconnu ;
|
||||||
|
- duplication de `component_id` dans un profil composite ;
|
||||||
|
- disponibilité des constantes/schema/provenance depuis le crate-root.
|
||||||
|
|
||||||
|
Les tests de résolution utilisent un descriptor composite privé à la crate pointant vers l'exemple versionné ; il ne fait pas partie de `ConfigFileRegistry::defaults()`.
|
||||||
|
|
||||||
|
## Hors scope
|
||||||
|
|
||||||
|
Cette tranche n'introduit pas encore :
|
||||||
|
|
||||||
|
- composite runtime d'une application réelle ;
|
||||||
|
- `.env` ;
|
||||||
|
- lecture de l'environnement du processus ;
|
||||||
|
- interpolation `${NAME}` / `${NAME:-fallback}` ;
|
||||||
|
- `Public/Internal/Secret` ;
|
||||||
|
- redaction ;
|
||||||
|
- adaptation vers `ksp_logging_lib::LoggingSettings` ;
|
||||||
|
- mutation/persistence.
|
||||||
|
|
||||||
|
## Dépendances
|
||||||
|
|
||||||
|
Aucune dépendance externe ou feature Cargo supplémentaire n'est ajoutée.
|
||||||
|
|
||||||
|
`ksp-config-lib` ne dépend toujours pas de `ksp-logging-lib` dans cette tranche.
|
||||||
|
|
||||||
|
## Version technique
|
||||||
|
|
||||||
|
La prerelease devient :
|
||||||
|
|
||||||
|
```text
|
||||||
|
workspace.package.version = "0.1.3-pre.9"
|
||||||
|
Cargo.toml header version = 50
|
||||||
|
```
|
||||||
|
|
||||||
|
## Fichiers ajoutés
|
||||||
|
|
||||||
|
```text
|
||||||
|
config/examples/composite.example.json
|
||||||
|
config/schemas/composite.schema.json
|
||||||
|
crates/ksp-config-lib/src/composite.rs
|
||||||
|
crates/ksp-config-lib/unit_tests/composite.rs
|
||||||
|
deltas/0.1.3/pre.009.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## Fichiers modifiés
|
||||||
|
|
||||||
|
```text
|
||||||
|
Cargo.toml
|
||||||
|
crates/ksp-config-lib/src/document.rs
|
||||||
|
crates/ksp-config-lib/src/error.rs
|
||||||
|
crates/ksp-config-lib/src/lib.rs
|
||||||
|
crates/ksp-config-lib/src/profile.rs
|
||||||
|
crates/ksp-config-lib/src/registry.rs
|
||||||
|
crates/ksp-config-lib/tests/public_api.rs
|
||||||
|
crates/ksp-config-lib/unit_tests/registry.rs
|
||||||
|
docs/plans/000-README.md
|
||||||
|
docs/plans/002-FUNCTIONAL_RELEASE_SEQUENCE.md
|
||||||
|
docs/plans/005-V0_1_3_CONFIG_FOUNDATION_PLAN.md
|
||||||
|
docs/rules/FILE_CONTRACTS.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## Fichiers supprimés
|
||||||
|
|
||||||
|
Aucun.
|
||||||
|
|
||||||
|
## Contrôles exécutés dans l'environnement de génération
|
||||||
|
|
||||||
|
- parsing JSON du schema et de l'exemple composite ;
|
||||||
|
- validation du schema Draft 2020-12 et de l'exemple avec l'implémentation JSON Schema disponible dans l'environnement ;
|
||||||
|
- comparaison du delta avec la base `pre.008` ;
|
||||||
|
- contrôle des headers/version modifiés ;
|
||||||
|
- contrôle de l'absence de nouvelle dépendance Cargo ;
|
||||||
|
- contrôle statique des interdictions `unsafe`, `unwrap`, `expect`, `panic`, opérateur `?` et accès environnement applicatif dans les nouveaux sources de production ;
|
||||||
|
- contrôle de la composition exacte de l'archive delta ;
|
||||||
|
- réapplication du delta sur une copie de `pre.008` pour vérifier la reproduction de l'arbre livré.
|
||||||
|
|
||||||
|
`cargo`, `rustc` et `rustfmt` ne sont pas disponibles dans l'environnement de génération. Aucune validation Rust n'est déclarée réussie ici.
|
||||||
|
|
||||||
|
## Validations utilisateur à exécuter
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo fmt --all
|
||||||
|
cargo check --workspace
|
||||||
|
cargo clippy --workspace --all-targets
|
||||||
|
cargo test --workspace
|
||||||
|
cargo tree -p ksp-config-lib
|
||||||
|
cargo tree -p ksp-config-lib -d
|
||||||
|
cargo tree -p ksp-config-lib -e features
|
||||||
|
```
|
||||||
|
|
||||||
|
Après validation de `pre.009`, la prochaine tranche planifiée est :
|
||||||
|
|
||||||
|
```text
|
||||||
|
0.1.3-pre.010 — .env + process env + resolver ${...}
|
||||||
|
```
|
||||||
68
deltas/0.1.3/pre.010-fix.001.md
Normal file
68
deltas/0.1.3/pre.010-fix.001.md
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
<!-- file: deltas/0.1.3/pre.010-fix.001.md -->
|
||||||
|
<!-- version: 1 -->
|
||||||
|
|
||||||
|
# Delta 0.1.3-pre.010-fix.001
|
||||||
|
|
||||||
|
## Objectif
|
||||||
|
|
||||||
|
Corriger deux tests unitaires de `pre.010` qui dépendaient à tort du répertoire courant du binaire de test pour retrouver les fichiers versionnés du workspace.
|
||||||
|
|
||||||
|
Le resolver environnemental, le parser `.env`, `.env.example`, les contrats publics et les dépendances restent inchangés.
|
||||||
|
|
||||||
|
## Cause
|
||||||
|
|
||||||
|
Les tests :
|
||||||
|
|
||||||
|
```text
|
||||||
|
committed_logging_profile_resolves_environment_fallback_without_changing_source_profile
|
||||||
|
env_example_inventory_contains_current_runtime_variable_with_preceding_comment
|
||||||
|
```
|
||||||
|
|
||||||
|
utilisaient respectivement :
|
||||||
|
|
||||||
|
```text
|
||||||
|
config/
|
||||||
|
config/schemas/
|
||||||
|
.env.example
|
||||||
|
```
|
||||||
|
|
||||||
|
comme chemins relatifs au current working directory du processus de test. Cette hypothèse n'est pas un contrat Cargo fiable et différait déjà des tests document/profile existants, qui dérivent la racine du workspace depuis `CARGO_MANIFEST_DIR`.
|
||||||
|
|
||||||
|
## Modifications
|
||||||
|
|
||||||
|
- `workspace.package.version` passe de `0.1.3-pre.10` à `0.1.3-pre.10.fix.1` ;
|
||||||
|
- le header du `Cargo.toml` racine passe de `52` à `53` ;
|
||||||
|
- `crates/ksp-config-lib/unit_tests/environment.rs` passe de la version de fichier `1` à `2` ;
|
||||||
|
- les deux tests concernés utilisent maintenant une fonction locale `workspace_root()` basée sur :
|
||||||
|
|
||||||
|
```text
|
||||||
|
env!("CARGO_MANIFEST_DIR")/../..
|
||||||
|
```
|
||||||
|
|
||||||
|
- le test du profil Logging ouvre donc les répertoires `config/` et `config/schemas/` sous la racine du workspace ;
|
||||||
|
- le test d'inventaire ouvre `.env.example` via `DEFAULT_DOTENV_EXAMPLE_PATH` sous cette même racine ;
|
||||||
|
- aucun fichier runtime Config, schema, `.env.example`, contrat public, resolver ou dépendance n'est modifié.
|
||||||
|
|
||||||
|
## Validation utilisateur de pre.010
|
||||||
|
|
||||||
|
Avant ce fix :
|
||||||
|
|
||||||
|
```text
|
||||||
|
cargo fmt --all OK
|
||||||
|
cargo check --workspace OK
|
||||||
|
cargo clippy --workspace --all-targets OK
|
||||||
|
cargo test --workspace 51/53 tests Config OK, 2 tests échoués
|
||||||
|
```
|
||||||
|
|
||||||
|
Les deux échecs sont ceux listés ci-dessus. Les autres tests, dont la priorité `process > .env > fallback`, le parser `.env`, les placeholders et les composites, passent.
|
||||||
|
|
||||||
|
## Validation attendue
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo fmt --all
|
||||||
|
cargo check --workspace
|
||||||
|
cargo clippy --workspace --all-targets
|
||||||
|
cargo test --workspace
|
||||||
|
```
|
||||||
|
|
||||||
|
Le résultat attendu est que les 53 tests unitaires de `ksp-config-lib` passent, ainsi que les 7 tests d'API publique et l'ensemble du workspace.
|
||||||
298
deltas/0.1.3/pre.010.md
Normal file
298
deltas/0.1.3/pre.010.md
Normal file
@@ -0,0 +1,298 @@
|
|||||||
|
<!-- file: deltas/0.1.3/pre.010.md -->
|
||||||
|
<!-- version: 1 -->
|
||||||
|
|
||||||
|
# Delta 0.1.3-pre.010
|
||||||
|
|
||||||
|
## Base requise
|
||||||
|
|
||||||
|
Livraison précédente validée :
|
||||||
|
|
||||||
|
```text
|
||||||
|
0.1.3-pre.009-fix.001
|
||||||
|
```
|
||||||
|
|
||||||
|
Version technique de cette base :
|
||||||
|
|
||||||
|
```text
|
||||||
|
workspace.package.version = "0.1.3-pre.9.fix.1"
|
||||||
|
Cargo.toml header version = 51
|
||||||
|
```
|
||||||
|
|
||||||
|
Validations utilisateur exécutées le 2026-08-15 :
|
||||||
|
|
||||||
|
```text
|
||||||
|
cargo fmt --all OK
|
||||||
|
cargo check --workspace OK
|
||||||
|
cargo clippy --workspace --all-targets OK
|
||||||
|
cargo test --workspace OK
|
||||||
|
cargo tree -p ksp-config-lib OK
|
||||||
|
cargo tree -p ksp-config-lib -d OK, doublon transitif syn 2/3 déjà connu
|
||||||
|
cargo tree -p ksp-config-lib -e features OK
|
||||||
|
```
|
||||||
|
|
||||||
|
`cargo test --workspace` confirme notamment 39 tests unitaires + 7 tests publics pour `ksp-config-lib`.
|
||||||
|
|
||||||
|
## Objet de pre.010
|
||||||
|
|
||||||
|
Introduire la première résolution environnementale Config réellement consommable :
|
||||||
|
|
||||||
|
```text
|
||||||
|
process environment
|
||||||
|
>
|
||||||
|
./.env
|
||||||
|
>
|
||||||
|
placeholder/API fallback
|
||||||
|
->
|
||||||
|
ConfigEnvironmentValue
|
||||||
|
->
|
||||||
|
${NAME} / ${NAME:-fallback}
|
||||||
|
->
|
||||||
|
JSON effective values
|
||||||
|
```
|
||||||
|
|
||||||
|
La sensibilité et les représentations sûres restent volontairement réservées à `pre.011`.
|
||||||
|
|
||||||
|
## Propriété et sources
|
||||||
|
|
||||||
|
`ConfigEnvironment` est le snapshot possédé par `ksp-config-lib`.
|
||||||
|
|
||||||
|
`ConfigEnvironment::load()` :
|
||||||
|
|
||||||
|
1. capture les variables supportées héritées par le processus ;
|
||||||
|
2. lit `./.env` s'il existe ;
|
||||||
|
3. ne modifie jamais l'environnement du processus, du shell, de systemd ou du parent ;
|
||||||
|
4. considère l'absence de `.env` comme une source locale vide.
|
||||||
|
|
||||||
|
Une erreur de lecture réelle de `.env` reste distincte de son absence.
|
||||||
|
|
||||||
|
Les seuls namespaces applicatifs acceptés sont :
|
||||||
|
|
||||||
|
```text
|
||||||
|
KSP_*
|
||||||
|
KSPB_*
|
||||||
|
```
|
||||||
|
|
||||||
|
ce qui couvre naturellement :
|
||||||
|
|
||||||
|
```text
|
||||||
|
KSP_PUBLIC_*
|
||||||
|
KSP_SECRET_*
|
||||||
|
KSPB_PUBLIC_*
|
||||||
|
KSPB_SECRET_*
|
||||||
|
```
|
||||||
|
|
||||||
|
## Priorité et chaîne vide
|
||||||
|
|
||||||
|
`ConfigEnvironment::resolve_variable(name, fallback)` applique :
|
||||||
|
|
||||||
|
```text
|
||||||
|
process > .env > fallback > missing
|
||||||
|
```
|
||||||
|
|
||||||
|
Une chaîne vide explicitement présente dans le processus ou `.env` reste une valeur définie. Elle ne déclenche pas le fallback.
|
||||||
|
|
||||||
|
`ConfigEnvironmentValue` expose :
|
||||||
|
|
||||||
|
```text
|
||||||
|
variable_name
|
||||||
|
value réelle
|
||||||
|
source = Process | DotEnv | Fallback
|
||||||
|
```
|
||||||
|
|
||||||
|
Le type n'implémente volontairement pas `Debug` afin de ne pas créer une voie de fuite accidentelle avant l'introduction de la sensibilité/redaction en `pre.011`.
|
||||||
|
|
||||||
|
## Resolver `${...}`
|
||||||
|
|
||||||
|
Config supporte :
|
||||||
|
|
||||||
|
```text
|
||||||
|
${KSP_VAR}
|
||||||
|
${KSP_VAR:-fallback}
|
||||||
|
```
|
||||||
|
|
||||||
|
Règles :
|
||||||
|
|
||||||
|
- plusieurs placeholders peuvent apparaître dans une même string ;
|
||||||
|
- le fallback n'est utilisé que si la variable est absente ;
|
||||||
|
- le fallback est littéral dans cette tranche et n'est pas récursivement interprété ;
|
||||||
|
- les placeholders imbriqués/malformés sont refusés ;
|
||||||
|
- les clés JSON ne sont pas interpolées ; seules les valeurs string le sont ;
|
||||||
|
- objets et tableaux JSON sont parcourus récursivement.
|
||||||
|
|
||||||
|
APIs principales :
|
||||||
|
|
||||||
|
```text
|
||||||
|
ConfigEnvironment::resolve_variable(...)
|
||||||
|
ConfigEnvironment::resolve_text(...)
|
||||||
|
ConfigEnvironment::resolve_json(...)
|
||||||
|
ConfigEnvironment::resolve_map(...)
|
||||||
|
ResolvedConfigProfile::resolve_effective_environment(...)
|
||||||
|
```
|
||||||
|
|
||||||
|
La résolution d'environnement d'un profil produit une nouvelle map effective et ne modifie pas la source, le `profile_id`, la provenance `Global/Profile` ni la sélection du profil déjà résolu.
|
||||||
|
|
||||||
|
## Variable manquante
|
||||||
|
|
||||||
|
Une référence sans valeur process, sans valeur `.env` et sans fallback retourne :
|
||||||
|
|
||||||
|
```text
|
||||||
|
config.environment_variable_missing
|
||||||
|
```
|
||||||
|
|
||||||
|
Config émet également un warning via la façade KSP :
|
||||||
|
|
||||||
|
```text
|
||||||
|
target = ksp-config-lib
|
||||||
|
domain = config.environment
|
||||||
|
```
|
||||||
|
|
||||||
|
Le warning et l'erreur contiennent le nom de la variable mais jamais sa valeur.
|
||||||
|
|
||||||
|
`ksp-config-lib` dépend donc maintenant réellement de `ksp-logging-lib`. La dépendance inverse Logging -> Config reste interdite.
|
||||||
|
|
||||||
|
## `.env`
|
||||||
|
|
||||||
|
Le parser Config de cette tranche accepte un sous-ensemble déterministe adapté au fichier KSP géré :
|
||||||
|
|
||||||
|
- lignes vides et commentaires `#` ;
|
||||||
|
- préfixe optionnel `export ` ;
|
||||||
|
- `NAME=value` ;
|
||||||
|
- valeur non quotée ;
|
||||||
|
- valeur entre quotes simples ;
|
||||||
|
- valeur entre quotes doubles avec escapes bornés `\\`, `\"`, `\n`, `\r`, `\t` ;
|
||||||
|
- commentaire inline d'une valeur non quotée lorsqu'il commence par ` #` ;
|
||||||
|
- chaîne vide ;
|
||||||
|
- clés étrangères valides ignorées par Config ;
|
||||||
|
- doublon d'une clé KSP/KSPB refusé pour éviter une résolution locale ambiguë.
|
||||||
|
|
||||||
|
Aucune interpolation interne du fichier `.env` n'est ajoutée dans cette tranche.
|
||||||
|
|
||||||
|
## `.env.example` — nouvelle règle durable
|
||||||
|
|
||||||
|
Le dépôt possède désormais à la racine :
|
||||||
|
|
||||||
|
```text
|
||||||
|
.env.example
|
||||||
|
```
|
||||||
|
|
||||||
|
Ce fichier est versionné et sert d'inventaire canonique des variables runtime KSP/KSPB utilisées par les fichiers Config ou le code opérationnel.
|
||||||
|
|
||||||
|
Règles enregistrées :
|
||||||
|
|
||||||
|
- toute nouvelle variable runtime KSP/KSPB est ajoutée à `.env.example` dans le même delta que sa première utilisation ;
|
||||||
|
- chaque variable est précédée d'un commentaire expliquant son usage/utilité ;
|
||||||
|
- une entrée peut être active avec une valeur par défaut sûre, porter une valeur générique non secrète ou être commentée ;
|
||||||
|
- aucun vrai secret n'y est stocké ;
|
||||||
|
- le fichier local `.env` reste non versionné et non échangé ;
|
||||||
|
- l'objectif est de pouvoir créer `.env` depuis `.env.example` et repérer localement les nouvelles clés par diff.
|
||||||
|
|
||||||
|
La première entrée est :
|
||||||
|
|
||||||
|
```text
|
||||||
|
KSP_LOGS_DIRECTORY=logs
|
||||||
|
```
|
||||||
|
|
||||||
|
car `config/std.logging.json` est actuellement le seul fichier runtime utilisant une variable d'environnement.
|
||||||
|
|
||||||
|
`.gitignore` possédait déjà :
|
||||||
|
|
||||||
|
```text
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
```
|
||||||
|
|
||||||
|
et n'a donc pas besoin d'être modifié.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
Les nouveaux tests couvrent notamment :
|
||||||
|
|
||||||
|
- priorité process > `.env` > fallback ;
|
||||||
|
- chaîne vide process et `.env` considérée comme définie ;
|
||||||
|
- variable manquante sans fallback ;
|
||||||
|
- namespaces KSP/KSPB ;
|
||||||
|
- résolution de plusieurs placeholders ;
|
||||||
|
- placeholder malformé/imbriqué ;
|
||||||
|
- résolution récursive object/array JSON ;
|
||||||
|
- parsing `.env` avec commentaires, `export` et quotes ;
|
||||||
|
- refus des doublons KSP dans `.env` ;
|
||||||
|
- collecte process synthétique sans mutation du vrai environnement ;
|
||||||
|
- résolution du fallback `${KSP_LOGS_DIRECTORY:-logs}` du document Logging commité ;
|
||||||
|
- présence commentée de `KSP_LOGS_DIRECTORY` dans `.env.example` ;
|
||||||
|
- disponibilité de la surface environment depuis le crate-root.
|
||||||
|
|
||||||
|
Les tests n'appellent pas `std::env::set_var` / `remove_var`; aucune mutation unsafe de l'environnement n'est nécessaire en Rust 2024.
|
||||||
|
|
||||||
|
## Dépendances
|
||||||
|
|
||||||
|
Aucune nouvelle dépendance externe n'est ajoutée.
|
||||||
|
|
||||||
|
Dépendance KSP désormais utilisée :
|
||||||
|
|
||||||
|
```text
|
||||||
|
ksp-config-lib -> ksp-logging-lib
|
||||||
|
```
|
||||||
|
|
||||||
|
pour les warnings/diagnostics runtime Config.
|
||||||
|
|
||||||
|
Les dépendances externes `serde`, `serde_json` et `jsonschema` restent inchangées.
|
||||||
|
|
||||||
|
## Version technique
|
||||||
|
|
||||||
|
La prerelease devient :
|
||||||
|
|
||||||
|
```text
|
||||||
|
workspace.package.version = "0.1.3-pre.10"
|
||||||
|
Cargo.toml header version = 52
|
||||||
|
```
|
||||||
|
|
||||||
|
## Fichiers ajoutés
|
||||||
|
|
||||||
|
```text
|
||||||
|
.env.example
|
||||||
|
crates/ksp-config-lib/src/environment.rs
|
||||||
|
crates/ksp-config-lib/unit_tests/environment.rs
|
||||||
|
deltas/0.1.3/pre.010.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## Fichiers modifiés
|
||||||
|
|
||||||
|
```text
|
||||||
|
Cargo.toml
|
||||||
|
crates/ksp-config-lib/Cargo.toml
|
||||||
|
crates/ksp-config-lib/src/error.rs
|
||||||
|
crates/ksp-config-lib/src/lib.rs
|
||||||
|
crates/ksp-config-lib/src/profile.rs
|
||||||
|
crates/ksp-config-lib/tests/public_api.rs
|
||||||
|
docs/architecture/005-DEPENDENCY_GRAPH.md
|
||||||
|
docs/plans/000-README.md
|
||||||
|
docs/plans/002-FUNCTIONAL_RELEASE_SEQUENCE.md
|
||||||
|
docs/plans/005-V0_1_3_CONFIG_FOUNDATION_PLAN.md
|
||||||
|
docs/rules/FILE_CONTRACTS.md
|
||||||
|
docs/rules/RULES_KSP.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## Fichiers supprimés
|
||||||
|
|
||||||
|
Aucun.
|
||||||
|
|
||||||
|
## Contrôles exécutés dans l'environnement de génération
|
||||||
|
|
||||||
|
- parsing TOML du manifest racine et de `ksp-config-lib` ;
|
||||||
|
- audit statique des accès `std::env::*` : seul `crates/ksp-config-lib/src/environment.rs` lit l'environnement applicatif ;
|
||||||
|
- audit statique des variables runtime exactes dans `crates/*/src` + `config/` : `KSP_LOGS_DIRECTORY` est la seule clé actuelle et elle est présente dans `.env.example` ;
|
||||||
|
- absence de `unsafe`, `unwrap`, `expect`, `panic!` et `?` dans le nouveau code de production Config ;
|
||||||
|
- contrôle des headers/version et des lignes Rust ajoutées/modifiées ;
|
||||||
|
- comparaison du delta avec la base `pre.009-fix.001` ;
|
||||||
|
- aucun `Cargo.lock` ajouté.
|
||||||
|
|
||||||
|
Le toolchain Rust n'est pas disponible dans l'environnement de génération. `cargo fmt/check/clippy/test` doivent donc être exécutés par l'utilisateur.
|
||||||
|
|
||||||
|
## Étape suivante
|
||||||
|
|
||||||
|
Après validation utilisateur :
|
||||||
|
|
||||||
|
```text
|
||||||
|
0.1.3-pre.011 — sensibilité Public/Internal/Secret + real/safe/provenance + redaction par segment
|
||||||
|
```
|
||||||
42
deltas/0.1.3/pre.011-fix.001.md
Normal file
42
deltas/0.1.3/pre.011-fix.001.md
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
<!-- file: deltas/0.1.3/pre.011-fix.001.md -->
|
||||||
|
<!-- version: 1 -->
|
||||||
|
|
||||||
|
# 0.1.3-pre.011-fix.001 — Clippy test closure fix
|
||||||
|
|
||||||
|
## Objectif
|
||||||
|
|
||||||
|
Corriger l'unique erreur Clippy observée après validation de `0.1.3-pre.011`, sans modifier le comportement fonctionnel de `ksp-config-lib`.
|
||||||
|
|
||||||
|
## Correction
|
||||||
|
|
||||||
|
Le test `detailed_profile_environment_keeps_global_origin_and_adds_environment_provenance` utilisait une closure avec retour implicite :
|
||||||
|
|
||||||
|
```rust
|
||||||
|
.and_then(|items| items.last())
|
||||||
|
```
|
||||||
|
|
||||||
|
Le workspace impose `clippy::implicit_return = "deny"`. La closure utilise désormais un `return` explicite :
|
||||||
|
|
||||||
|
```rust
|
||||||
|
.and_then(|items| return items.last())
|
||||||
|
```
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
- aucun changement du resolver d'environnement ;
|
||||||
|
- aucun changement de la classification `Public` / `Internal` / `Secret` ;
|
||||||
|
- aucun changement de redaction, provenance ou représentation sûre ;
|
||||||
|
- aucune nouvelle dépendance ;
|
||||||
|
- aucun changement de fichier Config, schema ou `.env.example` ;
|
||||||
|
- signal Cargo mis à jour vers `0.1.3-pre.11.fix.1`.
|
||||||
|
|
||||||
|
## Validation attendue
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo fmt --all
|
||||||
|
cargo check --workspace
|
||||||
|
cargo clippy --workspace --all-targets
|
||||||
|
cargo test --workspace
|
||||||
|
```
|
||||||
|
|
||||||
|
`0.1.3-pre.011` avait déjà passé les 61 tests unitaires Config et les 9 tests d'API publique ; ce fix vise uniquement la conformité Clippy.
|
||||||
329
deltas/0.1.3/pre.011.md
Normal file
329
deltas/0.1.3/pre.011.md
Normal file
@@ -0,0 +1,329 @@
|
|||||||
|
<!-- file: deltas/0.1.3/pre.011.md -->
|
||||||
|
<!-- version: 1 -->
|
||||||
|
|
||||||
|
# Delta 0.1.3-pre.011
|
||||||
|
|
||||||
|
## Base requise
|
||||||
|
|
||||||
|
Livraison précédente validée :
|
||||||
|
|
||||||
|
```text
|
||||||
|
0.1.3-pre.010-fix.001
|
||||||
|
```
|
||||||
|
|
||||||
|
Version technique de cette base :
|
||||||
|
|
||||||
|
```text
|
||||||
|
workspace.package.version = "0.1.3-pre.10.fix.1"
|
||||||
|
Cargo.toml header version = 53
|
||||||
|
```
|
||||||
|
|
||||||
|
Validations utilisateur exécutées le 2026-08-15 :
|
||||||
|
|
||||||
|
```text
|
||||||
|
cargo fmt --all OK
|
||||||
|
cargo check --workspace OK
|
||||||
|
cargo clippy --workspace --all-targets OK
|
||||||
|
cargo test --workspace OK
|
||||||
|
cargo tree -p ksp-config-lib OK
|
||||||
|
cargo tree -p ksp-config-lib -d OK, doublon transitif syn 2/3 déjà connu
|
||||||
|
cargo tree -p ksp-config-lib -e features OK
|
||||||
|
```
|
||||||
|
|
||||||
|
`cargo test --workspace` confirme notamment 53 tests unitaires + 8 tests publics pour `ksp-config-lib`.
|
||||||
|
|
||||||
|
## Objet de pre.011
|
||||||
|
|
||||||
|
Ajouter la couche de sécurité/provenance qui manquait au resolver environnemental :
|
||||||
|
|
||||||
|
```text
|
||||||
|
Config source string / JSON
|
||||||
|
+
|
||||||
|
process > .env > fallback
|
||||||
|
->
|
||||||
|
real runtime value
|
||||||
|
safe diagnostic value
|
||||||
|
Public | Internal | Secret
|
||||||
|
ordered / JSON-Pointer provenance
|
||||||
|
```
|
||||||
|
|
||||||
|
Cette tranche ne modifie pas encore les documents Config, ne construit pas `LoggingSettings` et ne persiste rien.
|
||||||
|
|
||||||
|
## Classification de sensibilité
|
||||||
|
|
||||||
|
`ConfigSensitivity` expose :
|
||||||
|
|
||||||
|
```text
|
||||||
|
Public
|
||||||
|
Internal
|
||||||
|
Secret
|
||||||
|
```
|
||||||
|
|
||||||
|
Classification nominale :
|
||||||
|
|
||||||
|
```text
|
||||||
|
KSP_SECRET_* / KSPB_SECRET_* -> Secret
|
||||||
|
KSP_PUBLIC_* / KSPB_PUBLIC_* -> Public
|
||||||
|
autre KSP_* / KSPB_* -> Internal
|
||||||
|
```
|
||||||
|
|
||||||
|
Ordre :
|
||||||
|
|
||||||
|
```text
|
||||||
|
Secret > Internal > Public
|
||||||
|
```
|
||||||
|
|
||||||
|
La sensibilité d'une chaîne contenant des placeholders est la sensibilité la plus forte des placeholders effectivement référencés. Une chaîne littérale sans placeholder est `Internal`.
|
||||||
|
|
||||||
|
Un fallback hérite toujours de la sensibilité du nom de variable référencé. Ainsi :
|
||||||
|
|
||||||
|
```text
|
||||||
|
${KSP_SECRET_TOKEN:-false-secret}
|
||||||
|
```
|
||||||
|
|
||||||
|
reste `Secret` même si la valeur réelle vient du fallback.
|
||||||
|
|
||||||
|
## Valeur réelle et représentation sûre
|
||||||
|
|
||||||
|
`ConfigEnvironmentValue` conserve désormais :
|
||||||
|
|
||||||
|
```text
|
||||||
|
variable_name
|
||||||
|
value réel runtime
|
||||||
|
safe_value diagnostic sûr
|
||||||
|
sensitivity
|
||||||
|
source Process | DotEnv | Fallback
|
||||||
|
provenance
|
||||||
|
```
|
||||||
|
|
||||||
|
Pour `Secret` :
|
||||||
|
|
||||||
|
```text
|
||||||
|
value = valeur réelle
|
||||||
|
safe_value = ********
|
||||||
|
```
|
||||||
|
|
||||||
|
Pour `Public` et `Internal`, la représentation sûre conserve la valeur réelle dans cette tranche.
|
||||||
|
|
||||||
|
`ConfigEnvironmentValue` possède un `Debug` manuel qui n'affiche jamais `value`; il affiche `safe_value`, sensibilité, source et nom de variable.
|
||||||
|
|
||||||
|
## Chaînes composées
|
||||||
|
|
||||||
|
Nouveau contrat :
|
||||||
|
|
||||||
|
```text
|
||||||
|
ResolvedConfigText
|
||||||
|
```
|
||||||
|
|
||||||
|
Il conserve :
|
||||||
|
|
||||||
|
```text
|
||||||
|
value
|
||||||
|
safe_value
|
||||||
|
sensitivity
|
||||||
|
provenance[]
|
||||||
|
```
|
||||||
|
|
||||||
|
Exemple :
|
||||||
|
|
||||||
|
```text
|
||||||
|
source:
|
||||||
|
https://${KSP_PUBLIC_HOST}/?token=${KSP_SECRET_TOKEN}
|
||||||
|
|
||||||
|
real:
|
||||||
|
https://rpc.example.test/?token=secret-canary
|
||||||
|
|
||||||
|
safe:
|
||||||
|
https://rpc.example.test/?token=********
|
||||||
|
```
|
||||||
|
|
||||||
|
La redaction est réalisée par segment de substitution. Les fragments littéraux et non secrets restent visibles dans la représentation sûre.
|
||||||
|
|
||||||
|
`ConfigEnvironment::resolve_text_detailed()` expose ce contrat.
|
||||||
|
|
||||||
|
L'API existante :
|
||||||
|
|
||||||
|
```text
|
||||||
|
resolve_text(...)
|
||||||
|
```
|
||||||
|
|
||||||
|
reste compatible et retourne uniquement la valeur réelle.
|
||||||
|
|
||||||
|
## Provenance
|
||||||
|
|
||||||
|
`ConfigValueProvenance` distingue :
|
||||||
|
|
||||||
|
```text
|
||||||
|
DocumentLiteral
|
||||||
|
EnvironmentProcess { variable_name }
|
||||||
|
EnvironmentDotEnv { variable_name }
|
||||||
|
EnvironmentFallback { variable_name }
|
||||||
|
```
|
||||||
|
|
||||||
|
La provenance n'embarque jamais la valeur d'environnement elle-même.
|
||||||
|
|
||||||
|
Une chaîne composée conserve l'ordre des segments/références ayant participé à sa construction.
|
||||||
|
|
||||||
|
## JSON détaillé
|
||||||
|
|
||||||
|
Nouveau contrat :
|
||||||
|
|
||||||
|
```text
|
||||||
|
ResolvedConfigJson
|
||||||
|
```
|
||||||
|
|
||||||
|
Il conserve :
|
||||||
|
|
||||||
|
```text
|
||||||
|
value arbre JSON réel
|
||||||
|
safe_value arbre JSON redacted
|
||||||
|
sensitivity plus forte sensibilité contenue
|
||||||
|
provenance map JSON Pointer -> provenance[]
|
||||||
|
```
|
||||||
|
|
||||||
|
`ConfigEnvironment::resolve_json_detailed()` parcourt récursivement objets et tableaux sans modifier les clés.
|
||||||
|
|
||||||
|
Les JSON Pointer suivent RFC 6901 pour les clés contenant `~` ou `/`.
|
||||||
|
|
||||||
|
L'API existante :
|
||||||
|
|
||||||
|
```text
|
||||||
|
resolve_json(...)
|
||||||
|
resolve_map(...)
|
||||||
|
```
|
||||||
|
|
||||||
|
reste compatible et retourne uniquement la valeur réelle.
|
||||||
|
|
||||||
|
## Profil effectif
|
||||||
|
|
||||||
|
`ResolvedConfigProfile` ajoute :
|
||||||
|
|
||||||
|
```text
|
||||||
|
resolve_effective_environment_detailed(...)
|
||||||
|
```
|
||||||
|
|
||||||
|
La provenance top-level déjà existante :
|
||||||
|
|
||||||
|
```text
|
||||||
|
Global
|
||||||
|
Profile
|
||||||
|
```
|
||||||
|
|
||||||
|
reste attachée au profil source via `origin(key)`.
|
||||||
|
|
||||||
|
La résolution détaillée ajoute séparément la provenance de la couche environnement :
|
||||||
|
|
||||||
|
```text
|
||||||
|
DocumentLiteral
|
||||||
|
Process
|
||||||
|
DotEnv
|
||||||
|
Fallback
|
||||||
|
```
|
||||||
|
|
||||||
|
L'ancienne méthode `resolve_effective_environment()` reste disponible et retourne seulement la map réelle.
|
||||||
|
|
||||||
|
## Non-divulgation
|
||||||
|
|
||||||
|
Les tests canary vérifient notamment :
|
||||||
|
|
||||||
|
- secret venant du process : réel disponible, safe redacted, `Debug` sans canary ;
|
||||||
|
- secret venant d'un fallback : fallback réel disponible mais safe redacted ;
|
||||||
|
- URL composée public + secret : seul le segment secret est masqué ;
|
||||||
|
- JSON imbriqué : arbre réel complet, arbre safe redacted, `Debug` sans canary ;
|
||||||
|
- provenance de la variable sans transport de la valeur elle-même.
|
||||||
|
|
||||||
|
`ksp-logging-lib` ne reçoit aucune logique générique de redaction de messages arbitraires. La valeur sûre est construite par Config avant qu'un diagnostic ne l'utilise.
|
||||||
|
|
||||||
|
## Clarification `KSP_LOGS_DIRECTORY`
|
||||||
|
|
||||||
|
La décision préparatoire de l'adapter `pre.012` est enregistrée dans le plan :
|
||||||
|
|
||||||
|
- après interpolation, `logs_directory` pourra être absolu ou relatif ;
|
||||||
|
- un chemin relatif sera interprété relativement au current working directory du processus qui initialise Logging, pas automatiquement au répertoire du binaire ;
|
||||||
|
- le fallback `logs` s'applique seulement si `KSP_LOGS_DIRECTORY` est absent ;
|
||||||
|
- une valeur explicitement présente mais invalide ne déclenche pas le fallback et devra produire un diagnostic de configuration effective invalide ;
|
||||||
|
- les paths de sinks restent relatifs sous `logs_directory` sans traversal.
|
||||||
|
|
||||||
|
Aucun code de validation/conversion Logging correspondant n'est ajouté dans `pre.011`; cette responsabilité appartient à `pre.012`.
|
||||||
|
|
||||||
|
## `.env.example`
|
||||||
|
|
||||||
|
Aucune nouvelle variable runtime n'est introduite.
|
||||||
|
|
||||||
|
`.env.example` reste donc inchangé et contient toujours la seule variable actuellement utilisée :
|
||||||
|
|
||||||
|
```text
|
||||||
|
KSP_LOGS_DIRECTORY=logs
|
||||||
|
```
|
||||||
|
|
||||||
|
## Dépendances
|
||||||
|
|
||||||
|
Aucune dépendance ou feature Cargo n'est ajoutée/modifiée.
|
||||||
|
|
||||||
|
La direction reste :
|
||||||
|
|
||||||
|
```text
|
||||||
|
ksp-config-lib -> ksp-logging-lib -> ksp-core-lib
|
||||||
|
ksp-config-lib -> ksp-core-lib
|
||||||
|
```
|
||||||
|
|
||||||
|
Logging ne dépend toujours pas de Config.
|
||||||
|
|
||||||
|
## Version technique
|
||||||
|
|
||||||
|
La prerelease devient :
|
||||||
|
|
||||||
|
```text
|
||||||
|
workspace.package.version = "0.1.3-pre.11"
|
||||||
|
Cargo.toml header version = 54
|
||||||
|
```
|
||||||
|
|
||||||
|
## Fichiers ajoutés
|
||||||
|
|
||||||
|
```text
|
||||||
|
crates/ksp-config-lib/src/sensitivity.rs
|
||||||
|
crates/ksp-config-lib/unit_tests/sensitivity.rs
|
||||||
|
deltas/0.1.3/pre.011.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## Fichiers modifiés
|
||||||
|
|
||||||
|
```text
|
||||||
|
Cargo.toml
|
||||||
|
crates/ksp-config-lib/src/environment.rs
|
||||||
|
crates/ksp-config-lib/src/lib.rs
|
||||||
|
crates/ksp-config-lib/src/profile.rs
|
||||||
|
crates/ksp-config-lib/tests/public_api.rs
|
||||||
|
crates/ksp-config-lib/unit_tests/environment.rs
|
||||||
|
docs/plans/000-README.md
|
||||||
|
docs/plans/002-FUNCTIONAL_RELEASE_SEQUENCE.md
|
||||||
|
docs/plans/005-V0_1_3_CONFIG_FOUNDATION_PLAN.md
|
||||||
|
docs/rules/RULES_KSP.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## Fichiers supprimés
|
||||||
|
|
||||||
|
Aucun.
|
||||||
|
|
||||||
|
## Contrôles exécutés dans l'environnement de génération
|
||||||
|
|
||||||
|
- comparaison statique avec la base validée `pre.010-fix.001` ;
|
||||||
|
- absence de nouvelle dépendance/feature Cargo ;
|
||||||
|
- absence de nouvelle variable runtime nécessitant une modification de `.env.example` ;
|
||||||
|
- audit statique du nouveau code Config contre `unsafe`, `unwrap`, `expect`, `panic!` et opérateur `?` de production ;
|
||||||
|
- contrôle des headers/version ;
|
||||||
|
- contrôle des lignes Rust à 160 colonnes maximum ;
|
||||||
|
- contrôle canary dans les tests : les représentations safe/Debug attendues ne contiennent pas le secret ;
|
||||||
|
- reproduction du delta sur la base précédente avant packaging.
|
||||||
|
|
||||||
|
Le toolchain Rust n'est pas disponible dans l'environnement de génération. `cargo fmt/check/clippy/test` doivent donc être exécutés par l'utilisateur.
|
||||||
|
|
||||||
|
## Étape suivante
|
||||||
|
|
||||||
|
Après validation utilisateur :
|
||||||
|
|
||||||
|
```text
|
||||||
|
0.1.3-pre.012 — adapter Config -> Logging
|
||||||
|
```
|
||||||
|
|
||||||
|
Cette tranche devra notamment valider/résoudre `logs_directory`, convertir le profil Logging effectif vers les contrats publics `ksp_logging_lib::*` et démontrer `initialize/reinitialize` sans créer de dépendance Logging -> Config.
|
||||||
315
deltas/0.1.3/pre.012.md
Normal file
315
deltas/0.1.3/pre.012.md
Normal file
@@ -0,0 +1,315 @@
|
|||||||
|
<!-- file: deltas/0.1.3/pre.012.md -->
|
||||||
|
<!-- version: 1 -->
|
||||||
|
|
||||||
|
# Delta 0.1.3-pre.012
|
||||||
|
|
||||||
|
## Base requise
|
||||||
|
|
||||||
|
Livraison précédente validée :
|
||||||
|
|
||||||
|
```text
|
||||||
|
0.1.3-pre.011-fix.001
|
||||||
|
```
|
||||||
|
|
||||||
|
Version technique de cette base :
|
||||||
|
|
||||||
|
```text
|
||||||
|
workspace.package.version = "0.1.3-pre.11.fix.1"
|
||||||
|
Cargo.toml header version = 55
|
||||||
|
```
|
||||||
|
|
||||||
|
Validations utilisateur exécutées le 2026-08-16 :
|
||||||
|
|
||||||
|
```text
|
||||||
|
cargo fmt --all OK
|
||||||
|
cargo check --workspace OK
|
||||||
|
cargo clippy --workspace --all-targets OK
|
||||||
|
cargo test --workspace OK
|
||||||
|
cargo tree -p ksp-config-lib OK
|
||||||
|
cargo tree -p ksp-config-lib -d OK, doublon transitif syn 2/3 déjà connu
|
||||||
|
cargo tree -p ksp-logging-lib -d OK, aucun doublon
|
||||||
|
```
|
||||||
|
|
||||||
|
`cargo test --workspace` confirme notamment 61 tests unitaires + 9 tests publics pour `ksp-config-lib`.
|
||||||
|
|
||||||
|
## Objet de pre.012
|
||||||
|
|
||||||
|
Construire la première frontière runtime complète possédée par Config :
|
||||||
|
|
||||||
|
```text
|
||||||
|
cfg.std.logging
|
||||||
|
-> JSON + schema + invariants source
|
||||||
|
-> profil sélectionné
|
||||||
|
-> process > .env > fallback
|
||||||
|
-> real/safe/sensitivity/provenance
|
||||||
|
-> validation effective
|
||||||
|
-> ksp_logging_lib::LoggingSettings
|
||||||
|
```
|
||||||
|
|
||||||
|
Cette tranche ne modifie pas encore les documents Config et ne persiste rien.
|
||||||
|
|
||||||
|
## `ResolvedLoggingConfig`
|
||||||
|
|
||||||
|
Nouveau contrat public :
|
||||||
|
|
||||||
|
```text
|
||||||
|
ResolvedLoggingConfig
|
||||||
|
```
|
||||||
|
|
||||||
|
Il conserve :
|
||||||
|
|
||||||
|
```text
|
||||||
|
file_id
|
||||||
|
source_path
|
||||||
|
profile_id
|
||||||
|
selection_source
|
||||||
|
effective ResolvedConfigJson
|
||||||
|
logs_directory résolu
|
||||||
|
LoggingSettings
|
||||||
|
```
|
||||||
|
|
||||||
|
Son `Debug` manuel n'affiche pas directement `LoggingSettings` ni le root réel. Il s'appuie sur `ResolvedConfigJson::Debug`, donc sur l'arbre sûr/redacted.
|
||||||
|
|
||||||
|
Les consumers légitimes peuvent obtenir :
|
||||||
|
|
||||||
|
```text
|
||||||
|
settings()
|
||||||
|
into_settings()
|
||||||
|
logs_directory()
|
||||||
|
effective()
|
||||||
|
```
|
||||||
|
|
||||||
|
Le `LoggingGuard` n'est jamais stocké par Config : il reste détenu par l'orchestration qui appelle `ksp_logging_lib::initialize/reinitialize`.
|
||||||
|
|
||||||
|
## Entrée de l'adapter
|
||||||
|
|
||||||
|
`ConfigDocumentEngine` ajoute :
|
||||||
|
|
||||||
|
```text
|
||||||
|
load_resolved_logging_config(requested_profile, environment)
|
||||||
|
```
|
||||||
|
|
||||||
|
La méthode :
|
||||||
|
|
||||||
|
1. charge `cfg.std.logging` ;
|
||||||
|
2. applique le profil par défaut ou le profil explicite ;
|
||||||
|
3. résout les placeholders avec le snapshot `ConfigEnvironment` ;
|
||||||
|
4. conserve la vue réelle/sûre et la provenance ;
|
||||||
|
5. valide la configuration effective ;
|
||||||
|
6. mappe les valeurs vers les contrats publics `ksp_logging_lib::*` ;
|
||||||
|
7. exécute enfin `LoggingSettings::validate()`.
|
||||||
|
|
||||||
|
## Mapping Logging
|
||||||
|
|
||||||
|
Le mapping couvre explicitement :
|
||||||
|
|
||||||
|
```text
|
||||||
|
LogFilterLevel
|
||||||
|
SpanEvents
|
||||||
|
ConsoleOutput
|
||||||
|
LogFormat
|
||||||
|
FileRotation
|
||||||
|
OutputFilter
|
||||||
|
TargetFilter
|
||||||
|
ConsoleSettings
|
||||||
|
FileSettings[]
|
||||||
|
LoggingSettings
|
||||||
|
```
|
||||||
|
|
||||||
|
Le document commité `local_dev` doit donc produire les deux sinks fichier déjà déclarés et leurs filtres `level/target/domain`, ainsi que les deux overrides globaux de target.
|
||||||
|
|
||||||
|
## `logs_directory`
|
||||||
|
|
||||||
|
Après interpolation :
|
||||||
|
|
||||||
|
- un chemin absolu est conservé ;
|
||||||
|
- un chemin relatif est ancré sur le current working directory du processus au moment de l'adaptation ;
|
||||||
|
- un root inexistant est accepté : Logging créera les répertoires requis lors de l'initialisation des appenders ;
|
||||||
|
- un root existant qui n'est pas un directory est rejeté ;
|
||||||
|
- une erreur filesystem autre que `NotFound` pendant l'inspection est rejetée.
|
||||||
|
|
||||||
|
Le fallback `${KSP_LOGS_DIRECTORY:-logs}` s'applique uniquement si `KSP_LOGS_DIRECTORY` est absent.
|
||||||
|
|
||||||
|
Une valeur explicitement présente mais vide :
|
||||||
|
|
||||||
|
```text
|
||||||
|
KSP_LOGS_DIRECTORY=
|
||||||
|
```
|
||||||
|
|
||||||
|
reste une valeur présente et produit :
|
||||||
|
|
||||||
|
```text
|
||||||
|
config.effective_config_invalid
|
||||||
|
```
|
||||||
|
|
||||||
|
Elle ne retombe jamais silencieusement sur `logs`.
|
||||||
|
|
||||||
|
## Chemins des sinks
|
||||||
|
|
||||||
|
Les `files[].path` restent relatifs sous `logs_directory`.
|
||||||
|
|
||||||
|
L'invariant est désormais contrôlé deux fois :
|
||||||
|
|
||||||
|
1. sur le document source ;
|
||||||
|
2. après interpolation environnementale.
|
||||||
|
|
||||||
|
La seconde validation empêche par exemple une valeur environnementale de transformer dynamiquement un path relatif en :
|
||||||
|
|
||||||
|
```text
|
||||||
|
../outside.log
|
||||||
|
/var/log/outside.log
|
||||||
|
```
|
||||||
|
|
||||||
|
L'adapter sépare ensuite le path relatif en :
|
||||||
|
|
||||||
|
```text
|
||||||
|
directory relatif
|
||||||
|
file-name prefix
|
||||||
|
```
|
||||||
|
|
||||||
|
puis construit `FileSettings` sous le root Logging résolu.
|
||||||
|
|
||||||
|
## Frontière secrets
|
||||||
|
|
||||||
|
Le document standard Logging n'a aucun besoin fonctionnel de secret.
|
||||||
|
|
||||||
|
L'adapter refuse donc toute configuration effective dont `ResolvedConfigJson::sensitivity()` est `Secret`.
|
||||||
|
|
||||||
|
Cette règle évite de transmettre une vraie valeur secrète à `LoggingSettings`, puis potentiellement à des diagnostics filesystem de `ksp-logging-lib`.
|
||||||
|
|
||||||
|
Les erreurs finales de `LoggingSettings::validate()` sont encapsulées dans `config.effective_config_invalid` avec seulement :
|
||||||
|
|
||||||
|
```text
|
||||||
|
logging_error_domain
|
||||||
|
logging_error_code
|
||||||
|
```
|
||||||
|
|
||||||
|
Les contextes internes de l'erreur Logging ne sont pas recopiés par Config.
|
||||||
|
|
||||||
|
## Démonstration runtime
|
||||||
|
|
||||||
|
Un test Config utilise le document commité, un root temporaire et le vrai runtime Logging afin de démontrer :
|
||||||
|
|
||||||
|
```text
|
||||||
|
Config -> LoggingSettings -> initialize -> reinitialize
|
||||||
|
```
|
||||||
|
|
||||||
|
Le test vérifie aussi que l'initialisation des appenders crée les sous-répertoires configurés lorsqu'ils n'existent pas, puis recharge une configuration sans outputs avant cleanup.
|
||||||
|
|
||||||
|
## Diagnostics et erreurs
|
||||||
|
|
||||||
|
Nouveau code stable :
|
||||||
|
|
||||||
|
```text
|
||||||
|
config.effective_config_invalid
|
||||||
|
```
|
||||||
|
|
||||||
|
Il distingue une source JSON/schema valide d'une configuration devenue invalide après environnement/adaptation runtime.
|
||||||
|
|
||||||
|
Les diagnostics Config de paths utilisent une représentation sûre lorsque la valeur pourrait provenir d'un resolver détaillé.
|
||||||
|
|
||||||
|
## Règles documentaires
|
||||||
|
|
||||||
|
Deux règles KSP deviennent durables :
|
||||||
|
|
||||||
|
```text
|
||||||
|
KSP-CONFIG-012
|
||||||
|
KSP-CONFIG-013
|
||||||
|
```
|
||||||
|
|
||||||
|
Elles fixent respectivement :
|
||||||
|
|
||||||
|
- la sémantique absolu/relatif/CWD/fallback de `logs_directory` ;
|
||||||
|
- l'interdiction de secrets dans le standard Logging effectif.
|
||||||
|
|
||||||
|
`FILE_CONTRACTS.md` enregistre également la revalidation post-interpolation de `files[].path`.
|
||||||
|
|
||||||
|
## `.env.example`
|
||||||
|
|
||||||
|
Aucune nouvelle variable runtime n'est introduite.
|
||||||
|
|
||||||
|
`.env.example` reste inchangé :
|
||||||
|
|
||||||
|
```text
|
||||||
|
KSP_LOGS_DIRECTORY=logs
|
||||||
|
```
|
||||||
|
|
||||||
|
## Dépendances
|
||||||
|
|
||||||
|
Aucune nouvelle dépendance Cargo.
|
||||||
|
|
||||||
|
La direction reste :
|
||||||
|
|
||||||
|
```text
|
||||||
|
ksp-config-lib -> ksp-logging-lib
|
||||||
|
ksp-logging-lib -X-> ksp-config-lib
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tests ajoutés
|
||||||
|
|
||||||
|
Le nouveau module `unit_tests/logging.rs` couvre notamment :
|
||||||
|
|
||||||
|
- mapping complet du profil `local_dev` ;
|
||||||
|
- root relatif ancré au CWD ;
|
||||||
|
- root absolu conservé ;
|
||||||
|
- valeur explicite vide rejetée sans fallback ;
|
||||||
|
- root existant non-directory rejeté ;
|
||||||
|
- paths fichier effectifs sans escape ;
|
||||||
|
- frontière Secret de Logging et canary sans fuite ;
|
||||||
|
- `Debug` de `ResolvedLoggingConfig` ;
|
||||||
|
- `initialize/reinitialize` réel et création des répertoires de sinks.
|
||||||
|
|
||||||
|
La surface publique ajoute un test d'adressabilité de `ResolvedLoggingConfig`, de la méthode adapter et du nouveau code d'erreur.
|
||||||
|
|
||||||
|
Après ajout, la crate contient 70 tests unitaires Config et 10 tests publics à exécuter chez l'utilisateur.
|
||||||
|
|
||||||
|
## Fichiers ajoutés
|
||||||
|
|
||||||
|
```text
|
||||||
|
crates/ksp-config-lib/src/logging.rs
|
||||||
|
crates/ksp-config-lib/unit_tests/logging.rs
|
||||||
|
deltas/0.1.3/pre.012.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## Fichiers modifiés
|
||||||
|
|
||||||
|
```text
|
||||||
|
Cargo.toml
|
||||||
|
crates/ksp-config-lib/src/environment.rs
|
||||||
|
crates/ksp-config-lib/src/error.rs
|
||||||
|
crates/ksp-config-lib/src/lib.rs
|
||||||
|
crates/ksp-config-lib/tests/public_api.rs
|
||||||
|
docs/rules/RULES_KSP.md
|
||||||
|
docs/rules/FILE_CONTRACTS.md
|
||||||
|
docs/plans/000-README.md
|
||||||
|
docs/plans/002-FUNCTIONAL_RELEASE_SEQUENCE.md
|
||||||
|
docs/plans/005-V0_1_3_CONFIG_FOUNDATION_PLAN.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## Hors scope
|
||||||
|
|
||||||
|
Toujours hors `pre.012` :
|
||||||
|
|
||||||
|
- mutation/persistence JSON ;
|
||||||
|
- create/update/remove `.env` ;
|
||||||
|
- reveal secret de management ;
|
||||||
|
- application desktop Config ;
|
||||||
|
- watcher/reload automatique de Config ;
|
||||||
|
- autres documents standard Store/Wallet/Transport.
|
||||||
|
|
||||||
|
Ces sujets commencent avec `pre.013` ou les releases prévues ultérieurement.
|
||||||
|
|
||||||
|
## Validation demandée
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo fmt --all
|
||||||
|
cargo check --workspace
|
||||||
|
cargo clippy --workspace --all-targets
|
||||||
|
cargo test --workspace
|
||||||
|
cargo tree -p ksp-config-lib
|
||||||
|
cargo tree -p ksp-config-lib -d
|
||||||
|
cargo tree -p ksp-config-lib -e features
|
||||||
|
cargo tree -p ksp-logging-lib -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Aucune validation Cargo locale n'est revendiquée dans l'environnement de génération de ce delta.
|
||||||
59
deltas/0.1.3/pre.013-fix.001.md
Normal file
59
deltas/0.1.3/pre.013-fix.001.md
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
<!-- file: deltas/0.1.3/pre.013-fix.001.md -->
|
||||||
|
<!-- version: 1 -->
|
||||||
|
|
||||||
|
# Delta 0.1.3-pre.013-fix.001
|
||||||
|
|
||||||
|
## Base requise
|
||||||
|
|
||||||
|
Cette correction s'applique après `0.1.3-pre.013`.
|
||||||
|
|
||||||
|
La validation utilisateur de `pre.013` a détecté une erreur de compilation dans `crates/ksp-config-lib/src/persistence.rs` :
|
||||||
|
|
||||||
|
```text
|
||||||
|
error: expected `,`, found `:`
|
||||||
|
...
|
||||||
|
ksp_logging_lib::warn!(target: "ksp-config-lib", domain: "config.persistence", ...)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Correction
|
||||||
|
|
||||||
|
L'appel de la façade `ksp-logging-lib` utilise désormais la syntaxe structurée supportée par les macros KSP/tracing :
|
||||||
|
|
||||||
|
```rust
|
||||||
|
ksp_logging_lib::warn!(
|
||||||
|
target: "ksp-config-lib",
|
||||||
|
domain = "config.persistence",
|
||||||
|
path = %path.to_string_lossy(),
|
||||||
|
error = %error,
|
||||||
|
"unable to cleanup temporary Config file"
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
Le champ `domain` est un champ structuré de l'événement et utilise donc `=`. La forme `domain:` introduite dans `pre.013` était invalide.
|
||||||
|
|
||||||
|
## Périmètre
|
||||||
|
|
||||||
|
Aucun comportement de management/persistence n'est modifié. Aucun changement de dépendance, schema, Config, `.env` ou `.env.example`.
|
||||||
|
|
||||||
|
La version technique devient :
|
||||||
|
|
||||||
|
```text
|
||||||
|
workspace.package.version = "0.1.3-pre.13.fix.1"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Fichiers modifiés
|
||||||
|
|
||||||
|
```text
|
||||||
|
Cargo.toml
|
||||||
|
crates/ksp-config-lib/src/persistence.rs
|
||||||
|
deltas/0.1.3/pre.013-fix.001.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## Validation utilisateur demandée
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo fmt --all
|
||||||
|
cargo check --workspace
|
||||||
|
cargo clippy --workspace --all-targets
|
||||||
|
cargo test --workspace
|
||||||
|
```
|
||||||
348
deltas/0.1.3/pre.013.md
Normal file
348
deltas/0.1.3/pre.013.md
Normal file
@@ -0,0 +1,348 @@
|
|||||||
|
<!-- file: deltas/0.1.3/pre.013.md -->
|
||||||
|
<!-- version: 1 -->
|
||||||
|
|
||||||
|
# Delta 0.1.3-pre.013
|
||||||
|
|
||||||
|
## Base requise
|
||||||
|
|
||||||
|
Livraison précédente validée :
|
||||||
|
|
||||||
|
```text
|
||||||
|
0.1.3-pre.012
|
||||||
|
```
|
||||||
|
|
||||||
|
Version technique de cette base :
|
||||||
|
|
||||||
|
```text
|
||||||
|
workspace.package.version = "0.1.3-pre.12"
|
||||||
|
Cargo.toml header version = 56
|
||||||
|
```
|
||||||
|
|
||||||
|
Validations utilisateur exécutées le 2026-08-16 :
|
||||||
|
|
||||||
|
```text
|
||||||
|
cargo fmt --all OK
|
||||||
|
cargo check --workspace OK
|
||||||
|
cargo clippy --workspace --all-targets OK
|
||||||
|
cargo test --workspace OK
|
||||||
|
cargo tree -p ksp-config-lib OK
|
||||||
|
cargo tree -p ksp-config-lib -d OK, doublon transitif syn 2/3 déjà connu
|
||||||
|
cargo tree -p ksp-config-lib -e features OK
|
||||||
|
cargo tree -p ksp-logging-lib -d OK, aucun doublon
|
||||||
|
```
|
||||||
|
|
||||||
|
`cargo test --workspace` confirme notamment 70 tests unitaires + 10 tests publics pour `ksp-config-lib` et 34 tests unitaires Logging avec toutes ses intégrations vertes.
|
||||||
|
|
||||||
|
## Objet de pre.013
|
||||||
|
|
||||||
|
Fermer la première surface de management/persistence Config sans introduire Tauri ni permettre aux applications de contourner `ksp-config-lib` :
|
||||||
|
|
||||||
|
```text
|
||||||
|
registered file_id
|
||||||
|
-> management source read
|
||||||
|
-> typed std.logging mutation
|
||||||
|
-> candidate validation
|
||||||
|
-> atomic persistence
|
||||||
|
|
||||||
|
process env (read-only) + .env (read/write)
|
||||||
|
-> desired/effective/shadow report
|
||||||
|
-> explicit reveal when UI management intentionally needs the real value
|
||||||
|
```
|
||||||
|
|
||||||
|
## `ConfigManagement`
|
||||||
|
|
||||||
|
Nouveau contrat public :
|
||||||
|
|
||||||
|
```text
|
||||||
|
ConfigManagement
|
||||||
|
```
|
||||||
|
|
||||||
|
Il possède un `ConfigDocumentEngine` et utilise par défaut le `.env` conventionnel :
|
||||||
|
|
||||||
|
```text
|
||||||
|
./.env
|
||||||
|
```
|
||||||
|
|
||||||
|
Il n'ajoute aucun path runtime Config supplémentaire et ne modifie jamais l'environnement hérité du processus.
|
||||||
|
|
||||||
|
L'authentification/autorisation de l'utilisateur final reste une responsabilité de la future application `ksp-app-config-desk`. Config fournit une frontière explicite qui évite l'exposition accidentelle mais ne prétend pas être un système d'authentification intra-processus.
|
||||||
|
|
||||||
|
## Lecture management
|
||||||
|
|
||||||
|
`ConfigManagement::read_source(file_id)` lit le texte brut d'un document **Config enregistré** par son `file_id`.
|
||||||
|
|
||||||
|
Cette lecture :
|
||||||
|
|
||||||
|
- ne prend jamais un path arbitraire fourni par le caller ;
|
||||||
|
- refuse un `file_id` de kind Schema ;
|
||||||
|
- reste possible lorsque le document est syntaxiquement/schema/sémantiquement invalide, afin qu'une UI de management puisse afficher et corriger sa source ;
|
||||||
|
- retourne `ConfigManagedSource`, dont `Debug` n'expose pas le contenu brut.
|
||||||
|
|
||||||
|
Aucune primitive publique générique « save raw JSON to path » n'est ajoutée.
|
||||||
|
|
||||||
|
## Mutation typée de `std.logging.json`
|
||||||
|
|
||||||
|
Nouveaux contrats source publics :
|
||||||
|
|
||||||
|
```text
|
||||||
|
LoggingConfigDocument
|
||||||
|
LoggingProfileConfig
|
||||||
|
LoggingConsoleConfig
|
||||||
|
LoggingFileConfig
|
||||||
|
LoggingOutputFilterConfig
|
||||||
|
LoggingTargetFilterConfig
|
||||||
|
```
|
||||||
|
|
||||||
|
Ils représentent la forme source de `cfg.std.logging` et permettent une mutation structurée en mémoire.
|
||||||
|
|
||||||
|
La persistance suit obligatoirement :
|
||||||
|
|
||||||
|
```text
|
||||||
|
LoggingConfigDocument
|
||||||
|
-> serde_json candidate
|
||||||
|
-> registered std.logging schema
|
||||||
|
-> KSP profile/logging semantic invariants
|
||||||
|
-> pretty JSON + newline final
|
||||||
|
-> atomic commit
|
||||||
|
```
|
||||||
|
|
||||||
|
Un candidate invalide retourne l'erreur de validation existante et ne touche pas au fichier destination.
|
||||||
|
|
||||||
|
`ConfigDocumentEngine` reçoit uniquement un helper interne `validate_candidate(...)`; la validation reste possédée par Config et n'est pas dupliquée dans la couche management.
|
||||||
|
|
||||||
|
## Persistence atomique
|
||||||
|
|
||||||
|
Nouveau module privé :
|
||||||
|
|
||||||
|
```text
|
||||||
|
src/persistence.rs
|
||||||
|
```
|
||||||
|
|
||||||
|
Stratégie :
|
||||||
|
|
||||||
|
1. créer un fichier temporaire avec `create_new` dans le même répertoire que la destination ;
|
||||||
|
2. appliquer les permissions requises ;
|
||||||
|
3. écrire tout le contenu ;
|
||||||
|
4. `sync_all` le fichier temporaire ;
|
||||||
|
5. fermer le handle ;
|
||||||
|
6. remplacer la destination par `rename` ;
|
||||||
|
7. nettoyer le temporaire si une étape pré-commit échoue.
|
||||||
|
|
||||||
|
La destination reste donc l'ancien fichier complet jusqu'au commit final.
|
||||||
|
|
||||||
|
Les permissions du fichier existant sont conservées. Sur Unix, un nouveau `.env` créé par Config reçoit explicitement :
|
||||||
|
|
||||||
|
```text
|
||||||
|
0600
|
||||||
|
```
|
||||||
|
|
||||||
|
Les documents JSON ne reçoivent pas artificiellement ce mode privé ; un fichier existant conserve son mode.
|
||||||
|
|
||||||
|
Nouveau code d'erreur :
|
||||||
|
|
||||||
|
```text
|
||||||
|
config.persistence_write_failed
|
||||||
|
```
|
||||||
|
|
||||||
|
## Management du `.env`
|
||||||
|
|
||||||
|
Nouvelles opérations :
|
||||||
|
|
||||||
|
```text
|
||||||
|
set_dotenv_value(name, value)
|
||||||
|
remove_dotenv_value(name)
|
||||||
|
reveal_dotenv_value(name)
|
||||||
|
reveal_effective_environment_value(name)
|
||||||
|
environment_report()
|
||||||
|
```
|
||||||
|
|
||||||
|
Mutation autorisée uniquement pour les namespaces déjà possédés par Config :
|
||||||
|
|
||||||
|
```text
|
||||||
|
KSP_*
|
||||||
|
KSPB_*
|
||||||
|
```
|
||||||
|
|
||||||
|
Une mutation ne touche jamais `std::env::set_var/remove_var` et ne prétend donc jamais modifier le shell parent, systemd, Docker/Kubernetes ou l'environnement déjà hérité du processus.
|
||||||
|
|
||||||
|
Avant mutation, le `.env` existant doit être interprétable par la grammaire Config actuelle. Un `.env` invalide est refusé sans altération.
|
||||||
|
|
||||||
|
L'éditeur ciblé :
|
||||||
|
|
||||||
|
- conserve les lignes/commentaires non concernés ;
|
||||||
|
- remplace uniquement l'assignment ciblé ;
|
||||||
|
- encode les valeurs nécessitant espaces, `#`, quotes, backslash ou contrôles avec la forme double-quoted déjà comprise par Config ;
|
||||||
|
- normalise un fichier modifié avec newline final ;
|
||||||
|
- ne réécrit pas un assignment si la valeur persistée est déjà identique ;
|
||||||
|
- ne crée pas le fichier lors d'un remove d'une clé absente.
|
||||||
|
|
||||||
|
## Desired / effective / shadow
|
||||||
|
|
||||||
|
`ConfigEnvironmentReport` n'embarque aucune valeur réelle secrète.
|
||||||
|
|
||||||
|
Il expose :
|
||||||
|
|
||||||
|
```text
|
||||||
|
variable_name
|
||||||
|
sensitivity
|
||||||
|
desired_safe_value # valeur .env persistée
|
||||||
|
effective_safe_value # process sinon .env
|
||||||
|
effective_source
|
||||||
|
shadowed_by_process_environment
|
||||||
|
```
|
||||||
|
|
||||||
|
Une entrée `.env` est `desired`; la valeur héritée du process reste prioritaire et peut donc la shadow.
|
||||||
|
|
||||||
|
`ConfigEnvironmentChangeReport` expose après create/update/remove :
|
||||||
|
|
||||||
|
```text
|
||||||
|
source_changed
|
||||||
|
effective_changed
|
||||||
|
shadowed_by_process_environment
|
||||||
|
reload_required
|
||||||
|
```
|
||||||
|
|
||||||
|
Si le process shadow une modification `.env`, `source_changed = true` mais `effective_changed = false` et `reload_required = false` pour le processus courant.
|
||||||
|
|
||||||
|
## Reveal explicite et secrets
|
||||||
|
|
||||||
|
Les rapports management ordinaires utilisent la représentation sûre :
|
||||||
|
|
||||||
|
```text
|
||||||
|
KSP_SECRET_* / KSPB_SECRET_* -> ********
|
||||||
|
```
|
||||||
|
|
||||||
|
La valeur réelle n'est accessible qu'en appelant explicitement :
|
||||||
|
|
||||||
|
```text
|
||||||
|
reveal_dotenv_value(...)
|
||||||
|
reveal_effective_environment_value(...)
|
||||||
|
```
|
||||||
|
|
||||||
|
Ces méthodes sont destinées à une surface UI management légitimement autorisée. Elles ne changent pas les règles suivantes :
|
||||||
|
|
||||||
|
- jamais de secret réel dans `Debug` ;
|
||||||
|
- jamais de secret réel dans les logs ;
|
||||||
|
- jamais de secret réel dans les diagnostics génériques ;
|
||||||
|
- l'application reste propriétaire de l'autorisation utilisateur.
|
||||||
|
|
||||||
|
## Règles durables
|
||||||
|
|
||||||
|
Ajout de :
|
||||||
|
|
||||||
|
```text
|
||||||
|
KSP-CONFIG-014
|
||||||
|
KSP-CONFIG-015
|
||||||
|
KSP-CONFIG-016
|
||||||
|
```
|
||||||
|
|
||||||
|
pour figer respectivement :
|
||||||
|
|
||||||
|
- la persistence validée/atomique bornée aux ressources Config connues ;
|
||||||
|
- la distinction process read-only / `.env` read-write et le reporting shadow ;
|
||||||
|
- la séparation report sûr / reveal explicite des valeurs sensibles.
|
||||||
|
|
||||||
|
`FILE_CONTRACTS.md` documente également la stratégie de persistence, le newline JSON, la preservation des permissions et le mode `0600` d'un nouveau `.env` sur Unix.
|
||||||
|
|
||||||
|
## `.env.example`
|
||||||
|
|
||||||
|
Aucune nouvelle variable runtime n'est introduite par cette tranche.
|
||||||
|
|
||||||
|
`.env.example` reste donc inchangé :
|
||||||
|
|
||||||
|
```text
|
||||||
|
KSP_LOGS_DIRECTORY=logs
|
||||||
|
```
|
||||||
|
|
||||||
|
Les variables utilisées uniquement comme canaries/tests ne font pas partie de l'inventaire runtime.
|
||||||
|
|
||||||
|
## Dépendances
|
||||||
|
|
||||||
|
Aucune nouvelle dépendance Cargo.
|
||||||
|
|
||||||
|
La direction reste :
|
||||||
|
|
||||||
|
```text
|
||||||
|
ksp-config-lib -> ksp-core-lib
|
||||||
|
ksp-config-lib -> ksp-logging-lib
|
||||||
|
ksp-logging-lib -X-> ksp-config-lib
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tests ajoutés
|
||||||
|
|
||||||
|
Le nouveau module `unit_tests/management.rs` ajoute 10 tests couvrant notamment :
|
||||||
|
|
||||||
|
- lecture raw d'un source schema-invalide ;
|
||||||
|
- mutation typée + persistance + reload de `std.logging.json` ;
|
||||||
|
- candidate Logging invalide sans altération du fichier ;
|
||||||
|
- save identique sans reload ;
|
||||||
|
- create/update/remove `.env` ;
|
||||||
|
- quoting et preservation de commentaires/lignes externes ;
|
||||||
|
- `.env` invalide non modifié ;
|
||||||
|
- namespace externe refusé sans création de `.env` ;
|
||||||
|
- report secret redacted + reveal explicite du canary ;
|
||||||
|
- process shadowing desired `.env` sans faux changement effectif ;
|
||||||
|
- mode `0600` du nouveau `.env` sur Unix.
|
||||||
|
|
||||||
|
La surface publique ajoute un test d'adressabilité des contrats management.
|
||||||
|
|
||||||
|
Après ajout, `ksp-config-lib` doit compter :
|
||||||
|
|
||||||
|
```text
|
||||||
|
80 tests unitaires
|
||||||
|
11 tests publics
|
||||||
|
```
|
||||||
|
|
||||||
|
à exécuter chez l'utilisateur.
|
||||||
|
|
||||||
|
## Fichiers ajoutés
|
||||||
|
|
||||||
|
```text
|
||||||
|
crates/ksp-config-lib/src/management.rs
|
||||||
|
crates/ksp-config-lib/src/persistence.rs
|
||||||
|
crates/ksp-config-lib/unit_tests/management.rs
|
||||||
|
deltas/0.1.3/pre.013.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## Fichiers modifiés
|
||||||
|
|
||||||
|
```text
|
||||||
|
Cargo.toml
|
||||||
|
crates/ksp-config-lib/src/document.rs
|
||||||
|
crates/ksp-config-lib/src/environment.rs
|
||||||
|
crates/ksp-config-lib/src/error.rs
|
||||||
|
crates/ksp-config-lib/src/lib.rs
|
||||||
|
crates/ksp-config-lib/tests/public_api.rs
|
||||||
|
docs/rules/RULES_KSP.md
|
||||||
|
docs/rules/FILE_CONTRACTS.md
|
||||||
|
docs/plans/000-README.md
|
||||||
|
docs/plans/002-FUNCTIONAL_RELEASE_SEQUENCE.md
|
||||||
|
docs/plans/005-V0_1_3_CONFIG_FOUNDATION_PLAN.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## Hors scope
|
||||||
|
|
||||||
|
Toujours hors `pre.013` :
|
||||||
|
|
||||||
|
- application desktop Config/Tauri ;
|
||||||
|
- watcher automatique de fichiers ;
|
||||||
|
- rechargement automatique du runtime après save ;
|
||||||
|
- mutation générique de documents arbitraires ;
|
||||||
|
- management d'un environnement parent/systemd/container ;
|
||||||
|
- autres documents standard Store/Wallet/Transport ;
|
||||||
|
- audit global empêchant tous les futurs contournements Config hors crate, prévu en `pre.014`.
|
||||||
|
|
||||||
|
## Validation demandée
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo fmt --all
|
||||||
|
cargo check --workspace
|
||||||
|
cargo clippy --workspace --all-targets
|
||||||
|
cargo test --workspace
|
||||||
|
cargo tree -p ksp-config-lib
|
||||||
|
cargo tree -p ksp-config-lib -d
|
||||||
|
cargo tree -p ksp-config-lib -e features
|
||||||
|
cargo tree -p ksp-logging-lib -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Aucune validation Cargo locale n'est revendiquée dans l'environnement de génération de ce delta.
|
||||||
245
deltas/0.1.3/pre.014.md
Normal file
245
deltas/0.1.3/pre.014.md
Normal file
@@ -0,0 +1,245 @@
|
|||||||
|
<!-- file: deltas/0.1.3/pre.014.md -->
|
||||||
|
<!-- version: 1 -->
|
||||||
|
|
||||||
|
# Delta 0.1.3-pre.014
|
||||||
|
|
||||||
|
## Base requise
|
||||||
|
|
||||||
|
Livraison précédente validée :
|
||||||
|
|
||||||
|
```text
|
||||||
|
0.1.3-pre.013-fix.001
|
||||||
|
```
|
||||||
|
|
||||||
|
Version technique de cette base :
|
||||||
|
|
||||||
|
```text
|
||||||
|
workspace.package.version = "0.1.3-pre.13.fix.1"
|
||||||
|
Cargo.toml header version = 58
|
||||||
|
```
|
||||||
|
|
||||||
|
Validations utilisateur exécutées le 2026-08-16 :
|
||||||
|
|
||||||
|
```text
|
||||||
|
cargo fmt --all OK
|
||||||
|
cargo check --workspace OK
|
||||||
|
cargo clippy --workspace --all-targets OK
|
||||||
|
cargo test --workspace OK
|
||||||
|
cargo tree -p ksp-config-lib OK
|
||||||
|
cargo tree -p ksp-config-lib -d OK, doublon transitif syn 2/3 déjà connu
|
||||||
|
cargo tree -p ksp-config-lib -e features OK
|
||||||
|
cargo tree -p ksp-logging-lib OK
|
||||||
|
```
|
||||||
|
|
||||||
|
`cargo test --workspace` confirme notamment 80 tests unitaires + 11 tests publics pour `ksp-config-lib`, 34 tests unitaires Logging et toutes les intégrations existantes vertes.
|
||||||
|
|
||||||
|
## Objet de pre.014
|
||||||
|
|
||||||
|
Transformer les principales frontières d'ownership Config en audits exécutables avant la clôture :
|
||||||
|
|
||||||
|
```text
|
||||||
|
workspace source/manifests/config
|
||||||
|
-> ownership audit
|
||||||
|
-> fail cargo test on boundary regression
|
||||||
|
|
||||||
|
config JSON + production Rust
|
||||||
|
-> concrete KSP/KSPB environment names
|
||||||
|
-> .env.example inventory coverage
|
||||||
|
```
|
||||||
|
|
||||||
|
Cette tranche ne crée aucun nouveau runtime Config et ne modifie aucune API métier.
|
||||||
|
|
||||||
|
## Audit de direction des dépendances fondatrices
|
||||||
|
|
||||||
|
Le nouveau test d'intégration vérifie explicitement que :
|
||||||
|
|
||||||
|
```text
|
||||||
|
ksp-core-lib -X-> ksp-config-lib
|
||||||
|
ksp-logging-lib -X-> ksp-config-lib
|
||||||
|
```
|
||||||
|
|
||||||
|
La direction autorisée reste :
|
||||||
|
|
||||||
|
```text
|
||||||
|
ksp-config-lib -> ksp-core-lib
|
||||||
|
ksp-config-lib -> ksp-logging-lib
|
||||||
|
```
|
||||||
|
|
||||||
|
L'audit est volontairement limité aux deux fondations qui ne doivent jamais dépendre de Config. Les futures applications/crates pourront, elles, dépendre normalement de `ksp-config-lib`.
|
||||||
|
|
||||||
|
## Audit des lectures d'environnement KSP/KSPB
|
||||||
|
|
||||||
|
Pour toutes les crates autres que `ksp-config-lib`, l'audit recherche les bypass directs simples de la frontière Config :
|
||||||
|
|
||||||
|
```text
|
||||||
|
std::env::var("KSP_...")
|
||||||
|
std::env::var("KSPB_...")
|
||||||
|
std::env::var_os("KSP_...")
|
||||||
|
std::env::var_os("KSPB_...")
|
||||||
|
env::var(...)
|
||||||
|
env::var_os(...)
|
||||||
|
std::env::vars()
|
||||||
|
std::env::vars_os()
|
||||||
|
```
|
||||||
|
|
||||||
|
L'audit ne prétend pas interdire toute utilisation possible de `std::env` pour des informations système non applicatives. Il verrouille spécifiquement la frontière des variables KSP/KSPB et l'énumération globale qui permettrait de la contourner.
|
||||||
|
|
||||||
|
## Audit des noms physiques gérés
|
||||||
|
|
||||||
|
Les crates hors Config ne doivent pas coder en dur les ressources physiques déjà possédées par Config :
|
||||||
|
|
||||||
|
```text
|
||||||
|
.env
|
||||||
|
std.logging.json
|
||||||
|
std.logging.schema.json
|
||||||
|
composite.schema.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Un consumer doit utiliser les contrats publics, constantes et `file_id` Config plutôt que recréer localement la connaissance du layout physique.
|
||||||
|
|
||||||
|
L'audit ignore les lignes de commentaire Rust afin qu'une documentation locale ne soit pas confondue avec un accès runtime.
|
||||||
|
|
||||||
|
## Inventaire `.env.example`
|
||||||
|
|
||||||
|
Le test collecte automatiquement les noms KSP/KSPB concrets trouvés dans :
|
||||||
|
|
||||||
|
```text
|
||||||
|
config/**/*.json
|
||||||
|
crates/*/src/**/*.rs
|
||||||
|
```
|
||||||
|
|
||||||
|
Les préfixes génériques tels que `KSP_SECRET_` ou `KSPB_PUBLIC_` ne sont pas considérés comme des variables concrètes.
|
||||||
|
|
||||||
|
Chaque variable runtime concrète détectée doit apparaître dans `.env.example` sous forme d'assignment active ou commentée et cette assignment doit être précédée d'un commentaire explicatif distinct des headers de fichier/version.
|
||||||
|
|
||||||
|
Dans l'état actuel, l'inventaire concret reste :
|
||||||
|
|
||||||
|
```text
|
||||||
|
KSP_LOGS_DIRECTORY
|
||||||
|
```
|
||||||
|
|
||||||
|
Aucune nouvelle variable runtime n'est introduite par `pre.014`.
|
||||||
|
|
||||||
|
## Réaudit de robustesse existante
|
||||||
|
|
||||||
|
Les tests déjà acquis avant `pre.014` couvrent notamment :
|
||||||
|
|
||||||
|
- JSON malformed, schema invalid et document schema-invalide ;
|
||||||
|
- invariants sémantiques Logging ;
|
||||||
|
- profils dupliqués/default_profile invalide/profil explicite absent ;
|
||||||
|
- composite references et component ids invalides ;
|
||||||
|
- filenames/mappings `file_id` invalides ;
|
||||||
|
- `.env` syntaxiquement invalide, doublons, namespaces externes et valeurs vides ;
|
||||||
|
- placeholders malformés, variables absentes et fallbacks ;
|
||||||
|
- priorité process > `.env` > fallback ;
|
||||||
|
- sensibilité `Public/Internal/Secret`, real/safe/provenance et canaries de non-divulgation ;
|
||||||
|
- validation effective `logs_directory` et confinement des `files[].path` après interpolation ;
|
||||||
|
- rejet d'un secret dans `std.logging` ;
|
||||||
|
- management desired/effective/shadow, reveal explicite et persistence atomique ;
|
||||||
|
- candidate JSON invalide sans modification du destination ;
|
||||||
|
- permissions `.env` privées à la création Unix.
|
||||||
|
|
||||||
|
Aucune lacune nécessitant une nouvelle API publique n'a été identifiée dans cette tranche. Les responsabilités restent séparées entre :
|
||||||
|
|
||||||
|
```text
|
||||||
|
ConfigBootstrapOptions / ConfigFileRegistry
|
||||||
|
ConfigDocumentEngine
|
||||||
|
ConfigEnvironment
|
||||||
|
ConfigManagement
|
||||||
|
ResolvedLoggingConfig adapter
|
||||||
|
ksp-logging-lib runtime ownership
|
||||||
|
```
|
||||||
|
|
||||||
|
## Règles durables
|
||||||
|
|
||||||
|
Ajout de :
|
||||||
|
|
||||||
|
```text
|
||||||
|
KSP-CONFIG-017
|
||||||
|
KSP-CONFIG-018
|
||||||
|
```
|
||||||
|
|
||||||
|
pour figer :
|
||||||
|
|
||||||
|
- les audits exécutables d'ownership Config au niveau workspace ;
|
||||||
|
- la vérification automatique de la couverture `.env.example`.
|
||||||
|
|
||||||
|
## Dépendances
|
||||||
|
|
||||||
|
Aucune nouvelle dépendance Cargo.
|
||||||
|
|
||||||
|
Le seul doublon transitif attendu dans le graphe Config reste `syn 2` / `syn 3` via `jsonschema`. Aucun contournement de cette dépendance n'est introduit ici.
|
||||||
|
|
||||||
|
## Tests ajoutés
|
||||||
|
|
||||||
|
Nouveau fichier :
|
||||||
|
|
||||||
|
```text
|
||||||
|
crates/ksp-config-lib/tests/ownership.rs
|
||||||
|
```
|
||||||
|
|
||||||
|
Quatre tests d'intégration workspace :
|
||||||
|
|
||||||
|
```text
|
||||||
|
foundational_dependency_direction_does_not_point_back_to_config
|
||||||
|
workspace_crates_do_not_read_ksp_environment_directly
|
||||||
|
workspace_crates_do_not_hardcode_config_managed_physical_files
|
||||||
|
dotenv_example_covers_runtime_environment_names_with_comments
|
||||||
|
```
|
||||||
|
|
||||||
|
Les 80 tests unitaires et 11 tests de `tests/public_api.rs` restent inchangés ; `cargo test --workspace` exécute désormais en plus ce nouveau binaire d'intégration à 4 tests.
|
||||||
|
|
||||||
|
## Version technique
|
||||||
|
|
||||||
|
```text
|
||||||
|
workspace.package.version = "0.1.3-pre.14"
|
||||||
|
Cargo.toml header version = 59
|
||||||
|
```
|
||||||
|
|
||||||
|
La version change car la tranche ajoute du code Rust de test participant au build/test du workspace.
|
||||||
|
|
||||||
|
## Fichiers ajoutés
|
||||||
|
|
||||||
|
```text
|
||||||
|
crates/ksp-config-lib/tests/ownership.rs
|
||||||
|
deltas/0.1.3/pre.014.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## Fichiers modifiés
|
||||||
|
|
||||||
|
```text
|
||||||
|
Cargo.toml
|
||||||
|
docs/rules/RULES_KSP.md
|
||||||
|
docs/plans/000-README.md
|
||||||
|
docs/plans/002-FUNCTIONAL_RELEASE_SEQUENCE.md
|
||||||
|
docs/plans/005-V0_1_3_CONFIG_FOUNDATION_PLAN.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## Contrôles statiques réalisés avant livraison
|
||||||
|
|
||||||
|
Dans l'environnement de préparation :
|
||||||
|
|
||||||
|
- reconstruction de la base par application ordonnée de tous les deltas jusqu'à `pre.013-fix.001` ;
|
||||||
|
- inventaire workspace : aucune lecture directe `std::env::var*` KSP/KSPB hors Config détectée ;
|
||||||
|
- aucune référence physique gérée interdite détectée hors Config ;
|
||||||
|
- `KSP_LOGS_DIRECTORY` est la seule variable runtime concrète détectée et figure dans `.env.example` avec commentaire ;
|
||||||
|
- aucun `.env` runtime local n'est inclus dans le delta ;
|
||||||
|
- aucune nouvelle dépendance Cargo ;
|
||||||
|
- réapplication du delta sur la base reconstruite vérifiée séparément.
|
||||||
|
|
||||||
|
Aucune commande Cargo n'est déclarée réussie dans l'environnement de préparation lorsqu'elle n'y est pas disponible.
|
||||||
|
|
||||||
|
## Validation demandée
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo fmt --all
|
||||||
|
cargo check --workspace
|
||||||
|
cargo clippy --workspace --all-targets
|
||||||
|
cargo test --workspace
|
||||||
|
cargo tree -p ksp-config-lib
|
||||||
|
cargo tree -p ksp-config-lib -d
|
||||||
|
cargo tree -p ksp-config-lib -e features
|
||||||
|
cargo tree -p ksp-logging-lib -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Après validation, `0.1.3-pre.015` pourra ouvrir la clôture : validations finales, documentation durable, changelog, nettoyage/archivage et prompt `0.1.4 — ksp-app-config-desk`.
|
||||||
93
deltas/0.1.3/pre.015-fix.001.md
Normal file
93
deltas/0.1.3/pre.015-fix.001.md
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
<!-- file: deltas/0.1.3/pre.015-fix.001.md -->
|
||||||
|
<!-- version: 1 -->
|
||||||
|
|
||||||
|
# Delta 0.1.3-pre.015-fix.001 — règles de validation, documentation et gabarit Tauri
|
||||||
|
|
||||||
|
## Base requise
|
||||||
|
|
||||||
|
```text
|
||||||
|
0.1.3-pre.015
|
||||||
|
workspace.package.version = "0.1.3-pre.15"
|
||||||
|
```
|
||||||
|
|
||||||
|
Ce correctif est exclusivement documentaire. Il ne modifie aucun fichier Rust, manifest Cargo, fichier Config runtime, schema ou variable d'environnement. Conformément à la règle de version technique, `workspace.package.version` reste `0.1.3-pre.15`.
|
||||||
|
|
||||||
|
## Objet
|
||||||
|
|
||||||
|
Le correctif fige les conventions demandées avant `rel.001` et avant l'ouverture de `0.1.4 — ksp-app-config-desk`.
|
||||||
|
|
||||||
|
### Validation Cargo
|
||||||
|
|
||||||
|
Après toute modification d'un fichier Rust, une tranche doit exécuter :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo fmt --all
|
||||||
|
cargo check --workspace
|
||||||
|
cargo clippy --workspace --all-targets
|
||||||
|
```
|
||||||
|
|
||||||
|
Pendant le développement courant, les tests privilégient la crate concernée :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo test -p <crate>
|
||||||
|
```
|
||||||
|
|
||||||
|
`cargo test --workspace` est conservé pour les ouvertures/fermetures de session ou de version et les contrôles globaux justifiés. Les audits `cargo tree` pertinents restent exécutés pour les crates travaillées/complétées.
|
||||||
|
|
||||||
|
### Documentation de fin de crate/application
|
||||||
|
|
||||||
|
Une crate/composant complété possède un `README.md`. Une bibliothèque complétée possède un `USAGE.md` durable, sans notes de version, centré sur sa surface publique et fournissant des exemples d'utilisation des APIs publiques directement consommables. Une application Tauri complétée possède un `USAGE.md` décrivant ses fenêtres et leur utilisation.
|
||||||
|
|
||||||
|
`crates/ksp-config-lib/USAGE.md` est renforcé immédiatement pour servir de premier exemple de cette convention.
|
||||||
|
|
||||||
|
### Gabarit Tauri de référence
|
||||||
|
|
||||||
|
`ksp-app-config-desk` doit servir de modèle aux futures applications Tauri KSP et réutiliser/refondre les acquis utiles de khadhroony-bot3 : SASS/SCSS, packages frontend, gabarit et icône, Vite/TypeScript, package Rust `lib` + `bin` aux noms distincts, launcher `main.rs` minimal, `lib.rs` déclaratif, `tauri.rs` propriétaire de `run` et des wrappers `#[tauri::command]`, modules de fenêtres `tw_*`, helpers communs, single-instance et splashscreen factorisé.
|
||||||
|
|
||||||
|
La temporisation du splashscreen est pilotée par Config/.env. Le nom de la variable sera choisi quand l'implémentation en aura réellement besoin puis ajouté dans le même delta à `.env.example` avec commentaire.
|
||||||
|
|
||||||
|
Le prompt corrige aussi le layout applicatif : la crate est `crates/ksp-app-config-desk`, pas `apps/ksp-app-config-desk`.
|
||||||
|
|
||||||
|
### `PRESENTATION.md`
|
||||||
|
|
||||||
|
Le `README.md` d'une application Tauri n'est jamais utilisé comme contenu de présentation embarqué. Si une application possède une vue de présentation Markdown, elle utilise `PRESENTATION.md`; `markdown-it` reste le renderer de référence hérité de bot3 lorsque ce besoin existe et sa version doit être vérifiée avant ajout.
|
||||||
|
|
||||||
|
`PRESENTATION.md` ne contient aucun lien navigable Markdown/HTML susceptible de casser la navigation ou le lifecycle des fenêtres Tauri. Une application monofenêtre sans présentation ne crée pas ce fichier et n'ajoute pas `markdown-it` uniquement par symétrie.
|
||||||
|
|
||||||
|
## Fichiers modifiés
|
||||||
|
|
||||||
|
```text
|
||||||
|
docs/rules/RULES_KSP.md
|
||||||
|
docs/rules/FILE_CONTRACTS.md
|
||||||
|
prompts/004-V0_1_4_START_PROMPT.md
|
||||||
|
crates/ksp-config-lib/USAGE.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## Fichier ajouté
|
||||||
|
|
||||||
|
```text
|
||||||
|
deltas/0.1.3/pre.015-fix.001.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## Version Cargo
|
||||||
|
|
||||||
|
Aucune modification :
|
||||||
|
|
||||||
|
```text
|
||||||
|
workspace.package.version = "0.1.3-pre.15"
|
||||||
|
```
|
||||||
|
|
||||||
|
Le fix ne touche aucun artefact participant au code/build/runtime/config/migrations.
|
||||||
|
|
||||||
|
## Validation du correctif
|
||||||
|
|
||||||
|
Le correctif documentaire est contrôlé par :
|
||||||
|
|
||||||
|
- headers `file:` / `version:` ;
|
||||||
|
- cohérence des références `crates/ksp-app-config-desk` ;
|
||||||
|
- absence de modification Rust/Cargo/config ;
|
||||||
|
- absence de nouvelle variable runtime ou modification `.env.example` ;
|
||||||
|
- équilibre des fences Markdown ;
|
||||||
|
- archive limitée aux cinq fichiers ci-dessus.
|
||||||
|
|
||||||
|
Aucune commande Cargo n'est requise spécifiquement pour ce fix documentaire. La validation globale de fermeture de `pre.015` reste cependant celle prévue avant `rel.001`, incluant `cargo test --workspace` parce que la version est en clôture.
|
||||||
78
deltas/0.1.3/pre.015-fix.002.md
Normal file
78
deltas/0.1.3/pre.015-fix.002.md
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
<!-- file: deltas/0.1.3/pre.015-fix.002.md -->
|
||||||
|
<!-- version: 1 -->
|
||||||
|
|
||||||
|
# Delta 0.1.3-pre.015-fix.002 — tracing Tauri et critères de clôture de ksp-app-config-desk
|
||||||
|
|
||||||
|
## Base requise
|
||||||
|
|
||||||
|
```text
|
||||||
|
0.1.3-pre.015-fix.001
|
||||||
|
workspace.package.version = "0.1.3-pre.15"
|
||||||
|
```
|
||||||
|
|
||||||
|
Ce correctif est exclusivement documentaire. Il ne modifie aucun fichier Rust, manifest Cargo, fichier Config runtime, schema ou variable d'environnement. `workspace.package.version` reste donc `0.1.3-pre.15`.
|
||||||
|
|
||||||
|
## Objet
|
||||||
|
|
||||||
|
Le correctif complète les règles du gabarit Tauri de référence et les critères fonctionnels de `0.1.4`.
|
||||||
|
|
||||||
|
### Intégration tracing des applications Tauri
|
||||||
|
|
||||||
|
Les applications Tauri KSP restent dans l'écosystème `tracing` :
|
||||||
|
|
||||||
|
- `ksp-logging-lib` reste propriétaire de la configuration et du runtime `tracing` ;
|
||||||
|
- la frontière desktop utilise le plugin Tauri de tracing retenu (`tauri-plugin-tracing` ou successeur explicitement validé) et des adapters similaires à ceux éprouvés dans khadhroony-bot3 ;
|
||||||
|
- l'application ne possède/configure pas directement `tracing-subscriber` ou `tracing-appender` ;
|
||||||
|
- `tauri-plugin-log` et une seconde pile fondée sur la crate `log` sont interdits sauf future décision architecturale explicite remplaçant cette règle.
|
||||||
|
|
||||||
|
Le `pre.001` de `0.1.4` devra vérifier la version réellement actuelle/compatible du plugin avant ajout de la dépendance.
|
||||||
|
|
||||||
|
### Critères minimaux de clôture de `0.1.4`
|
||||||
|
|
||||||
|
La première version de `ksp-app-config-desk` ne pourra être considérée comme validée que si l'application permet réellement de :
|
||||||
|
|
||||||
|
1. créer et modifier plusieurs profils Logging depuis l'UI ;
|
||||||
|
2. configurer au minimum un profil mono-fichier ;
|
||||||
|
3. configurer au minimum un profil multi-fichiers avec sorties séparées et sortie logiciel/console ;
|
||||||
|
4. sauvegarder ces modifications via les APIs de management de `ksp-config-lib` ;
|
||||||
|
5. sélectionner/appliquer un profil ;
|
||||||
|
6. recharger à chaud la configuration Logging ;
|
||||||
|
7. constater effectivement le nouveau routage/format/sink sans redémarrage, et pas seulement obtenir un retour `Ok` d'une commande de reload ;
|
||||||
|
8. conserver le runtime Logging précédent lorsqu'une nouvelle configuration est invalide.
|
||||||
|
|
||||||
|
### Extensibilité du gestionnaire Config
|
||||||
|
|
||||||
|
`ksp-app-config-desk` doit devenir un shell de management extensible. La première version peut implémenter un éditeur Logging spécialisé et typé, mais son architecture ne doit pas être figée sur `std.logging.json` : de futurs `file_id`, schemas et éditeurs/panneaux spécialisés devront pouvoir être ajoutés sans déplacer parsing, validation ou persistence hors de `ksp-config-lib`.
|
||||||
|
|
||||||
|
## Fichiers modifiés
|
||||||
|
|
||||||
|
```text
|
||||||
|
docs/rules/RULES_KSP.md
|
||||||
|
prompts/004-V0_1_4_START_PROMPT.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## Fichier ajouté
|
||||||
|
|
||||||
|
```text
|
||||||
|
deltas/0.1.3/pre.015-fix.002.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## Version Cargo
|
||||||
|
|
||||||
|
Aucune modification :
|
||||||
|
|
||||||
|
```text
|
||||||
|
workspace.package.version = "0.1.3-pre.15"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Validation du correctif
|
||||||
|
|
||||||
|
Le correctif étant documentaire :
|
||||||
|
|
||||||
|
- vérifier les headers `file:` / `version:` ;
|
||||||
|
- vérifier l'absence de modification Rust/Cargo/config ;
|
||||||
|
- vérifier la cohérence entre les règles KSP et le prompt `0.1.4` ;
|
||||||
|
- vérifier que `tauri-plugin-log` n'est cité que comme dépendance interdite ;
|
||||||
|
- vérifier que les critères de clôture multi-profils + hot reload sont explicitement présents.
|
||||||
|
|
||||||
|
Aucune commande Cargo n'est requise spécifiquement pour ce fix documentaire. La validation globale de fermeture de `0.1.3` reste requise avant `rel.001`.
|
||||||
242
deltas/0.1.3/pre.015.md
Normal file
242
deltas/0.1.3/pre.015.md
Normal file
@@ -0,0 +1,242 @@
|
|||||||
|
<!-- file: deltas/0.1.3/pre.015.md -->
|
||||||
|
<!-- version: 1 -->
|
||||||
|
|
||||||
|
# Delta 0.1.3-pre.015 — clôture Config
|
||||||
|
|
||||||
|
## Base requise
|
||||||
|
|
||||||
|
Livraison précédente validée :
|
||||||
|
|
||||||
|
```text
|
||||||
|
0.1.3-pre.014
|
||||||
|
workspace.package.version = "0.1.3-pre.14"
|
||||||
|
Cargo.toml header version = 59
|
||||||
|
```
|
||||||
|
|
||||||
|
## Validation de `pre.014`
|
||||||
|
|
||||||
|
Validations utilisateur exécutées le 2026-08-16 :
|
||||||
|
|
||||||
|
```text
|
||||||
|
cargo fmt --all OK
|
||||||
|
cargo check --workspace OK
|
||||||
|
cargo clippy --workspace --all-targets OK
|
||||||
|
cargo test --workspace OK
|
||||||
|
cargo tree -p ksp-config-lib OK
|
||||||
|
cargo tree -p ksp-config-lib -d OK, doublon transitif syn 2/3 connu via jsonschema
|
||||||
|
cargo tree -p ksp-config-lib -e features OK
|
||||||
|
cargo tree -p ksp-logging-lib -d OK, aucun doublon
|
||||||
|
```
|
||||||
|
|
||||||
|
Le test workspace confirme :
|
||||||
|
|
||||||
|
```text
|
||||||
|
ksp-config-lib unit tests 80 passed
|
||||||
|
Config ownership integration 4 passed
|
||||||
|
Config public API 11 passed
|
||||||
|
ksp-core-lib unit 14 passed
|
||||||
|
Core public API 3 passed
|
||||||
|
ksp-logging-lib unit 34 passed
|
||||||
|
Logging integrations all passed
|
||||||
|
```
|
||||||
|
|
||||||
|
## Objet de `pre.015`
|
||||||
|
|
||||||
|
`pre.015` est la prerelease finale prévue de `0.1.3`.
|
||||||
|
|
||||||
|
Elle ne développe aucune nouvelle capacité Config. Elle :
|
||||||
|
|
||||||
|
- consolide la documentation durable de `ksp-config-lib` ;
|
||||||
|
- ferme ou reporte explicitement les TODO de release ;
|
||||||
|
- introduit le changelog général selon les règles déjà définies ;
|
||||||
|
- prépare le prompt de `0.1.4 — ksp-app-config-desk` ;
|
||||||
|
- réaligne les index et plans ;
|
||||||
|
- prépare les validations finales précédant `rel.001` ;
|
||||||
|
- confirme qu'aucun cleanup destructif supplémentaire n'est nécessaire.
|
||||||
|
|
||||||
|
## Version technique
|
||||||
|
|
||||||
|
Conformément à `VER-ID-009`, une prerelease non-fix synchronise toujours la version Cargo, même lorsque la tranche est principalement documentaire :
|
||||||
|
|
||||||
|
```text
|
||||||
|
workspace.package.version = "0.1.3-pre.15"
|
||||||
|
Cargo.toml header version = 60
|
||||||
|
```
|
||||||
|
|
||||||
|
Aucune dépendance ni feature Cargo ne change.
|
||||||
|
|
||||||
|
## Documentation durable de `ksp-config-lib`
|
||||||
|
|
||||||
|
Ajout de :
|
||||||
|
|
||||||
|
```text
|
||||||
|
crates/ksp-config-lib/README.md
|
||||||
|
crates/ksp-config-lib/USAGE.md
|
||||||
|
crates/ksp-config-lib/TODO.md
|
||||||
|
```
|
||||||
|
|
||||||
|
Le README décrit les responsabilités et frontières stabilisées de Config.
|
||||||
|
|
||||||
|
`USAGE.md` documente :
|
||||||
|
|
||||||
|
- bootstrap + registre ;
|
||||||
|
- chargement/validation ;
|
||||||
|
- environnement effectif ;
|
||||||
|
- mapping Logging ;
|
||||||
|
- profils/composites ;
|
||||||
|
- management du document Logging ;
|
||||||
|
- management `.env` ;
|
||||||
|
- secret reveal explicite ;
|
||||||
|
- `.env.example` ;
|
||||||
|
- frontière Tauri.
|
||||||
|
|
||||||
|
`TODO.md` confirme qu'aucun TODO fonctionnel bloquant ne reste dans `0.1.3`. Les validations desktop/Tauri sont transférées à `0.1.4`; les futurs documents/composites/watchers restent conditionnés à l'apparition d'un besoin concret.
|
||||||
|
|
||||||
|
Le crate-level Rustdoc est rendu durable et ne décrit plus la surface comme limitée à `pre.013`.
|
||||||
|
|
||||||
|
## Changelog général
|
||||||
|
|
||||||
|
Ajout de :
|
||||||
|
|
||||||
|
```text
|
||||||
|
CHANGELOG.md
|
||||||
|
```
|
||||||
|
|
||||||
|
Il applique `DOC-ROOT-008` : uniquement les releases stables et ordre décroissant.
|
||||||
|
|
||||||
|
Il contient à cette étape :
|
||||||
|
|
||||||
|
```text
|
||||||
|
0.1.2
|
||||||
|
0.1.1
|
||||||
|
0.0.3
|
||||||
|
```
|
||||||
|
|
||||||
|
`0.1.3` n'y figure pas encore car `pre.015` n'est pas une release stable. Son entrée sera ajoutée dans `rel.001` après validation finale.
|
||||||
|
|
||||||
|
## Prompt `0.1.4`
|
||||||
|
|
||||||
|
Ajout de :
|
||||||
|
|
||||||
|
```text
|
||||||
|
prompts/004-V0_1_4_START_PROMPT.md
|
||||||
|
```
|
||||||
|
|
||||||
|
Le prompt ne doit être utilisé qu'après publication stable `v0.1.3`.
|
||||||
|
|
||||||
|
Il impose que `0.1.4-pre.001` commence par brainstorming + audit + planification, avant tout développement Tauri significatif, et cadre notamment :
|
||||||
|
|
||||||
|
- application `ksp-app-config-desk` mince au-dessus de Config ;
|
||||||
|
- DTO/Tauri à la frontière applicative ;
|
||||||
|
- management de documents/profils/env ;
|
||||||
|
- reveal de secrets explicitement privilégié ;
|
||||||
|
- ownership applicatif du `LoggingGuard` ;
|
||||||
|
- tests et découpage des prereleases ;
|
||||||
|
- hors-scope de la release.
|
||||||
|
|
||||||
|
## Cleanup / archivage
|
||||||
|
|
||||||
|
Aucune suppression n'est nécessaire.
|
||||||
|
|
||||||
|
Sont conservés intentionnellement :
|
||||||
|
|
||||||
|
- tous les deltas `0.1.3` et leurs fixes ;
|
||||||
|
- les plans historiques `0.1.1`/`0.1.2` ;
|
||||||
|
- le plan `0.1.3` ;
|
||||||
|
- les prompts historiques ;
|
||||||
|
- les schemas et exemples Config ;
|
||||||
|
- `.env.example`.
|
||||||
|
|
||||||
|
Le fichier local `.env` reste ignoré, non échangé et absent du delta.
|
||||||
|
|
||||||
|
Aucun `Cargo.lock`, cache, build output ou archive imbriquée n'est ajouté.
|
||||||
|
|
||||||
|
## TODO et reports
|
||||||
|
|
||||||
|
Audit du code Config avant livraison : aucun marqueur `TODO`, `FIXME`, `XXX` ou `HACK` n'est présent dans les sources/manifest de la crate.
|
||||||
|
|
||||||
|
Les reports utiles sont documentés dans `crates/ksp-config-lib/TODO.md` et concernent principalement :
|
||||||
|
|
||||||
|
- validation desktop `0.1.4` ;
|
||||||
|
- futurs documents standards quand leurs composants existent ;
|
||||||
|
- composites de consumers réels ;
|
||||||
|
- watcher/reload automatique uniquement si un besoin concret l'exige ;
|
||||||
|
- éventuel secrets manager externe plus tard.
|
||||||
|
|
||||||
|
Aucun de ces reports ne bloque `0.1.3`.
|
||||||
|
|
||||||
|
## Fichiers ajoutés
|
||||||
|
|
||||||
|
```text
|
||||||
|
CHANGELOG.md
|
||||||
|
crates/ksp-config-lib/README.md
|
||||||
|
crates/ksp-config-lib/TODO.md
|
||||||
|
crates/ksp-config-lib/USAGE.md
|
||||||
|
prompts/004-V0_1_4_START_PROMPT.md
|
||||||
|
deltas/0.1.3/pre.015.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## Fichiers modifiés
|
||||||
|
|
||||||
|
```text
|
||||||
|
Cargo.toml
|
||||||
|
README.md
|
||||||
|
crates/ksp-config-lib/src/lib.rs
|
||||||
|
docs/000-README.md
|
||||||
|
docs/plans/000-README.md
|
||||||
|
docs/plans/002-FUNCTIONAL_RELEASE_SEQUENCE.md
|
||||||
|
docs/plans/005-V0_1_3_CONFIG_FOUNDATION_PLAN.md
|
||||||
|
prompts/000-README.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## Fichiers supprimés
|
||||||
|
|
||||||
|
Aucun.
|
||||||
|
|
||||||
|
## Validations finales demandées
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo fmt --all
|
||||||
|
cargo check --workspace
|
||||||
|
cargo build -p ksp-config-lib
|
||||||
|
cargo clippy --workspace --all-targets
|
||||||
|
cargo test --workspace
|
||||||
|
cargo test -p ksp-config-lib --test ownership
|
||||||
|
cargo tree -p ksp-config-lib
|
||||||
|
cargo tree -p ksp-config-lib -d
|
||||||
|
cargo tree -p ksp-config-lib -e features
|
||||||
|
cargo tree -p ksp-config-lib -e normal
|
||||||
|
cargo tree -p ksp-logging-lib -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Vérifier également localement qu'aucun `.env` réel n'est suivi/ajouté au commit.
|
||||||
|
|
||||||
|
## Étape suivante après validation
|
||||||
|
|
||||||
|
Si `pre.015` est propre, préparer :
|
||||||
|
|
||||||
|
```text
|
||||||
|
0.1.3-rel.001
|
||||||
|
```
|
||||||
|
|
||||||
|
avec :
|
||||||
|
|
||||||
|
```text
|
||||||
|
workspace.package.version = "0.1.3"
|
||||||
|
```
|
||||||
|
|
||||||
|
`rel.001` devra :
|
||||||
|
|
||||||
|
- enregistrer les validations finales de `pre.015` ;
|
||||||
|
- ajouter la synthèse stable `0.1.3` à `CHANGELOG.md` en tête ;
|
||||||
|
- marquer `0.1.3` `[X]` dans `ROADMAP.md` ;
|
||||||
|
- classer le plan `005` comme historique clôturé ;
|
||||||
|
- réaligner les index/séquence ;
|
||||||
|
- ne modifier aucune surface fonctionnelle ;
|
||||||
|
- préparer le commit stable et, après validation, le tag `v0.1.3`.
|
||||||
|
|
||||||
|
La session suivante peut ensuite démarrer avec :
|
||||||
|
|
||||||
|
```text
|
||||||
|
prompts/004-V0_1_4_START_PROMPT.md
|
||||||
|
```
|
||||||
182
deltas/0.1.3/rel.001.md
Normal file
182
deltas/0.1.3/rel.001.md
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
<!-- file: deltas/0.1.3/rel.001.md -->
|
||||||
|
<!-- version: 1 -->
|
||||||
|
|
||||||
|
# Delta `0.1.3-rel.001` — publication stable Configuration foundation
|
||||||
|
|
||||||
|
## Base requise
|
||||||
|
|
||||||
|
`0.1.3-pre.015` avec les correctifs documentaires `pre.015-fix.001` et `pre.015-fix.002`, au sens des commits de livraison correspondants, avec :
|
||||||
|
|
||||||
|
```text
|
||||||
|
workspace.package.version = "0.1.3-pre.15"
|
||||||
|
```
|
||||||
|
|
||||||
|
Les deux fixes de `pre.015` ne modifient pas la version Cargo.
|
||||||
|
|
||||||
|
## Objectif
|
||||||
|
|
||||||
|
Publier la release stable `0.1.3`, clôturer `Configuration foundation` et préparer l'ouverture de `0.1.4 — ksp-app-config-desk` sans ajouter de nouvelle fonctionnalité runtime.
|
||||||
|
|
||||||
|
## Version Cargo
|
||||||
|
|
||||||
|
`workspace.package.version` passe de :
|
||||||
|
|
||||||
|
```text
|
||||||
|
0.1.3-pre.15
|
||||||
|
```
|
||||||
|
|
||||||
|
à :
|
||||||
|
|
||||||
|
```text
|
||||||
|
0.1.3
|
||||||
|
```
|
||||||
|
|
||||||
|
Le header de `Cargo.toml` passe de version 60 à 61.
|
||||||
|
|
||||||
|
Aucune dépendance ni feature Cargo n'est ajoutée ou retirée par cette publication.
|
||||||
|
|
||||||
|
## Validations finales exécutées par le user
|
||||||
|
|
||||||
|
Commandes communiquées avec succès le 2026-08-16 sur `0.1.3-pre.15` après application de `pre.015-fix.001` et `pre.015-fix.002` :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo fmt --all
|
||||||
|
cargo check --workspace
|
||||||
|
cargo clippy --workspace --all-targets
|
||||||
|
cargo test --workspace
|
||||||
|
cargo tree -p ksp-config-lib
|
||||||
|
cargo tree -p ksp-config-lib -d
|
||||||
|
cargo tree -p ksp-config-lib -e features
|
||||||
|
cargo tree -p ksp-config-lib -e normal
|
||||||
|
cargo tree -p ksp-logging-lib -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Résultats communiqués :
|
||||||
|
|
||||||
|
- `cargo check --workspace` : succès ;
|
||||||
|
- `cargo clippy --workspace --all-targets` : succès sans warning communiqué ;
|
||||||
|
- `cargo test --workspace` : succès ;
|
||||||
|
- `ksp-config-lib` : 80 tests unitaires, 4 audits ownership et 11 tests publics réussis ;
|
||||||
|
- `ksp-core-lib` : 14 tests unitaires et 3 tests publics réussis ;
|
||||||
|
- `ksp-logging-lib` : 34 tests unitaires et toutes les intégrations exécutées réussies ; le probe d'overhead reste volontairement `ignored` par défaut ;
|
||||||
|
- `cargo tree -p ksp-config-lib -d` : seul doublon observé, `syn 2.0.119` / `syn 3.0.3`, transitif via `jsonschema` et ses dépendances ;
|
||||||
|
- `cargo tree -p ksp-config-lib -e normal` : graphe runtime cohérent avec Core, Logging, `serde`, `serde_json` et `jsonschema` ;
|
||||||
|
- `cargo tree -p ksp-logging-lib -d` : aucun doublon.
|
||||||
|
|
||||||
|
## Surface stable publiée
|
||||||
|
|
||||||
|
`0.1.3` stabilise notamment :
|
||||||
|
|
||||||
|
- `ksp-config-lib` comme propriétaire unique KSP des documents Config, schemas, profils, compositions, variables applicatives KSP/KSPB, `.env`, interpolation et persistence autorisée ;
|
||||||
|
- le bootstrap non récursif `cfgpath` / `schemapath` et les overrides physiques `--filemap=<file_id>=<filename>` ;
|
||||||
|
- le registre logique `file_id -> filename` et l'association logique document/schema ;
|
||||||
|
- parsing JSON, validation JSON Schema Draft 2020-12 et validation sémantique KSP ;
|
||||||
|
- globals, `default_profile`, profils, provenance de sélection et compositions par `file_id` ;
|
||||||
|
- priorité `process environment > .env > fallback > missing`, avec chaîne vide explicitement définie ;
|
||||||
|
- placeholders `${NAME}` et `${NAME:-fallback}` sous contrôle exclusif de Config ;
|
||||||
|
- sensibilité `Public` / `Internal` / `Secret`, valeurs réelle/sûre et provenance par segment/pointeur JSON ;
|
||||||
|
- redaction des secrets dans les surfaces sûres et accès réel explicite pour les consumers/management légitimes ;
|
||||||
|
- `Config -> ksp_logging_lib::LoggingSettings`, validation effective de `logs_directory`, console, multi-sinks, formats, rotations, targets et domains ;
|
||||||
|
- management typé de `std.logging.json`, lecture management du source brut et persistence atomique ;
|
||||||
|
- create/update/remove `.env`, preservation des commentaires/lignes non ciblées, permissions existantes et mode `0600` pour un nouveau `.env` sur Unix ;
|
||||||
|
- rapports de changement distinguant source/effective/shadowing/reload ;
|
||||||
|
- audits d'ownership empêchant les crates hors Config de contourner Config pour les variables KSP/KSPB ou les ressources physiques gérées ;
|
||||||
|
- audit automatique de couverture `.env.example` ;
|
||||||
|
- documentation durable `ksp-config-lib/README.md`, `USAGE.md` et `TODO.md`.
|
||||||
|
|
||||||
|
La release complète également `ksp-logging-lib` avec les capacités nécessaires au contrat `std.logging.json` : settings multi-output, runtime multi-sink, routing niveau/target/domain, formats et hot reload transactionnel, sans introduire de dépendance inverse Logging -> Config.
|
||||||
|
|
||||||
|
## Documentation de clôture
|
||||||
|
|
||||||
|
Le présent delta :
|
||||||
|
|
||||||
|
- ajoute `0.1.3` en tête de `CHANGELOG.md` ;
|
||||||
|
- marque `0.1.3` réalisée dans `ROADMAP.md` ;
|
||||||
|
- clôt `005-V0_1_3_CONFIG_FOUNDATION_PLAN.md` comme plan historique ;
|
||||||
|
- actualise les index docs/plans et la séquence fonctionnelle ;
|
||||||
|
- corrige dans le README racine l'ancienne mention `apps/` : les packages Rust KSP, y compris les applications Tauri, vivent directement sous `crates/` selon la convention workspace retenue ;
|
||||||
|
- conserve `prompts/004-V0_1_4_START_PROMPT.md` comme prompt final d'ouverture de la release suivante.
|
||||||
|
|
||||||
|
## Fichiers ajoutés
|
||||||
|
|
||||||
|
```text
|
||||||
|
deltas/0.1.3/rel.001.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## Fichiers modifiés
|
||||||
|
|
||||||
|
```text
|
||||||
|
Cargo.toml
|
||||||
|
CHANGELOG.md
|
||||||
|
ROADMAP.md
|
||||||
|
README.md
|
||||||
|
docs/000-README.md
|
||||||
|
docs/plans/000-README.md
|
||||||
|
docs/plans/002-FUNCTIONAL_RELEASE_SEQUENCE.md
|
||||||
|
docs/plans/005-V0_1_3_CONFIG_FOUNDATION_PLAN.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## Fichiers supprimés
|
||||||
|
|
||||||
|
Aucun.
|
||||||
|
|
||||||
|
## Décisions
|
||||||
|
|
||||||
|
Aucune nouvelle décision fonctionnelle Config n'est introduite par `rel.001`.
|
||||||
|
|
||||||
|
La publication stable confirme les décisions prises pendant `0.1.3`, notamment l'ownership exclusif de Config, la séparation source/effective, la politique de sensibilité/redaction, la persistence atomique et la frontière Config -> Logging.
|
||||||
|
|
||||||
|
Les conventions Tauri ajoutées dans `pre.015-fix.001/.002` servent de contraintes d'entrée pour `0.1.4`, mais leur implémentation appartient à cette release suivante.
|
||||||
|
|
||||||
|
## Validation du commit stable
|
||||||
|
|
||||||
|
Ce delta modifie `Cargo.toml` et de la documentation, mais aucun fichier Rust. Conformément aux règles KSP, `cargo fmt --all` n'est pas requis par une modification Rust dans ce delta.
|
||||||
|
|
||||||
|
Avant publication/tag, exécuter sur `workspace.package.version = "0.1.3"` :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo check --workspace
|
||||||
|
cargo clippy --workspace --all-targets
|
||||||
|
cargo test --workspace
|
||||||
|
cargo tree -p ksp-config-lib
|
||||||
|
cargo tree -p ksp-config-lib -d
|
||||||
|
cargo tree -p ksp-config-lib -e normal
|
||||||
|
cargo tree -p ksp-logging-lib -d
|
||||||
|
```
|
||||||
|
|
||||||
|
`cargo test --workspace` est approprié ici car il s'agit précisément de la fermeture d'une version stable.
|
||||||
|
|
||||||
|
## Publication Git
|
||||||
|
|
||||||
|
Après validation du delta :
|
||||||
|
|
||||||
|
1. vérifier le working tree ;
|
||||||
|
2. créer le commit de release :
|
||||||
|
|
||||||
|
```text
|
||||||
|
v0.1.3-rel.001
|
||||||
|
```
|
||||||
|
|
||||||
|
3. créer le tag stable :
|
||||||
|
|
||||||
|
```text
|
||||||
|
v0.1.3
|
||||||
|
```
|
||||||
|
|
||||||
|
Aucun tag des prereleases/fixes intermédiaires n'est requis.
|
||||||
|
|
||||||
|
## Suite
|
||||||
|
|
||||||
|
Après le tag `v0.1.3`, ouvrir :
|
||||||
|
|
||||||
|
```text
|
||||||
|
0.1.4-pre.001
|
||||||
|
```
|
||||||
|
|
||||||
|
avec :
|
||||||
|
|
||||||
|
```text
|
||||||
|
prompts/004-V0_1_4_START_PROMPT.md
|
||||||
|
```
|
||||||
|
|
||||||
|
`0.1.4-pre.001` commence par brainstorming, audit de la base Tauri de khadhroony-bot3 et planification détaillée avant implémentation de `ksp-app-config-desk`.
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<!-- file: docs/000-README.md -->
|
<!-- file: docs/000-README.md -->
|
||||||
<!-- version: 11 -->
|
<!-- version: 13 -->
|
||||||
|
|
||||||
# Documentation KSP
|
# Documentation KSP
|
||||||
|
|
||||||
@@ -36,7 +36,8 @@ docs/
|
|||||||
│ ├── 001-V0_0_3_PLAN.md
|
│ ├── 001-V0_0_3_PLAN.md
|
||||||
│ ├── 002-FUNCTIONAL_RELEASE_SEQUENCE.md
|
│ ├── 002-FUNCTIONAL_RELEASE_SEQUENCE.md
|
||||||
│ ├── 003-V0_1_1_CORE_FOUNDATION_PLAN.md
|
│ ├── 003-V0_1_1_CORE_FOUNDATION_PLAN.md
|
||||||
│ └── 004-V0_1_2_LOGGING_FOUNDATION_PLAN.md
|
│ ├── 004-V0_1_2_LOGGING_FOUNDATION_PLAN.md
|
||||||
|
│ └── 005-V0_1_3_CONFIG_FOUNDATION_PLAN.md
|
||||||
└── rules/
|
└── rules/
|
||||||
├── FILE_CONTRACTS.md
|
├── FILE_CONTRACTS.md
|
||||||
├── PROMPT_STRUCTURE.md
|
├── PROMPT_STRUCTURE.md
|
||||||
@@ -53,7 +54,7 @@ D'autres sous-répertoires seront ajoutés uniquement lorsque leur rôle aura é
|
|||||||
|
|
||||||
## Documents de planification
|
## Documents de planification
|
||||||
|
|
||||||
Le plan historique de la phase fondatrice clôturée est conservé dans [`plans/001-V0_0_3_PLAN.md`](plans/001-V0_0_3_PLAN.md). La séquence active des premières releases fonctionnelles est définie dans [`plans/002-FUNCTIONAL_RELEASE_SEQUENCE.md`](plans/002-FUNCTIONAL_RELEASE_SEQUENCE.md). Le plan détaillé de la release stable `0.1.1` est conservé comme historique clôturé dans [`plans/003-V0_1_1_CORE_FOUNDATION_PLAN.md`](plans/003-V0_1_1_CORE_FOUNDATION_PLAN.md). Le plan détaillé de la release stable `0.1.2` est conservé comme historique clôturé dans [`plans/004-V0_1_2_LOGGING_FOUNDATION_PLAN.md`](plans/004-V0_1_2_LOGGING_FOUNDATION_PLAN.md).
|
Le plan historique de la phase fondatrice clôturée est conservé dans [`plans/001-V0_0_3_PLAN.md`](plans/001-V0_0_3_PLAN.md). La séquence active des premières releases fonctionnelles est définie dans [`plans/002-FUNCTIONAL_RELEASE_SEQUENCE.md`](plans/002-FUNCTIONAL_RELEASE_SEQUENCE.md). Le plan détaillé de la release stable `0.1.1` est conservé comme historique clôturé dans [`plans/003-V0_1_1_CORE_FOUNDATION_PLAN.md`](plans/003-V0_1_1_CORE_FOUNDATION_PLAN.md). Le plan détaillé de la release stable `0.1.2` est conservé comme historique clôturé dans [`plans/004-V0_1_2_LOGGING_FOUNDATION_PLAN.md`](plans/004-V0_1_2_LOGGING_FOUNDATION_PLAN.md). Le plan détaillé de la release stable `0.1.3 — Configuration foundation` est conservé comme historique clôturé dans [`plans/005-V0_1_3_CONFIG_FOUNDATION_PLAN.md`](plans/005-V0_1_3_CONFIG_FOUNDATION_PLAN.md). La prochaine release active est `0.1.4 — ksp-app-config-desk`, ouverte à partir du prompt [`../prompts/004-V0_1_4_START_PROMPT.md`](../prompts/004-V0_1_4_START_PROMPT.md).
|
||||||
|
|
||||||
`IDEAS.md` conserve les pistes et questions qui ne sont pas encore des engagements du roadmap ni des décisions architecturales.
|
`IDEAS.md` conserve les pistes et questions qui ne sont pas encore des engagements du roadmap ni des décisions architecturales.
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<!-- file: docs/architecture/005-DEPENDENCY_GRAPH.md -->
|
<!-- file: docs/architecture/005-DEPENDENCY_GRAPH.md -->
|
||||||
<!-- version: 8 -->
|
<!-- version: 9 -->
|
||||||
|
|
||||||
# Graphe de dépendances KSP
|
# Graphe de dépendances KSP
|
||||||
|
|
||||||
@@ -76,7 +76,7 @@ ksp-core-lib
|
|||||||
|
|
||||||
ksp-logging-lib -> ksp-core-lib
|
ksp-logging-lib -> ksp-core-lib
|
||||||
ksp-config-lib -> ksp-core-lib
|
ksp-config-lib -> ksp-core-lib
|
||||||
ksp-config-lib -> ksp-logging-lib # lorsque du runtime logging est nécessaire
|
ksp-config-lib -> ksp-logging-lib # warnings/diagnostics Config runtime
|
||||||
ksp-interface-lib -> ksp-core-lib
|
ksp-interface-lib -> ksp-core-lib
|
||||||
ksp-interface-lib -> ksp-logging-lib # lorsque du runtime logging est nécessaire
|
ksp-interface-lib -> ksp-logging-lib # lorsque du runtime logging est nécessaire
|
||||||
```
|
```
|
||||||
@@ -85,7 +85,7 @@ ksp-interface-lib -> ksp-logging-lib # lorsque du runtime logging est né
|
|||||||
|
|
||||||
`ksp-interface-lib` est propriétaire de la façade wire et peut dépendre des crates externes Solana/interface explicitement autorisées par `RULES_DEPENDENCIES.md`.
|
`ksp-interface-lib` est propriétaire de la façade wire et peut dépendre des crates externes Solana/interface explicitement autorisées par `RULES_DEPENDENCIES.md`.
|
||||||
|
|
||||||
`ksp-logging-lib` est la façade logging/tracing KSP et peut dépendre de `ksp-core-lib` pour `Error` / `Result`. `ksp-core-lib` n'a pas de dépendance inverse vers le logging. Les composants comportant du runtime peuvent dépendre directement de `ksp-logging-lib`; cette permission n'oblige pas les crates purement déclaratives à le faire.
|
`ksp-logging-lib` est la façade logging/tracing KSP et peut dépendre de `ksp-core-lib` pour `Error` / `Result`. `ksp-core-lib` n'a pas de dépendance inverse vers le logging. Les composants comportant du runtime peuvent dépendre directement de `ksp-logging-lib`; cette permission n'oblige pas les crates purement déclaratives à le faire. Depuis `0.1.3-pre.010`, `ksp-config-lib` utilise cette dépendance pour ses warnings/diagnostics runtime, sans dépendance inverse Logging -> Config.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<!-- file: docs/plans/000-README.md -->
|
<!-- file: docs/plans/000-README.md -->
|
||||||
<!-- version: 7 -->
|
<!-- version: 23 -->
|
||||||
|
|
||||||
# Plans KSP
|
# Plans KSP
|
||||||
|
|
||||||
@@ -13,6 +13,7 @@ Un plan décrit le périmètre, les décisions déjà acquises, les questions ou
|
|||||||
- [`002-FUNCTIONAL_RELEASE_SEQUENCE.md`](002-FUNCTIONAL_RELEASE_SEQUENCE.md) — séquence active de référence des premières releases fonctionnelles ;
|
- [`002-FUNCTIONAL_RELEASE_SEQUENCE.md`](002-FUNCTIONAL_RELEASE_SEQUENCE.md) — séquence active de référence des premières releases fonctionnelles ;
|
||||||
- [`003-V0_1_1_CORE_FOUNDATION_PLAN.md`](003-V0_1_1_CORE_FOUNDATION_PLAN.md) — plan historique clôturé de la release stable `0.1.1`, établi par `0.1.1-pre.001` puis consolidé jusqu'à `0.1.1-rel.001`.
|
- [`003-V0_1_1_CORE_FOUNDATION_PLAN.md`](003-V0_1_1_CORE_FOUNDATION_PLAN.md) — plan historique clôturé de la release stable `0.1.1`, établi par `0.1.1-pre.001` puis consolidé jusqu'à `0.1.1-rel.001`.
|
||||||
- [`004-V0_1_2_LOGGING_FOUNDATION_PLAN.md`](004-V0_1_2_LOGGING_FOUNDATION_PLAN.md) — plan historique clôturé de la release stable `0.1.2`, établi par `0.1.2-pre.001` puis consolidé jusqu'à `0.1.2-rel.001`.
|
- [`004-V0_1_2_LOGGING_FOUNDATION_PLAN.md`](004-V0_1_2_LOGGING_FOUNDATION_PLAN.md) — plan historique clôturé de la release stable `0.1.2`, établi par `0.1.2-pre.001` puis consolidé jusqu'à `0.1.2-rel.001`.
|
||||||
|
- [`005-V0_1_3_CONFIG_FOUNDATION_PLAN.md`](005-V0_1_3_CONFIG_FOUNDATION_PLAN.md) — plan historique clôturé de la release stable `0.1.3 — Configuration foundation`, établi par `0.1.3-pre.001`, exécuté jusqu'à `pre.015` puis publié par `0.1.3-rel.001`.
|
||||||
|
|
||||||
Le `pre.001` de chaque release fonctionnelle peut introduire son propre plan détaillé lorsque la release s'ouvre.
|
Le `pre.001` de chaque release fonctionnelle peut introduire son propre plan détaillé lorsque la release s'ouvre.
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<!-- file: docs/plans/002-FUNCTIONAL_RELEASE_SEQUENCE.md -->
|
<!-- file: docs/plans/002-FUNCTIONAL_RELEASE_SEQUENCE.md -->
|
||||||
<!-- version: 5 -->
|
<!-- version: 21 -->
|
||||||
|
|
||||||
# Séquence des releases fonctionnelles KSP
|
# Séquence des releases fonctionnelles KSP
|
||||||
|
|
||||||
@@ -36,9 +36,9 @@ Séquence par défaut :
|
|||||||
0.1.4 ksp-app-config-desk
|
0.1.4 ksp-app-config-desk
|
||||||
```
|
```
|
||||||
|
|
||||||
`0.1.1` et `0.1.2` sont fixées.
|
`0.1.1`, `0.1.2` et `0.1.3` sont désormais des releases stables.
|
||||||
|
|
||||||
`0.1.3` et `0.1.4` sont la séquence par défaut. Si le `pre.001` de Config démontre qu'une seule release ne permet pas un développement propre, Config est scindé et les numéros suivants sont décalés.
|
`0.1.4 — ksp-app-config-desk` est l'étape active suivante. Elle doit valider réellement la fondation Config et établir le modèle de référence des futures applications Tauri KSP sans déplacer la logique Config dans l'application.
|
||||||
|
|
||||||
## `0.1.1` — Core foundation
|
## `0.1.1` — Core foundation
|
||||||
|
|
||||||
@@ -155,7 +155,7 @@ rel.001 publication stable validée de 0.1.2
|
|||||||
|
|
||||||
## `0.1.3` — Configuration foundation
|
## `0.1.3` — Configuration foundation
|
||||||
|
|
||||||
### Dépendances candidates
|
### Dépendances stabilisées
|
||||||
|
|
||||||
```text
|
```text
|
||||||
ksp-config-lib
|
ksp-config-lib
|
||||||
@@ -167,8 +167,13 @@ ksp-config-lib
|
|||||||
|
|
||||||
Introduire la configuration générale KSP.
|
Introduire la configuration générale KSP.
|
||||||
|
|
||||||
Périmètre candidat à revalider dans son `pre.001` :
|
Le `0.1.3-pre.001`, corrigé par `pre.001-fix.001`, `pre.001-fix.002` puis `pre.001-fix.003`, a revalidé ce périmètre et décidé qu'il tient dans une seule release à condition de limiter le premier cycle au socle générique, au document Logging, à la composition, à l'environnement KSP/KSPB, aux surfaces d'accès et à la persistence autorisée. Le plan normatif détaillé est `docs/plans/005-V0_1_3_CONFIG_FOUNDATION_PLAN.md`.
|
||||||
|
|
||||||
|
Périmètre retenu :
|
||||||
|
|
||||||
|
- registre logique `file_id -> filename` des fichiers Config/schemas connus, avec mappings par défaut surchargeables au bootstrap ;
|
||||||
|
- bootstrap non récursif `--cfgpath` / `--schemapath` avec défauts codés en dur `config` / `config/schemas` ;
|
||||||
|
- override répétable des filenames par `--filemap=<file_id>=<filename>` ;
|
||||||
- documents spécialisés ;
|
- documents spécialisés ;
|
||||||
- profils ;
|
- profils ;
|
||||||
- `default_profile` autonome ;
|
- `default_profile` autonome ;
|
||||||
@@ -176,23 +181,79 @@ Périmètre candidat à revalider dans son `pre.001` :
|
|||||||
- résolution ;
|
- résolution ;
|
||||||
- validation ;
|
- validation ;
|
||||||
- modification/sauvegarde ;
|
- modification/sauvegarde ;
|
||||||
- variables d'environnement `KSP_*` / `KSPB_*` ;
|
- variables d'environnement `KSP_*` / `KSPB_*`, résolues exclusivement par Config ;
|
||||||
|
- priorité process env > `.env` > fallback `${NAME:-fallback}` ;
|
||||||
|
- propagation de la sensibilité et représentation sûre/redacted des valeurs dérivées de `*_SECRET_*` ;
|
||||||
- secret/public/debug exposure policy ;
|
- secret/public/debug exposure policy ;
|
||||||
- `logging.config.json` séparé ;
|
- premier document spécialisé concret `config/std.logging.json`, identifié logiquement par `cfg.std.logging` et validé par `schema.std.logging` ;
|
||||||
- vrais fichiers runtime sous `config/`, schemas sous `config/schemas/` et exemples sous `config/examples/` ;
|
- vrais fichiers runtime sous `config/`, schemas sous `config/schemas/` et exemples sous `config/examples/` ;
|
||||||
- documents unitaires spécialisés + fichiers composites par application/exécutable ;
|
- documents unitaires spécialisés + fichiers composites par application/exécutable, avec références inter-document par `file_id` et non par filename ;
|
||||||
- ownership exclusif de `ksp-config-lib` sur lecture/résolution/validation/mutation des fichiers Config et variables d'environnement ;
|
- ownership exclusif de `ksp-config-lib` sur lecture/résolution/validation/mutation des fichiers Config et variables d'environnement ;
|
||||||
- accès explicite aux secrets pour les surfaces de management autorisées.
|
- accès explicite aux secrets pour les surfaces de management autorisées ;
|
||||||
|
- modèle Logging non régressif : console configurable et plusieurs sinks fichier/routings ; les capacités manquantes de `ksp-logging-lib 0.1.2` sont complétées dans Logging sans dépendance inverse vers Config.
|
||||||
|
|
||||||
### Règle de scission
|
### Décision de scission
|
||||||
|
|
||||||
Si le plan détaillé démontre que validation/schemas, résolution/profiles et mutation/persistence dépassent une release raisonnable, Config est réparti sur deux releases de `0.1.x`.
|
Le `pre.001` ne scinde pas Config : `0.1.3` reste une release unique et `0.1.4` reste réservée à `ksp-app-config-desk`.
|
||||||
|
|
||||||
Aucun sous-périmètre n'est compressé artificiellement pour préserver le numéro `0.1.4` de l'application desktop.
|
Cette décision repose sur l'absence volontaire de documents Transport/Wallet/Store/Execution et de watcher générique dans le premier cycle. Si une tranche ultérieure révèle une contrainte technique majeure réellement non bornable, la séquence peut encore être corrigée par un delta explicite plutôt que de comprimer artificiellement le périmètre.
|
||||||
|
|
||||||
|
Prévision souple regranularisée par `pre.001-fix.003`, puis scindée à nouveau pendant `pre.005` afin de traiter `domain` comme un champ structuré distinct :
|
||||||
|
|
||||||
|
```text
|
||||||
|
pre.002 crate Config + bootstrap cfgpath/schemapath
|
||||||
|
pre.003 registre file_id -> filename + --filemap
|
||||||
|
pre.004 Logging : contrats/settings multi-output
|
||||||
|
pre.005 Logging : runtime multi-sink + level/target/formats
|
||||||
|
pre.006 Logging : routing structuré domain
|
||||||
|
pre.007 JSON/JSON Schema + std.logging.json
|
||||||
|
pre.008 globals + profils + default_profile
|
||||||
|
pre.009 compositions génériques par file_id
|
||||||
|
pre.010 .env + process env + resolver ${...}
|
||||||
|
pre.011 sensibilité + real/safe/provenance
|
||||||
|
pre.012 adapter Config -> Logging
|
||||||
|
pre.013 management + persistence JSON/.env
|
||||||
|
pre.014 ownership audits + robustesse
|
||||||
|
pre.015 clôture
|
||||||
|
```
|
||||||
|
|
||||||
|
Cette prévision n'est pas un plafond : chaque prerelease doit rester une petite tranche, avec scission explicite si l'objectif dépasse environ 15–20 minutes de travail effectif.
|
||||||
|
|
||||||
|
`pre.006` a fermé le routing Logging structuré `domain`; `pre.007` a livré le moteur JSON/JSON Schema et `std.logging.json`; `pre.008` a ajouté la résolution générique globals/profils/`default_profile`; `pre.009` les compositions génériques par `file_id`; `pre.010` le snapshot process + `.env`, `.env.example` et le resolver `${...}`; `pre.011` la sensibilité, les valeurs réelle/sûre, la redaction et la provenance enrichie; `pre.012` l'adapter Config -> Logging et la validation effective des chemins; `pre.013` management + persistence JSON/.env; `pre.014` les audits exécutables d'ownership et la couverture automatique de `.env.example`; `pre.015` la documentation durable et le prompt `0.1.4`, complétés par deux fixes documentaires sur les validations et le modèle Tauri. `rel.001` publie cette surface sous `0.1.3` stable.
|
||||||
|
|
||||||
|
Trajectoire réellement suivie :
|
||||||
|
|
||||||
|
```text
|
||||||
|
pre.001 brainstorming + audit + plan détaillé
|
||||||
|
pre.001-fix.001..003 corrections de cadrage, file_id/bootstrap et granularité
|
||||||
|
pre.002 crate Config + bootstrap cfgpath/schemapath
|
||||||
|
pre.002-fix.001 correction de la première tranche Config
|
||||||
|
pre.003 registre file_id + --filemap
|
||||||
|
pre.004 Logging contracts/settings multi-output
|
||||||
|
pre.005 Logging runtime multi-sink + routing level/target/formats
|
||||||
|
pre.005-fix.001 correction du test JSON runtime
|
||||||
|
pre.006 Logging routing structuré domain
|
||||||
|
pre.007 moteur JSON/JSON Schema + std.logging.json
|
||||||
|
pre.008 globals + profils + default_profile
|
||||||
|
pre.009 compositions génériques par file_id
|
||||||
|
pre.009-fix.001 correction Clippy du test composite
|
||||||
|
pre.010 process env + .env + placeholders + .env.example
|
||||||
|
pre.010-fix.001 correction des racines de fixtures de test
|
||||||
|
pre.011 sensibilité + real/safe/provenance
|
||||||
|
pre.011-fix.001 correction Clippy du test de provenance
|
||||||
|
pre.012 adapter Config -> Logging
|
||||||
|
pre.013 management + persistence JSON/.env
|
||||||
|
pre.013-fix.001 correction de syntaxe du warning persistence
|
||||||
|
pre.014 ownership audits + robustesse
|
||||||
|
pre.015 clôture/docs/prompt 0.1.4
|
||||||
|
pre.015-fix.001 règles validation/docs + modèle Tauri/PRESENTATION
|
||||||
|
pre.015-fix.002 tracing Tauri + critères fonctionnels de clôture 0.1.4
|
||||||
|
rel.001 publication stable validée de 0.1.3
|
||||||
|
```
|
||||||
|
|
||||||
## `0.1.4` — Config desktop par défaut
|
## `0.1.4` — Config desktop par défaut
|
||||||
|
|
||||||
Sous réserve d'absence de scission de Config.
|
La décision `0.1.3-pre.001` conserve cette release comme étape suivante par défaut.
|
||||||
|
|
||||||
Mission :
|
Mission :
|
||||||
|
|
||||||
@@ -259,7 +320,7 @@ La première prerelease ne doit pas se transformer automatiquement en une grosse
|
|||||||
|
|
||||||
Chaque prerelease porte un objectif borné et cohérent.
|
Chaque prerelease porte un objectif borné et cohérent.
|
||||||
|
|
||||||
Une tranche de travail de planification/développement manifestement trop grosse est scindée.
|
Une tranche de travail de planification/développement manifestement trop grosse est scindée. La cible de dimensionnement KSP est d'environ 15–20 minutes de travail effectif par prerelease ; ce budget est un garde-fou de granularité, pas une raison pour comprimer le périmètre.
|
||||||
|
|
||||||
## Dernière prerelease
|
## Dernière prerelease
|
||||||
|
|
||||||
|
|||||||
1989
docs/plans/005-V0_1_3_CONFIG_FOUNDATION_PLAN.md
Normal file
1989
docs/plans/005-V0_1_3_CONFIG_FOUNDATION_PLAN.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,5 @@
|
|||||||
<!-- file: docs/rules/FILE_CONTRACTS.md -->
|
<!-- file: docs/rules/FILE_CONTRACTS.md -->
|
||||||
<!-- version: 8 -->
|
<!-- version: 15 -->
|
||||||
|
|
||||||
# Contrats des fichiers
|
# Contrats des fichiers
|
||||||
|
|
||||||
@@ -9,17 +9,19 @@ Les règles `FILE-*` définissent la responsabilité et le mode de modification
|
|||||||
|
|
||||||
## Fichiers racine et configuration Cargo
|
## Fichiers racine et configuration Cargo
|
||||||
|
|
||||||
| Fichier | Responsabilité | Règle de modification |
|
| Fichier | Responsabilité | Règle de modification |
|
||||||
|----------------------|-----------------------------------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
|----------------------|-----------------------------------------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||||
| `.gitignore` | Exclure uniquement les artefacts non versionnés décidés par le projet. | Ajouter une exclusion lorsqu'un besoin réel apparaît ; éviter les exclusions spéculatives. |
|
| `.gitignore` | Exclure uniquement les artefacts non versionnés décidés par le projet. | Ajouter une exclusion lorsqu'un besoin réel apparaît ; éviter les exclusions spéculatives. |
|
||||||
| `README.md` | Présenter KSP, sa finalité, son périmètre général, ses principes et les points d'entrée. | Mettre à jour lorsqu'une définition structurante du projet change ; ne pas y consigner l'historique des versions. |
|
| `.env` | Fournir les valeurs d'environnement locales KSP/KSPB du runtime lorsqu'elles ne viennent pas du processus externe. | Fichier local non versionné et non échangé ; lu puis, à terme, modifié uniquement par `ksp-config-lib`. L'environnement du processus garde priorité sur cette source. |
|
||||||
| `RULES.md` | Indexer les règles normatives. | Modifier uniquement lorsque la structure normative ou ses points d'entrée changent. |
|
| `.env.example` | Inventorier le contrat versionné de toutes les variables d'environnement runtime KSP/KSPB utilisées par les fichiers Config ou le code. | Ajouter la variable dans le même delta que sa première utilisation. Chaque entrée est précédée d'un commentaire décrivant son usage ; elle peut être active avec une valeur par défaut/générique non secrète ou rester commentée. Ce fichier ne contient jamais de vrai secret. |
|
||||||
| `Cargo.toml` | Définir le workspace, sa version Cargo, les métadonnées héritées et les lints communs. | Modifier lors de toute prerelease/release non-fix, lors d'un correctif touchant le code/build/runtime/configuration/migrations, lorsqu'une crate entre/sort du workspace ou lorsqu'un contrat Cargo commun change. Un correctif purement documentaire ou de référence non consommée par le runtime ne force pas un changement de version Cargo. |
|
| `README.md` | Présenter KSP, sa finalité, son périmètre général, ses principes et les points d'entrée. | Mettre à jour lorsqu'une définition structurante du projet change ; ne pas y consigner l'historique des versions. |
|
||||||
| `.cargo/config.toml` | Définir les réglages Cargo propres au workspace qui ne relèvent pas du manifeste, notamment l'emplacement des artefacts de build. | Modifier lorsqu'un réglage Cargo commun change ; ne pas y placer de secret ni de configuration spécifique à une machine particulière. |
|
| `RULES.md` | Indexer les règles normatives. | Modifier uniquement lorsque la structure normative ou ses points d'entrée changent. |
|
||||||
| `rustfmt.toml` | Définir le formatage Rust commun. | Modifier comme changement normatif, avec justification dans le delta. |
|
| `Cargo.toml` | Définir le workspace, sa version Cargo, les métadonnées héritées et les lints communs. | Modifier lors de toute prerelease/release non-fix, lors d'un correctif touchant le code/build/runtime/configuration/migrations, lorsqu'une crate entre/sort du workspace ou lorsqu'un contrat Cargo commun change. Un correctif purement documentaire ou de référence non consommée par le runtime ne force pas un changement de version Cargo. |
|
||||||
| `clippy.toml` | Définir les paramètres Clippy communs. | Modifier comme changement normatif, avec justification dans le delta. |
|
| `.cargo/config.toml` | Définir les réglages Cargo propres au workspace qui ne relèvent pas du manifeste, notamment l'emplacement des artefacts de build. | Modifier lorsqu'un réglage Cargo commun change ; ne pas y placer de secret ni de configuration spécifique à une machine particulière. |
|
||||||
| `ROADMAP.md` | Décrire les objectifs globaux et les grandes étapes prévues par phase/version, avec leur état synthétique. | Modifier lorsqu'un objectif, une grande étape, un report, une annulation ou un état global change ; ne pas y recopier le détail des prereleases prévu dans les plans de version. |
|
| `rustfmt.toml` | Définir le formatage Rust commun. | Modifier comme changement normatif, avec justification dans le delta. |
|
||||||
| `CHANGELOG.md` | Résumer les releases stables dans un ordre chronologique décroissant, sous forme d'un ou plusieurs paragraphes par release. | Synchroniser lors de la phase documentaire finale ; ne pas dupliquer les deltas ni créer de changelog par crate/module. |
|
| `clippy.toml` | Définir les paramètres Clippy communs. | Modifier comme changement normatif, avec justification dans le delta. |
|
||||||
|
| `ROADMAP.md` | Décrire les objectifs globaux et les grandes étapes prévues par phase/version, avec leur état synthétique. | Modifier lorsqu'un objectif, une grande étape, un report, une annulation ou un état global change ; ne pas y recopier le détail des prereleases prévu dans les plans de version. |
|
||||||
|
| `CHANGELOG.md` | Résumer les releases stables dans un ordre chronologique décroissant, sous forme d'un ou plusieurs paragraphes par release. | Synchroniser lors de la phase documentaire finale ; ne pas dupliquer les deltas ni créer de changelog par crate/module. |
|
||||||
|
|
||||||
## Répertoire `docs/`
|
## Répertoire `docs/`
|
||||||
|
|
||||||
@@ -36,6 +38,25 @@ Les règles `FILE-*` définissent la responsabilité et le mode de modification
|
|||||||
| futurs documents de référence | Définir vocabulaire, identifiants et références canoniques. | Mettre à jour quand la référence canonique évolue. |
|
| futurs documents de référence | Définir vocabulaire, identifiants et références canoniques. | Mettre à jour quand la référence canonique évolue. |
|
||||||
| futures validations | Conserver des résultats réellement exécutés. | Ne jamais enregistrer une validation supposée comme réussie. |
|
| futures validations | Conserver des résultats réellement exécutés. | Ne jamais enregistrer une validation supposée comme réussie. |
|
||||||
|
|
||||||
|
## Répertoire `config/`
|
||||||
|
|
||||||
|
| Fichier/famille | Responsabilité | Règle de modification |
|
||||||
|
|------------------------------------|------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||||
|
| `config/std.<domain>.json` | Document runtime spécialisé Config possédé par `ksp-config-lib`. | Identifié par un `file_id` stable, validé par son schema enregistré et lu/modifié uniquement via Config. Comme JSON ne porte pas de commentaires de header, la version du format appartient au champ JSON `format_version`. |
|
||||||
|
| `config/composite.<consumer>.json` | Composition runtime propre à un consumer concret. | Référence uniquement des documents standards par `file_id`; son descriptor référence `schema.composite` tant qu’un schema spécialisé n’est pas requis. Aucun composite runtime fictif n’est créé avant l’existence du consumer. |
|
||||||
|
| `config/schemas/*.schema.json` | JSON Schema des documents Config gérés. | Identifié par un `file_id` `schema.*`; le schema doit être valide pour le draft déclaré avant validation d'une instance. Aucun secret/runtime local ne doit y apparaître. |
|
||||||
|
| `config/examples/*.json` | Exemples versionnés séparés des vrais fichiers runtime. | Doivent rester schema-valides et illustratifs ; ils ne constituent jamais une source runtime implicite. |
|
||||||
|
|
||||||
|
Les noms physiques sont remplaçables via le registre Config lorsque le contrat le permet ; les consumers référencent les documents par `file_id`, pas par filename.
|
||||||
|
|
||||||
|
Le fichier runtime d'environnement est toujours `./.env` pour `0.1.3`. Il n'est ni un document `config/` ni une source de bootstrap de `cfgpath`/`schemapath`. Le template `.env.example` est la référence versionnée permettant de créer localement `.env` et d'identifier par diff les nouvelles clés attendues.
|
||||||
|
|
||||||
|
La persistance management des documents Config et du `.env` utilise un fichier temporaire créé dans le même répertoire puis un remplacement par rename après validation/écriture/synchronisation. Les documents JSON sont sérialisés lisiblement avec newline final. Le writer préserve les permissions d'un fichier existant ; sur Unix, un `.env` nouvellement créé par Config reçoit le mode `0600`. Les mutations `.env` ciblées préservent les lignes/commentaires non concernés et ne modifient jamais l'environnement du processus.
|
||||||
|
|
||||||
|
Pour `config/std.logging.json`, `logs_directory` accepte un chemin absolu ou relatif. Après interpolation, un chemin relatif est ancré sur le current working directory du processus au moment où Config construit `LoggingSettings`. Une valeur explicitement définie mais vide/invalide ne retombe jamais sur le fallback `logs`. Les `files[].path` restent relatifs sous ce root et sont revalidés après interpolation afin d'interdire un chemin absolu ou un traversal introduit dynamiquement.
|
||||||
|
|
||||||
|
Pour un document standard profilé, `default_profile` et `profiles` sont des clés structurelles réservées. Les autres propriétés top-level sont des valeurs globales. Chaque entrée de `profiles` possède un `profile_id` unique ; `default_profile` référence obligatoirement l'un de ces identifiants. La résolution Config peut sélectionner le profil par défaut ou un profil explicite et conserve séparément la provenance `Global` / `Profile` de la vue effective. Les consumers ne reconstituent jamais eux-mêmes cette fusion.
|
||||||
|
|
||||||
## Répertoire `prompts/`
|
## Répertoire `prompts/`
|
||||||
|
|
||||||
| Fichier/famille | Responsabilité | Règle de modification |
|
| Fichier/famille | Responsabilité | Règle de modification |
|
||||||
@@ -65,3 +86,23 @@ Les règles `FILE-*` définissent la responsabilité et le mode de modification
|
|||||||
- **FILE-GEN-001** — Un fichier généré n'est jamais modifié manuellement lorsque sa source de vérité est un générateur.
|
- **FILE-GEN-001** — Un fichier généré n'est jamais modifié manuellement lorsque sa source de vérité est un générateur.
|
||||||
- **FILE-GEN-002** — Le choix de versionner ou ignorer une famille générée est décidé explicitement lorsqu'elle apparaît.
|
- **FILE-GEN-002** — Le choix de versionner ou ignorer une famille générée est décidé explicitement lorsqu'elle apparaît.
|
||||||
- **FILE-GEN-003** — Les futurs artefacts Tauri `bindings/` et `gen/` ne sont pas encore une convention KSP ; ils seront traités lorsqu'ils apparaîtront.
|
- **FILE-GEN-003** — Les futurs artefacts Tauri `bindings/` et `gen/` ne sont pas encore une convention KSP ; ils seront traités lorsqu'ils apparaîtront.
|
||||||
|
|
||||||
|
## Documentation durable des crates et applications
|
||||||
|
|
||||||
|
Une crate ou un composant considéré comme complété possède un `README.md` descriptif durable.
|
||||||
|
|
||||||
|
Une bibliothèque complétée possède également un `USAGE.md` sans notes de version. Ce guide privilégie la surface publique et fournit un exemple d'utilisation pour chaque API publique destinée à être consommée directement ; plusieurs APIs étroitement liées peuvent être démontrées dans le même exemple lorsque le flux réel les compose. Les notes de release restent dans `CHANGELOG.md` et les deltas.
|
||||||
|
|
||||||
|
Une application Tauri complétée possède un `USAGE.md` orienté opérateur décrivant les fenêtres, leurs rôles, les actions disponibles et les flux usuels.
|
||||||
|
|
||||||
|
Le `README.md` d'une application Tauri n'est jamais chargé comme contenu de présentation de l'interface. Si l'application possède une vue/fenêtre de présentation Markdown, elle utilise un fichier dédié :
|
||||||
|
|
||||||
|
```text
|
||||||
|
PRESENTATION.md
|
||||||
|
```
|
||||||
|
|
||||||
|
Ce fichier est optionnel. Une application monofenêtre sans présentation ne le crée pas. Lorsqu'il existe :
|
||||||
|
|
||||||
|
- il est destiné au renderer Markdown embarqué de l'application (`markdown-it` est la référence historique KSP lorsqu'un renderer est nécessaire) ;
|
||||||
|
- il ne contient aucun lien navigable Markdown ou HTML susceptible de provoquer une navigation hors du flux Tauri ;
|
||||||
|
- il reste distinct du `README.md` de package et du `USAGE.md` opérateur.
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<!-- file: docs/rules/RULES_KSP.md -->
|
<!-- file: docs/rules/RULES_KSP.md -->
|
||||||
<!-- version: 14 -->
|
<!-- version: 22 -->
|
||||||
|
|
||||||
# Règles spécifiques à KSP
|
# Règles spécifiques à KSP
|
||||||
|
|
||||||
@@ -26,6 +26,28 @@
|
|||||||
- **KSP-API-006** — `ksp-store-lib` contient PostgreSQL comme implémentation officielle de référence derrière `ksp-store-api`.
|
- **KSP-API-006** — `ksp-store-lib` contient PostgreSQL comme implémentation officielle de référence derrière `ksp-store-api`.
|
||||||
- **KSP-API-007** — Une crate `*-api` n'est créée que lorsqu'un vrai besoin d'extension, backend ou lifecycle le justifie ; la symétrie de nommage n'est jamais une justification suffisante.
|
- **KSP-API-007** — Une crate `*-api` n'est créée que lorsqu'un vrai besoin d'extension, backend ou lifecycle le justifie ; la symétrie de nommage n'est jamais une justification suffisante.
|
||||||
|
|
||||||
|
|
||||||
|
## Configuration et environnement
|
||||||
|
|
||||||
|
- **KSP-CONFIG-001** — `ksp-config-lib` est l'unique propriétaire KSP de la lecture des documents Config, du `.env`, des variables applicatives `KSP_*` / `KSPB_*` et de leur résolution ; les autres crates ne lisent pas directement ces sources.
|
||||||
|
- **KSP-CONFIG-002** — Les namespaces d'environnement sont `KSP_*`, `KSP_PUBLIC_*`, `KSP_SECRET_*` pour les composants génériques et `KSPB_*`, `KSPB_PUBLIC_*`, `KSPB_SECRET_*` pour la branche bot ; les anciens préfixes KS/KB ne sont pas utilisés dans KSP.
|
||||||
|
- **KSP-CONFIG-003** — La priorité d'une variable est environnement du processus > `./.env` > fallback déclaré au point d'usage. Une chaîne vide explicitement définie est une valeur présente et n'active pas le fallback.
|
||||||
|
- **KSP-CONFIG-004** — Le `.env` runtime est local, non versionné et non échangé. Config le lit sans prétendre modifier l'environnement du shell/systemd/parent qui a lancé le processus.
|
||||||
|
- **KSP-CONFIG-005** — `.env.example` est versionné à la racine et inventorie toutes les variables d'environnement runtime KSP/KSPB utilisées par les fichiers Config ou le code ; toute nouvelle variable y est ajoutée dans le même delta que sa première utilisation.
|
||||||
|
- **KSP-CONFIG-006** — Chaque entrée de `.env.example` est précédée d'un commentaire décrivant son usage/utilité. Sa valeur peut être un défaut sûr, une valeur générique non secrète ou une entrée commentée ; aucun vrai secret n'y est enregistré.
|
||||||
|
- **KSP-CONFIG-007** — Les placeholders Config utilisent `${NAME}` ou `${NAME:-fallback}`. Le fallback s'applique uniquement si la variable est absente ; l'interpolation appartient à `ksp-config-lib` et non aux consumers.
|
||||||
|
- **KSP-CONFIG-008** — La sensibilité d'une variable est dérivée de son nom : `KSP_SECRET_*`/`KSPB_SECRET_*` -> `Secret`, `KSP_PUBLIC_*`/`KSPB_PUBLIC_*` -> `Public`, les autres variables KSP/KSPB -> `Internal`, avec l'ordre `Secret > Internal > Public`.
|
||||||
|
- **KSP-CONFIG-009** — Toute valeur Config contenant un segment `Secret` conserve une valeur réelle pour le runtime légitime et une représentation sûre où chaque segment secret est remplacé par `********`; les `Debug` génériques ne révèlent jamais la valeur réelle secrète.
|
||||||
|
- **KSP-CONFIG-010** — La sensibilité d'une chaîne composée est la plus forte des placeholders utilisés. Un fallback d'une variable `Secret` reste `Secret` et doit être redacted même si la valeur provient du fallback.
|
||||||
|
- **KSP-CONFIG-011** — La provenance de résolution n'embarque jamais la valeur d'environnement elle-même ; elle distingue document literal, process, `.env` et fallback avec le nom de variable concerné.
|
||||||
|
- **KSP-CONFIG-012** — Pour `std.logging`, `logs_directory` peut être absolu ou relatif ; un chemin relatif est ancré sur le current working directory du processus lors de la construction des settings. Le fallback du placeholder ne s'applique que si la variable est absente ; une valeur explicitement présente mais invalide provoque une erreur de configuration effective.
|
||||||
|
- **KSP-CONFIG-013** — Le document standard Logging ne consomme pas de variable classée `Secret`. L'adapter Config -> Logging rejette une configuration effective `Secret` afin qu'aucune valeur secrète ne soit transmise aux diagnostics runtime/filesystem de Logging.
|
||||||
|
- **KSP-CONFIG-014** — La persistence Config n'expose pas de primitive publique d'écriture vers un chemin arbitraire. Un document connu est muté via son contrat source typé, validé complètement puis remplacé atomiquement dans le path résolu par son `file_id`; un échec avant commit conserve l'ancien fichier.
|
||||||
|
- **KSP-CONFIG-015** — L'environnement du processus reste read-only. La surface management peut créer/modifier/supprimer uniquement des entrées KSP/KSPB du `.env`; chaque mutation rapporte séparément changement de source persistée, changement effectif courant, shadowing par le process et besoin de reload.
|
||||||
|
- **KSP-CONFIG-016** — Les rapports management ordinaires n'exposent que des valeurs sûres/redacted. L'accès en clair à une valeur d'environnement passe par un appel `reveal_*` explicite; l'authentification/autorisation de l'utilisateur final appartient à l'application et cette révélation n'autorise jamais le secret dans les logs/`Debug`/diagnostics génériques.
|
||||||
|
- **KSP-CONFIG-017** — Les frontières d'ownership Config sont vérifiées par des audits exécutables du workspace : Core/Logging ne dépendent pas de Config, les crates hors `ksp-config-lib` ne lisent pas directement les variables KSP/KSPB via `std::env::var*`/énumération de l'environnement et ne codent pas en dur les noms physiques des fichiers gérés lorsqu'un contrat Config existe.
|
||||||
|
- **KSP-CONFIG-018** — L'inventaire `.env.example` est vérifié automatiquement contre les variables KSP/KSPB concrètes utilisées par les JSON sous `config/` et le code Rust production. Toute clé runtime détectée doit posséder une entrée d'inventaire précédée d'un commentaire explicatif.
|
||||||
|
|
||||||
## Programmes et exécution
|
## Programmes et exécution
|
||||||
|
|
||||||
- **KSP-PROGRAM-001** — Les contrats de décodage et de préparation d'exécution appartiennent à `ksp-program-api`; les implémentations officielles intégrées appartiennent à `ksp-program-lib`.
|
- **KSP-PROGRAM-001** — Les contrats de décodage et de préparation d'exécution appartiennent à `ksp-program-api`; les implémentations officielles intégrées appartiennent à `ksp-program-lib`.
|
||||||
@@ -178,6 +200,20 @@
|
|||||||
- **KSP-APP-005** — Les applications spécialisées précèdent toute future application globale ; aucune app globale n'est un livrable actuel.
|
- **KSP-APP-005** — Les applications spécialisées précèdent toute future application globale ; aucune app globale n'est un livrable actuel.
|
||||||
- **KSP-APP-006** — Une app manager worker utilise le control plane et ne modifie pas directement l'état interne de processing du worker en base.
|
- **KSP-APP-006** — Une app manager worker utilise le control plane et ne modifie pas directement l'état interne de processing du worker en base.
|
||||||
- **KSP-APP-007** — Une future application globale est conservée comme idée produit et sera cadrée seulement après validation suffisante des apps spécialisées/demos.
|
- **KSP-APP-007** — Une future application globale est conservée comme idée produit et sera cadrée seulement après validation suffisante des apps spécialisées/demos.
|
||||||
|
- **KSP-APP-008** — `ksp-app-config-desk` est l'application desktop de référence destinée à établir le gabarit des futures applications Tauri KSP. Les applications suivantes réutilisent ce gabarit sauf justification explicite documentée.
|
||||||
|
- **KSP-APP-009** — Le gabarit desktop KSP réutilise/refond les éléments éprouvés de khadhroony-bot3 : organisation SASS/SCSS, dépendances frontend utiles, gabarit visuel initial, icône de base et fichiers de build TypeScript/Vite similaires. Cette référence est auditée et adaptée ; elle n'est pas copiée aveuglément.
|
||||||
|
- **KSP-APP-010** — Une application Tauri KSP est un package Rust mixte avec cible bibliothèque et cible binaire de noms distincts. Le binaire reste un launcher mince.
|
||||||
|
- **KSP-APP-011** — `main.rs` d'une application Tauri appelle essentiellement la fonction publique `run` de la bibliothèque applicative ; il porte uniquement le bootstrap strictement exécutable, notamment le verrou single-instance et les arguments CLI lorsqu'ils sont nécessaires.
|
||||||
|
- **KSP-APP-012** — `lib.rs` d'une application Tauri reste un point de déclaration de modules et de réexport des fonctions/contrats nécessaires ; la logique des fenêtres et opérations n'y est pas accumulée.
|
||||||
|
- **KSP-APP-013** — `tauri.rs` possède la fonction `run`, les wrappers `#[tauri::command]` vers les fonctions déclarées dans leurs modules, ainsi que les helpers Tauri/démarrage communs lorsqu'ils sont réellement partagés (`init_rustls`, `open_or_focus_window`, etc.). Les annotations `#[tauri::command]` ne sont pas dispersées dans les modules métier/fenêtre.
|
||||||
|
- **KSP-APP-014** — Les modules propres à une fenêtre Tauri utilisent le préfixe `tw_` (`Tauri window`) sauf convention équivalente explicitement décidée avant implémentation. Les helpers réutilisables entre fenêtres sont regroupés dans des modules communs au lieu d'être dupliqués.
|
||||||
|
- **KSP-APP-015** — Toutes les applications Tauri KSP réutilisent un splashscreen commun et son lifecycle de démarrage. Les paramètres de temporisation du splashscreen sont configurables via Config/.env, jamais codés en dur dans chaque application.
|
||||||
|
- **KSP-APP-016** — Toute variable d'environnement introduite pour le splashscreen ou une autre capacité desktop respecte les namespaces KSP/KSPB et est ajoutée à `.env.example`, avec commentaire, dans le même delta que sa première utilisation runtime conformément à `KSP-CONFIG-005/006`.
|
||||||
|
- **KSP-APP-017** — Le `README.md` d'une application Tauri documente le package et ne sert jamais de source Markdown chargée dans une fenêtre de présentation. Lorsqu'une application affiche une présentation embarquée, le contenu UI appartient à un fichier dédié `PRESENTATION.md`; `markdown-it` est la référence de rendu issue de khadhroony-bot3 lorsque ce besoin existe et sa version est auditée avant ajout.
|
||||||
|
- **KSP-APP-018** — `PRESENTATION.md` est optionnel : une application monofenêtre qui ne possède aucune vue de présentation n'en crée pas. Lorsqu'il existe, il contient du contenu Markdown statique destiné à l'UI et aucun lien navigable Markdown ou HTML (`[texte](...)`, `<a ...>`, URL brute destinée à la navigation) susceptible de détourner ou casser le comportement des fenêtres Tauri.
|
||||||
|
- **KSP-APP-019** — Les applications Tauri KSP utilisent l'écosystème `tracing` à leur frontière desktop via le plugin Tauri de tracing retenu (`tauri-plugin-tracing` ou successeur explicitement validé) et des adapters similaires au modèle éprouvé de khadhroony-bot3. Elles n'utilisent pas `tauri-plugin-log` ni la façade `log`, sauf décision architecturale future explicite qui remplacerait cette règle. L'application ne configure pas directement `tracing-subscriber`/`tracing-appender` : `ksp-logging-lib` reste propriétaire du runtime Logging, le plugin Tauri servant d'adapter d'intégration desktop.
|
||||||
|
- **KSP-APP-020** — La première version de `ksp-app-config-desk` n'est clôturable que si l'UI permet de créer, modifier, sauvegarder et sélectionner plusieurs profils Logging, dont au minimum un profil mono-fichier et un profil multi-fichiers avec sorties séparées plus sortie logiciel/console, puis de recharger à chaud la configuration Logging et de constater effectivement le nouveau routage sans redémarrage de l'application.
|
||||||
|
- **KSP-APP-021** — `ksp-app-config-desk` est conçu comme un gestionnaire Config extensible : la première version peut fournir un éditeur Logging typé, mais son architecture/navigation/état ne doit pas figer l'application sur `std.logging.json`. De futurs `file_id`, schemas et éditeurs spécialisés doivent pouvoir être ajoutés sans déplacer la propriété des documents, de leur validation ou de leur persistence hors de `ksp-config-lib`.
|
||||||
|
|
||||||
## Data plane / control plane
|
## Data plane / control plane
|
||||||
|
|
||||||
@@ -197,3 +233,10 @@
|
|||||||
- **KSP-REL-006** — Une release ou prerelease trop grosse est scindée plutôt que compressée pour respecter un numéro prévu.
|
- **KSP-REL-006** — Une release ou prerelease trop grosse est scindée plutôt que compressée pour respecter un numéro prévu.
|
||||||
- **KSP-REL-007** — La dernière prerelease d'une release fonctionnelle réalise par défaut validations finales, documentation, nettoyage/archivage, changelog et prompt de la release suivante.
|
- **KSP-REL-007** — La dernière prerelease d'une release fonctionnelle réalise par défaut validations finales, documentation, nettoyage/archivage, changelog et prompt de la release suivante.
|
||||||
- **KSP-REL-008** — La première release fonctionnelle sélectionnée est `0.1.1`, dédiée à la stabilisation de `ksp-core-lib`.
|
- **KSP-REL-008** — La première release fonctionnelle sélectionnée est `0.1.1`, dédiée à la stabilisation de `ksp-core-lib`.
|
||||||
|
- **KSP-REL-009** — Après toute modification d'un fichier Rust, la tranche concernée exécute avant livraison au minimum `cargo fmt --all`, `cargo check --workspace` et `cargo clippy --workspace --all-targets`. Une commande non exécutée n'est jamais déclarée réussie.
|
||||||
|
- **KSP-REL-010** — Pendant le développement courant d'une crate, les tests Rust privilégient la portée ciblée `cargo test -p <crate>` et, si utile, ses tests d'intégration nommés. `cargo test --workspace` est conservé pour l'ouverture ou la fermeture d'une session/version et pour les validations globales explicitement justifiées.
|
||||||
|
- **KSP-REL-011** — Les validations d'une crate incluent les audits `cargo tree` pertinents pour son graphe réel : arbre normal, doublons et features lorsque ces vues apportent une information utile. Les crates fondamentales/complétées conservent leurs canaries de dépendances.
|
||||||
|
- **KSP-REL-012** — Toute crate ou composant KSP considéré comme complété possède un `README.md` descriptif durable avant clôture de sa release.
|
||||||
|
- **KSP-REL-013** — Toute bibliothèque KSP considérée comme complétée possède un `USAGE.md` durable, sans notes de version, présentant sa surface publique et un exemple d'utilisation pour chaque API publique destinée à être consommée directement ; plusieurs APIs étroitement liées peuvent partager un même exemple lorsque leur usage réel est composé. Les changements de release appartiennent au changelog/delta, pas au guide d'utilisation.
|
||||||
|
- **KSP-REL-014** — Toute application/binaire Tauri KSP considéré comme complété possède un `USAGE.md` durable décrivant ses fenêtres, leurs objectifs, leurs flux principaux et leur utilisation opérateur.
|
||||||
|
- **KSP-REL-015** — Une application Tauri complétée ne crée `PRESENTATION.md` que si elle possède réellement une vue de présentation embarquée ; dans ce cas le fichier est finalisé comme contenu UI sans liens navigables et reste distinct du `README.md` et du `USAGE.md`.
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<!-- file: prompts/000-README.md -->
|
<!-- file: prompts/000-README.md -->
|
||||||
<!-- version: 5 -->
|
<!-- version: 6 -->
|
||||||
|
|
||||||
# Prompts KSP
|
# Prompts KSP
|
||||||
|
|
||||||
@@ -23,4 +23,5 @@ Le prompt générique `0.1.x` a été affiné pendant `0.0.3` puis remplacé par
|
|||||||
|
|
||||||
- [`001-V0_1_1_START_PROMPT.md`](001-V0_1_1_START_PROMPT.md) — prompt historique ouvrant la première release fonctionnelle `0.1.1` après publication stable de `0.0.3` ;
|
- [`001-V0_1_1_START_PROMPT.md`](001-V0_1_1_START_PROMPT.md) — prompt historique ouvrant la première release fonctionnelle `0.1.1` après publication stable de `0.0.3` ;
|
||||||
- [`002-V0_1_2_START_PROMPT.md`](002-V0_1_2_START_PROMPT.md) — prompt final destiné à ouvrir `0.1.2 — Logging foundation` après publication stable de `0.1.1`.
|
- [`002-V0_1_2_START_PROMPT.md`](002-V0_1_2_START_PROMPT.md) — prompt final destiné à ouvrir `0.1.2 — Logging foundation` après publication stable de `0.1.1`.
|
||||||
- [`003-V0_1_3_START_PROMPT.md`](003-V0_1_3_START_PROMPT.md) — prompt final destiné à ouvrir `0.1.3 — Configuration foundation` après publication stable de `0.1.2`.
|
- [`003-V0_1_3_START_PROMPT.md`](003-V0_1_3_START_PROMPT.md) — prompt historique destiné à ouvrir `0.1.3 — Configuration foundation` après publication stable de `0.1.2` ;
|
||||||
|
- [`004-V0_1_4_START_PROMPT.md`](004-V0_1_4_START_PROMPT.md) — prompt final destiné à ouvrir `0.1.4 — ksp-app-config-desk` après publication stable de `0.1.3`.
|
||||||
|
|||||||
311
prompts/004-V0_1_4_START_PROMPT.md
Normal file
311
prompts/004-V0_1_4_START_PROMPT.md
Normal file
@@ -0,0 +1,311 @@
|
|||||||
|
<!-- file: prompts/004-V0_1_4_START_PROMPT.md -->
|
||||||
|
<!-- version: 4 -->
|
||||||
|
|
||||||
|
# Prompt de démarrage `0.1.4` — ksp-app-config-desk
|
||||||
|
|
||||||
|
## 1. Mission
|
||||||
|
|
||||||
|
Ouvrir `0.1.4` uniquement après publication et tag validés de `v0.1.3`.
|
||||||
|
|
||||||
|
La mission de cette release est d'introduire `ksp-app-config-desk`, première application desktop spécialisée KSP, afin de valider réellement `ksp-config-lib` et la frontière Tauri sans déplacer la logique Config dans l'application.
|
||||||
|
|
||||||
|
La **première prerelease `0.1.4-pre.001` doit être consacrée au brainstorming, à l'audit et au plan détaillé**. Ne pas commencer directement par une implémentation Tauri dispersée. Le `pre.001` doit décider les écrans, commandes, DTO, lifecycle Logging, risques secrets, packaging et découpage des prereleases avant le développement fonctionnel.
|
||||||
|
|
||||||
|
## 2. Base requise
|
||||||
|
|
||||||
|
Base stable attendue :
|
||||||
|
|
||||||
|
```text
|
||||||
|
v0.1.3
|
||||||
|
workspace.package.version = "0.1.3"
|
||||||
|
```
|
||||||
|
|
||||||
|
Le workspace doit contenir au minimum :
|
||||||
|
|
||||||
|
```text
|
||||||
|
crates/ksp-core-lib
|
||||||
|
crates/ksp-logging-lib
|
||||||
|
crates/ksp-config-lib
|
||||||
|
config/std.logging.json
|
||||||
|
config/schemas/std.logging.schema.json
|
||||||
|
config/schemas/composite.schema.json
|
||||||
|
config/examples/std.logging.example.json
|
||||||
|
config/examples/composite.example.json
|
||||||
|
.env.example
|
||||||
|
```
|
||||||
|
|
||||||
|
Avant toute modification, relire :
|
||||||
|
|
||||||
|
```text
|
||||||
|
README.md
|
||||||
|
RULES.md
|
||||||
|
ROADMAP.md
|
||||||
|
CHANGELOG.md
|
||||||
|
docs/000-README.md
|
||||||
|
docs/plans/002-FUNCTIONAL_RELEASE_SEQUENCE.md
|
||||||
|
docs/plans/005-V0_1_3_CONFIG_FOUNDATION_PLAN.md
|
||||||
|
crates/ksp-config-lib/README.md
|
||||||
|
crates/ksp-config-lib/USAGE.md
|
||||||
|
crates/ksp-config-lib/TODO.md
|
||||||
|
crates/ksp-logging-lib/README.md
|
||||||
|
crates/ksp-logging-lib/USAGE.md
|
||||||
|
```
|
||||||
|
|
||||||
|
Relire également les règles Tauri/Rust/documentation avant de choisir la structure finale de l'application.
|
||||||
|
|
||||||
|
## 3. Surface Config stable à réutiliser
|
||||||
|
|
||||||
|
`ksp-config-lib 0.1.3` possède déjà :
|
||||||
|
|
||||||
|
- `ConfigBootstrapOptions` et les arguments `--cfgpath` / `--schemapath` ;
|
||||||
|
- `ConfigFileRegistry`, `ConfigFileId` et `--filemap` ;
|
||||||
|
- `ConfigDocumentEngine` ;
|
||||||
|
- validation JSON/JSON Schema et invariants sémantiques ;
|
||||||
|
- globals, profils, `default_profile` et provenance ;
|
||||||
|
- composites génériques par `file_id` ;
|
||||||
|
- `ConfigEnvironment` avec priorité process > `.env` > fallback ;
|
||||||
|
- `${NAME}` / `${NAME:-fallback}` ;
|
||||||
|
- `Public` / `Internal` / `Secret`, real/safe/provenance ;
|
||||||
|
- `ResolvedLoggingConfig` et le mapping vers `ksp_logging_lib::LoggingSettings` ;
|
||||||
|
- `ConfigManagement` ;
|
||||||
|
- lecture source brute d'un `file_id` connu ;
|
||||||
|
- `LoggingConfigDocument` et ses sous-contrats typés mutables ;
|
||||||
|
- persistence atomique de `std.logging.json` ;
|
||||||
|
- rapports d'environnement desired/effective/shadow ;
|
||||||
|
- `reveal_effective_environment_value()` / `reveal_dotenv_value()` comme frontières explicites d'accès au réel ;
|
||||||
|
- `set_dotenv_value()` / `remove_dotenv_value()` ;
|
||||||
|
- audits workspace de non-contournement Config et couverture `.env.example`.
|
||||||
|
|
||||||
|
L'application doit **consommer ces APIs**, pas reproduire leur comportement.
|
||||||
|
|
||||||
|
## 4. Architecture cible à auditer pendant `pre.001`
|
||||||
|
|
||||||
|
Direction attendue :
|
||||||
|
|
||||||
|
```text
|
||||||
|
ksp-app-config-desk
|
||||||
|
-> ksp-config-lib
|
||||||
|
-> ksp-logging-lib
|
||||||
|
-> ksp-core-lib via les contrats KSP nécessaires
|
||||||
|
```
|
||||||
|
|
||||||
|
L'application est une composition/interface. Elle ne devient pas propriétaire :
|
||||||
|
|
||||||
|
- du parsing JSON ;
|
||||||
|
- des schemas ;
|
||||||
|
- de `.env` ;
|
||||||
|
- des placeholders ;
|
||||||
|
- de la classification des secrets ;
|
||||||
|
- du mapping Logging ;
|
||||||
|
- de la persistence Config.
|
||||||
|
|
||||||
|
La crate applicative est placée directement sous :
|
||||||
|
|
||||||
|
```text
|
||||||
|
crates/ksp-app-config-desk
|
||||||
|
```
|
||||||
|
|
||||||
|
conformément au layout KSP. Son intégration au workspace et l'organisation interne frontend/Tauri doivent être confirmées dans le plan `pre.001` à partir des règles KSP actives et des contraintes Tauri actuelles.
|
||||||
|
|
||||||
|
## 5. Capacités desktop à valider
|
||||||
|
|
||||||
|
Le brainstorming `pre.001` doit au minimum cadrer une UI permettant de tester réellement :
|
||||||
|
|
||||||
|
### Documents et diagnostics
|
||||||
|
|
||||||
|
- afficher les documents Config enregistrés par `file_id` ;
|
||||||
|
- afficher le source brut lorsqu'un document est invalide afin de permettre sa réparation ;
|
||||||
|
- distinguer erreurs JSON, schema, sémantiques et erreurs de configuration effective ;
|
||||||
|
- ne jamais demander à l'UI de reconstruire elle-même les validations.
|
||||||
|
|
||||||
|
### Profils
|
||||||
|
|
||||||
|
- afficher `default_profile` ;
|
||||||
|
- lister les profils disponibles ;
|
||||||
|
- sélectionner explicitement un profil pour inspection/résolution ;
|
||||||
|
- montrer distinctement source/global/profile/effective lorsque cela est utile à l'opérateur.
|
||||||
|
|
||||||
|
### Environnement
|
||||||
|
|
||||||
|
- afficher les variables KSP/KSPB via les rapports Config ;
|
||||||
|
- distinguer desired `.env`, effective, source process/`.env` et shadowing ;
|
||||||
|
- ne montrer par défaut que `safe_value` ;
|
||||||
|
- créer/modifier/supprimer une entrée `.env` uniquement via `ConfigManagement` ;
|
||||||
|
- signaler clairement qu'une valeur process peut masquer une modification `.env` et qu'un process parent ne peut pas être modifié par l'application.
|
||||||
|
|
||||||
|
### Secrets
|
||||||
|
|
||||||
|
- aucune valeur `Secret` réelle dans un DTO général, un log, un diagnostic ou un état UI persistant par défaut ;
|
||||||
|
- une action utilisateur explicitement privilégiée peut appeler une méthode `reveal_*` ;
|
||||||
|
- l'authentification/autorisation de cette action appartient à l'application, pas à `ksp-config-lib` ;
|
||||||
|
- le `pre.001` doit décider le contrat UX/DTO précis de reveal sans journaliser le secret.
|
||||||
|
|
||||||
|
### Logging
|
||||||
|
|
||||||
|
- charger `std.logging.json` via Config ;
|
||||||
|
- éditer ses profils/sinks via les types de management Config ;
|
||||||
|
- sauvegarder via `save_logging_document()` ;
|
||||||
|
- construire la configuration effective via `load_resolved_logging_config()` ;
|
||||||
|
- initialiser/reconfigurer `ksp-logging-lib` ;
|
||||||
|
- conserver `LoggingGuard` dans l'état applicatif/orchestration approprié ;
|
||||||
|
- permettre depuis l'UI la création et la modification de plusieurs profils Logging ;
|
||||||
|
- valider au minimum un profil mono-fichier et un profil multi-fichiers avec sorties séparées plus sortie logiciel/console ;
|
||||||
|
- permettre la sélection du profil à appliquer, sa sauvegarde puis le rechargement à chaud du runtime Logging ;
|
||||||
|
- prouver le hot reload par un changement observable du routage/format/sink sans redémarrage de l'application, idéalement au moyen d'événements de test contrôlés ;
|
||||||
|
- vérifier qu'une erreur de nouvelle configuration ne détruit pas le runtime Logging déjà valide.
|
||||||
|
|
||||||
|
Ces capacités constituent le **critère fonctionnel minimal de clôture de la première version de `ksp-app-config-desk`**. Une UI qui ne fait qu'afficher ou sauvegarder `std.logging.json` sans démontrer plusieurs profils et le rechargement effectif n'est pas suffisante pour clore `0.1.4`.
|
||||||
|
|
||||||
|
L'architecture de l'application doit en parallèle rester extensible. `0.1.4` peut implémenter un éditeur Logging spécialisé et typé, mais le shell de management, la navigation et l'état applicatif doivent pouvoir accueillir ultérieurement de nouveaux `file_id`, schemas et panneaux/éditeurs spécialisés sans faire de l'application le propriétaire du parsing, de la validation ou de la persistence Config.
|
||||||
|
|
||||||
|
## 6. Frontière Tauri
|
||||||
|
|
||||||
|
Conserver les règles KSP déjà retenues :
|
||||||
|
|
||||||
|
- TS-RS principalement à la frontière de l'application ;
|
||||||
|
- les DTO Tauri appartiennent à `ksp-app-config-desk`, pas à `ksp-config-lib`, sauf contrat externe générique explicitement justifié ;
|
||||||
|
- le `pre.001` doit décider et formaliser l’emplacement exact des `#[tauri::command]`; conserver comme direction héritée un adapter Tauri centralisé plutôt que des annotations dispersées ;
|
||||||
|
- pas de `?`, `unwrap`, `expect` ou `panic` dans les commandes ;
|
||||||
|
- l'application reste mince et appelle des fonctions/services internes qui réutilisent les crates KSP ;
|
||||||
|
- aucune utilisation directe de `tracing`, `tracing-subscriber` ou `tracing-appender` pour posséder/configurer le runtime : `ksp-logging-lib` reste la façade et le propriétaire ;
|
||||||
|
- intégrer à la frontière Tauri le plugin de tracing retenu (`tauri-plugin-tracing` ou successeur explicitement validé) et les adapters nécessaires, sur le modèle éprouvé de khadhroony-bot3 ; cette intégration desktop est l'exception prévue à la règle d'absence d'usage direct des crates `tracing*` ;
|
||||||
|
- ne pas utiliser `tauri-plugin-log` ni construire une seconde pile basée sur la crate `log` ;
|
||||||
|
- aucune lecture directe `std::env::var*` pour `KSP_*` / `KSPB_*` ;
|
||||||
|
- aucune lecture/écriture directe des fichiers physiques Config/`.env`.
|
||||||
|
|
||||||
|
Le `pre.001` doit vérifier les versions actuelles de Tauri et des dépendances frontend réellement nécessaires avant leur ajout. Les dépendances communes sont déclarées au niveau workspace lorsqu'elles sont partagées ; aucune dépendance n'est ajoutée sans usage immédiat.
|
||||||
|
|
||||||
|
### 6.1 Gabarit Tauri de référence à établir
|
||||||
|
|
||||||
|
`ksp-app-config-desk` doit servir de **modèle de référence** pour les futures applications Tauri KSP. `pre.001` doit auditer la base khadhroony-bot3 puis décider précisément ce qui est réutilisé/refondu, notamment :
|
||||||
|
|
||||||
|
- organisation SASS/SCSS et dépendances frontend/package utiles ;
|
||||||
|
- même gabarit visuel initial à affiner et même icône de base tant qu'aucune identité graphique KSP plus spécifique n'est décidée ;
|
||||||
|
- `vite.config.ts`, `tsconfig.json` et fichiers frontend/build équivalents structurés de manière cohérente avec le modèle éprouvé ;
|
||||||
|
- package Rust mixte `lib` + `bin`, avec noms de cibles distincts ;
|
||||||
|
- `main.rs` minimal : verrou single-instance, parsing/bootstrap des éventuels arguments CLI nécessaires, puis appel de la fonction `run` exposée par la bibliothèque applicative ;
|
||||||
|
- `lib.rs` limité aux déclarations de modules et réexports nécessaires ;
|
||||||
|
- `tauri.rs` propriétaire de `run`, des wrappers `#[tauri::command]` et des helpers Tauri/démarrage partagés (`init_rustls`, `open_or_focus_window`, etc.) ;
|
||||||
|
- fonctions métier/fenêtre implémentées dans leurs modules puis appelées par les wrappers de `tauri.rs` ; aucune annotation `#[tauri::command]` dispersée hors de cette frontière ;
|
||||||
|
- modules spécifiques aux fenêtres nommés `tw_*` (`Tauri window`) sauf meilleure convention explicitement décidée pendant `pre.001` ;
|
||||||
|
- helpers communs regroupés dans des modules partagés et non copiés entre fenêtres.
|
||||||
|
- modules/adapters Tauri de tracing repris/refondus depuis le modèle khadhroony-bot3, en utilisant `tauri-plugin-tracing` plutôt que `tauri-plugin-log`, tout en laissant `ksp-logging-lib` posséder la configuration/runtime `tracing`.
|
||||||
|
|
||||||
|
Cette réutilisation de bot3 est une référence de conception : le `pre.001` doit vérifier ce qui reste pertinent avec les versions Tauri/Vite/TypeScript actuelles avant intégration.
|
||||||
|
|
||||||
|
### 6.2 Splashscreen commun
|
||||||
|
|
||||||
|
Le splashscreen et ses fonctionnalités doivent devenir une capacité commune aux applications Tauri KSP :
|
||||||
|
|
||||||
|
- même lifecycle de splashscreen réutilisable ;
|
||||||
|
- comportement configurable plutôt que recopié par application ;
|
||||||
|
- durée/temporisation configurée via Config/.env ;
|
||||||
|
- le nom exact de la ou des variables est décidé au moment où le besoin runtime est implémenté, en respectant `KSP_*`/`KSP_PUBLIC_*` selon l'exposition nécessaire ;
|
||||||
|
- toute nouvelle clé est ajoutée à `.env.example` avec commentaire **dans le même delta que sa première utilisation** ;
|
||||||
|
- aucune temporisation sensible à l'environnement n'est codée en dur dans chaque application.
|
||||||
|
|
||||||
|
### 6.3 Documentation affichée dans l'UI
|
||||||
|
|
||||||
|
Ne jamais charger `README.md` comme contenu d'une fenêtre Tauri. Le README reste la documentation du package.
|
||||||
|
|
||||||
|
Si `ksp-app-config-desk` retient une vue/fenêtre de présentation, créer un `PRESENTATION.md` dédié et utiliser `markdown-it` comme renderer de référence, après vérification de la version réellement actuelle/compatible avant ajout de la dépendance. `PRESENTATION.md` ne contient aucun lien navigable Markdown ou HTML susceptible de modifier la navigation de la webview ou d'ouvrir/casser une fenêtre.
|
||||||
|
|
||||||
|
Si l'application finale est monofenêtre et n'affiche aucune présentation, ne pas créer `PRESENTATION.md` et ne pas ajouter `markdown-it` uniquement par symétrie avec bot3.
|
||||||
|
|
||||||
|
## 7. Sécurité et redaction
|
||||||
|
|
||||||
|
Le desktop Config est une application de management privilégiée, mais cela ne supprime pas les frontières de sécurité :
|
||||||
|
|
||||||
|
- les secrets ne sont jamais loggés ;
|
||||||
|
- `Debug`/diagnostics utilisent les vues sûres ;
|
||||||
|
- les APIs `reveal_*` sont appelées uniquement pour une intention explicite ;
|
||||||
|
- les DTO de reveal sont séparés des DTO ordinaires ;
|
||||||
|
- l'application doit éviter de conserver inutilement les secrets en mémoire/état UI ;
|
||||||
|
- une capture d'erreur ne doit pas recopier une valeur réelle dans son message/context ;
|
||||||
|
- le fichier `.env` reste non versionné ; `.env.example` reste l'inventaire versionné.
|
||||||
|
|
||||||
|
## 8. Règles Rust et projet à conserver
|
||||||
|
|
||||||
|
Conserver notamment :
|
||||||
|
|
||||||
|
- Rust 2024 ;
|
||||||
|
- `unsafe` interdit ;
|
||||||
|
- pas de `unwrap`, `expect`, `panic` dans le code production ;
|
||||||
|
- pas d'opérateur `?` ;
|
||||||
|
- retours explicites selon Clippy workspace ;
|
||||||
|
- pas de `mod.rs` ;
|
||||||
|
- pas de `pub(super)` / `pub(in ...)` ;
|
||||||
|
- code/Rustdoc en anglais ;
|
||||||
|
- Markdown projet en français ;
|
||||||
|
- tests unitaires hors `src` avec structure miroir ;
|
||||||
|
- tests publics dans `tests/` ;
|
||||||
|
- dépendances externes communes sous `[workspace.dependencies]` puis `.workspace = true` ;
|
||||||
|
- versions caret de génération compatibles ;
|
||||||
|
- aucun `Cargo.lock`/lockfile frontend versionné selon la politique KSP actuelle ;
|
||||||
|
- chaque delta `0.1.x` est commité ; un défaut livré est corrigé par un `fix`, jamais réécrit silencieusement.
|
||||||
|
- après toute modification Rust : `cargo fmt --all`, `cargo check --workspace` et `cargo clippy --workspace --all-targets` sont obligatoires avant livraison ;
|
||||||
|
- pendant une tranche de développement, préférer `cargo test -p <crate>` pour la crate travaillée ; réserver `cargo test --workspace` aux ouvertures/fermetures de session/version et aux contrôles globaux justifiés ;
|
||||||
|
- exécuter les `cargo tree` pertinents pour les crates modifiées/complétées ;
|
||||||
|
- toute crate complétée possède un `README.md`; une bibliothèque complétée possède un `USAGE.md` version-neutral centré sur l'API publique avec exemples ; une application Tauri complétée possède un `USAGE.md` décrivant ses fenêtres et leur utilisation.
|
||||||
|
|
||||||
|
## 9. Hors scope par défaut de `0.1.4`
|
||||||
|
|
||||||
|
Ne pas ouvrir automatiquement :
|
||||||
|
|
||||||
|
- Wallet ;
|
||||||
|
- Store/PostgreSQL ;
|
||||||
|
- RPC/WS/provider ;
|
||||||
|
- Program/decoder/executor ;
|
||||||
|
- workers/jobs/pipelines ;
|
||||||
|
- trading/ML ;
|
||||||
|
- nouveaux documents Config pour des composants inexistants ;
|
||||||
|
- watcher filesystem générique ;
|
||||||
|
- service distribué de configuration ;
|
||||||
|
- secrets manager distant ;
|
||||||
|
- chiffrement maison de `.env` ;
|
||||||
|
- application de contrôle globale KSP.
|
||||||
|
|
||||||
|
Toute extension doit être justifiée dans `pre.001` ou reportée.
|
||||||
|
|
||||||
|
## 10. Première livraison attendue : `0.1.4-pre.001`
|
||||||
|
|
||||||
|
`pre.001` doit être un **plan de travail**, pas une grosse implémentation.
|
||||||
|
|
||||||
|
Il doit produire au minimum :
|
||||||
|
|
||||||
|
1. audit exact de la base stable `v0.1.3` ;
|
||||||
|
2. audit des règles Tauri/app existantes ;
|
||||||
|
3. choix du layout interne de `crates/ksp-app-config-desk` et de son intégration workspace ;
|
||||||
|
4. matrice commandes Tauri / services internes / APIs Config appelées ;
|
||||||
|
5. matrice DTO ordinaires / DTO secrets privilégiés ;
|
||||||
|
6. modèle d'état applicatif et ownership de `LoggingGuard` ;
|
||||||
|
7. écrans/panneaux minimums et flux utilisateur ;
|
||||||
|
8. stratégie de tests Rust, Tauri et frontend, avec tests ciblés par package pendant le développement et `cargo test --workspace` aux frontières globales ;
|
||||||
|
9. audit du gabarit bot3 à réutiliser/refondre : SASS/SCSS, packages, Vite/TypeScript, icône, layout frontend et splashscreen ;
|
||||||
|
10. contrat exact `lib` + `bin`, `main.rs`, `lib.rs`, `tauri.rs`, modules `tw_*`, helpers communs et single-instance ;
|
||||||
|
11. stratégie de splashscreen commun et choix futur de la/des variable(s) Config/.env avec mise à jour obligatoire de `.env.example` lors de leur première utilisation ;
|
||||||
|
12. dépendances externes réellement nécessaires et versions actuelles vérifiées, dont le plugin Tauri de tracing ; confirmer explicitement l'absence de `tauri-plugin-log` ;
|
||||||
|
13. hors-scope confirmés ;
|
||||||
|
14. prévision souple des prereleases, chaque tranche visant environ 15–20 minutes de travail effectif ;
|
||||||
|
15. décision explicite sur la présence d'une vue de présentation : `PRESENTATION.md` + `markdown-it` si elle existe, aucun fichier/dépendance de présentation si elle n'existe pas ;
|
||||||
|
16. critères de validation de la release et documentation finale `README.md` / `USAGE.md` / `PRESENTATION.md` conditionnel ;
|
||||||
|
17. matrice de validation fonctionnelle de clôture couvrant au minimum : création/modification de plusieurs profils Logging, profil mono-fichier, profil multi-fichiers + sortie logiciel/console, sauvegarde, sélection et hot reload observable sans redémarrage ;
|
||||||
|
18. stratégie d'extensibilité permettant d'ajouter de futurs `file_id`/schemas/éditeurs sans refondre le shell de management ni contourner `ksp-config-lib`.
|
||||||
|
|
||||||
|
Ne commencer `pre.002` qu'après validation de ce plan.
|
||||||
|
|
||||||
|
## 11. Clôture future de `0.1.4`
|
||||||
|
|
||||||
|
La dernière prerelease de `0.1.4` devra comme d'habitude :
|
||||||
|
|
||||||
|
- exécuter les validations finales, dont `cargo test --workspace` puisque la release se ferme ;
|
||||||
|
- consolider la documentation ;
|
||||||
|
- finaliser le `README.md` de l'application et son `USAGE.md` décrivant chaque fenêtre et son utilisation ;
|
||||||
|
- finaliser `PRESENTATION.md` uniquement si l'application possède réellement une vue de présentation, et vérifier l'absence de liens navigables Markdown/HTML ;
|
||||||
|
- démontrer les critères fonctionnels de clôture : plusieurs profils Logging créés/modifiés via l'UI, un profil mono-fichier, un profil multi-fichiers avec sortie logiciel/console, sauvegarde et hot reload observable sans redémarrage ;
|
||||||
|
- vérifier que l'application reste structurée pour accueillir de futurs documents/schemas Config sans duplication de la logique de `ksp-config-lib` ;
|
||||||
|
- fermer/report explicitement les TODO ;
|
||||||
|
- nettoyer/archiver ce qui doit l'être ;
|
||||||
|
- synchroniser le changelog général lors de la publication stable ;
|
||||||
|
- produire le prompt de la release suivante ;
|
||||||
|
- préparer `rel.001` puis le tag stable `v0.1.4` après validation utilisateur.
|
||||||
Reference in New Issue
Block a user