Compare commits
44 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 | |||
| 3a7219fa59 | |||
| 77f6eaf487 | |||
| 8a0c119878 | |||
| d96f41fb8e | |||
| ea87a58e32 | |||
| 20c5643701 | |||
| d55903b13e | |||
| 37f53f3080 | |||
| 9f23eb950c | |||
| 151590422d | |||
| 488d8ae0a8 | |||
| 6e06802e38 | |||
| 697527675a | |||
| 7b4444fb07 | |||
| bd8401c0d0 | |||
| 18ae0e4873 |
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.
|
||||
13
Cargo.toml
13
Cargo.toml
@@ -1,12 +1,12 @@
|
||||
# file: Cargo.toml
|
||||
# version: 25
|
||||
# version: 61
|
||||
|
||||
[workspace]
|
||||
resolver = "3"
|
||||
members = ["crates/ksp-core-lib"]
|
||||
members = ["crates/ksp-config-lib", "crates/ksp-core-lib", "crates/ksp-logging-lib"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.1"
|
||||
version = "0.1.3"
|
||||
edition = "2024"
|
||||
license = "MIT"
|
||||
repository = "https://git.sasedev.com/Sasedev/khadhroony-solana-project"
|
||||
@@ -14,7 +14,14 @@ authors = ["SinuS von SifriduS <sinus@sasedev.net>"]
|
||||
publish = false
|
||||
|
||||
[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 }
|
||||
tracing = { version = "^0.1", default-features = false, features = ["std"] }
|
||||
tracing-subscriber = { version = "^0.3", default-features = false, features = ["fmt", "json", "ansi"] }
|
||||
tracing-appender = { version = "^0.2", default-features = false }
|
||||
tokio = { version = "^1.53", default-features = false, features = ["rt", "rt-multi-thread", "macros"] }
|
||||
|
||||
[workspace.lints.rust]
|
||||
missing_docs = "warn"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!-- file: README.md -->
|
||||
<!-- version: 5 -->
|
||||
<!-- version: 7 -->
|
||||
|
||||
# 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-`.
|
||||
- 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 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 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.
|
||||
@@ -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 ;
|
||||
- [`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/IDEAS.md`](docs/IDEAS.md) — idées et sujets à explorer ;
|
||||
- [`prompts/000-README.md`](prompts/000-README.md) — prompts de reprise ;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!-- file: ROADMAP.md -->
|
||||
<!-- version: 13 -->
|
||||
<!-- version: 16 -->
|
||||
|
||||
# Roadmap KSP
|
||||
|
||||
@@ -32,11 +32,11 @@ Regrouper les releases consacrées aux fondations N1. Chaque release concrète e
|
||||
### Releases concrètes
|
||||
|
||||
- [X] `0.1.1` — Stabiliser `ksp-core-lib` : `Error`/`Result`, Program IDs fondamentaux et primitives réellement N1.
|
||||
- [ ] `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.2` — Introduire `ksp-logging-lib` comme façade KSP de `tracing`, `tracing-appender` et `tracing-subscriber`.
|
||||
- [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.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.
|
||||
|
||||
|
||||
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);
|
||||
}
|
||||
20
crates/ksp-logging-lib/Cargo.toml
Normal file
20
crates/ksp-logging-lib/Cargo.toml
Normal file
@@ -0,0 +1,20 @@
|
||||
# file: crates/ksp-logging-lib/Cargo.toml
|
||||
# version: 4
|
||||
|
||||
[package]
|
||||
name = "ksp-logging-lib"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
ksp-core-lib = { path = "../ksp-core-lib" }
|
||||
tracing.workspace = true
|
||||
tracing-subscriber.workspace = true
|
||||
tracing-appender.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tokio.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
62
crates/ksp-logging-lib/README.md
Normal file
62
crates/ksp-logging-lib/README.md
Normal file
@@ -0,0 +1,62 @@
|
||||
<!-- file: crates/ksp-logging-lib/README.md -->
|
||||
<!-- version: 5 -->
|
||||
|
||||
# ksp-logging-lib
|
||||
|
||||
`ksp-logging-lib` est la façade commune de logging/tracing runtime de Khadhroony Solana Project.
|
||||
|
||||
## Responsabilités
|
||||
|
||||
La crate possède :
|
||||
|
||||
- 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 ;
|
||||
- `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 ;
|
||||
- le hot reload via `reinitialize` sans second subscriber global ;
|
||||
- le takeover des logs : les targets externes sont silencieux par défaut ;
|
||||
- les writers non bloquants console/fichier et leurs `WorkerGuard` ;
|
||||
- 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'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
|
||||
|
||||
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`.
|
||||
|
||||
Les événements utiles issus d'une dépendance externe ne sont pas renommés : la crate KSP propriétaire de l'opération réémet explicitement l'information utile sous son propre target KSP.
|
||||
|
||||
`ksp-logging-lib` ne dépend pas de `ksp-config-lib`. Config pourra construire un `LoggingSettings` puis appeler `initialize` ou `reinitialize`.
|
||||
|
||||
## Documentation
|
||||
|
||||
- [`USAGE.md`](USAGE.md) — utilisation concrète de la façade et du runtime ;
|
||||
- [`TODO.md`](TODO.md) — capacités explicitement différées ou points restant à fermer.
|
||||
37
crates/ksp-logging-lib/TODO.md
Normal file
37
crates/ksp-logging-lib/TODO.md
Normal file
@@ -0,0 +1,37 @@
|
||||
<!-- file: crates/ksp-logging-lib/TODO.md -->
|
||||
<!-- version: 5 -->
|
||||
|
||||
# TODO ksp-logging-lib
|
||||
|
||||
## À fermer pendant `0.1.3`
|
||||
|
||||
Les trois tranches Logging nécessaires à la fondation Config sont maintenant couvertes fonctionnellement :
|
||||
|
||||
- `pre.004` : contrats/settings multi-output ;
|
||||
- `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
|
||||
|
||||
Ces éléments ne sont pas requis par la fondation Config `0.1.3` :
|
||||
|
||||
- rotation par taille, rétention/compression et symlink `latest` ;
|
||||
- OpenTelemetry/export réseau ;
|
||||
- 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.
|
||||
231
crates/ksp-logging-lib/USAGE.md
Normal file
231
crates/ksp-logging-lib/USAGE.md
Normal file
@@ -0,0 +1,231 @@
|
||||
<!-- file: crates/ksp-logging-lib/USAGE.md -->
|
||||
<!-- version: 5 -->
|
||||
|
||||
# Utilisation de ksp-logging-lib
|
||||
|
||||
## Target d'une crate consommatrice
|
||||
|
||||
Chaque crate KSP comportementale fournit explicitement son target, égal au nom Cargo de la crate :
|
||||
|
||||
```rust
|
||||
const LOGGING_TARGET: &str = "ksp-store-lib";
|
||||
|
||||
ksp_logging_lib::trace!(
|
||||
target: LOGGING_TARGET,
|
||||
domain = "store",
|
||||
component = "postgres",
|
||||
operation = "load_transactions",
|
||||
"executing store operation"
|
||||
);
|
||||
```
|
||||
|
||||
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
|
||||
|
||||
```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(
|
||||
ksp_logging_lib::LogFilterLevel::Info,
|
||||
ksp_logging_lib::SpanEvents::NewAndClose,
|
||||
std::option::Option::Some(ksp_logging_lib::ConsoleSettings::stderr()),
|
||||
std::vec![file],
|
||||
);
|
||||
|
||||
let initialize_result = ksp_logging_lib::initialize(&settings);
|
||||
let mut logging_guard = match initialize_result {
|
||||
std::result::Result::Ok(guard) => guard,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
```
|
||||
|
||||
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
|
||||
|
||||
Une configuration peut être appliquée sans redémarrer le processus ou le worker :
|
||||
|
||||
```rust
|
||||
let debug_settings = ksp_logging_lib::LoggingSettings::new(
|
||||
ksp_logging_lib::LogFilterLevel::Info,
|
||||
ksp_logging_lib::SpanEvents::NewAndClose,
|
||||
std::option::Option::Some(ksp_logging_lib::ConsoleSettings::stderr()),
|
||||
std::vec::Vec::new(),
|
||||
)
|
||||
.with_target_filter(ksp_logging_lib::TargetFilter::new(
|
||||
"ksp-store-lib",
|
||||
ksp_logging_lib::LogFilterLevel::Debug,
|
||||
));
|
||||
|
||||
let reload_result = ksp_logging_lib::reinitialize(&mut logging_guard, &debug_settings);
|
||||
if let std::result::Result::Err(error) = reload_result {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
```
|
||||
|
||||
La nouvelle configuration est préparée avant la bascule. Si sa validation ou la création d'un nouveau sink échoue, l'ancienne configuration reste active.
|
||||
|
||||
## Spans synchrones
|
||||
|
||||
```rust
|
||||
let span = ksp_logging_lib::trace_span!(
|
||||
target: LOGGING_TARGET,
|
||||
"materialize_transaction",
|
||||
domain = "store"
|
||||
);
|
||||
|
||||
let output = span.in_scope(|| {
|
||||
return materialize_transaction();
|
||||
});
|
||||
```
|
||||
|
||||
Avec `SpanEvents::NewAndClose`, le formatter produit les événements de création/fermeture et les temps `busy` / `idle` à la fermeture.
|
||||
|
||||
## Spans async
|
||||
|
||||
Une `Future` doit être instrumentée avec `ksp_logging_lib::instrument` ; un guard d'entrée de span ne doit pas être conservé à travers `.await` :
|
||||
|
||||
```rust
|
||||
let span = ksp_logging_lib::trace_span!(
|
||||
target: LOGGING_TARGET,
|
||||
"fetch_account",
|
||||
domain = "transport"
|
||||
);
|
||||
|
||||
let output = ksp_logging_lib::instrument(span, fetch_account()).await;
|
||||
```
|
||||
|
||||
La future instrumentée entre/sort du span pendant ses polls et lors de son `Drop`, conformément au contrat de la primitive `tracing` sous-jacente.
|
||||
|
||||
## Lignes abandonnées
|
||||
|
||||
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
|
||||
let dropped = logging_guard.dropped_lines();
|
||||
ksp_logging_lib::warn!(
|
||||
target: LOGGING_TARGET,
|
||||
console = dropped.console(),
|
||||
file = dropped.file(),
|
||||
total = dropped.total(),
|
||||
"logging queues dropped lines"
|
||||
);
|
||||
```
|
||||
|
||||
Pour un fichier précis :
|
||||
|
||||
```rust
|
||||
let dropped_for_output = logging_guard.dropped_file_lines("file.worker");
|
||||
```
|
||||
|
||||
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;
|
||||
11
crates/ksp-logging-lib/src/error.rs
Normal file
11
crates/ksp-logging-lib/src/error.rs
Normal file
@@ -0,0 +1,11 @@
|
||||
// file: crates/ksp-logging-lib/src/error.rs
|
||||
// version: 3
|
||||
|
||||
/// Error code used when runtime logging settings are invalid.
|
||||
pub const ERROR_CODE_INVALID_SETTINGS: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("logging", "invalid_settings");
|
||||
/// Error code used when a global logging subscriber is already installed.
|
||||
pub const ERROR_CODE_ALREADY_INITIALIZED: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("logging", "already_initialized");
|
||||
/// Error code used when a hot reload cannot replace the active runtime layers.
|
||||
pub const ERROR_CODE_RELOAD_FAILED: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("logging", "reload_failed");
|
||||
/// Error code used when the rolling file output cannot be initialized.
|
||||
pub const ERROR_CODE_FILE_OUTPUT_INITIALIZATION_FAILED: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("logging", "file_output_initialization_failed");
|
||||
65
crates/ksp-logging-lib/src/lib.rs
Normal file
65
crates/ksp-logging-lib/src/lib.rs
Normal file
@@ -0,0 +1,65 @@
|
||||
// file: crates/ksp-logging-lib/src/lib.rs
|
||||
// version: 7
|
||||
#![warn(missing_docs)]
|
||||
#![deny(unreachable_pub)]
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
//! 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
|
||||
//! `tracing` stack. The crate owns the single global subscriber, KSP takeover filtering, hot reload and non-blocking outputs. `0.1.3-pre.006` supports
|
||||
//! multiple simultaneous outputs with per-output level/target/domain routing, selectable formats, console ANSI and per-file dropped-line accounting. Structured
|
||||
//! `domain` routing remains distinct from targets and follows explicit event domains or inherited span domains.
|
||||
|
||||
mod domain;
|
||||
mod error;
|
||||
mod macros;
|
||||
mod runtime;
|
||||
mod settings;
|
||||
mod span;
|
||||
mod writer;
|
||||
|
||||
/// Error code used when a global logging subscriber is already installed.
|
||||
pub use self::error::ERROR_CODE_ALREADY_INITIALIZED;
|
||||
/// Error code used when the rolling file output cannot be initialized.
|
||||
pub use self::error::ERROR_CODE_FILE_OUTPUT_INITIALIZATION_FAILED;
|
||||
/// Error code used when runtime logging settings are invalid.
|
||||
pub use self::error::ERROR_CODE_INVALID_SETTINGS;
|
||||
/// Error code used when a hot reload cannot replace the active runtime layers.
|
||||
pub use self::error::ERROR_CODE_RELOAD_FAILED;
|
||||
/// Cumulative number of log lines dropped by non-blocking KSP outputs.
|
||||
pub use self::runtime::DroppedLines;
|
||||
/// Guard owning the mutable runtime state and non-blocking writers of the installed KSP logging subscriber.
|
||||
pub use self::runtime::LoggingGuard;
|
||||
/// Installs the global KSP tracing subscriber.
|
||||
pub use self::runtime::initialize;
|
||||
/// Replaces the active KSP logging settings without reinstalling the global subscriber.
|
||||
pub use self::runtime::reinitialize;
|
||||
/// Console stream selected for human-readable logs.
|
||||
pub use self::settings::ConsoleOutput;
|
||||
/// Runtime settings for the optional console output.
|
||||
pub use self::settings::ConsoleSettings;
|
||||
/// Rotation cadence for one file output.
|
||||
pub use self::settings::FileRotation;
|
||||
/// Runtime settings for one file output.
|
||||
pub use self::settings::FileSettings;
|
||||
/// Runtime filter level used by KSP logging settings.
|
||||
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.
|
||||
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.
|
||||
pub use self::settings::SpanEvents;
|
||||
/// Per-target filter override owned by Logging.
|
||||
pub use self::settings::TargetFilter;
|
||||
/// KSP-owned handle to a tracing span.
|
||||
pub use self::span::Span;
|
||||
/// Instruments an asynchronous future with a KSP span.
|
||||
pub use self::span::instrument;
|
||||
|
||||
#[doc(hidden)]
|
||||
/// Internal macro bridge. KSP consumers must not use this reexport directly.
|
||||
pub extern crate tracing as __private_tracing;
|
||||
97
crates/ksp-logging-lib/src/macros.rs
Normal file
97
crates/ksp-logging-lib/src/macros.rs
Normal file
@@ -0,0 +1,97 @@
|
||||
// file: crates/ksp-logging-lib/src/macros.rs
|
||||
// version: 1
|
||||
|
||||
/// Emits a KSP error event with an explicit owning target.
|
||||
#[macro_export]
|
||||
macro_rules! error {
|
||||
(target: $target:expr, $($argument:tt)+) => {{
|
||||
$crate::__private_tracing::error!(target: $target, $($argument)+);
|
||||
}};
|
||||
}
|
||||
|
||||
/// Emits a KSP warning event with an explicit owning target.
|
||||
#[macro_export]
|
||||
macro_rules! warn {
|
||||
(target: $target:expr, $($argument:tt)+) => {{
|
||||
$crate::__private_tracing::warn!(target: $target, $($argument)+);
|
||||
}};
|
||||
}
|
||||
|
||||
/// Emits a KSP informational event with an explicit owning target.
|
||||
#[macro_export]
|
||||
macro_rules! info {
|
||||
(target: $target:expr, $($argument:tt)+) => {{
|
||||
$crate::__private_tracing::info!(target: $target, $($argument)+);
|
||||
}};
|
||||
}
|
||||
|
||||
/// Emits a KSP debug event with an explicit owning target.
|
||||
#[macro_export]
|
||||
macro_rules! debug {
|
||||
(target: $target:expr, $($argument:tt)+) => {{
|
||||
$crate::__private_tracing::debug!(target: $target, $($argument)+);
|
||||
}};
|
||||
}
|
||||
|
||||
/// Emits a KSP trace event with an explicit owning target.
|
||||
#[macro_export]
|
||||
macro_rules! trace {
|
||||
(target: $target:expr, $($argument:tt)+) => {{
|
||||
$crate::__private_tracing::trace!(target: $target, $($argument)+);
|
||||
}};
|
||||
}
|
||||
|
||||
/// Creates a KSP error span with an explicit owning target.
|
||||
#[macro_export]
|
||||
macro_rules! error_span {
|
||||
(target: $target:expr, $name:expr) => {{
|
||||
$crate::Span::__from_tracing($crate::__private_tracing::error_span!(target: $target, $name))
|
||||
}};
|
||||
(target: $target:expr, $name:expr, $($field:tt)+) => {{
|
||||
$crate::Span::__from_tracing($crate::__private_tracing::error_span!(target: $target, $name, $($field)+))
|
||||
}};
|
||||
}
|
||||
|
||||
/// Creates a KSP warning span with an explicit owning target.
|
||||
#[macro_export]
|
||||
macro_rules! warn_span {
|
||||
(target: $target:expr, $name:expr) => {{
|
||||
$crate::Span::__from_tracing($crate::__private_tracing::warn_span!(target: $target, $name))
|
||||
}};
|
||||
(target: $target:expr, $name:expr, $($field:tt)+) => {{
|
||||
$crate::Span::__from_tracing($crate::__private_tracing::warn_span!(target: $target, $name, $($field)+))
|
||||
}};
|
||||
}
|
||||
|
||||
/// Creates a KSP informational span with an explicit owning target.
|
||||
#[macro_export]
|
||||
macro_rules! info_span {
|
||||
(target: $target:expr, $name:expr) => {{
|
||||
$crate::Span::__from_tracing($crate::__private_tracing::info_span!(target: $target, $name))
|
||||
}};
|
||||
(target: $target:expr, $name:expr, $($field:tt)+) => {{
|
||||
$crate::Span::__from_tracing($crate::__private_tracing::info_span!(target: $target, $name, $($field)+))
|
||||
}};
|
||||
}
|
||||
|
||||
/// Creates a KSP debug span with an explicit owning target.
|
||||
#[macro_export]
|
||||
macro_rules! debug_span {
|
||||
(target: $target:expr, $name:expr) => {{
|
||||
$crate::Span::__from_tracing($crate::__private_tracing::debug_span!(target: $target, $name))
|
||||
}};
|
||||
(target: $target:expr, $name:expr, $($field:tt)+) => {{
|
||||
$crate::Span::__from_tracing($crate::__private_tracing::debug_span!(target: $target, $name, $($field)+))
|
||||
}};
|
||||
}
|
||||
|
||||
/// Creates a KSP trace span with an explicit owning target.
|
||||
#[macro_export]
|
||||
macro_rules! trace_span {
|
||||
(target: $target:expr, $name:expr) => {{
|
||||
$crate::Span::__from_tracing($crate::__private_tracing::trace_span!(target: $target, $name))
|
||||
}};
|
||||
(target: $target:expr, $name:expr, $($field:tt)+) => {{
|
||||
$crate::Span::__from_tracing($crate::__private_tracing::trace_span!(target: $target, $name, $($field)+))
|
||||
}};
|
||||
}
|
||||
410
crates/ksp-logging-lib/src/runtime.rs
Normal file
410
crates/ksp-logging-lib/src/runtime.rs
Normal file
@@ -0,0 +1,410 @@
|
||||
// file: crates/ksp-logging-lib/src/runtime.rs
|
||||
// version: 11
|
||||
|
||||
use tracing_subscriber::Layer; // rust-rules: trait-import
|
||||
use tracing_subscriber::layer::SubscriberExt; // rust-rules: trait-import
|
||||
|
||||
/// Cumulative number of log lines dropped by non-blocking KSP outputs.
|
||||
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
|
||||
pub struct DroppedLines {
|
||||
console: usize,
|
||||
file: usize,
|
||||
}
|
||||
|
||||
impl DroppedLines {
|
||||
/// Returns an empty dropped-line snapshot.
|
||||
#[must_use]
|
||||
pub const fn zero() -> Self {
|
||||
return Self { console: 0, file: 0 };
|
||||
}
|
||||
|
||||
/// Returns the number of console lines dropped since Logging initialization.
|
||||
#[must_use]
|
||||
pub const fn console(&self) -> usize {
|
||||
return self.console;
|
||||
}
|
||||
|
||||
/// Returns the number of file lines dropped since Logging initialization.
|
||||
#[must_use]
|
||||
pub const fn file(&self) -> usize {
|
||||
return self.file;
|
||||
}
|
||||
|
||||
/// Returns the total number of dropped lines across console and file outputs.
|
||||
#[must_use]
|
||||
pub const fn total(&self) -> usize {
|
||||
return self.console.saturating_add(self.file);
|
||||
}
|
||||
|
||||
const fn saturating_add(self, other: Self) -> Self {
|
||||
return Self { console: self.console.saturating_add(other.console), file: self.file.saturating_add(other.file) };
|
||||
}
|
||||
}
|
||||
|
||||
/// Guard owning the mutable runtime state and non-blocking writers of the installed KSP logging subscriber.
|
||||
pub struct LoggingGuard {
|
||||
reload_handle: RuntimeReloadHandle,
|
||||
settings: crate::LoggingSettings,
|
||||
outputs: RuntimeOutputs,
|
||||
retired_dropped_lines: crate::DroppedLines,
|
||||
retired_file_dropped_lines: std::collections::HashMap<std::string::String, usize>,
|
||||
}
|
||||
|
||||
impl LoggingGuard {
|
||||
/// Returns the settings currently active in the KSP logging runtime.
|
||||
#[must_use]
|
||||
pub fn settings(&self) -> &crate::LoggingSettings {
|
||||
return &self.settings;
|
||||
}
|
||||
|
||||
/// Returns cumulative dropped-line counters across active and previously reloaded outputs.
|
||||
#[must_use]
|
||||
pub fn dropped_lines(&self) -> crate::DroppedLines {
|
||||
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 RuntimeLayers = std::vec::Vec<BoxedRuntimeLayer>;
|
||||
type RuntimeReloadHandle = tracing_subscriber::reload::Handle<RuntimeLayers, tracing_subscriber::Registry>;
|
||||
|
||||
struct PreparedRuntime {
|
||||
layers: RuntimeLayers,
|
||||
outputs: RuntimeOutputs,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct RuntimeOutputs {
|
||||
console: std::option::Option<RuntimeOutput>,
|
||||
files: std::vec::Vec<RuntimeFileOutput>,
|
||||
}
|
||||
|
||||
impl RuntimeOutputs {
|
||||
fn dropped_lines(&self) -> crate::DroppedLines {
|
||||
let console = match self.console.as_ref() {
|
||||
std::option::Option::Some(output) => output.dropped_lines(),
|
||||
std::option::Option::None => 0,
|
||||
};
|
||||
let mut file = 0_usize;
|
||||
for output in &self.files {
|
||||
file = file.saturating_add(output.output.dropped_lines());
|
||||
}
|
||||
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 {
|
||||
_worker_guard: tracing_appender::non_blocking::WorkerGuard,
|
||||
error_counter: tracing_appender::non_blocking::ErrorCounter,
|
||||
}
|
||||
|
||||
impl RuntimeOutput {
|
||||
fn dropped_lines(&self) -> usize {
|
||||
return self.error_counter.dropped_lines();
|
||||
}
|
||||
}
|
||||
|
||||
struct PreparedOutput {
|
||||
layer: BoxedRuntimeLayer,
|
||||
output: RuntimeOutput,
|
||||
}
|
||||
|
||||
struct PreparedFileOutput {
|
||||
output_id: std::string::String,
|
||||
layer: BoxedRuntimeLayer,
|
||||
output: RuntimeOutput,
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// [`crate::reinitialize`] to replace the active KSP logging configuration without installing a second global subscriber.
|
||||
pub fn initialize(settings: &crate::LoggingSettings) -> ksp_core_lib::Result<crate::LoggingGuard> {
|
||||
return prepare_runtime(settings).and_then(|prepared| -> ksp_core_lib::Result<crate::LoggingGuard> {
|
||||
let PreparedRuntime { layers, outputs } = prepared;
|
||||
let (reload_layer, reload_handle) = tracing_subscriber::reload::Layer::new(layers);
|
||||
let subscriber = tracing_subscriber::registry().with(reload_layer);
|
||||
let install_result = tracing::subscriber::set_global_default(subscriber);
|
||||
return match install_result {
|
||||
std::result::Result::Ok(()) => std::result::Result::Ok(crate::LoggingGuard {
|
||||
reload_handle,
|
||||
settings: settings.clone(),
|
||||
outputs,
|
||||
retired_dropped_lines: crate::DroppedLines::zero(),
|
||||
retired_file_dropped_lines: std::collections::HashMap::new(),
|
||||
}),
|
||||
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),
|
||||
),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/// Replaces the active KSP logging settings and non-blocking outputs without reinstalling the global subscriber.
|
||||
///
|
||||
/// New runtime layers, writers and guards are fully prepared before the reload is attempted. If validation or preparation fails, the currently active
|
||||
/// configuration remains unchanged. After a successful layer swap, dropped-line counters from the retired outputs are retained cumulatively. Retired
|
||||
/// layers are then dropped before their worker guards so all retired `NonBlocking` senders are released before shutdown asks the workers to drain/flush.
|
||||
pub fn reinitialize(guard: &mut crate::LoggingGuard, settings: &crate::LoggingSettings) -> ksp_core_lib::Result<()> {
|
||||
return prepare_runtime(settings).and_then(|prepared| -> ksp_core_lib::Result<()> {
|
||||
let PreparedRuntime { layers, outputs } = prepared;
|
||||
let mut retired_layers = RuntimeLayers::new();
|
||||
let reload_result = guard.reload_handle.modify(|active_layers| {
|
||||
retired_layers = std::mem::replace(active_layers, layers);
|
||||
});
|
||||
return match reload_result {
|
||||
std::result::Result::Ok(()) => {
|
||||
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);
|
||||
guard.settings = settings.clone();
|
||||
drop(retired_layers);
|
||||
drop(retired_outputs);
|
||||
std::result::Result::Ok(())
|
||||
},
|
||||
std::result::Result::Err(error) => std::result::Result::Err(
|
||||
ksp_core_lib::Error::new(crate::ERROR_CODE_RELOAD_FAILED, "unable to reload the KSP logging runtime").with_source(error),
|
||||
),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
fn prepare_runtime(settings: &crate::LoggingSettings) -> ksp_core_lib::Result<PreparedRuntime> {
|
||||
let validation_error = settings.validate().err();
|
||||
if let std::option::Option::Some(error) = validation_error {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
let enabled_console = settings.console().filter(|console| -> bool {
|
||||
return console.enabled();
|
||||
});
|
||||
let enabled_files = settings.files().iter().filter(|file| -> bool {
|
||||
return file.enabled();
|
||||
});
|
||||
let mut output_layers = RuntimeLayers::new();
|
||||
let mut outputs = RuntimeOutputs::default();
|
||||
if let std::option::Option::Some(console) = enabled_console {
|
||||
let prepared_console = build_console_output(console, settings);
|
||||
output_layers.push(prepared_console.layer);
|
||||
outputs.console = std::option::Option::Some(prepared_console.output);
|
||||
}
|
||||
for file in enabled_files {
|
||||
let prepared_file_result = build_file_output(file, settings);
|
||||
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();
|
||||
return std::result::Result::Ok(PreparedRuntime { layers: vec![takeover_layer], outputs });
|
||||
}
|
||||
|
||||
fn build_console_output(console: &crate::ConsoleSettings, settings: &crate::LoggingSettings) -> PreparedOutput {
|
||||
return match console.output() {
|
||||
crate::ConsoleOutput::Stdout => build_non_blocking_output(
|
||||
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<PreparedFileOutput> {
|
||||
let appender_result = tracing_appender::rolling::RollingFileAppender::builder()
|
||||
.rotation(map_file_rotation(file.rotation()))
|
||||
.filename_prefix(file.file_name_prefix())
|
||||
.build(file.directory());
|
||||
let appender = match appender_result {
|
||||
std::result::Result::Ok(appender) => appender,
|
||||
std::result::Result::Err(error) => {
|
||||
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")
|
||||
.with_context("output_id", file.output_id())
|
||||
.with_context("directory", file.directory().display().to_string())
|
||||
.with_context("file_name_prefix", file.file_name_prefix())
|
||||
.with_source(error),
|
||||
);
|
||||
},
|
||||
};
|
||||
let stripped_writer = crate::writer::StripAnsiWriter::new(appender);
|
||||
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,
|
||||
span_events: crate::SpanEvents,
|
||||
ansi_sanitization: bool,
|
||||
ansi: bool,
|
||||
format: crate::LogFormat,
|
||||
filter: &crate::OutputFilter,
|
||||
) -> PreparedOutput
|
||||
where
|
||||
W: std::io::Write + std::marker::Send + 'static,
|
||||
{
|
||||
let (non_blocking, worker_guard) = non_blocking_builder(thread_name).finish(writer);
|
||||
let error_counter = non_blocking.error_counter();
|
||||
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 } };
|
||||
}
|
||||
|
||||
fn non_blocking_builder(thread_name: &str) -> tracing_appender::non_blocking::NonBlockingBuilder {
|
||||
return tracing_appender::non_blocking::NonBlockingBuilder::default().lossy(true).thread_name(thread_name);
|
||||
}
|
||||
|
||||
fn build_format_layer(
|
||||
writer: tracing_appender::non_blocking::NonBlocking,
|
||||
span_events: crate::SpanEvents,
|
||||
ansi_sanitization: bool,
|
||||
ansi: bool,
|
||||
format: crate::LogFormat,
|
||||
filter: &crate::OutputFilter,
|
||||
) -> BoxedRuntimeLayer {
|
||||
let span_events = map_span_events(span_events);
|
||||
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 {
|
||||
let mut filter = tracing_subscriber::filter::Targets::new()
|
||||
.with_default(tracing_subscriber::filter::LevelFilter::OFF)
|
||||
.with_target("ksp-", map_filter_level(settings.default_filter()));
|
||||
for target_filter in settings.target_filters() {
|
||||
filter = filter.with_target(target_filter.target_prefix(), map_filter_level(target_filter.level()));
|
||||
}
|
||||
return filter;
|
||||
}
|
||||
|
||||
const fn map_filter_level(level: crate::LogFilterLevel) -> tracing_subscriber::filter::LevelFilter {
|
||||
return match level {
|
||||
crate::LogFilterLevel::Off => tracing_subscriber::filter::LevelFilter::OFF,
|
||||
crate::LogFilterLevel::Error => tracing_subscriber::filter::LevelFilter::ERROR,
|
||||
crate::LogFilterLevel::Warn => tracing_subscriber::filter::LevelFilter::WARN,
|
||||
crate::LogFilterLevel::Info => tracing_subscriber::filter::LevelFilter::INFO,
|
||||
crate::LogFilterLevel::Debug => tracing_subscriber::filter::LevelFilter::DEBUG,
|
||||
crate::LogFilterLevel::Trace => tracing_subscriber::filter::LevelFilter::TRACE,
|
||||
};
|
||||
}
|
||||
|
||||
const fn map_file_rotation(rotation: crate::FileRotation) -> tracing_appender::rolling::Rotation {
|
||||
return match rotation {
|
||||
crate::FileRotation::Never => tracing_appender::rolling::Rotation::NEVER,
|
||||
crate::FileRotation::Hourly => tracing_appender::rolling::Rotation::HOURLY,
|
||||
crate::FileRotation::Daily => tracing_appender::rolling::Rotation::DAILY,
|
||||
};
|
||||
}
|
||||
|
||||
fn map_span_events(span_events: crate::SpanEvents) -> tracing_subscriber::fmt::format::FmtSpan {
|
||||
return match span_events {
|
||||
crate::SpanEvents::Off => tracing_subscriber::fmt::format::FmtSpan::NONE,
|
||||
crate::SpanEvents::NewAndClose => tracing_subscriber::fmt::format::FmtSpan::NEW | tracing_subscriber::fmt::format::FmtSpan::CLOSE,
|
||||
crate::SpanEvents::Full => tracing_subscriber::fmt::format::FmtSpan::FULL,
|
||||
};
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "../unit_tests/runtime.rs"]
|
||||
mod tests;
|
||||
509
crates/ksp-logging-lib/src/settings.rs
Normal file
509
crates/ksp-logging-lib/src/settings.rs
Normal file
@@ -0,0 +1,509 @@
|
||||
// file: crates/ksp-logging-lib/src/settings.rs
|
||||
// version: 4
|
||||
|
||||
/// Runtime filter level used by KSP logging settings.
|
||||
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
||||
pub enum LogFilterLevel {
|
||||
/// Disables matching logging events and spans.
|
||||
Off,
|
||||
/// Enables only error-level events and spans.
|
||||
Error,
|
||||
/// Enables warning and error events and spans.
|
||||
Warn,
|
||||
/// Enables informational, warning and error events and spans.
|
||||
Info,
|
||||
/// Enables debug and less verbose events and spans.
|
||||
Debug,
|
||||
/// Enables all KSP logging events and spans.
|
||||
Trace,
|
||||
}
|
||||
|
||||
/// Per-target filter override owned by Logging for the global KSP takeover policy.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct TargetFilter {
|
||||
target_prefix: std::string::String,
|
||||
level: crate::LogFilterLevel,
|
||||
}
|
||||
|
||||
impl TargetFilter {
|
||||
/// Creates a filter override for a KSP target prefix.
|
||||
#[must_use]
|
||||
pub fn new(target_prefix: impl std::convert::Into<std::string::String>, level: crate::LogFilterLevel) -> Self {
|
||||
return Self { target_prefix: target_prefix.into(), level };
|
||||
}
|
||||
|
||||
/// Returns the configured target prefix.
|
||||
#[must_use]
|
||||
pub fn target_prefix(&self) -> &str {
|
||||
return self.target_prefix.as_str();
|
||||
}
|
||||
|
||||
/// Returns the configured filter level.
|
||||
#[must_use]
|
||||
pub const fn level(&self) -> crate::LogFilterLevel {
|
||||
return self.level;
|
||||
}
|
||||
}
|
||||
|
||||
/// Lifecycle events emitted for spans by the formatted subscriber.
|
||||
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
||||
pub enum SpanEvents {
|
||||
/// Does not synthesize span lifecycle events.
|
||||
Off,
|
||||
/// Emits span creation and closure events for timing-oriented diagnostics.
|
||||
NewAndClose,
|
||||
/// Emits all supported span lifecycle events.
|
||||
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.
|
||||
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
||||
pub enum ConsoleOutput {
|
||||
/// Writes console logs to standard output.
|
||||
Stdout,
|
||||
/// Writes console logs to standard error.
|
||||
Stderr,
|
||||
}
|
||||
|
||||
/// Runtime settings for the optional console output.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct ConsoleSettings {
|
||||
enabled: bool,
|
||||
output: crate::ConsoleOutput,
|
||||
ansi: bool,
|
||||
format: crate::LogFormat,
|
||||
filter: crate::OutputFilter,
|
||||
}
|
||||
|
||||
impl ConsoleSettings {
|
||||
/// Creates explicit console settings.
|
||||
#[must_use]
|
||||
pub fn new(enabled: bool, output: crate::ConsoleOutput, ansi: bool, format: crate::LogFormat, filter: crate::OutputFilter) -> Self {
|
||||
return Self { enabled, output, ansi, format, filter };
|
||||
}
|
||||
|
||||
/// Creates enabled standard-output settings compatible with the `0.1.2` runtime behavior.
|
||||
#[must_use]
|
||||
pub fn stdout() -> Self {
|
||||
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.
|
||||
#[must_use]
|
||||
pub const fn output(&self) -> crate::ConsoleOutput {
|
||||
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 a file output.
|
||||
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
||||
pub enum FileRotation {
|
||||
/// Keeps a single non-rotating file.
|
||||
Never,
|
||||
/// Rotates the file every hour.
|
||||
Hourly,
|
||||
/// Rotates the file every day.
|
||||
Daily,
|
||||
}
|
||||
|
||||
/// Runtime settings for one file output.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct FileSettings {
|
||||
output_id: std::string::String,
|
||||
enabled: bool,
|
||||
directory: std::path::PathBuf,
|
||||
file_name_prefix: std::string::String,
|
||||
rotation: crate::FileRotation,
|
||||
format: crate::LogFormat,
|
||||
ansi: bool,
|
||||
filter: crate::OutputFilter,
|
||||
}
|
||||
|
||||
impl FileSettings {
|
||||
/// Creates explicit file output settings.
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
output_id: impl std::convert::Into<std::string::String>,
|
||||
enabled: bool,
|
||||
directory: impl std::convert::Into<std::path::PathBuf>,
|
||||
file_name_prefix: impl std::convert::Into<std::string::String>,
|
||||
rotation: crate::FileRotation,
|
||||
format: crate::LogFormat,
|
||||
filter: crate::OutputFilter,
|
||||
) -> Self {
|
||||
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.
|
||||
#[must_use]
|
||||
pub fn directory(&self) -> &std::path::Path {
|
||||
return self.directory.as_path();
|
||||
}
|
||||
|
||||
/// Returns the file-name prefix passed to the file appender.
|
||||
#[must_use]
|
||||
pub fn file_name_prefix(&self) -> &str {
|
||||
return self.file_name_prefix.as_str();
|
||||
}
|
||||
|
||||
/// Returns the selected file rotation cadence.
|
||||
#[must_use]
|
||||
pub const fn rotation(&self) -> crate::FileRotation {
|
||||
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.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct LoggingSettings {
|
||||
default_filter: crate::LogFilterLevel,
|
||||
target_filters: std::vec::Vec<crate::TargetFilter>,
|
||||
span_events: crate::SpanEvents,
|
||||
console: std::option::Option<crate::ConsoleSettings>,
|
||||
files: std::vec::Vec<crate::FileSettings>,
|
||||
}
|
||||
|
||||
impl LoggingSettings {
|
||||
/// Creates explicit Logging settings without any global target override.
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
default_filter: crate::LogFilterLevel,
|
||||
span_events: crate::SpanEvents,
|
||||
console: std::option::Option<crate::ConsoleSettings>,
|
||||
files: std::vec::Vec<crate::FileSettings>,
|
||||
) -> Self {
|
||||
return Self { default_filter, target_filters: std::vec::Vec::new(), span_events, console, files };
|
||||
}
|
||||
|
||||
/// Adds one target-prefix override to the global KSP takeover policy and returns the updated settings.
|
||||
#[must_use]
|
||||
pub fn with_target_filter(mut self, target_filter: crate::TargetFilter) -> Self {
|
||||
self.target_filters.push(target_filter);
|
||||
return self;
|
||||
}
|
||||
|
||||
/// Returns the default level applied to KSP-owned targets by the global takeover policy.
|
||||
#[must_use]
|
||||
pub const fn default_filter(&self) -> crate::LogFilterLevel {
|
||||
return self.default_filter;
|
||||
}
|
||||
|
||||
/// Returns target-prefix overrides in insertion order.
|
||||
#[must_use]
|
||||
pub fn target_filters(&self) -> &[crate::TargetFilter] {
|
||||
return self.target_filters.as_slice();
|
||||
}
|
||||
|
||||
/// Returns the selected span lifecycle event policy.
|
||||
#[must_use]
|
||||
pub const fn span_events(&self) -> crate::SpanEvents {
|
||||
return self.span_events;
|
||||
}
|
||||
|
||||
/// Returns console settings when the console output is declared.
|
||||
#[must_use]
|
||||
pub fn console(&self) -> std::option::Option<&crate::ConsoleSettings> {
|
||||
return self.console.as_ref();
|
||||
}
|
||||
|
||||
/// Returns all declared file outputs in declaration order.
|
||||
#[must_use]
|
||||
pub fn files(&self) -> &[crate::FileSettings] {
|
||||
return self.files.as_slice();
|
||||
}
|
||||
|
||||
/// Validates backend-independent invariants of the runtime settings.
|
||||
pub fn validate(&self) -> ksp_core_lib::Result<()> {
|
||||
for target_filter in &self.target_filters {
|
||||
if target_filter.target_prefix().is_empty() {
|
||||
return std::result::Result::Err(
|
||||
ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_SETTINGS, "target filter prefix must not be empty")
|
||||
.with_context("field", "target_filters.target_prefix"),
|
||||
);
|
||||
}
|
||||
if !target_filter.target_prefix().starts_with("ksp-") {
|
||||
return std::result::Result::Err(
|
||||
ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_SETTINGS, "target filter prefix must identify a KSP-owned target")
|
||||
.with_context("field", "target_filters.target_prefix")
|
||||
.with_context("target_prefix", target_filter.target_prefix()),
|
||||
);
|
||||
}
|
||||
}
|
||||
if let std::option::Option::Some(console) = self.console.as_ref() {
|
||||
if console.ansi() && console.format() == crate::LogFormat::Json {
|
||||
return std::result::Result::Err(
|
||||
ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_SETTINGS, "ANSI formatting is not compatible with JSON console output")
|
||||
.with_context("field", "console.ansi"),
|
||||
);
|
||||
}
|
||||
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(());
|
||||
}
|
||||
}
|
||||
|
||||
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)]
|
||||
#[path = "../unit_tests/settings.rs"]
|
||||
mod tests;
|
||||
41
crates/ksp-logging-lib/src/span.rs
Normal file
41
crates/ksp-logging-lib/src/span.rs
Normal file
@@ -0,0 +1,41 @@
|
||||
// file: crates/ksp-logging-lib/src/span.rs
|
||||
// version: 2
|
||||
|
||||
/// KSP-owned handle to a tracing span.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Span {
|
||||
inner: tracing::Span,
|
||||
}
|
||||
|
||||
impl Span {
|
||||
/// Runs synchronous work while this span is entered.
|
||||
pub fn in_scope<T>(&self, operation: impl std::ops::FnOnce() -> T) -> T {
|
||||
return self.inner.in_scope(operation);
|
||||
}
|
||||
|
||||
#[doc(hidden)]
|
||||
/// Constructs the KSP span wrapper for macro expansion support.
|
||||
#[must_use]
|
||||
pub fn __from_tracing(inner: tracing::Span) -> Self {
|
||||
return Self { inner };
|
||||
}
|
||||
|
||||
/// Consumes this wrapper and returns the internal tracing span.
|
||||
pub(crate) fn into_tracing(self) -> tracing::Span {
|
||||
return self.inner;
|
||||
}
|
||||
}
|
||||
|
||||
/// Instruments an asynchronous future with a KSP span.
|
||||
///
|
||||
/// The span is entered whenever the future is polled or dropped and exited when that operation returns, so no enter guard is held across an `.await` point.
|
||||
pub fn instrument<F>(span: crate::Span, future: F) -> impl std::future::Future<Output = F::Output>
|
||||
where
|
||||
F: std::future::Future,
|
||||
{
|
||||
return tracing::Instrument::instrument(future, span.into_tracing());
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "../unit_tests/span.rs"]
|
||||
mod tests;
|
||||
198
crates/ksp-logging-lib/src/writer.rs
Normal file
198
crates/ksp-logging-lib/src/writer.rs
Normal file
@@ -0,0 +1,198 @@
|
||||
// file: crates/ksp-logging-lib/src/writer.rs
|
||||
// version: 3
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
enum StripAnsiState {
|
||||
Text,
|
||||
Escape,
|
||||
Csi,
|
||||
Osc,
|
||||
OscEscape,
|
||||
String,
|
||||
StringEscape,
|
||||
}
|
||||
|
||||
pub(crate) struct StripAnsiWriter<W> {
|
||||
inner: W,
|
||||
state: StripAnsiState,
|
||||
}
|
||||
|
||||
impl<W> StripAnsiWriter<W> {
|
||||
pub(crate) const fn new(inner: W) -> Self {
|
||||
return Self { inner, state: StripAnsiState::Text };
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn into_inner(self) -> W {
|
||||
return self.inner;
|
||||
}
|
||||
}
|
||||
|
||||
impl<W> std::io::Write for StripAnsiWriter<W>
|
||||
where
|
||||
W: std::io::Write,
|
||||
{
|
||||
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
|
||||
let mut stripped = std::vec::Vec::with_capacity(buf.len());
|
||||
for byte in buf {
|
||||
self.consume_byte(*byte, &mut stripped);
|
||||
}
|
||||
let write_result = std::io::Write::write_all(&mut self.inner, stripped.as_slice());
|
||||
return match write_result {
|
||||
std::result::Result::Ok(()) => std::result::Result::Ok(buf.len()),
|
||||
std::result::Result::Err(error) => std::result::Result::Err(error),
|
||||
};
|
||||
}
|
||||
|
||||
fn flush(&mut self) -> std::io::Result<()> {
|
||||
return std::io::Write::flush(&mut self.inner);
|
||||
}
|
||||
}
|
||||
|
||||
impl<W> StripAnsiWriter<W> {
|
||||
fn consume_byte(&mut self, byte: u8, output: &mut std::vec::Vec<u8>) {
|
||||
self.state = match self.state {
|
||||
StripAnsiState::Text => {
|
||||
if byte == 0x1B {
|
||||
StripAnsiState::Escape
|
||||
} else {
|
||||
output.push(byte);
|
||||
StripAnsiState::Text
|
||||
}
|
||||
},
|
||||
StripAnsiState::Escape => match byte {
|
||||
b'[' => StripAnsiState::Csi,
|
||||
b']' => StripAnsiState::Osc,
|
||||
b'P' | b'X' | b'^' | b'_' => StripAnsiState::String,
|
||||
0x1B => StripAnsiState::Escape,
|
||||
_ => StripAnsiState::Text,
|
||||
},
|
||||
StripAnsiState::Csi => {
|
||||
if (0x40..=0x7E).contains(&byte) {
|
||||
StripAnsiState::Text
|
||||
} else {
|
||||
StripAnsiState::Csi
|
||||
}
|
||||
},
|
||||
StripAnsiState::Osc => match byte {
|
||||
0x07 => StripAnsiState::Text,
|
||||
0x1B => StripAnsiState::OscEscape,
|
||||
_ => StripAnsiState::Osc,
|
||||
},
|
||||
StripAnsiState::OscEscape => match byte {
|
||||
b'\\' => StripAnsiState::Text,
|
||||
0x1B => StripAnsiState::OscEscape,
|
||||
_ => StripAnsiState::Osc,
|
||||
},
|
||||
StripAnsiState::String => {
|
||||
if byte == 0x1B {
|
||||
StripAnsiState::StringEscape
|
||||
} else {
|
||||
StripAnsiState::String
|
||||
}
|
||||
},
|
||||
StripAnsiState::StringEscape => match byte {
|
||||
b'\\' => StripAnsiState::Text,
|
||||
0x1B => StripAnsiState::StringEscape,
|
||||
_ => StripAnsiState::String,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
#[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)]
|
||||
#[path = "../unit_tests/writer.rs"]
|
||||
mod tests;
|
||||
163
crates/ksp-logging-lib/tests/callsite.rs
Normal file
163
crates/ksp-logging-lib/tests/callsite.rs
Normal file
@@ -0,0 +1,163 @@
|
||||
// file: crates/ksp-logging-lib/tests/callsite.rs
|
||||
// version: 2
|
||||
|
||||
//! Integration tests for KSP logging callsite and async span instrumentation behavior.
|
||||
|
||||
const TEST_TARGET: &str = "ksp-logging-lib";
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
struct CapturedMetadata {
|
||||
target: std::string::String,
|
||||
file: std::option::Option<std::string::String>,
|
||||
module_path: std::option::Option<std::string::String>,
|
||||
line: std::option::Option<u32>,
|
||||
is_event: bool,
|
||||
is_span: bool,
|
||||
}
|
||||
|
||||
impl CapturedMetadata {
|
||||
fn from_metadata(metadata: &tracing::Metadata<'_>) -> Self {
|
||||
return Self {
|
||||
target: metadata.target().to_owned(),
|
||||
file: metadata.file().map(str::to_owned),
|
||||
module_path: metadata.module_path().map(str::to_owned),
|
||||
line: metadata.line(),
|
||||
is_event: metadata.is_event(),
|
||||
is_span: metadata.is_span(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct CaptureSubscriber {
|
||||
captured: std::sync::Arc<std::sync::Mutex<std::vec::Vec<CapturedMetadata>>>,
|
||||
enters: std::sync::Arc<std::sync::atomic::AtomicU64>,
|
||||
exits: std::sync::Arc<std::sync::atomic::AtomicU64>,
|
||||
next_id: std::sync::Arc<std::sync::atomic::AtomicU64>,
|
||||
}
|
||||
|
||||
impl CaptureSubscriber {
|
||||
fn new(
|
||||
captured: std::sync::Arc<std::sync::Mutex<std::vec::Vec<CapturedMetadata>>>,
|
||||
enters: std::sync::Arc<std::sync::atomic::AtomicU64>,
|
||||
exits: std::sync::Arc<std::sync::atomic::AtomicU64>,
|
||||
) -> Self {
|
||||
return Self { captured, enters, exits, next_id: std::sync::Arc::new(std::sync::atomic::AtomicU64::new(1)) };
|
||||
}
|
||||
|
||||
fn capture(&self, metadata: &tracing::Metadata<'_>) {
|
||||
let lock = self.captured.lock();
|
||||
if let std::result::Result::Ok(mut values) = lock {
|
||||
values.push(CapturedMetadata::from_metadata(metadata));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl tracing::Subscriber for CaptureSubscriber {
|
||||
fn enabled(&self, _metadata: &tracing::Metadata<'_>) -> bool {
|
||||
return true;
|
||||
}
|
||||
|
||||
fn new_span(&self, span: &tracing::span::Attributes<'_>) -> tracing::span::Id {
|
||||
self.capture(span.metadata());
|
||||
let id = self.next_id.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
return tracing::span::Id::from_u64(id);
|
||||
}
|
||||
|
||||
fn record(&self, _span: &tracing::span::Id, _values: &tracing::span::Record<'_>) {
|
||||
return;
|
||||
}
|
||||
|
||||
fn record_follows_from(&self, _span: &tracing::span::Id, _follows: &tracing::span::Id) {
|
||||
return;
|
||||
}
|
||||
|
||||
fn event(&self, event: &tracing::Event<'_>) {
|
||||
self.capture(event.metadata());
|
||||
return;
|
||||
}
|
||||
|
||||
fn enter(&self, _span: &tracing::span::Id) {
|
||||
self.enters.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
return;
|
||||
}
|
||||
|
||||
fn exit(&self, _span: &tracing::span::Id) {
|
||||
self.exits.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
fn captured_values(captured: &std::sync::Arc<std::sync::Mutex<std::vec::Vec<CapturedMetadata>>>) -> std::vec::Vec<CapturedMetadata> {
|
||||
let lock = captured.lock();
|
||||
return match lock {
|
||||
std::result::Result::Ok(values) => values.clone(),
|
||||
std::result::Result::Err(error) => error.into_inner().clone(),
|
||||
};
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn event_macro_preserves_consumer_callsite() {
|
||||
let captured = std::sync::Arc::new(std::sync::Mutex::new(std::vec::Vec::new()));
|
||||
let enters = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0));
|
||||
let exits = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0));
|
||||
let subscriber = CaptureSubscriber::new(captured.clone(), enters, exits);
|
||||
let expected_line = line!() + 2;
|
||||
tracing::subscriber::with_default(subscriber, || {
|
||||
ksp_logging_lib::info!(target: TEST_TARGET, domain = "logging", "callsite event");
|
||||
return;
|
||||
});
|
||||
let values = captured_values(&captured);
|
||||
assert_eq!(values.len(), 1);
|
||||
assert_eq!(values[0].target, TEST_TARGET);
|
||||
assert_eq!(values[0].file.as_deref(), std::option::Option::Some(file!()));
|
||||
assert_eq!(values[0].module_path.as_deref(), std::option::Option::Some(module_path!()));
|
||||
assert_eq!(values[0].line, std::option::Option::Some(expected_line));
|
||||
assert!(values[0].is_event);
|
||||
assert!(!values[0].is_span);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn span_macro_preserves_consumer_callsite() {
|
||||
let captured = std::sync::Arc::new(std::sync::Mutex::new(std::vec::Vec::new()));
|
||||
let enters = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0));
|
||||
let exits = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0));
|
||||
let subscriber = CaptureSubscriber::new(captured.clone(), enters, exits);
|
||||
let expected_line = line!() + 2;
|
||||
tracing::subscriber::with_default(subscriber, || {
|
||||
let _span = ksp_logging_lib::trace_span!(target: TEST_TARGET, "callsite_span", component = "test");
|
||||
return;
|
||||
});
|
||||
let values = captured_values(&captured);
|
||||
assert_eq!(values.len(), 1);
|
||||
assert_eq!(values[0].target, TEST_TARGET);
|
||||
assert_eq!(values[0].file.as_deref(), std::option::Option::Some(file!()));
|
||||
assert_eq!(values[0].module_path.as_deref(), std::option::Option::Some(module_path!()));
|
||||
assert_eq!(values[0].line, std::option::Option::Some(expected_line));
|
||||
assert!(!values[0].is_event);
|
||||
assert!(values[0].is_span);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn async_instrumentation_enters_and_exits_span_during_poll_and_drop() {
|
||||
let captured = std::sync::Arc::new(std::sync::Mutex::new(std::vec::Vec::new()));
|
||||
let enters = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0));
|
||||
let exits = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0));
|
||||
let subscriber = CaptureSubscriber::new(captured, std::sync::Arc::clone(&enters), std::sync::Arc::clone(&exits));
|
||||
tracing::subscriber::with_default(subscriber, || {
|
||||
let span = ksp_logging_lib::trace_span!(target: TEST_TARGET, "async_poll_span", domain = "logging");
|
||||
let future = ksp_logging_lib::instrument(span, std::future::ready(42_u32));
|
||||
let mut future = std::boxed::Box::pin(future);
|
||||
let waker = std::task::Waker::noop();
|
||||
let mut context = std::task::Context::from_waker(waker);
|
||||
let poll = std::future::Future::poll(future.as_mut(), &mut context);
|
||||
assert_eq!(poll, std::task::Poll::Ready(42_u32));
|
||||
assert_eq!(enters.load(std::sync::atomic::Ordering::Relaxed), 1);
|
||||
assert_eq!(exits.load(std::sync::atomic::Ordering::Relaxed), 1);
|
||||
std::mem::drop(future);
|
||||
assert_eq!(enters.load(std::sync::atomic::Ordering::Relaxed), 2);
|
||||
assert_eq!(exits.load(std::sync::atomic::Ordering::Relaxed), 2);
|
||||
return;
|
||||
});
|
||||
assert_eq!(enters.load(std::sync::atomic::Ordering::Relaxed), exits.load(std::sync::atomic::Ordering::Relaxed));
|
||||
}
|
||||
47
crates/ksp-logging-lib/tests/overhead.rs
Normal file
47
crates/ksp-logging-lib/tests/overhead.rs
Normal file
@@ -0,0 +1,47 @@
|
||||
// file: crates/ksp-logging-lib/tests/overhead.rs
|
||||
// version: 1
|
||||
|
||||
//! Diagnostic gross-overhead probe for the reload layer used by KSP Logging.
|
||||
|
||||
use tracing_subscriber::layer::SubscriberExt; // rust-rules: trait-import
|
||||
|
||||
const TEST_TARGET: &str = "ksp-logging-lib";
|
||||
const ITERATIONS: u64 = 200_000;
|
||||
|
||||
fn emit_probe_events() {
|
||||
for sequence in 0..ITERATIONS {
|
||||
tracing::trace!(target: TEST_TARGET, sequence, "reload overhead probe");
|
||||
}
|
||||
}
|
||||
|
||||
fn trace_filter() -> tracing_subscriber::filter::Targets {
|
||||
return tracing_subscriber::filter::Targets::new()
|
||||
.with_default(tracing_subscriber::filter::LevelFilter::OFF)
|
||||
.with_target(TEST_TARGET, tracing_subscriber::filter::LevelFilter::TRACE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "diagnostic timing probe; run explicitly with --ignored --nocapture"]
|
||||
fn reload_layer_overhead_remains_within_a_gross_regression_guardrail() {
|
||||
let baseline_subscriber = tracing_subscriber::registry().with(trace_filter());
|
||||
let baseline_start = std::time::Instant::now();
|
||||
tracing::subscriber::with_default(baseline_subscriber, || {
|
||||
emit_probe_events();
|
||||
return;
|
||||
});
|
||||
let baseline_elapsed = baseline_start.elapsed();
|
||||
let (reload_layer, _reload_handle) = tracing_subscriber::reload::Layer::new(trace_filter());
|
||||
let reload_subscriber = tracing_subscriber::registry().with(reload_layer);
|
||||
let reload_start = std::time::Instant::now();
|
||||
tracing::subscriber::with_default(reload_subscriber, || {
|
||||
emit_probe_events();
|
||||
return;
|
||||
});
|
||||
let reload_elapsed = reload_start.elapsed();
|
||||
let gross_ceiling = baseline_elapsed.saturating_mul(100).saturating_add(std::time::Duration::from_millis(100));
|
||||
println!("KSP reload overhead probe: baseline={baseline_elapsed:?}, reload={reload_elapsed:?}, iterations={ITERATIONS}");
|
||||
assert!(
|
||||
reload_elapsed <= gross_ceiling,
|
||||
"reload layer exceeded the gross regression guardrail: baseline={baseline_elapsed:?}, reload={reload_elapsed:?}"
|
||||
);
|
||||
}
|
||||
81
crates/ksp-logging-lib/tests/ownership.rs
Normal file
81
crates/ksp-logging-lib/tests/ownership.rs
Normal file
@@ -0,0 +1,81 @@
|
||||
// file: crates/ksp-logging-lib/tests/ownership.rs
|
||||
// version: 1
|
||||
|
||||
//! Integration audit ensuring KSP crates do not bypass the logging facade.
|
||||
|
||||
fn collect_rust_files(directory: &std::path::Path, files: &mut std::vec::Vec<std::path::PathBuf>) {
|
||||
let entries_result = std::fs::read_dir(directory);
|
||||
let entries = match entries_result {
|
||||
std::result::Result::Ok(entries) => entries,
|
||||
std::result::Result::Err(_) => return,
|
||||
};
|
||||
for entry_result in entries {
|
||||
let entry = match entry_result {
|
||||
std::result::Result::Ok(entry) => entry,
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_crates_do_not_bypass_ksp_logging_facade() {
|
||||
let logging_manifest_directory = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"));
|
||||
let workspace_root = match logging_manifest_directory.parent().and_then(std::path::Path::parent) {
|
||||
std::option::Option::Some(root) => root,
|
||||
std::option::Option::None => return,
|
||||
};
|
||||
let crates_directory = workspace_root.join("crates");
|
||||
let entries_result = std::fs::read_dir(crates_directory.as_path());
|
||||
assert!(entries_result.is_ok(), "unable to inspect workspace crates at {}", crates_directory.display());
|
||||
let entries = match entries_result {
|
||||
std::result::Result::Ok(entries) => entries,
|
||||
std::result::Result::Err(_) => return,
|
||||
};
|
||||
for entry_result in entries {
|
||||
let entry = match entry_result {
|
||||
std::result::Result::Ok(entry) => entry,
|
||||
std::result::Result::Err(_) => continue,
|
||||
};
|
||||
let crate_path = entry.path();
|
||||
if !crate_path.is_dir() || entry.file_name() == std::ffi::OsStr::new("ksp-logging-lib") {
|
||||
continue;
|
||||
}
|
||||
let manifest_path = crate_path.join("Cargo.toml");
|
||||
if manifest_path.exists() {
|
||||
let manifest_result = std::fs::read_to_string(manifest_path.as_path());
|
||||
assert!(manifest_result.is_ok(), "unable to read {}", manifest_path.display());
|
||||
let manifest = match manifest_result {
|
||||
std::result::Result::Ok(manifest) => manifest,
|
||||
std::result::Result::Err(_) => continue,
|
||||
};
|
||||
assert!(
|
||||
!manifest.contains("tracing.workspace") && !manifest.contains("\ntracing =") && !manifest.contains("[dependencies.tracing]"),
|
||||
"{} depends directly on tracing",
|
||||
manifest_path.display(),
|
||||
);
|
||||
assert!(!manifest.contains("tracing-subscriber"), "{} depends directly on tracing-subscriber", manifest_path.display());
|
||||
assert!(!manifest.contains("tracing-appender"), "{} depends directly on tracing-appender", manifest_path.display());
|
||||
}
|
||||
let mut rust_files = std::vec::Vec::new();
|
||||
collect_rust_files(crate_path.as_path(), &mut rust_files);
|
||||
for rust_file in rust_files {
|
||||
let source_result = std::fs::read_to_string(rust_file.as_path());
|
||||
assert!(source_result.is_ok(), "unable to read {}", rust_file.display());
|
||||
let source = match source_result {
|
||||
std::result::Result::Ok(source) => source,
|
||||
std::result::Result::Err(_) => continue,
|
||||
};
|
||||
assert!(!source.contains("tracing::"), "{} bypasses ksp-logging-lib via tracing", rust_file.display());
|
||||
assert!(!source.contains("tracing_subscriber::"), "{} bypasses ksp-logging-lib via tracing-subscriber", rust_file.display());
|
||||
assert!(!source.contains("tracing_appender::"), "{} bypasses ksp-logging-lib via tracing-appender", rust_file.display());
|
||||
}
|
||||
}
|
||||
}
|
||||
93
crates/ksp-logging-lib/tests/public_api.rs
Normal file
93
crates/ksp-logging-lib/tests/public_api.rs
Normal file
@@ -0,0 +1,93 @@
|
||||
// file: crates/ksp-logging-lib/tests/public_api.rs
|
||||
// version: 6
|
||||
|
||||
//! Integration tests for the public crate-root surface of `ksp-logging-lib`.
|
||||
|
||||
const TEST_TARGET: &str = "ksp-logging-lib";
|
||||
|
||||
#[test]
|
||||
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(
|
||||
ksp_logging_lib::LogFilterLevel::Info,
|
||||
ksp_logging_lib::SpanEvents::NewAndClose,
|
||||
std::option::Option::Some(ksp_logging_lib::ConsoleSettings::new(
|
||||
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));
|
||||
assert!(settings.validate().is_ok());
|
||||
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::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]
|
||||
fn public_event_macros_are_usable() {
|
||||
ksp_logging_lib::error!(target: TEST_TARGET, operation = "public_api", "error event");
|
||||
ksp_logging_lib::warn!(target: TEST_TARGET, operation = "public_api", "warn event");
|
||||
ksp_logging_lib::info!(target: TEST_TARGET, operation = "public_api", "info event");
|
||||
ksp_logging_lib::debug!(target: TEST_TARGET, operation = "public_api", "debug event");
|
||||
ksp_logging_lib::trace!(target: TEST_TARGET, operation = "public_api", "trace event");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn public_span_surface_is_usable_for_sync_and_async() {
|
||||
let span = ksp_logging_lib::trace_span!(target: TEST_TARGET, "public_sync", domain = "logging");
|
||||
let value = span.in_scope(|| -> u32 {
|
||||
return 7;
|
||||
});
|
||||
assert_eq!(value, 7);
|
||||
let async_span = ksp_logging_lib::debug_span!(target: TEST_TARGET, "public_async", component = "test");
|
||||
let future = ksp_logging_lib::instrument(async_span, std::future::ready(9_u32));
|
||||
let mut future = std::boxed::Box::pin(future);
|
||||
let waker = std::task::Waker::noop();
|
||||
let mut context = std::task::Context::from_waker(waker);
|
||||
let poll = std::future::Future::poll(future.as_mut(), &mut context);
|
||||
assert_eq!(poll, std::task::Poll::Ready(9_u32));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_span_levels_are_usable() {
|
||||
let _error = ksp_logging_lib::error_span!(target: TEST_TARGET, "error_span");
|
||||
let _warn = ksp_logging_lib::warn_span!(target: TEST_TARGET, "warn_span");
|
||||
let _info = ksp_logging_lib::info_span!(target: TEST_TARGET, "info_span");
|
||||
let _debug = ksp_logging_lib::debug_span!(target: TEST_TARGET, "debug_span");
|
||||
let _trace = ksp_logging_lib::trace_span!(target: TEST_TARGET, "trace_span");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn public_runtime_surface_is_addressable_without_installing_it() {
|
||||
let _initialize = ksp_logging_lib::initialize;
|
||||
let _reinitialize = ksp_logging_lib::reinitialize;
|
||||
let _already_initialized = ksp_logging_lib::ERROR_CODE_ALREADY_INITIALIZED;
|
||||
let _reload_failed = ksp_logging_lib::ERROR_CODE_RELOAD_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();
|
||||
assert_eq!(dropped.console(), 0);
|
||||
assert_eq!(dropped.file(), 0);
|
||||
assert_eq!(dropped.total(), 0);
|
||||
}
|
||||
383
crates/ksp-logging-lib/tests/runtime.rs
Normal file
383
crates/ksp-logging-lib/tests/runtime.rs
Normal file
@@ -0,0 +1,383 @@
|
||||
// file: crates/ksp-logging-lib/tests/runtime.rs
|
||||
// version: 8
|
||||
|
||||
//! Integration tests for global initialization, takeover filtering, non-blocking outputs and hot reload.
|
||||
|
||||
const LOGGING_TARGET: &str = "ksp-logging-lib";
|
||||
const OTHER_KSP_TARGET: &str = "ksp-store-lib";
|
||||
const JSON_KSP_TARGET: &str = "ksp-logging-json-test";
|
||||
const EXTERNAL_TARGET: &str = "sqlx";
|
||||
|
||||
fn logging_trace_enabled() -> bool {
|
||||
return tracing::enabled!(target: LOGGING_TARGET, tracing::Level::TRACE);
|
||||
}
|
||||
|
||||
fn other_ksp_info_enabled() -> bool {
|
||||
return tracing::enabled!(target: OTHER_KSP_TARGET, tracing::Level::INFO);
|
||||
}
|
||||
|
||||
fn other_ksp_debug_enabled() -> bool {
|
||||
return tracing::enabled!(target: OTHER_KSP_TARGET, tracing::Level::DEBUG);
|
||||
}
|
||||
|
||||
fn external_error_enabled() -> bool {
|
||||
return tracing::enabled!(target: EXTERNAL_TARGET, tracing::Level::ERROR);
|
||||
}
|
||||
|
||||
fn test_root_directory() -> std::path::PathBuf {
|
||||
return std::env::temp_dir().join(format!("ksp-logging-lib-runtime-{}", std::process::id()));
|
||||
}
|
||||
|
||||
fn reset_directory(path: &std::path::Path) {
|
||||
if path.exists() {
|
||||
let remove_result = std::fs::remove_dir_all(path);
|
||||
assert!(remove_result.is_ok());
|
||||
}
|
||||
}
|
||||
|
||||
fn read_directory_text(path: &std::path::Path) -> std::string::String {
|
||||
let read_result = std::fs::read_dir(path);
|
||||
let entries = match read_result {
|
||||
std::result::Result::Ok(entries) => entries,
|
||||
std::result::Result::Err(_) => return std::string::String::new(),
|
||||
};
|
||||
let mut output = std::string::String::new();
|
||||
for entry_result in entries {
|
||||
let entry = match entry_result {
|
||||
std::result::Result::Ok(entry) => entry,
|
||||
std::result::Result::Err(_) => continue,
|
||||
};
|
||||
let file_type = match entry.file_type() {
|
||||
std::result::Result::Ok(file_type) => file_type,
|
||||
std::result::Result::Err(_) => continue,
|
||||
};
|
||||
if !file_type.is_file() {
|
||||
continue;
|
||||
}
|
||||
let content = match std::fs::read_to_string(entry.path()) {
|
||||
std::result::Result::Ok(content) => content,
|
||||
std::result::Result::Err(_) => continue,
|
||||
};
|
||||
output.push_str(content.as_str());
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
fn exercise_concurrent_reload(guard: &mut ksp_logging_lib::LoggingGuard, disabled: &ksp_logging_lib::LoggingSettings) {
|
||||
let quiet_console = ksp_logging_lib::LoggingSettings::new(
|
||||
ksp_logging_lib::LogFilterLevel::Off,
|
||||
ksp_logging_lib::SpanEvents::Off,
|
||||
std::option::Option::Some(ksp_logging_lib::ConsoleSettings::stderr()),
|
||||
std::vec::Vec::new(),
|
||||
);
|
||||
let quiet_reload = ksp_logging_lib::reinitialize(guard, &quiet_console);
|
||||
assert!(quiet_reload.is_ok());
|
||||
let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||||
let barrier = std::sync::Arc::new(std::sync::Barrier::new(5));
|
||||
let mut threads = std::vec::Vec::new();
|
||||
for worker_index in 0..4_u32 {
|
||||
let worker_stop = std::sync::Arc::clone(&stop);
|
||||
let worker_barrier = std::sync::Arc::clone(&barrier);
|
||||
threads.push(std::thread::spawn(move || {
|
||||
worker_barrier.wait();
|
||||
let mut sequence = 0_u64;
|
||||
while !worker_stop.load(std::sync::atomic::Ordering::Relaxed) {
|
||||
ksp_logging_lib::trace!(target: LOGGING_TARGET, worker_index, sequence, "concurrent reload probe");
|
||||
sequence = sequence.wrapping_add(1);
|
||||
}
|
||||
return;
|
||||
}));
|
||||
}
|
||||
barrier.wait();
|
||||
let mut reloads_succeeded = true;
|
||||
for reload_index in 0..32_u32 {
|
||||
let settings = if reload_index % 2 == 0 { &quiet_console } else { disabled };
|
||||
let reload_result = ksp_logging_lib::reinitialize(guard, settings);
|
||||
if reload_result.is_err() {
|
||||
reloads_succeeded = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
stop.store(true, std::sync::atomic::Ordering::Relaxed);
|
||||
let mut joins_succeeded = true;
|
||||
for thread in threads {
|
||||
if thread.join().is_err() {
|
||||
joins_succeeded = false;
|
||||
}
|
||||
}
|
||||
assert!(reloads_succeeded);
|
||||
assert!(joins_succeeded);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn global_runtime_supports_takeover_non_blocking_outputs_hot_reload_and_single_initialization() {
|
||||
let root = test_root_directory();
|
||||
reset_directory(root.as_path());
|
||||
let disabled = ksp_logging_lib::LoggingSettings::new(
|
||||
ksp_logging_lib::LogFilterLevel::Info,
|
||||
ksp_logging_lib::SpanEvents::Off,
|
||||
std::option::Option::None,
|
||||
std::vec::Vec::new(),
|
||||
);
|
||||
let initialize_result = ksp_logging_lib::initialize(&disabled);
|
||||
assert!(initialize_result.is_ok());
|
||||
let mut guard = match initialize_result {
|
||||
std::result::Result::Ok(guard) => guard,
|
||||
std::result::Result::Err(_) => return,
|
||||
};
|
||||
assert_eq!(guard.dropped_lines(), ksp_logging_lib::DroppedLines::zero());
|
||||
assert!(!logging_trace_enabled());
|
||||
assert!(!other_ksp_info_enabled());
|
||||
assert!(!external_error_enabled());
|
||||
let console_enabled = ksp_logging_lib::LoggingSettings::new(
|
||||
ksp_logging_lib::LogFilterLevel::Info,
|
||||
ksp_logging_lib::SpanEvents::NewAndClose,
|
||||
std::option::Option::Some(ksp_logging_lib::ConsoleSettings::stderr()),
|
||||
std::vec::Vec::new(),
|
||||
)
|
||||
.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);
|
||||
assert!(reload_result.is_ok());
|
||||
assert_eq!(guard.settings(), &console_enabled);
|
||||
assert!(logging_trace_enabled());
|
||||
assert!(other_ksp_info_enabled());
|
||||
assert!(!other_ksp_debug_enabled());
|
||||
assert!(!external_error_enabled());
|
||||
let blocked_directory = root.join("not-a-directory");
|
||||
let create_root = std::fs::create_dir_all(root.as_path());
|
||||
assert!(create_root.is_ok());
|
||||
let create_blocker = std::fs::write(blocked_directory.as_path(), b"file blocks directory creation");
|
||||
assert!(create_blocker.is_ok());
|
||||
let invalid_file = ksp_logging_lib::LoggingSettings::new(
|
||||
ksp_logging_lib::LogFilterLevel::Error,
|
||||
ksp_logging_lib::SpanEvents::Full,
|
||||
std::option::Option::None,
|
||||
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);
|
||||
assert!(failed_reload.is_err());
|
||||
let file_error = match failed_reload {
|
||||
std::result::Result::Ok(()) => return,
|
||||
std::result::Result::Err(error) => error,
|
||||
};
|
||||
assert_eq!(file_error.code(), ksp_logging_lib::ERROR_CODE_FILE_OUTPUT_INITIALIZATION_FAILED);
|
||||
assert_eq!(guard.settings(), &console_enabled);
|
||||
assert!(logging_trace_enabled());
|
||||
assert!(!external_error_enabled());
|
||||
exercise_concurrent_reload(&mut guard, &disabled);
|
||||
let human_directory = root.join("human");
|
||||
let compact_directory = root.join("compact");
|
||||
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,
|
||||
std::option::Option::None,
|
||||
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, &files_enabled);
|
||||
assert!(file_reload.is_ok());
|
||||
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");
|
||||
let disable_after_file = ksp_logging_lib::reinitialize(&mut guard, &disabled);
|
||||
assert!(disable_after_file.is_ok());
|
||||
let human_text = read_directory_text(human_directory.as_path());
|
||||
let compact_text = read_directory_text(compact_directory.as_path());
|
||||
let pretty_text = read_directory_text(pretty_directory.as_path());
|
||||
let json_text = read_directory_text(json_directory.as_path());
|
||||
assert!(human_text.contains("logging info marker"));
|
||||
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();
|
||||
assert_eq!(dropped.total(), dropped.console().saturating_add(dropped.file()));
|
||||
let second_initialize = ksp_logging_lib::initialize(&disabled);
|
||||
assert!(second_initialize.is_err());
|
||||
let error = match second_initialize {
|
||||
std::result::Result::Ok(_) => return,
|
||||
std::result::Result::Err(error) => error,
|
||||
};
|
||||
assert_eq!(error.code(), ksp_logging_lib::ERROR_CODE_ALREADY_INITIALIZED);
|
||||
reset_directory(root.as_path());
|
||||
}
|
||||
73
crates/ksp-logging-lib/tests/span_lifecycle.rs
Normal file
73
crates/ksp-logging-lib/tests/span_lifecycle.rs
Normal file
@@ -0,0 +1,73 @@
|
||||
// file: crates/ksp-logging-lib/tests/span_lifecycle.rs
|
||||
// version: 1
|
||||
|
||||
//! Integration tests for formatted KSP span lifecycle timing output.
|
||||
|
||||
use tracing_subscriber::layer::SubscriberExt; // rust-rules: trait-import
|
||||
|
||||
const TEST_TARGET: &str = "ksp-logging-lib";
|
||||
|
||||
#[derive(Clone)]
|
||||
struct SharedWriter {
|
||||
buffer: std::sync::Arc<std::sync::Mutex<std::vec::Vec<u8>>>,
|
||||
}
|
||||
|
||||
impl SharedWriter {
|
||||
fn new(buffer: std::sync::Arc<std::sync::Mutex<std::vec::Vec<u8>>>) -> Self {
|
||||
return Self { buffer };
|
||||
}
|
||||
}
|
||||
|
||||
impl std::io::Write for SharedWriter {
|
||||
fn write(&mut self, bytes: &[u8]) -> std::io::Result<usize> {
|
||||
let lock_result = self.buffer.lock();
|
||||
let mut buffer = match lock_result {
|
||||
std::result::Result::Ok(buffer) => buffer,
|
||||
std::result::Result::Err(_) => return std::result::Result::Err(std::io::Error::other("span test buffer is poisoned")),
|
||||
};
|
||||
buffer.extend_from_slice(bytes);
|
||||
return std::result::Result::Ok(bytes.len());
|
||||
}
|
||||
|
||||
fn flush(&mut self) -> std::io::Result<()> {
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
fn captured_text(buffer: &std::sync::Arc<std::sync::Mutex<std::vec::Vec<u8>>>) -> std::string::String {
|
||||
let lock_result = buffer.lock();
|
||||
let bytes = match lock_result {
|
||||
std::result::Result::Ok(bytes) => bytes.clone(),
|
||||
std::result::Result::Err(error) => error.into_inner().clone(),
|
||||
};
|
||||
return std::string::String::from_utf8_lossy(bytes.as_slice()).into_owned();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_and_close_span_events_expose_busy_and_idle_timing_fields() {
|
||||
let buffer = std::sync::Arc::new(std::sync::Mutex::new(std::vec::Vec::new()));
|
||||
let writer_buffer = std::sync::Arc::clone(&buffer);
|
||||
let layer = tracing_subscriber::fmt::layer()
|
||||
.with_writer(move || -> SharedWriter {
|
||||
return SharedWriter::new(std::sync::Arc::clone(&writer_buffer));
|
||||
})
|
||||
.with_ansi(false)
|
||||
.with_target(true)
|
||||
.with_span_events(tracing_subscriber::fmt::format::FmtSpan::NEW | tracing_subscriber::fmt::format::FmtSpan::CLOSE);
|
||||
let subscriber = tracing_subscriber::registry().with(layer);
|
||||
tracing::subscriber::with_default(subscriber, || {
|
||||
let span = ksp_logging_lib::trace_span!(target: TEST_TARGET, "timed_scope", domain = "logging");
|
||||
span.in_scope(|| {
|
||||
std::hint::black_box(42_u32);
|
||||
return;
|
||||
});
|
||||
drop(span);
|
||||
return;
|
||||
});
|
||||
let text = captured_text(&buffer);
|
||||
assert!(text.contains("timed_scope"));
|
||||
assert!(text.contains("new"));
|
||||
assert!(text.contains("close"));
|
||||
assert!(text.contains("time.busy"));
|
||||
assert!(text.contains("time.idle"));
|
||||
}
|
||||
118
crates/ksp-logging-lib/tests/tokio_span.rs
Normal file
118
crates/ksp-logging-lib/tests/tokio_span.rs
Normal file
@@ -0,0 +1,118 @@
|
||||
// file: crates/ksp-logging-lib/tests/tokio_span.rs
|
||||
// version: 1
|
||||
|
||||
//! Integration tests for KSP span instrumentation on a real Tokio executor.
|
||||
|
||||
const TEST_TARGET: &str = "ksp-logging-lib";
|
||||
|
||||
#[derive(Clone)]
|
||||
struct CountingSubscriber {
|
||||
enters: std::sync::Arc<std::sync::atomic::AtomicU64>,
|
||||
exits: std::sync::Arc<std::sync::atomic::AtomicU64>,
|
||||
next_id: std::sync::Arc<std::sync::atomic::AtomicU64>,
|
||||
}
|
||||
|
||||
impl CountingSubscriber {
|
||||
fn new(enters: std::sync::Arc<std::sync::atomic::AtomicU64>, exits: std::sync::Arc<std::sync::atomic::AtomicU64>) -> Self {
|
||||
return Self { enters, exits, next_id: std::sync::Arc::new(std::sync::atomic::AtomicU64::new(1)) };
|
||||
}
|
||||
}
|
||||
|
||||
impl tracing::Subscriber for CountingSubscriber {
|
||||
fn enabled(&self, _metadata: &tracing::Metadata<'_>) -> bool {
|
||||
return true;
|
||||
}
|
||||
|
||||
fn new_span(&self, _span: &tracing::span::Attributes<'_>) -> tracing::span::Id {
|
||||
let id = self.next_id.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
return tracing::span::Id::from_u64(id);
|
||||
}
|
||||
|
||||
fn record(&self, _span: &tracing::span::Id, _values: &tracing::span::Record<'_>) {
|
||||
return;
|
||||
}
|
||||
|
||||
fn record_follows_from(&self, _span: &tracing::span::Id, _follows: &tracing::span::Id) {
|
||||
return;
|
||||
}
|
||||
|
||||
fn event(&self, _event: &tracing::Event<'_>) {
|
||||
return;
|
||||
}
|
||||
|
||||
fn enter(&self, _span: &tracing::span::Id) {
|
||||
self.enters.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
return;
|
||||
}
|
||||
|
||||
fn exit(&self, _span: &tracing::span::Id) {
|
||||
self.exits.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
fn test_span(enters: std::sync::Arc<std::sync::atomic::AtomicU64>, exits: std::sync::Arc<std::sync::atomic::AtomicU64>) -> ksp_logging_lib::Span {
|
||||
let subscriber = CountingSubscriber::new(enters, exits);
|
||||
return tracing::subscriber::with_default(subscriber, || -> ksp_logging_lib::Span {
|
||||
return ksp_logging_lib::trace_span!(target: TEST_TARGET, "tokio_runtime_span", domain = "logging", executor = "tokio");
|
||||
});
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn instrumented_span_reenters_across_real_tokio_suspensions() {
|
||||
let enters = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0));
|
||||
let exits = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0));
|
||||
let span = test_span(std::sync::Arc::clone(&enters), std::sync::Arc::clone(&exits));
|
||||
let observed_enters = std::sync::Arc::clone(&enters);
|
||||
let future = ksp_logging_lib::instrument(span, async move {
|
||||
assert!(observed_enters.load(std::sync::atomic::Ordering::Relaxed) >= 1);
|
||||
tokio::task::yield_now().await;
|
||||
assert!(observed_enters.load(std::sync::atomic::Ordering::Relaxed) >= 2);
|
||||
tokio::task::yield_now().await;
|
||||
assert!(observed_enters.load(std::sync::atomic::Ordering::Relaxed) >= 3);
|
||||
return 42_u32;
|
||||
});
|
||||
let value = future.await;
|
||||
assert_eq!(value, 42_u32);
|
||||
let enter_count = enters.load(std::sync::atomic::Ordering::Relaxed);
|
||||
let exit_count = exits.load(std::sync::atomic::Ordering::Relaxed);
|
||||
assert!(enter_count >= 3);
|
||||
assert_eq!(enter_count, exit_count);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn instrumented_spans_are_usable_on_tokio_multithread_runtime() {
|
||||
let enters = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0));
|
||||
let exits = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0));
|
||||
let first_span = test_span(std::sync::Arc::clone(&enters), std::sync::Arc::clone(&exits));
|
||||
let second_span = test_span(std::sync::Arc::clone(&enters), std::sync::Arc::clone(&exits));
|
||||
let first_task = tokio::spawn(ksp_logging_lib::instrument(first_span, async {
|
||||
for _iteration in 0..32 {
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
return 20_u32;
|
||||
}));
|
||||
let second_task = tokio::spawn(ksp_logging_lib::instrument(second_span, async {
|
||||
for _iteration in 0..32 {
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
return 22_u32;
|
||||
}));
|
||||
let first_result = first_task.await;
|
||||
assert!(first_result.is_ok(), "first Tokio task must complete successfully");
|
||||
let first_value = match first_result {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return,
|
||||
};
|
||||
let second_result = second_task.await;
|
||||
assert!(second_result.is_ok(), "second Tokio task must complete successfully");
|
||||
let second_value = match second_result {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return,
|
||||
};
|
||||
assert_eq!(first_value + second_value, 42_u32);
|
||||
let enter_count = enters.load(std::sync::atomic::Ordering::Relaxed);
|
||||
let exit_count = exits.load(std::sync::atomic::Ordering::Relaxed);
|
||||
assert!(enter_count >= 4);
|
||||
assert_eq!(enter_count, exit_count);
|
||||
}
|
||||
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()]));
|
||||
}
|
||||
260
crates/ksp-logging-lib/unit_tests/runtime.rs
Normal file
260
crates/ksp-logging-lib/unit_tests/runtime.rs
Normal file
@@ -0,0 +1,260 @@
|
||||
// file: crates/ksp-logging-lib/unit_tests/runtime.rs
|
||||
// version: 9
|
||||
|
||||
#[test]
|
||||
fn level_mapping_covers_all_ksp_levels() {
|
||||
assert_eq!(super::map_filter_level(crate::LogFilterLevel::Off), tracing_subscriber::filter::LevelFilter::OFF);
|
||||
assert_eq!(super::map_filter_level(crate::LogFilterLevel::Error), tracing_subscriber::filter::LevelFilter::ERROR);
|
||||
assert_eq!(super::map_filter_level(crate::LogFilterLevel::Warn), tracing_subscriber::filter::LevelFilter::WARN);
|
||||
assert_eq!(super::map_filter_level(crate::LogFilterLevel::Info), tracing_subscriber::filter::LevelFilter::INFO);
|
||||
assert_eq!(super::map_filter_level(crate::LogFilterLevel::Debug), tracing_subscriber::filter::LevelFilter::DEBUG);
|
||||
assert_eq!(super::map_filter_level(crate::LogFilterLevel::Trace), tracing_subscriber::filter::LevelFilter::TRACE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn takeover_filter_silences_external_targets_and_applies_ksp_overrides() {
|
||||
let settings = crate::LoggingSettings::new(
|
||||
crate::LogFilterLevel::Info,
|
||||
crate::SpanEvents::Off,
|
||||
std::option::Option::Some(crate::ConsoleSettings::stdout()),
|
||||
std::vec::Vec::new(),
|
||||
)
|
||||
.with_target_filter(crate::TargetFilter::new("ksp-logging-lib", crate::LogFilterLevel::Trace));
|
||||
let filter = super::build_target_filter(&settings);
|
||||
assert!(filter.would_enable("ksp-store-lib", &tracing::Level::INFO));
|
||||
assert!(!filter.would_enable("ksp-store-lib", &tracing::Level::DEBUG));
|
||||
assert!(filter.would_enable("ksp-logging-lib", &tracing::Level::TRACE));
|
||||
assert!(!filter.would_enable("sqlx", &tracing::Level::ERROR));
|
||||
assert!(!filter.would_enable("hyper", &tracing::Level::ERROR));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn span_event_mapping_supports_disabled_timing_and_full_lifecycle() {
|
||||
assert_eq!(super::map_span_events(crate::SpanEvents::Off), tracing_subscriber::fmt::format::FmtSpan::NONE);
|
||||
assert_eq!(
|
||||
super::map_span_events(crate::SpanEvents::NewAndClose),
|
||||
tracing_subscriber::fmt::format::FmtSpan::NEW | tracing_subscriber::fmt::format::FmtSpan::CLOSE,
|
||||
);
|
||||
assert_eq!(super::map_span_events(crate::SpanEvents::Full), tracing_subscriber::fmt::format::FmtSpan::FULL);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_rotation_mapping_covers_supported_cadences() {
|
||||
assert_eq!(super::map_file_rotation(crate::FileRotation::Never), tracing_appender::rolling::Rotation::NEVER);
|
||||
assert_eq!(super::map_file_rotation(crate::FileRotation::Hourly), tracing_appender::rolling::Rotation::HOURLY);
|
||||
assert_eq!(super::map_file_rotation(crate::FileRotation::Daily), tracing_appender::rolling::Rotation::DAILY);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disabled_runtime_has_no_layers_or_outputs() {
|
||||
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);
|
||||
assert!(result.is_ok());
|
||||
let prepared = match result {
|
||||
std::result::Result::Ok(prepared) => prepared,
|
||||
std::result::Result::Err(_) => return,
|
||||
};
|
||||
assert!(prepared.layers.is_empty());
|
||||
assert!(prepared.outputs.console.is_none());
|
||||
assert!(prepared.outputs.files.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn console_runtime_composes_takeover_filter_before_formatter_and_owns_guard() {
|
||||
let settings = crate::LoggingSettings::new(
|
||||
crate::LogFilterLevel::Info,
|
||||
crate::SpanEvents::Off,
|
||||
std::option::Option::Some(crate::ConsoleSettings::stdout()),
|
||||
std::vec::Vec::new(),
|
||||
);
|
||||
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!(prepared.outputs.files.is_empty());
|
||||
assert_eq!(prepared.outputs.dropped_lines(), crate::DroppedLines::zero());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dropped_line_snapshots_add_saturating_by_sink() {
|
||||
let first = crate::DroppedLines { console: usize::MAX, file: 4 };
|
||||
let second = crate::DroppedLines { console: 1, file: 7 };
|
||||
let combined = first.saturating_add(second);
|
||||
assert_eq!(combined.console(), usize::MAX);
|
||||
assert_eq!(combined.file(), 11);
|
||||
assert_eq!(combined.total(), usize::MAX);
|
||||
}
|
||||
|
||||
#[test]
|
||||
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::vec::Vec::new())
|
||||
.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-wallet-lib", crate::LogFilterLevel::Off));
|
||||
let filter = super::build_target_filter(&settings);
|
||||
assert!(filter.would_enable("ksp-store-other", &tracing::Level::DEBUG));
|
||||
assert!(!filter.would_enable("ksp-store-other", &tracing::Level::TRACE));
|
||||
assert!(filter.would_enable("ksp-store-lib", &tracing::Level::TRACE));
|
||||
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 {
|
||||
first_write: bool,
|
||||
started: std::sync::mpsc::SyncSender<()>,
|
||||
release: std::sync::Arc<(std::sync::Mutex<bool>, std::sync::Condvar)>,
|
||||
}
|
||||
|
||||
impl BlockingWriter {
|
||||
fn new(started: std::sync::mpsc::SyncSender<()>, release: std::sync::Arc<(std::sync::Mutex<bool>, std::sync::Condvar)>) -> Self {
|
||||
return Self { first_write: true, started, release };
|
||||
}
|
||||
}
|
||||
|
||||
impl std::io::Write for BlockingWriter {
|
||||
fn write(&mut self, buffer: &[u8]) -> std::io::Result<usize> {
|
||||
if self.first_write {
|
||||
self.first_write = false;
|
||||
if self.started.send(()).is_err() {
|
||||
return std::result::Result::Err(std::io::Error::other("unable to notify saturation test that the writer is blocked"));
|
||||
}
|
||||
let (lock, condition) = self.release.as_ref();
|
||||
let lock_result = lock.lock();
|
||||
let mut released = match lock_result {
|
||||
std::result::Result::Ok(released) => released,
|
||||
std::result::Result::Err(_) => {
|
||||
return std::result::Result::Err(std::io::Error::other("saturation test release lock is poisoned"));
|
||||
},
|
||||
};
|
||||
while !*released {
|
||||
let wait_result = condition.wait(released);
|
||||
released = match wait_result {
|
||||
std::result::Result::Ok(released) => released,
|
||||
std::result::Result::Err(_) => {
|
||||
return std::result::Result::Err(std::io::Error::other("saturation test release wait is poisoned"));
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
return std::result::Result::Ok(buffer.len());
|
||||
}
|
||||
|
||||
fn flush(&mut self) -> std::io::Result<()> {
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
fn release_blocked_writer(release: &std::sync::Arc<(std::sync::Mutex<bool>, std::sync::Condvar)>) {
|
||||
let (lock, condition) = release.as_ref();
|
||||
let lock_result = lock.lock();
|
||||
let mut released = match lock_result {
|
||||
std::result::Result::Ok(released) => released,
|
||||
std::result::Result::Err(error) => error.into_inner(),
|
||||
};
|
||||
*released = true;
|
||||
condition.notify_all();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lossy_non_blocking_builder_drops_lines_instead_of_blocking_a_stalled_producer() {
|
||||
let (started_sender, started_receiver) = std::sync::mpsc::sync_channel(1);
|
||||
let release = std::sync::Arc::new((std::sync::Mutex::new(false), std::sync::Condvar::new()));
|
||||
let writer = BlockingWriter::new(started_sender, std::sync::Arc::clone(&release));
|
||||
let (mut non_blocking, worker_guard) = super::non_blocking_builder("ksp-logging-saturation-test").buffered_lines_limit(1).finish(writer);
|
||||
let error_counter = non_blocking.error_counter();
|
||||
let first_write = std::io::Write::write_all(&mut non_blocking, b"block worker\n");
|
||||
assert!(first_write.is_ok());
|
||||
let writer_started = started_receiver.recv_timeout(std::time::Duration::from_secs(2));
|
||||
assert!(writer_started.is_ok());
|
||||
let mut producer = non_blocking.clone();
|
||||
let (finished_sender, finished_receiver) = std::sync::mpsc::sync_channel(1);
|
||||
let producer_thread = std::thread::spawn(move || {
|
||||
let mut succeeded = true;
|
||||
for _ in 0..1_024 {
|
||||
let write_result = std::io::Write::write_all(&mut producer, b"queued line\n");
|
||||
if write_result.is_err() {
|
||||
succeeded = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
let _send_result = finished_sender.send(succeeded);
|
||||
return;
|
||||
});
|
||||
let producer_finished = finished_receiver.recv_timeout(std::time::Duration::from_secs(2));
|
||||
release_blocked_writer(&release);
|
||||
let join_result = producer_thread.join();
|
||||
assert!(join_result.is_ok());
|
||||
assert_eq!(producer_finished, std::result::Result::Ok(true));
|
||||
assert!(error_counter.dropped_lines() > 0);
|
||||
drop(non_blocking);
|
||||
drop(worker_guard);
|
||||
}
|
||||
226
crates/ksp-logging-lib/unit_tests/settings.rs
Normal file
226
crates/ksp-logging-lib/unit_tests/settings.rs
Normal file
@@ -0,0 +1,226 @@
|
||||
// file: crates/ksp-logging-lib/unit_tests/settings.rs
|
||||
// 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]
|
||||
fn level_variants_are_distinct() {
|
||||
assert_ne!(crate::LogFilterLevel::Off, crate::LogFilterLevel::Error);
|
||||
assert_ne!(crate::LogFilterLevel::Error, crate::LogFilterLevel::Warn);
|
||||
assert_ne!(crate::LogFilterLevel::Warn, crate::LogFilterLevel::Info);
|
||||
assert_ne!(crate::LogFilterLevel::Info, crate::LogFilterLevel::Debug);
|
||||
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]
|
||||
fn target_filter_preserves_prefix_and_level() {
|
||||
let filter = crate::TargetFilter::new("ksp-store-lib", crate::LogFilterLevel::Trace);
|
||||
assert_eq!(filter.target_prefix(), "ksp-store-lib");
|
||||
assert_eq!(filter.level(), crate::LogFilterLevel::Trace);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_filter_preserves_level_targets_and_domains() {
|
||||
let filter = crate::OutputFilter::new(
|
||||
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]
|
||||
fn console_settings_preserve_enabled_stream_ansi_format_and_filter() {
|
||||
let filter = crate::OutputFilter::new(crate::LogFilterLevel::Debug, std::vec!["*".to_string()], std::vec!["config".to_string()]);
|
||||
let settings = crate::ConsoleSettings::new(true, crate::ConsoleOutput::Stderr, true, crate::LogFormat::Compact, filter.clone());
|
||||
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.format(), crate::LogFormat::Json);
|
||||
assert!(!settings.ansi());
|
||||
assert_eq!(settings.filter(), &filter);
|
||||
}
|
||||
|
||||
#[test]
|
||||
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(
|
||||
crate::LogFilterLevel::Info,
|
||||
crate::SpanEvents::NewAndClose,
|
||||
std::option::Option::Some(crate::ConsoleSettings::stdout()),
|
||||
std::vec![first, second],
|
||||
)
|
||||
.with_target_filter(crate::TargetFilter::new("ksp-store-lib", crate::LogFilterLevel::Trace));
|
||||
assert_eq!(settings.default_filter(), crate::LogFilterLevel::Info);
|
||||
assert_eq!(settings.span_events(), crate::SpanEvents::NewAndClose);
|
||||
assert_eq!(settings.target_filters().len(), 1);
|
||||
assert_eq!(settings.files().len(), 2);
|
||||
assert_eq!(settings.files()[0].output_id(), "file.debug");
|
||||
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]
|
||||
fn validation_rejects_empty_or_external_global_target_prefix() {
|
||||
let empty = crate::LoggingSettings::new(crate::LogFilterLevel::Info, crate::SpanEvents::Off, std::option::Option::None, std::vec::Vec::new())
|
||||
.with_target_filter(crate::TargetFilter::new("", crate::LogFilterLevel::Debug));
|
||||
let external = crate::LoggingSettings::new(crate::LogFilterLevel::Info, crate::SpanEvents::Off, std::option::Option::None, std::vec::Vec::new())
|
||||
.with_target_filter(crate::TargetFilter::new("sqlx", crate::LogFilterLevel::Debug));
|
||||
assert!(empty.validate().is_err());
|
||||
assert!(external.validate().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validation_rejects_invalid_file_identity_and_duplicate_output_ids() {
|
||||
let invalid_id =
|
||||
crate::LoggingSettings::new(crate::LogFilterLevel::Info, crate::SpanEvents::Off, std::option::Option::None, std::vec![unrestricted_file("File.Debug")]);
|
||||
let duplicate_id = crate::LoggingSettings::new(
|
||||
crate::LogFilterLevel::Info,
|
||||
crate::SpanEvents::Off,
|
||||
std::option::Option::None,
|
||||
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());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validation_rejects_invalid_output_selectors() {
|
||||
let external_target = crate::OutputFilter::new(crate::LogFilterLevel::Debug, std::vec!["sqlx".to_string()], std::vec!["*".to_string()]);
|
||||
let wildcard_mix =
|
||||
crate::OutputFilter::new(crate::LogFilterLevel::Debug, std::vec!["*".to_string(), "ksp-config-lib".to_string()], std::vec!["*".to_string()]);
|
||||
let duplicate_domain =
|
||||
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] {
|
||||
let console = crate::ConsoleSettings::new(true, crate::ConsoleOutput::Stdout, false, crate::LogFormat::Human, filter);
|
||||
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]
|
||||
fn settings_allow_all_outputs_to_be_disabled() {
|
||||
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());
|
||||
}
|
||||
22
crates/ksp-logging-lib/unit_tests/span.rs
Normal file
22
crates/ksp-logging-lib/unit_tests/span.rs
Normal file
@@ -0,0 +1,22 @@
|
||||
// file: crates/ksp-logging-lib/unit_tests/span.rs
|
||||
// version: 1
|
||||
|
||||
#[test]
|
||||
fn synchronous_scope_returns_operation_value() {
|
||||
let span = crate::Span::__from_tracing(tracing::info_span!("unit_test_span"));
|
||||
let value = span.in_scope(|| -> u32 {
|
||||
return 42;
|
||||
});
|
||||
assert_eq!(value, 42);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn async_instrumentation_returns_future_output() {
|
||||
let span = crate::Span::__from_tracing(tracing::info_span!("unit_test_async_span"));
|
||||
let future = crate::instrument(span, std::future::ready(42_u32));
|
||||
let mut future = std::boxed::Box::pin(future);
|
||||
let waker = std::task::Waker::noop();
|
||||
let mut context = std::task::Context::from_waker(waker);
|
||||
let poll = std::future::Future::poll(future.as_mut(), &mut context);
|
||||
assert_eq!(poll, std::task::Poll::Ready(42_u32));
|
||||
}
|
||||
51
crates/ksp-logging-lib/unit_tests/writer.rs
Normal file
51
crates/ksp-logging-lib/unit_tests/writer.rs
Normal file
@@ -0,0 +1,51 @@
|
||||
// file: crates/ksp-logging-lib/unit_tests/writer.rs
|
||||
// version: 2
|
||||
|
||||
#[test]
|
||||
fn ansi_writer_strips_csi_sequences() {
|
||||
let mut writer = super::StripAnsiWriter::new(std::vec::Vec::<u8>::new());
|
||||
let write_result = std::io::Write::write_all(&mut writer, b"before\x1b[31mred\x1b[0mafter");
|
||||
assert!(write_result.is_ok());
|
||||
assert_eq!(writer.into_inner(), b"beforeredafter");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ansi_writer_preserves_state_across_split_writes() {
|
||||
let mut writer = super::StripAnsiWriter::new(std::vec::Vec::<u8>::new());
|
||||
let first = std::io::Write::write_all(&mut writer, b"a\x1b[");
|
||||
let second = std::io::Write::write_all(&mut writer, b"32mb");
|
||||
assert!(first.is_ok());
|
||||
assert!(second.is_ok());
|
||||
assert_eq!(writer.into_inner(), b"ab");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ansi_writer_strips_osc_sequences_terminated_by_bell_or_st() {
|
||||
let mut writer = super::StripAnsiWriter::new(std::vec::Vec::<u8>::new());
|
||||
let first = std::io::Write::write_all(&mut writer, b"a\x1b]0;title\x07b");
|
||||
let second = std::io::Write::write_all(&mut writer, b"c\x1b]8;;https://example.invalid\x1b\\d");
|
||||
assert!(first.is_ok());
|
||||
assert!(second.is_ok());
|
||||
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());
|
||||
}
|
||||
162
deltas/0.1.2/pre.001-fix.001.md
Normal file
162
deltas/0.1.2/pre.001-fix.001.md
Normal file
@@ -0,0 +1,162 @@
|
||||
<!-- file: deltas/0.1.2/pre.001-fix.001.md -->
|
||||
<!-- version: 1 -->
|
||||
|
||||
# Delta 0.1.2-pre.001-fix.001
|
||||
|
||||
## Base requise
|
||||
|
||||
Livraison précédente :
|
||||
|
||||
```text
|
||||
0.1.2-pre.001
|
||||
```
|
||||
|
||||
Ce correctif est documentaire et corrige le plan de `pre.001` sans réécrire son delta historique.
|
||||
|
||||
## Objectif
|
||||
|
||||
Corriger le cadrage Logging avant validation du plan afin de fixer :
|
||||
|
||||
- le takeover complet du logging/tracing KSP par `ksp-logging-lib` ;
|
||||
- le target KSP explicite égal au nom Cargo de la crate propriétaire ;
|
||||
- le silence par défaut des targets tiers et la réémission explicite des informations utiles par le composant KSP propriétaire ;
|
||||
- console et fichier non bloquants avec guards et compteurs de lignes abandonnées ;
|
||||
- le stripping ANSI des fichiers ;
|
||||
- un `initialize` global unique suivi d'un hot reload via `reinitialize` sans second subscriber global ;
|
||||
- une surface de spans KSP synchrones et async avec diagnostic de durée `NEW/CLOSE`, `busy` et `idle` ;
|
||||
- la responsabilité des données loggées au caller, sans détection/redaction automatique par Logging.
|
||||
|
||||
Aucun développement fonctionnel de `ksp-logging-lib` n'est introduit par ce fix.
|
||||
|
||||
## Décisions prises
|
||||
|
||||
### Takeover tracing
|
||||
|
||||
`ksp-logging-lib` devient le seul propriétaire KSP direct de la stack tracing et la seule façade autorisée pour les événements/spans KSP.
|
||||
|
||||
Le subscriber applique une politique de takeover :
|
||||
|
||||
```text
|
||||
external targets = Off by default
|
||||
ksp-* targets = configured KSP default
|
||||
specific ksp-* = optional override
|
||||
```
|
||||
|
||||
KSP ne renomme pas un événement tiers. Lorsqu'un détail provenant d'une dépendance externe est utile, la crate KSP propriétaire le réémet sous son propre target.
|
||||
|
||||
Exemple attendu pour Store : les logs SQLx natifs sont désactivés/silencieux ; les opérations SQL utiles sont journalisées explicitement par `ksp-store-lib`, typiquement au niveau `trace`.
|
||||
|
||||
### Targets
|
||||
|
||||
Les macros événements et spans exigent un target explicite correspondant au nom Cargo de la crate propriétaire. `domain`, `component` et autres fields restent des subdivisions structurées, pas des remplacements du target.
|
||||
|
||||
### Settings runtime
|
||||
|
||||
`LoggingSettings` reste propriétaire de Logging et indépendant de Config. Il couvre niveau KSP default, overrides de target, sorties console/fichier et politique d'événements de spans.
|
||||
|
||||
Une future `ksp-config-lib` pourra construire ces settings puis appeler la façade Logging.
|
||||
|
||||
### Non-blocking
|
||||
|
||||
Console et fichier utilisent des writers non bloquants avec leurs `WorkerGuard` possédés par `LoggingGuard`.
|
||||
|
||||
Le mode retenu privilégie l'absence de backpressure sur le hot path : une saturation peut abandonner des lignes. Les `ErrorCounter` sont conservés afin que ces pertes restent observables.
|
||||
|
||||
### Stripping ANSI
|
||||
|
||||
Les fichiers passent par un stripping ANSI générique avant persistence. Logging ne dépend pas de Tauri ; cette protection évite seulement de persister des séquences de terminal déjà présentes dans les données écrites.
|
||||
|
||||
### Initialisation et hot reload
|
||||
|
||||
`initialize(settings)` installe le subscriber global une seule fois et retourne `LoggingGuard`.
|
||||
|
||||
Après succès, `reinitialize(&mut guard, settings)` ou une méthode équivalente peut être appelée 0..N fois. Elle ne réinstalle pas le subscriber global ; elle modifie les filters/layers/sinks de l'infrastructure déjà installée.
|
||||
|
||||
Le reload vise une sémantique transactionnelle : une nouvelle configuration invalide ou impossible à construire laisse l'ancienne configuration active.
|
||||
|
||||
Le mécanisme interne exact (`tracing_subscriber::reload` ciblé ou routing KSP dynamique) sera choisi par implémentation/tests selon correction et overhead, sans modifier le contrat public.
|
||||
|
||||
### Spans sync/async et durée
|
||||
|
||||
`0.1.2` inclut désormais une surface de spans KSP par niveau, sans dépendance directe `tracing` dans les crates consommatrices.
|
||||
|
||||
Le code sync doit pouvoir exécuter un scope dans un span. Le code async doit instrumenter la `Future` elle-même et ne pas maintenir un enter guard à travers `.await`.
|
||||
|
||||
Les settings permettent au minimum `Off` et `NewAndClose`; `NEW | CLOSE` fournit des repères de début/fin et, lorsque les timestamps sont actifs, le close fournit `busy`/`idle`. Cette capacité sert au diagnostic rapide de latence/blocage et n'est pas présentée comme un benchmark de précision absolue.
|
||||
|
||||
### Contenu sensible
|
||||
|
||||
`ksp-logging-lib` n'essaie pas de détecter ou redacter automatiquement les données sensibles. La crate appelante est responsable du contenu qu'elle choisit de logger.
|
||||
|
||||
## Fichiers ajoutés
|
||||
|
||||
- `deltas/0.1.2/pre.001-fix.001.md`
|
||||
|
||||
## Fichiers modifiés
|
||||
|
||||
- `docs/plans/004-V0_1_2_LOGGING_FOUNDATION_PLAN.md`
|
||||
- `docs/rules/RULES_DEPENDENCIES.md`
|
||||
- `docs/architecture/003-COMPONENT_CONTRACTS.md`
|
||||
- `docs/architecture/005-DEPENDENCY_GRAPH.md`
|
||||
|
||||
## Fichiers supprimés
|
||||
|
||||
Aucun.
|
||||
|
||||
## Version Cargo
|
||||
|
||||
Aucune modification de `Cargo.toml`.
|
||||
|
||||
Ce fix est limité à la documentation et respecte `VER-ID-008` : `workspace.package.version` reste donc :
|
||||
|
||||
```text
|
||||
0.1.2-pre.1
|
||||
```
|
||||
|
||||
L'identifiant de livraison est :
|
||||
|
||||
```text
|
||||
0.1.2-pre.001-fix.001
|
||||
```
|
||||
|
||||
## Validations exécutées
|
||||
|
||||
- vérification du delta `pre.001` fourni et des règles de version/delta/archive de la base `0.1.1` ;
|
||||
- vérification de la documentation officielle `tracing` indiquant que le subscriber global ne peut être installé qu'une fois ;
|
||||
- vérification de `tracing-subscriber::reload` pour le remplacement runtime d'une Layer/Filter ;
|
||||
- vérification de l'avertissement officiel contre `Span::enter()` conservé à travers `.await` ;
|
||||
- vérification de l'instrumentation de `Future` fournie par `tracing::Instrument` ;
|
||||
- vérification de `FmtSpan::NEW | FmtSpan::CLOSE` et des champs `busy`/`idle` au close lorsque les timestamps sont actifs ;
|
||||
- vérification du writer non bloquant, de `WorkerGuard` et `ErrorCounter` dans `tracing-appender 0.2.5` ;
|
||||
- contrôle des headers `file:` / `version:` des fichiers livrés ;
|
||||
- contrôle de l'absence de modification Cargo dans ce fix documentaire ;
|
||||
- contrôle du contenu de l'archive selon `VER-ARCHIVE-004`.
|
||||
|
||||
## Validations non exécutées
|
||||
|
||||
Aucune validation Cargo n'est applicable à ce correctif documentaire et aucun code fonctionnel Logging n'existe encore dans la livraison.
|
||||
|
||||
Les commandes suivantes restent à exécuter dès que les tranches de développement les rendent applicables :
|
||||
|
||||
```bash
|
||||
cargo fmt --all
|
||||
cargo check --workspace
|
||||
cargo test --workspace
|
||||
cargo clippy --workspace --all-targets
|
||||
cargo tree -p ksp-logging-lib
|
||||
cargo tree -p ksp-logging-lib -d
|
||||
cargo tree -p ksp-logging-lib -e features
|
||||
```
|
||||
|
||||
## Questions ouvertes
|
||||
|
||||
Aucune question architecturale bloquante.
|
||||
|
||||
Restent à trancher par implémentation/tests dans les prereleases suivantes :
|
||||
|
||||
- mécanisme exact des macros spans/events préservant le callsite sans fuite de types `tracing` ;
|
||||
- abstraction KSP exacte pour instrumenter les futures async ;
|
||||
- composition reloadable interne la moins coûteuse ;
|
||||
- API exacte d'observation des dropped lines.
|
||||
|
||||
Après validation de ce fix, la prochaine tranche reste `0.1.2-pre.002`.
|
||||
375
deltas/0.1.2/pre.001.md
Normal file
375
deltas/0.1.2/pre.001.md
Normal file
@@ -0,0 +1,375 @@
|
||||
<!-- file: deltas/0.1.2/pre.001.md -->
|
||||
<!-- version: 1 -->
|
||||
|
||||
# Delta 0.1.2-pre.001
|
||||
|
||||
## Base requise
|
||||
|
||||
Release stable/taguée attendue :
|
||||
|
||||
```text
|
||||
v0.1.1
|
||||
```
|
||||
|
||||
L'archive Gitea fournie `khadhroony-solana-project-v0.1.1.zip` contient bien :
|
||||
|
||||
- `workspace.package.version = "0.1.1"` ;
|
||||
- le delta final `deltas/0.1.1/rel.001.md` ;
|
||||
- le prompt final `prompts/002-V0_1_2_START_PROMPT.md` ;
|
||||
- la surface Core stabilisée attendue.
|
||||
|
||||
Dans le workflow KSP, cette archive provient directement du tag correspondant et constitue la base stable suffisante pour ouvrir `0.1.2`.
|
||||
|
||||
## Objectif
|
||||
|
||||
Ouvrir `0.1.2` par la prerelease obligatoire de brainstorming, audit et planification, sans développement fonctionnel Logging.
|
||||
|
||||
Cette tranche :
|
||||
|
||||
- inventorie l'état réel du workspace et confirme l'absence actuelle de `ksp-logging-lib` ;
|
||||
- audite la stack `tracing` officielle actuelle ;
|
||||
- fixe la frontière façade/instrumentation/runtime subscriber ;
|
||||
- retient les macros KSP pour préserver les callsites ;
|
||||
- définit les niveaux et settings runtime candidats ;
|
||||
- borne la sémantique de `target`, `domain` et `component` ;
|
||||
- retient le filtering global + target-prefix via `Targets` ;
|
||||
- retient console + fichier optionnel ;
|
||||
- retient un writer fichier non bloquant non-lossy avec guard possédé explicitement ;
|
||||
- définit le lifecycle d'initialisation/réinitialisation ;
|
||||
- fixe la stratégie d'erreurs Core et de protection des secrets ;
|
||||
- dimensionne `pre.002` à `pre.006` ;
|
||||
- confirme les hors-scope.
|
||||
|
||||
Le détail est consigné dans `docs/plans/004-V0_1_2_LOGGING_FOUNDATION_PLAN.md`.
|
||||
|
||||
## Version Cargo
|
||||
|
||||
`workspace.package.version` passe de :
|
||||
|
||||
```text
|
||||
0.1.1
|
||||
```
|
||||
|
||||
à :
|
||||
|
||||
```text
|
||||
0.1.2-pre.1
|
||||
```
|
||||
|
||||
L'identifiant Cargo respecte SemVer sans zéro initial ; l'identifiant de livraison reste `0.1.2-pre.001`.
|
||||
|
||||
Le header de `Cargo.toml` passe de version 25 à 26.
|
||||
|
||||
Aucune dépendance `tracing*` n'est ajoutée par cette tranche de planification : elles seront introduites uniquement lorsque le code/tests de `ksp-logging-lib` les consommeront réellement.
|
||||
|
||||
## Fichiers ajoutés
|
||||
|
||||
- `docs/plans/004-V0_1_2_LOGGING_FOUNDATION_PLAN.md`
|
||||
- `deltas/0.1.2/pre.001.md`
|
||||
|
||||
## Fichiers modifiés
|
||||
|
||||
- `Cargo.toml`
|
||||
- `ROADMAP.md`
|
||||
- `docs/plans/000-README.md`
|
||||
|
||||
## Fichiers supprimés
|
||||
|
||||
Aucun.
|
||||
|
||||
## Inventaire du workspace
|
||||
|
||||
État de la base stable auditée :
|
||||
|
||||
```text
|
||||
workspace members
|
||||
└── crates/ksp-core-lib
|
||||
```
|
||||
|
||||
`ksp-logging-lib` n'existe pas encore.
|
||||
|
||||
Core fournit déjà les contrats nécessaires à Logging :
|
||||
|
||||
```text
|
||||
ksp_core_lib::ErrorCode
|
||||
ksp_core_lib::ErrorContext
|
||||
ksp_core_lib::Error
|
||||
ksp_core_lib::Result<T>
|
||||
ksp_core_lib::Pubkey
|
||||
```
|
||||
|
||||
ainsi que les Program IDs fondamentaux et leur registre descriptif.
|
||||
|
||||
La relation retenue reste unidirectionnelle :
|
||||
|
||||
```text
|
||||
ksp-logging-lib -> ksp-core-lib
|
||||
ksp-core-lib -X-> ksp-logging-lib
|
||||
```
|
||||
|
||||
## Audit externe tracing
|
||||
|
||||
Audit effectué le 2026-08-14 sur les publications/docs officielles Tokio `tracing`, docs.rs/crates.io et les manifests publiés.
|
||||
|
||||
Versions observées :
|
||||
|
||||
```text
|
||||
tracing 0.1.44 rustc 1.65+
|
||||
tracing-subscriber 0.3.23 rustc 1.65+
|
||||
tracing-appender 0.2.5 rustc 1.63+
|
||||
```
|
||||
|
||||
Contraintes candidates à revérifier au moment de l'ajout effectif :
|
||||
|
||||
```toml
|
||||
tracing = { version = "^0.1", default-features = false, features = ["std"] }
|
||||
tracing-subscriber = { version = "^0.3", default-features = false, features = ["fmt"] }
|
||||
tracing-appender = { version = "^0.2", default-features = false }
|
||||
```
|
||||
|
||||
Décisions de features :
|
||||
|
||||
- pas de `tracing-attributes`/`attributes` ;
|
||||
- pas de `ansi` ;
|
||||
- pas de `tracing-log` ;
|
||||
- pas d'`env-filter` ;
|
||||
- pas de JSON/Serde ;
|
||||
- pas de chrono/time formatter via `tracing-subscriber` ;
|
||||
- pas de `parking_lot` appender ;
|
||||
- `tracing-appender` tire lui-même `tracing-subscriber` avec `default-features = false`, `fmt` et `std` ainsi que les dépendances internes nécessaires à son fonctionnement.
|
||||
|
||||
Aucune dépendance n'est ajoutée uniquement parce qu'elle figure dans l'architecture candidate.
|
||||
|
||||
## Décisions de planification
|
||||
|
||||
### Façade et callsites
|
||||
|
||||
La surface d'émission KSP sera :
|
||||
|
||||
```text
|
||||
ksp_logging_lib::error!
|
||||
ksp_logging_lib::warn!
|
||||
ksp_logging_lib::info!
|
||||
ksp_logging_lib::debug!
|
||||
ksp_logging_lib::trace!
|
||||
```
|
||||
|
||||
Les événements ne seront pas émis par de simples fonctions wrappers qui déplaceraient les métadonnées source.
|
||||
|
||||
L'implémentation exacte des macros doit réussir un test d'intégration prouvant que file/module/line et target implicite restent ceux du consommateur.
|
||||
|
||||
### Champs structurés
|
||||
|
||||
- `target` : métadonnée native de routage/filtering, naturelle au callsite ou explicitement overridable ;
|
||||
- `domain` : champ structuré KSP optionnel ;
|
||||
- `component` : champ structuré KSP optionnel ;
|
||||
- autres champs : ouverts selon besoin, sans taxonomie fermée.
|
||||
|
||||
`domain` et `component` ne deviennent pas des filtres dans `0.1.2`.
|
||||
|
||||
### Filtering
|
||||
|
||||
Première surface :
|
||||
|
||||
```text
|
||||
default filter level
|
||||
+ zero or more target-prefix overrides
|
||||
```
|
||||
|
||||
`tracing_subscriber::filter::Targets` est retenu comme mécanisme initial.
|
||||
|
||||
`EnvFilter`, `RUST_LOG`, field-based filtering et hot reload restent hors scope.
|
||||
|
||||
### Settings runtime
|
||||
|
||||
Surface conceptuelle retenue :
|
||||
|
||||
```text
|
||||
LogFilterLevel
|
||||
TargetFilter
|
||||
ConsoleOutput
|
||||
ConsoleSettings
|
||||
FileRotation
|
||||
FileSettings
|
||||
LoggingSettings
|
||||
LoggingGuard
|
||||
initialize(...)
|
||||
```
|
||||
|
||||
Les settings ne lisent ni fichier, ni environnement, ni profil Config et ne contiennent aucun secret.
|
||||
|
||||
### Console
|
||||
|
||||
Sortie console avec choix explicite stdout/stderr.
|
||||
|
||||
Le formatter initial reste humain, sans JSON ni ANSI obligatoire.
|
||||
|
||||
### Fichier
|
||||
|
||||
Sortie fichier optionnelle retenue avec :
|
||||
|
||||
```text
|
||||
Never | Hourly | Daily
|
||||
```
|
||||
|
||||
Le builder fallible du `RollingFileAppender` doit être utilisé afin de remonter les erreurs au lieu de paniquer.
|
||||
|
||||
Le writer fichier utilise `NonBlockingBuilder` en mode :
|
||||
|
||||
```text
|
||||
lossy(false)
|
||||
```
|
||||
|
||||
La saturation applique donc de la backpressure plutôt que de supprimer silencieusement des logs.
|
||||
|
||||
### Lifecycle
|
||||
|
||||
`LoggingGuard` possède le ou les `WorkerGuard` nécessaires au backend non bloquant.
|
||||
|
||||
L'appelant conserve le guard jusqu'à la fin ordonnée du processus.
|
||||
|
||||
Le lifecycle global est volontairement :
|
||||
|
||||
```text
|
||||
uninitialized -> initialized -> process shutdown
|
||||
```
|
||||
|
||||
Une initialisation répétée échoue avec une erreur KSP ; elle ne remplace pas silencieusement un subscriber existant et ne panique pas.
|
||||
|
||||
### Erreurs
|
||||
|
||||
Les erreurs Logging utilisent le contrat Core et restent dans le domaine :
|
||||
|
||||
```text
|
||||
logging
|
||||
```
|
||||
|
||||
Codes conceptuels initiaux :
|
||||
|
||||
```text
|
||||
logging.invalid_settings
|
||||
logging.already_initialized
|
||||
logging.file_output_initialization_failed
|
||||
```
|
||||
|
||||
Les causes externes utiles sont conservées via `Error::with_source(...)` lorsque possible.
|
||||
|
||||
### Secrets
|
||||
|
||||
Sont explicitement interdits dans les logs : clés privées, seeds/mnemonics, passwords/passphrases/PIN, tokens API/bearer/session, cookies/auth headers, secrets de chiffrement/signature, credentials de connexion et futurs `*_SECRET_*`.
|
||||
|
||||
Aucun helper de redaction universel n'est introduit : le caller doit omettre ou redacter explicitement la valeur avant émission.
|
||||
|
||||
Les settings Logging ne contiennent eux-mêmes aucun secret.
|
||||
|
||||
### Surface différée
|
||||
|
||||
Ne pas ajouter dans `0.1.2` sans nouveau besoin validé :
|
||||
|
||||
- spans KSP/`#[instrument]` ;
|
||||
- OpenTelemetry ;
|
||||
- JSON ;
|
||||
- ANSI ;
|
||||
- compatibilité `log` ;
|
||||
- `EnvFilter` ;
|
||||
- reload de filtre ;
|
||||
- filtering par fields/domain ;
|
||||
- rotation minutely/weekly/by-size ;
|
||||
- compression/rétention complexe/latest symlink ;
|
||||
- routes multiples avancées.
|
||||
|
||||
## Référence historique bot3
|
||||
|
||||
L'ancien `ks-logging` de l'archive bot3 fournie a été relu comme référence historique uniquement.
|
||||
|
||||
Éléments conservés comme leçons utiles :
|
||||
|
||||
- objet de lifecycle possédant les `WorkerGuard` ;
|
||||
- console + fichier ;
|
||||
- rotation ;
|
||||
- filtering par targets.
|
||||
|
||||
Éléments non migrés :
|
||||
|
||||
- dépendance Logging -> Config ;
|
||||
- document/schema JSON propre à Logging ;
|
||||
- Serde/JSON pour la configuration ;
|
||||
- routes/formats multiples non nécessaires à la première surface KSP.
|
||||
|
||||
## Prereleases prévues
|
||||
|
||||
```text
|
||||
pre.001 audit + brainstorming + plan
|
||||
pre.002 crate + settings + macros/façade
|
||||
pre.003 subscriber + console + filtering + callsite final
|
||||
pre.004 fichier + non-blocking + lifecycle
|
||||
pre.005 intégration + tests + audits
|
||||
pre.006 validation finale + docs/cleanup + prompt 0.1.3
|
||||
```
|
||||
|
||||
Le découpage reste souple ; une tranche trop large sera scindée plutôt que surchargée.
|
||||
|
||||
## Hors scope confirmé
|
||||
|
||||
- Config/documents/profils ;
|
||||
- Tauri ;
|
||||
- Wallet/signing ;
|
||||
- RPC/WS/providers ;
|
||||
- Program decoding/execution ;
|
||||
- Store/PostgreSQL ;
|
||||
- Materializer ;
|
||||
- workers/jobs/pipelines ;
|
||||
- scenarios ;
|
||||
- trading/ML ;
|
||||
- observabilité distribuée/OpenTelemetry.
|
||||
|
||||
## Validations exécutées
|
||||
|
||||
Dans l'environnement de préparation de ce delta :
|
||||
|
||||
- lecture/audit de l'archive complète `0.1.1` fournie ;
|
||||
- vérification statique de `workspace.package.version = "0.1.1"` ;
|
||||
- vérification de la présence du delta `0.1.1/rel.001` et du prompt final `0.1.2` ;
|
||||
- inventaire des membres workspace et confirmation de l'absence de `ksp-logging-lib` ;
|
||||
- lecture des règles, plans, indexes et documents d'architecture demandés par le prompt ;
|
||||
- lecture de `ksp-core-lib` et de son contrat Error/Result ;
|
||||
- audit de l'ancien `ks-logging` bot3 fourni comme référence historique, sans le traiter comme source de vérité KSP ;
|
||||
- vérification des versions/features/MSRV actuels de `tracing`, `tracing-subscriber` et `tracing-appender` depuis leurs sources de publication officielles ;
|
||||
- audit du manifest publié de `tracing-appender` pour ses dépendances/features ;
|
||||
- audit de `Targets`, `EnvFilter`, du non-blocking, du mode lossy/backpressure, de `WorkerGuard`, du builder fallible et de la rotation ;
|
||||
- parsing TOML statique du manifest modifié ;
|
||||
- contrôle statique des headers `file:` / `version:` des fichiers ajoutés/modifiés ;
|
||||
- contrôle statique des liens Markdown locaux après modification ;
|
||||
- contrôle du contenu de l'archive delta selon `VER-ARCHIVE-004`.
|
||||
|
||||
## Validations non exécutées
|
||||
|
||||
L'environnement de préparation ne contient ni `cargo` ni `rustc`.
|
||||
|
||||
Les commandes suivantes n'ont donc pas pu être exécutées ici :
|
||||
|
||||
```bash
|
||||
cargo fmt --all
|
||||
cargo check --workspace
|
||||
cargo test --workspace
|
||||
cargo clippy --workspace --all-targets
|
||||
cargo tree -p ksp-logging-lib
|
||||
cargo tree -p ksp-logging-lib -d
|
||||
cargo tree -p ksp-logging-lib -e features
|
||||
```
|
||||
|
||||
Les trois commandes `cargo tree -p ksp-logging-lib` ne sont de toute façon applicables qu'après création effective de la crate.
|
||||
|
||||
Aucun succès Cargo n'est déclaré par ce delta.
|
||||
|
||||
## Questions ouvertes
|
||||
|
||||
Aucune question architecturale bloquante ne justifie de poursuivre le développement dans `pre.001`.
|
||||
|
||||
À confirmer par tests dans les tranches suivantes :
|
||||
|
||||
- mécanisme exact de macro KSP préservant le callsite avec la plus petite surface ;
|
||||
- format visuel exact des lignes humaines sans le figer comme protocole ;
|
||||
- nécessité future de capacités volontairement différées comme rétention, ANSI, JSON, `tracing-log`, `EnvFilter`, spans ou reload.
|
||||
|
||||
La prochaine tranche après validation de ce plan est `0.1.2-pre.002`.
|
||||
125
deltas/0.1.2/pre.002-fix.001.md
Normal file
125
deltas/0.1.2/pre.002-fix.001.md
Normal file
@@ -0,0 +1,125 @@
|
||||
<!-- file: deltas/0.1.2/pre.002-fix.001.md -->
|
||||
<!-- version: 1 -->
|
||||
|
||||
# Delta 0.1.2-pre.002-fix.001
|
||||
|
||||
## Base requise
|
||||
|
||||
Livraison précédente :
|
||||
|
||||
```text
|
||||
0.1.2-pre.002
|
||||
```
|
||||
|
||||
Ce correctif traite uniquement les résultats de validation remontés après `pre.002`. Il ne modifie pas le périmètre fonctionnel de la prerelease et n'ouvre pas `pre.003`.
|
||||
|
||||
## Résultats de validation à corriger
|
||||
|
||||
Les commandes exécutées sur le workspace de développement ont montré :
|
||||
|
||||
- `cargo fmt --all` : exécuté sans erreur ;
|
||||
- `cargo check --workspace` : réussi ;
|
||||
- `cargo clippy --workspace --all-targets` : terminé avec quatre catégories de warnings à nettoyer dans Logging/tests ;
|
||||
- `cargo test --workspace` : tous les tests Core et les tests unitaires Logging réussissent, mais `async_instrumentation_enters_and_exits_span_during_poll` échoue avec `enters = 2` au lieu de l'attente `1`.
|
||||
|
||||
## Cause du test async
|
||||
|
||||
Le test `pre.002` supposait qu'une future instrumentée n'entrait dans son span que pendant son unique `poll`.
|
||||
|
||||
Le contrat de `tracing::Instrument` est plus précis : la future instrumentée entre dans le span lors de chaque `poll` **et lors de son `Drop`**. Pour `std::future::ready(42_u32)`, le test observe donc :
|
||||
|
||||
```text
|
||||
poll -> enter + exit
|
||||
Drop -> enter + exit
|
||||
```
|
||||
|
||||
Le compteur final `2` est donc conforme au comportement de `tracing`; c'est l'attente du test qui était incorrecte.
|
||||
|
||||
Le test corrigé vérifie séparément :
|
||||
|
||||
1. une paire `enter` / `exit` immédiatement après le `poll` ;
|
||||
2. une deuxième paire après destruction explicite de la future instrumentée ;
|
||||
3. l'équilibre final entre le nombre d'entrées et de sorties.
|
||||
|
||||
Le plan actif documente désormais explicitement cette sémantique afin qu'un futur test async ne réintroduise pas l'hypothèse erronée d'une seule paire `enter` / `exit` sur toute la durée de vie d'une future.
|
||||
|
||||
## Nettoyage Clippy
|
||||
|
||||
### `collapsible_if`
|
||||
|
||||
La validation du préfixe de fichier utilise désormais un `if let` avec condition chaînée compatible Rust 2024 au lieu de deux `if` imbriqués.
|
||||
|
||||
### `double_must_use`
|
||||
|
||||
L'attribut `#[must_use]` explicite de `ksp_logging_lib::instrument(...)` est supprimé : la fonction retourne déjà un type `Future`, lui-même marqué `must_use` par son contrat standard.
|
||||
|
||||
## Documentation des tests d'intégration
|
||||
|
||||
Les crates de tests d'intégration :
|
||||
|
||||
```text
|
||||
crates/ksp-logging-lib/tests/callsite.rs
|
||||
crates/ksp-logging-lib/tests/public_api.rs
|
||||
```
|
||||
|
||||
reçoivent chacune une documentation crate-root `//! ...` afin de satisfaire `missing_docs = "warn"` lorsque les tests sont compilés comme crates séparées.
|
||||
|
||||
## Version Cargo
|
||||
|
||||
La version reste :
|
||||
|
||||
```text
|
||||
0.1.2-pre.2
|
||||
```
|
||||
|
||||
Aucune dépendance et aucun manifest ne sont modifiés.
|
||||
|
||||
L'identifiant de livraison de ce correctif est :
|
||||
|
||||
```text
|
||||
0.1.2-pre.002-fix.001
|
||||
```
|
||||
|
||||
## Fichiers modifiés
|
||||
|
||||
- `crates/ksp-logging-lib/src/settings.rs`
|
||||
- `crates/ksp-logging-lib/src/span.rs`
|
||||
- `crates/ksp-logging-lib/tests/callsite.rs`
|
||||
- `crates/ksp-logging-lib/tests/public_api.rs`
|
||||
- `docs/plans/004-V0_1_2_LOGGING_FOUNDATION_PLAN.md`
|
||||
|
||||
## Fichier ajouté
|
||||
|
||||
- `deltas/0.1.2/pre.002-fix.001.md`
|
||||
|
||||
## Validations statiques exécutées lors de la préparation
|
||||
|
||||
- contrôle des headers `file:` / `version:` des fichiers du correctif ;
|
||||
- contrôle que `Cargo.toml` n'est pas inclus dans le delta ;
|
||||
- contrôle que la version Cargo de la base reste `0.1.2-pre.2` ;
|
||||
- contrôle de l'absence de nouvelle dépendance ;
|
||||
- contrôle que le correctif ne contient aucun ajout `unwrap`, `expect`, `panic` ou opérateur `?` dans le code production modifié ;
|
||||
- contrôle que l'archive contient uniquement les cinq fichiers modifiés et le nouveau delta.
|
||||
|
||||
## Validations à réexécuter sur le workspace
|
||||
|
||||
```bash
|
||||
cargo fmt --all
|
||||
cargo check --workspace
|
||||
cargo test --workspace
|
||||
cargo clippy --workspace --all-targets
|
||||
```
|
||||
|
||||
Puis, pour compléter les validations prévues pour `pre.002` si elles ne l'ont pas encore été :
|
||||
|
||||
```bash
|
||||
cargo tree -p ksp-logging-lib
|
||||
cargo tree -p ksp-logging-lib -d
|
||||
cargo tree -p ksp-logging-lib -e features
|
||||
```
|
||||
|
||||
Aucune validation Cargo non exécutable dans l'environnement de préparation n'est déclarée réussie par ce delta.
|
||||
|
||||
## Suite
|
||||
|
||||
Une fois ce correctif validé, `0.1.2-pre.002` peut être considérée propre et la session peut passer à `0.1.2-pre.003` pour le subscriber runtime, le takeover, le filtering, la console non bloquante et la fondation du hot reload.
|
||||
270
deltas/0.1.2/pre.002.md
Normal file
270
deltas/0.1.2/pre.002.md
Normal file
@@ -0,0 +1,270 @@
|
||||
<!-- file: deltas/0.1.2/pre.002.md -->
|
||||
<!-- version: 1 -->
|
||||
|
||||
# Delta 0.1.2-pre.002
|
||||
|
||||
## Base requise
|
||||
|
||||
Livraison précédente validée :
|
||||
|
||||
```text
|
||||
0.1.2-pre.001-fix.001
|
||||
```
|
||||
|
||||
Cette tranche applique le plan corrigé de `pre.001` et ouvre le développement fonctionnel de `ksp-logging-lib` sans encore installer le subscriber runtime.
|
||||
|
||||
## Objectif
|
||||
|
||||
Créer la première surface fonctionnelle de Logging :
|
||||
|
||||
- créer `crates/ksp-logging-lib` et l'ajouter au workspace ;
|
||||
- dépendre de `ksp-core-lib` pour le contrat commun d'erreur ;
|
||||
- ajouter uniquement `tracing` parmi les dépendances de la stack de logging ;
|
||||
- définir les settings runtime propres à Logging, indépendants de Config ;
|
||||
- exposer les cinq niveaux d'événements par macros KSP avec `target:` explicite ;
|
||||
- exposer les cinq niveaux de spans KSP ;
|
||||
- fournir une abstraction `Span` KSP pour les scopes synchrones ;
|
||||
- fournir `instrument(span, future)` pour l'instrumentation async sans demander au consumer d'utiliser `tracing::Instrument` ;
|
||||
- vérifier par tests la préservation du callsite événement/span et le cycle enter/exit d'une future instrumentée.
|
||||
|
||||
Le subscriber global, le takeover effectif, le filtering runtime, les sorties console/fichier non bloquantes, les guards et le hot reload restent réservés aux prereleases suivantes conformément au plan.
|
||||
|
||||
## Version Cargo
|
||||
|
||||
`workspace.package.version` passe de :
|
||||
|
||||
```text
|
||||
0.1.2-pre.1
|
||||
```
|
||||
|
||||
à :
|
||||
|
||||
```text
|
||||
0.1.2-pre.2
|
||||
```
|
||||
|
||||
L'identifiant de livraison reste :
|
||||
|
||||
```text
|
||||
0.1.2-pre.002
|
||||
```
|
||||
|
||||
Le header du `Cargo.toml` racine passe de version 26 à 27.
|
||||
|
||||
## Dépendances
|
||||
|
||||
`tracing` est ajouté à la racine sous `[workspace.dependencies]` :
|
||||
|
||||
```toml
|
||||
tracing = { version = "^0.1", default-features = false, features = ["std"] }
|
||||
```
|
||||
|
||||
`ksp-logging-lib` le consomme avec :
|
||||
|
||||
```toml
|
||||
tracing.workspace = true
|
||||
```
|
||||
|
||||
L'audit de la publication actuelle retient `tracing 0.1.44`. Les default features ne sont pas activées : `attributes` n'est pas nécessaire à cette tranche, car KSP n'utilise pas `#[instrument]`. La feature `std` suffit à la façade retenue et aux tests de subscriber local.
|
||||
|
||||
`tracing-subscriber` et `tracing-appender` ne sont pas ajoutés dans `pre.002` : ils ne sont pas encore consommés par du code runtime.
|
||||
|
||||
## Settings runtime
|
||||
|
||||
La surface publique introduit :
|
||||
|
||||
```text
|
||||
LogFilterLevel
|
||||
TargetFilter
|
||||
SpanEvents
|
||||
ConsoleOutput
|
||||
ConsoleSettings
|
||||
FileRotation
|
||||
FileSettings
|
||||
LoggingSettings
|
||||
```
|
||||
|
||||
Ces types :
|
||||
|
||||
- appartiennent à `ksp-logging-lib` ;
|
||||
- ne lisent aucun document Config ;
|
||||
- ne consultent aucune variable d'environnement ;
|
||||
- ne dépendent pas de `ksp-config-lib` ;
|
||||
- utilisent des champs privés et une construction/getters explicites.
|
||||
|
||||
Une configuration sans console ni fichier est valide et représente un logging KSP désactivé. Les validations actuelles rejettent uniquement les ambiguïtés propres au contrat déjà fixé, notamment les préfixes de target vides/externes et un préfixe de fichier vide.
|
||||
|
||||
## Façade événements
|
||||
|
||||
Les macros crate-root suivantes sont introduites :
|
||||
|
||||
```text
|
||||
ksp_logging_lib::error!
|
||||
ksp_logging_lib::warn!
|
||||
ksp_logging_lib::info!
|
||||
ksp_logging_lib::debug!
|
||||
ksp_logging_lib::trace!
|
||||
```
|
||||
|
||||
Leur syntaxe KSP exige `target:` explicitement. Elles délèguent directement aux macros `tracing` au point d'expansion afin que les métadonnées `file`, `module_path` et `line` correspondent au callsite consumer et non à une fonction wrapper dans Logging.
|
||||
|
||||
Un bridge `tracing` public mais caché de la documentation est nécessaire à l'expansion des macros depuis les crates consommatrices. Il est réservé à l'implémentation des macros ; `DEP-LOG-009` interdit son usage direct comme API consumer.
|
||||
|
||||
## Spans synchrones et async
|
||||
|
||||
Les macros suivantes sont introduites :
|
||||
|
||||
```text
|
||||
ksp_logging_lib::error_span!
|
||||
ksp_logging_lib::warn_span!
|
||||
ksp_logging_lib::info_span!
|
||||
ksp_logging_lib::debug_span!
|
||||
ksp_logging_lib::trace_span!
|
||||
```
|
||||
|
||||
Elles exigent également `target:` explicitement et retournent `ksp_logging_lib::Span`.
|
||||
|
||||
Pour le synchrone :
|
||||
|
||||
```text
|
||||
Span::in_scope(operation)
|
||||
```
|
||||
|
||||
entre dans le span pendant le scope puis en sort à la fin du scope.
|
||||
|
||||
Pour l'async :
|
||||
|
||||
```text
|
||||
ksp_logging_lib::instrument(span, future)
|
||||
```
|
||||
|
||||
retourne une `Future` opaque instrumentée. Le span est entré pendant chaque poll de la future et quitté lorsque ce poll rend la main ; aucun enter guard KSP n'est destiné à être conservé à travers `.await`.
|
||||
|
||||
Cette surface prépare les diagnostics de durée `NEW/CLOSE`, `busy` et `idle` qui seront activés par le formatter/subscriber dans les tranches runtime suivantes.
|
||||
|
||||
## Erreurs
|
||||
|
||||
`ksp-logging-lib` utilise :
|
||||
|
||||
```text
|
||||
ksp_core_lib::Result<T>
|
||||
ksp_core_lib::Error
|
||||
ksp_core_lib::ErrorCode
|
||||
```
|
||||
|
||||
Le premier code propre à Logging est :
|
||||
|
||||
```text
|
||||
logging.invalid_settings
|
||||
```
|
||||
|
||||
Core ne reçoit aucune connaissance de Logging et aucune dépendance inverse n'est introduite.
|
||||
|
||||
## Tests ajoutés
|
||||
|
||||
### Unitaires
|
||||
|
||||
- distinction des niveaux ;
|
||||
- construction/getters des target filters ;
|
||||
- console stdout/stderr ;
|
||||
- settings fichier/rotation ;
|
||||
- conservation des settings explicites ;
|
||||
- logging désactivé sans sink ;
|
||||
- rejet des target prefixes vides ou externes ;
|
||||
- rejet du préfixe fichier vide ;
|
||||
- scope synchrone d'un span ;
|
||||
- propagation du résultat d'une future instrumentée.
|
||||
|
||||
### Intégration
|
||||
|
||||
- surface publique des settings sans Config ;
|
||||
- disponibilité des cinq macros événements ;
|
||||
- disponibilité des cinq macros spans ;
|
||||
- usage sync et async sans import consumer de `tracing::Span` ou `tracing::Instrument` ;
|
||||
- préservation de `target`, `file`, `module_path` et `line` au callsite événement ;
|
||||
- préservation de `target`, `file`, `module_path` et `line` au callsite span ;
|
||||
- entrée puis sortie du span lors du poll d'une future instrumentée.
|
||||
|
||||
## Règles ajustées
|
||||
|
||||
`DEP-LOG-009` documente explicitement que le bridge `tracing` caché nécessaire aux macros est un détail d'implémentation de `ksp-logging-lib`, jamais une surface utilisable par une crate consommatrice.
|
||||
|
||||
Le plan `004-V0_1_2_LOGGING_FOUNDATION_PLAN.md` est synchronisé avec l'API effectivement retenue dans `pre.002` et avec la validité d'un logging entièrement désactivé.
|
||||
|
||||
## Fichiers ajoutés
|
||||
|
||||
- `crates/ksp-logging-lib/Cargo.toml`
|
||||
- `crates/ksp-logging-lib/src/error.rs`
|
||||
- `crates/ksp-logging-lib/src/lib.rs`
|
||||
- `crates/ksp-logging-lib/src/macros.rs`
|
||||
- `crates/ksp-logging-lib/src/settings.rs`
|
||||
- `crates/ksp-logging-lib/src/span.rs`
|
||||
- `crates/ksp-logging-lib/unit_tests/settings.rs`
|
||||
- `crates/ksp-logging-lib/unit_tests/span.rs`
|
||||
- `crates/ksp-logging-lib/tests/callsite.rs`
|
||||
- `crates/ksp-logging-lib/tests/public_api.rs`
|
||||
- `deltas/0.1.2/pre.002.md`
|
||||
|
||||
## Fichiers modifiés
|
||||
|
||||
- `Cargo.toml`
|
||||
- `docs/plans/004-V0_1_2_LOGGING_FOUNDATION_PLAN.md`
|
||||
- `docs/rules/RULES_DEPENDENCIES.md`
|
||||
|
||||
## Fichiers supprimés
|
||||
|
||||
Aucun.
|
||||
|
||||
## Validations exécutées
|
||||
|
||||
Validations statiques exécutées dans l'environnement de préparation :
|
||||
|
||||
- parsing TOML des manifests ;
|
||||
- contrôle des headers `file:` / `version:` des fichiers livrés ;
|
||||
- contrôle de l'absence de `Cargo.lock` dans le delta ;
|
||||
- contrôle de l'absence de `tracing-subscriber` et `tracing-appender` dans les manifests ;
|
||||
- contrôle de la centralisation de `tracing` sous `[workspace.dependencies]` ;
|
||||
- contrôle que les usages directs de `tracing` restent bornés à `ksp-logging-lib` ;
|
||||
- contrôle des patterns Rust interdits par les règles workspace dans le code production ajouté ;
|
||||
- contrôle des liens Markdown locaux du plan modifié ;
|
||||
- contrôle du contenu de l'archive selon `VER-ARCHIVE-004`.
|
||||
|
||||
## Validations non exécutées
|
||||
|
||||
L'environnement de préparation ne fournit pas `cargo`, `rustc` ou `rustfmt`. Les validations suivantes ne sont donc **pas** déclarées réussies :
|
||||
|
||||
```bash
|
||||
cargo fmt --all
|
||||
cargo check --workspace
|
||||
cargo test --workspace
|
||||
cargo clippy --workspace --all-targets
|
||||
cargo tree -p ksp-logging-lib
|
||||
cargo tree -p ksp-logging-lib -d
|
||||
cargo tree -p ksp-logging-lib -e features
|
||||
```
|
||||
|
||||
Elles doivent être exécutées sur le workspace de développement avant validation de la tranche. Toute erreur sera corrigée par le delta suivant conformément au workflow KSP.
|
||||
|
||||
## Décisions prises
|
||||
|
||||
- `tracing` est la seule dépendance de la stack ajoutée en `pre.002` ;
|
||||
- les macros KSP exigent `target:` ;
|
||||
- le callsite est préservé par expansion de macro et testé ;
|
||||
- l'abstraction publique de span est `ksp_logging_lib::Span` ;
|
||||
- le synchrone utilise `Span::in_scope(...)` ;
|
||||
- l'async utilise `instrument(span, future)` ;
|
||||
- une configuration sans sink est valide et représente Logging désactivé ;
|
||||
- aucune initialisation/subscriber global n'est introduit prématurément dans cette tranche.
|
||||
|
||||
## Questions ouvertes
|
||||
|
||||
Aucune question bloquante pour `pre.002`.
|
||||
|
||||
Restent à choisir/tester dans les tranches runtime suivantes :
|
||||
|
||||
- la composition interne reloadable la moins coûteuse ;
|
||||
- l'API exacte d'observation des lignes abandonnées ;
|
||||
- les détails finaux du formatter console/fichier ;
|
||||
- la stratégie de swap des sinks garantissant le maintien de l'ancienne configuration si une reconfiguration échoue.
|
||||
|
||||
Après validation de cette tranche, la prochaine étape est `0.1.2-pre.003` : subscriber, takeover, filtering, console initiale et fondation du hot reload.
|
||||
144
deltas/0.1.2/pre.003-fix.001.md
Normal file
144
deltas/0.1.2/pre.003-fix.001.md
Normal file
@@ -0,0 +1,144 @@
|
||||
<!-- file: deltas/0.1.2/pre.003-fix.001.md -->
|
||||
<!-- version: 1 -->
|
||||
|
||||
# Delta 0.1.2-pre.003-fix.001
|
||||
|
||||
## Base requise
|
||||
|
||||
Livraison précédente :
|
||||
|
||||
```text
|
||||
0.1.2-pre.003
|
||||
```
|
||||
|
||||
La base porte :
|
||||
|
||||
```text
|
||||
workspace.package.version = "0.1.2-pre.3"
|
||||
Cargo.toml header version = 29
|
||||
```
|
||||
|
||||
## Motif du correctif
|
||||
|
||||
Les validations remontées pour `pre.003` sont :
|
||||
|
||||
```text
|
||||
cargo fmt --all OK
|
||||
cargo check --workspace OK
|
||||
cargo clippy --workspace --all-targets OK
|
||||
cargo test --workspace ECHEC
|
||||
```
|
||||
|
||||
Le test d'intégration :
|
||||
|
||||
```text
|
||||
global_runtime_supports_takeover_hot_reload_and_single_initialization
|
||||
```
|
||||
|
||||
panique pendant le premier `reinitialize()` activant la console :
|
||||
|
||||
```text
|
||||
a `Filtered` layer was used, but it had no `FilterId`; was it registered with the subscriber?
|
||||
```
|
||||
|
||||
## Cause
|
||||
|
||||
`pre.003` construisait le sink console sous cette forme conceptuelle :
|
||||
|
||||
```text
|
||||
fmt layer
|
||||
.with_filter(Targets)
|
||||
-> Filtered<fmt, Targets, Registry>
|
||||
```
|
||||
|
||||
Ce `Filtered` était ensuite boxed dans le `Vec<Box<dyn Layer<Registry>>>` placé derrière `tracing_subscriber::reload::Layer`.
|
||||
|
||||
Au démarrage sans sink, le `Vec` initial était vide. Le premier hot reload construisait donc un nouveau `Filtered` après l'installation du subscriber global puis remplaçait le `Vec` via `Handle::reload`. Or un per-layer `Filtered` a besoin que son `FilterId` soit enregistré lors de son attachement au subscriber. La documentation de `tracing-subscriber 0.3.23` indique explicitement que `Handle::reload` ne doit pas être utilisé pour remplacer directement un `Filtered`.
|
||||
|
||||
Le panic n'indique donc pas un défaut du contrat public KSP de hot reload, mais une composition interne incorrecte des layers de `pre.003`.
|
||||
|
||||
## Correction
|
||||
|
||||
Le runtime conserve :
|
||||
|
||||
```text
|
||||
reload::Layer<Vec<Box<dyn Layer<Registry>>>>
|
||||
```
|
||||
|
||||
mais la composition devient :
|
||||
|
||||
```text
|
||||
Vec reloadable
|
||||
├── Targets global takeover filter
|
||||
└── fmt console layer
|
||||
```
|
||||
|
||||
au lieu de :
|
||||
|
||||
```text
|
||||
Vec reloadable
|
||||
└── Filtered<fmt console layer, Targets>
|
||||
```
|
||||
|
||||
`Targets` est utilisé comme layer de filtrage global. Le layer `fmt` n'appelle plus `with_filter`.
|
||||
|
||||
Conséquences :
|
||||
|
||||
- aucun nouveau `Filtered` n'est injecté par `Handle::reload` ;
|
||||
- aucun `FilterId` tardif n'est nécessaire ;
|
||||
- le takeover reste global : les targets externes restent `OFF` ;
|
||||
- les niveaux KSP et overrides par préfixe restent inchangés ;
|
||||
- le `Vec` complet peut toujours être remplacé pour activer/désactiver des sinks à chaud ;
|
||||
- l'API publique `initialize` / `reinitialize` / `LoggingGuard` ne change pas ;
|
||||
- `pre.004` peut toujours ajouter le backend fichier au même runtime reloadable.
|
||||
|
||||
Une configuration sans sink conserve un `Vec` vide, donc le logging reste effectivement désactivé jusqu'à un `reinitialize()` qui ajoute une sortie.
|
||||
|
||||
## Tests
|
||||
|
||||
Le test d'intégration déjà présent qui a révélé la régression reste le test de non-régression principal :
|
||||
|
||||
```text
|
||||
global_runtime_supports_takeover_hot_reload_and_single_initialization
|
||||
```
|
||||
|
||||
Un test unitaire supplémentaire vérifie que la console prépare deux layers distincts : le takeover filter global et le formatter.
|
||||
|
||||
## Version technique
|
||||
|
||||
Ce correctif modifie du Rust. Conformément à la règle KSP de signal technique, la version workspace devient :
|
||||
|
||||
```text
|
||||
workspace.package.version = "0.1.2-pre.3.fix.1"
|
||||
```
|
||||
|
||||
et l'en-tête du `Cargo.toml` racine devient :
|
||||
|
||||
```text
|
||||
# version: 30
|
||||
```
|
||||
|
||||
## Fichiers du delta
|
||||
|
||||
```text
|
||||
Cargo.toml
|
||||
crates/ksp-logging-lib/src/runtime.rs
|
||||
crates/ksp-logging-lib/unit_tests/runtime.rs
|
||||
docs/plans/004-V0_1_2_LOGGING_FOUNDATION_PLAN.md
|
||||
deltas/0.1.2/pre.003-fix.001.md
|
||||
```
|
||||
|
||||
## Validations à exécuter
|
||||
|
||||
```bash
|
||||
cargo fmt --all
|
||||
cargo check --workspace
|
||||
cargo test --workspace
|
||||
cargo clippy --workspace --all-targets
|
||||
```
|
||||
|
||||
Si ces validations sont propres, la tranche suivante reste :
|
||||
|
||||
```text
|
||||
0.1.2-pre.004 — non-blocking console/file + guards + ANSI + reload sinks
|
||||
```
|
||||
305
deltas/0.1.2/pre.003.md
Normal file
305
deltas/0.1.2/pre.003.md
Normal file
@@ -0,0 +1,305 @@
|
||||
<!-- file: deltas/0.1.2/pre.003.md -->
|
||||
<!-- version: 1 -->
|
||||
|
||||
# Delta 0.1.2-pre.003
|
||||
|
||||
## Base requise
|
||||
|
||||
Livraison précédente validée :
|
||||
|
||||
```text
|
||||
0.1.2-pre.002-fix.001
|
||||
```
|
||||
|
||||
La base de développement validée porte :
|
||||
|
||||
```text
|
||||
workspace.package.version = "0.1.2-pre.2.fix.1"
|
||||
Cargo.toml header version = 28
|
||||
```
|
||||
|
||||
Les validations remontées avant l'ouverture de cette tranche sont propres :
|
||||
|
||||
```bash
|
||||
cargo fmt --all
|
||||
cargo check --workspace
|
||||
cargo clippy --workspace --all-targets
|
||||
cargo test --workspace
|
||||
```
|
||||
|
||||
## Objectif
|
||||
|
||||
Introduire le runtime subscriber de Logging sans encore ouvrir le backend fichier/non bloquant :
|
||||
|
||||
- ajouter `tracing-subscriber` avec la feature minimale `fmt` ;
|
||||
- installer une seule fois le subscriber global KSP ;
|
||||
- appliquer le takeover KSP et rendre silencieux les targets externes par défaut ;
|
||||
- mapper `LogFilterLevel` vers `LevelFilter` ;
|
||||
- appliquer un niveau KSP global puis les overrides par préfixe de target ;
|
||||
- introduire une première couche console stdout/stderr ;
|
||||
- intégrer les événements de lifecycle des spans `Off`, `NewAndClose` et `Full` ;
|
||||
- introduire `LoggingGuard`, `initialize()` et `reinitialize()` ;
|
||||
- permettre un démarrage sans sink puis une activation à chaud ;
|
||||
- vérifier le hot reload sans second subscriber global.
|
||||
|
||||
La console reste volontairement synchrone dans cette tranche intermédiaire. `pre.004` la remplacera par un writer `tracing-appender` non bloquant et ajoutera fichier, guards, dropped-line counters et stripping ANSI avant toute stabilisation de `0.1.2`.
|
||||
|
||||
## Version Cargo
|
||||
|
||||
`workspace.package.version` passe de :
|
||||
|
||||
```text
|
||||
0.1.2-pre.2.fix.1
|
||||
```
|
||||
|
||||
à :
|
||||
|
||||
```text
|
||||
0.1.2-pre.3
|
||||
```
|
||||
|
||||
L'identifiant de livraison est :
|
||||
|
||||
```text
|
||||
0.1.2-pre.003
|
||||
```
|
||||
|
||||
Le header du `Cargo.toml` racine passe de version 28 à 29.
|
||||
|
||||
## Dépendance `tracing-subscriber`
|
||||
|
||||
L'audit du 2026-08-14 confirme `tracing-subscriber 0.3.23` dans la génération `^0.3`.
|
||||
|
||||
La dépendance est centralisée sous `[workspace.dependencies]` :
|
||||
|
||||
```toml
|
||||
tracing-subscriber = { version = "^0.3", default-features = false, features = ["fmt"] }
|
||||
```
|
||||
|
||||
`ksp-logging-lib` la consomme avec :
|
||||
|
||||
```toml
|
||||
tracing-subscriber.workspace = true
|
||||
```
|
||||
|
||||
La feature `fmt` fournit le formatter et entraîne les capacités `registry`/`std` nécessaires à la composition retenue. Ne sont pas activés par anticipation :
|
||||
|
||||
- `env-filter` ;
|
||||
- `ansi` ;
|
||||
- `tracing-log` ;
|
||||
- `json` ;
|
||||
- `time` ;
|
||||
- `chrono` ;
|
||||
- `parking_lot`.
|
||||
|
||||
`tracing-appender` reste absent jusqu'à `pre.004`.
|
||||
|
||||
## Takeover et filtering
|
||||
|
||||
Le runtime utilise `tracing_subscriber::filter::Targets`.
|
||||
|
||||
La construction est conceptuellement :
|
||||
|
||||
```text
|
||||
default unmatched targets = OFF
|
||||
ksp-* = LoggingSettings.default_filter
|
||||
target overrides = TargetFilter entries
|
||||
```
|
||||
|
||||
Conséquences :
|
||||
|
||||
- un événement `sqlx`, `hyper`, `rustls` ou autre target externe reste silencieux même à `ERROR` tant qu'aucune couche KSP ne le réémet explicitement ;
|
||||
- les crates KSP utilisent leur nom Cargo comme target ;
|
||||
- `ksp-logging-lib`, `ksp-store-lib`, etc. suivent le niveau global KSP ;
|
||||
- un `TargetFilter` plus spécifique peut relever ou abaisser le niveau d'une crate KSP donnée ;
|
||||
- aucune chaîne `RUST_LOG` ou `EnvFilter` n'est introduite.
|
||||
|
||||
## Console initiale
|
||||
|
||||
`ConsoleSettings::stdout()` et `ConsoleSettings::stderr()` construisent une couche `fmt` avec :
|
||||
|
||||
- target affiché ;
|
||||
- ANSI explicitement désactivé ;
|
||||
- lifecycle de span selon `SpanEvents` ;
|
||||
- filtering KSP `Targets`.
|
||||
|
||||
Cette couche utilise encore directement `std::io::stdout` / `std::io::stderr`. Ce writer synchrone est uniquement la fondation de `pre.003`; il n'est pas le contrat final de la release.
|
||||
|
||||
## Spans runtime
|
||||
|
||||
Le mapping retenu est :
|
||||
|
||||
```text
|
||||
SpanEvents::Off -> FmtSpan::NONE
|
||||
SpanEvents::NewAndClose -> FmtSpan::NEW | FmtSpan::CLOSE
|
||||
SpanEvents::Full -> FmtSpan::FULL
|
||||
```
|
||||
|
||||
`NewAndClose` active ainsi la surface nécessaire aux diagnostics de début/fin et de temps busy/idle fournis par le formatter sans obliger les consumers à utiliser directement `tracing-subscriber`.
|
||||
|
||||
## Subscriber global
|
||||
|
||||
La nouvelle API publique est :
|
||||
|
||||
```text
|
||||
ksp_logging_lib::LoggingGuard
|
||||
ksp_logging_lib::initialize(&LoggingSettings) -> Result<LoggingGuard>
|
||||
ksp_logging_lib::reinitialize(&mut LoggingGuard, &LoggingSettings) -> Result<()>
|
||||
```
|
||||
|
||||
`initialize()` :
|
||||
|
||||
1. valide/prépare les layers ;
|
||||
2. crée une unique infrastructure `reload::Layer` ;
|
||||
3. installe le subscriber global avec l'API fallible `tracing::subscriber::set_global_default` ;
|
||||
4. retourne un `LoggingGuard` possédant le handle de reload et les settings actifs.
|
||||
|
||||
Une seconde installation globale retourne :
|
||||
|
||||
```text
|
||||
logging.already_initialized
|
||||
```
|
||||
|
||||
La cause `SetGlobalDefaultError` est conservée comme `source` Core.
|
||||
|
||||
## Hot reload
|
||||
|
||||
La composition interne retenue est :
|
||||
|
||||
```text
|
||||
Registry
|
||||
-> reload::Layer
|
||||
-> Vec<Box<dyn Layer<Registry> + Send + Sync>>
|
||||
```
|
||||
|
||||
Le `Vec` peut être vide. Cela permet :
|
||||
|
||||
```text
|
||||
initialize(no sink)
|
||||
-> subscriber global installé mais silencieux
|
||||
|
||||
reinitialize(console enabled)
|
||||
-> console activée sans second subscriber global
|
||||
```
|
||||
|
||||
Le choix d'un `Vec` de layers boxed prépare directement `pre.004`, qui pourra ajouter ou retirer console/fichier sans changer la surface publique de reload.
|
||||
|
||||
`reinitialize()` prépare d'abord complètement la nouvelle représentation. Une erreur de validation/préparation retourne avant le swap et conserve :
|
||||
|
||||
- les settings actifs du `LoggingGuard` ;
|
||||
- les layers actuellement installés ;
|
||||
- le comportement de filtering en cours.
|
||||
|
||||
Une erreur effective du handle `reload` retourne :
|
||||
|
||||
```text
|
||||
logging.reload_failed
|
||||
```
|
||||
|
||||
et conserve sa cause externe via le contrat `source` Core.
|
||||
|
||||
## File settings pendant `pre.003`
|
||||
|
||||
`FileSettings` reste dans la surface publique définie par `pre.002`, mais le backend fichier n'est pas encore construit dans cette tranche.
|
||||
|
||||
`initialize()` / `reinitialize()` refusent donc temporairement une configuration avec `file = Some(...)` avec `logging.invalid_settings` et contexte `field = file` au lieu d'ignorer silencieusement la demande.
|
||||
|
||||
Cette restriction transitoire disparaîtra lorsque le backend fichier réel sera introduit en `pre.004`.
|
||||
|
||||
## Erreurs ajoutées
|
||||
|
||||
```text
|
||||
logging.already_initialized
|
||||
logging.reload_failed
|
||||
```
|
||||
|
||||
Elles s'ajoutent à :
|
||||
|
||||
```text
|
||||
logging.invalid_settings
|
||||
```
|
||||
|
||||
Aucune connaissance Logging n'est ajoutée à Core.
|
||||
|
||||
## Tests ajoutés
|
||||
|
||||
### Unitaires runtime
|
||||
|
||||
- mapping complet des niveaux KSP ;
|
||||
- silence des targets externes ;
|
||||
- default KSP `Info` ;
|
||||
- override `ksp-logging-lib = Trace` ;
|
||||
- mapping des événements de span ;
|
||||
- rejet temporaire du backend fichier avant `pre.004`.
|
||||
|
||||
### Intégration runtime global
|
||||
|
||||
Un seul test global dans sa crate de test dédiée vérifie :
|
||||
|
||||
1. `initialize()` avec aucun sink ;
|
||||
2. absence d'admission des callsites tant que Logging est désactivé ;
|
||||
3. `reinitialize()` avec console active ;
|
||||
4. activation `Trace` de `ksp-logging-lib` par override ;
|
||||
5. maintien de `ksp-store-lib` à `Info` ;
|
||||
6. maintien de `sqlx` à `Off` même pour `Error` ;
|
||||
7. échec d'un reload demandant le backend fichier non encore disponible ;
|
||||
8. conservation des anciens settings/filtering après cet échec ;
|
||||
9. refus d'un deuxième `initialize()`.
|
||||
|
||||
Le changement de filtering est observé via des fonctions contenant des callsites `tracing::enabled!` stables, afin de vérifier que le reload invalide correctement l'intérêt mis en cache.
|
||||
|
||||
## Fichiers ajoutés
|
||||
|
||||
- `crates/ksp-logging-lib/src/runtime.rs`
|
||||
- `crates/ksp-logging-lib/unit_tests/runtime.rs`
|
||||
- `crates/ksp-logging-lib/tests/runtime.rs`
|
||||
- `deltas/0.1.2/pre.003.md`
|
||||
|
||||
## Fichiers modifiés
|
||||
|
||||
- `Cargo.toml`
|
||||
- `crates/ksp-logging-lib/Cargo.toml`
|
||||
- `crates/ksp-logging-lib/src/error.rs`
|
||||
- `crates/ksp-logging-lib/src/lib.rs`
|
||||
- `crates/ksp-logging-lib/tests/public_api.rs`
|
||||
- `docs/plans/004-V0_1_2_LOGGING_FOUNDATION_PLAN.md`
|
||||
|
||||
## Fichiers supprimés
|
||||
|
||||
Aucun.
|
||||
|
||||
## Validations exécutées pendant la préparation
|
||||
|
||||
- revérification documentaire de `tracing-subscriber 0.3.23` et de ses features ;
|
||||
- contrôle TOML des manifests ;
|
||||
- contrôle des headers `file:` / `version:` ;
|
||||
- contrôle de la centralisation de `tracing-subscriber` sous `[workspace.dependencies]` ;
|
||||
- contrôle que `tracing-appender` reste absent ;
|
||||
- contrôle que le code production ajouté n'utilise ni `unwrap`, ni `expect`, ni `panic`, ni opérateur `?`, ni `unsafe` ;
|
||||
- contrôle que les usages directs de la stack tracing restent dans `ksp-logging-lib` ;
|
||||
- contrôle du contenu du delta contre la base reconstruite `0.1.2-pre.2.fix.1`.
|
||||
|
||||
## Validations à exécuter dans le workspace
|
||||
|
||||
```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 non exécutable dans l'environnement de préparation n'est déclarée réussie.
|
||||
|
||||
## Suite
|
||||
|
||||
Après validation de `pre.003`, passer à `0.1.2-pre.004` :
|
||||
|
||||
- `tracing-appender` ;
|
||||
- console non bloquante ;
|
||||
- fichier Never/Hourly/Daily ;
|
||||
- `WorkerGuard` / `ErrorCounter` ;
|
||||
- stripping ANSI fichier ;
|
||||
- hot reload des sinks non bloquants et de leurs guards.
|
||||
157
deltas/0.1.2/pre.004-fix.001.md
Normal file
157
deltas/0.1.2/pre.004-fix.001.md
Normal file
@@ -0,0 +1,157 @@
|
||||
<!-- file: deltas/0.1.2/pre.004-fix.001.md -->
|
||||
<!-- version: 1 -->
|
||||
|
||||
# Delta 0.1.2-pre.004-fix.001
|
||||
|
||||
## Base requise
|
||||
|
||||
Livraison précédente :
|
||||
|
||||
```text
|
||||
0.1.2-pre.004
|
||||
```
|
||||
|
||||
La base porte :
|
||||
|
||||
```text
|
||||
workspace.package.version = "0.1.2-pre.4"
|
||||
Cargo.toml header version = 31
|
||||
```
|
||||
|
||||
## Validations remontées
|
||||
|
||||
Les validations utilisateur de `pre.004` sont :
|
||||
|
||||
```text
|
||||
cargo fmt --all OK
|
||||
cargo check --workspace OK
|
||||
cargo clippy --workspace --all-targets OK
|
||||
cargo test --workspace ECHEC
|
||||
cargo tree -p ksp-logging-lib OK
|
||||
cargo tree -p ksp-logging-lib -d OK — aucun doublon
|
||||
cargo tree -p ksp-logging-lib -e features inspecté
|
||||
```
|
||||
|
||||
Tous les tests unitaires, de callsite et de façade publique passent. Le seul échec est :
|
||||
|
||||
```text
|
||||
global_runtime_supports_takeover_non_blocking_outputs_hot_reload_and_single_initialization
|
||||
```
|
||||
|
||||
sur :
|
||||
|
||||
```text
|
||||
assertion failed: file_text.contains("file output marker")
|
||||
```
|
||||
|
||||
Le test émet une ligne sur le sink fichier, retire immédiatement ce sink par hot reload, puis lit le fichier. Il constitue donc un test direct du contrat de drain/flush lors d'un reload.
|
||||
|
||||
## Cause de lifecycle
|
||||
|
||||
`pre.004` faisait conceptuellement :
|
||||
|
||||
```text
|
||||
reload_handle.reload(new_layers)
|
||||
retire counters
|
||||
replace outputs
|
||||
drop(old WorkerGuard)
|
||||
```
|
||||
|
||||
Le layer `fmt` retiré possède les clones `NonBlocking` utilisés pour alimenter le worker. KSP ne récupérait cependant pas explicitement l'ancien `Vec` de layers ; l'ordre entre la destruction effective de ces anciens layers et la destruction des `WorkerGuard` n'était donc pas exprimé dans notre lifecycle.
|
||||
|
||||
Pour un sink non bloquant, l'ordre voulu est explicite :
|
||||
|
||||
```text
|
||||
1. préparer complètement le nouveau runtime
|
||||
2. remplacer le Vec actif et récupérer l'ancien Vec
|
||||
3. mémoriser les dropped-line counters
|
||||
4. remplacer les outputs actifs
|
||||
5. détruire les anciens layers / NonBlocking senders
|
||||
6. détruire les anciens WorkerGuard
|
||||
7. retourner du reinitialize()
|
||||
```
|
||||
|
||||
`WorkerGuard` envoie le signal de shutdown au worker et attend son drain/flush de manière bornée. Les anciens senders doivent donc être libérés avant cette étape lorsqu'un sink vient d'être retiré.
|
||||
|
||||
## Correction
|
||||
|
||||
`reinitialize()` n'utilise plus :
|
||||
|
||||
```text
|
||||
Handle::reload(new_layers)
|
||||
```
|
||||
|
||||
pour les changements de runtime.
|
||||
|
||||
Il utilise :
|
||||
|
||||
```text
|
||||
Handle::modify(... mem::replace(active_layers, new_layers) ...)
|
||||
```
|
||||
|
||||
et récupère ainsi l'ancien `RuntimeLayers`.
|
||||
|
||||
Après succès du swap :
|
||||
|
||||
```text
|
||||
drop(retired_layers)
|
||||
drop(retired_outputs)
|
||||
```
|
||||
|
||||
est exécuté dans cet ordre.
|
||||
|
||||
Cette correction :
|
||||
|
||||
- ne change pas l'API publique ;
|
||||
- conserve le subscriber global unique ;
|
||||
- conserve le takeover KSP ;
|
||||
- conserve la préparation transactionnelle des nouveaux sinks avant le swap ;
|
||||
- conserve l'ancienne configuration lorsqu'une validation ou une construction de sink échoue avant le swap ;
|
||||
- rend explicite le lifecycle de retrait des `NonBlocking` writers avant leurs `WorkerGuard` ;
|
||||
- évite d'ajouter un sleep ou un polling temporel au test.
|
||||
|
||||
Le test d'intégration qui a révélé le défaut reste inchangé et sert directement de test de non-régression.
|
||||
|
||||
## Référence backend
|
||||
|
||||
`tracing-appender 0.2.5` documente `WorkerGuard` comme responsable du flush des logs bufferisés à sa destruction. Son implémentation de `Drop` envoie un `Msg::Shutdown` au worker puis attend le signal de fin de drain de manière bornée. KSP doit donc contrôler clairement l'ordre de destruction des senders/layers et du guard au moment d'un hot reload.
|
||||
|
||||
## Version technique
|
||||
|
||||
Ce correctif modifie du Rust. La version workspace devient :
|
||||
|
||||
```text
|
||||
workspace.package.version = "0.1.2-pre.4.fix.1"
|
||||
```
|
||||
|
||||
et l'en-tête du `Cargo.toml` racine devient :
|
||||
|
||||
```text
|
||||
# version: 32
|
||||
```
|
||||
|
||||
## Fichiers du delta
|
||||
|
||||
```text
|
||||
Cargo.toml
|
||||
crates/ksp-logging-lib/src/runtime.rs
|
||||
docs/plans/004-V0_1_2_LOGGING_FOUNDATION_PLAN.md
|
||||
deltas/0.1.2/pre.004-fix.001.md
|
||||
```
|
||||
|
||||
## Validations à exécuter
|
||||
|
||||
```bash
|
||||
cargo fmt --all
|
||||
cargo check --workspace
|
||||
cargo clippy --workspace --all-targets
|
||||
cargo test --workspace
|
||||
```
|
||||
|
||||
Le graphe Cargo/features de `pre.004` a déjà été remonté sans doublon. Il pourra être réaudité dans `pre.005` avec les validations d'intégration finales.
|
||||
|
||||
Si ces validations sont propres, la tranche suivante reste :
|
||||
|
||||
```text
|
||||
0.1.2-pre.005 — intégration + concurrence + saturation + audits
|
||||
```
|
||||
135
deltas/0.1.2/pre.004-fix.002.md
Normal file
135
deltas/0.1.2/pre.004-fix.002.md
Normal file
@@ -0,0 +1,135 @@
|
||||
<!-- file: deltas/0.1.2/pre.004-fix.002.md -->
|
||||
<!-- version: 1 -->
|
||||
|
||||
# Delta 0.1.2-pre.004-fix.002
|
||||
|
||||
## Base requise
|
||||
|
||||
Livraison précédente :
|
||||
|
||||
```text
|
||||
0.1.2-pre.004-fix.001
|
||||
```
|
||||
|
||||
La base porte :
|
||||
|
||||
```text
|
||||
workspace.package.version = "0.1.2-pre.4.fix.1"
|
||||
Cargo.toml header version = 32
|
||||
```
|
||||
|
||||
## Validation remontée
|
||||
|
||||
Après application de `pre.004-fix.001`, un rebuild propre a donné :
|
||||
|
||||
```text
|
||||
cargo clean OK
|
||||
cargo fmt --all OK
|
||||
cargo check --workspace OK
|
||||
cargo clippy --workspace --all-targets OK
|
||||
cargo test --workspace ECHEC
|
||||
```
|
||||
|
||||
Tous les tests sauf le test runtime global passent encore. L'échec reste strictement identique :
|
||||
|
||||
```text
|
||||
assertion failed: file_text.contains("file output marker")
|
||||
```
|
||||
|
||||
La reproduction après `cargo clean` invalide donc l'hypothèse selon laquelle cet échec précis provenait de l'ordre de destruction corrigé par `pre.004-fix.001`. Ce lifecycle explicite est néanmoins conservé.
|
||||
|
||||
## Cause réelle
|
||||
|
||||
`tracing-subscriber 0.3.23` active par défaut la sanitization ANSI des valeurs dans `fmt::Layer`. Cette protection intervient pendant le formatage, donc avant l'appel au `MakeWriter`.
|
||||
|
||||
Le sink fichier KSP était composé comme suit :
|
||||
|
||||
```text
|
||||
value containing ESC
|
||||
-> fmt::Layer ANSI sanitization
|
||||
-> NonBlocking
|
||||
-> StripAnsiWriter
|
||||
-> RollingFileAppender
|
||||
```
|
||||
|
||||
Le `StripAnsiWriter` KSP ne recevait donc plus les octets ESC originaux à supprimer. Le test attend volontairement que :
|
||||
|
||||
```text
|
||||
file ESC[31moutput ESC[0m marker
|
||||
```
|
||||
|
||||
devienne dans le fichier :
|
||||
|
||||
```text
|
||||
file output marker
|
||||
```
|
||||
|
||||
La sanitization native et le stripping KSP sont deux politiques différentes : KSP veut supprimer les contrôles du fichier, pas les transformer avant son propre writer.
|
||||
|
||||
## Correction
|
||||
|
||||
Le runtime distingue désormais la politique du formatter selon le sink :
|
||||
|
||||
```text
|
||||
console
|
||||
fmt::Layer.with_ansi(false)
|
||||
fmt::Layer.with_ansi_sanitization(true)
|
||||
-> NonBlocking console
|
||||
|
||||
file
|
||||
fmt::Layer.with_ansi(false)
|
||||
fmt::Layer.with_ansi_sanitization(false)
|
||||
-> NonBlocking
|
||||
-> StripAnsiWriter
|
||||
-> RollingFileAppender
|
||||
```
|
||||
|
||||
La console conserve donc la protection native de `tracing-subscriber`. Le fichier laisse passer jusqu'au worker les séquences présentes dans les valeurs afin que `StripAnsiWriter` les supprime avant persistence.
|
||||
|
||||
Le stripping reste hors du hot path : il est toujours exécuté derrière la queue non bloquante.
|
||||
|
||||
Le test d'intégration runtime reste inchangé. Il continue à vérifier :
|
||||
|
||||
- l'émission fichier après hot reload ;
|
||||
- le retrait immédiat du sink et son drain ;
|
||||
- la présence du target et du callsite ;
|
||||
- l'absence de séquences ANSI ;
|
||||
- le silence des targets externes.
|
||||
|
||||
## Version technique
|
||||
|
||||
Ce correctif modifie du Rust. La version workspace devient :
|
||||
|
||||
```text
|
||||
workspace.package.version = "0.1.2-pre.4.fix.2"
|
||||
```
|
||||
|
||||
et l'en-tête du `Cargo.toml` racine devient :
|
||||
|
||||
```text
|
||||
# version: 33
|
||||
```
|
||||
|
||||
## Fichiers du delta
|
||||
|
||||
```text
|
||||
Cargo.toml
|
||||
crates/ksp-logging-lib/src/runtime.rs
|
||||
docs/plans/004-V0_1_2_LOGGING_FOUNDATION_PLAN.md
|
||||
deltas/0.1.2/pre.004-fix.002.md
|
||||
```
|
||||
|
||||
## Validations à exécuter
|
||||
|
||||
```bash
|
||||
cargo fmt --all
|
||||
cargo check --workspace
|
||||
cargo clippy --workspace --all-targets
|
||||
cargo test --workspace
|
||||
```
|
||||
|
||||
Si ces validations sont propres, la tranche suivante reste :
|
||||
|
||||
```text
|
||||
0.1.2-pre.005 — intégration + concurrence + saturation + audits
|
||||
```
|
||||
131
deltas/0.1.2/pre.004-fix.003.md
Normal file
131
deltas/0.1.2/pre.004-fix.003.md
Normal file
@@ -0,0 +1,131 @@
|
||||
<!-- file: deltas/0.1.2/pre.004-fix.003.md -->
|
||||
<!-- version: 1 -->
|
||||
|
||||
# Delta 0.1.2-pre.004-fix.003
|
||||
|
||||
## Base requise
|
||||
|
||||
Livraison précédente :
|
||||
|
||||
```text
|
||||
0.1.2-pre.004-fix.002
|
||||
```
|
||||
|
||||
La base porte :
|
||||
|
||||
```text
|
||||
workspace.package.version = "0.1.2-pre.4.fix.2"
|
||||
Cargo.toml header version = 33
|
||||
```
|
||||
|
||||
## Validation remontée
|
||||
|
||||
Après application de `pre.004-fix.002` :
|
||||
|
||||
```text
|
||||
cargo fmt --all OK
|
||||
cargo check --workspace OK
|
||||
cargo clippy --workspace --all-targets OK
|
||||
cargo test --workspace ECHEC
|
||||
```
|
||||
|
||||
Le marqueur fichier précédemment absent est désormais correctement persisté et les assertions de présence du target, du callsite et d'absence d'ANSI passent. Le test runtime global échoue plus loin sur :
|
||||
|
||||
```text
|
||||
assertion failed: !file_text.contains("external marker must remain silent")
|
||||
```
|
||||
|
||||
Le défaut restant concerne donc exclusivement le takeover : un événement `tracing` émis directement avec le target externe `sqlx` atteint encore le sink fichier alors que la politique KSP exige son silence total.
|
||||
|
||||
## Cause réelle
|
||||
|
||||
`pre.003-fix.001` avait évité le panic `Filtered`/`FilterId` en plaçant `Targets` comme layer global distinct dans le même :
|
||||
|
||||
```text
|
||||
Vec<Box<dyn Layer<Registry>>>
|
||||
```
|
||||
|
||||
que les formatters.
|
||||
|
||||
Cette composition n'est toutefois pas correcte pour le filtrage global au niveau des callsites. L'implémentation `Layer` de `Vec<L>` agrège `register_callsite` en conservant l'intérêt le plus élevé retourné par ses enfants. Un `fmt::Layer` intéressé par le callsite peut donc produire un intérêt actif alors que `Targets` retourne `Interest::never()` pour un target externe.
|
||||
|
||||
Lorsque le callsite est enregistré comme toujours actif, `enabled()` n'est ensuite pas consulté à chaque émission. Le `Targets` frère du formatter ne peut donc plus bloquer l'événement externe.
|
||||
|
||||
Le test avec `sqlx` expose précisément cette fuite.
|
||||
|
||||
## Correction
|
||||
|
||||
Les layers de sortie sont d'abord construits dans un `Vec` :
|
||||
|
||||
```text
|
||||
outputs
|
||||
├── console fmt layer, si actif
|
||||
└── file fmt layer, si actif
|
||||
```
|
||||
|
||||
Le takeover est ensuite composé **devant tout ce groupe** :
|
||||
|
||||
```text
|
||||
Targets
|
||||
.and_then(outputs)
|
||||
```
|
||||
|
||||
et ce composite unique devient l'élément du `Vec` reloadable :
|
||||
|
||||
```text
|
||||
reload::Layer
|
||||
└── Vec
|
||||
└── Targets -> output layers
|
||||
```
|
||||
|
||||
Cette forme rétablit la sémantique de filtre global : un `Interest::never()` produit par `Targets` court-circuite le groupe de sinks avant leur formatter.
|
||||
|
||||
Elle conserve simultanément les propriétés requises :
|
||||
|
||||
- aucun `Layer::with_filter` n'est utilisé sur un layer remplacé à chaud ;
|
||||
- aucun `Filtered` et donc aucun `FilterId` reloadable n'est introduit ;
|
||||
- console et fichier restent activables/désactivables dynamiquement ;
|
||||
- `Handle::modify` continue de récupérer l'ancien composite avant destruction de ses `WorkerGuard` ;
|
||||
- la sanitization console et le stripping ANSI fichier de `fix.002` restent inchangés ;
|
||||
- l'API publique reste inchangée.
|
||||
|
||||
Le test d'intégration runtime conserve son assertion directe sur un événement `tracing::error!` de target `sqlx`. Il reste donc le test de non-régression du takeover effectif, au-delà du test unitaire de `Targets::would_enable`.
|
||||
|
||||
## Version technique
|
||||
|
||||
Ce correctif modifie du Rust. La version workspace devient :
|
||||
|
||||
```text
|
||||
workspace.package.version = "0.1.2-pre.4.fix.3"
|
||||
```
|
||||
|
||||
et l'en-tête du `Cargo.toml` racine devient :
|
||||
|
||||
```text
|
||||
# version: 34
|
||||
```
|
||||
|
||||
## Fichiers du delta
|
||||
|
||||
```text
|
||||
Cargo.toml
|
||||
crates/ksp-logging-lib/src/runtime.rs
|
||||
crates/ksp-logging-lib/unit_tests/runtime.rs
|
||||
docs/plans/004-V0_1_2_LOGGING_FOUNDATION_PLAN.md
|
||||
deltas/0.1.2/pre.004-fix.003.md
|
||||
```
|
||||
|
||||
## Validations à exécuter
|
||||
|
||||
```bash
|
||||
cargo fmt --all
|
||||
cargo check --workspace
|
||||
cargo clippy --workspace --all-targets
|
||||
cargo test --workspace
|
||||
```
|
||||
|
||||
Aucune dépendance n'est modifiée par ce fix. Après validation propre, la tranche suivante reste :
|
||||
|
||||
```text
|
||||
0.1.2-pre.005 — intégration + concurrence + saturation + audits
|
||||
```
|
||||
95
deltas/0.1.2/pre.004-fix.004.md
Normal file
95
deltas/0.1.2/pre.004-fix.004.md
Normal file
@@ -0,0 +1,95 @@
|
||||
<!-- file: deltas/0.1.2/pre.004-fix.004.md -->
|
||||
<!-- version: 1 -->
|
||||
|
||||
# Delta 0.1.2-pre.004-fix.004
|
||||
|
||||
## Base requise
|
||||
|
||||
Livraison précédente :
|
||||
|
||||
```text
|
||||
0.1.2-pre.004-fix.003
|
||||
```
|
||||
|
||||
La base porte :
|
||||
|
||||
```text
|
||||
workspace.package.version = "0.1.2-pre.4.fix.3"
|
||||
Cargo.toml header version = 34
|
||||
```
|
||||
|
||||
## Validation remontée
|
||||
|
||||
Après application de `pre.004-fix.003` :
|
||||
|
||||
```text
|
||||
cargo fmt --all OK
|
||||
cargo check --workspace OK
|
||||
cargo test --workspace OK
|
||||
cargo clippy --workspace --all-targets WARNING
|
||||
```
|
||||
|
||||
Tous les tests fonctionnels passent désormais, y compris le test runtime global couvrant takeover, sorties non bloquantes, hot reload, fichier, stripping ANSI et initialisation unique.
|
||||
|
||||
Clippy signale uniquement :
|
||||
|
||||
```text
|
||||
clippy::vec_init_then_push
|
||||
```
|
||||
|
||||
sur la construction du `Vec` reloadable après création du composite `Targets -> sinks`.
|
||||
|
||||
## Correction
|
||||
|
||||
La construction :
|
||||
|
||||
```rust
|
||||
let mut layers = RuntimeLayers::new();
|
||||
layers.push(takeover_layer);
|
||||
```
|
||||
|
||||
est remplacée par :
|
||||
|
||||
```rust
|
||||
let layers = vec![takeover_layer];
|
||||
```
|
||||
|
||||
Aucun comportement runtime, test, setting, writer, filtre ou contrat public n'est modifié.
|
||||
|
||||
## Version technique
|
||||
|
||||
Ce correctif modifie du Rust. La version workspace devient :
|
||||
|
||||
```text
|
||||
workspace.package.version = "0.1.2-pre.4.fix.4"
|
||||
```
|
||||
|
||||
et l'en-tête du `Cargo.toml` racine devient :
|
||||
|
||||
```text
|
||||
# version: 35
|
||||
```
|
||||
|
||||
## Fichiers du delta
|
||||
|
||||
```text
|
||||
Cargo.toml
|
||||
crates/ksp-logging-lib/src/runtime.rs
|
||||
docs/plans/004-V0_1_2_LOGGING_FOUNDATION_PLAN.md
|
||||
deltas/0.1.2/pre.004-fix.004.md
|
||||
```
|
||||
|
||||
## Validations à exécuter
|
||||
|
||||
```bash
|
||||
cargo fmt --all
|
||||
cargo check --workspace
|
||||
cargo clippy --workspace --all-targets
|
||||
cargo test --workspace
|
||||
```
|
||||
|
||||
Aucune dépendance n'est modifiée. Après validation propre, la tranche suivante reste :
|
||||
|
||||
```text
|
||||
0.1.2-pre.005 — intégration + concurrence + saturation + audits
|
||||
```
|
||||
301
deltas/0.1.2/pre.004.md
Normal file
301
deltas/0.1.2/pre.004.md
Normal file
@@ -0,0 +1,301 @@
|
||||
<!-- file: deltas/0.1.2/pre.004.md -->
|
||||
<!-- version: 1 -->
|
||||
|
||||
# Delta 0.1.2-pre.004
|
||||
|
||||
## Base requise
|
||||
|
||||
Livraison précédente validée :
|
||||
|
||||
```text
|
||||
0.1.2-pre.003-fix.001
|
||||
```
|
||||
|
||||
La base de développement validée porte :
|
||||
|
||||
```text
|
||||
workspace.package.version = "0.1.2-pre.3.fix.1"
|
||||
Cargo.toml header version = 30
|
||||
```
|
||||
|
||||
Les validations remontées avant l'ouverture de cette tranche sont propres :
|
||||
|
||||
```bash
|
||||
cargo fmt --all
|
||||
cargo check --workspace
|
||||
cargo clippy --workspace --all-targets
|
||||
cargo test --workspace
|
||||
```
|
||||
|
||||
## Objectif
|
||||
|
||||
Compléter le runtime Logging avec les sorties réellement retenues pour `0.1.2` :
|
||||
|
||||
- ajouter `tracing-appender` ;
|
||||
- rendre console et fichier non bloquants pour le caller ;
|
||||
- posséder les `WorkerGuard` jusqu'au reload/shutdown approprié ;
|
||||
- exposer les dropped-line counters ;
|
||||
- activer le fichier `Never/Hourly/Daily` avec construction fallible ;
|
||||
- supprimer les séquences ANSI avant persistence ;
|
||||
- conserver le takeover et le hot reload transactionnel établis par `pre.003-fix.001`.
|
||||
|
||||
## Version Cargo
|
||||
|
||||
`workspace.package.version` passe de :
|
||||
|
||||
```text
|
||||
0.1.2-pre.3.fix.1
|
||||
```
|
||||
|
||||
à :
|
||||
|
||||
```text
|
||||
0.1.2-pre.4
|
||||
```
|
||||
|
||||
L'identifiant de livraison est :
|
||||
|
||||
```text
|
||||
0.1.2-pre.004
|
||||
```
|
||||
|
||||
Le header du `Cargo.toml` racine passe de version 30 à 31.
|
||||
|
||||
## Dépendance `tracing-appender`
|
||||
|
||||
L'audit du 2026-08-14 confirme `tracing-appender 0.2.5`, publié le 2026-04-17, dans la génération `^0.2`.
|
||||
|
||||
La dépendance est centralisée sous `[workspace.dependencies]` :
|
||||
|
||||
```toml
|
||||
tracing-appender = { version = "^0.2", default-features = false }
|
||||
```
|
||||
|
||||
`ksp-logging-lib` la consomme avec :
|
||||
|
||||
```toml
|
||||
tracing-appender.workspace = true
|
||||
```
|
||||
|
||||
Aucune feature optionnelle n'est activée. Le backend expose `NonBlockingBuilder`, `WorkerGuard`, `ErrorCounter` et `RollingFileAppender` sans feature supplémentaire.
|
||||
|
||||
## Console non bloquante
|
||||
|
||||
La console n'utilise plus directement `stdout`/`stderr` dans le formatter.
|
||||
|
||||
Chaque sink console construit :
|
||||
|
||||
```text
|
||||
Stdout | Stderr
|
||||
-> NonBlockingBuilder(lossy = true)
|
||||
-> fmt layer
|
||||
+ WorkerGuard
|
||||
+ ErrorCounter
|
||||
```
|
||||
|
||||
Le mode lossy est explicite : lorsque la queue est saturée, un log peut être abandonné au lieu de bloquer le thread appelant.
|
||||
|
||||
Le thread worker console est nommé :
|
||||
|
||||
```text
|
||||
ksp-logging-console
|
||||
```
|
||||
|
||||
## Fichier et rotation
|
||||
|
||||
`FileSettings` est maintenant réellement consommé par le runtime.
|
||||
|
||||
Le mapping est :
|
||||
|
||||
```text
|
||||
FileRotation::Never -> Rotation::NEVER
|
||||
FileRotation::Hourly -> Rotation::HOURLY
|
||||
FileRotation::Daily -> Rotation::DAILY
|
||||
```
|
||||
|
||||
Le runtime utilise uniquement :
|
||||
|
||||
```text
|
||||
RollingFileAppender::builder()
|
||||
.rotation(...)
|
||||
.filename_prefix(...)
|
||||
.build(directory)
|
||||
```
|
||||
|
||||
La forme builder retourne un `Result`; aucune API de construction qui panique n'est utilisée par KSP.
|
||||
|
||||
Un échec retourne :
|
||||
|
||||
```text
|
||||
logging.file_output_initialization_failed
|
||||
```
|
||||
|
||||
avec le directory, le file-name prefix et l'erreur `InitError` externe conservés dans le contrat Core.
|
||||
|
||||
## Stripping ANSI
|
||||
|
||||
Le fichier est composé comme suit :
|
||||
|
||||
```text
|
||||
fmt layer
|
||||
-> NonBlocking queue
|
||||
-> StripAnsiWriter
|
||||
-> RollingFileAppender
|
||||
```
|
||||
|
||||
Le stripping est donc effectué par le thread logging et non par le caller.
|
||||
|
||||
`StripAnsiWriter` conserve un état entre les appels `Write` afin de retirer correctement une séquence terminal coupée entre plusieurs buffers. La première surface couvre :
|
||||
|
||||
- CSI (`ESC [` ... final byte) ;
|
||||
- OSC terminé par BEL ou ST ;
|
||||
- autres chaînes terminal ESC de type DCS/SOS/PM/APC terminées par ST.
|
||||
|
||||
Ce mécanisme est générique et n'introduit aucune dépendance Tauri.
|
||||
|
||||
## Formatter humain
|
||||
|
||||
Console et fichier partagent le formatter humain KSP avec :
|
||||
|
||||
- timestamp standard `tracing-subscriber` ;
|
||||
- niveau ;
|
||||
- target ;
|
||||
- champs/message ;
|
||||
- source file ;
|
||||
- line number ;
|
||||
- ANSI du formatter désactivé ;
|
||||
- lifecycle de spans selon `SpanEvents`.
|
||||
|
||||
La ponctuation exacte du formatter reste hors contrat public.
|
||||
|
||||
## Ownership et reload
|
||||
|
||||
`LoggingGuard` possède désormais les outputs actifs :
|
||||
|
||||
```text
|
||||
LoggingGuard
|
||||
├── reload handle
|
||||
├── current LoggingSettings
|
||||
├── active console WorkerGuard/ErrorCounter
|
||||
├── active file WorkerGuard/ErrorCounter
|
||||
└── cumulative retired dropped-line counters
|
||||
```
|
||||
|
||||
`reinitialize()` :
|
||||
|
||||
1. valide les nouveaux settings ;
|
||||
2. construit entièrement le nouveau file appender et tous les nouveaux non-blocking writers/guards ;
|
||||
3. construit les nouveaux layers ;
|
||||
4. remplace le `Vec` reloadable ;
|
||||
5. mémorise les dropped lines des anciens sinks ;
|
||||
6. remplace les outputs actifs ;
|
||||
7. détruit les anciens `WorkerGuard`, provoquant leur flush borné par le backend.
|
||||
|
||||
Une erreur avant le swap détruit uniquement les nouveaux outputs préparés et laisse l'ancienne configuration active.
|
||||
|
||||
## Dropped lines
|
||||
|
||||
Nouvelle surface publique :
|
||||
|
||||
```text
|
||||
DroppedLines
|
||||
LoggingGuard::dropped_lines() -> DroppedLines
|
||||
```
|
||||
|
||||
`DroppedLines` expose :
|
||||
|
||||
```text
|
||||
console()
|
||||
file()
|
||||
total()
|
||||
```
|
||||
|
||||
Les valeurs sont cumulées pour toute la durée de vie du `LoggingGuard`, y compris après plusieurs hot reloads. Les `ErrorCounter` de `tracing-appender` ne sont pas exposés directement aux consumers.
|
||||
|
||||
## Tests
|
||||
|
||||
### Unitaires
|
||||
|
||||
- mapping `Never/Hourly/Daily` ;
|
||||
- runtime sans sink ;
|
||||
- console préparée avec filter séparé, non-blocking output et guard ;
|
||||
- addition saturante des dropped-line counters ;
|
||||
- stripping CSI ;
|
||||
- stripping d'une CSI coupée entre deux writes ;
|
||||
- stripping OSC terminé par BEL/ST.
|
||||
|
||||
### Intégration runtime global
|
||||
|
||||
Le test global vérifie désormais :
|
||||
|
||||
1. initialisation silencieuse sans sink ;
|
||||
2. hot reload console non bloquante ;
|
||||
3. takeover KSP et silence `sqlx` ;
|
||||
4. erreur de création d'un file appender sur un chemin invalide ;
|
||||
5. conservation des settings précédents après cet échec ;
|
||||
6. hot reload vers un fichier `Never` ;
|
||||
7. émission d'un message contenant des codes ANSI ;
|
||||
8. retrait du sink fichier par reload, donc drop/flush de son guard ;
|
||||
9. présence du message KSP dans le fichier ;
|
||||
10. absence des codes ANSI persistés ;
|
||||
11. absence du message externe `sqlx` ;
|
||||
12. présence du target et de la source ;
|
||||
13. lecture de la statistique cumulée ;
|
||||
14. refus d'un second `initialize()`.
|
||||
|
||||
La saturation déterministe avec une queue artificiellement petite est reportée à `pre.005`, où un writer de test injecté pourra être utilisé sans rendre la capacité de queue publique dans `LoggingSettings`.
|
||||
|
||||
## Fichiers ajoutés
|
||||
|
||||
- `crates/ksp-logging-lib/src/writer.rs`
|
||||
- `crates/ksp-logging-lib/unit_tests/writer.rs`
|
||||
- `deltas/0.1.2/pre.004.md`
|
||||
|
||||
## Fichiers modifiés
|
||||
|
||||
- `Cargo.toml`
|
||||
- `crates/ksp-logging-lib/Cargo.toml`
|
||||
- `crates/ksp-logging-lib/src/error.rs`
|
||||
- `crates/ksp-logging-lib/src/lib.rs`
|
||||
- `crates/ksp-logging-lib/src/runtime.rs`
|
||||
- `crates/ksp-logging-lib/unit_tests/runtime.rs`
|
||||
- `crates/ksp-logging-lib/tests/runtime.rs`
|
||||
- `crates/ksp-logging-lib/tests/public_api.rs`
|
||||
- `docs/plans/004-V0_1_2_LOGGING_FOUNDATION_PLAN.md`
|
||||
|
||||
## Validations exécutées pendant la préparation
|
||||
|
||||
- revérification documentaire officielle de `tracing-appender 0.2.5` ;
|
||||
- vérification de la sémantique lossy de `NonBlockingBuilder` ;
|
||||
- vérification de `WorkerGuard` et `ErrorCounter::dropped_lines()` ;
|
||||
- vérification du builder fallible de `RollingFileAppender` ;
|
||||
- contrôle TOML des manifests ;
|
||||
- contrôle des headers `file:` / `version:` ;
|
||||
- contrôle de la centralisation de `tracing-appender` sous `[workspace.dependencies]` ;
|
||||
- contrôle que le code production ajouté n'utilise ni `unwrap`, ni `expect`, ni `panic`, ni opérateur `?`, ni `unsafe` ;
|
||||
- contrôle que les usages directs de `tracing-appender` restent dans `ksp-logging-lib`.
|
||||
|
||||
## Validations à exécuter dans le workspace
|
||||
|
||||
```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 non exécutable dans l'environnement de préparation n'est déclarée réussie.
|
||||
|
||||
## Suite
|
||||
|
||||
Après validation de `pre.004`, passer à `0.1.2-pre.005` :
|
||||
|
||||
- concurrence/reloads répétés ;
|
||||
- saturation déterministe et dropped lines ;
|
||||
- audits de façade et usages directs de la stack tracing ;
|
||||
- audits Cargo/features/doublons ;
|
||||
- mesure grossière de l'overhead du reload/runtime ;
|
||||
- compléments de tests et documentation de crate avant la tranche finale.
|
||||
88
deltas/0.1.2/pre.005-fix.001.md
Normal file
88
deltas/0.1.2/pre.005-fix.001.md
Normal file
@@ -0,0 +1,88 @@
|
||||
<!-- file: deltas/0.1.2/pre.005-fix.001.md -->
|
||||
<!-- version: 1 -->
|
||||
|
||||
# Delta 0.1.2-pre.005-fix.001
|
||||
|
||||
## Base requise
|
||||
|
||||
Livraison précédente :
|
||||
|
||||
```text
|
||||
0.1.2-pre.005
|
||||
```
|
||||
|
||||
La base porte :
|
||||
|
||||
```text
|
||||
workspace.package.version = "0.1.2-pre.5"
|
||||
Cargo.toml header version = 36
|
||||
```
|
||||
|
||||
## Validation remontée
|
||||
|
||||
La validation utilisateur de `pre.005` est fonctionnellement propre :
|
||||
|
||||
```text
|
||||
cargo fmt --all OK
|
||||
cargo check --workspace OK
|
||||
cargo clippy --workspace --all-targets OK
|
||||
cargo test --workspace OK
|
||||
cargo test -p ksp-logging-lib --test overhead -- --ignored --nocapture OK
|
||||
```
|
||||
|
||||
Le probe d'overhead a également passé son garde-fou grossier sur 200000 itérations.
|
||||
|
||||
Le test runtime concurrent produit toutefois un grand volume de lignes `TRACE` sur stderr au démarrage du stress test. Le runtime est encore sur la configuration précédente `console_enabled` avec un override `Trace` lorsque les producteurs sont libérés ; ils peuvent donc émettre avant le premier reload vers la configuration silencieuse.
|
||||
|
||||
## Correction
|
||||
|
||||
`exercise_concurrent_reload` effectue maintenant un `reinitialize` vers `quiet_console` **avant** de créer/libérer les producteurs concurrents.
|
||||
|
||||
Le stress test conserve ensuite exactement :
|
||||
|
||||
- 4 producteurs ;
|
||||
- les appels continus à `ksp_logging_lib::trace!` ;
|
||||
- 32 hot reloads ;
|
||||
- l'alternance entre console non bloquante présente avec filtre KSP `Off` et runtime sans sink ;
|
||||
- les assertions de succès des reloads et des joins.
|
||||
|
||||
La correction supprime uniquement la fenêtre de course initiale qui laissait la configuration `Trace` précédente produire des lignes. Elle ne modifie aucun comportement de production, aucune API publique et aucune dépendance.
|
||||
|
||||
## Version technique
|
||||
|
||||
Ce correctif modifie un fichier Rust de test. La version workspace devient :
|
||||
|
||||
```text
|
||||
workspace.package.version = "0.1.2-pre.5.fix.1"
|
||||
```
|
||||
|
||||
et l'en-tête du `Cargo.toml` racine devient :
|
||||
|
||||
```text
|
||||
# version: 37
|
||||
```
|
||||
|
||||
## Fichiers du delta
|
||||
|
||||
```text
|
||||
Cargo.toml
|
||||
crates/ksp-logging-lib/tests/runtime.rs
|
||||
docs/plans/004-V0_1_2_LOGGING_FOUNDATION_PLAN.md
|
||||
deltas/0.1.2/pre.005-fix.001.md
|
||||
```
|
||||
|
||||
## Validations à exécuter
|
||||
|
||||
```bash
|
||||
cargo fmt --all
|
||||
cargo check --workspace
|
||||
cargo clippy --workspace --all-targets
|
||||
cargo test --workspace
|
||||
cargo test -p ksp-logging-lib --test overhead -- --ignored --nocapture
|
||||
```
|
||||
|
||||
Si ces validations sont propres et que le test runtime ne pollue plus la console, `pre.005` est clôturée et la tranche suivante reste :
|
||||
|
||||
```text
|
||||
0.1.2-pre.006 — validation finale, documentation, cleanup et prompt 0.1.3
|
||||
```
|
||||
173
deltas/0.1.2/pre.005.md
Normal file
173
deltas/0.1.2/pre.005.md
Normal file
@@ -0,0 +1,173 @@
|
||||
<!-- file: deltas/0.1.2/pre.005.md -->
|
||||
<!-- version: 1 -->
|
||||
|
||||
# Delta 0.1.2-pre.005
|
||||
|
||||
## Identité
|
||||
|
||||
```text
|
||||
0.1.2-pre.005 — intégration, concurrence, saturation et audits Logging
|
||||
```
|
||||
|
||||
Version Cargo portée par ce delta :
|
||||
|
||||
```text
|
||||
0.1.2-pre.5
|
||||
```
|
||||
|
||||
## Base
|
||||
|
||||
Base directe : `0.1.2-pre.004-fix.004`, version Cargo `0.1.2-pre.4.fix.4`.
|
||||
|
||||
La validation utilisateur de cette base a exécuté avec succès :
|
||||
|
||||
```bash
|
||||
cargo fmt --all
|
||||
cargo check --workspace
|
||||
cargo clippy --workspace --all-targets
|
||||
cargo test --workspace
|
||||
```
|
||||
|
||||
Le test runtime global passe alors avec takeover des targets externes, console/fichier non bloquants, stripping ANSI, lifecycle des `WorkerGuard` et hot reload.
|
||||
|
||||
## Mission du delta
|
||||
|
||||
Cette tranche ne modifie pas la surface fonctionnelle publique de Logging. Elle durcit la fondation existante avant la validation finale :
|
||||
|
||||
- saturation déterministe des queues lossy ;
|
||||
- émissions concurrentes pendant hot reload ;
|
||||
- vérification des temps de lifecycle de spans ;
|
||||
- audit automatique du takeover de dépendances ;
|
||||
- documentation consommateur de la crate ;
|
||||
- probe diagnostic de l'overhead du mécanisme reload.
|
||||
|
||||
## Changements Rust
|
||||
|
||||
### Construction non bloquante testable sans setting supplémentaire
|
||||
|
||||
`runtime.rs` centralise la construction du `NonBlockingBuilder` dans un helper privé utilisé par la production.
|
||||
|
||||
La politique reste :
|
||||
|
||||
```text
|
||||
lossy = true
|
||||
```
|
||||
|
||||
La taille de queue n'entre pas dans `LoggingSettings`. Le test unitaire peut cependant dériver le même builder avec `buffered_lines_limit(1)` afin de provoquer une saturation contrôlée.
|
||||
|
||||
### Saturation déterministe
|
||||
|
||||
Le test unitaire runtime introduit un writer qui bloque volontairement son worker sur la première écriture.
|
||||
|
||||
Une fois le worker bloqué :
|
||||
|
||||
1. la queue est limitée à une ligne ;
|
||||
2. un producteur émet 1024 lignes supplémentaires ;
|
||||
3. le producteur doit terminer sous timeout alors que le writer reste bloqué ;
|
||||
4. le compteur `ErrorCounter::dropped_lines()` doit être strictement positif ;
|
||||
5. le writer est ensuite libéré et le `WorkerGuard` peut terminer proprement.
|
||||
|
||||
Ce test distingue directement la politique lossy retenue d'une régression vers une queue exerçant de la backpressure.
|
||||
|
||||
### Concurrence pendant hot reload
|
||||
|
||||
Le test runtime global lance quatre threads qui émettent continuellement via `ksp_logging_lib::trace!` pendant que le thread possédant `LoggingGuard` effectue 32 `reinitialize()` successifs.
|
||||
|
||||
Les settings alternent entre :
|
||||
|
||||
- runtime sans sink ;
|
||||
- console non bloquante présente avec niveau KSP `Off`.
|
||||
|
||||
Le test exerce donc le reload, la création/retrait de worker guards et la lecture concurrente du subscriber sans inonder stdout/stderr.
|
||||
|
||||
### Lifecycle des spans
|
||||
|
||||
Un nouveau test avec subscriber local vérifie que `FmtSpan::NEW | FmtSpan::CLOSE` produit pour un span KSP :
|
||||
|
||||
- l'identité du span ;
|
||||
- l'événement `new` ;
|
||||
- l'événement `close` ;
|
||||
- `time.busy` ;
|
||||
- `time.idle`.
|
||||
|
||||
### Audit de takeover
|
||||
|
||||
`tests/ownership.rs` parcourt les crates du workspace autres que `ksp-logging-lib` et rejette :
|
||||
|
||||
- une dépendance Cargo directe `tracing` ;
|
||||
- une dépendance directe `tracing-subscriber` ;
|
||||
- une dépendance directe `tracing-appender` ;
|
||||
- les usages Rust directs `tracing::`, `tracing_subscriber::` ou `tracing_appender::`.
|
||||
|
||||
La crate Logging elle-même est explicitement exclue de cet audit car elle possède légitimement la stack.
|
||||
|
||||
## Documentation de crate
|
||||
|
||||
Ajouts :
|
||||
|
||||
```text
|
||||
crates/ksp-logging-lib/README.md
|
||||
crates/ksp-logging-lib/USAGE.md
|
||||
crates/ksp-logging-lib/TODO.md
|
||||
```
|
||||
|
||||
Ils documentent notamment :
|
||||
|
||||
- target = nom Cargo de la crate propriétaire ;
|
||||
- champs structurés additionnels ;
|
||||
- `initialize` puis `reinitialize` ;
|
||||
- hot reload transactionnel ;
|
||||
- spans sync/async ;
|
||||
- dropped lines ;
|
||||
- capacités explicitement différées.
|
||||
|
||||
## Probe d'overhead
|
||||
|
||||
`tests/overhead.rs` est ignoré par défaut car il s'agit d'un probe temporel diagnostic, pas d'un benchmark de précision.
|
||||
|
||||
Commande explicite :
|
||||
|
||||
```bash
|
||||
cargo test -p ksp-logging-lib --test overhead -- --ignored --nocapture
|
||||
```
|
||||
|
||||
Le probe compare un filtre local fixe au même filtre derrière `tracing_subscriber::reload::Layer` sur 200000 événements et n'échoue que si le coût reload dépasse un garde-fou volontairement très large. Le résultat sert à repérer une régression grossière ; il ne constitue pas une mesure HFT ni un engagement de performance absolue.
|
||||
|
||||
## Audit des dépendances
|
||||
|
||||
Aucune dépendance n'est ajoutée par `pre.005`.
|
||||
|
||||
Lors de `pre.004`, les commandes utilisateur ont observé :
|
||||
|
||||
```text
|
||||
tracing 0.1.44
|
||||
tracing-subscriber 0.3.23
|
||||
tracing-appender 0.2.5
|
||||
```
|
||||
|
||||
`cargo tree -p ksp-logging-lib -d` ne signalait aucun doublon. Le graphe de features n'activait pas via KSP `tracing-attributes`, `tracing-log`, `env-filter`, JSON/Serde ou le formatter ANSI. Ces commandes doivent être réexécutées sur `pre.005` avant validation finale de la tranche car une validation d'une base précédente ne vaut pas validation du delta courant.
|
||||
|
||||
## Validations à exécuter
|
||||
|
||||
Non exécutées dans l'environnement de génération :
|
||||
|
||||
```bash
|
||||
cargo fmt --all
|
||||
cargo check --workspace
|
||||
cargo test --workspace
|
||||
cargo clippy --workspace --all-targets
|
||||
cargo test -p ksp-logging-lib --test overhead -- --ignored --nocapture
|
||||
cargo tree -p ksp-logging-lib
|
||||
cargo tree -p ksp-logging-lib -d
|
||||
cargo tree -p ksp-logging-lib -e features
|
||||
```
|
||||
|
||||
La base stable ne contient pas de répertoire `scripts/`; aucun script inexistant n'est déclaré réussi.
|
||||
|
||||
## Suite
|
||||
|
||||
Après validation de `pre.005`, la tranche prévue est :
|
||||
|
||||
```text
|
||||
0.1.2-pre.006 — validation finale, documentation, cleanup et prompt 0.1.3
|
||||
```
|
||||
69
deltas/0.1.2/pre.006-fix.001.md
Normal file
69
deltas/0.1.2/pre.006-fix.001.md
Normal file
@@ -0,0 +1,69 @@
|
||||
<!-- file: deltas/0.1.2/pre.006-fix.001.md -->
|
||||
<!-- version: 1 -->
|
||||
|
||||
# Delta 0.1.2-pre.006-fix.001
|
||||
|
||||
## Nature
|
||||
|
||||
Correctif **documentaire uniquement** appliqué après validation complète de `0.1.2-pre.006`.
|
||||
|
||||
La version Cargo reste :
|
||||
|
||||
```text
|
||||
workspace.package.version = "0.1.2-pre.6"
|
||||
Cargo.toml header version = 38
|
||||
```
|
||||
|
||||
Aucun fichier Rust, manifest Cargo, dépendance ou comportement runtime n'est modifié.
|
||||
|
||||
## Base validée
|
||||
|
||||
La validation utilisateur de `pre.006` est propre :
|
||||
|
||||
```text
|
||||
cargo fmt --all OK
|
||||
cargo check --workspace OK
|
||||
cargo build -p ksp-logging-lib OK
|
||||
cargo clippy --workspace --all-targets OK
|
||||
cargo test --workspace OK
|
||||
cargo test -p ksp-logging-lib --test overhead -- --ignored --nocapture OK
|
||||
cargo tree -p ksp-logging-lib OK
|
||||
cargo tree -p ksp-logging-lib -d OK (aucun doublon)
|
||||
cargo tree -p ksp-logging-lib -e features OK
|
||||
cargo tree -p ksp-logging-lib -e normal OK (Tokio absent)
|
||||
cargo tree -p ksp-logging-lib -e dev OK (Tokio seul dev-dependency)
|
||||
```
|
||||
|
||||
Les deux tests Tokio réels passent et le build normal de `ksp-logging-lib` confirme que Tokio reste hors du graphe runtime normal.
|
||||
|
||||
## Corrections du prompt `0.1.3`
|
||||
|
||||
Le prompt Config est corrigé avant `rel.001` afin de ne pas démarrer la session suivante avec d'anciens contrats khadhroony-bot3 devenus incorrects.
|
||||
|
||||
Décisions enregistrées :
|
||||
|
||||
1. les vrais fichiers de configuration runtime sont sous `config/` ;
|
||||
2. les schemas sont sous `config/schemas/` ;
|
||||
3. les exemples sont sous `config/examples/`, séparés des fichiers réels ;
|
||||
4. la conception doit reprendre un système de **documents unitaires** spécialisés et de **fichiers composites** qui assemblent ces documents pour un exécutable/application et peuvent sélectionner/remplacer les profils ;
|
||||
5. `ksp-config-lib` est le propriétaire unique de la lecture, résolution, validation et mutation des fichiers Config ainsi que des variables d'environnement applicatives ; les autres crates/apps passent par ses APIs ;
|
||||
6. les namespaces d'environnement deviennent `KSP_*`, `KSP_PUBLIC_*`, `KSP_SECRET_*` pour KSP et `KSPB_*`, `KSPB_PUBLIC_*`, `KSPB_SECRET_*` pour la branche bot ;
|
||||
7. les secrets ne suivent plus une règle absolue « jamais exposés » : ils restent protégés contre toute exposition implicite, mais les composants légitimes et les applications de management Config doivent pouvoir les consulter/modifier via des contrats explicitement autorisés ;
|
||||
8. `ksp-app-config-desk` est cité comme premier consommateur probable d'une telle surface privilégiée, avant une éventuelle application générale disposant d'une section Config.
|
||||
|
||||
Le détail des formats, contrats d'autorisation et découpage fonctionnel reste volontairement à décider lors du brainstorming obligatoire de `0.1.3-pre.001`.
|
||||
|
||||
## Fichiers
|
||||
|
||||
Modifiés :
|
||||
|
||||
- `prompts/003-V0_1_3_START_PROMPT.md` ;
|
||||
- `docs/plans/004-V0_1_2_LOGGING_FOUNDATION_PLAN.md`.
|
||||
|
||||
Ajouté :
|
||||
|
||||
- `deltas/0.1.2/pre.006-fix.001.md`.
|
||||
|
||||
## Suite
|
||||
|
||||
Après application de ce correctif documentaire, `0.1.2-pre.006` reste la dernière prerelease fonctionnelle validée. La prochaine livraison est `0.1.2-rel.001` avec passage à la version stable `0.1.2` et validations finales de publication.
|
||||
213
deltas/0.1.2/pre.006.md
Normal file
213
deltas/0.1.2/pre.006.md
Normal file
@@ -0,0 +1,213 @@
|
||||
<!-- file: deltas/0.1.2/pre.006.md -->
|
||||
<!-- version: 1 -->
|
||||
|
||||
# Delta 0.1.2-pre.006
|
||||
|
||||
## Base requise
|
||||
|
||||
Livraison précédente validée :
|
||||
|
||||
```text
|
||||
0.1.2-pre.005-fix.001
|
||||
```
|
||||
|
||||
La base porte :
|
||||
|
||||
```text
|
||||
workspace.package.version = "0.1.2-pre.5.fix.1"
|
||||
Cargo.toml header version = 37
|
||||
```
|
||||
|
||||
## Validation de la base
|
||||
|
||||
La validation utilisateur de `pre.005-fix.001` est propre :
|
||||
|
||||
```text
|
||||
cargo fmt --all OK
|
||||
cargo check --workspace OK
|
||||
cargo clippy --workspace --all-targets OK
|
||||
cargo test --workspace OK
|
||||
cargo test -p ksp-logging-lib --test overhead -- --ignored --nocapture OK
|
||||
```
|
||||
|
||||
Le stress test concurrent ne produit plus le flux TRACE parasite corrigé par `pre.005-fix.001`.
|
||||
|
||||
Le probe diagnostic d'overhead a passé son garde-fou sur 200000 itérations :
|
||||
|
||||
```text
|
||||
baseline = 19.658553 ms
|
||||
reload = 28.807958 ms
|
||||
```
|
||||
|
||||
Ces nombres restent des observations diagnostiques et non un benchmark contractuel.
|
||||
|
||||
## Objet de pre.006
|
||||
|
||||
`pre.006` est la prerelease finale prévue de `0.1.2`.
|
||||
|
||||
Elle :
|
||||
|
||||
- ajoute une validation des spans async sur un executor Tokio réel ;
|
||||
- garde Tokio hors des dépendances runtime de `ksp-logging-lib` ;
|
||||
- consolide la documentation de la crate ;
|
||||
- ferme les TODO de prerelease ;
|
||||
- prépare le prompt final de `0.1.3 — ksp-config-lib` ;
|
||||
- prépare les validations finales précédant `rel.001`.
|
||||
|
||||
## Tokio uniquement pour les tests
|
||||
|
||||
Version actuelle vérifiée le 2026-08-14 :
|
||||
|
||||
```text
|
||||
tokio 1.53.1
|
||||
```
|
||||
|
||||
Le workspace centralise une contrainte de génération :
|
||||
|
||||
```toml
|
||||
tokio = { version = "^1.53", default-features = false, features = ["rt", "rt-multi-thread", "macros"] }
|
||||
```
|
||||
|
||||
`ksp-logging-lib` le consomme uniquement comme dev-dependency :
|
||||
|
||||
```toml
|
||||
[dev-dependencies]
|
||||
tokio.workspace = true
|
||||
```
|
||||
|
||||
Aucun source de production de Logging n'importe Tokio. L'API `instrument(span, future)` reste fondée sur `std::future::Future` et reste indépendante de l'executor choisi par le consumer.
|
||||
|
||||
## Tests Tokio réels
|
||||
|
||||
Nouveau fichier :
|
||||
|
||||
```text
|
||||
crates/ksp-logging-lib/tests/tokio_span.rs
|
||||
```
|
||||
|
||||
### Current-thread
|
||||
|
||||
Le premier test utilise :
|
||||
|
||||
```text
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
```
|
||||
|
||||
Il instrumente une future contenant plusieurs `tokio::task::yield_now().await` et utilise un subscriber de test associé au span pour compter les `enter`/`exit`.
|
||||
|
||||
Le test exige plusieurs ré-entrées du span après suspension et un nombre final d'entrées/sorties identique.
|
||||
|
||||
### Multi-thread
|
||||
|
||||
Le second test utilise :
|
||||
|
||||
```text
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
```
|
||||
|
||||
Deux futures instrumentées sont lancées avec `tokio::spawn`, effectuent des suspensions répétées puis doivent terminer normalement. Le test vérifie également l'équilibre des `enter`/`exit`.
|
||||
|
||||
Ce test démontre l'utilisation correcte de la surface KSP sous un runtime Tokio multi-thread ; il ne prétend pas imposer ni mesurer une migration déterministe d'une même future entre worker threads.
|
||||
|
||||
## Documentation finale
|
||||
|
||||
Mises à jour :
|
||||
|
||||
- `crates/ksp-logging-lib/README.md` : indépendance de l'executor en production et statut test-only de Tokio ;
|
||||
- `crates/ksp-logging-lib/USAGE.md` : exemple async et frontière executor ;
|
||||
- `crates/ksp-logging-lib/TODO.md` : seules restent les validations finales et la future livraison stable ;
|
||||
- `docs/plans/004-V0_1_2_LOGGING_FOUNDATION_PLAN.md` : statut validé de `pre.005-fix.001`, contenu `pre.006`, validations finales et absence de question architecturale bloquante ;
|
||||
- `prompts/000-README.md` : ajout du prompt Config ;
|
||||
- `prompts/003-V0_1_3_START_PROMPT.md` : prompt final pour ouvrir `0.1.3` après `v0.1.2`.
|
||||
|
||||
`ROADMAP.md` reste volontairement inchangé : `0.1.2` demeure en cours tant que `rel.001` et le tag `v0.1.2` ne sont pas validés.
|
||||
|
||||
Aucun changelog général n'existe actuellement dans le dépôt ; `pre.006` n'en crée pas artificiellement un.
|
||||
|
||||
## Version technique
|
||||
|
||||
La prerelease devient :
|
||||
|
||||
```text
|
||||
workspace.package.version = "0.1.2-pre.6"
|
||||
```
|
||||
|
||||
L'en-tête du manifest racine devient :
|
||||
|
||||
```text
|
||||
# version: 38
|
||||
```
|
||||
|
||||
Le manifest de `ksp-logging-lib` devient :
|
||||
|
||||
```text
|
||||
# version: 4
|
||||
```
|
||||
|
||||
## Fichiers du delta
|
||||
|
||||
```text
|
||||
Cargo.toml
|
||||
crates/ksp-logging-lib/Cargo.toml
|
||||
crates/ksp-logging-lib/README.md
|
||||
crates/ksp-logging-lib/TODO.md
|
||||
crates/ksp-logging-lib/USAGE.md
|
||||
crates/ksp-logging-lib/tests/tokio_span.rs
|
||||
docs/plans/004-V0_1_2_LOGGING_FOUNDATION_PLAN.md
|
||||
prompts/000-README.md
|
||||
prompts/003-V0_1_3_START_PROMPT.md
|
||||
deltas/0.1.2/pre.006.md
|
||||
```
|
||||
|
||||
## Validations finales à exécuter
|
||||
|
||||
```bash
|
||||
cargo fmt --all
|
||||
cargo check --workspace
|
||||
cargo build -p ksp-logging-lib
|
||||
cargo clippy --workspace --all-targets
|
||||
cargo test --workspace
|
||||
cargo test -p ksp-logging-lib --test overhead -- --ignored --nocapture
|
||||
cargo tree -p ksp-logging-lib
|
||||
cargo tree -p ksp-logging-lib -d
|
||||
cargo tree -p ksp-logging-lib -e features
|
||||
cargo tree -p ksp-logging-lib -e normal
|
||||
cargo tree -p ksp-logging-lib -e dev
|
||||
```
|
||||
|
||||
Contrôles attendus en particulier :
|
||||
|
||||
- les deux tests de `tests/tokio_span.rs` passent ;
|
||||
- `cargo build -p ksp-logging-lib` reste un build normal sans Tokio comme dépendance runtime ;
|
||||
- le graphe `-e normal` n'inclut pas Tokio ;
|
||||
- Tokio est visible uniquement via l'usage dev attendu ;
|
||||
- aucune seconde version évitable n'apparaît ;
|
||||
- l'audit ownership continue à interdire les contournements de la façade tracing.
|
||||
|
||||
Aucune validation Cargo n'est déclarée réussie dans ce delta avant exécution dans l'environnement de développement.
|
||||
|
||||
## Suite après validation
|
||||
|
||||
Si `pre.006` est propre, la prochaine livraison est :
|
||||
|
||||
```text
|
||||
0.1.2-rel.001
|
||||
```
|
||||
|
||||
Elle publiera :
|
||||
|
||||
```text
|
||||
workspace.package.version = "0.1.2"
|
||||
```
|
||||
|
||||
puis, après validation utilisateur, le commit final recevra :
|
||||
|
||||
```text
|
||||
v0.1.2
|
||||
```
|
||||
|
||||
La session fonctionnelle suivante pourra alors démarrer avec :
|
||||
|
||||
```text
|
||||
prompts/003-V0_1_3_START_PROMPT.md
|
||||
```
|
||||
193
deltas/0.1.2/rel.001.md
Normal file
193
deltas/0.1.2/rel.001.md
Normal file
@@ -0,0 +1,193 @@
|
||||
<!-- file: deltas/0.1.2/rel.001.md -->
|
||||
<!-- version: 1 -->
|
||||
|
||||
# Delta `0.1.2-rel.001` — publication stable Logging
|
||||
|
||||
## Base requise
|
||||
|
||||
`0.1.2-pre.006` avec le correctif documentaire `0.1.2-pre.006-fix.001`, au sens des commits de livraison correspondants, avec :
|
||||
|
||||
```text
|
||||
workspace.package.version = "0.1.2-pre.6"
|
||||
```
|
||||
|
||||
Le correctif `pre.006-fix.001` ne modifie pas la version Cargo.
|
||||
|
||||
## Objectif
|
||||
|
||||
Publier la release stable `0.1.2`, clôturer `Logging foundation` et préparer l'ouverture de `0.1.3 — Configuration foundation` sans modifier la surface fonctionnelle de `ksp-logging-lib`.
|
||||
|
||||
## Version Cargo
|
||||
|
||||
`workspace.package.version` passe de :
|
||||
|
||||
```text
|
||||
0.1.2-pre.6
|
||||
```
|
||||
|
||||
à :
|
||||
|
||||
```text
|
||||
0.1.2
|
||||
```
|
||||
|
||||
Le header de `Cargo.toml` passe de version 38 à 39.
|
||||
|
||||
Les contraintes de dépendances restent inchangées :
|
||||
|
||||
```toml
|
||||
[workspace.dependencies]
|
||||
solana-pubkey = { version = "^4.3", default-features = false }
|
||||
tracing = { version = "^0.1", default-features = false, features = ["std"] }
|
||||
tracing-subscriber = { version = "^0.3", default-features = false, features = ["fmt"] }
|
||||
tracing-appender = { version = "^0.2", default-features = false }
|
||||
tokio = { version = "^1.53", default-features = false, features = ["rt", "rt-multi-thread", "macros"] }
|
||||
```
|
||||
|
||||
Tokio reste uniquement une dev-dependency de `ksp-logging-lib` et n'appartient pas à son graphe normal.
|
||||
|
||||
## Validations finales exécutées par le user
|
||||
|
||||
Commandes exécutées avec succès le 2026-08-14 sur `0.1.2-pre.6` :
|
||||
|
||||
```bash
|
||||
cargo fmt --all
|
||||
cargo check --workspace
|
||||
cargo build -p ksp-logging-lib
|
||||
cargo clippy --workspace --all-targets
|
||||
cargo test --workspace
|
||||
cargo test -p ksp-logging-lib --test overhead -- --ignored --nocapture
|
||||
cargo tree -p ksp-logging-lib
|
||||
cargo tree -p ksp-logging-lib -d
|
||||
cargo tree -p ksp-logging-lib -e features
|
||||
cargo tree -p ksp-logging-lib -e normal
|
||||
cargo tree -p ksp-logging-lib -e dev
|
||||
```
|
||||
|
||||
Résultats communiqués :
|
||||
|
||||
- `cargo check --workspace` : succès ;
|
||||
- `cargo build -p ksp-logging-lib` : succès sur le graphe normal ;
|
||||
- `cargo clippy --workspace --all-targets` : succès sans warning communiqué ;
|
||||
- `cargo test --workspace` : tous les tests exécutés réussissent, dont les tests de takeover, saturation non bloquante, hot reload concurrent, lifecycle span et les deux tests Tokio réels ;
|
||||
- probe d'overhead explicite : succès sur 200000 itérations, `baseline=16.959425ms`, `reload=24.942444ms` ;
|
||||
- `cargo tree -p ksp-logging-lib -d` : aucun doublon ;
|
||||
- `cargo tree -p ksp-logging-lib -e normal` : Tokio absent ;
|
||||
- `cargo tree -p ksp-logging-lib -e dev` : Tokio présent comme seule dev-dependency directe ;
|
||||
- features Tokio observées : `macros`, `rt`, `rt-multi-thread`, sans feature `full`.
|
||||
|
||||
Le correctif documentaire `pre.006-fix.001` appliqué après ces validations ne modifie ni Rust, ni manifest, ni runtime.
|
||||
|
||||
## Surface stable publiée
|
||||
|
||||
`0.1.2` stabilise notamment :
|
||||
|
||||
- `ksp-logging-lib` comme façade runtime KSP unique de logging/tracing ;
|
||||
- les macros `error!`, `warn!`, `info!`, `debug!`, `trace!` avec target KSP explicite et callsite consommateur préservé ;
|
||||
- les spans KSP synchrones et `instrument(span, future)` pour l'async sans dépendance `tracing` directe chez les consumers ;
|
||||
- `LoggingSettings`, `LogFilterLevel`, `TargetFilter`, `SpanEvents`, `ConsoleSettings`, `FileSettings` et `FileRotation` ;
|
||||
- `initialize()` unique, `reinitialize()` à chaud et `LoggingGuard` ;
|
||||
- takeover KSP avec silence externe par défaut et overrides par préfixe `ksp-*` ;
|
||||
- console et fichier non bloquants avec `WorkerGuard` possédés par Logging ;
|
||||
- mode lossy sans backpressure sur le hot path et observation cumulée des lignes abandonnées via `DroppedLines` ;
|
||||
- rotation fichier `Never`, `Hourly`, `Daily` ;
|
||||
- suppression des séquences ANSI avant persistence fichier ;
|
||||
- reconfiguration transactionnelle conservant l'ancienne configuration si la nouvelle préparation échoue ;
|
||||
- lifecycle spans `Off`, `NewAndClose`, `Full`, avec `busy`/`idle` lorsque demandé ;
|
||||
- tests de concurrence/reload, saturation, ownership de la stack tracing, callsites et instrumentation Tokio current-thread/multi-thread ;
|
||||
- ownership exclusif de `tracing`, `tracing-subscriber` et `tracing-appender` par `ksp-logging-lib` dans le workspace KSP.
|
||||
|
||||
Aucune nouvelle primitive ou API n'est ajoutée par le présent delta de publication.
|
||||
|
||||
## Documentation de clôture
|
||||
|
||||
Le présent delta :
|
||||
|
||||
- marque `0.1.2` réalisée dans `ROADMAP.md` ;
|
||||
- conserve `004-V0_1_2_LOGGING_FOUNDATION_PLAN.md` comme plan historique clôturé ;
|
||||
- ajoute ce plan aux index de documentation/plans ;
|
||||
- remplace le périmètre candidat Logging dans la séquence fonctionnelle par la surface réellement stabilisée ;
|
||||
- réaligne la section Config de la séquence fonctionnelle sur les décisions de `pre.006-fix.001` : `KSP_*`/`KSPB_*`, `config/examples/`, documents unitaires + composites, ownership exclusif de Config et accès explicite aux secrets pour les surfaces autorisées ;
|
||||
- conserve `prompts/003-V0_1_3_START_PROMPT.md` comme prompt final d'ouverture de `0.1.3`.
|
||||
|
||||
Aucun changelog général n'existe dans la base actuelle ; aucun changelog artificiel n'est créé.
|
||||
|
||||
## Fichiers ajoutés
|
||||
|
||||
```text
|
||||
deltas/0.1.2/rel.001.md
|
||||
```
|
||||
|
||||
## Fichiers modifiés
|
||||
|
||||
```text
|
||||
Cargo.toml
|
||||
ROADMAP.md
|
||||
docs/000-README.md
|
||||
docs/plans/000-README.md
|
||||
docs/plans/002-FUNCTIONAL_RELEASE_SEQUENCE.md
|
||||
docs/plans/004-V0_1_2_LOGGING_FOUNDATION_PLAN.md
|
||||
```
|
||||
|
||||
## Fichiers supprimés
|
||||
|
||||
Aucun.
|
||||
|
||||
## Décisions
|
||||
|
||||
Aucune nouvelle décision fonctionnelle concernant Logging.
|
||||
|
||||
La publication stable confirme les décisions, contrats et corrections stabilisés pendant les prereleases `0.1.2` et leurs fixes.
|
||||
|
||||
La synchronisation de la documentation Config en clôture ne remplace pas le brainstorming `0.1.3-pre.001`; elle ne fait qu'enregistrer les décisions déjà prises dans `pre.006-fix.001`.
|
||||
|
||||
## Validations non exécutées dans cette livraison
|
||||
|
||||
L'environnement de génération du delta ne dispose pas de Cargo/Rust. Les commandes Cargo ne sont donc pas réexécutées ici sur la version finale `0.1.2`.
|
||||
|
||||
Après application du delta, le user doit exécuter au minimum :
|
||||
|
||||
```bash
|
||||
cargo fmt --all
|
||||
cargo check --workspace
|
||||
cargo clippy --workspace --all-targets
|
||||
cargo test --workspace
|
||||
```
|
||||
|
||||
Le build normal et les graphes Cargo peuvent également être rejoués pour confirmer une dernière fois l'absence de Tokio dans le graphe runtime.
|
||||
|
||||
## Publication Git
|
||||
|
||||
Après application et validation de ce delta :
|
||||
|
||||
1. vérifier que le working tree ne contient que les modifications attendues ;
|
||||
2. exécuter les validations finales sur `workspace.package.version = "0.1.2"` ;
|
||||
3. créer le commit de release :
|
||||
|
||||
```text
|
||||
v0.1.2-rel.001
|
||||
```
|
||||
|
||||
4. marquer ce commit comme release stable avec le tag :
|
||||
|
||||
```text
|
||||
v0.1.2
|
||||
```
|
||||
|
||||
Aucun tag supplémentaire n'est requis pour les prereleases/fixes historiques.
|
||||
|
||||
## Suite
|
||||
|
||||
Après le tag stable `v0.1.2`, ouvrir :
|
||||
|
||||
```text
|
||||
0.1.3-pre.001
|
||||
```
|
||||
|
||||
avec :
|
||||
|
||||
```text
|
||||
prompts/003-V0_1_3_START_PROMPT.md
|
||||
```
|
||||
|
||||
La première prerelease de `0.1.3` reste une phase de brainstorming, audit et planification avant développement fonctionnel de `ksp-config-lib`.
|
||||
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`.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user