Compare commits
54 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 | |||
| 033912fb2c | |||
| 11ca53ba48 | |||
| b01fb3fa53 | |||
| 4a86a76ca8 | |||
| 75e5ec047f | |||
| 81eda2e5ad | |||
| 72ddabd9f2 | |||
| 27a6715a3e | |||
| 37a1480c72 | |||
| ecc82f681d |
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.
|
||||||
16
Cargo.toml
16
Cargo.toml
@@ -1,18 +1,28 @@
|
|||||||
# file: Cargo.toml
|
# file: Cargo.toml
|
||||||
# version: 17
|
# version: 61
|
||||||
|
|
||||||
[workspace]
|
[workspace]
|
||||||
resolver = "3"
|
resolver = "3"
|
||||||
members = ["crates/ksp-core-lib"]
|
members = ["crates/ksp-config-lib", "crates/ksp-core-lib", "crates/ksp-logging-lib"]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "0.0.3"
|
version = "0.1.3"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
repository = "https://git.sasedev.com/Sasedev/khadhroony-solana-project"
|
repository = "https://git.sasedev.com/Sasedev/khadhroony-solana-project"
|
||||||
authors = ["SinuS von SifriduS <sinus@sasedev.net>"]
|
authors = ["SinuS von SifriduS <sinus@sasedev.net>"]
|
||||||
publish = false
|
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]
|
[workspace.lints.rust]
|
||||||
missing_docs = "warn"
|
missing_docs = "warn"
|
||||||
unreachable_pub = "deny"
|
unreachable_pub = "deny"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<!-- file: README.md -->
|
<!-- file: README.md -->
|
||||||
<!-- version: 5 -->
|
<!-- version: 7 -->
|
||||||
|
|
||||||
# Khadhroony Solana Project
|
# Khadhroony Solana Project
|
||||||
|
|
||||||
@@ -35,7 +35,7 @@ Les besoins du trading constituent une priorité produit à court terme mais ne
|
|||||||
- Les jobs utilisent le préfixe `ksp-job-`.
|
- Les jobs utilisent le préfixe `ksp-job-`.
|
||||||
- Une application ou un outil de démonstration se termine par `-demo`.
|
- Une application ou un outil de démonstration se termine par `-demo`.
|
||||||
- Les crates Rust sont placées directement sous `crates/`, sans sous-répertoires de catégories.
|
- Les crates Rust sont placées directement sous `crates/`, sans sous-répertoires de catégories.
|
||||||
- Les applications sont placées sous `apps/` lorsqu'elles sont introduites.
|
- Les applications Tauri, workers, jobs et autres packages Rust KSP sont des crates workspace placées directement sous `crates/`; leur nom encode leur rôle (`ksp-app-*`, `ksp-worker-*`, `ksp-job-*`).
|
||||||
- Les composants réutilisables restent séparés de leurs applications de manipulation ou de démonstration.
|
- Les composants réutilisables restent séparés de leurs applications de manipulation ou de démonstration.
|
||||||
- Les applications et demos restent des interfaces/compositions ; les opérations réutilisables appartiennent aux composants KSP de niveau approprié.
|
- Les applications et demos restent des interfaces/compositions ; les opérations réutilisables appartiennent aux composants KSP de niveau approprié.
|
||||||
- Les exécutables KSP ne dépendent pas directement de crates externes relatives à Solana ou à un protocole Solana.
|
- Les exécutables KSP ne dépendent pas directement de crates externes relatives à Solana ou à un protocole Solana.
|
||||||
@@ -51,6 +51,7 @@ Les besoins du trading constituent une priorité produit à court terme mais ne
|
|||||||
|
|
||||||
- [`RULES.md`](RULES.md) — index des règles normatives ;
|
- [`RULES.md`](RULES.md) — index des règles normatives ;
|
||||||
- [`ROADMAP.md`](ROADMAP.md) — trajectoire globale du projet ;
|
- [`ROADMAP.md`](ROADMAP.md) — trajectoire globale du projet ;
|
||||||
|
- [`CHANGELOG.md`](CHANGELOG.md) — synthèse des releases stables ;
|
||||||
- [`docs/000-README.md`](docs/000-README.md) — point d'entrée de la documentation ;
|
- [`docs/000-README.md`](docs/000-README.md) — point d'entrée de la documentation ;
|
||||||
- [`docs/IDEAS.md`](docs/IDEAS.md) — idées et sujets à explorer ;
|
- [`docs/IDEAS.md`](docs/IDEAS.md) — idées et sujets à explorer ;
|
||||||
- [`prompts/000-README.md`](prompts/000-README.md) — prompts de reprise ;
|
- [`prompts/000-README.md`](prompts/000-README.md) — prompts de reprise ;
|
||||||
|
|||||||
10
ROADMAP.md
10
ROADMAP.md
@@ -1,5 +1,5 @@
|
|||||||
<!-- file: ROADMAP.md -->
|
<!-- file: ROADMAP.md -->
|
||||||
<!-- version: 12 -->
|
<!-- version: 16 -->
|
||||||
|
|
||||||
# Roadmap KSP
|
# Roadmap KSP
|
||||||
|
|
||||||
@@ -31,12 +31,12 @@ Regrouper les releases consacrées aux fondations N1. Chaque release concrète e
|
|||||||
|
|
||||||
### Releases concrètes
|
### Releases concrètes
|
||||||
|
|
||||||
- [ ] `0.1.1` — Stabiliser `ksp-core-lib` : `Error`/`Result`, Program IDs fondamentaux et primitives réellement N1.
|
- [X] `0.1.1` — Stabiliser `ksp-core-lib` : `Error`/`Result`, Program IDs fondamentaux et primitives réellement N1.
|
||||||
- [ ] `0.1.2` — Introduire `ksp-logging-lib` comme façade KSP de `tracing`, `tracing-appender` et `tracing-subscriber`.
|
- [X] `0.1.2` — Introduire `ksp-logging-lib` comme façade KSP de `tracing`, `tracing-appender` et `tracing-subscriber`.
|
||||||
- [ ] `0.1.3` — Introduire `ksp-config-lib` : documents, profils, résolution, validation et modifications autorisées.
|
- [X] `0.1.3` — Stabiliser `ksp-config-lib` : documents, profils, résolution, validation, environnement KSP/KSPB, management/persistence et adapter Logging.
|
||||||
- [ ] `0.1.4` — Introduire `ksp-app-config-desk` pour valider réellement Config et la frontière Tauri.
|
- [ ] `0.1.4` — Introduire `ksp-app-config-desk` pour valider réellement Config et la frontière Tauri.
|
||||||
|
|
||||||
`0.1.1` et `0.1.2` sont fixées. `0.1.3` / `0.1.4` constituent la séquence par défaut : si le `pre.001` de Config démontre que son périmètre doit être scindé, une release supplémentaire est insérée et les numéros suivants sont décalés plutôt que de surcharger une release.
|
`0.1.1`, `0.1.2` et `0.1.3` sont désormais stables. `0.1.4` constitue l'étape active suivante avec `ksp-app-config-desk`, première validation desktop/Tauri de Config et modèle des futures applications Tauri KSP.
|
||||||
|
|
||||||
Les contrats publics supplémentaires ne sont introduits que lorsqu'une release concrète en démontre le besoin.
|
Les contrats publics supplémentaires ne sont introduits que lorsqu'une release concrète en démontre le besoin.
|
||||||
|
|
||||||
|
|||||||
25
config/examples/composite.example.json
Normal file
25
config/examples/composite.example.json
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"format_version": 1,
|
||||||
|
"default_profile": "local_default",
|
||||||
|
"profiles": [
|
||||||
|
{
|
||||||
|
"profile_id": "local_default",
|
||||||
|
"documents": [
|
||||||
|
{
|
||||||
|
"component_id": "logging",
|
||||||
|
"file_id": "cfg.std.logging"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"profile_id": "local_explicit",
|
||||||
|
"documents": [
|
||||||
|
{
|
||||||
|
"component_id": "logging",
|
||||||
|
"file_id": "cfg.std.logging",
|
||||||
|
"profile_id": "local_dev"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
64
config/examples/std.logging.example.json
Normal file
64
config/examples/std.logging.example.json
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
{
|
||||||
|
"format_version": 1,
|
||||||
|
"logs_directory": "${KSP_LOGS_DIRECTORY:-logs}",
|
||||||
|
"default_profile": "example",
|
||||||
|
"profiles": [
|
||||||
|
{
|
||||||
|
"profile_id": "example",
|
||||||
|
"default_filter": "info",
|
||||||
|
"span_events": "new_and_close",
|
||||||
|
"console": {
|
||||||
|
"enabled": true,
|
||||||
|
"output": "stdout",
|
||||||
|
"ansi": true,
|
||||||
|
"format": "pretty",
|
||||||
|
"filter": {
|
||||||
|
"level": "debug",
|
||||||
|
"targets": [
|
||||||
|
"*"
|
||||||
|
],
|
||||||
|
"domains": [
|
||||||
|
"*"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
{
|
||||||
|
"output_id": "file.all.info",
|
||||||
|
"enabled": true,
|
||||||
|
"path": "ksp-info.log",
|
||||||
|
"rotation": "daily",
|
||||||
|
"format": "compact",
|
||||||
|
"ansi": false,
|
||||||
|
"filter": {
|
||||||
|
"level": "info",
|
||||||
|
"targets": [
|
||||||
|
"*"
|
||||||
|
],
|
||||||
|
"domains": [
|
||||||
|
"*"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"output_id": "file.store.trace",
|
||||||
|
"enabled": false,
|
||||||
|
"path": "store/store-trace.jsonl",
|
||||||
|
"rotation": "hourly",
|
||||||
|
"format": "json",
|
||||||
|
"ansi": false,
|
||||||
|
"filter": {
|
||||||
|
"level": "trace",
|
||||||
|
"targets": [
|
||||||
|
"*"
|
||||||
|
],
|
||||||
|
"domains": [
|
||||||
|
"store"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"target_filters": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
62
config/schemas/composite.schema.json
Normal file
62
config/schemas/composite.schema.json
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"title": "KSP composite configuration",
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": ["format_version", "default_profile", "profiles"],
|
||||||
|
"properties": {
|
||||||
|
"format_version": {
|
||||||
|
"const": 1
|
||||||
|
},
|
||||||
|
"default_profile": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
},
|
||||||
|
"profiles": {
|
||||||
|
"type": "array",
|
||||||
|
"minItems": 1,
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/$defs/composite_profile"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"$defs": {
|
||||||
|
"composite_profile": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": ["profile_id", "documents"],
|
||||||
|
"properties": {
|
||||||
|
"profile_id": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
},
|
||||||
|
"documents": {
|
||||||
|
"type": "array",
|
||||||
|
"minItems": 1,
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/$defs/document_reference"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"document_reference": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": ["component_id", "file_id"],
|
||||||
|
"properties": {
|
||||||
|
"component_id": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
},
|
||||||
|
"file_id": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^cfg\\.std\\.[a-z0-9][a-z0-9._-]*$"
|
||||||
|
},
|
||||||
|
"profile_id": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
266
config/schemas/std.logging.schema.json
Normal file
266
config/schemas/std.logging.schema.json
Normal file
@@ -0,0 +1,266 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"$id": "urn:ksp:schema:std.logging:v1",
|
||||||
|
"title": "KSP standard Logging configuration",
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": [
|
||||||
|
"format_version",
|
||||||
|
"logs_directory",
|
||||||
|
"default_profile",
|
||||||
|
"profiles"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"format_version": {
|
||||||
|
"const": 1
|
||||||
|
},
|
||||||
|
"logs_directory": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
},
|
||||||
|
"default_profile": {
|
||||||
|
"$ref": "#/$defs/profileId"
|
||||||
|
},
|
||||||
|
"profiles": {
|
||||||
|
"type": "array",
|
||||||
|
"minItems": 1,
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/$defs/profile"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"$defs": {
|
||||||
|
"profileId": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^[a-z0-9][a-z0-9._-]*$"
|
||||||
|
},
|
||||||
|
"level": {
|
||||||
|
"enum": [
|
||||||
|
"off",
|
||||||
|
"error",
|
||||||
|
"warn",
|
||||||
|
"info",
|
||||||
|
"debug",
|
||||||
|
"trace"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"format": {
|
||||||
|
"enum": [
|
||||||
|
"human",
|
||||||
|
"compact",
|
||||||
|
"pretty",
|
||||||
|
"json"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"selectorList": {
|
||||||
|
"type": "array",
|
||||||
|
"minItems": 1,
|
||||||
|
"uniqueItems": true,
|
||||||
|
"items": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
},
|
||||||
|
"allOf": [
|
||||||
|
{
|
||||||
|
"if": {
|
||||||
|
"contains": {
|
||||||
|
"const": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"then": {
|
||||||
|
"maxItems": 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"targetSelectorList": {
|
||||||
|
"allOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/$defs/selectorList"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"items": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"const": "*"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^ksp-"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"outputFilter": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": [
|
||||||
|
"level",
|
||||||
|
"targets",
|
||||||
|
"domains"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"level": {
|
||||||
|
"$ref": "#/$defs/level"
|
||||||
|
},
|
||||||
|
"targets": {
|
||||||
|
"$ref": "#/$defs/targetSelectorList"
|
||||||
|
},
|
||||||
|
"domains": {
|
||||||
|
"$ref": "#/$defs/selectorList"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"console": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": [
|
||||||
|
"enabled",
|
||||||
|
"output",
|
||||||
|
"ansi",
|
||||||
|
"format",
|
||||||
|
"filter"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"enabled": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"output": {
|
||||||
|
"enum": [
|
||||||
|
"stdout",
|
||||||
|
"stderr"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"ansi": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"format": {
|
||||||
|
"$ref": "#/$defs/format"
|
||||||
|
},
|
||||||
|
"filter": {
|
||||||
|
"$ref": "#/$defs/outputFilter"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"not": {
|
||||||
|
"properties": {
|
||||||
|
"ansi": {
|
||||||
|
"const": true
|
||||||
|
},
|
||||||
|
"format": {
|
||||||
|
"const": "json"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"ansi",
|
||||||
|
"format"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"outputId": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^[a-z0-9][a-z0-9_-]*(\\.[a-z0-9][a-z0-9_-]*)*$"
|
||||||
|
},
|
||||||
|
"file": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": [
|
||||||
|
"output_id",
|
||||||
|
"enabled",
|
||||||
|
"path",
|
||||||
|
"rotation",
|
||||||
|
"format",
|
||||||
|
"ansi",
|
||||||
|
"filter"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"output_id": {
|
||||||
|
"$ref": "#/$defs/outputId"
|
||||||
|
},
|
||||||
|
"enabled": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"path": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
},
|
||||||
|
"rotation": {
|
||||||
|
"enum": [
|
||||||
|
"never",
|
||||||
|
"hourly",
|
||||||
|
"daily"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"format": {
|
||||||
|
"$ref": "#/$defs/format"
|
||||||
|
},
|
||||||
|
"ansi": {
|
||||||
|
"const": false
|
||||||
|
},
|
||||||
|
"filter": {
|
||||||
|
"$ref": "#/$defs/outputFilter"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"targetFilter": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": [
|
||||||
|
"target_prefix",
|
||||||
|
"level"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"target_prefix": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^ksp-"
|
||||||
|
},
|
||||||
|
"level": {
|
||||||
|
"$ref": "#/$defs/level"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"profile": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": [
|
||||||
|
"profile_id",
|
||||||
|
"default_filter",
|
||||||
|
"span_events",
|
||||||
|
"console",
|
||||||
|
"files",
|
||||||
|
"target_filters"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"profile_id": {
|
||||||
|
"$ref": "#/$defs/profileId"
|
||||||
|
},
|
||||||
|
"default_filter": {
|
||||||
|
"$ref": "#/$defs/level"
|
||||||
|
},
|
||||||
|
"span_events": {
|
||||||
|
"enum": [
|
||||||
|
"off",
|
||||||
|
"new_and_close",
|
||||||
|
"full"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"console": {
|
||||||
|
"$ref": "#/$defs/console"
|
||||||
|
},
|
||||||
|
"files": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/$defs/file"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"target_filters": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/$defs/targetFilter"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
73
config/std.logging.json
Normal file
73
config/std.logging.json
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
{
|
||||||
|
"format_version": 1,
|
||||||
|
"logs_directory": "${KSP_LOGS_DIRECTORY:-logs}",
|
||||||
|
"default_profile": "local_dev",
|
||||||
|
"profiles": [
|
||||||
|
{
|
||||||
|
"profile_id": "local_dev",
|
||||||
|
"default_filter": "warn",
|
||||||
|
"span_events": "new_and_close",
|
||||||
|
"console": {
|
||||||
|
"enabled": true,
|
||||||
|
"output": "stderr",
|
||||||
|
"ansi": true,
|
||||||
|
"format": "compact",
|
||||||
|
"filter": {
|
||||||
|
"level": "debug",
|
||||||
|
"targets": [
|
||||||
|
"*"
|
||||||
|
],
|
||||||
|
"domains": [
|
||||||
|
"*"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
{
|
||||||
|
"output_id": "file.all.debug",
|
||||||
|
"enabled": true,
|
||||||
|
"path": "debug/ksp-debug.log",
|
||||||
|
"rotation": "daily",
|
||||||
|
"format": "human",
|
||||||
|
"ansi": false,
|
||||||
|
"filter": {
|
||||||
|
"level": "debug",
|
||||||
|
"targets": [
|
||||||
|
"*"
|
||||||
|
],
|
||||||
|
"domains": [
|
||||||
|
"*"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"output_id": "file.config.error",
|
||||||
|
"enabled": true,
|
||||||
|
"path": "config/ksp-config-errors.jsonl",
|
||||||
|
"rotation": "daily",
|
||||||
|
"format": "json",
|
||||||
|
"ansi": false,
|
||||||
|
"filter": {
|
||||||
|
"level": "error",
|
||||||
|
"targets": [
|
||||||
|
"ksp-config-lib"
|
||||||
|
],
|
||||||
|
"domains": [
|
||||||
|
"config"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"target_filters": [
|
||||||
|
{
|
||||||
|
"target_prefix": "ksp-config-lib",
|
||||||
|
"level": "trace"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"target_prefix": "ksp-logging-lib",
|
||||||
|
"level": "debug"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
18
crates/ksp-config-lib/Cargo.toml
Normal file
18
crates/ksp-config-lib/Cargo.toml
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# file: crates/ksp-config-lib/Cargo.toml
|
||||||
|
# version: 3
|
||||||
|
|
||||||
|
[package]
|
||||||
|
name = "ksp-config-lib"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
ksp-core-lib = { path = "../ksp-core-lib" }
|
||||||
|
ksp-logging-lib = { path = "../ksp-logging-lib" }
|
||||||
|
serde.workspace = true
|
||||||
|
serde_json.workspace = true
|
||||||
|
jsonschema.workspace = true
|
||||||
|
|
||||||
|
[lints]
|
||||||
|
workspace = true
|
||||||
82
crates/ksp-config-lib/README.md
Normal file
82
crates/ksp-config-lib/README.md
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
<!-- file: crates/ksp-config-lib/README.md -->
|
||||||
|
<!-- version: 1 -->
|
||||||
|
|
||||||
|
# ksp-config-lib
|
||||||
|
|
||||||
|
`ksp-config-lib` est le propriétaire unique de la configuration applicative KSP.
|
||||||
|
|
||||||
|
La crate centralise les documents JSON, leurs schemas, les profils et compositions, les variables d'environnement `KSP_*` / `KSPB_*`, le fichier `.env`, la résolution effective et les mutations persistantes explicitement autorisées.
|
||||||
|
|
||||||
|
## Responsabilités
|
||||||
|
|
||||||
|
`ksp-config-lib` possède :
|
||||||
|
|
||||||
|
- le bootstrap non récursif `config/` / `config/schemas/` et les overrides `--cfgpath` / `--schemapath` ;
|
||||||
|
- le registre logique `file_id -> filename` et les overrides `--filemap=<file_id>=<filename>` ;
|
||||||
|
- la lecture JSON et la validation JSON Schema Draft 2020-12 ;
|
||||||
|
- les invariants sémantiques KSP des documents connus ;
|
||||||
|
- les globals, `default_profile`, profils nommés et leur provenance ;
|
||||||
|
- les compositions génériques par `file_id`, sans dépendance à un filename physique ;
|
||||||
|
- le snapshot des variables process KSP/KSPB et la lecture de `./.env` ;
|
||||||
|
- la priorité `process > .env > fallback > missing` ;
|
||||||
|
- les placeholders `${NAME}` et `${NAME:-fallback}` ;
|
||||||
|
- la classification `Public`, `Internal`, `Secret` ;
|
||||||
|
- les représentations réelle et sûre/redacted ainsi que la provenance des valeurs résolues ;
|
||||||
|
- l'adapter du document Logging effectif vers `ksp_logging_lib::LoggingSettings` ;
|
||||||
|
- la surface de management pour inspecter les sources, modifier `std.logging.json`, consulter les rapports d'environnement, révéler explicitement une valeur réelle et modifier `.env` ;
|
||||||
|
- les écritures atomiques JSON/`.env` et la protection des permissions `.env` ;
|
||||||
|
- les audits workspace empêchant les bypass d'ownership Config et les oublis dans `.env.example`.
|
||||||
|
|
||||||
|
## Ressources gérées dans `0.1.3`
|
||||||
|
|
||||||
|
Le registre par défaut connaît :
|
||||||
|
|
||||||
|
```text
|
||||||
|
cfg.std.logging -> config/std.logging.json
|
||||||
|
schema.std.logging -> config/schemas/std.logging.schema.json
|
||||||
|
schema.composite -> config/schemas/composite.schema.json
|
||||||
|
```
|
||||||
|
|
||||||
|
`config/examples/composite.example.json` démontre le format composite sans créer de composite runtime fictif.
|
||||||
|
|
||||||
|
Le fichier local d'environnement est :
|
||||||
|
|
||||||
|
```text
|
||||||
|
./.env
|
||||||
|
```
|
||||||
|
|
||||||
|
Il n'est ni versionné ni livré. Le dépôt maintient `/.env.example` comme inventaire versionné des variables runtime utilisées. Toute nouvelle variable KSP/KSPB concrète doit y être ajoutée avec un commentaire d'usage dans le même delta que sa première utilisation.
|
||||||
|
|
||||||
|
## Frontières
|
||||||
|
|
||||||
|
Les autres crates et applications KSP ne doivent pas :
|
||||||
|
|
||||||
|
- lire directement les variables applicatives `KSP_*` / `KSPB_*` ;
|
||||||
|
- parser ou écrire directement `.env` ;
|
||||||
|
- ouvrir directement les documents Config connus par leur filename physique ;
|
||||||
|
- réimplémenter la sélection de profils, les compositions ou les placeholders ;
|
||||||
|
- reconstruire elles-mêmes la configuration Logging depuis le JSON.
|
||||||
|
|
||||||
|
`ksp-config-lib` dépend de `ksp-core-lib` pour `Error`/`Result` et de `ksp-logging-lib` pour les événements Config utiles et le contrat `LoggingSettings`.
|
||||||
|
|
||||||
|
La dépendance inverse est interdite : `ksp-core-lib` et `ksp-logging-lib` ne dépendent pas de Config.
|
||||||
|
|
||||||
|
Config ne possède pas le `LoggingGuard`. L'application ou le service qui orchestre le runtime construit la configuration effective puis possède le lifecycle `ksp_logging_lib::initialize/reinitialize`.
|
||||||
|
|
||||||
|
Tauri et les DTO TS-RS restent hors de cette crate. La future `ksp-app-config-desk` doit rester une frontière applicative mince au-dessus des APIs Config.
|
||||||
|
|
||||||
|
## Secrets
|
||||||
|
|
||||||
|
Un secret reste accessible au runtime ou au management lorsqu'un consumer autorisé en a réellement besoin, mais les vues ordinaires utilisent la représentation sûre.
|
||||||
|
|
||||||
|
Les méthodes `reveal_*` constituent un opt-in explicite au réel. L'authentification/autorisation de l'utilisateur humain appartient à l'application appelante et les valeurs retournées par ces méthodes ne doivent jamais être journalisées.
|
||||||
|
|
||||||
|
Le document Logging refuse les valeurs de sensibilité `Secret` dans sa configuration effective.
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
- [`USAGE.md`](USAGE.md) — construction du moteur, résolution runtime et management ;
|
||||||
|
- [`TODO.md`](TODO.md) — points explicitement différés après `0.1.3` ;
|
||||||
|
- [`../../docs/plans/005-V0_1_3_CONFIG_FOUNDATION_PLAN.md`](../../docs/plans/005-V0_1_3_CONFIG_FOUNDATION_PLAN.md) — plan historique détaillé de la fondation Config ;
|
||||||
|
- [`../../config/std.logging.json`](../../config/std.logging.json) — premier document standard concret ;
|
||||||
|
- [`../../.env.example`](../../.env.example) — inventaire versionné des variables d'environnement runtime.
|
||||||
38
crates/ksp-config-lib/TODO.md
Normal file
38
crates/ksp-config-lib/TODO.md
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
<!-- file: crates/ksp-config-lib/TODO.md -->
|
||||||
|
<!-- version: 1 -->
|
||||||
|
|
||||||
|
# TODO ksp-config-lib
|
||||||
|
|
||||||
|
## État de clôture `0.1.3`
|
||||||
|
|
||||||
|
Aucun TODO fonctionnel bloquant n'est ouvert pour la fondation Config `0.1.3`.
|
||||||
|
|
||||||
|
Les responsabilités prévues pour cette release sont implémentées et couvertes par les tests : bootstrap, registre `file_id`, JSON/JSON Schema, profils, composites, environnement process/`.env`, placeholders, sensibilité/provenance, adapter Logging, management/persistence et audits d'ownership.
|
||||||
|
|
||||||
|
## Reporté explicitement à `0.1.4`
|
||||||
|
|
||||||
|
La validation applicative desktop appartient à `ksp-app-config-desk` :
|
||||||
|
|
||||||
|
- frontière Tauri et DTO TS-RS applicatifs ;
|
||||||
|
- affichage des sources et diagnostics Config ;
|
||||||
|
- sélection/inspection des profils ;
|
||||||
|
- affichage desired/effective/shadow des variables ;
|
||||||
|
- actions explicites de reveal de secrets avec contrôle d'autorisation côté application ;
|
||||||
|
- édition/sauvegarde de `std.logging.json` via `ConfigManagement` ;
|
||||||
|
- édition de `.env` via `ConfigManagement` ;
|
||||||
|
- orchestration réelle `Config -> LoggingSettings -> initialize/reinitialize` avec `LoggingGuard` possédé par l'application ;
|
||||||
|
- validation UX des erreurs de source invalide, des modifications non effectives car masquées par le process et des besoins de reload.
|
||||||
|
|
||||||
|
Ces points ne nécessitent pas de duplication de logique dans `ksp-config-lib`; toute lacune réelle révélée par l'application ouvrira un delta Config explicite.
|
||||||
|
|
||||||
|
## Futur, uniquement au besoin
|
||||||
|
|
||||||
|
Les capacités suivantes sont différées jusqu'à l'apparition de composants réels :
|
||||||
|
|
||||||
|
- nouveaux documents `std.<domain>.json` et schemas associés ;
|
||||||
|
- descriptors `cfg.composite.<consumer>` pour de vrais consumers ;
|
||||||
|
- contrats typés de management supplémentaires pour les nouveaux documents ;
|
||||||
|
- watcher filesystem/reload automatique si une application ou un service démontre le besoin ;
|
||||||
|
- intégration éventuelle d'un secrets manager externe.
|
||||||
|
|
||||||
|
Ne pas introduire par anticipation un JSON patch arbitraire, un watcher générique, un service distribué de configuration ou un chiffrement maison de `.env`.
|
||||||
287
crates/ksp-config-lib/USAGE.md
Normal file
287
crates/ksp-config-lib/USAGE.md
Normal file
@@ -0,0 +1,287 @@
|
|||||||
|
<!-- file: crates/ksp-config-lib/USAGE.md -->
|
||||||
|
<!-- version: 2 -->
|
||||||
|
|
||||||
|
# Utilisation de ksp-config-lib
|
||||||
|
|
||||||
|
## 1. Bootstrap et moteur documentaire
|
||||||
|
|
||||||
|
Config doit interpréter ses propres arguments de bootstrap avant toute lecture de document :
|
||||||
|
|
||||||
|
```rust
|
||||||
|
let args: std::vec::Vec<std::ffi::OsString> = std::env::args_os().collect();
|
||||||
|
|
||||||
|
let bootstrap = match ksp_config_lib::ConfigBootstrapOptions::from_args(args.as_slice()) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
|
||||||
|
let registry = match ksp_config_lib::ConfigFileRegistry::from_args(args.as_slice()) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
|
||||||
|
let engine = ksp_config_lib::ConfigDocumentEngine::new(bootstrap, registry);
|
||||||
|
```
|
||||||
|
|
||||||
|
Les arguments compris par Config sont :
|
||||||
|
|
||||||
|
```text
|
||||||
|
--cfgpath=/path/to/config
|
||||||
|
--schemapath=/path/to/schemas
|
||||||
|
--filemap=cfg.std.logging=my-logging.json
|
||||||
|
```
|
||||||
|
|
||||||
|
`cfgpath` et `schemapath` ne sont jamais lus depuis JSON, `.env` ou une variable KSP : cette règle évite un bootstrap récursif.
|
||||||
|
|
||||||
|
## 2. Charger et valider un document connu
|
||||||
|
|
||||||
|
Les consumers utilisent un `file_id` logique :
|
||||||
|
|
||||||
|
```rust
|
||||||
|
let file_id = match ksp_config_lib::ConfigFileId::new(ksp_config_lib::FILE_ID_STD_LOGGING) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
|
||||||
|
let document = engine.load_validated_document(&file_id);
|
||||||
|
```
|
||||||
|
|
||||||
|
Le moteur résout le path physique via le registre, charge le schema associé, valide le schema lui-même, valide l'instance puis applique les invariants sémantiques KSP.
|
||||||
|
|
||||||
|
## 3. Environnement effectif
|
||||||
|
|
||||||
|
`ConfigEnvironment::load()` capture les variables process KSP/KSPB et lit `./.env` :
|
||||||
|
|
||||||
|
```rust
|
||||||
|
let environment = match ksp_config_lib::ConfigEnvironment::load() {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
La priorité est :
|
||||||
|
|
||||||
|
```text
|
||||||
|
process environment > .env > fallback > missing
|
||||||
|
```
|
||||||
|
|
||||||
|
Une chaîne vide explicitement présente est une valeur définie ; elle ne provoque pas l'utilisation du fallback.
|
||||||
|
|
||||||
|
Exemples de placeholders :
|
||||||
|
|
||||||
|
```text
|
||||||
|
${KSP_LOGS_DIRECTORY}
|
||||||
|
${KSP_LOGS_DIRECTORY:-logs}
|
||||||
|
```
|
||||||
|
|
||||||
|
Pour conserver la sensibilité et la provenance, préférer les variantes détaillées :
|
||||||
|
|
||||||
|
```rust
|
||||||
|
let resolved = environment.resolve_text_detailed("${KSP_SECRET_EXAMPLE}");
|
||||||
|
```
|
||||||
|
|
||||||
|
`ResolvedConfigText` / `ResolvedConfigJson` séparent valeur réelle et valeur sûre. Une représentation `Debug` ne doit pas révéler le réel d'un secret.
|
||||||
|
|
||||||
|
## 4. Construire Logging depuis Config
|
||||||
|
|
||||||
|
Le chemin normal consiste à charger le profil Logging, résoudre l'environnement puis construire directement le contrat Logging public :
|
||||||
|
|
||||||
|
```rust
|
||||||
|
let resolved = match engine.load_resolved_logging_config(std::option::Option::None, &environment) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
|
||||||
|
let settings = resolved.into_settings();
|
||||||
|
let initialized = ksp_logging_lib::initialize(&settings);
|
||||||
|
let mut logging_guard = match initialized {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
L'application/service possède `logging_guard`. Config ne conserve pas de singleton Logging.
|
||||||
|
|
||||||
|
Un `logs_directory` relatif est ancré sur le current working directory du processus. Un path absolu est conservé. Une valeur explicite invalide produit une erreur effective : elle ne retombe pas silencieusement sur le fallback du placeholder.
|
||||||
|
|
||||||
|
Les `files[].path` restent relatifs sous le root Logging, y compris après interpolation.
|
||||||
|
|
||||||
|
## 5. Profils et composites
|
||||||
|
|
||||||
|
Pour un document standard profilé :
|
||||||
|
|
||||||
|
```rust
|
||||||
|
let profile = engine.load_resolved_profile(&file_id, std::option::Option::None);
|
||||||
|
```
|
||||||
|
|
||||||
|
`None` utilise le `default_profile`; `Some("profile_id")` impose un profil explicite.
|
||||||
|
|
||||||
|
Un composite référence les documents par `file_id`, jamais par filename. `load_resolved_composite(...)` conserve chaque `ResolvedConfigProfile` composant et sa provenance plutôt que d'aplatir plusieurs domaines dans une map ambiguë.
|
||||||
|
|
||||||
|
## 6. Management de `std.logging.json`
|
||||||
|
|
||||||
|
Une application de management construit la façade à partir d'un moteur :
|
||||||
|
|
||||||
|
```rust
|
||||||
|
let management = ksp_config_lib::ConfigManagement::new(engine);
|
||||||
|
|
||||||
|
let document = match management.load_logging_document() {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
Le type `LoggingConfigDocument` et ses sous-structures exposent des setters/mutators typés. Après modification, la sauvegarde :
|
||||||
|
|
||||||
|
```rust
|
||||||
|
let saved = management.save_logging_document(&document);
|
||||||
|
```
|
||||||
|
|
||||||
|
valide le candidat complet avant toute substitution du fichier. Un candidat invalide ne remplace pas la source existante.
|
||||||
|
|
||||||
|
`read_source(file_id)` reste disponible pour une UI de réparation : il peut lire le texte brut d'un document enregistré même lorsque son JSON ou son schema est invalide. Il n'ouvre pas un path arbitraire.
|
||||||
|
|
||||||
|
## 7. Management de `.env`
|
||||||
|
|
||||||
|
Les rapports ordinaires sont sûrs :
|
||||||
|
|
||||||
|
```rust
|
||||||
|
let report = management.environment_report();
|
||||||
|
```
|
||||||
|
|
||||||
|
Ils distinguent notamment valeur souhaitée `.env`, valeur effective, source et shadowing process sans exposer un secret réel.
|
||||||
|
|
||||||
|
L'accès au réel est volontairement explicite :
|
||||||
|
|
||||||
|
```rust
|
||||||
|
let effective = management.reveal_effective_environment_value("KSP_SECRET_EXAMPLE");
|
||||||
|
let persisted = management.reveal_dotenv_value("KSP_SECRET_EXAMPLE");
|
||||||
|
```
|
||||||
|
|
||||||
|
Une application doit contrôler l'autorisation de l'utilisateur avant ces appels et ne jamais journaliser les valeurs retournées.
|
||||||
|
|
||||||
|
Les mutations persistantes utilisent :
|
||||||
|
|
||||||
|
```rust
|
||||||
|
let changed = management.set_dotenv_value("KSP_LOGS_DIRECTORY", "logs");
|
||||||
|
let removed = management.remove_dotenv_value("KSP_LOGS_DIRECTORY");
|
||||||
|
```
|
||||||
|
|
||||||
|
Elles n'altèrent jamais l'environnement hérité du processus. Une valeur process peut donc masquer une modification `.env`; `ConfigEnvironmentChangeReport` distingue `source_changed`, `effective_changed`, `shadowed_by_process_environment` et `reload_required`.
|
||||||
|
|
||||||
|
Sur Unix, un nouveau `.env` est créé avec des permissions privées `0600`; les permissions existantes sont préservées lors des remplacements atomiques.
|
||||||
|
|
||||||
|
## 8. `.env.example`
|
||||||
|
|
||||||
|
`/.env.example` est l'inventaire versionné. `/.env` reste local et ignoré.
|
||||||
|
|
||||||
|
Toute nouvelle variable runtime concrète `KSP_*` / `KSPB_*` introduite dans le code ou les documents Config doit être ajoutée à `.env.example` avec un commentaire expliquant son usage. Les audits `ksp-config-lib/tests/ownership.rs` font échouer `cargo test` lorsqu'une clé concrète est oubliée.
|
||||||
|
|
||||||
|
## 9. Frontière Tauri
|
||||||
|
|
||||||
|
Une application Tauri doit appeler les APIs ci-dessus via ses commandes/DTO applicatifs. Elle ne lit ni JSON ni `.env` directement et ne résout jamais elle-même les placeholders.
|
||||||
|
|
||||||
|
Les valeurs `Secret` ne doivent pas être incluses par défaut dans les DTO publics. Une action UI explicitement autorisée peut appeler une méthode `reveal_*` et transporter le résultat par un DTO spécifique, sans log ni diagnostic contenant la valeur réelle.
|
||||||
|
|
||||||
|
## 10. Index de la surface publique
|
||||||
|
|
||||||
|
Ce guide reste volontairement indépendant des numéros de release. Les contrats publics sont regroupés ci-dessous par usage ; les constantes de noms/erreurs accompagnent les mêmes familles et ne constituent pas des workflows séparés.
|
||||||
|
|
||||||
|
| Famille publique | Contrats principaux | Exemple |
|
||||||
|
|------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------|--------------------------|
|
||||||
|
| Bootstrap | `ConfigBootstrapOptions`, `ARG_CFG_PATH`, `ARG_SCHEMA_PATH`, `DEFAULT_CFG_PATH`, `DEFAULT_SCHEMA_PATH` | §1 |
|
||||||
|
| Registre logique | `ConfigFileRegistry`, `ConfigFileId`, `ConfigFileDescriptor`, `ConfigFileKind`, `ARG_FILE_MAP`, constantes `FILE_ID_*` / `DEFAULT_*_FILENAME` | §1–2 |
|
||||||
|
| Documents | `ConfigDocumentEngine`, `ConfigJsonDocument` | §2 |
|
||||||
|
| Profils | `ResolvedConfigProfile`, `ConfigProfileSelectionSource`, `ConfigValueOrigin` | §5 |
|
||||||
|
| Composites | `ResolvedConfigComposite`, `ResolvedCompositeComponent` | §5 |
|
||||||
|
| Environnement | `ConfigEnvironment`, `ConfigEnvironmentSource`, `ConfigEnvironmentValue`, `DEFAULT_DOTENV_PATH`, `DEFAULT_DOTENV_EXAMPLE_PATH` | §3, §7–8 |
|
||||||
|
| Sensibilité/provenance | `ConfigSensitivity`, `ConfigValueProvenance`, `ResolvedConfigText`, `ResolvedConfigJson`, `REDACTED_CONFIG_VALUE` | §3 |
|
||||||
|
| Logging effectif | `ResolvedLoggingConfig` | §4 |
|
||||||
|
| Management | `ConfigManagement`, `ConfigManagedSource`, `ConfigDocumentChangeReport`, `ConfigEnvironmentReport`, `ConfigEnvironmentChangeReport` | §6–7 |
|
||||||
|
| Source Logging typée | `LoggingConfigDocument`, `LoggingProfileConfig`, `LoggingConsoleConfig`, `LoggingFileConfig`, `LoggingOutputFilterConfig`, `LoggingTargetFilterConfig` | §6 et exemple ci-dessous |
|
||||||
|
| Erreurs Config | constantes `ERROR_CODE_*` réexportées par la crate | exemple ci-dessous |
|
||||||
|
|
||||||
|
### 10.1 Modifier une configuration Logging typée
|
||||||
|
|
||||||
|
Les getters permettent d'inspecter la source ; les setters et vues `*_mut()` permettent de construire un candidat avant validation/persistence :
|
||||||
|
|
||||||
|
```rust
|
||||||
|
let mut document = match management.load_logging_document() {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
|
||||||
|
document.set_logs_directory("${KSP_LOGS_DIRECTORY:-logs}");
|
||||||
|
|
||||||
|
if let std::option::Option::Some(profile) = document.profiles_mut().first_mut() {
|
||||||
|
profile.set_default_filter("debug");
|
||||||
|
profile.console_mut().set_enabled(true);
|
||||||
|
profile.console_mut().filter_mut().set_level("info");
|
||||||
|
profile.console_mut().filter_mut().domains_mut().push("config".to_owned());
|
||||||
|
}
|
||||||
|
|
||||||
|
let saved = match management.save_logging_document(&document) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
|
||||||
|
if saved.source_changed() && saved.reload_required() {
|
||||||
|
// The application decides when/how to reload the affected runtime consumer.
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
La construction depuis zéro utilise les constructeurs publics `LoggingConfigDocument::new`, `LoggingProfileConfig::new`, `LoggingConsoleConfig::new`, `LoggingFileConfig::new`, `LoggingOutputFilterConfig::new` et `LoggingTargetFilterConfig::new`. Les mêmes contraintes schema/sémantiques sont appliquées au moment de `save_logging_document()`.
|
||||||
|
|
||||||
|
### 10.2 Inspecter un source enregistré sans contourner Config
|
||||||
|
|
||||||
|
```rust
|
||||||
|
let source = match management.read_source(&file_id) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
|
||||||
|
let logical_id = source.file_id();
|
||||||
|
let managed_path = source.path();
|
||||||
|
let raw_content = source.content();
|
||||||
|
```
|
||||||
|
|
||||||
|
Cette API est notamment destinée à une UI de réparation lorsque le document n'est plus validable. Elle n'autorise pas la lecture d'un chemin arbitraire.
|
||||||
|
|
||||||
|
### 10.3 Exploiter les rapports `.env`
|
||||||
|
|
||||||
|
```rust
|
||||||
|
let reports = match management.environment_report() {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
|
||||||
|
for report in reports {
|
||||||
|
let name = report.variable_name();
|
||||||
|
let sensitivity = report.sensitivity();
|
||||||
|
let desired = report.desired_safe_value();
|
||||||
|
let effective = report.effective_safe_value();
|
||||||
|
let source = report.effective_source();
|
||||||
|
let shadowed = report.shadowed_by_process_environment();
|
||||||
|
let _ = (name, sensitivity, desired, effective, source, shadowed);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Après une mutation, `ConfigEnvironmentChangeReport` expose `source_changed()`, `effective_changed()`, `shadowed_by_process_environment()` et `reload_required()`.
|
||||||
|
|
||||||
|
### 10.4 Distinguer un code d'erreur Config
|
||||||
|
|
||||||
|
Les codes publics permettent à une UI/service de brancher sa logique sans parser le texte du message :
|
||||||
|
|
||||||
|
```rust
|
||||||
|
let loaded = engine.load_validated_document(&file_id);
|
||||||
|
|
||||||
|
if let std::result::Result::Err(error) = loaded {
|
||||||
|
if error.code() == ksp_config_lib::ERROR_CODE_SCHEMA_VALIDATION_FAILED {
|
||||||
|
// Present a schema-specific diagnostic path to the caller.
|
||||||
|
}
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Le message/context d'erreur reste destiné au diagnostic ; l'identité machine-readable passe par `ErrorCode`.
|
||||||
|
|
||||||
205
crates/ksp-config-lib/src/bootstrap.rs
Normal file
205
crates/ksp-config-lib/src/bootstrap.rs
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
// file: crates/ksp-config-lib/src/bootstrap.rs
|
||||||
|
// version: 1
|
||||||
|
|
||||||
|
/// Default root containing KSP runtime configuration documents.
|
||||||
|
pub const DEFAULT_CFG_PATH: &str = "config";
|
||||||
|
/// Default root containing KSP JSON schemas.
|
||||||
|
pub const DEFAULT_SCHEMA_PATH: &str = "config/schemas";
|
||||||
|
/// Bootstrap argument used to replace the configuration document root.
|
||||||
|
pub const ARG_CFG_PATH: &str = "--cfgpath";
|
||||||
|
/// Bootstrap argument used to replace the schema root.
|
||||||
|
pub const ARG_SCHEMA_PATH: &str = "--schemapath";
|
||||||
|
|
||||||
|
/// Non-recursive bootstrap options required before Config can resolve any managed document.
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub struct ConfigBootstrapOptions {
|
||||||
|
cfg_path: std::path::PathBuf,
|
||||||
|
schema_path: std::path::PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ConfigBootstrapOptions {
|
||||||
|
/// Creates bootstrap options using the KSP hardcoded configuration and schema roots.
|
||||||
|
pub fn defaults() -> ksp_core_lib::Result<Self> {
|
||||||
|
return Self::from_paths(crate::DEFAULT_CFG_PATH, crate::DEFAULT_SCHEMA_PATH);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates bootstrap options from explicit programmatic configuration and schema roots.
|
||||||
|
pub fn from_paths(
|
||||||
|
cfg_path: impl std::convert::Into<std::path::PathBuf>,
|
||||||
|
schema_path: impl std::convert::Into<std::path::PathBuf>,
|
||||||
|
) -> ksp_core_lib::Result<Self> {
|
||||||
|
let cfg_path = validate_bootstrap_path(crate::ARG_CFG_PATH, cfg_path.into());
|
||||||
|
let cfg_path = match cfg_path {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let schema_path = validate_bootstrap_path(crate::ARG_SCHEMA_PATH, schema_path.into());
|
||||||
|
return match schema_path {
|
||||||
|
std::result::Result::Ok(value) => std::result::Result::Ok(Self { cfg_path, schema_path: value }),
|
||||||
|
std::result::Result::Err(error) => std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parses the KSP-owned bootstrap path arguments from a raw process argument slice.
|
||||||
|
///
|
||||||
|
/// Both `--cfgpath=value` / `--cfgpath value` and `--schemapath=value` / `--schemapath value` are accepted. Unrelated arguments are ignored so an
|
||||||
|
/// application can pass its complete argument vector. When the same bootstrap path is specified more than once, the last explicit value wins.
|
||||||
|
pub fn from_args(args: &[std::ffi::OsString]) -> ksp_core_lib::Result<Self> {
|
||||||
|
let mut options = Self::defaults_unchecked();
|
||||||
|
let mut index: usize = 0;
|
||||||
|
while index < args.len() {
|
||||||
|
let argument = &args[index];
|
||||||
|
if argument.as_os_str() == std::ffi::OsStr::new(crate::ARG_CFG_PATH) {
|
||||||
|
let parsed = parse_separate_path_argument(args, index, crate::ARG_CFG_PATH);
|
||||||
|
match parsed {
|
||||||
|
std::result::Result::Ok((path, next_index)) => {
|
||||||
|
options.cfg_path = path;
|
||||||
|
index = next_index;
|
||||||
|
},
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
}
|
||||||
|
} else if argument.as_os_str() == std::ffi::OsStr::new(crate::ARG_SCHEMA_PATH) {
|
||||||
|
let parsed = parse_separate_path_argument(args, index, crate::ARG_SCHEMA_PATH);
|
||||||
|
match parsed {
|
||||||
|
std::result::Result::Ok((path, next_index)) => {
|
||||||
|
options.schema_path = path;
|
||||||
|
index = next_index;
|
||||||
|
},
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let inline = parse_inline_path_argument(argument);
|
||||||
|
match inline {
|
||||||
|
std::option::Option::Some((kind, path)) => match kind {
|
||||||
|
BootstrapPathKind::Config => options.cfg_path = path,
|
||||||
|
BootstrapPathKind::Schema => options.schema_path = path,
|
||||||
|
},
|
||||||
|
std::option::Option::None => {},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
index += 1;
|
||||||
|
}
|
||||||
|
return Self::from_paths(options.cfg_path, options.schema_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the root used for managed runtime configuration documents.
|
||||||
|
#[must_use]
|
||||||
|
pub fn cfg_path(&self) -> &std::path::Path {
|
||||||
|
return self.cfg_path.as_path();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the root used for managed JSON schemas.
|
||||||
|
#[must_use]
|
||||||
|
pub fn schema_path(&self) -> &std::path::Path {
|
||||||
|
return self.schema_path.as_path();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Replaces the configuration document root after applying bootstrap path validation.
|
||||||
|
pub fn with_cfg_path(self, path: impl std::convert::Into<std::path::PathBuf>) -> ksp_core_lib::Result<Self> {
|
||||||
|
let validated = validate_bootstrap_path(crate::ARG_CFG_PATH, path.into());
|
||||||
|
return match validated {
|
||||||
|
std::result::Result::Ok(cfg_path) => std::result::Result::Ok(Self { cfg_path, schema_path: self.schema_path }),
|
||||||
|
std::result::Result::Err(error) => std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Replaces the schema root after applying bootstrap path validation.
|
||||||
|
pub fn with_schema_path(self, path: impl std::convert::Into<std::path::PathBuf>) -> ksp_core_lib::Result<Self> {
|
||||||
|
let validated = validate_bootstrap_path(crate::ARG_SCHEMA_PATH, path.into());
|
||||||
|
return match validated {
|
||||||
|
std::result::Result::Ok(schema_path) => std::result::Result::Ok(Self { cfg_path: self.cfg_path, schema_path }),
|
||||||
|
std::result::Result::Err(error) => std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn defaults_unchecked() -> Self {
|
||||||
|
return Self {
|
||||||
|
cfg_path: std::path::PathBuf::from(crate::DEFAULT_CFG_PATH),
|
||||||
|
schema_path: std::path::PathBuf::from(crate::DEFAULT_SCHEMA_PATH),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
enum BootstrapPathKind {
|
||||||
|
Config,
|
||||||
|
Schema,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_inline_path_argument(argument: &std::ffi::OsStr) -> std::option::Option<(BootstrapPathKind, std::path::PathBuf)> {
|
||||||
|
let text = match argument.to_str() {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => return std::option::Option::None,
|
||||||
|
};
|
||||||
|
let cfg_prefix = "--cfgpath=";
|
||||||
|
let schema_prefix = "--schemapath=";
|
||||||
|
if let std::option::Option::Some(value) = text.strip_prefix(cfg_prefix) {
|
||||||
|
return std::option::Option::Some((BootstrapPathKind::Config, std::path::PathBuf::from(value)));
|
||||||
|
}
|
||||||
|
if let std::option::Option::Some(value) = text.strip_prefix(schema_prefix) {
|
||||||
|
return std::option::Option::Some((BootstrapPathKind::Schema, std::path::PathBuf::from(value)));
|
||||||
|
}
|
||||||
|
return std::option::Option::None;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_separate_path_argument(args: &[std::ffi::OsString], index: usize, argument_name: &'static str) -> ksp_core_lib::Result<(std::path::PathBuf, usize)> {
|
||||||
|
let value_index = index + 1;
|
||||||
|
if value_index >= args.len() {
|
||||||
|
return std::result::Result::Err(missing_argument_value_error(argument_name));
|
||||||
|
}
|
||||||
|
let value = &args[value_index];
|
||||||
|
let option_like = match value.to_str() {
|
||||||
|
std::option::Option::Some(text) => text.starts_with("--"),
|
||||||
|
std::option::Option::None => false,
|
||||||
|
};
|
||||||
|
if option_like {
|
||||||
|
return std::result::Result::Err(missing_argument_value_error(argument_name));
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok((std::path::PathBuf::from(value.as_os_str()), value_index));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_bootstrap_path(argument_name: &'static str, path: std::path::PathBuf) -> ksp_core_lib::Result<std::path::PathBuf> {
|
||||||
|
if path.as_os_str().is_empty() {
|
||||||
|
return std::result::Result::Err(invalid_path_error(argument_name, &path, "path is empty"));
|
||||||
|
}
|
||||||
|
let metadata = std::fs::metadata(path.as_path());
|
||||||
|
return match metadata {
|
||||||
|
std::result::Result::Ok(value) => {
|
||||||
|
if value.is_dir() {
|
||||||
|
std::result::Result::Ok(path)
|
||||||
|
} else {
|
||||||
|
std::result::Result::Err(invalid_path_error(argument_name, &path, "existing path is not a directory"))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
std::result::Result::Err(error) => {
|
||||||
|
if error.kind() == std::io::ErrorKind::NotFound {
|
||||||
|
std::result::Result::Ok(path)
|
||||||
|
} else {
|
||||||
|
std::result::Result::Err(invalid_path_source_error(argument_name, &path, error))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn missing_argument_value_error(argument_name: &'static str) -> ksp_core_lib::Error {
|
||||||
|
return ksp_core_lib::Error::new(crate::ERROR_CODE_BOOTSTRAP_ARGUMENT_MISSING_VALUE, "Config bootstrap argument requires a path value")
|
||||||
|
.with_context("argument", argument_name);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn invalid_path_error(argument_name: &'static str, path: &std::path::Path, reason: &'static str) -> ksp_core_lib::Error {
|
||||||
|
return ksp_core_lib::Error::new(crate::ERROR_CODE_BOOTSTRAP_INVALID_PATH, "Config bootstrap path is invalid")
|
||||||
|
.with_context("argument", argument_name)
|
||||||
|
.with_context("path", path.to_string_lossy().into_owned())
|
||||||
|
.with_context("reason", reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn invalid_path_source_error(argument_name: &'static str, path: &std::path::Path, source: std::io::Error) -> ksp_core_lib::Error {
|
||||||
|
return ksp_core_lib::Error::new(crate::ERROR_CODE_BOOTSTRAP_INVALID_PATH, "Config bootstrap path cannot be inspected")
|
||||||
|
.with_context("argument", argument_name)
|
||||||
|
.with_context("path", path.to_string_lossy().into_owned())
|
||||||
|
.with_source(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "../unit_tests/bootstrap.rs"]
|
||||||
|
mod tests;
|
||||||
309
crates/ksp-config-lib/src/composite.rs
Normal file
309
crates/ksp-config-lib/src/composite.rs
Normal file
@@ -0,0 +1,309 @@
|
|||||||
|
// file: crates/ksp-config-lib/src/composite.rs
|
||||||
|
// version: 1
|
||||||
|
|
||||||
|
/// One resolved document component selected by a composite profile.
|
||||||
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
|
pub struct ResolvedCompositeComponent {
|
||||||
|
component_id: String,
|
||||||
|
resolved: crate::ResolvedConfigProfile,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ResolvedCompositeComponent {
|
||||||
|
/// Returns the component identifier unique inside the selected composite profile.
|
||||||
|
#[must_use]
|
||||||
|
pub fn component_id(&self) -> &str {
|
||||||
|
return self.component_id.as_str();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the referenced standard document after global/profile resolution.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn resolved(&self) -> &crate::ResolvedConfigProfile {
|
||||||
|
return &self.resolved;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validated composite document resolved to one composite profile and all of its referenced standard documents.
|
||||||
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
|
pub struct ResolvedConfigComposite {
|
||||||
|
file_id: crate::ConfigFileId,
|
||||||
|
path: std::path::PathBuf,
|
||||||
|
profile_id: String,
|
||||||
|
selection_source: crate::ConfigProfileSelectionSource,
|
||||||
|
components: std::collections::BTreeMap<String, ResolvedCompositeComponent>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ResolvedConfigComposite {
|
||||||
|
/// Returns the logical composite file identifier.
|
||||||
|
#[must_use]
|
||||||
|
pub fn file_id(&self) -> &crate::ConfigFileId {
|
||||||
|
return &self.file_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the physical path of the validated composite source.
|
||||||
|
#[must_use]
|
||||||
|
pub fn path(&self) -> &std::path::Path {
|
||||||
|
return self.path.as_path();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the selected composite profile identifier.
|
||||||
|
#[must_use]
|
||||||
|
pub fn profile_id(&self) -> &str {
|
||||||
|
return self.profile_id.as_str();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns whether the composite profile came from `default_profile` or an explicit caller selection.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn selection_source(&self) -> crate::ConfigProfileSelectionSource {
|
||||||
|
return self.selection_source;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns all resolved components keyed by their composite-local `component_id`.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn components(&self) -> &std::collections::BTreeMap<String, ResolvedCompositeComponent> {
|
||||||
|
return &self.components;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns one resolved component by its composite-local identifier.
|
||||||
|
#[must_use]
|
||||||
|
pub fn component(&self, component_id: &str) -> std::option::Option<&ResolvedCompositeComponent> {
|
||||||
|
return self.components.get(component_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl crate::ConfigDocumentEngine {
|
||||||
|
/// Loads, validates and resolves one registered composite Config document.
|
||||||
|
///
|
||||||
|
/// Composite descriptors are introduced only when a concrete consumer exists. The generic contract and schema are available from `0.1.3-pre.009` onward.
|
||||||
|
/// Passing `None` selects the composite's `default_profile`; passing `Some(profile_id)` selects a composite profile explicitly.
|
||||||
|
pub fn load_resolved_composite(
|
||||||
|
&self,
|
||||||
|
file_id: &crate::ConfigFileId,
|
||||||
|
requested_profile: std::option::Option<&str>,
|
||||||
|
) -> ksp_core_lib::Result<ResolvedConfigComposite> {
|
||||||
|
let document = self.load_validated_document(file_id);
|
||||||
|
let document = match document {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
if !document.file_id().as_str().starts_with("cfg.composite.") {
|
||||||
|
return std::result::Result::Err(
|
||||||
|
ksp_core_lib::Error::new(crate::ERROR_CODE_COMPOSITE_REFERENCE_INVALID, "requested Config document is not a composite")
|
||||||
|
.with_context("file_id", document.file_id().as_str()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return resolve_composite_document(self, &document, requested_profile);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
struct CompositeDocumentSource {
|
||||||
|
format_version: u32,
|
||||||
|
default_profile: String,
|
||||||
|
profiles: std::vec::Vec<CompositeProfileSource>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
struct CompositeProfileSource {
|
||||||
|
profile_id: String,
|
||||||
|
documents: std::vec::Vec<CompositeDocumentReferenceSource>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
struct CompositeDocumentReferenceSource {
|
||||||
|
component_id: String,
|
||||||
|
file_id: String,
|
||||||
|
profile_id: std::option::Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn validate_composite_document_contract(engine: &crate::ConfigDocumentEngine, document: &crate::ConfigJsonDocument) -> ksp_core_lib::Result<()> {
|
||||||
|
if !document.file_id().as_str().starts_with("cfg.composite.") {
|
||||||
|
return std::result::Result::Ok(());
|
||||||
|
}
|
||||||
|
let source = parse_composite_source(document);
|
||||||
|
let source = match source {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
if source.format_version != 1 {
|
||||||
|
return std::result::Result::Err(composite_semantic_error(document, "unsupported composite format_version"));
|
||||||
|
}
|
||||||
|
if source.default_profile.trim().is_empty() {
|
||||||
|
return std::result::Result::Err(composite_semantic_error(document, "composite default_profile must not be empty"));
|
||||||
|
}
|
||||||
|
for (profile_index, profile) in source.profiles.iter().enumerate() {
|
||||||
|
if profile.profile_id.trim().is_empty() {
|
||||||
|
return std::result::Result::Err(
|
||||||
|
composite_semantic_error(document, "composite profile_id must not be empty").with_context("profile_index", profile_index.to_string()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let mut component_ids = std::collections::BTreeSet::<String>::new();
|
||||||
|
for (component_index, reference) in profile.documents.iter().enumerate() {
|
||||||
|
if component_ids.contains(reference.component_id.as_str()) {
|
||||||
|
return std::result::Result::Err(
|
||||||
|
composite_semantic_error(document, "component_id values must be unique inside one composite profile")
|
||||||
|
.with_context("profile_id", profile.profile_id.as_str())
|
||||||
|
.with_context("component_index", component_index.to_string())
|
||||||
|
.with_context("component_id", reference.component_id.as_str()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
component_ids.insert(reference.component_id.clone());
|
||||||
|
let validation = validate_reference(engine, document, profile, reference, component_index);
|
||||||
|
if let std::result::Result::Err(error) = validation {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_composite_document(
|
||||||
|
engine: &crate::ConfigDocumentEngine,
|
||||||
|
document: &crate::ConfigJsonDocument,
|
||||||
|
requested_profile: std::option::Option<&str>,
|
||||||
|
) -> ksp_core_lib::Result<ResolvedConfigComposite> {
|
||||||
|
let source = parse_composite_source(document);
|
||||||
|
let source = match source {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let (selected_profile_id, selection_source) = match requested_profile {
|
||||||
|
std::option::Option::Some(value) => (value, crate::ConfigProfileSelectionSource::Explicit),
|
||||||
|
std::option::Option::None => (source.default_profile.as_str(), crate::ConfigProfileSelectionSource::DefaultProfile),
|
||||||
|
};
|
||||||
|
let mut selected: std::option::Option<&CompositeProfileSource> = std::option::Option::None;
|
||||||
|
for profile in &source.profiles {
|
||||||
|
if profile.profile_id == selected_profile_id {
|
||||||
|
selected = std::option::Option::Some(profile);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let selected = match selected {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => {
|
||||||
|
return std::result::Result::Err(
|
||||||
|
ksp_core_lib::Error::new(crate::ERROR_CODE_PROFILE_NOT_FOUND, "requested composite Config profile does not exist")
|
||||||
|
.with_context("file_id", document.file_id().as_str())
|
||||||
|
.with_context("path", document.path().to_string_lossy().into_owned())
|
||||||
|
.with_context("profile_id", selected_profile_id),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let mut components = std::collections::BTreeMap::<String, ResolvedCompositeComponent>::new();
|
||||||
|
for reference in &selected.documents {
|
||||||
|
let file_id = crate::ConfigFileId::new(reference.file_id.as_str());
|
||||||
|
let file_id = match file_id {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let resolved = crate::profile::load_resolved_profile_with_source(
|
||||||
|
engine,
|
||||||
|
&file_id,
|
||||||
|
reference.profile_id.as_deref(),
|
||||||
|
crate::ConfigProfileSelectionSource::Composite,
|
||||||
|
);
|
||||||
|
let resolved = match resolved {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let component = ResolvedCompositeComponent { component_id: reference.component_id.clone(), resolved };
|
||||||
|
components.insert(reference.component_id.clone(), component);
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(ResolvedConfigComposite {
|
||||||
|
file_id: document.file_id().clone(),
|
||||||
|
path: document.path().to_path_buf(),
|
||||||
|
profile_id: selected_profile_id.to_owned(),
|
||||||
|
selection_source,
|
||||||
|
components,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_reference(
|
||||||
|
engine: &crate::ConfigDocumentEngine,
|
||||||
|
document: &crate::ConfigJsonDocument,
|
||||||
|
profile: &CompositeProfileSource,
|
||||||
|
reference: &CompositeDocumentReferenceSource,
|
||||||
|
component_index: usize,
|
||||||
|
) -> ksp_core_lib::Result<()> {
|
||||||
|
if reference.component_id.trim().is_empty() {
|
||||||
|
return std::result::Result::Err(composite_reference_error(document, profile, reference, component_index, "component_id must not be empty"));
|
||||||
|
}
|
||||||
|
let file_id = crate::ConfigFileId::new(reference.file_id.as_str());
|
||||||
|
let file_id = match file_id {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => {
|
||||||
|
return std::result::Result::Err(composite_reference_error(document, profile, reference, component_index, "referenced file_id is invalid"));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
if !file_id.as_str().starts_with("cfg.std.") {
|
||||||
|
return std::result::Result::Err(composite_reference_error(
|
||||||
|
document,
|
||||||
|
profile,
|
||||||
|
reference,
|
||||||
|
component_index,
|
||||||
|
"composite references must target standard Config document file_ids",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let descriptor = engine.registry().descriptor(&file_id);
|
||||||
|
let descriptor = match descriptor {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => {
|
||||||
|
return std::result::Result::Err(composite_reference_error(document, profile, reference, component_index, "referenced file_id is not registered"));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
if descriptor.kind() != crate::ConfigFileKind::Config {
|
||||||
|
return std::result::Result::Err(composite_reference_error(
|
||||||
|
document,
|
||||||
|
profile,
|
||||||
|
reference,
|
||||||
|
component_index,
|
||||||
|
"referenced file_id is not a Config document",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let resolved =
|
||||||
|
crate::profile::load_resolved_profile_with_source(engine, &file_id, reference.profile_id.as_deref(), crate::ConfigProfileSelectionSource::Composite);
|
||||||
|
if let std::result::Result::Err(error) = resolved {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_composite_source(document: &crate::ConfigJsonDocument) -> ksp_core_lib::Result<CompositeDocumentSource> {
|
||||||
|
let source = serde_json::from_value::<CompositeDocumentSource>(document.value().clone());
|
||||||
|
return match source {
|
||||||
|
std::result::Result::Ok(value) => std::result::Result::Ok(value),
|
||||||
|
std::result::Result::Err(error) => std::result::Result::Err(
|
||||||
|
composite_semantic_error(document, "schema-valid composite document cannot be decoded into the KSP source contract").with_source(error),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn composite_semantic_error(document: &crate::ConfigJsonDocument, reason: &'static str) -> ksp_core_lib::Error {
|
||||||
|
return ksp_core_lib::Error::new(crate::ERROR_CODE_DOCUMENT_SEMANTIC_INVALID, "Config composite violates KSP semantic invariants")
|
||||||
|
.with_context("file_id", document.file_id().as_str())
|
||||||
|
.with_context("path", document.path().to_string_lossy().into_owned())
|
||||||
|
.with_context("reason", reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn composite_reference_error(
|
||||||
|
document: &crate::ConfigJsonDocument,
|
||||||
|
profile: &CompositeProfileSource,
|
||||||
|
reference: &CompositeDocumentReferenceSource,
|
||||||
|
component_index: usize,
|
||||||
|
reason: &'static str,
|
||||||
|
) -> ksp_core_lib::Error {
|
||||||
|
return ksp_core_lib::Error::new(crate::ERROR_CODE_COMPOSITE_REFERENCE_INVALID, "Config composite contains an invalid document reference")
|
||||||
|
.with_context("file_id", document.file_id().as_str())
|
||||||
|
.with_context("path", document.path().to_string_lossy().into_owned())
|
||||||
|
.with_context("profile_id", profile.profile_id.as_str())
|
||||||
|
.with_context("component_index", component_index.to_string())
|
||||||
|
.with_context("component_id", reference.component_id.as_str())
|
||||||
|
.with_context("referenced_file_id", reference.file_id.as_str())
|
||||||
|
.with_context("reason", reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "../unit_tests/composite.rs"]
|
||||||
|
mod tests;
|
||||||
493
crates/ksp-config-lib/src/document.rs
Normal file
493
crates/ksp-config-lib/src/document.rs
Normal file
@@ -0,0 +1,493 @@
|
|||||||
|
// file: crates/ksp-config-lib/src/document.rs
|
||||||
|
// version: 4
|
||||||
|
|
||||||
|
/// A Config-managed JSON document that has passed syntax, schema and current semantic validation.
|
||||||
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
|
pub struct ConfigJsonDocument {
|
||||||
|
file_id: crate::ConfigFileId,
|
||||||
|
path: std::path::PathBuf,
|
||||||
|
value: serde_json::Value,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ConfigJsonDocument {
|
||||||
|
/// Returns the logical Config file identifier used to load this document.
|
||||||
|
#[must_use]
|
||||||
|
pub fn file_id(&self) -> &crate::ConfigFileId {
|
||||||
|
return &self.file_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the resolved physical path from which this document was loaded.
|
||||||
|
#[must_use]
|
||||||
|
pub fn path(&self) -> &std::path::Path {
|
||||||
|
return self.path.as_path();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the validated JSON value without transferring Config ownership of file I/O or validation.
|
||||||
|
#[must_use]
|
||||||
|
pub fn value(&self) -> &serde_json::Value {
|
||||||
|
return &self.value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generic JSON/JSON Schema engine owned by `ksp-config-lib`.
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub struct ConfigDocumentEngine {
|
||||||
|
bootstrap: crate::ConfigBootstrapOptions,
|
||||||
|
registry: crate::ConfigFileRegistry,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ConfigDocumentEngine {
|
||||||
|
/// Creates a document engine from already validated bootstrap options and a logical file registry.
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(bootstrap: crate::ConfigBootstrapOptions, registry: crate::ConfigFileRegistry) -> Self {
|
||||||
|
return Self { bootstrap, registry };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the bootstrap roots used by this engine.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn bootstrap(&self) -> &crate::ConfigBootstrapOptions {
|
||||||
|
return &self.bootstrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the logical file registry used by this engine.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn registry(&self) -> &crate::ConfigFileRegistry {
|
||||||
|
return &self.registry;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Loads one registered Config document and validates it against its registered JSON Schema and current KSP semantic invariants.
|
||||||
|
pub fn load_validated_document(&self, file_id: &crate::ConfigFileId) -> ksp_core_lib::Result<ConfigJsonDocument> {
|
||||||
|
let descriptor = self.registry.descriptor(file_id);
|
||||||
|
let descriptor = match descriptor {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
if descriptor.kind() != crate::ConfigFileKind::Config {
|
||||||
|
return std::result::Result::Err(
|
||||||
|
ksp_core_lib::Error::new(crate::ERROR_CODE_FILE_MAPPING_INVALID, "requested file_id does not identify a Config document")
|
||||||
|
.with_context("file_id", file_id.as_str()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let schema_file_id = match descriptor.schema_file_id() {
|
||||||
|
std::option::Option::Some(value) => value.clone(),
|
||||||
|
std::option::Option::None => {
|
||||||
|
return std::result::Result::Err(
|
||||||
|
ksp_core_lib::Error::new(crate::ERROR_CODE_FILE_MAPPING_INVALID, "Config document has no registered validation schema")
|
||||||
|
.with_context("file_id", file_id.as_str()),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let document = self.load_json(file_id);
|
||||||
|
let document = match document {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
return self.validate_document(document, &schema_file_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn validate_candidate(&self, file_id: &crate::ConfigFileId, value: serde_json::Value) -> ksp_core_lib::Result<ConfigJsonDocument> {
|
||||||
|
let descriptor = self.registry.descriptor(file_id);
|
||||||
|
let descriptor = match descriptor {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
if descriptor.kind() != crate::ConfigFileKind::Config {
|
||||||
|
return std::result::Result::Err(
|
||||||
|
ksp_core_lib::Error::new(crate::ERROR_CODE_FILE_MAPPING_INVALID, "requested file_id does not identify a Config document")
|
||||||
|
.with_context("file_id", file_id.as_str()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let schema_file_id = match descriptor.schema_file_id() {
|
||||||
|
std::option::Option::Some(value) => value.clone(),
|
||||||
|
std::option::Option::None => {
|
||||||
|
return std::result::Result::Err(
|
||||||
|
ksp_core_lib::Error::new(crate::ERROR_CODE_FILE_MAPPING_INVALID, "Config document has no registered validation schema")
|
||||||
|
.with_context("file_id", file_id.as_str()),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let path = self.registry.resolve_path(&self.bootstrap, file_id);
|
||||||
|
let path = match path {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let document = ConfigJsonDocument { file_id: file_id.clone(), path, value };
|
||||||
|
return self.validate_document(document, &schema_file_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_document(&self, document: ConfigJsonDocument, schema_file_id: &crate::ConfigFileId) -> ksp_core_lib::Result<ConfigJsonDocument> {
|
||||||
|
let schema = self.load_json(schema_file_id);
|
||||||
|
let schema = match schema {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let schema_validation = validate_schema_document(&schema);
|
||||||
|
match schema_validation {
|
||||||
|
std::result::Result::Ok(()) => {},
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
}
|
||||||
|
let instance_validation = validate_instance(&document, &schema);
|
||||||
|
match instance_validation {
|
||||||
|
std::result::Result::Ok(()) => {},
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
}
|
||||||
|
let semantic_validation = validate_document_semantics(self, &document);
|
||||||
|
return match semantic_validation {
|
||||||
|
std::result::Result::Ok(()) => std::result::Result::Ok(document),
|
||||||
|
std::result::Result::Err(error) => std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_json(&self, file_id: &crate::ConfigFileId) -> ksp_core_lib::Result<ConfigJsonDocument> {
|
||||||
|
let path = self.registry.resolve_path(&self.bootstrap, file_id);
|
||||||
|
let path = match path {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let content = std::fs::read_to_string(path.as_path());
|
||||||
|
let content = match content {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(json_read_error(file_id, &path, error)),
|
||||||
|
};
|
||||||
|
let value = serde_json::from_str::<serde_json::Value>(content.as_str());
|
||||||
|
return match value {
|
||||||
|
std::result::Result::Ok(value) => std::result::Result::Ok(ConfigJsonDocument { file_id: file_id.clone(), path, value }),
|
||||||
|
std::result::Result::Err(error) => std::result::Result::Err(json_syntax_error(file_id, &path, error)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
struct LoggingDocumentSource {
|
||||||
|
format_version: u32,
|
||||||
|
logs_directory: String,
|
||||||
|
default_profile: String,
|
||||||
|
profiles: std::vec::Vec<LoggingProfileSource>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
struct LoggingProfileSource {
|
||||||
|
profile_id: String,
|
||||||
|
default_filter: String,
|
||||||
|
span_events: String,
|
||||||
|
console: LoggingConsoleSource,
|
||||||
|
files: std::vec::Vec<LoggingFileSource>,
|
||||||
|
target_filters: std::vec::Vec<LoggingTargetFilterSource>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
struct LoggingConsoleSource {
|
||||||
|
enabled: bool,
|
||||||
|
output: String,
|
||||||
|
ansi: bool,
|
||||||
|
format: String,
|
||||||
|
filter: LoggingOutputFilterSource,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
struct LoggingFileSource {
|
||||||
|
output_id: String,
|
||||||
|
enabled: bool,
|
||||||
|
path: String,
|
||||||
|
rotation: String,
|
||||||
|
format: String,
|
||||||
|
ansi: bool,
|
||||||
|
filter: LoggingOutputFilterSource,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
struct LoggingOutputFilterSource {
|
||||||
|
level: String,
|
||||||
|
targets: std::vec::Vec<String>,
|
||||||
|
domains: std::vec::Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
struct LoggingTargetFilterSource {
|
||||||
|
target_prefix: String,
|
||||||
|
level: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_schema_document(schema: &ConfigJsonDocument) -> ksp_core_lib::Result<()> {
|
||||||
|
let validation = jsonschema::meta::validate(schema.value());
|
||||||
|
return match validation {
|
||||||
|
std::result::Result::Ok(()) => std::result::Result::Ok(()),
|
||||||
|
std::result::Result::Err(error) => std::result::Result::Err(
|
||||||
|
ksp_core_lib::Error::new(crate::ERROR_CODE_SCHEMA_INVALID, "Config JSON Schema document is invalid")
|
||||||
|
.with_context("file_id", schema.file_id().as_str())
|
||||||
|
.with_context("path", schema.path().to_string_lossy().into_owned())
|
||||||
|
.with_context("detail", error.to_string()),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_instance(document: &ConfigJsonDocument, schema: &ConfigJsonDocument) -> ksp_core_lib::Result<()> {
|
||||||
|
let validation = jsonschema::draft202012::validate(schema.value(), document.value());
|
||||||
|
return match validation {
|
||||||
|
std::result::Result::Ok(()) => std::result::Result::Ok(()),
|
||||||
|
std::result::Result::Err(error) => std::result::Result::Err(
|
||||||
|
ksp_core_lib::Error::new(crate::ERROR_CODE_SCHEMA_VALIDATION_FAILED, "Config document does not satisfy its registered JSON Schema")
|
||||||
|
.with_context("file_id", document.file_id().as_str())
|
||||||
|
.with_context("schema_file_id", schema.file_id().as_str())
|
||||||
|
.with_context("path", document.path().to_string_lossy().into_owned())
|
||||||
|
.with_context("detail", error.to_string()),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_document_semantics(engine: &ConfigDocumentEngine, document: &ConfigJsonDocument) -> ksp_core_lib::Result<()> {
|
||||||
|
let profile_validation = crate::profile::validate_document_profile_contract(document);
|
||||||
|
if let std::result::Result::Err(error) = profile_validation {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
let composite_validation = crate::composite::validate_composite_document_contract(engine, document);
|
||||||
|
if let std::result::Result::Err(error) = composite_validation {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
if document.file_id().as_str() != crate::FILE_ID_STD_LOGGING {
|
||||||
|
return std::result::Result::Ok(());
|
||||||
|
}
|
||||||
|
let parsed = serde_json::from_value::<LoggingDocumentSource>(document.value().clone());
|
||||||
|
let parsed = match parsed {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => {
|
||||||
|
return std::result::Result::Err(
|
||||||
|
semantic_error(document, "schema-valid Logging document cannot be decoded into the KSP source contract").with_source(error),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return validate_logging_document(document, &parsed);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_logging_document(document: &ConfigJsonDocument, source: &LoggingDocumentSource) -> ksp_core_lib::Result<()> {
|
||||||
|
if source.format_version != 1 {
|
||||||
|
return std::result::Result::Err(semantic_error(document, "unsupported Logging document format_version"));
|
||||||
|
}
|
||||||
|
if source.logs_directory.trim().is_empty() {
|
||||||
|
return std::result::Result::Err(semantic_error(document, "logs_directory must not be empty"));
|
||||||
|
}
|
||||||
|
if source.default_profile.trim().is_empty() {
|
||||||
|
return std::result::Result::Err(semantic_error(document, "default_profile must not be empty"));
|
||||||
|
}
|
||||||
|
for (profile_index, profile) in source.profiles.iter().enumerate() {
|
||||||
|
let validation = validate_logging_profile(document, profile, profile_index);
|
||||||
|
if let std::result::Result::Err(error) = validation {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_logging_profile(document: &ConfigJsonDocument, profile: &LoggingProfileSource, profile_index: usize) -> ksp_core_lib::Result<()> {
|
||||||
|
if profile.profile_id.trim().is_empty() || profile.default_filter.trim().is_empty() || profile.span_events.trim().is_empty() {
|
||||||
|
return std::result::Result::Err(
|
||||||
|
semantic_error(document, "Logging profile identity and base settings must not be empty").with_context("profile_index", profile_index.to_string()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let console_validation = validate_logging_console(document, &profile.console, profile_index);
|
||||||
|
if let std::result::Result::Err(error) = console_validation {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
for (file_index, file) in profile.files.iter().enumerate() {
|
||||||
|
let file_validation = validate_logging_file(document, file, profile_index, file_index);
|
||||||
|
if let std::result::Result::Err(error) = file_validation {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
for previous in &profile.files[..file_index] {
|
||||||
|
if previous.output_id == file.output_id {
|
||||||
|
return std::result::Result::Err(
|
||||||
|
semantic_error(document, "Logging file output identifiers must be unique within a profile")
|
||||||
|
.with_context("profile_index", profile_index.to_string())
|
||||||
|
.with_context("output_id", file.output_id.as_str()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (target_index, target_filter) in profile.target_filters.iter().enumerate() {
|
||||||
|
if target_filter.target_prefix.trim().is_empty() || !target_filter.target_prefix.starts_with("ksp-") || target_filter.level.trim().is_empty() {
|
||||||
|
return std::result::Result::Err(
|
||||||
|
semantic_error(document, "Logging global target filter must identify a KSP-owned target")
|
||||||
|
.with_context("profile_index", profile_index.to_string())
|
||||||
|
.with_context("target_filter_index", target_index.to_string()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_logging_console(document: &ConfigJsonDocument, console: &LoggingConsoleSource, profile_index: usize) -> ksp_core_lib::Result<()> {
|
||||||
|
let _enabled = console.enabled;
|
||||||
|
if console.output.trim().is_empty() || console.format.trim().is_empty() {
|
||||||
|
return std::result::Result::Err(
|
||||||
|
semantic_error(document, "Logging console output and format must not be empty").with_context("profile_index", profile_index.to_string()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if console.ansi && console.format == "json" {
|
||||||
|
return std::result::Result::Err(
|
||||||
|
semantic_error(document, "ANSI formatting is not compatible with JSON console output").with_context("profile_index", profile_index.to_string()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return validate_logging_output_filter(document, &console.filter, profile_index, "console");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_logging_file(document: &ConfigJsonDocument, file: &LoggingFileSource, profile_index: usize, file_index: usize) -> ksp_core_lib::Result<()> {
|
||||||
|
let _enabled = file.enabled;
|
||||||
|
if !valid_output_id(file.output_id.as_str()) {
|
||||||
|
return std::result::Result::Err(
|
||||||
|
semantic_error(document, "Logging file output_id is invalid")
|
||||||
|
.with_context("profile_index", profile_index.to_string())
|
||||||
|
.with_context("file_index", file_index.to_string())
|
||||||
|
.with_context("output_id", file.output_id.as_str()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if file.path.trim().is_empty() || !relative_log_path_is_valid(file.path.as_str()) {
|
||||||
|
return std::result::Result::Err(
|
||||||
|
semantic_error(document, "Logging file path must stay relative to logs_directory without traversal")
|
||||||
|
.with_context("profile_index", profile_index.to_string())
|
||||||
|
.with_context("file_index", file_index.to_string()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if file.rotation.trim().is_empty() || file.format.trim().is_empty() {
|
||||||
|
return std::result::Result::Err(
|
||||||
|
semantic_error(document, "Logging file rotation and format must not be empty").with_context("profile_index", profile_index.to_string()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if file.ansi {
|
||||||
|
return std::result::Result::Err(
|
||||||
|
semantic_error(document, "ANSI sequences are not allowed in persistent Logging outputs").with_context("profile_index", profile_index.to_string()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return validate_logging_output_filter(document, &file.filter, profile_index, file.output_id.as_str());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_logging_output_filter(
|
||||||
|
document: &ConfigJsonDocument,
|
||||||
|
filter: &LoggingOutputFilterSource,
|
||||||
|
profile_index: usize,
|
||||||
|
output: &str,
|
||||||
|
) -> ksp_core_lib::Result<()> {
|
||||||
|
if filter.level.trim().is_empty() {
|
||||||
|
return std::result::Result::Err(
|
||||||
|
semantic_error(document, "Logging output filter level must not be empty").with_context("profile_index", profile_index.to_string()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let targets = validate_selectors(document, &filter.targets, true, profile_index, output, "targets");
|
||||||
|
if let std::result::Result::Err(error) = targets {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
return validate_selectors(document, &filter.domains, false, profile_index, output, "domains");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_selectors(
|
||||||
|
document: &ConfigJsonDocument,
|
||||||
|
selectors: &[String],
|
||||||
|
target_dimension: bool,
|
||||||
|
profile_index: usize,
|
||||||
|
output: &str,
|
||||||
|
dimension: &'static str,
|
||||||
|
) -> ksp_core_lib::Result<()> {
|
||||||
|
if selectors.is_empty() {
|
||||||
|
return std::result::Result::Err(selector_error(document, profile_index, output, dimension, "selector list must not be empty"));
|
||||||
|
}
|
||||||
|
if selectors.len() > 1
|
||||||
|
&& selectors.iter().any(|selector| -> bool {
|
||||||
|
return selector == "*";
|
||||||
|
})
|
||||||
|
{
|
||||||
|
return std::result::Result::Err(selector_error(document, profile_index, output, dimension, "wildcard selector must be used alone"));
|
||||||
|
}
|
||||||
|
for (index, selector) in selectors.iter().enumerate() {
|
||||||
|
if selector.trim().is_empty() {
|
||||||
|
return std::result::Result::Err(
|
||||||
|
selector_error(document, profile_index, output, dimension, "selector must not be empty").with_context("selector_index", index.to_string()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if target_dimension && selector != "*" && !selector.starts_with("ksp-") {
|
||||||
|
return std::result::Result::Err(
|
||||||
|
selector_error(document, profile_index, output, dimension, "target selector must identify a KSP-owned target")
|
||||||
|
.with_context("selector_index", index.to_string()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for previous in &selectors[..index] {
|
||||||
|
if previous == selector {
|
||||||
|
return std::result::Result::Err(
|
||||||
|
selector_error(document, profile_index, output, dimension, "selectors must be unique").with_context("selector_index", index.to_string()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn valid_output_id(output_id: &str) -> bool {
|
||||||
|
let mut previous_was_separator = true;
|
||||||
|
if output_id.is_empty() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
for byte in output_id.bytes() {
|
||||||
|
if byte == b'.' {
|
||||||
|
if previous_was_separator {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
previous_was_separator = true;
|
||||||
|
} else if byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_' || byte == b'-' {
|
||||||
|
previous_was_separator = false;
|
||||||
|
} else {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return !previous_was_separator;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn relative_log_path_is_valid(value: &str) -> bool {
|
||||||
|
let path = std::path::Path::new(value);
|
||||||
|
if path.is_absolute() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let mut has_normal_component = false;
|
||||||
|
for component in path.components() {
|
||||||
|
match component {
|
||||||
|
std::path::Component::Normal(_) => has_normal_component = true,
|
||||||
|
std::path::Component::CurDir | std::path::Component::ParentDir | std::path::Component::RootDir | std::path::Component::Prefix(_) => return false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return has_normal_component;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn selector_error(document: &ConfigJsonDocument, profile_index: usize, output: &str, dimension: &'static str, reason: &'static str) -> ksp_core_lib::Error {
|
||||||
|
return semantic_error(document, reason)
|
||||||
|
.with_context("profile_index", profile_index.to_string())
|
||||||
|
.with_context("output", output)
|
||||||
|
.with_context("dimension", dimension);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn semantic_error(document: &ConfigJsonDocument, reason: &'static str) -> ksp_core_lib::Error {
|
||||||
|
return ksp_core_lib::Error::new(crate::ERROR_CODE_DOCUMENT_SEMANTIC_INVALID, "Config document violates KSP semantic invariants")
|
||||||
|
.with_context("file_id", document.file_id().as_str())
|
||||||
|
.with_context("path", document.path().to_string_lossy().into_owned())
|
||||||
|
.with_context("reason", reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn json_read_error(file_id: &crate::ConfigFileId, path: &std::path::Path, source: std::io::Error) -> ksp_core_lib::Error {
|
||||||
|
return ksp_core_lib::Error::new(crate::ERROR_CODE_JSON_FILE_READ_FAILED, "Config-managed JSON file cannot be read")
|
||||||
|
.with_context("file_id", file_id.as_str())
|
||||||
|
.with_context("path", path.to_string_lossy().into_owned())
|
||||||
|
.with_source(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn json_syntax_error(file_id: &crate::ConfigFileId, path: &std::path::Path, source: serde_json::Error) -> ksp_core_lib::Error {
|
||||||
|
return ksp_core_lib::Error::new(crate::ERROR_CODE_JSON_SYNTAX_INVALID, "Config-managed file contains invalid JSON syntax")
|
||||||
|
.with_context("file_id", file_id.as_str())
|
||||||
|
.with_context("path", path.to_string_lossy().into_owned())
|
||||||
|
.with_source(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "../unit_tests/document.rs"]
|
||||||
|
mod tests;
|
||||||
605
crates/ksp-config-lib/src/environment.rs
Normal file
605
crates/ksp-config-lib/src/environment.rs
Normal file
@@ -0,0 +1,605 @@
|
|||||||
|
// file: crates/ksp-config-lib/src/environment.rs
|
||||||
|
// version: 4
|
||||||
|
|
||||||
|
/// Default local environment file read by Config from the process launch directory.
|
||||||
|
pub const DEFAULT_DOTENV_PATH: &str = ".env";
|
||||||
|
|
||||||
|
/// Versioned environment contract template expected at the repository/runtime root.
|
||||||
|
pub const DEFAULT_DOTENV_EXAMPLE_PATH: &str = ".env.example";
|
||||||
|
|
||||||
|
const LOGGING_TARGET: &str = "ksp-config-lib";
|
||||||
|
const LOGGING_DOMAIN: &str = "config.environment";
|
||||||
|
|
||||||
|
/// Source that supplied one resolved Config environment variable.
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub enum ConfigEnvironmentSource {
|
||||||
|
/// Value was present in the environment inherited by the current process.
|
||||||
|
Process,
|
||||||
|
/// Value was absent from the process environment and came from the local `.env` file.
|
||||||
|
DotEnv,
|
||||||
|
/// Value was absent from both external sources and came from the placeholder/API fallback.
|
||||||
|
Fallback,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One resolved Config environment variable with real/safe values, sensitivity and its winning source.
|
||||||
|
#[derive(Clone, Eq, PartialEq)]
|
||||||
|
pub struct ConfigEnvironmentValue {
|
||||||
|
variable_name: String,
|
||||||
|
value: String,
|
||||||
|
safe_value: String,
|
||||||
|
sensitivity: crate::ConfigSensitivity,
|
||||||
|
source: ConfigEnvironmentSource,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ConfigEnvironmentValue {
|
||||||
|
/// Returns the resolved variable name.
|
||||||
|
#[must_use]
|
||||||
|
pub fn variable_name(&self) -> &str {
|
||||||
|
return self.variable_name.as_str();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the real resolved value.
|
||||||
|
#[must_use]
|
||||||
|
pub fn value(&self) -> &str {
|
||||||
|
return self.value.as_str();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the representation safe for ordinary diagnostics.
|
||||||
|
#[must_use]
|
||||||
|
pub fn safe_value(&self) -> &str {
|
||||||
|
return self.safe_value.as_str();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the sensitivity derived from the variable namespace.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn sensitivity(&self) -> crate::ConfigSensitivity {
|
||||||
|
return self.sensitivity;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the source that won process > `.env` > fallback resolution.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn source(&self) -> ConfigEnvironmentSource {
|
||||||
|
return self.source;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns provenance without embedding the resolved value.
|
||||||
|
#[must_use]
|
||||||
|
pub fn provenance(&self) -> crate::ConfigValueProvenance {
|
||||||
|
return environment_provenance(self.variable_name.as_str(), self.source);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Debug for ConfigEnvironmentValue {
|
||||||
|
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
return formatter
|
||||||
|
.debug_struct("ConfigEnvironmentValue")
|
||||||
|
.field("variable_name", &self.variable_name)
|
||||||
|
.field("safe_value", &self.safe_value)
|
||||||
|
.field("sensitivity", &self.sensitivity)
|
||||||
|
.field("source", &self.source)
|
||||||
|
.finish();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Config-owned snapshot of KSP/KSPB process environment values and the local `.env` file.
|
||||||
|
///
|
||||||
|
/// The process environment is captured first and always has priority over `.env`. An absent `.env` file is equivalent to an empty local environment source.
|
||||||
|
/// Config never mutates the parent/process environment through this type.
|
||||||
|
#[derive(Clone, Eq, PartialEq)]
|
||||||
|
pub struct ConfigEnvironment {
|
||||||
|
process: std::collections::BTreeMap<String, String>,
|
||||||
|
dotenv: std::collections::BTreeMap<String, String>,
|
||||||
|
dotenv_path: std::path::PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ConfigEnvironment {
|
||||||
|
/// Captures supported variables from the current process and reads `./.env` when it exists.
|
||||||
|
pub fn load() -> ksp_core_lib::Result<Self> {
|
||||||
|
return Self::load_from_dotenv_path(std::path::Path::new(DEFAULT_DOTENV_PATH));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the local `.env` path used by this environment snapshot.
|
||||||
|
#[must_use]
|
||||||
|
pub fn dotenv_path(&self) -> &std::path::Path {
|
||||||
|
return self.dotenv_path.as_path();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolves one KSP/KSPB variable using process > `.env` > fallback priority.
|
||||||
|
///
|
||||||
|
/// The fallback is used only when the variable is absent. An explicitly defined empty string is a real value and therefore wins over the fallback.
|
||||||
|
pub fn resolve_variable(&self, variable_name: &str, fallback: std::option::Option<&str>) -> ksp_core_lib::Result<ConfigEnvironmentValue> {
|
||||||
|
let validation = validate_supported_variable_name(variable_name);
|
||||||
|
if let std::result::Result::Err(error) = validation {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
if let std::option::Option::Some(value) = self.process.get(variable_name) {
|
||||||
|
return resolved_environment_value(variable_name, value.as_str(), ConfigEnvironmentSource::Process);
|
||||||
|
}
|
||||||
|
if let std::option::Option::Some(value) = self.dotenv.get(variable_name) {
|
||||||
|
return resolved_environment_value(variable_name, value.as_str(), ConfigEnvironmentSource::DotEnv);
|
||||||
|
}
|
||||||
|
if let std::option::Option::Some(value) = fallback {
|
||||||
|
return resolved_environment_value(variable_name, value, ConfigEnvironmentSource::Fallback);
|
||||||
|
}
|
||||||
|
emit_missing_variable_warning(variable_name);
|
||||||
|
return std::result::Result::Err(missing_variable_error(variable_name));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolves `${NAME}` and `${NAME:-fallback}` placeholders embedded in one UTF-8 string.
|
||||||
|
///
|
||||||
|
/// This compatibility helper returns only the real runtime string. Use [`Self::resolve_text_detailed`] when safe value, sensitivity or provenance are
|
||||||
|
/// needed.
|
||||||
|
pub fn resolve_text(&self, source: &str) -> ksp_core_lib::Result<String> {
|
||||||
|
let resolved = self.resolve_text_detailed(source);
|
||||||
|
return match resolved {
|
||||||
|
std::result::Result::Ok(value) => std::result::Result::Ok(value.value().to_owned()),
|
||||||
|
std::result::Result::Err(error) => std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolves one UTF-8 string while preserving real/safe representations, strongest sensitivity and ordered provenance.
|
||||||
|
///
|
||||||
|
/// Multiple placeholders are supported. Fallback text is literal and inherits the sensitivity of the referenced variable. Literal-only strings are
|
||||||
|
/// classified as `Internal`; when placeholders are present, the result sensitivity is the strongest placeholder sensitivity.
|
||||||
|
pub fn resolve_text_detailed(&self, source: &str) -> ksp_core_lib::Result<crate::ResolvedConfigText> {
|
||||||
|
let mut value = String::new();
|
||||||
|
let mut safe_value = String::new();
|
||||||
|
let mut provenance = std::vec::Vec::<crate::ConfigValueProvenance>::new();
|
||||||
|
let mut sensitivity = crate::ConfigSensitivity::Public;
|
||||||
|
let mut saw_placeholder = false;
|
||||||
|
let mut remaining = source;
|
||||||
|
loop {
|
||||||
|
let start = remaining.find("${");
|
||||||
|
let start = match start {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => {
|
||||||
|
if !remaining.is_empty() {
|
||||||
|
value.push_str(remaining);
|
||||||
|
safe_value.push_str(remaining);
|
||||||
|
provenance.push(crate::ConfigValueProvenance::DocumentLiteral);
|
||||||
|
}
|
||||||
|
if !saw_placeholder {
|
||||||
|
sensitivity = crate::ConfigSensitivity::Internal;
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(crate::ResolvedConfigText::new(value, safe_value, sensitivity, provenance));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let literal = &remaining[..start];
|
||||||
|
if !literal.is_empty() {
|
||||||
|
value.push_str(literal);
|
||||||
|
safe_value.push_str(literal);
|
||||||
|
provenance.push(crate::ConfigValueProvenance::DocumentLiteral);
|
||||||
|
}
|
||||||
|
let expression_and_tail = &remaining[start + 2..];
|
||||||
|
let end = expression_and_tail.find('}');
|
||||||
|
let end = match end {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => return std::result::Result::Err(invalid_placeholder_error("placeholder is missing its closing '}'")),
|
||||||
|
};
|
||||||
|
let expression = &expression_and_tail[..end];
|
||||||
|
let parsed = parse_placeholder_expression(expression);
|
||||||
|
let (variable_name, fallback) = match parsed {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let resolved = self.resolve_variable(variable_name, fallback);
|
||||||
|
let resolved = match resolved {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
saw_placeholder = true;
|
||||||
|
sensitivity = sensitivity.strongest(resolved.sensitivity());
|
||||||
|
value.push_str(resolved.value());
|
||||||
|
safe_value.push_str(resolved.safe_value());
|
||||||
|
provenance.push(resolved.provenance());
|
||||||
|
remaining = &expression_and_tail[end + 1..];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Recursively resolves environment placeholders in JSON values and returns only the real runtime tree.
|
||||||
|
///
|
||||||
|
/// Use [`Self::resolve_json_detailed`] when safe value, sensitivity or per-location provenance are needed.
|
||||||
|
pub fn resolve_json(&self, source: &serde_json::Value) -> ksp_core_lib::Result<serde_json::Value> {
|
||||||
|
let resolved = self.resolve_json_detailed(source);
|
||||||
|
return match resolved {
|
||||||
|
std::result::Result::Ok(value) => std::result::Result::Ok(value.value().clone()),
|
||||||
|
std::result::Result::Err(error) => std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Recursively resolves environment placeholders in JSON values while preserving real/safe trees, strongest sensitivity and JSON-Pointer provenance.
|
||||||
|
pub fn resolve_json_detailed(&self, source: &serde_json::Value) -> ksp_core_lib::Result<crate::ResolvedConfigJson> {
|
||||||
|
let mut provenance = std::collections::BTreeMap::<String, std::vec::Vec<crate::ConfigValueProvenance>>::new();
|
||||||
|
let resolved = resolve_json_node(self, source, "", &mut provenance);
|
||||||
|
let (value, safe_value, sensitivity) = match resolved {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
return std::result::Result::Ok(crate::ResolvedConfigJson::new(value, safe_value, sensitivity, provenance));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Recursively resolves environment placeholders in one JSON object map while leaving map keys unchanged.
|
||||||
|
pub fn resolve_map(&self, source: &serde_json::Map<String, serde_json::Value>) -> ksp_core_lib::Result<serde_json::Map<String, serde_json::Value>> {
|
||||||
|
let resolved = self.resolve_json(&serde_json::Value::Object(source.clone()));
|
||||||
|
let resolved = match resolved {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
return match resolved {
|
||||||
|
serde_json::Value::Object(value) => std::result::Result::Ok(value),
|
||||||
|
_ => std::result::Result::Err(invalid_placeholder_error("resolved JSON object changed shape")),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn load_from_dotenv_path(dotenv_path: &std::path::Path) -> ksp_core_lib::Result<Self> {
|
||||||
|
let process = collect_process_environment(std::env::vars_os());
|
||||||
|
let process = match process {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let dotenv = load_dotenv_file(dotenv_path);
|
||||||
|
let dotenv = match dotenv {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
return std::result::Result::Ok(Self { process, dotenv, dotenv_path: dotenv_path.to_path_buf() });
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) const fn process_values(&self) -> &std::collections::BTreeMap<String, String> {
|
||||||
|
return &self.process;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) const fn dotenv_values(&self) -> &std::collections::BTreeMap<String, String> {
|
||||||
|
return &self.dotenv;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(crate) fn from_maps(process: std::collections::BTreeMap<String, String>, dotenv: std::collections::BTreeMap<String, String>) -> Self {
|
||||||
|
return Self { process, dotenv, dotenv_path: std::path::PathBuf::from(DEFAULT_DOTENV_PATH) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn collect_process_environment<I>(values: I) -> ksp_core_lib::Result<std::collections::BTreeMap<String, String>>
|
||||||
|
where
|
||||||
|
I: std::iter::IntoIterator<Item = (std::ffi::OsString, std::ffi::OsString)>,
|
||||||
|
{
|
||||||
|
let mut output = std::collections::BTreeMap::<String, String>::new();
|
||||||
|
for (name, value) in values {
|
||||||
|
let name = match name.to_str() {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => continue,
|
||||||
|
};
|
||||||
|
if !has_supported_namespace(name) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let validation = validate_supported_variable_name(name);
|
||||||
|
if let std::result::Result::Err(error) = validation {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
let value = value.into_string();
|
||||||
|
let value = match value {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return std::result::Result::Err(invalid_environment_value_error(name)),
|
||||||
|
};
|
||||||
|
output.insert(name.to_owned(), value);
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(output);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_dotenv_file(path: &std::path::Path) -> ksp_core_lib::Result<std::collections::BTreeMap<String, String>> {
|
||||||
|
let content = std::fs::read_to_string(path);
|
||||||
|
let content = match content {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) if error.kind() == std::io::ErrorKind::NotFound => return std::result::Result::Ok(std::collections::BTreeMap::new()),
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(dotenv_read_error(path, error)),
|
||||||
|
};
|
||||||
|
return parse_dotenv_content(path, content.as_str());
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn parse_dotenv_content(path: &std::path::Path, content: &str) -> ksp_core_lib::Result<std::collections::BTreeMap<String, String>> {
|
||||||
|
let mut output = std::collections::BTreeMap::<String, String>::new();
|
||||||
|
for (line_index, raw_line) in content.lines().enumerate() {
|
||||||
|
let raw_line = if line_index == 0 { raw_line.trim_start_matches('\u{feff}') } else { raw_line };
|
||||||
|
let line = raw_line.trim();
|
||||||
|
if line.is_empty() || line.starts_with('#') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let assignment = match line.strip_prefix("export ") {
|
||||||
|
std::option::Option::Some(value) => value.trim_start(),
|
||||||
|
std::option::Option::None => line,
|
||||||
|
};
|
||||||
|
let separator = assignment.find('=');
|
||||||
|
let separator = match separator {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => return std::result::Result::Err(dotenv_syntax_error(path, line_index + 1, "assignment is missing '='")),
|
||||||
|
};
|
||||||
|
let variable_name = assignment[..separator].trim();
|
||||||
|
if !is_generic_dotenv_name(variable_name) {
|
||||||
|
return std::result::Result::Err(dotenv_syntax_error(path, line_index + 1, "variable name is invalid"));
|
||||||
|
}
|
||||||
|
let raw_value = assignment[separator + 1..].trim();
|
||||||
|
let value = parse_dotenv_value(path, line_index + 1, raw_value);
|
||||||
|
let value = match value {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
if !has_supported_namespace(variable_name) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let validation = validate_supported_variable_name(variable_name);
|
||||||
|
if let std::result::Result::Err(error) = validation {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
let entry = output.entry(variable_name.to_owned());
|
||||||
|
match entry {
|
||||||
|
std::collections::btree_map::Entry::Occupied(_) => {
|
||||||
|
return std::result::Result::Err(dotenv_duplicate_error(path, line_index + 1, variable_name));
|
||||||
|
},
|
||||||
|
std::collections::btree_map::Entry::Vacant(entry) => {
|
||||||
|
entry.insert(value);
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(output);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_dotenv_value(path: &std::path::Path, line_number: usize, raw_value: &str) -> ksp_core_lib::Result<String> {
|
||||||
|
if raw_value.starts_with('\'') && (raw_value.len() < 2 || !raw_value.ends_with('\'')) {
|
||||||
|
return std::result::Result::Err(dotenv_syntax_error(path, line_number, "single-quoted value is not terminated"));
|
||||||
|
}
|
||||||
|
if raw_value.starts_with('\'') {
|
||||||
|
return std::result::Result::Ok(raw_value[1..raw_value.len() - 1].to_owned());
|
||||||
|
}
|
||||||
|
if raw_value.starts_with('"') && (raw_value.len() < 2 || !raw_value.ends_with('"')) {
|
||||||
|
return std::result::Result::Err(dotenv_syntax_error(path, line_number, "double-quoted value is not terminated"));
|
||||||
|
}
|
||||||
|
if raw_value.starts_with('"') {
|
||||||
|
return parse_double_quoted_value(path, line_number, &raw_value[1..raw_value.len() - 1]);
|
||||||
|
}
|
||||||
|
let inline_comment = raw_value.find(" #");
|
||||||
|
let value = match inline_comment {
|
||||||
|
std::option::Option::Some(index) => raw_value[..index].trim_end(),
|
||||||
|
std::option::Option::None => raw_value,
|
||||||
|
};
|
||||||
|
return std::result::Result::Ok(value.to_owned());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_double_quoted_value(path: &std::path::Path, line_number: usize, source: &str) -> ksp_core_lib::Result<String> {
|
||||||
|
let mut output = String::new();
|
||||||
|
let mut escaped = false;
|
||||||
|
for character in source.chars() {
|
||||||
|
if escaped {
|
||||||
|
let mapped = match character {
|
||||||
|
'n' => '\n',
|
||||||
|
'r' => '\r',
|
||||||
|
't' => '\t',
|
||||||
|
'\\' => '\\',
|
||||||
|
'"' => '"',
|
||||||
|
_ => return std::result::Result::Err(dotenv_syntax_error(path, line_number, "double-quoted value contains an unsupported escape")),
|
||||||
|
};
|
||||||
|
output.push(mapped);
|
||||||
|
escaped = false;
|
||||||
|
} else if character == '\\' {
|
||||||
|
escaped = true;
|
||||||
|
} else {
|
||||||
|
output.push(character);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if escaped {
|
||||||
|
return std::result::Result::Err(dotenv_syntax_error(path, line_number, "double-quoted value ends with an incomplete escape"));
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(output);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_placeholder_expression(expression: &str) -> ksp_core_lib::Result<(&str, std::option::Option<&str>)> {
|
||||||
|
if expression.is_empty() || expression.contains("${") {
|
||||||
|
return std::result::Result::Err(invalid_placeholder_error("placeholder expression is empty or nested"));
|
||||||
|
}
|
||||||
|
let fallback_separator = expression.find(":-");
|
||||||
|
let (variable_name, fallback) = match fallback_separator {
|
||||||
|
std::option::Option::Some(index) => (&expression[..index], std::option::Option::Some(&expression[index + 2..])),
|
||||||
|
std::option::Option::None => (expression, std::option::Option::None),
|
||||||
|
};
|
||||||
|
let validation = validate_supported_variable_name(variable_name);
|
||||||
|
if let std::result::Result::Err(error) = validation {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok((variable_name, fallback));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolved_environment_value(variable_name: &str, value: &str, source: ConfigEnvironmentSource) -> ksp_core_lib::Result<ConfigEnvironmentValue> {
|
||||||
|
let sensitivity = crate::ConfigSensitivity::from_variable_name(variable_name);
|
||||||
|
let sensitivity = match sensitivity {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let safe_value = if sensitivity.is_secret() { crate::REDACTED_CONFIG_VALUE.to_owned() } else { value.to_owned() };
|
||||||
|
return std::result::Result::Ok(ConfigEnvironmentValue {
|
||||||
|
variable_name: variable_name.to_owned(),
|
||||||
|
value: value.to_owned(),
|
||||||
|
safe_value,
|
||||||
|
sensitivity,
|
||||||
|
source,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn environment_provenance(variable_name: &str, source: ConfigEnvironmentSource) -> crate::ConfigValueProvenance {
|
||||||
|
return match source {
|
||||||
|
ConfigEnvironmentSource::Process => crate::ConfigValueProvenance::EnvironmentProcess { variable_name: variable_name.to_owned() },
|
||||||
|
ConfigEnvironmentSource::DotEnv => crate::ConfigValueProvenance::EnvironmentDotEnv { variable_name: variable_name.to_owned() },
|
||||||
|
ConfigEnvironmentSource::Fallback => crate::ConfigValueProvenance::EnvironmentFallback { variable_name: variable_name.to_owned() },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_json_node(
|
||||||
|
environment: &ConfigEnvironment,
|
||||||
|
source: &serde_json::Value,
|
||||||
|
pointer: &str,
|
||||||
|
provenance: &mut std::collections::BTreeMap<String, std::vec::Vec<crate::ConfigValueProvenance>>,
|
||||||
|
) -> ksp_core_lib::Result<(serde_json::Value, serde_json::Value, crate::ConfigSensitivity)> {
|
||||||
|
return match source {
|
||||||
|
serde_json::Value::Null | serde_json::Value::Bool(_) | serde_json::Value::Number(_) => {
|
||||||
|
provenance.insert(pointer.to_owned(), vec![crate::ConfigValueProvenance::DocumentLiteral]);
|
||||||
|
std::result::Result::Ok((source.clone(), source.clone(), crate::ConfigSensitivity::Internal))
|
||||||
|
},
|
||||||
|
serde_json::Value::String(value) => {
|
||||||
|
let resolved = environment.resolve_text_detailed(value.as_str());
|
||||||
|
let resolved = match resolved {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
provenance.insert(pointer.to_owned(), resolved.provenance().to_vec());
|
||||||
|
std::result::Result::Ok((
|
||||||
|
serde_json::Value::String(resolved.value().to_owned()),
|
||||||
|
serde_json::Value::String(resolved.safe_value().to_owned()),
|
||||||
|
resolved.sensitivity(),
|
||||||
|
))
|
||||||
|
},
|
||||||
|
serde_json::Value::Array(values) => resolve_json_array(environment, values, pointer, provenance),
|
||||||
|
serde_json::Value::Object(values) => resolve_json_object(environment, values, pointer, provenance),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_json_array(
|
||||||
|
environment: &ConfigEnvironment,
|
||||||
|
source: &[serde_json::Value],
|
||||||
|
pointer: &str,
|
||||||
|
provenance: &mut std::collections::BTreeMap<String, std::vec::Vec<crate::ConfigValueProvenance>>,
|
||||||
|
) -> ksp_core_lib::Result<(serde_json::Value, serde_json::Value, crate::ConfigSensitivity)> {
|
||||||
|
let mut value = std::vec::Vec::<serde_json::Value>::with_capacity(source.len());
|
||||||
|
let mut safe_value = std::vec::Vec::<serde_json::Value>::with_capacity(source.len());
|
||||||
|
let mut sensitivity = crate::ConfigSensitivity::Public;
|
||||||
|
if source.is_empty() {
|
||||||
|
sensitivity = crate::ConfigSensitivity::Internal;
|
||||||
|
}
|
||||||
|
for (index, item) in source.iter().enumerate() {
|
||||||
|
let child_pointer = format!("{pointer}/{index}");
|
||||||
|
let resolved = resolve_json_node(environment, item, child_pointer.as_str(), provenance);
|
||||||
|
let (resolved_value, resolved_safe_value, resolved_sensitivity) = match resolved {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
value.push(resolved_value);
|
||||||
|
safe_value.push(resolved_safe_value);
|
||||||
|
sensitivity = sensitivity.strongest(resolved_sensitivity);
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok((serde_json::Value::Array(value), serde_json::Value::Array(safe_value), sensitivity));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_json_object(
|
||||||
|
environment: &ConfigEnvironment,
|
||||||
|
source: &serde_json::Map<String, serde_json::Value>,
|
||||||
|
pointer: &str,
|
||||||
|
provenance: &mut std::collections::BTreeMap<String, std::vec::Vec<crate::ConfigValueProvenance>>,
|
||||||
|
) -> ksp_core_lib::Result<(serde_json::Value, serde_json::Value, crate::ConfigSensitivity)> {
|
||||||
|
let mut value = serde_json::Map::<String, serde_json::Value>::new();
|
||||||
|
let mut safe_value = serde_json::Map::<String, serde_json::Value>::new();
|
||||||
|
let mut sensitivity = crate::ConfigSensitivity::Public;
|
||||||
|
if source.is_empty() {
|
||||||
|
sensitivity = crate::ConfigSensitivity::Internal;
|
||||||
|
}
|
||||||
|
for (key, item) in source {
|
||||||
|
let escaped_key = escape_json_pointer_token(key.as_str());
|
||||||
|
let child_pointer = format!("{pointer}/{escaped_key}");
|
||||||
|
let resolved = resolve_json_node(environment, item, child_pointer.as_str(), provenance);
|
||||||
|
let (resolved_value, resolved_safe_value, resolved_sensitivity) = match resolved {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
value.insert(key.clone(), resolved_value);
|
||||||
|
safe_value.insert(key.clone(), resolved_safe_value);
|
||||||
|
sensitivity = sensitivity.strongest(resolved_sensitivity);
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok((serde_json::Value::Object(value), serde_json::Value::Object(safe_value), sensitivity));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn escape_json_pointer_token(value: &str) -> String {
|
||||||
|
return value.replace('~', "~0").replace('/', "~1");
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn validate_supported_variable_name(variable_name: &str) -> ksp_core_lib::Result<()> {
|
||||||
|
if !has_supported_namespace(variable_name) {
|
||||||
|
return std::result::Result::Err(invalid_variable_error(variable_name, "variable must use the KSP_ or KSPB_ namespace"));
|
||||||
|
}
|
||||||
|
let prefix_length = if variable_name.starts_with("KSPB_") { 5 } else { 4 };
|
||||||
|
if variable_name.len() <= prefix_length {
|
||||||
|
return std::result::Result::Err(invalid_variable_error(variable_name, "variable namespace must be followed by a name"));
|
||||||
|
}
|
||||||
|
for byte in variable_name.bytes() {
|
||||||
|
let valid = byte.is_ascii_uppercase() || byte.is_ascii_digit() || byte == b'_';
|
||||||
|
if !valid {
|
||||||
|
return std::result::Result::Err(invalid_variable_error(variable_name, "variable names use uppercase ASCII letters, digits and underscores"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn has_supported_namespace(variable_name: &str) -> bool {
|
||||||
|
return variable_name.starts_with("KSP_") || variable_name.starts_with("KSPB_");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_generic_dotenv_name(variable_name: &str) -> bool {
|
||||||
|
let mut bytes = variable_name.bytes();
|
||||||
|
let first = match bytes.next() {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => return false,
|
||||||
|
};
|
||||||
|
if !(first.is_ascii_alphabetic() || first == b'_') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
for byte in bytes {
|
||||||
|
if !(byte.is_ascii_alphanumeric() || byte == b'_') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn emit_missing_variable_warning(variable_name: &str) {
|
||||||
|
ksp_logging_lib::warn!(target: LOGGING_TARGET, domain = LOGGING_DOMAIN, variable_name = variable_name, "Config environment variable is missing");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn missing_variable_error(variable_name: &str) -> ksp_core_lib::Error {
|
||||||
|
return ksp_core_lib::Error::new(crate::ERROR_CODE_ENVIRONMENT_VARIABLE_MISSING, "required Config environment variable is missing")
|
||||||
|
.with_context("variable_name", variable_name);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn invalid_variable_error(variable_name: &str, reason: &'static str) -> ksp_core_lib::Error {
|
||||||
|
return ksp_core_lib::Error::new(crate::ERROR_CODE_ENVIRONMENT_VARIABLE_INVALID, "Config environment variable name is invalid")
|
||||||
|
.with_context("variable_name", variable_name)
|
||||||
|
.with_context("reason", reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn invalid_environment_value_error(variable_name: &str) -> ksp_core_lib::Error {
|
||||||
|
return ksp_core_lib::Error::new(crate::ERROR_CODE_ENVIRONMENT_VALUE_INVALID, "Config environment variable value is not valid UTF-8")
|
||||||
|
.with_context("variable_name", variable_name);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn invalid_placeholder_error(reason: &'static str) -> ksp_core_lib::Error {
|
||||||
|
return ksp_core_lib::Error::new(crate::ERROR_CODE_ENVIRONMENT_PLACEHOLDER_INVALID, "Config environment placeholder is invalid")
|
||||||
|
.with_context("reason", reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn dotenv_read_error(path: &std::path::Path, source: std::io::Error) -> ksp_core_lib::Error {
|
||||||
|
return ksp_core_lib::Error::new(crate::ERROR_CODE_DOTENV_FILE_READ_FAILED, "Config cannot read the local .env file")
|
||||||
|
.with_context("path", path.to_string_lossy().into_owned())
|
||||||
|
.with_source(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn dotenv_syntax_error(path: &std::path::Path, line_number: usize, reason: &'static str) -> ksp_core_lib::Error {
|
||||||
|
return ksp_core_lib::Error::new(crate::ERROR_CODE_DOTENV_SYNTAX_INVALID, "Config local .env syntax is invalid")
|
||||||
|
.with_context("path", path.to_string_lossy().into_owned())
|
||||||
|
.with_context("line", line_number.to_string())
|
||||||
|
.with_context("reason", reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn dotenv_duplicate_error(path: &std::path::Path, line_number: usize, variable_name: &str) -> ksp_core_lib::Error {
|
||||||
|
return ksp_core_lib::Error::new(crate::ERROR_CODE_DOTENV_SYNTAX_INVALID, "Config local .env contains a duplicate KSP variable")
|
||||||
|
.with_context("path", path.to_string_lossy().into_owned())
|
||||||
|
.with_context("line", line_number.to_string())
|
||||||
|
.with_context("variable_name", variable_name);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "../unit_tests/environment.rs"]
|
||||||
|
mod tests;
|
||||||
68
crates/ksp-config-lib/src/error.rs
Normal file
68
crates/ksp-config-lib/src/error.rs
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
// file: crates/ksp-config-lib/src/error.rs
|
||||||
|
// version: 8
|
||||||
|
|
||||||
|
/// Error code used when a Config bootstrap argument is missing its value.
|
||||||
|
pub const ERROR_CODE_BOOTSTRAP_ARGUMENT_MISSING_VALUE: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config", "bootstrap_argument_missing_value");
|
||||||
|
|
||||||
|
/// Error code used when a Config bootstrap path is empty, inaccessible, or resolves to an existing non-directory path.
|
||||||
|
pub const ERROR_CODE_BOOTSTRAP_INVALID_PATH: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config", "bootstrap_invalid_path");
|
||||||
|
|
||||||
|
/// Error code used when a logical Config file identifier is malformed.
|
||||||
|
pub const ERROR_CODE_FILE_ID_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config", "file_id_invalid");
|
||||||
|
|
||||||
|
/// Error code used when a requested logical Config file identifier is not registered.
|
||||||
|
pub const ERROR_CODE_FILE_ID_UNKNOWN: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config", "file_id_unknown");
|
||||||
|
|
||||||
|
/// Error code used when the same logical Config file identifier is registered more than once.
|
||||||
|
pub const ERROR_CODE_FILE_ID_DUPLICATE: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config", "file_id_duplicate");
|
||||||
|
|
||||||
|
/// Error code used when a Config filename mapping or descriptor relation is invalid.
|
||||||
|
pub const ERROR_CODE_FILE_MAPPING_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config", "file_mapping_invalid");
|
||||||
|
|
||||||
|
/// Error code used when a Config-managed JSON document or schema cannot be read from its resolved path.
|
||||||
|
pub const ERROR_CODE_JSON_FILE_READ_FAILED: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config", "json_file_read_failed");
|
||||||
|
|
||||||
|
/// Error code used when a Config-managed file contains invalid JSON syntax.
|
||||||
|
pub const ERROR_CODE_JSON_SYNTAX_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config", "json_syntax_invalid");
|
||||||
|
|
||||||
|
/// Error code used when a JSON Schema document is itself invalid for the selected JSON Schema draft.
|
||||||
|
pub const ERROR_CODE_SCHEMA_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config", "schema_invalid");
|
||||||
|
|
||||||
|
/// Error code used when a Config document does not satisfy its registered JSON Schema.
|
||||||
|
pub const ERROR_CODE_SCHEMA_VALIDATION_FAILED: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config", "schema_validation_failed");
|
||||||
|
|
||||||
|
/// Error code used when a schema-valid Config document violates KSP semantic invariants for its document type.
|
||||||
|
pub const ERROR_CODE_DOCUMENT_SEMANTIC_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config", "document_semantic_invalid");
|
||||||
|
|
||||||
|
/// Error code used when an explicitly requested Config profile does not exist in a validated document.
|
||||||
|
pub const ERROR_CODE_PROFILE_NOT_FOUND: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config", "profile_not_found");
|
||||||
|
|
||||||
|
/// Error code used when a composite document references an invalid, unknown, or unsupported Config document.
|
||||||
|
pub const ERROR_CODE_COMPOSITE_REFERENCE_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config", "composite_reference_invalid");
|
||||||
|
|
||||||
|
/// Error code used when the local `.env` file cannot be read for a reason other than absence.
|
||||||
|
pub const ERROR_CODE_DOTENV_FILE_READ_FAILED: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config", "dotenv_file_read_failed");
|
||||||
|
|
||||||
|
/// Error code used when the local `.env` file contains syntax Config cannot interpret safely.
|
||||||
|
pub const ERROR_CODE_DOTENV_SYNTAX_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config", "dotenv_syntax_invalid");
|
||||||
|
|
||||||
|
/// Error code used when a Config environment variable name is malformed or outside the KSP/KSPB namespaces.
|
||||||
|
pub const ERROR_CODE_ENVIRONMENT_VARIABLE_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config", "environment_variable_invalid");
|
||||||
|
|
||||||
|
/// Error code used when a referenced Config environment variable is absent and has no fallback.
|
||||||
|
pub const ERROR_CODE_ENVIRONMENT_VARIABLE_MISSING: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config", "environment_variable_missing");
|
||||||
|
|
||||||
|
/// Error code used when a supported process environment variable has a value that cannot become a JSON UTF-8 string.
|
||||||
|
pub const ERROR_CODE_ENVIRONMENT_VALUE_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config", "environment_value_invalid");
|
||||||
|
|
||||||
|
/// Error code used when a `${NAME}` / `${NAME:-fallback}` expression is malformed.
|
||||||
|
pub const ERROR_CODE_ENVIRONMENT_PLACEHOLDER_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config", "environment_placeholder_invalid");
|
||||||
|
|
||||||
|
/// Error code used when an environment-resolved Config cannot be mapped safely to a runtime consumer contract.
|
||||||
|
pub const ERROR_CODE_EFFECTIVE_CONFIG_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config", "effective_config_invalid");
|
||||||
|
|
||||||
|
/// Error code used when an explicit Config management operation is unsupported or targets the wrong managed resource kind.
|
||||||
|
pub const ERROR_CODE_MANAGEMENT_OPERATION_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config", "management_operation_invalid");
|
||||||
|
|
||||||
|
/// Error code used when an atomic managed Config or `.env` persistence operation fails before commit.
|
||||||
|
pub const ERROR_CODE_PERSISTENCE_WRITE_FAILED: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config", "persistence_write_failed");
|
||||||
159
crates/ksp-config-lib/src/lib.rs
Normal file
159
crates/ksp-config-lib/src/lib.rs
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
// file: crates/ksp-config-lib/src/lib.rs
|
||||||
|
// version: 10
|
||||||
|
#![warn(missing_docs)]
|
||||||
|
#![deny(unreachable_pub)]
|
||||||
|
#![forbid(unsafe_code)]
|
||||||
|
|
||||||
|
//! KSP-owned application configuration facade.
|
||||||
|
//!
|
||||||
|
//! The `0.1.3` surface owns bootstrap roots, the logical file registry, JSON/JSON Schema validation, standard-document profiles, generic composites and
|
||||||
|
//! KSP/KSPB environment resolution through process + `.env` + fallback precedence. Resolved values preserve real/safe representations, sensitivity and
|
||||||
|
//! provenance. The standard Logging document maps explicitly to `ksp_logging_lib::LoggingSettings`, while the management surface provides typed Logging
|
||||||
|
//! mutation, safe environment reports, explicit privileged reveal calls and atomic JSON/`.env` persistence.
|
||||||
|
|
||||||
|
mod bootstrap;
|
||||||
|
mod composite;
|
||||||
|
mod document;
|
||||||
|
mod environment;
|
||||||
|
mod error;
|
||||||
|
mod logging;
|
||||||
|
mod management;
|
||||||
|
mod persistence;
|
||||||
|
mod profile;
|
||||||
|
mod registry;
|
||||||
|
mod sensitivity;
|
||||||
|
|
||||||
|
/// Bootstrap argument used to replace the configuration document root.
|
||||||
|
pub use self::bootstrap::ARG_CFG_PATH;
|
||||||
|
/// Bootstrap argument used to replace the schema root.
|
||||||
|
pub use self::bootstrap::ARG_SCHEMA_PATH;
|
||||||
|
/// Non-recursive bootstrap options required before Config can resolve any managed document.
|
||||||
|
pub use self::bootstrap::ConfigBootstrapOptions;
|
||||||
|
/// Default root containing KSP runtime configuration documents.
|
||||||
|
pub use self::bootstrap::DEFAULT_CFG_PATH;
|
||||||
|
/// Default root containing KSP JSON schemas.
|
||||||
|
pub use self::bootstrap::DEFAULT_SCHEMA_PATH;
|
||||||
|
/// One resolved document component selected by a composite profile.
|
||||||
|
pub use self::composite::ResolvedCompositeComponent;
|
||||||
|
/// Validated composite document resolved to one profile and its referenced standard documents.
|
||||||
|
pub use self::composite::ResolvedConfigComposite;
|
||||||
|
/// Generic JSON/JSON Schema engine owned by Config.
|
||||||
|
pub use self::document::ConfigDocumentEngine;
|
||||||
|
/// A Config-managed JSON document after syntax, schema and current semantic validation.
|
||||||
|
pub use self::document::ConfigJsonDocument;
|
||||||
|
/// Config-owned snapshot of KSP/KSPB process environment values and the local `.env` file.
|
||||||
|
pub use self::environment::ConfigEnvironment;
|
||||||
|
/// Source that supplied one resolved Config environment variable.
|
||||||
|
pub use self::environment::ConfigEnvironmentSource;
|
||||||
|
/// One resolved Config environment variable with real/safe values, sensitivity and its winning source.
|
||||||
|
pub use self::environment::ConfigEnvironmentValue;
|
||||||
|
/// Versioned environment contract template expected at the repository/runtime root.
|
||||||
|
pub use self::environment::DEFAULT_DOTENV_EXAMPLE_PATH;
|
||||||
|
/// Default local environment file read by Config from the process launch directory.
|
||||||
|
pub use self::environment::DEFAULT_DOTENV_PATH;
|
||||||
|
/// Error code used when a Config bootstrap argument is missing its value.
|
||||||
|
pub use self::error::ERROR_CODE_BOOTSTRAP_ARGUMENT_MISSING_VALUE;
|
||||||
|
/// Error code used when a Config bootstrap path is empty, inaccessible, or resolves to an existing non-directory path.
|
||||||
|
pub use self::error::ERROR_CODE_BOOTSTRAP_INVALID_PATH;
|
||||||
|
/// Error code used when a composite document contains an invalid or unsupported document reference.
|
||||||
|
pub use self::error::ERROR_CODE_COMPOSITE_REFERENCE_INVALID;
|
||||||
|
/// Error code used when a schema-valid Config document violates KSP semantic invariants.
|
||||||
|
pub use self::error::ERROR_CODE_DOCUMENT_SEMANTIC_INVALID;
|
||||||
|
/// Error code used when the local `.env` file cannot be read for a reason other than absence.
|
||||||
|
pub use self::error::ERROR_CODE_DOTENV_FILE_READ_FAILED;
|
||||||
|
/// Error code used when the local `.env` file contains invalid syntax.
|
||||||
|
pub use self::error::ERROR_CODE_DOTENV_SYNTAX_INVALID;
|
||||||
|
/// Error code used when an environment-resolved Config cannot map safely to a runtime consumer contract.
|
||||||
|
pub use self::error::ERROR_CODE_EFFECTIVE_CONFIG_INVALID;
|
||||||
|
/// Error code used when a Config environment placeholder is malformed.
|
||||||
|
pub use self::error::ERROR_CODE_ENVIRONMENT_PLACEHOLDER_INVALID;
|
||||||
|
/// Error code used when a supported Config environment variable has a non-UTF-8 process value.
|
||||||
|
pub use self::error::ERROR_CODE_ENVIRONMENT_VALUE_INVALID;
|
||||||
|
/// Error code used when a Config environment variable name is invalid or outside KSP/KSPB namespaces.
|
||||||
|
pub use self::error::ERROR_CODE_ENVIRONMENT_VARIABLE_INVALID;
|
||||||
|
/// Error code used when a referenced Config environment variable is absent and has no fallback.
|
||||||
|
pub use self::error::ERROR_CODE_ENVIRONMENT_VARIABLE_MISSING;
|
||||||
|
/// Error code used when the same logical Config file identifier is registered more than once.
|
||||||
|
pub use self::error::ERROR_CODE_FILE_ID_DUPLICATE;
|
||||||
|
/// Error code used when a logical Config file identifier is malformed.
|
||||||
|
pub use self::error::ERROR_CODE_FILE_ID_INVALID;
|
||||||
|
/// Error code used when a requested logical Config file identifier is not registered.
|
||||||
|
pub use self::error::ERROR_CODE_FILE_ID_UNKNOWN;
|
||||||
|
/// Error code used when a Config filename mapping or descriptor relation is invalid.
|
||||||
|
pub use self::error::ERROR_CODE_FILE_MAPPING_INVALID;
|
||||||
|
/// Error code used when a Config-managed JSON document or schema cannot be read.
|
||||||
|
pub use self::error::ERROR_CODE_JSON_FILE_READ_FAILED;
|
||||||
|
/// Error code used when a Config-managed file contains invalid JSON syntax.
|
||||||
|
pub use self::error::ERROR_CODE_JSON_SYNTAX_INVALID;
|
||||||
|
/// Error code used when an explicit management operation is unsupported or targets the wrong managed resource kind.
|
||||||
|
pub use self::error::ERROR_CODE_MANAGEMENT_OPERATION_INVALID;
|
||||||
|
/// Error code used when atomic managed Config or `.env` persistence fails before commit.
|
||||||
|
pub use self::error::ERROR_CODE_PERSISTENCE_WRITE_FAILED;
|
||||||
|
/// Error code used when an explicitly requested Config profile does not exist.
|
||||||
|
pub use self::error::ERROR_CODE_PROFILE_NOT_FOUND;
|
||||||
|
/// Error code used when a JSON Schema document is itself invalid.
|
||||||
|
pub use self::error::ERROR_CODE_SCHEMA_INVALID;
|
||||||
|
/// Error code used when a Config document fails its registered JSON Schema validation.
|
||||||
|
pub use self::error::ERROR_CODE_SCHEMA_VALIDATION_FAILED;
|
||||||
|
/// Effective standard Logging configuration mapped to `ksp_logging_lib::LoggingSettings`.
|
||||||
|
pub use self::logging::ResolvedLoggingConfig;
|
||||||
|
/// Result of one validated Config document persistence operation.
|
||||||
|
pub use self::management::ConfigDocumentChangeReport;
|
||||||
|
/// Result of one persistent `.env` mutation.
|
||||||
|
pub use self::management::ConfigEnvironmentChangeReport;
|
||||||
|
/// Safe desired/effective/shadow view of one KSP/KSPB environment variable.
|
||||||
|
pub use self::management::ConfigEnvironmentReport;
|
||||||
|
/// Raw source of one registered Config document read through the explicit management surface.
|
||||||
|
pub use self::management::ConfigManagedSource;
|
||||||
|
/// Explicit Config management facade for source inspection and validated persistent mutations.
|
||||||
|
pub use self::management::ConfigManagement;
|
||||||
|
/// Typed source contract for `config/std.logging.json`.
|
||||||
|
pub use self::management::LoggingConfigDocument;
|
||||||
|
/// Typed source contract for the standard Logging console output.
|
||||||
|
pub use self::management::LoggingConsoleConfig;
|
||||||
|
/// Typed source contract for one persistent Logging file output.
|
||||||
|
pub use self::management::LoggingFileConfig;
|
||||||
|
/// Typed source contract for one Logging sink selector/filter.
|
||||||
|
pub use self::management::LoggingOutputFilterConfig;
|
||||||
|
/// Typed source contract for one profile in `std.logging.json`.
|
||||||
|
pub use self::management::LoggingProfileConfig;
|
||||||
|
/// Typed source contract for one global Logging target override.
|
||||||
|
pub use self::management::LoggingTargetFilterConfig;
|
||||||
|
/// Source that selected an effective standard Config profile.
|
||||||
|
pub use self::profile::ConfigProfileSelectionSource;
|
||||||
|
/// Origin of one top-level value in a resolved standard Config profile.
|
||||||
|
pub use self::profile::ConfigValueOrigin;
|
||||||
|
/// Validated standard Config document resolved to one profile with global/profile provenance.
|
||||||
|
pub use self::profile::ResolvedConfigProfile;
|
||||||
|
/// Bootstrap argument used to replace a known Config filename mapping.
|
||||||
|
pub use self::registry::ARG_FILE_MAP;
|
||||||
|
/// Logical descriptor associating a stable file identifier with its physical filename and validation schema.
|
||||||
|
pub use self::registry::ConfigFileDescriptor;
|
||||||
|
/// Stable logical identifier for a Config-managed file.
|
||||||
|
pub use self::registry::ConfigFileId;
|
||||||
|
/// Physical root category used to resolve a Config-managed file.
|
||||||
|
pub use self::registry::ConfigFileKind;
|
||||||
|
/// Registry of KSP-known logical Config files and their replaceable physical filenames.
|
||||||
|
pub use self::registry::ConfigFileRegistry;
|
||||||
|
/// Default physical filename for the generic composite JSON Schema document.
|
||||||
|
pub use self::registry::DEFAULT_COMPOSITE_SCHEMA_FILENAME;
|
||||||
|
/// Default physical filename for the standard Logging configuration document.
|
||||||
|
pub use self::registry::DEFAULT_STD_LOGGING_FILENAME;
|
||||||
|
/// Default physical filename for the standard Logging JSON Schema document.
|
||||||
|
pub use self::registry::DEFAULT_STD_LOGGING_SCHEMA_FILENAME;
|
||||||
|
/// Logical file identifier for the generic composite JSON Schema document.
|
||||||
|
pub use self::registry::FILE_ID_SCHEMA_COMPOSITE;
|
||||||
|
/// Logical file identifier for the standard Logging JSON Schema document.
|
||||||
|
pub use self::registry::FILE_ID_SCHEMA_STD_LOGGING;
|
||||||
|
/// Logical file identifier for the standard Logging configuration document.
|
||||||
|
pub use self::registry::FILE_ID_STD_LOGGING;
|
||||||
|
/// Sensitivity assigned to one Config value after environment resolution.
|
||||||
|
pub use self::sensitivity::ConfigSensitivity;
|
||||||
|
/// Provenance segment participating in one resolved Config value.
|
||||||
|
pub use self::sensitivity::ConfigValueProvenance;
|
||||||
|
/// Replacement used for secret environment fragments in safe diagnostic representations.
|
||||||
|
pub use self::sensitivity::REDACTED_CONFIG_VALUE;
|
||||||
|
/// Recursively resolved JSON value preserving real/safe trees and provenance.
|
||||||
|
pub use self::sensitivity::ResolvedConfigJson;
|
||||||
|
/// One resolved Config string preserving real/safe representations and provenance.
|
||||||
|
pub use self::sensitivity::ResolvedConfigText;
|
||||||
437
crates/ksp-config-lib/src/logging.rs
Normal file
437
crates/ksp-config-lib/src/logging.rs
Normal file
@@ -0,0 +1,437 @@
|
|||||||
|
// file: crates/ksp-config-lib/src/logging.rs
|
||||||
|
// version: 1
|
||||||
|
|
||||||
|
/// Effective standard Logging configuration resolved from Config and mapped to the Logging runtime contract.
|
||||||
|
#[derive(Clone, Eq, PartialEq)]
|
||||||
|
pub struct ResolvedLoggingConfig {
|
||||||
|
file_id: crate::ConfigFileId,
|
||||||
|
source_path: std::path::PathBuf,
|
||||||
|
profile_id: String,
|
||||||
|
selection_source: crate::ConfigProfileSelectionSource,
|
||||||
|
effective: crate::ResolvedConfigJson,
|
||||||
|
logs_directory: std::path::PathBuf,
|
||||||
|
settings: ksp_logging_lib::LoggingSettings,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ResolvedLoggingConfig {
|
||||||
|
/// Returns the logical Config document identifier used by this runtime configuration.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn file_id(&self) -> &crate::ConfigFileId {
|
||||||
|
return &self.file_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the physical source Config document path.
|
||||||
|
#[must_use]
|
||||||
|
pub fn source_path(&self) -> &std::path::Path {
|
||||||
|
return self.source_path.as_path();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the selected standard Logging profile identifier.
|
||||||
|
#[must_use]
|
||||||
|
pub fn profile_id(&self) -> &str {
|
||||||
|
return self.profile_id.as_str();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the source that selected the standard Logging profile.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn selection_source(&self) -> crate::ConfigProfileSelectionSource {
|
||||||
|
return self.selection_source;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the detailed environment-resolved effective Config view.
|
||||||
|
///
|
||||||
|
/// The real tree is available to legitimate runtime consumers and the safe tree is suitable for ordinary diagnostics.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn effective(&self) -> &crate::ResolvedConfigJson {
|
||||||
|
return &self.effective;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the validated real Logging root directory.
|
||||||
|
///
|
||||||
|
/// Relative Config values are anchored to the process current working directory when this adapter runs. Absolute Config values are preserved.
|
||||||
|
#[must_use]
|
||||||
|
pub fn logs_directory(&self) -> &std::path::Path {
|
||||||
|
return self.logs_directory.as_path();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the mapped runtime Logging settings.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn settings(&self) -> &ksp_logging_lib::LoggingSettings {
|
||||||
|
return &self.settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Consumes this resolved Config and returns the mapped runtime Logging settings.
|
||||||
|
#[must_use]
|
||||||
|
pub fn into_settings(self) -> ksp_logging_lib::LoggingSettings {
|
||||||
|
return self.settings;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Debug for ResolvedLoggingConfig {
|
||||||
|
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
return formatter
|
||||||
|
.debug_struct("ResolvedLoggingConfig")
|
||||||
|
.field("file_id", &self.file_id)
|
||||||
|
.field("source_path", &self.source_path)
|
||||||
|
.field("profile_id", &self.profile_id)
|
||||||
|
.field("selection_source", &self.selection_source)
|
||||||
|
.field("effective", &self.effective)
|
||||||
|
.finish_non_exhaustive();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl crate::ConfigDocumentEngine {
|
||||||
|
/// Loads the standard Logging document, selects a profile, resolves environment placeholders and maps the effective result to `LoggingSettings`.
|
||||||
|
///
|
||||||
|
/// `requested_profile = None` uses the document `default_profile`; `Some(profile_id)` requests an explicit profile. Source JSON validation remains distinct
|
||||||
|
/// from effective runtime validation: an environment value that resolves to an invalid Logging setting returns
|
||||||
|
/// [`crate::ERROR_CODE_EFFECTIVE_CONFIG_INVALID`] and does not silently fall back to the placeholder fallback.
|
||||||
|
pub fn load_resolved_logging_config(
|
||||||
|
&self,
|
||||||
|
requested_profile: std::option::Option<&str>,
|
||||||
|
environment: &crate::ConfigEnvironment,
|
||||||
|
) -> ksp_core_lib::Result<ResolvedLoggingConfig> {
|
||||||
|
let file_id = crate::ConfigFileId::new(crate::FILE_ID_STD_LOGGING);
|
||||||
|
let file_id = match file_id {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let profile = self.load_resolved_profile(&file_id, requested_profile);
|
||||||
|
let profile = match profile {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
return resolve_logging_profile(&profile, environment);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
struct EffectiveLoggingSource {
|
||||||
|
format_version: u32,
|
||||||
|
logs_directory: String,
|
||||||
|
profile_id: String,
|
||||||
|
default_filter: String,
|
||||||
|
span_events: String,
|
||||||
|
console: EffectiveConsoleSource,
|
||||||
|
files: std::vec::Vec<EffectiveFileSource>,
|
||||||
|
target_filters: std::vec::Vec<EffectiveTargetFilterSource>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
struct EffectiveConsoleSource {
|
||||||
|
enabled: bool,
|
||||||
|
output: String,
|
||||||
|
ansi: bool,
|
||||||
|
format: String,
|
||||||
|
filter: EffectiveOutputFilterSource,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
struct EffectiveFileSource {
|
||||||
|
output_id: String,
|
||||||
|
enabled: bool,
|
||||||
|
path: String,
|
||||||
|
rotation: String,
|
||||||
|
format: String,
|
||||||
|
ansi: bool,
|
||||||
|
filter: EffectiveOutputFilterSource,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
struct EffectiveOutputFilterSource {
|
||||||
|
level: String,
|
||||||
|
targets: std::vec::Vec<String>,
|
||||||
|
domains: std::vec::Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
struct EffectiveTargetFilterSource {
|
||||||
|
target_prefix: String,
|
||||||
|
level: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_logging_profile(profile: &crate::ResolvedConfigProfile, environment: &crate::ConfigEnvironment) -> ksp_core_lib::Result<ResolvedLoggingConfig> {
|
||||||
|
let effective = profile.resolve_effective_environment_detailed(environment);
|
||||||
|
let effective = match effective {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let sensitivity_validation = validate_logging_sensitivity(profile, &effective);
|
||||||
|
if let std::result::Result::Err(error) = sensitivity_validation {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
let source = serde_json::from_value::<EffectiveLoggingSource>(effective.value().clone());
|
||||||
|
let source = match source {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => {
|
||||||
|
return std::result::Result::Err(
|
||||||
|
effective_error(profile, "effective Logging Config cannot be decoded into the runtime adapter contract").with_source(error),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
if source.format_version != 1 {
|
||||||
|
return std::result::Result::Err(effective_error(profile, "effective Logging format_version is unsupported"));
|
||||||
|
}
|
||||||
|
if source.profile_id != profile.profile_id() {
|
||||||
|
return std::result::Result::Err(effective_error(profile, "effective Logging profile_id differs from the selected source profile"));
|
||||||
|
}
|
||||||
|
let safe_logs_directory = safe_string_at(effective.safe_value(), "/logs_directory");
|
||||||
|
let logs_directory = resolve_logs_directory(source.logs_directory.as_str(), safe_logs_directory.as_str(), profile);
|
||||||
|
let logs_directory = match logs_directory {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let default_filter = map_level(source.default_filter.as_str(), "default_filter", profile);
|
||||||
|
let default_filter = match default_filter {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let span_events = map_span_events(source.span_events.as_str(), profile);
|
||||||
|
let span_events = match span_events {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let console = map_console(source.console, profile);
|
||||||
|
let console = match console {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let files = map_files(source.files, logs_directory.as_path(), profile);
|
||||||
|
let files = match files {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let mut settings = ksp_logging_lib::LoggingSettings::new(default_filter, span_events, std::option::Option::Some(console), files);
|
||||||
|
for target_filter in source.target_filters {
|
||||||
|
let level = map_level(target_filter.level.as_str(), "target_filters.level", profile);
|
||||||
|
let level = match level {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
settings = settings.with_target_filter(ksp_logging_lib::TargetFilter::new(target_filter.target_prefix, level));
|
||||||
|
}
|
||||||
|
let validation = settings.validate();
|
||||||
|
if let std::result::Result::Err(error) = validation {
|
||||||
|
return std::result::Result::Err(
|
||||||
|
effective_error(profile, "effective Logging settings fail the Logging runtime contract")
|
||||||
|
.with_context("logging_error_domain", error.code().domain())
|
||||||
|
.with_context("logging_error_code", error.code().code()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(ResolvedLoggingConfig {
|
||||||
|
file_id: profile.file_id().clone(),
|
||||||
|
source_path: profile.path().to_path_buf(),
|
||||||
|
profile_id: profile.profile_id().to_owned(),
|
||||||
|
selection_source: profile.selection_source(),
|
||||||
|
effective,
|
||||||
|
logs_directory,
|
||||||
|
settings,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_logging_sensitivity(profile: &crate::ResolvedConfigProfile, effective: &crate::ResolvedConfigJson) -> ksp_core_lib::Result<()> {
|
||||||
|
if effective.sensitivity().is_secret() {
|
||||||
|
return std::result::Result::Err(effective_error(profile, "standard Logging configuration must not consume Secret environment values"));
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map_console(source: EffectiveConsoleSource, profile: &crate::ResolvedConfigProfile) -> ksp_core_lib::Result<ksp_logging_lib::ConsoleSettings> {
|
||||||
|
let output = match source.output.as_str() {
|
||||||
|
"stdout" => ksp_logging_lib::ConsoleOutput::Stdout,
|
||||||
|
"stderr" => ksp_logging_lib::ConsoleOutput::Stderr,
|
||||||
|
_ => return std::result::Result::Err(effective_field_error(profile, "console.output", "effective Logging console output is unsupported")),
|
||||||
|
};
|
||||||
|
let format = map_format(source.format.as_str(), "console.format", profile);
|
||||||
|
let format = match format {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let filter = map_output_filter(source.filter, "console.filter", profile);
|
||||||
|
let filter = match filter {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
return std::result::Result::Ok(ksp_logging_lib::ConsoleSettings::new(source.enabled, output, source.ansi, format, filter));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map_files(
|
||||||
|
sources: std::vec::Vec<EffectiveFileSource>,
|
||||||
|
logs_directory: &std::path::Path,
|
||||||
|
profile: &crate::ResolvedConfigProfile,
|
||||||
|
) -> ksp_core_lib::Result<std::vec::Vec<ksp_logging_lib::FileSettings>> {
|
||||||
|
let mut files = std::vec::Vec::<ksp_logging_lib::FileSettings>::with_capacity(sources.len());
|
||||||
|
for source in sources {
|
||||||
|
if !relative_file_path_is_valid(source.path.as_str()) {
|
||||||
|
return std::result::Result::Err(
|
||||||
|
effective_field_error(profile, "files.path", "effective Logging file path must stay relative to logs_directory without traversal")
|
||||||
|
.with_context("output_id", source.output_id.as_str()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let file_path = std::path::Path::new(source.path.as_str());
|
||||||
|
let file_name = match file_path.file_name().and_then(std::ffi::OsStr::to_str) {
|
||||||
|
std::option::Option::Some(value) if !value.is_empty() => value.to_owned(),
|
||||||
|
_ => return std::result::Result::Err(effective_field_error(profile, "files.path", "effective Logging file path has no UTF-8 file name")),
|
||||||
|
};
|
||||||
|
let relative_directory = match file_path.parent() {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => std::path::Path::new(""),
|
||||||
|
};
|
||||||
|
let directory = logs_directory.join(relative_directory);
|
||||||
|
let rotation = map_rotation(source.rotation.as_str(), profile);
|
||||||
|
let rotation = match rotation {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let format = map_format(source.format.as_str(), "files.format", profile);
|
||||||
|
let format = match format {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let filter = map_output_filter(source.filter, "files.filter", profile);
|
||||||
|
let filter = match filter {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let file = ksp_logging_lib::FileSettings::new(source.output_id, source.enabled, directory, file_name, rotation, format, filter).with_ansi(source.ansi);
|
||||||
|
files.push(file);
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(files);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map_output_filter(
|
||||||
|
source: EffectiveOutputFilterSource,
|
||||||
|
field: &'static str,
|
||||||
|
profile: &crate::ResolvedConfigProfile,
|
||||||
|
) -> ksp_core_lib::Result<ksp_logging_lib::OutputFilter> {
|
||||||
|
let level = map_level(source.level.as_str(), field, profile);
|
||||||
|
let level = match level {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
return std::result::Result::Ok(ksp_logging_lib::OutputFilter::new(level, source.targets, source.domains));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map_level(value: &str, field: &'static str, profile: &crate::ResolvedConfigProfile) -> ksp_core_lib::Result<ksp_logging_lib::LogFilterLevel> {
|
||||||
|
return match value {
|
||||||
|
"off" => std::result::Result::Ok(ksp_logging_lib::LogFilterLevel::Off),
|
||||||
|
"error" => std::result::Result::Ok(ksp_logging_lib::LogFilterLevel::Error),
|
||||||
|
"warn" => std::result::Result::Ok(ksp_logging_lib::LogFilterLevel::Warn),
|
||||||
|
"info" => std::result::Result::Ok(ksp_logging_lib::LogFilterLevel::Info),
|
||||||
|
"debug" => std::result::Result::Ok(ksp_logging_lib::LogFilterLevel::Debug),
|
||||||
|
"trace" => std::result::Result::Ok(ksp_logging_lib::LogFilterLevel::Trace),
|
||||||
|
_ => std::result::Result::Err(effective_field_error(profile, field, "effective Logging level is unsupported")),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map_span_events(value: &str, profile: &crate::ResolvedConfigProfile) -> ksp_core_lib::Result<ksp_logging_lib::SpanEvents> {
|
||||||
|
return match value {
|
||||||
|
"off" => std::result::Result::Ok(ksp_logging_lib::SpanEvents::Off),
|
||||||
|
"new_and_close" => std::result::Result::Ok(ksp_logging_lib::SpanEvents::NewAndClose),
|
||||||
|
"full" => std::result::Result::Ok(ksp_logging_lib::SpanEvents::Full),
|
||||||
|
_ => std::result::Result::Err(effective_field_error(profile, "span_events", "effective Logging span_events value is unsupported")),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map_format(value: &str, field: &'static str, profile: &crate::ResolvedConfigProfile) -> ksp_core_lib::Result<ksp_logging_lib::LogFormat> {
|
||||||
|
return match value {
|
||||||
|
"human" => std::result::Result::Ok(ksp_logging_lib::LogFormat::Human),
|
||||||
|
"compact" => std::result::Result::Ok(ksp_logging_lib::LogFormat::Compact),
|
||||||
|
"pretty" => std::result::Result::Ok(ksp_logging_lib::LogFormat::Pretty),
|
||||||
|
"json" => std::result::Result::Ok(ksp_logging_lib::LogFormat::Json),
|
||||||
|
_ => std::result::Result::Err(effective_field_error(profile, field, "effective Logging format is unsupported")),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map_rotation(value: &str, profile: &crate::ResolvedConfigProfile) -> ksp_core_lib::Result<ksp_logging_lib::FileRotation> {
|
||||||
|
return match value {
|
||||||
|
"never" => std::result::Result::Ok(ksp_logging_lib::FileRotation::Never),
|
||||||
|
"hourly" => std::result::Result::Ok(ksp_logging_lib::FileRotation::Hourly),
|
||||||
|
"daily" => std::result::Result::Ok(ksp_logging_lib::FileRotation::Daily),
|
||||||
|
_ => std::result::Result::Err(effective_field_error(profile, "files.rotation", "effective Logging rotation is unsupported")),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_logs_directory(value: &str, safe_value: &str, profile: &crate::ResolvedConfigProfile) -> ksp_core_lib::Result<std::path::PathBuf> {
|
||||||
|
if value.trim().is_empty() {
|
||||||
|
return std::result::Result::Err(
|
||||||
|
effective_field_error(profile, "logs_directory", "effective Logging logs_directory must not be empty").with_context("safe_value", safe_value),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let configured = std::path::PathBuf::from(value);
|
||||||
|
let resolved = if configured.is_absolute() {
|
||||||
|
configured
|
||||||
|
} else {
|
||||||
|
let current_directory = std::env::current_dir();
|
||||||
|
let current_directory = match current_directory {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => {
|
||||||
|
return std::result::Result::Err(
|
||||||
|
effective_field_error(profile, "logs_directory", "process current working directory cannot be resolved").with_source(error),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
current_directory.join(configured)
|
||||||
|
};
|
||||||
|
let metadata = std::fs::metadata(resolved.as_path());
|
||||||
|
match metadata {
|
||||||
|
std::result::Result::Ok(value) if !value.is_dir() => {
|
||||||
|
return std::result::Result::Err(
|
||||||
|
effective_field_error(profile, "logs_directory", "effective Logging logs_directory resolves to an existing non-directory path")
|
||||||
|
.with_context("safe_value", safe_value),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
std::result::Result::Ok(_) => {},
|
||||||
|
std::result::Result::Err(error) if error.kind() == std::io::ErrorKind::NotFound => {},
|
||||||
|
std::result::Result::Err(error) => {
|
||||||
|
return std::result::Result::Err(
|
||||||
|
effective_field_error(profile, "logs_directory", "effective Logging logs_directory cannot be inspected")
|
||||||
|
.with_context("safe_value", safe_value)
|
||||||
|
.with_source(error),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(resolved);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn relative_file_path_is_valid(value: &str) -> bool {
|
||||||
|
let path = std::path::Path::new(value);
|
||||||
|
if path.is_absolute() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let mut has_normal_component = false;
|
||||||
|
for component in path.components() {
|
||||||
|
match component {
|
||||||
|
std::path::Component::Normal(_) => has_normal_component = true,
|
||||||
|
std::path::Component::CurDir | std::path::Component::ParentDir | std::path::Component::RootDir | std::path::Component::Prefix(_) => return false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return has_normal_component;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn safe_string_at(value: &serde_json::Value, pointer: &str) -> String {
|
||||||
|
return match value.pointer(pointer).and_then(serde_json::Value::as_str) {
|
||||||
|
std::option::Option::Some(value) => value.to_owned(),
|
||||||
|
std::option::Option::None => "<unavailable>".to_owned(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn effective_field_error(profile: &crate::ResolvedConfigProfile, field: &'static str, reason: &'static str) -> ksp_core_lib::Error {
|
||||||
|
return effective_error(profile, reason).with_context("field", field);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn effective_error(profile: &crate::ResolvedConfigProfile, reason: &'static str) -> ksp_core_lib::Error {
|
||||||
|
return ksp_core_lib::Error::new(crate::ERROR_CODE_EFFECTIVE_CONFIG_INVALID, "effective Config cannot be mapped to the requested runtime contract")
|
||||||
|
.with_context("file_id", profile.file_id().as_str())
|
||||||
|
.with_context("profile_id", profile.profile_id())
|
||||||
|
.with_context("reason", reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "../unit_tests/logging.rs"]
|
||||||
|
mod tests;
|
||||||
1008
crates/ksp-config-lib/src/management.rs
Normal file
1008
crates/ksp-config-lib/src/management.rs
Normal file
File diff suppressed because it is too large
Load Diff
119
crates/ksp-config-lib/src/persistence.rs
Normal file
119
crates/ksp-config-lib/src/persistence.rs
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
// file: crates/ksp-config-lib/src/persistence.rs
|
||||||
|
// version: 2
|
||||||
|
|
||||||
|
static NEXT_TEMPORARY_FILE_ID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
|
||||||
|
|
||||||
|
pub(crate) fn atomic_write(path: &std::path::Path, content: &[u8]) -> ksp_core_lib::Result<()> {
|
||||||
|
return atomic_write_with_policy(path, content, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn atomic_write_private(path: &std::path::Path, content: &[u8]) -> ksp_core_lib::Result<()> {
|
||||||
|
return atomic_write_with_policy(path, content, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn atomic_write_with_policy(path: &std::path::Path, content: &[u8], private_when_new: bool) -> ksp_core_lib::Result<()> {
|
||||||
|
let parent = match path.parent() {
|
||||||
|
std::option::Option::Some(value) if !value.as_os_str().is_empty() => value,
|
||||||
|
_ => std::path::Path::new("."),
|
||||||
|
};
|
||||||
|
let filename = match path.file_name().and_then(std::ffi::OsStr::to_str) {
|
||||||
|
std::option::Option::Some(value) if !value.is_empty() => value,
|
||||||
|
_ => return std::result::Result::Err(persistence_error(path, "managed Config path has no UTF-8 file name")),
|
||||||
|
};
|
||||||
|
let existing_permissions = destination_permissions(path);
|
||||||
|
let existing_permissions = match existing_permissions {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let temporary_id = NEXT_TEMPORARY_FILE_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||||
|
let temporary_name = format!(".{filename}.ksp-tmp-{}-{temporary_id}", std::process::id());
|
||||||
|
let temporary_path = parent.join(temporary_name);
|
||||||
|
let opened = std::fs::OpenOptions::new().write(true).create_new(true).open(temporary_path.as_path());
|
||||||
|
let mut file = match opened {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(persistence_io_error(path, "temporary Config file cannot be created", error)),
|
||||||
|
};
|
||||||
|
let permissions = apply_temporary_permissions(&file, existing_permissions, private_when_new);
|
||||||
|
if let std::result::Result::Err(error) = permissions {
|
||||||
|
cleanup_temporary_file(temporary_path.as_path());
|
||||||
|
return std::result::Result::Err(persistence_io_error(path, "temporary Config file permissions cannot be applied", error));
|
||||||
|
}
|
||||||
|
let write = std::io::Write::write_all(&mut file, content);
|
||||||
|
if let std::result::Result::Err(error) = write {
|
||||||
|
cleanup_temporary_file(temporary_path.as_path());
|
||||||
|
return std::result::Result::Err(persistence_io_error(path, "temporary Config file cannot be written", error));
|
||||||
|
}
|
||||||
|
let sync = file.sync_all();
|
||||||
|
if let std::result::Result::Err(error) = sync {
|
||||||
|
cleanup_temporary_file(temporary_path.as_path());
|
||||||
|
return std::result::Result::Err(persistence_io_error(path, "temporary Config file cannot be synchronized", error));
|
||||||
|
}
|
||||||
|
drop(file);
|
||||||
|
let rename = std::fs::rename(temporary_path.as_path(), path);
|
||||||
|
if let std::result::Result::Err(error) = rename {
|
||||||
|
cleanup_temporary_file(temporary_path.as_path());
|
||||||
|
return std::result::Result::Err(persistence_io_error(path, "atomic Config file replacement failed", error));
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn destination_permissions(path: &std::path::Path) -> ksp_core_lib::Result<std::option::Option<std::fs::Permissions>> {
|
||||||
|
let metadata = std::fs::metadata(path);
|
||||||
|
return match metadata {
|
||||||
|
std::result::Result::Ok(value) => std::result::Result::Ok(std::option::Option::Some(value.permissions())),
|
||||||
|
std::result::Result::Err(error) if error.kind() == std::io::ErrorKind::NotFound => std::result::Result::Ok(std::option::Option::None),
|
||||||
|
std::result::Result::Err(error) => {
|
||||||
|
std::result::Result::Err(persistence_io_error(path, "managed Config file metadata cannot be read before replacement", error))
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply_temporary_permissions(
|
||||||
|
file: &std::fs::File,
|
||||||
|
existing_permissions: std::option::Option<std::fs::Permissions>,
|
||||||
|
private_when_new: bool,
|
||||||
|
) -> std::io::Result<()> {
|
||||||
|
if let std::option::Option::Some(permissions) = existing_permissions {
|
||||||
|
return file.set_permissions(permissions);
|
||||||
|
}
|
||||||
|
return apply_new_file_permissions(file, private_when_new);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
fn apply_new_file_permissions(file: &std::fs::File, private_when_new: bool) -> std::io::Result<()> {
|
||||||
|
if private_when_new {
|
||||||
|
let permissions = <std::fs::Permissions as std::os::unix::fs::PermissionsExt>::from_mode(0o600);
|
||||||
|
return file.set_permissions(permissions);
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(unix))]
|
||||||
|
fn apply_new_file_permissions(_file: &std::fs::File, _private_when_new: bool) -> std::io::Result<()> {
|
||||||
|
return std::result::Result::Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cleanup_temporary_file(path: &std::path::Path) {
|
||||||
|
let removal = std::fs::remove_file(path);
|
||||||
|
if let std::result::Result::Err(error) = removal
|
||||||
|
&& error.kind() != std::io::ErrorKind::NotFound
|
||||||
|
{
|
||||||
|
ksp_logging_lib::warn!(
|
||||||
|
target: "ksp-config-lib",
|
||||||
|
domain = "config.persistence",
|
||||||
|
path = %path.to_string_lossy(),
|
||||||
|
error = %error,
|
||||||
|
"unable to cleanup temporary Config file"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn persistence_error(path: &std::path::Path, reason: &'static str) -> ksp_core_lib::Error {
|
||||||
|
return ksp_core_lib::Error::new(crate::ERROR_CODE_PERSISTENCE_WRITE_FAILED, "Config persistence failed")
|
||||||
|
.with_context("path", path.to_string_lossy().into_owned())
|
||||||
|
.with_context("reason", reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn persistence_io_error(path: &std::path::Path, reason: &'static str, source: std::io::Error) -> ksp_core_lib::Error {
|
||||||
|
return persistence_error(path, reason).with_source(source);
|
||||||
|
}
|
||||||
292
crates/ksp-config-lib/src/profile.rs
Normal file
292
crates/ksp-config-lib/src/profile.rs
Normal file
@@ -0,0 +1,292 @@
|
|||||||
|
// file: crates/ksp-config-lib/src/profile.rs
|
||||||
|
// version: 4
|
||||||
|
|
||||||
|
/// Origin of one top-level value in a resolved standard Config profile.
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub enum ConfigValueOrigin {
|
||||||
|
/// Value comes from the global section of the specialized document.
|
||||||
|
Global,
|
||||||
|
/// Value comes from the selected profile object.
|
||||||
|
Profile,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Source that selected the effective profile.
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub enum ConfigProfileSelectionSource {
|
||||||
|
/// The document's autonomous `default_profile` selected the profile.
|
||||||
|
DefaultProfile,
|
||||||
|
/// A caller explicitly requested the profile by `profile_id`.
|
||||||
|
Explicit,
|
||||||
|
/// A composite document selected the referenced standard document profile.
|
||||||
|
Composite,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validated standard document resolved to one profile while retaining global/profile provenance.
|
||||||
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
|
pub struct ResolvedConfigProfile {
|
||||||
|
file_id: crate::ConfigFileId,
|
||||||
|
path: std::path::PathBuf,
|
||||||
|
profile_id: String,
|
||||||
|
selection_source: ConfigProfileSelectionSource,
|
||||||
|
globals: serde_json::Map<String, serde_json::Value>,
|
||||||
|
profile: serde_json::Map<String, serde_json::Value>,
|
||||||
|
effective: serde_json::Map<String, serde_json::Value>,
|
||||||
|
origins: std::collections::BTreeMap<String, ConfigValueOrigin>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ResolvedConfigProfile {
|
||||||
|
/// Returns the logical document identifier from which this profile was resolved.
|
||||||
|
#[must_use]
|
||||||
|
pub fn file_id(&self) -> &crate::ConfigFileId {
|
||||||
|
return &self.file_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the physical path of the validated source document.
|
||||||
|
#[must_use]
|
||||||
|
pub fn path(&self) -> &std::path::Path {
|
||||||
|
return self.path.as_path();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the selected unique profile identifier.
|
||||||
|
#[must_use]
|
||||||
|
pub fn profile_id(&self) -> &str {
|
||||||
|
return self.profile_id.as_str();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns whether selection came from `default_profile` or an explicit caller request.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn selection_source(&self) -> ConfigProfileSelectionSource {
|
||||||
|
return self.selection_source;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns document-global values, excluding the reserved `default_profile` and `profiles` keys.
|
||||||
|
#[must_use]
|
||||||
|
pub fn globals(&self) -> &serde_json::Map<String, serde_json::Value> {
|
||||||
|
return &self.globals;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the selected profile object including its `profile_id`.
|
||||||
|
#[must_use]
|
||||||
|
pub fn profile(&self) -> &serde_json::Map<String, serde_json::Value> {
|
||||||
|
return &self.profile;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns a deterministic top-level effective view in which selected profile keys override same-named global keys.
|
||||||
|
#[must_use]
|
||||||
|
pub fn effective(&self) -> &serde_json::Map<String, serde_json::Value> {
|
||||||
|
return &self.effective;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the top-level provenance for an effective key.
|
||||||
|
#[must_use]
|
||||||
|
pub fn origin(&self, key: &str) -> std::option::Option<ConfigValueOrigin> {
|
||||||
|
return self.origins.get(key).copied();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolves environment placeholders in the effective view and returns only the real runtime map.
|
||||||
|
///
|
||||||
|
/// Use [`Self::resolve_effective_environment_detailed`] when safe value, sensitivity and environment provenance are required. Global/Profile provenance on
|
||||||
|
/// this source profile remains unchanged in both cases.
|
||||||
|
pub fn resolve_effective_environment(&self, environment: &crate::ConfigEnvironment) -> ksp_core_lib::Result<serde_json::Map<String, serde_json::Value>> {
|
||||||
|
let resolved = self.resolve_effective_environment_detailed(environment);
|
||||||
|
let resolved = match resolved {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
return match resolved.value() {
|
||||||
|
serde_json::Value::Object(value) => std::result::Result::Ok(value.clone()),
|
||||||
|
_ => std::result::Result::Err(ksp_core_lib::Error::new(
|
||||||
|
crate::ERROR_CODE_DOCUMENT_SEMANTIC_INVALID,
|
||||||
|
"resolved Config profile effective view changed JSON shape",
|
||||||
|
)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolves environment placeholders while preserving real/safe JSON trees, strongest sensitivity and JSON-Pointer environment provenance.
|
||||||
|
///
|
||||||
|
/// Top-level Global/Profile provenance remains available through [`Self::origin`]; the returned value adds literal/process/`.env`/fallback provenance for
|
||||||
|
/// the environment-resolution stage.
|
||||||
|
pub fn resolve_effective_environment_detailed(&self, environment: &crate::ConfigEnvironment) -> ksp_core_lib::Result<crate::ResolvedConfigJson> {
|
||||||
|
return environment.resolve_json_detailed(&serde_json::Value::Object(self.effective.clone()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl crate::ConfigDocumentEngine {
|
||||||
|
/// Loads, validates and resolves one standard Config document to its default or explicitly requested profile.
|
||||||
|
///
|
||||||
|
/// Passing `None` selects the autonomous `default_profile` declared by the document. Passing `Some(profile_id)` selects that profile explicitly.
|
||||||
|
/// Environment interpolation is intentionally not applied implicitly by profile selection. Call `ResolvedConfigProfile::resolve_effective_environment` for
|
||||||
|
/// a real runtime map or `ResolvedConfigProfile::resolve_effective_environment_detailed` when safe value, sensitivity and provenance are also required.
|
||||||
|
pub fn load_resolved_profile(
|
||||||
|
&self,
|
||||||
|
file_id: &crate::ConfigFileId,
|
||||||
|
requested_profile: std::option::Option<&str>,
|
||||||
|
) -> ksp_core_lib::Result<ResolvedConfigProfile> {
|
||||||
|
let document = self.load_validated_document(file_id);
|
||||||
|
let document = match document {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let source = match requested_profile {
|
||||||
|
std::option::Option::Some(_) => ConfigProfileSelectionSource::Explicit,
|
||||||
|
std::option::Option::None => ConfigProfileSelectionSource::DefaultProfile,
|
||||||
|
};
|
||||||
|
return resolve_document_profile(&document, requested_profile, source);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn load_resolved_profile_with_source(
|
||||||
|
engine: &crate::ConfigDocumentEngine,
|
||||||
|
file_id: &crate::ConfigFileId,
|
||||||
|
requested_profile: std::option::Option<&str>,
|
||||||
|
explicit_source: ConfigProfileSelectionSource,
|
||||||
|
) -> ksp_core_lib::Result<ResolvedConfigProfile> {
|
||||||
|
let document = engine.load_validated_document(file_id);
|
||||||
|
let document = match document {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let source = match requested_profile {
|
||||||
|
std::option::Option::Some(_) => explicit_source,
|
||||||
|
std::option::Option::None => ConfigProfileSelectionSource::DefaultProfile,
|
||||||
|
};
|
||||||
|
return resolve_document_profile(&document, requested_profile, source);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn validate_document_profile_contract(document: &crate::ConfigJsonDocument) -> ksp_core_lib::Result<()> {
|
||||||
|
let root = match document.value().as_object() {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => return std::result::Result::Ok(()),
|
||||||
|
};
|
||||||
|
let default_profile = root.get("default_profile");
|
||||||
|
let profiles = root.get("profiles");
|
||||||
|
if default_profile.is_none() && profiles.is_none() {
|
||||||
|
return std::result::Result::Ok(());
|
||||||
|
}
|
||||||
|
let default_profile = match default_profile.and_then(serde_json::Value::as_str) {
|
||||||
|
std::option::Option::Some(value) if !value.trim().is_empty() => value,
|
||||||
|
_ => return std::result::Result::Err(profile_semantic_error(document, "default_profile must identify a non-empty profile_id")),
|
||||||
|
};
|
||||||
|
let profiles = match profiles.and_then(serde_json::Value::as_array) {
|
||||||
|
std::option::Option::Some(value) if !value.is_empty() => value,
|
||||||
|
_ => return std::result::Result::Err(profile_semantic_error(document, "profiles must contain at least one profile object")),
|
||||||
|
};
|
||||||
|
let mut ids = std::collections::BTreeSet::<String>::new();
|
||||||
|
let mut default_found = false;
|
||||||
|
for (index, profile) in profiles.iter().enumerate() {
|
||||||
|
let object = match profile.as_object() {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => {
|
||||||
|
return std::result::Result::Err(
|
||||||
|
profile_semantic_error(document, "profile entry must be an object").with_context("profile_index", index.to_string()),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let profile_id = match object.get("profile_id").and_then(serde_json::Value::as_str) {
|
||||||
|
std::option::Option::Some(value) if !value.trim().is_empty() => value,
|
||||||
|
_ => {
|
||||||
|
return std::result::Result::Err(
|
||||||
|
profile_semantic_error(document, "profile entry must declare a non-empty profile_id").with_context("profile_index", index.to_string()),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
if ids.contains(profile_id) {
|
||||||
|
return std::result::Result::Err(
|
||||||
|
profile_semantic_error(document, "profile_id values must be unique")
|
||||||
|
.with_context("profile_index", index.to_string())
|
||||||
|
.with_context("profile_id", profile_id),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
ids.insert(profile_id.to_owned());
|
||||||
|
if profile_id == default_profile {
|
||||||
|
default_found = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !default_found {
|
||||||
|
return std::result::Result::Err(
|
||||||
|
profile_semantic_error(document, "default_profile must reference an existing profile_id").with_context("default_profile", default_profile),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_document_profile(
|
||||||
|
document: &crate::ConfigJsonDocument,
|
||||||
|
requested_profile: std::option::Option<&str>,
|
||||||
|
explicit_source: ConfigProfileSelectionSource,
|
||||||
|
) -> ksp_core_lib::Result<ResolvedConfigProfile> {
|
||||||
|
let root = match document.value().as_object() {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => return std::result::Result::Err(profile_semantic_error(document, "profile resolution requires an object document")),
|
||||||
|
};
|
||||||
|
let default_profile = match root.get("default_profile").and_then(serde_json::Value::as_str) {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => return std::result::Result::Err(profile_semantic_error(document, "profile resolution requires default_profile")),
|
||||||
|
};
|
||||||
|
let (selected_profile, selection_source) = match requested_profile {
|
||||||
|
std::option::Option::Some(value) => (value, explicit_source),
|
||||||
|
std::option::Option::None => (default_profile, ConfigProfileSelectionSource::DefaultProfile),
|
||||||
|
};
|
||||||
|
let profiles = match root.get("profiles").and_then(serde_json::Value::as_array) {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => return std::result::Result::Err(profile_semantic_error(document, "profile resolution requires profiles")),
|
||||||
|
};
|
||||||
|
let mut selected: std::option::Option<&serde_json::Map<String, serde_json::Value>> = std::option::Option::None;
|
||||||
|
for profile in profiles {
|
||||||
|
let object = match profile.as_object() {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => continue,
|
||||||
|
};
|
||||||
|
if object.get("profile_id").and_then(serde_json::Value::as_str) == std::option::Option::Some(selected_profile) {
|
||||||
|
selected = std::option::Option::Some(object);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let selected = match selected {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => {
|
||||||
|
return std::result::Result::Err(
|
||||||
|
ksp_core_lib::Error::new(crate::ERROR_CODE_PROFILE_NOT_FOUND, "requested Config profile does not exist")
|
||||||
|
.with_context("file_id", document.file_id().as_str())
|
||||||
|
.with_context("path", document.path().to_string_lossy().into_owned())
|
||||||
|
.with_context("profile_id", selected_profile),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let mut globals = serde_json::Map::<String, serde_json::Value>::new();
|
||||||
|
let mut effective = serde_json::Map::<String, serde_json::Value>::new();
|
||||||
|
let mut origins = std::collections::BTreeMap::<String, ConfigValueOrigin>::new();
|
||||||
|
for (key, value) in root {
|
||||||
|
if key != "default_profile" && key != "profiles" {
|
||||||
|
globals.insert(key.clone(), value.clone());
|
||||||
|
effective.insert(key.clone(), value.clone());
|
||||||
|
origins.insert(key.clone(), ConfigValueOrigin::Global);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let profile = (*selected).clone();
|
||||||
|
for (key, value) in &profile {
|
||||||
|
effective.insert(key.clone(), value.clone());
|
||||||
|
origins.insert(key.clone(), ConfigValueOrigin::Profile);
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(ResolvedConfigProfile {
|
||||||
|
file_id: document.file_id().clone(),
|
||||||
|
path: document.path().to_path_buf(),
|
||||||
|
profile_id: selected_profile.to_owned(),
|
||||||
|
selection_source,
|
||||||
|
globals,
|
||||||
|
profile,
|
||||||
|
effective,
|
||||||
|
origins,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn profile_semantic_error(document: &crate::ConfigJsonDocument, reason: &'static str) -> ksp_core_lib::Error {
|
||||||
|
return ksp_core_lib::Error::new(crate::ERROR_CODE_DOCUMENT_SEMANTIC_INVALID, "Config document violates KSP profile invariants")
|
||||||
|
.with_context("file_id", document.file_id().as_str())
|
||||||
|
.with_context("path", document.path().to_string_lossy().into_owned())
|
||||||
|
.with_context("reason", reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "../unit_tests/profile.rs"]
|
||||||
|
mod tests;
|
||||||
408
crates/ksp-config-lib/src/registry.rs
Normal file
408
crates/ksp-config-lib/src/registry.rs
Normal file
@@ -0,0 +1,408 @@
|
|||||||
|
// file: crates/ksp-config-lib/src/registry.rs
|
||||||
|
// version: 3
|
||||||
|
|
||||||
|
/// Bootstrap argument used to replace a known Config filename mapping.
|
||||||
|
pub const ARG_FILE_MAP: &str = "--filemap";
|
||||||
|
/// Logical file identifier for the standard Logging configuration document.
|
||||||
|
pub const FILE_ID_STD_LOGGING: &str = "cfg.std.logging";
|
||||||
|
/// Logical file identifier for the standard Logging JSON Schema document.
|
||||||
|
pub const FILE_ID_SCHEMA_STD_LOGGING: &str = "schema.std.logging";
|
||||||
|
/// Logical file identifier for the generic composite JSON Schema document.
|
||||||
|
pub const FILE_ID_SCHEMA_COMPOSITE: &str = "schema.composite";
|
||||||
|
/// Default physical filename for the standard Logging configuration document.
|
||||||
|
pub const DEFAULT_STD_LOGGING_FILENAME: &str = "std.logging.json";
|
||||||
|
/// Default physical filename for the standard Logging JSON Schema document.
|
||||||
|
pub const DEFAULT_STD_LOGGING_SCHEMA_FILENAME: &str = "std.logging.schema.json";
|
||||||
|
/// Default physical filename for the generic composite JSON Schema document.
|
||||||
|
pub const DEFAULT_COMPOSITE_SCHEMA_FILENAME: &str = "composite.schema.json";
|
||||||
|
|
||||||
|
/// Stable logical identifier for a Config-managed file.
|
||||||
|
#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
|
||||||
|
pub struct ConfigFileId(String);
|
||||||
|
|
||||||
|
impl ConfigFileId {
|
||||||
|
/// Creates and validates a logical Config file identifier.
|
||||||
|
pub fn new(value: impl std::convert::Into<String>) -> ksp_core_lib::Result<Self> {
|
||||||
|
let value = value.into();
|
||||||
|
let validated = validate_file_id(value.as_str());
|
||||||
|
return match validated {
|
||||||
|
std::result::Result::Ok(()) => std::result::Result::Ok(Self(value)),
|
||||||
|
std::result::Result::Err(error) => std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the logical identifier as text.
|
||||||
|
#[must_use]
|
||||||
|
pub fn as_str(&self) -> &str {
|
||||||
|
return self.0.as_str();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Physical root category used to resolve a Config-managed file.
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub enum ConfigFileKind {
|
||||||
|
/// Runtime configuration document resolved below `cfgpath`.
|
||||||
|
Config,
|
||||||
|
/// JSON Schema document resolved below `schemapath`.
|
||||||
|
Schema,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Logical descriptor associating a stable file identifier with its physical filename, root category and optional validation schema.
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub struct ConfigFileDescriptor {
|
||||||
|
file_id: ConfigFileId,
|
||||||
|
kind: ConfigFileKind,
|
||||||
|
filename: std::path::PathBuf,
|
||||||
|
schema_file_id: std::option::Option<ConfigFileId>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ConfigFileDescriptor {
|
||||||
|
/// Returns the stable logical identifier.
|
||||||
|
#[must_use]
|
||||||
|
pub fn file_id(&self) -> &ConfigFileId {
|
||||||
|
return &self.file_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the root category used when resolving the file.
|
||||||
|
#[must_use]
|
||||||
|
pub fn kind(&self) -> ConfigFileKind {
|
||||||
|
return self.kind;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the relative physical filename currently mapped to the identifier.
|
||||||
|
#[must_use]
|
||||||
|
pub fn filename(&self) -> &std::path::Path {
|
||||||
|
return self.filename.as_path();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the logical schema identifier associated with this Config document when one is declared.
|
||||||
|
#[must_use]
|
||||||
|
pub fn schema_file_id(&self) -> std::option::Option<&ConfigFileId> {
|
||||||
|
return self.schema_file_id.as_ref();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn new(
|
||||||
|
file_id: &'static str,
|
||||||
|
kind: ConfigFileKind,
|
||||||
|
filename: &'static str,
|
||||||
|
schema_file_id: std::option::Option<&'static str>,
|
||||||
|
) -> ksp_core_lib::Result<Self> {
|
||||||
|
let file_id = ConfigFileId::new(file_id);
|
||||||
|
let file_id = match file_id {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let kind_validation = validate_kind_prefix(&file_id, kind);
|
||||||
|
match kind_validation {
|
||||||
|
std::result::Result::Ok(()) => {},
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
}
|
||||||
|
let filename = validate_relative_filename(&file_id, std::path::PathBuf::from(filename));
|
||||||
|
let filename = match filename {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let schema_file_id = parse_schema_file_id(&file_id, kind, schema_file_id);
|
||||||
|
return match schema_file_id {
|
||||||
|
std::result::Result::Ok(value) => std::result::Result::Ok(Self { file_id, kind, filename, schema_file_id: value }),
|
||||||
|
std::result::Result::Err(error) => std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Registry of KSP-known logical Config files and their replaceable physical filenames.
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub struct ConfigFileRegistry {
|
||||||
|
descriptors: std::collections::BTreeMap<ConfigFileId, ConfigFileDescriptor>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ConfigFileRegistry {
|
||||||
|
/// Creates the registry containing the KSP default file mappings known in the current release.
|
||||||
|
pub fn defaults() -> ksp_core_lib::Result<Self> {
|
||||||
|
let logging = ConfigFileDescriptor::new(
|
||||||
|
FILE_ID_STD_LOGGING,
|
||||||
|
ConfigFileKind::Config,
|
||||||
|
DEFAULT_STD_LOGGING_FILENAME,
|
||||||
|
std::option::Option::Some(FILE_ID_SCHEMA_STD_LOGGING),
|
||||||
|
);
|
||||||
|
let logging = match logging {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let logging_schema =
|
||||||
|
ConfigFileDescriptor::new(FILE_ID_SCHEMA_STD_LOGGING, ConfigFileKind::Schema, DEFAULT_STD_LOGGING_SCHEMA_FILENAME, std::option::Option::None);
|
||||||
|
let logging_schema = match logging_schema {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let composite_schema =
|
||||||
|
ConfigFileDescriptor::new(FILE_ID_SCHEMA_COMPOSITE, ConfigFileKind::Schema, DEFAULT_COMPOSITE_SCHEMA_FILENAME, std::option::Option::None);
|
||||||
|
let composite_schema = match composite_schema {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
return build_registry([logging, logging_schema, composite_schema]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates the default registry and applies repeatable `--filemap=<file_id>=<filename>` overrides from raw process arguments.
|
||||||
|
///
|
||||||
|
/// Unrelated arguments are ignored. A repeated mapping for the same known `file_id` is accepted and the last mapping wins.
|
||||||
|
pub fn from_args(args: &[std::ffi::OsString]) -> ksp_core_lib::Result<Self> {
|
||||||
|
let registry = Self::defaults();
|
||||||
|
let mut registry = match registry {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let mut index: usize = 0;
|
||||||
|
while index < args.len() {
|
||||||
|
let application = apply_file_map_argument(&mut registry, &args[index]);
|
||||||
|
match application {
|
||||||
|
std::result::Result::Ok(()) => {},
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
}
|
||||||
|
index += 1;
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(registry);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the descriptor associated with a known logical file identifier.
|
||||||
|
pub fn descriptor(&self, file_id: &ConfigFileId) -> ksp_core_lib::Result<&ConfigFileDescriptor> {
|
||||||
|
return match self.descriptors.get(file_id) {
|
||||||
|
std::option::Option::Some(descriptor) => std::result::Result::Ok(descriptor),
|
||||||
|
std::option::Option::None => std::result::Result::Err(unknown_file_id_error(file_id.as_str())),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolves a known logical file identifier below the bootstrap root selected by its descriptor kind.
|
||||||
|
pub fn resolve_path(&self, bootstrap: &crate::ConfigBootstrapOptions, file_id: &ConfigFileId) -> ksp_core_lib::Result<std::path::PathBuf> {
|
||||||
|
let descriptor = self.descriptor(file_id);
|
||||||
|
let descriptor = match descriptor {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let root = match descriptor.kind() {
|
||||||
|
ConfigFileKind::Config => bootstrap.cfg_path(),
|
||||||
|
ConfigFileKind::Schema => bootstrap.schema_path(),
|
||||||
|
};
|
||||||
|
return std::result::Result::Ok(root.join(descriptor.filename()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Replaces the physical filename of one known logical identifier while preserving its kind, schema association and logical identity.
|
||||||
|
pub fn with_filename_override(mut self, file_id: &ConfigFileId, filename: impl std::convert::Into<std::path::PathBuf>) -> ksp_core_lib::Result<Self> {
|
||||||
|
let update = self.set_filename_override(file_id, filename.into());
|
||||||
|
return match update {
|
||||||
|
std::result::Result::Ok(()) => std::result::Result::Ok(self),
|
||||||
|
std::result::Result::Err(error) => std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_filename_override(&mut self, file_id: &ConfigFileId, filename: std::path::PathBuf) -> ksp_core_lib::Result<()> {
|
||||||
|
let descriptor = self.descriptors.get(file_id);
|
||||||
|
let descriptor = match descriptor {
|
||||||
|
std::option::Option::Some(value) => value.clone(),
|
||||||
|
std::option::Option::None => return std::result::Result::Err(unknown_file_id_error(file_id.as_str())),
|
||||||
|
};
|
||||||
|
let filename = validate_relative_filename(file_id, filename);
|
||||||
|
let filename = match filename {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let updated = ConfigFileDescriptor {
|
||||||
|
file_id: descriptor.file_id.clone(),
|
||||||
|
kind: descriptor.kind,
|
||||||
|
filename,
|
||||||
|
schema_file_id: descriptor.schema_file_id.clone(),
|
||||||
|
};
|
||||||
|
self.descriptors.insert(file_id.clone(), updated);
|
||||||
|
return std::result::Result::Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn build_registry<const N: usize>(descriptors: [ConfigFileDescriptor; N]) -> ksp_core_lib::Result<ConfigFileRegistry> {
|
||||||
|
let mut registry = ConfigFileRegistry { descriptors: std::collections::BTreeMap::new() };
|
||||||
|
for descriptor in descriptors {
|
||||||
|
let file_id = descriptor.file_id.clone();
|
||||||
|
let duplicate_id = file_id.clone();
|
||||||
|
let previous = registry.descriptors.insert(file_id, descriptor);
|
||||||
|
if previous.is_some() {
|
||||||
|
return std::result::Result::Err(duplicate_file_id_error(duplicate_id.as_str()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let associations = validate_schema_associations(®istry);
|
||||||
|
return match associations {
|
||||||
|
std::result::Result::Ok(()) => std::result::Result::Ok(registry),
|
||||||
|
std::result::Result::Err(error) => std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_schema_file_id(
|
||||||
|
file_id: &ConfigFileId,
|
||||||
|
kind: ConfigFileKind,
|
||||||
|
schema_file_id: std::option::Option<&'static str>,
|
||||||
|
) -> ksp_core_lib::Result<std::option::Option<ConfigFileId>> {
|
||||||
|
return match (kind, schema_file_id) {
|
||||||
|
(ConfigFileKind::Schema, std::option::Option::Some(_)) => {
|
||||||
|
std::result::Result::Err(invalid_file_mapping_with_id_error(file_id.as_str(), "schema descriptors cannot declare another validation schema"))
|
||||||
|
},
|
||||||
|
(ConfigFileKind::Schema, std::option::Option::None) | (ConfigFileKind::Config, std::option::Option::None) => {
|
||||||
|
std::result::Result::Ok(std::option::Option::None)
|
||||||
|
},
|
||||||
|
(ConfigFileKind::Config, std::option::Option::Some(value)) => {
|
||||||
|
let schema_id = ConfigFileId::new(value);
|
||||||
|
match schema_id {
|
||||||
|
std::result::Result::Ok(schema_id) => {
|
||||||
|
if schema_id.as_str().starts_with("schema.") {
|
||||||
|
std::result::Result::Ok(std::option::Option::Some(schema_id))
|
||||||
|
} else {
|
||||||
|
std::result::Result::Err(invalid_file_mapping_with_id_error(
|
||||||
|
file_id.as_str(),
|
||||||
|
"validation schema file_id must use the schema namespace",
|
||||||
|
))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
std::result::Result::Err(error) => std::result::Result::Err(error),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_schema_associations(registry: &ConfigFileRegistry) -> ksp_core_lib::Result<()> {
|
||||||
|
for descriptor in registry.descriptors.values() {
|
||||||
|
let schema_file_id = match descriptor.schema_file_id() {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => continue,
|
||||||
|
};
|
||||||
|
let schema = registry.descriptors.get(schema_file_id);
|
||||||
|
let schema = match schema {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => {
|
||||||
|
return std::result::Result::Err(invalid_file_mapping_with_id_error(
|
||||||
|
descriptor.file_id().as_str(),
|
||||||
|
"validation schema file_id is not registered",
|
||||||
|
));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
if schema.kind() != ConfigFileKind::Schema {
|
||||||
|
return std::result::Result::Err(invalid_file_mapping_with_id_error(
|
||||||
|
descriptor.file_id().as_str(),
|
||||||
|
"validation schema descriptor must have schema kind",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply_file_map_argument(registry: &mut ConfigFileRegistry, argument: &std::ffi::OsStr) -> ksp_core_lib::Result<()> {
|
||||||
|
let text = match argument.to_str() {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => return std::result::Result::Ok(()),
|
||||||
|
};
|
||||||
|
if text == ARG_FILE_MAP {
|
||||||
|
return std::result::Result::Err(invalid_file_mapping_error("--filemap requires the inline form --filemap=<file_id>=<filename>"));
|
||||||
|
}
|
||||||
|
let prefix = "--filemap=";
|
||||||
|
let mapping = match text.strip_prefix(prefix) {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => return std::result::Result::Ok(()),
|
||||||
|
};
|
||||||
|
let separator = mapping.find('=');
|
||||||
|
let separator = match separator {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => {
|
||||||
|
return std::result::Result::Err(invalid_file_mapping_error("file mapping must contain a file_id and filename separated by '='"));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let file_id_text = &mapping[..separator];
|
||||||
|
let filename_text = &mapping[separator + 1..];
|
||||||
|
if file_id_text.is_empty() || filename_text.is_empty() {
|
||||||
|
return std::result::Result::Err(invalid_file_mapping_error("file mapping requires non-empty file_id and filename values"));
|
||||||
|
}
|
||||||
|
let file_id = ConfigFileId::new(file_id_text);
|
||||||
|
let file_id = match file_id {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
return registry.set_filename_override(&file_id, std::path::PathBuf::from(filename_text));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_file_id(value: &str) -> ksp_core_lib::Result<()> {
|
||||||
|
if value.is_empty() || value.starts_with('.') || value.ends_with('.') || value.contains("..") {
|
||||||
|
return std::result::Result::Err(invalid_file_id_error(value));
|
||||||
|
}
|
||||||
|
let mut valid = true;
|
||||||
|
for byte in value.bytes() {
|
||||||
|
let allowed = byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'.' || byte == b'_' || byte == b'-';
|
||||||
|
if !allowed {
|
||||||
|
valid = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !valid {
|
||||||
|
return std::result::Result::Err(invalid_file_id_error(value));
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_kind_prefix(file_id: &ConfigFileId, kind: ConfigFileKind) -> ksp_core_lib::Result<()> {
|
||||||
|
let valid = match kind {
|
||||||
|
ConfigFileKind::Config => file_id.as_str().starts_with("cfg."),
|
||||||
|
ConfigFileKind::Schema => file_id.as_str().starts_with("schema."),
|
||||||
|
};
|
||||||
|
if !valid {
|
||||||
|
return std::result::Result::Err(invalid_file_mapping_with_id_error(file_id.as_str(), "file_id prefix does not match descriptor kind"));
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_relative_filename(file_id: &ConfigFileId, filename: std::path::PathBuf) -> ksp_core_lib::Result<std::path::PathBuf> {
|
||||||
|
if filename.as_os_str().is_empty() || filename.is_absolute() {
|
||||||
|
return std::result::Result::Err(invalid_filename_error(file_id.as_str(), &filename));
|
||||||
|
}
|
||||||
|
let mut has_normal_component = false;
|
||||||
|
for component in filename.components() {
|
||||||
|
match component {
|
||||||
|
std::path::Component::Normal(_) => has_normal_component = true,
|
||||||
|
std::path::Component::CurDir | std::path::Component::ParentDir | std::path::Component::RootDir | std::path::Component::Prefix(_) => {
|
||||||
|
return std::result::Result::Err(invalid_filename_error(file_id.as_str(), &filename));
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !has_normal_component {
|
||||||
|
return std::result::Result::Err(invalid_filename_error(file_id.as_str(), &filename));
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(filename);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn invalid_file_id_error(file_id: &str) -> ksp_core_lib::Error {
|
||||||
|
return ksp_core_lib::Error::new(crate::ERROR_CODE_FILE_ID_INVALID, "Config file_id is invalid")
|
||||||
|
.with_context("file_id", file_id)
|
||||||
|
.with_context("reason", "expected lowercase ASCII segments separated by single dots");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn unknown_file_id_error(file_id: &str) -> ksp_core_lib::Error {
|
||||||
|
return ksp_core_lib::Error::new(crate::ERROR_CODE_FILE_ID_UNKNOWN, "Config file_id is not registered").with_context("file_id", file_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn duplicate_file_id_error(file_id: &str) -> ksp_core_lib::Error {
|
||||||
|
return ksp_core_lib::Error::new(crate::ERROR_CODE_FILE_ID_DUPLICATE, "Config file_id is registered more than once").with_context("file_id", file_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn invalid_file_mapping_error(reason: &'static str) -> ksp_core_lib::Error {
|
||||||
|
return ksp_core_lib::Error::new(crate::ERROR_CODE_FILE_MAPPING_INVALID, "Config file mapping is invalid").with_context("reason", reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn invalid_file_mapping_with_id_error(file_id: &str, reason: &'static str) -> ksp_core_lib::Error {
|
||||||
|
return ksp_core_lib::Error::new(crate::ERROR_CODE_FILE_MAPPING_INVALID, "Config file mapping is invalid")
|
||||||
|
.with_context("file_id", file_id)
|
||||||
|
.with_context("reason", reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn invalid_filename_error(file_id: &str, filename: &std::path::Path) -> ksp_core_lib::Error {
|
||||||
|
return ksp_core_lib::Error::new(crate::ERROR_CODE_FILE_MAPPING_INVALID, "Config mapped filename is invalid")
|
||||||
|
.with_context("file_id", file_id)
|
||||||
|
.with_context("filename", filename.to_string_lossy().into_owned())
|
||||||
|
.with_context("reason", "filename must stay relative to its Config-owned root without traversal components");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "../unit_tests/registry.rs"]
|
||||||
|
mod tests;
|
||||||
209
crates/ksp-config-lib/src/sensitivity.rs
Normal file
209
crates/ksp-config-lib/src/sensitivity.rs
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
// file: crates/ksp-config-lib/src/sensitivity.rs
|
||||||
|
// version: 1
|
||||||
|
|
||||||
|
/// Replacement used for secret environment fragments in safe diagnostic representations.
|
||||||
|
pub const REDACTED_CONFIG_VALUE: &str = "********";
|
||||||
|
|
||||||
|
/// Sensitivity assigned to one Config value after environment resolution.
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
|
||||||
|
pub enum ConfigSensitivity {
|
||||||
|
/// Value may be exposed by a public projection when the consumer contract allows it.
|
||||||
|
Public,
|
||||||
|
/// Value is available to the runtime but is not generically public.
|
||||||
|
Internal,
|
||||||
|
/// Value must remain available to legitimate runtime/management consumers while being redacted from ordinary diagnostics.
|
||||||
|
Secret,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ConfigSensitivity {
|
||||||
|
/// Classifies one supported KSP/KSPB environment variable by its namespace.
|
||||||
|
pub fn from_variable_name(variable_name: &str) -> ksp_core_lib::Result<Self> {
|
||||||
|
let validation = crate::environment::validate_supported_variable_name(variable_name);
|
||||||
|
if let std::result::Result::Err(error) = validation {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
if variable_name.starts_with("KSP_SECRET_") || variable_name.starts_with("KSPB_SECRET_") {
|
||||||
|
return std::result::Result::Ok(Self::Secret);
|
||||||
|
}
|
||||||
|
if variable_name.starts_with("KSP_PUBLIC_") || variable_name.starts_with("KSPB_PUBLIC_") {
|
||||||
|
return std::result::Result::Ok(Self::Public);
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(Self::Internal);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the strongest of two sensitivities using `Secret > Internal > Public`.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn strongest(self, other: Self) -> Self {
|
||||||
|
if self as u8 >= other as u8 {
|
||||||
|
return self;
|
||||||
|
}
|
||||||
|
return other;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns whether this sensitivity requires ordinary diagnostic redaction.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn is_secret(self) -> bool {
|
||||||
|
return matches!(self, Self::Secret);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Provenance segment participating in one resolved Config value.
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub enum ConfigValueProvenance {
|
||||||
|
/// Literal text/value came directly from the Config document.
|
||||||
|
DocumentLiteral,
|
||||||
|
/// Environment substitution came from the inherited process environment.
|
||||||
|
EnvironmentProcess {
|
||||||
|
/// Referenced variable name; never its value.
|
||||||
|
variable_name: String,
|
||||||
|
},
|
||||||
|
/// Environment substitution came from the local `.env` file.
|
||||||
|
EnvironmentDotEnv {
|
||||||
|
/// Referenced variable name; never its value.
|
||||||
|
variable_name: String,
|
||||||
|
},
|
||||||
|
/// Environment substitution used the placeholder/API fallback.
|
||||||
|
EnvironmentFallback {
|
||||||
|
/// Referenced variable name; never its value.
|
||||||
|
variable_name: String,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ConfigValueProvenance {
|
||||||
|
/// Returns the referenced variable name for environment provenance.
|
||||||
|
#[must_use]
|
||||||
|
pub fn variable_name(&self) -> std::option::Option<&str> {
|
||||||
|
return match self {
|
||||||
|
Self::DocumentLiteral => std::option::Option::None,
|
||||||
|
Self::EnvironmentProcess { variable_name } | Self::EnvironmentDotEnv { variable_name } | Self::EnvironmentFallback { variable_name } => {
|
||||||
|
std::option::Option::Some(variable_name.as_str())
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the environment source represented by this provenance segment when applicable.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn environment_source(&self) -> std::option::Option<crate::ConfigEnvironmentSource> {
|
||||||
|
return match self {
|
||||||
|
Self::DocumentLiteral => std::option::Option::None,
|
||||||
|
Self::EnvironmentProcess { .. } => std::option::Option::Some(crate::ConfigEnvironmentSource::Process),
|
||||||
|
Self::EnvironmentDotEnv { .. } => std::option::Option::Some(crate::ConfigEnvironmentSource::DotEnv),
|
||||||
|
Self::EnvironmentFallback { .. } => std::option::Option::Some(crate::ConfigEnvironmentSource::Fallback),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One resolved Config string with real/safe representations, sensitivity and ordered provenance.
|
||||||
|
#[derive(Clone, Eq, PartialEq)]
|
||||||
|
pub struct ResolvedConfigText {
|
||||||
|
value: String,
|
||||||
|
safe_value: String,
|
||||||
|
sensitivity: ConfigSensitivity,
|
||||||
|
provenance: std::vec::Vec<ConfigValueProvenance>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ResolvedConfigText {
|
||||||
|
pub(crate) fn new(value: String, safe_value: String, sensitivity: ConfigSensitivity, provenance: std::vec::Vec<ConfigValueProvenance>) -> Self {
|
||||||
|
return Self { value, safe_value, sensitivity, provenance };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the real runtime value.
|
||||||
|
#[must_use]
|
||||||
|
pub fn value(&self) -> &str {
|
||||||
|
return self.value.as_str();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the representation safe for ordinary diagnostics.
|
||||||
|
#[must_use]
|
||||||
|
pub fn safe_value(&self) -> &str {
|
||||||
|
return self.safe_value.as_str();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the strongest sensitivity contributed by referenced environment placeholders.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn sensitivity(&self) -> ConfigSensitivity {
|
||||||
|
return self.sensitivity;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns ordered provenance segments participating in the resolved string.
|
||||||
|
#[must_use]
|
||||||
|
pub fn provenance(&self) -> &[ConfigValueProvenance] {
|
||||||
|
return self.provenance.as_slice();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Debug for ResolvedConfigText {
|
||||||
|
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
return formatter
|
||||||
|
.debug_struct("ResolvedConfigText")
|
||||||
|
.field("safe_value", &self.safe_value)
|
||||||
|
.field("sensitivity", &self.sensitivity)
|
||||||
|
.field("provenance", &self.provenance)
|
||||||
|
.finish();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Recursively resolved JSON value preserving a real tree, a safe tree and provenance indexed by JSON Pointer.
|
||||||
|
#[derive(Clone, Eq, PartialEq)]
|
||||||
|
pub struct ResolvedConfigJson {
|
||||||
|
value: serde_json::Value,
|
||||||
|
safe_value: serde_json::Value,
|
||||||
|
sensitivity: ConfigSensitivity,
|
||||||
|
provenance: std::collections::BTreeMap<String, std::vec::Vec<ConfigValueProvenance>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ResolvedConfigJson {
|
||||||
|
pub(crate) fn new(
|
||||||
|
value: serde_json::Value,
|
||||||
|
safe_value: serde_json::Value,
|
||||||
|
sensitivity: ConfigSensitivity,
|
||||||
|
provenance: std::collections::BTreeMap<String, std::vec::Vec<ConfigValueProvenance>>,
|
||||||
|
) -> Self {
|
||||||
|
return Self { value, safe_value, sensitivity, provenance };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the real JSON tree intended for legitimate runtime consumers.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn value(&self) -> &serde_json::Value {
|
||||||
|
return &self.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the JSON tree safe for ordinary diagnostics.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn safe_value(&self) -> &serde_json::Value {
|
||||||
|
return &self.safe_value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the strongest sensitivity found anywhere in the resolved JSON tree.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn sensitivity(&self) -> ConfigSensitivity {
|
||||||
|
return self.sensitivity;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns provenance indexed by RFC 6901 JSON Pointer strings.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn provenance(&self) -> &std::collections::BTreeMap<String, std::vec::Vec<ConfigValueProvenance>> {
|
||||||
|
return &self.provenance;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns provenance for one JSON Pointer when the resolved value recorded that location.
|
||||||
|
#[must_use]
|
||||||
|
pub fn provenance_at(&self, json_pointer: &str) -> std::option::Option<&[ConfigValueProvenance]> {
|
||||||
|
return self.provenance.get(json_pointer).map(std::vec::Vec::as_slice);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Debug for ResolvedConfigJson {
|
||||||
|
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
return formatter
|
||||||
|
.debug_struct("ResolvedConfigJson")
|
||||||
|
.field("safe_value", &self.safe_value)
|
||||||
|
.field("sensitivity", &self.sensitivity)
|
||||||
|
.field("provenance", &self.provenance)
|
||||||
|
.finish();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "../unit_tests/sensitivity.rs"]
|
||||||
|
mod tests;
|
||||||
304
crates/ksp-config-lib/tests/ownership.rs
Normal file
304
crates/ksp-config-lib/tests/ownership.rs
Normal file
@@ -0,0 +1,304 @@
|
|||||||
|
// file: crates/ksp-config-lib/tests/ownership.rs
|
||||||
|
// version: 1
|
||||||
|
|
||||||
|
//! Workspace ownership audits for KSP application configuration boundaries.
|
||||||
|
|
||||||
|
fn workspace_root() -> std::path::PathBuf {
|
||||||
|
let manifest_directory = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"));
|
||||||
|
let root = manifest_directory.parent().and_then(std::path::Path::parent);
|
||||||
|
return match root {
|
||||||
|
std::option::Option::Some(value) => value.to_path_buf(),
|
||||||
|
std::option::Option::None => manifest_directory,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn collect_rust_files(directory: &std::path::Path, files: &mut std::vec::Vec<std::path::PathBuf>) {
|
||||||
|
let entries = std::fs::read_dir(directory);
|
||||||
|
let entries = match entries {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
for entry in entries {
|
||||||
|
let entry = match entry {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => continue,
|
||||||
|
};
|
||||||
|
let path = entry.path();
|
||||||
|
if path.is_dir() {
|
||||||
|
collect_rust_files(path.as_path(), files);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if path.extension().and_then(std::ffi::OsStr::to_str) == std::option::Option::Some("rs") {
|
||||||
|
files.push(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn collect_json_files(directory: &std::path::Path, files: &mut std::vec::Vec<std::path::PathBuf>) {
|
||||||
|
let entries = std::fs::read_dir(directory);
|
||||||
|
let entries = match entries {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
for entry in entries {
|
||||||
|
let entry = match entry {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => continue,
|
||||||
|
};
|
||||||
|
let path = entry.path();
|
||||||
|
if path.is_dir() {
|
||||||
|
collect_json_files(path.as_path(), files);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if path.extension().and_then(std::ffi::OsStr::to_str) == std::option::Option::Some("json") {
|
||||||
|
files.push(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn non_comment_source(source: &str) -> String {
|
||||||
|
let mut filtered = String::new();
|
||||||
|
for line in source.lines() {
|
||||||
|
let trimmed = line.trim_start();
|
||||||
|
if trimmed.starts_with("//") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
filtered.push_str(line);
|
||||||
|
filtered.push('\n');
|
||||||
|
}
|
||||||
|
return filtered;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn collect_environment_names(source: &str, names: &mut std::collections::BTreeSet<String>) {
|
||||||
|
let bytes = source.as_bytes();
|
||||||
|
let mut index: usize = 0;
|
||||||
|
while index < bytes.len() {
|
||||||
|
let prefix_length = if bytes[index..].starts_with(b"KSPB_") {
|
||||||
|
5
|
||||||
|
} else if bytes[index..].starts_with(b"KSP_") {
|
||||||
|
4
|
||||||
|
} else {
|
||||||
|
index += 1;
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let mut end = index + prefix_length;
|
||||||
|
while end < bytes.len() {
|
||||||
|
let byte = bytes[end];
|
||||||
|
if byte.is_ascii_uppercase() || byte.is_ascii_digit() || byte == b'_' {
|
||||||
|
end += 1;
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let candidate = std::str::from_utf8(&bytes[index..end]);
|
||||||
|
if let std::result::Result::Ok(candidate) = candidate
|
||||||
|
&& candidate.len() > prefix_length
|
||||||
|
&& !candidate.ends_with('_')
|
||||||
|
{
|
||||||
|
names.insert(candidate.to_owned());
|
||||||
|
}
|
||||||
|
index = end;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn dotenv_example_assignments(source: &str) -> std::collections::BTreeMap<String, usize> {
|
||||||
|
let mut assignments = std::collections::BTreeMap::<String, usize>::new();
|
||||||
|
for (line_index, line) in source.lines().enumerate() {
|
||||||
|
let mut candidate = line.trim();
|
||||||
|
if let std::option::Option::Some(commented) = candidate.strip_prefix('#') {
|
||||||
|
candidate = commented.trim_start();
|
||||||
|
}
|
||||||
|
let separator = candidate.find('=');
|
||||||
|
let separator = match separator {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => continue,
|
||||||
|
};
|
||||||
|
let name = candidate[..separator].trim();
|
||||||
|
if (name.starts_with("KSP_") || name.starts_with("KSPB_")) && !name.ends_with('_') {
|
||||||
|
assignments.insert(name.to_owned(), line_index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return assignments;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn has_preceding_explanatory_comment(source: &str, assignment_line_index: usize) -> bool {
|
||||||
|
let lines: std::vec::Vec<&str> = source.lines().collect();
|
||||||
|
if assignment_line_index == 0 || assignment_line_index > lines.len() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let mut index = assignment_line_index;
|
||||||
|
while index > 0 {
|
||||||
|
index -= 1;
|
||||||
|
let trimmed = lines[index].trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if !trimmed.starts_with('#') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let comment = trimmed.trim_start_matches('#').trim_start();
|
||||||
|
if comment.starts_with("file:") || comment.starts_with("version:") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if comment.contains('=') && (comment.starts_with("KSP_") || comment.starts_with("KSPB_")) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn foundational_dependency_direction_does_not_point_back_to_config() {
|
||||||
|
let root = workspace_root();
|
||||||
|
for crate_name in ["ksp-core-lib", "ksp-logging-lib"] {
|
||||||
|
let manifest_path = root.join("crates").join(crate_name).join("Cargo.toml");
|
||||||
|
let manifest = std::fs::read_to_string(manifest_path.as_path());
|
||||||
|
assert!(manifest.is_ok(), "unable to read {}", manifest_path.display());
|
||||||
|
if let std::result::Result::Ok(manifest) = manifest {
|
||||||
|
assert!(!manifest.contains("ksp-config-lib"), "{} must not depend on ksp-config-lib", manifest_path.display());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn workspace_crates_do_not_read_ksp_environment_directly() {
|
||||||
|
let root = workspace_root();
|
||||||
|
let crates_directory = root.join("crates");
|
||||||
|
let entries = std::fs::read_dir(crates_directory.as_path());
|
||||||
|
assert!(entries.is_ok(), "unable to inspect workspace crates at {}", crates_directory.display());
|
||||||
|
let entries = match entries {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
for entry in entries {
|
||||||
|
let entry = match entry {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => continue,
|
||||||
|
};
|
||||||
|
if !entry.path().is_dir() || entry.file_name() == std::ffi::OsStr::new("ksp-config-lib") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let mut rust_files = std::vec::Vec::new();
|
||||||
|
collect_rust_files(entry.path().as_path(), &mut rust_files);
|
||||||
|
for rust_file in rust_files {
|
||||||
|
let source = std::fs::read_to_string(rust_file.as_path());
|
||||||
|
assert!(source.is_ok(), "unable to read {}", rust_file.display());
|
||||||
|
let source = match source {
|
||||||
|
std::result::Result::Ok(value) => non_comment_source(value.as_str()),
|
||||||
|
std::result::Result::Err(_) => continue,
|
||||||
|
};
|
||||||
|
let forbidden = [
|
||||||
|
"std::env::var(\"KSP_",
|
||||||
|
"std::env::var(\"KSPB_",
|
||||||
|
"std::env::var_os(\"KSP_",
|
||||||
|
"std::env::var_os(\"KSPB_",
|
||||||
|
"env::var(\"KSP_",
|
||||||
|
"env::var(\"KSPB_",
|
||||||
|
"env::var_os(\"KSP_",
|
||||||
|
"env::var_os(\"KSPB_",
|
||||||
|
"std::env::vars()",
|
||||||
|
"std::env::vars_os()",
|
||||||
|
];
|
||||||
|
for token in forbidden {
|
||||||
|
assert!(!source.contains(token), "{} bypasses ksp-config-lib for KSP/KSPB environment access via {token}", rust_file.display());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn workspace_crates_do_not_hardcode_config_managed_physical_files() {
|
||||||
|
let root = workspace_root();
|
||||||
|
let crates_directory = root.join("crates");
|
||||||
|
let entries = std::fs::read_dir(crates_directory.as_path());
|
||||||
|
assert!(entries.is_ok(), "unable to inspect workspace crates at {}", crates_directory.display());
|
||||||
|
let entries = match entries {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
for entry in entries {
|
||||||
|
let entry = match entry {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => continue,
|
||||||
|
};
|
||||||
|
if !entry.path().is_dir() || entry.file_name() == std::ffi::OsStr::new("ksp-config-lib") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let mut rust_files = std::vec::Vec::new();
|
||||||
|
collect_rust_files(entry.path().as_path(), &mut rust_files);
|
||||||
|
for rust_file in rust_files {
|
||||||
|
let source = std::fs::read_to_string(rust_file.as_path());
|
||||||
|
assert!(source.is_ok(), "unable to read {}", rust_file.display());
|
||||||
|
let source = match source {
|
||||||
|
std::result::Result::Ok(value) => non_comment_source(value.as_str()),
|
||||||
|
std::result::Result::Err(_) => continue,
|
||||||
|
};
|
||||||
|
for token in ["\".env\"", "\"std.logging.json\"", "\"std.logging.schema.json\"", "\"composite.schema.json\""] {
|
||||||
|
assert!(!source.contains(token), "{} hardcodes Config-managed physical resource {token}; use ksp-config-lib contracts", rust_file.display());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dotenv_example_covers_runtime_environment_names_with_comments() {
|
||||||
|
let root = workspace_root();
|
||||||
|
let mut runtime_names = std::collections::BTreeSet::<String>::new();
|
||||||
|
let config_directory = root.join("config");
|
||||||
|
let mut json_files = std::vec::Vec::new();
|
||||||
|
collect_json_files(config_directory.as_path(), &mut json_files);
|
||||||
|
for json_file in json_files {
|
||||||
|
let source = std::fs::read_to_string(json_file.as_path());
|
||||||
|
assert!(source.is_ok(), "unable to read {}", json_file.display());
|
||||||
|
if let std::result::Result::Ok(source) = source {
|
||||||
|
collect_environment_names(source.as_str(), &mut runtime_names);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let crates_directory = root.join("crates");
|
||||||
|
let crate_entries = std::fs::read_dir(crates_directory.as_path());
|
||||||
|
assert!(crate_entries.is_ok(), "unable to inspect workspace crates at {}", crates_directory.display());
|
||||||
|
let crate_entries = match crate_entries {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
for entry in crate_entries {
|
||||||
|
let entry = match entry {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => continue,
|
||||||
|
};
|
||||||
|
let source_directory = entry.path().join("src");
|
||||||
|
if !source_directory.is_dir() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let mut rust_files = std::vec::Vec::new();
|
||||||
|
collect_rust_files(source_directory.as_path(), &mut rust_files);
|
||||||
|
for rust_file in rust_files {
|
||||||
|
let source = std::fs::read_to_string(rust_file.as_path());
|
||||||
|
assert!(source.is_ok(), "unable to read {}", rust_file.display());
|
||||||
|
if let std::result::Result::Ok(source) = source {
|
||||||
|
let source = non_comment_source(source.as_str());
|
||||||
|
collect_environment_names(source.as_str(), &mut runtime_names);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let example_path = root.join(ksp_config_lib::DEFAULT_DOTENV_EXAMPLE_PATH);
|
||||||
|
let example = std::fs::read_to_string(example_path.as_path());
|
||||||
|
assert!(example.is_ok(), "unable to read canonical environment inventory {}", example_path.display());
|
||||||
|
let example = match example {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
let assignments = dotenv_example_assignments(example.as_str());
|
||||||
|
assert!(!runtime_names.is_empty(), "runtime environment audit should discover at least one concrete KSP/KSPB variable");
|
||||||
|
for variable_name in runtime_names {
|
||||||
|
let assignment_line = assignments.get(variable_name.as_str());
|
||||||
|
assert!(assignment_line.is_some(), ".env.example is missing runtime variable {variable_name}");
|
||||||
|
if let std::option::Option::Some(line_index) = assignment_line {
|
||||||
|
assert!(
|
||||||
|
has_preceding_explanatory_comment(example.as_str(), *line_index),
|
||||||
|
".env.example variable {variable_name} must be preceded by an explanatory comment",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
209
crates/ksp-config-lib/tests/public_api.rs
Normal file
209
crates/ksp-config-lib/tests/public_api.rs
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
// file: crates/ksp-config-lib/tests/public_api.rs
|
||||||
|
// version: 10
|
||||||
|
|
||||||
|
//! Integration tests for the public `ksp-config-lib` bootstrap, registry, JSON/profile/composite, environment-resolution, sensitivity, Logging-adapter and
|
||||||
|
//! management contracts.
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bootstrap_contract_is_available_from_crate_root() {
|
||||||
|
let result = ksp_config_lib::ConfigBootstrapOptions::defaults();
|
||||||
|
assert!(result.is_ok(), "default bootstrap options should be available: {result:?}");
|
||||||
|
if let std::result::Result::Ok(options) = result {
|
||||||
|
assert_eq!(options.cfg_path(), std::path::Path::new(ksp_config_lib::DEFAULT_CFG_PATH));
|
||||||
|
assert_eq!(options.schema_path(), std::path::Path::new(ksp_config_lib::DEFAULT_SCHEMA_PATH));
|
||||||
|
assert_eq!(ksp_config_lib::ARG_CFG_PATH, "--cfgpath");
|
||||||
|
assert_eq!(ksp_config_lib::ARG_SCHEMA_PATH, "--schemapath");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn programmatic_bootstrap_paths_are_independent() {
|
||||||
|
let result = ksp_config_lib::ConfigBootstrapOptions::from_paths("runtime-config", "runtime-schemas");
|
||||||
|
assert!(result.is_ok(), "programmatic bootstrap paths should be valid: {result:?}");
|
||||||
|
if let std::result::Result::Ok(options) = result {
|
||||||
|
assert_eq!(options.cfg_path(), std::path::Path::new("runtime-config"));
|
||||||
|
assert_eq!(options.schema_path(), std::path::Path::new("runtime-schemas"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cli_bootstrap_parser_is_available_from_crate_root() {
|
||||||
|
let args = [
|
||||||
|
std::ffi::OsString::from("consumer"),
|
||||||
|
std::ffi::OsString::from("--cfgpath=consumer-config"),
|
||||||
|
std::ffi::OsString::from("--schemapath"),
|
||||||
|
std::ffi::OsString::from("consumer-schemas"),
|
||||||
|
];
|
||||||
|
let result = ksp_config_lib::ConfigBootstrapOptions::from_args(&args);
|
||||||
|
assert!(result.is_ok(), "public bootstrap parser should accept KSP path arguments: {result:?}");
|
||||||
|
if let std::result::Result::Ok(options) = result {
|
||||||
|
assert_eq!(options.cfg_path(), std::path::Path::new("consumer-config"));
|
||||||
|
assert_eq!(options.schema_path(), std::path::Path::new("consumer-schemas"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn logical_file_registry_is_available_from_crate_root() {
|
||||||
|
let args = [
|
||||||
|
std::ffi::OsString::from("consumer"),
|
||||||
|
std::ffi::OsString::from("--filemap=cfg.std.logging=consumer.logging.json"),
|
||||||
|
std::ffi::OsString::from("--filemap=schema.std.logging=consumer.logging.schema.json"),
|
||||||
|
];
|
||||||
|
let registry = ksp_config_lib::ConfigFileRegistry::from_args(&args);
|
||||||
|
let bootstrap = ksp_config_lib::ConfigBootstrapOptions::from_paths("consumer-config", "consumer-schemas");
|
||||||
|
let logging_id = ksp_config_lib::ConfigFileId::new(ksp_config_lib::FILE_ID_STD_LOGGING);
|
||||||
|
let schema_id = ksp_config_lib::ConfigFileId::new(ksp_config_lib::FILE_ID_SCHEMA_STD_LOGGING);
|
||||||
|
assert!(registry.is_ok(), "public registry should accept known filemap overrides: {registry:?}");
|
||||||
|
assert!(bootstrap.is_ok(), "bootstrap paths should remain available: {bootstrap:?}");
|
||||||
|
assert!(logging_id.is_ok(), "public logging file_id should be valid: {logging_id:?}");
|
||||||
|
assert!(schema_id.is_ok(), "public schema file_id should be valid: {schema_id:?}");
|
||||||
|
if let (std::result::Result::Ok(registry), std::result::Result::Ok(bootstrap), std::result::Result::Ok(logging_id), std::result::Result::Ok(schema_id)) =
|
||||||
|
(registry, bootstrap, logging_id, schema_id)
|
||||||
|
{
|
||||||
|
let logging = registry.resolve_path(&bootstrap, &logging_id);
|
||||||
|
let schema = registry.resolve_path(&bootstrap, &schema_id);
|
||||||
|
assert!(logging.is_ok(), "public logging path should resolve: {logging:?}");
|
||||||
|
assert!(schema.is_ok(), "public schema path should resolve: {schema:?}");
|
||||||
|
if let std::result::Result::Ok(logging) = logging {
|
||||||
|
assert_eq!(logging, std::path::PathBuf::from("consumer-config/consumer.logging.json"));
|
||||||
|
}
|
||||||
|
if let std::result::Result::Ok(schema) = schema {
|
||||||
|
assert_eq!(schema, std::path::PathBuf::from("consumer-schemas/consumer.logging.schema.json"));
|
||||||
|
}
|
||||||
|
assert_eq!(ksp_config_lib::ARG_FILE_MAP, "--filemap");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validated_json_document_engine_is_available_from_crate_root() {
|
||||||
|
let workspace = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..");
|
||||||
|
let bootstrap = ksp_config_lib::ConfigBootstrapOptions::from_paths(workspace.join("config"), workspace.join("config/schemas"));
|
||||||
|
let registry = ksp_config_lib::ConfigFileRegistry::defaults();
|
||||||
|
let file_id = ksp_config_lib::ConfigFileId::new(ksp_config_lib::FILE_ID_STD_LOGGING);
|
||||||
|
assert!(bootstrap.is_ok(), "public bootstrap should accept committed Config roots: {bootstrap:?}");
|
||||||
|
assert!(registry.is_ok(), "public registry should remain constructible: {registry:?}");
|
||||||
|
assert!(file_id.is_ok(), "public Logging file_id should remain constructible: {file_id:?}");
|
||||||
|
if let (std::result::Result::Ok(bootstrap), std::result::Result::Ok(registry), std::result::Result::Ok(file_id)) = (bootstrap, registry, file_id) {
|
||||||
|
let engine = ksp_config_lib::ConfigDocumentEngine::new(bootstrap, registry);
|
||||||
|
let document = engine.load_validated_document(&file_id);
|
||||||
|
assert!(document.is_ok(), "public document engine should validate committed Logging configuration: {document:?}");
|
||||||
|
if let std::result::Result::Ok(document) = document {
|
||||||
|
assert_eq!(document.file_id(), &file_id);
|
||||||
|
assert_eq!(document.value().get("format_version"), std::option::Option::Some(&serde_json::Value::from(1)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolved_profile_contract_is_available_from_crate_root() {
|
||||||
|
let workspace = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..");
|
||||||
|
let bootstrap = ksp_config_lib::ConfigBootstrapOptions::from_paths(workspace.join("config"), workspace.join("config/schemas"));
|
||||||
|
let registry = ksp_config_lib::ConfigFileRegistry::defaults();
|
||||||
|
let file_id = ksp_config_lib::ConfigFileId::new(ksp_config_lib::FILE_ID_STD_LOGGING);
|
||||||
|
assert!(bootstrap.is_ok(), "public bootstrap should accept committed Config roots: {bootstrap:?}");
|
||||||
|
assert!(registry.is_ok(), "public registry should remain constructible: {registry:?}");
|
||||||
|
assert!(file_id.is_ok(), "public Logging file_id should remain constructible: {file_id:?}");
|
||||||
|
if let (std::result::Result::Ok(bootstrap), std::result::Result::Ok(registry), std::result::Result::Ok(file_id)) = (bootstrap, registry, file_id) {
|
||||||
|
let engine = ksp_config_lib::ConfigDocumentEngine::new(bootstrap, registry);
|
||||||
|
let resolved = engine.load_resolved_profile(&file_id, std::option::Option::None);
|
||||||
|
assert!(resolved.is_ok(), "public profile resolver should resolve committed default profile: {resolved:?}");
|
||||||
|
if let std::result::Result::Ok(resolved) = resolved {
|
||||||
|
assert_eq!(resolved.profile_id(), "local_dev");
|
||||||
|
assert_eq!(resolved.selection_source(), ksp_config_lib::ConfigProfileSelectionSource::DefaultProfile);
|
||||||
|
assert_eq!(resolved.origin("logs_directory"), std::option::Option::Some(ksp_config_lib::ConfigValueOrigin::Global));
|
||||||
|
assert_eq!(resolved.origin("default_filter"), std::option::Option::Some(ksp_config_lib::ConfigValueOrigin::Profile));
|
||||||
|
assert!(!resolved.effective().contains_key("default_profile"));
|
||||||
|
assert!(!resolved.effective().contains_key("profiles"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn composite_schema_and_provenance_contracts_are_available_from_crate_root() {
|
||||||
|
let registry = ksp_config_lib::ConfigFileRegistry::defaults();
|
||||||
|
let schema_id = ksp_config_lib::ConfigFileId::new(ksp_config_lib::FILE_ID_SCHEMA_COMPOSITE);
|
||||||
|
assert!(registry.is_ok(), "public registry should remain constructible: {registry:?}");
|
||||||
|
assert!(schema_id.is_ok(), "public composite schema file_id should be valid: {schema_id:?}");
|
||||||
|
if let (std::result::Result::Ok(registry), std::result::Result::Ok(schema_id)) = (registry, schema_id) {
|
||||||
|
let descriptor = registry.descriptor(&schema_id);
|
||||||
|
assert!(descriptor.is_ok(), "public composite schema descriptor should exist: {descriptor:?}");
|
||||||
|
if let std::result::Result::Ok(descriptor) = descriptor {
|
||||||
|
assert_eq!(descriptor.filename(), std::path::Path::new(ksp_config_lib::DEFAULT_COMPOSITE_SCHEMA_FILENAME));
|
||||||
|
assert_eq!(descriptor.kind(), ksp_config_lib::ConfigFileKind::Schema);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert_ne!(ksp_config_lib::ConfigProfileSelectionSource::Composite, ksp_config_lib::ConfigProfileSelectionSource::Explicit);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn environment_resolution_contract_is_available_from_crate_root() {
|
||||||
|
let loader: fn() -> ksp_core_lib::Result<ksp_config_lib::ConfigEnvironment> = ksp_config_lib::ConfigEnvironment::load;
|
||||||
|
let _ = loader;
|
||||||
|
assert_eq!(ksp_config_lib::DEFAULT_DOTENV_PATH, ".env");
|
||||||
|
assert_eq!(ksp_config_lib::DEFAULT_DOTENV_EXAMPLE_PATH, ".env.example");
|
||||||
|
assert_ne!(ksp_config_lib::ConfigEnvironmentSource::Process, ksp_config_lib::ConfigEnvironmentSource::DotEnv);
|
||||||
|
assert_ne!(ksp_config_lib::ConfigEnvironmentSource::DotEnv, ksp_config_lib::ConfigEnvironmentSource::Fallback);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sensitivity_and_safe_resolution_contracts_are_available_from_crate_root() {
|
||||||
|
assert_eq!(
|
||||||
|
ksp_config_lib::ConfigSensitivity::from_variable_name("KSP_PUBLIC_HOST").ok(),
|
||||||
|
std::option::Option::Some(ksp_config_lib::ConfigSensitivity::Public),
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
ksp_config_lib::ConfigSensitivity::from_variable_name("KSP_MODE").ok(),
|
||||||
|
std::option::Option::Some(ksp_config_lib::ConfigSensitivity::Internal)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
ksp_config_lib::ConfigSensitivity::from_variable_name("KSP_SECRET_TOKEN").ok(),
|
||||||
|
std::option::Option::Some(ksp_config_lib::ConfigSensitivity::Secret),
|
||||||
|
);
|
||||||
|
assert_eq!(ksp_config_lib::REDACTED_CONFIG_VALUE, "********");
|
||||||
|
let detailed_text: fn(&ksp_config_lib::ConfigEnvironment, &str) -> ksp_core_lib::Result<ksp_config_lib::ResolvedConfigText> =
|
||||||
|
ksp_config_lib::ConfigEnvironment::resolve_text_detailed;
|
||||||
|
let detailed_json: fn(&ksp_config_lib::ConfigEnvironment, &serde_json::Value) -> ksp_core_lib::Result<ksp_config_lib::ResolvedConfigJson> =
|
||||||
|
ksp_config_lib::ConfigEnvironment::resolve_json_detailed;
|
||||||
|
let _ = (detailed_text, detailed_json);
|
||||||
|
let provenance = ksp_config_lib::ConfigValueProvenance::EnvironmentFallback { variable_name: "KSP_SECRET_TOKEN".to_owned() };
|
||||||
|
assert_eq!(provenance.variable_name(), std::option::Option::Some("KSP_SECRET_TOKEN"));
|
||||||
|
assert_eq!(provenance.environment_source(), std::option::Option::Some(ksp_config_lib::ConfigEnvironmentSource::Fallback));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn logging_adapter_contract_is_available_from_crate_root() {
|
||||||
|
let adapter = ksp_config_lib::ConfigDocumentEngine::load_resolved_logging_config;
|
||||||
|
let _ = adapter;
|
||||||
|
assert_eq!(ksp_config_lib::ERROR_CODE_EFFECTIVE_CONFIG_INVALID.domain(), "config");
|
||||||
|
assert_eq!(ksp_config_lib::ERROR_CODE_EFFECTIVE_CONFIG_INVALID.code(), "effective_config_invalid");
|
||||||
|
assert!(std::mem::size_of::<ksp_config_lib::ResolvedLoggingConfig>() > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn management_contracts_are_available_from_crate_root() {
|
||||||
|
let workspace = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..");
|
||||||
|
let bootstrap = ksp_config_lib::ConfigBootstrapOptions::from_paths(workspace.join("config"), workspace.join("config/schemas"));
|
||||||
|
let registry = ksp_config_lib::ConfigFileRegistry::defaults();
|
||||||
|
assert!(bootstrap.is_ok(), "public bootstrap should accept committed Config roots: {bootstrap:?}");
|
||||||
|
assert!(registry.is_ok(), "public registry should remain constructible: {registry:?}");
|
||||||
|
if let (std::result::Result::Ok(bootstrap), std::result::Result::Ok(registry)) = (bootstrap, registry) {
|
||||||
|
let engine = ksp_config_lib::ConfigDocumentEngine::new(bootstrap, registry);
|
||||||
|
let management = ksp_config_lib::ConfigManagement::new(engine);
|
||||||
|
let logging = management.load_logging_document();
|
||||||
|
assert!(logging.is_ok(), "public typed Logging management contract should load committed source: {logging:?}");
|
||||||
|
if let std::result::Result::Ok(mut logging) = logging {
|
||||||
|
assert_eq!(logging.format_version(), 1);
|
||||||
|
assert_eq!(logging.default_profile(), "local_dev");
|
||||||
|
logging.set_logs_directory("public-api-management-test");
|
||||||
|
assert_eq!(logging.logs_directory(), "public-api-management-test");
|
||||||
|
assert_eq!(logging.profiles().len(), 1);
|
||||||
|
assert_eq!(logging.profiles()[0].profile_id(), "local_dev");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let reveal_effective: fn(&ksp_config_lib::ConfigManagement, &str) -> ksp_core_lib::Result<std::option::Option<String>> =
|
||||||
|
ksp_config_lib::ConfigManagement::reveal_effective_environment_value;
|
||||||
|
let reveal_dotenv: fn(&ksp_config_lib::ConfigManagement, &str) -> ksp_core_lib::Result<std::option::Option<String>> =
|
||||||
|
ksp_config_lib::ConfigManagement::reveal_dotenv_value;
|
||||||
|
let _ = (reveal_effective, reveal_dotenv);
|
||||||
|
assert_ne!(ksp_config_lib::ERROR_CODE_MANAGEMENT_OPERATION_INVALID, ksp_config_lib::ERROR_CODE_PERSISTENCE_WRITE_FAILED);
|
||||||
|
}
|
||||||
142
crates/ksp-config-lib/unit_tests/bootstrap.rs
Normal file
142
crates/ksp-config-lib/unit_tests/bootstrap.rs
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
// file: crates/ksp-config-lib/unit_tests/bootstrap.rs
|
||||||
|
// version: 1
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn defaults_use_hardcoded_ksp_roots() {
|
||||||
|
let result = super::ConfigBootstrapOptions::defaults();
|
||||||
|
assert!(result.is_ok(), "default bootstrap paths should be valid: {result:?}");
|
||||||
|
if let std::result::Result::Ok(options) = result {
|
||||||
|
assert_eq!(options.cfg_path(), std::path::Path::new(super::DEFAULT_CFG_PATH));
|
||||||
|
assert_eq!(options.schema_path(), std::path::Path::new(super::DEFAULT_SCHEMA_PATH));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cfg_path_override_keeps_schema_default() {
|
||||||
|
let defaults = super::ConfigBootstrapOptions::defaults();
|
||||||
|
assert!(defaults.is_ok(), "default bootstrap paths should be valid: {defaults:?}");
|
||||||
|
if let std::result::Result::Ok(options) = defaults {
|
||||||
|
let result = options.with_cfg_path("custom-config");
|
||||||
|
assert!(result.is_ok(), "cfg path override should be valid: {result:?}");
|
||||||
|
if let std::result::Result::Ok(options) = result {
|
||||||
|
assert_eq!(options.cfg_path(), std::path::Path::new("custom-config"));
|
||||||
|
assert_eq!(options.schema_path(), std::path::Path::new(super::DEFAULT_SCHEMA_PATH));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn schema_path_override_keeps_cfg_default() {
|
||||||
|
let defaults = super::ConfigBootstrapOptions::defaults();
|
||||||
|
assert!(defaults.is_ok(), "default bootstrap paths should be valid: {defaults:?}");
|
||||||
|
if let std::result::Result::Ok(options) = defaults {
|
||||||
|
let result = options.with_schema_path("custom-schemas");
|
||||||
|
assert!(result.is_ok(), "schema path override should be valid: {result:?}");
|
||||||
|
if let std::result::Result::Ok(options) = result {
|
||||||
|
assert_eq!(options.cfg_path(), std::path::Path::new(super::DEFAULT_CFG_PATH));
|
||||||
|
assert_eq!(options.schema_path(), std::path::Path::new("custom-schemas"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cfg_cli_override_keeps_schema_default() {
|
||||||
|
let args = [std::ffi::OsString::from("ksp-app"), std::ffi::OsString::from("--cfgpath=cli-config")];
|
||||||
|
let result = super::ConfigBootstrapOptions::from_args(&args);
|
||||||
|
assert!(result.is_ok(), "cfg CLI override should parse: {result:?}");
|
||||||
|
if let std::result::Result::Ok(options) = result {
|
||||||
|
assert_eq!(options.cfg_path(), std::path::Path::new("cli-config"));
|
||||||
|
assert_eq!(options.schema_path(), std::path::Path::new(super::DEFAULT_SCHEMA_PATH));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn schema_cli_override_keeps_cfg_default() {
|
||||||
|
let args = [std::ffi::OsString::from("ksp-app"), std::ffi::OsString::from("--schemapath=cli-schemas")];
|
||||||
|
let result = super::ConfigBootstrapOptions::from_args(&args);
|
||||||
|
assert!(result.is_ok(), "schema CLI override should parse: {result:?}");
|
||||||
|
if let std::result::Result::Ok(options) = result {
|
||||||
|
assert_eq!(options.cfg_path(), std::path::Path::new(super::DEFAULT_CFG_PATH));
|
||||||
|
assert_eq!(options.schema_path(), std::path::Path::new("cli-schemas"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parser_accepts_inline_and_separate_forms_and_last_value_wins() {
|
||||||
|
let args = [
|
||||||
|
std::ffi::OsString::from("ksp-app"),
|
||||||
|
std::ffi::OsString::from("--cfgpath=first-config"),
|
||||||
|
std::ffi::OsString::from("--unrelated"),
|
||||||
|
std::ffi::OsString::from("--cfgpath"),
|
||||||
|
std::ffi::OsString::from("second-config"),
|
||||||
|
std::ffi::OsString::from("--schemapath=first-schemas"),
|
||||||
|
std::ffi::OsString::from("--schemapath"),
|
||||||
|
std::ffi::OsString::from("second-schemas"),
|
||||||
|
];
|
||||||
|
let result = super::ConfigBootstrapOptions::from_args(&args);
|
||||||
|
assert!(result.is_ok(), "bootstrap arguments should parse: {result:?}");
|
||||||
|
if let std::result::Result::Ok(options) = result {
|
||||||
|
assert_eq!(options.cfg_path(), std::path::Path::new("second-config"));
|
||||||
|
assert_eq!(options.schema_path(), std::path::Path::new("second-schemas"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parser_reports_missing_separate_value() {
|
||||||
|
let args = [std::ffi::OsString::from("ksp-app"), std::ffi::OsString::from("--cfgpath")];
|
||||||
|
let result = super::ConfigBootstrapOptions::from_args(&args);
|
||||||
|
assert!(result.is_err(), "missing value must be rejected");
|
||||||
|
if let std::result::Result::Err(error) = result {
|
||||||
|
assert_eq!(error.code(), crate::ERROR_CODE_BOOTSTRAP_ARGUMENT_MISSING_VALUE);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parser_reports_another_option_as_missing_separate_value() {
|
||||||
|
let args = [std::ffi::OsString::from("ksp-app"), std::ffi::OsString::from("--cfgpath"), std::ffi::OsString::from("--other-option")];
|
||||||
|
let result = super::ConfigBootstrapOptions::from_args(&args);
|
||||||
|
assert!(result.is_err(), "another option must not become a bootstrap path value");
|
||||||
|
if let std::result::Result::Err(error) = result {
|
||||||
|
assert_eq!(error.code(), crate::ERROR_CODE_BOOTSTRAP_ARGUMENT_MISSING_VALUE);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty_inline_path_is_rejected() {
|
||||||
|
let args = [std::ffi::OsString::from("ksp-app"), std::ffi::OsString::from("--schemapath=")];
|
||||||
|
let result = super::ConfigBootstrapOptions::from_args(&args);
|
||||||
|
assert!(result.is_err(), "empty path must be rejected");
|
||||||
|
if let std::result::Result::Err(error) = result {
|
||||||
|
assert_eq!(error.code(), crate::ERROR_CODE_BOOTSTRAP_INVALID_PATH);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn explicit_programmatic_paths_do_not_depend_on_default_roots() {
|
||||||
|
let result = super::ConfigBootstrapOptions::from_paths("programmatic-config", "programmatic-schemas");
|
||||||
|
assert!(result.is_ok(), "explicit programmatic paths should be valid: {result:?}");
|
||||||
|
if let std::result::Result::Ok(options) = result {
|
||||||
|
assert_eq!(options.cfg_path(), std::path::Path::new("programmatic-config"));
|
||||||
|
assert_eq!(options.schema_path(), std::path::Path::new("programmatic-schemas"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn existing_non_directory_path_is_rejected() {
|
||||||
|
let fixture = unique_fixture_path("existing-file");
|
||||||
|
let create = std::fs::write(fixture.as_path(), b"fixture");
|
||||||
|
assert!(create.is_ok(), "fixture file should be creatable: {create:?}");
|
||||||
|
let result = super::ConfigBootstrapOptions::from_paths(fixture.as_path(), "programmatic-schemas");
|
||||||
|
let remove = std::fs::remove_file(fixture.as_path());
|
||||||
|
assert!(remove.is_ok(), "fixture file should be removable: {remove:?}");
|
||||||
|
assert!(result.is_err(), "existing file must not be accepted as a bootstrap directory");
|
||||||
|
if let std::result::Result::Err(error) = result {
|
||||||
|
assert_eq!(error.code(), crate::ERROR_CODE_BOOTSTRAP_INVALID_PATH);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn unique_fixture_path(name: &str) -> std::path::PathBuf {
|
||||||
|
let mut path = std::env::temp_dir();
|
||||||
|
path.push(format!("ksp-config-lib-{name}-{}", std::process::id()));
|
||||||
|
return path;
|
||||||
|
}
|
||||||
247
crates/ksp-config-lib/unit_tests/composite.rs
Normal file
247
crates/ksp-config-lib/unit_tests/composite.rs
Normal file
@@ -0,0 +1,247 @@
|
|||||||
|
// file: crates/ksp-config-lib/unit_tests/composite.rs
|
||||||
|
// version: 2
|
||||||
|
|
||||||
|
const TEST_COMPOSITE_FILE_ID: &str = "cfg.composite.test";
|
||||||
|
const TEST_COMPOSITE_FILENAME: &str = "examples/composite.example.json";
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn committed_composite_example_resolves_default_document_profile() {
|
||||||
|
let engine = committed_engine();
|
||||||
|
let file_id = crate::ConfigFileId::new(TEST_COMPOSITE_FILE_ID);
|
||||||
|
assert!(engine.is_ok(), "composite test engine should be constructible: {engine:?}");
|
||||||
|
assert!(file_id.is_ok(), "composite test file_id should be valid: {file_id:?}");
|
||||||
|
if let (std::result::Result::Ok(engine), std::result::Result::Ok(file_id)) = (engine, file_id) {
|
||||||
|
let resolved = engine.load_resolved_composite(&file_id, std::option::Option::None);
|
||||||
|
assert!(resolved.is_ok(), "committed composite example should resolve: {resolved:?}");
|
||||||
|
if let std::result::Result::Ok(resolved) = resolved {
|
||||||
|
assert_eq!(resolved.profile_id(), "local_default");
|
||||||
|
assert_eq!(resolved.selection_source(), crate::ConfigProfileSelectionSource::DefaultProfile);
|
||||||
|
let logging = resolved.component("logging");
|
||||||
|
assert!(logging.is_some(), "logging component should be resolved");
|
||||||
|
if let std::option::Option::Some(logging) = logging {
|
||||||
|
assert_eq!(logging.resolved().file_id().as_str(), crate::FILE_ID_STD_LOGGING);
|
||||||
|
assert_eq!(logging.resolved().profile_id(), "local_dev");
|
||||||
|
assert_eq!(logging.resolved().selection_source(), crate::ConfigProfileSelectionSource::DefaultProfile);
|
||||||
|
assert_eq!(logging.resolved().origin("logs_directory"), std::option::Option::Some(crate::ConfigValueOrigin::Global));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn composite_profile_override_marks_referenced_profile_selection_as_composite() {
|
||||||
|
let engine = committed_engine();
|
||||||
|
let file_id = crate::ConfigFileId::new(TEST_COMPOSITE_FILE_ID);
|
||||||
|
assert!(engine.is_ok(), "composite test engine should be constructible: {engine:?}");
|
||||||
|
assert!(file_id.is_ok(), "composite test file_id should be valid: {file_id:?}");
|
||||||
|
if let (std::result::Result::Ok(engine), std::result::Result::Ok(file_id)) = (engine, file_id) {
|
||||||
|
let resolved = engine.load_resolved_composite(&file_id, std::option::Option::Some("local_explicit"));
|
||||||
|
assert!(resolved.is_ok(), "explicit composite profile should resolve: {resolved:?}");
|
||||||
|
if let std::result::Result::Ok(resolved) = resolved {
|
||||||
|
assert_eq!(resolved.selection_source(), crate::ConfigProfileSelectionSource::Explicit);
|
||||||
|
let logging = resolved.component("logging");
|
||||||
|
assert!(logging.is_some(), "logging component should be resolved");
|
||||||
|
if let std::option::Option::Some(logging) = logging {
|
||||||
|
assert_eq!(logging.resolved().profile_id(), "local_dev");
|
||||||
|
assert_eq!(logging.resolved().selection_source(), crate::ConfigProfileSelectionSource::Composite);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unknown_composite_profile_has_profile_not_found_error() {
|
||||||
|
let engine = committed_engine();
|
||||||
|
let file_id = crate::ConfigFileId::new(TEST_COMPOSITE_FILE_ID);
|
||||||
|
assert!(engine.is_ok(), "composite test engine should be constructible: {engine:?}");
|
||||||
|
assert!(file_id.is_ok(), "composite test file_id should be valid: {file_id:?}");
|
||||||
|
if let (std::result::Result::Ok(engine), std::result::Result::Ok(file_id)) = (engine, file_id) {
|
||||||
|
let result = engine.load_resolved_composite(&file_id, std::option::Option::Some("missing"));
|
||||||
|
assert!(result.is_err(), "unknown composite profile must be rejected: {result:?}");
|
||||||
|
if let std::result::Result::Err(error) = result {
|
||||||
|
assert_eq!(error.code(), crate::ERROR_CODE_PROFILE_NOT_FOUND);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unknown_referenced_file_id_is_rejected_during_composite_validation() {
|
||||||
|
let fixture = fixture_roots("unknown-reference");
|
||||||
|
let composite = r#"{
|
||||||
|
"format_version": 1,
|
||||||
|
"default_profile": "test",
|
||||||
|
"profiles": [{
|
||||||
|
"profile_id": "test",
|
||||||
|
"documents": [{"component_id": "missing", "file_id": "cfg.std.missing"}]
|
||||||
|
}]
|
||||||
|
}"#;
|
||||||
|
let prepared = prepare_fixture(&fixture, composite);
|
||||||
|
assert!(prepared.is_ok(), "composite fixture should be writable: {prepared:?}");
|
||||||
|
if prepared.is_ok() {
|
||||||
|
let engine = fixture_engine(&fixture);
|
||||||
|
let file_id = crate::ConfigFileId::new(TEST_COMPOSITE_FILE_ID);
|
||||||
|
assert!(engine.is_ok(), "fixture engine should be constructible: {engine:?}");
|
||||||
|
if let (std::result::Result::Ok(engine), std::result::Result::Ok(file_id)) = (engine, file_id) {
|
||||||
|
let result = engine.load_validated_document(&file_id);
|
||||||
|
assert!(result.is_err(), "unknown composite reference must be rejected: {result:?}");
|
||||||
|
if let std::result::Result::Err(error) = result {
|
||||||
|
assert_eq!(error.code(), crate::ERROR_CODE_COMPOSITE_REFERENCE_INVALID);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cleanup_fixture(&fixture);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn duplicate_component_ids_are_rejected_inside_one_composite_profile() {
|
||||||
|
let fixture = fixture_roots("duplicate-component");
|
||||||
|
let composite = r#"{
|
||||||
|
"format_version": 1,
|
||||||
|
"default_profile": "test",
|
||||||
|
"profiles": [{
|
||||||
|
"profile_id": "test",
|
||||||
|
"documents": [
|
||||||
|
{"component_id": "logging", "file_id": "cfg.std.logging"},
|
||||||
|
{"component_id": "logging", "file_id": "cfg.std.logging"}
|
||||||
|
]
|
||||||
|
}]
|
||||||
|
}"#;
|
||||||
|
let prepared = prepare_fixture(&fixture, composite);
|
||||||
|
assert!(prepared.is_ok(), "composite fixture should be writable: {prepared:?}");
|
||||||
|
if prepared.is_ok() {
|
||||||
|
let engine = fixture_engine(&fixture);
|
||||||
|
let file_id = crate::ConfigFileId::new(TEST_COMPOSITE_FILE_ID);
|
||||||
|
assert!(engine.is_ok(), "fixture engine should be constructible: {engine:?}");
|
||||||
|
if let (std::result::Result::Ok(engine), std::result::Result::Ok(file_id)) = (engine, file_id) {
|
||||||
|
let result = engine.load_validated_document(&file_id);
|
||||||
|
assert!(result.is_err(), "duplicate component_id must be rejected: {result:?}");
|
||||||
|
if let std::result::Result::Err(error) = result {
|
||||||
|
assert_eq!(error.code(), crate::ERROR_CODE_DOCUMENT_SEMANTIC_INVALID);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cleanup_fixture(&fixture);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn committed_engine() -> ksp_core_lib::Result<crate::ConfigDocumentEngine> {
|
||||||
|
let workspace = workspace_root();
|
||||||
|
let bootstrap = crate::ConfigBootstrapOptions::from_paths(workspace.join("config"), workspace.join("config/schemas"));
|
||||||
|
let bootstrap = match bootstrap {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let registry = test_registry(TEST_COMPOSITE_FILENAME);
|
||||||
|
return match registry {
|
||||||
|
std::result::Result::Ok(value) => std::result::Result::Ok(crate::ConfigDocumentEngine::new(bootstrap, value)),
|
||||||
|
std::result::Result::Err(error) => std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fixture_engine(fixture: &FixtureRoots) -> ksp_core_lib::Result<crate::ConfigDocumentEngine> {
|
||||||
|
let bootstrap = crate::ConfigBootstrapOptions::from_paths(fixture.config.as_path(), fixture.schemas.as_path());
|
||||||
|
let bootstrap = match bootstrap {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let registry = test_registry("composite.test.json");
|
||||||
|
return match registry {
|
||||||
|
std::result::Result::Ok(value) => std::result::Result::Ok(crate::ConfigDocumentEngine::new(bootstrap, value)),
|
||||||
|
std::result::Result::Err(error) => std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn test_registry(composite_filename: &'static str) -> ksp_core_lib::Result<crate::ConfigFileRegistry> {
|
||||||
|
let logging = crate::registry::ConfigFileDescriptor::new(
|
||||||
|
crate::FILE_ID_STD_LOGGING,
|
||||||
|
crate::ConfigFileKind::Config,
|
||||||
|
crate::DEFAULT_STD_LOGGING_FILENAME,
|
||||||
|
std::option::Option::Some(crate::FILE_ID_SCHEMA_STD_LOGGING),
|
||||||
|
);
|
||||||
|
let logging = match logging {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let logging_schema = crate::registry::ConfigFileDescriptor::new(
|
||||||
|
crate::FILE_ID_SCHEMA_STD_LOGGING,
|
||||||
|
crate::ConfigFileKind::Schema,
|
||||||
|
crate::DEFAULT_STD_LOGGING_SCHEMA_FILENAME,
|
||||||
|
std::option::Option::None,
|
||||||
|
);
|
||||||
|
let logging_schema = match logging_schema {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let composite_schema = crate::registry::ConfigFileDescriptor::new(
|
||||||
|
crate::FILE_ID_SCHEMA_COMPOSITE,
|
||||||
|
crate::ConfigFileKind::Schema,
|
||||||
|
crate::DEFAULT_COMPOSITE_SCHEMA_FILENAME,
|
||||||
|
std::option::Option::None,
|
||||||
|
);
|
||||||
|
let composite_schema = match composite_schema {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let composite = crate::registry::ConfigFileDescriptor::new(
|
||||||
|
TEST_COMPOSITE_FILE_ID,
|
||||||
|
crate::ConfigFileKind::Config,
|
||||||
|
composite_filename,
|
||||||
|
std::option::Option::Some(crate::FILE_ID_SCHEMA_COMPOSITE),
|
||||||
|
);
|
||||||
|
let composite = match composite {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
return crate::registry::build_registry([logging, logging_schema, composite_schema, composite]);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn prepare_fixture(fixture: &FixtureRoots, composite: &str) -> std::io::Result<()> {
|
||||||
|
cleanup_fixture(fixture);
|
||||||
|
let config = std::fs::create_dir_all(fixture.config.as_path());
|
||||||
|
if let std::result::Result::Err(error) = config {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
let schemas = std::fs::create_dir_all(fixture.schemas.as_path());
|
||||||
|
if let std::result::Result::Err(error) = schemas {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
let workspace = workspace_root();
|
||||||
|
let logging = std::fs::copy(workspace.join("config/std.logging.json"), fixture.config.join(crate::DEFAULT_STD_LOGGING_FILENAME));
|
||||||
|
if let std::result::Result::Err(error) = logging {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
let logging_schema =
|
||||||
|
std::fs::copy(workspace.join("config/schemas/std.logging.schema.json"), fixture.schemas.join(crate::DEFAULT_STD_LOGGING_SCHEMA_FILENAME));
|
||||||
|
if let std::result::Result::Err(error) = logging_schema {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
let composite_schema =
|
||||||
|
std::fs::copy(workspace.join("config/schemas/composite.schema.json"), fixture.schemas.join(crate::DEFAULT_COMPOSITE_SCHEMA_FILENAME));
|
||||||
|
if let std::result::Result::Err(error) = composite_schema {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
return std::fs::write(fixture.config.join("composite.test.json"), composite);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn workspace_root() -> std::path::PathBuf {
|
||||||
|
return std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..");
|
||||||
|
}
|
||||||
|
|
||||||
|
struct FixtureRoots {
|
||||||
|
root: std::path::PathBuf,
|
||||||
|
config: std::path::PathBuf,
|
||||||
|
schemas: std::path::PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fixture_roots(name: &str) -> FixtureRoots {
|
||||||
|
let root = std::env::temp_dir().join(format!("ksp-config-composite-{name}-{}", std::process::id()));
|
||||||
|
return FixtureRoots { config: root.join("config"), schemas: root.join("schemas"), root };
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cleanup_fixture(fixture: &FixtureRoots) {
|
||||||
|
let removal = std::fs::remove_dir_all(fixture.root.as_path());
|
||||||
|
if let std::result::Result::Err(error) = removal
|
||||||
|
&& error.kind() != std::io::ErrorKind::NotFound
|
||||||
|
{
|
||||||
|
eprintln!("unable to cleanup Config composite fixture {}: {error}", fixture.root.display());
|
||||||
|
}
|
||||||
|
}
|
||||||
303
crates/ksp-config-lib/unit_tests/document.rs
Normal file
303
crates/ksp-config-lib/unit_tests/document.rs
Normal file
@@ -0,0 +1,303 @@
|
|||||||
|
// file: crates/ksp-config-lib/unit_tests/document.rs
|
||||||
|
// version: 2
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn committed_logging_document_passes_registered_schema_and_semantic_validation() {
|
||||||
|
let workspace = workspace_root();
|
||||||
|
let bootstrap = crate::ConfigBootstrapOptions::from_paths(workspace.join("config"), workspace.join("config/schemas"));
|
||||||
|
let registry = crate::ConfigFileRegistry::defaults();
|
||||||
|
let file_id = crate::ConfigFileId::new(crate::FILE_ID_STD_LOGGING);
|
||||||
|
assert!(bootstrap.is_ok(), "workspace Config paths should be valid: {bootstrap:?}");
|
||||||
|
assert!(registry.is_ok(), "default registry should be valid: {registry:?}");
|
||||||
|
assert!(file_id.is_ok(), "logging file_id should be valid: {file_id:?}");
|
||||||
|
if let (std::result::Result::Ok(bootstrap), std::result::Result::Ok(registry), std::result::Result::Ok(file_id)) = (bootstrap, registry, file_id) {
|
||||||
|
let engine = super::ConfigDocumentEngine::new(bootstrap, registry);
|
||||||
|
let document = engine.load_validated_document(&file_id);
|
||||||
|
assert!(document.is_ok(), "committed std.logging.json should validate: {document:?}");
|
||||||
|
if let std::result::Result::Ok(document) = document {
|
||||||
|
assert_eq!(document.file_id(), &file_id);
|
||||||
|
assert_eq!(document.path(), workspace.join("config/std.logging.json").as_path());
|
||||||
|
let default_profile = document.value().get("default_profile");
|
||||||
|
assert!(default_profile.is_some(), "validated Logging document should retain default_profile");
|
||||||
|
if let std::option::Option::Some(default_profile) = default_profile {
|
||||||
|
assert_eq!(default_profile.as_str(), std::option::Option::Some("local_dev"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn missing_document_is_reported_with_file_read_error() {
|
||||||
|
let fixture = fixture_roots("missing-document");
|
||||||
|
let prepared = prepare_fixture(&fixture, std::option::Option::None, valid_minimal_logging_schema());
|
||||||
|
assert!(prepared.is_ok(), "fixture should be writable: {prepared:?}");
|
||||||
|
if prepared.is_ok() {
|
||||||
|
let result = load_fixture(&fixture);
|
||||||
|
assert_error_code(result, crate::ERROR_CODE_JSON_FILE_READ_FAILED);
|
||||||
|
}
|
||||||
|
cleanup_fixture(&fixture);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn malformed_json_is_reported_before_schema_validation() {
|
||||||
|
let fixture = fixture_roots("malformed-json");
|
||||||
|
let prepared = prepare_fixture(&fixture, std::option::Option::Some("{ invalid-json"), valid_minimal_logging_schema());
|
||||||
|
assert!(prepared.is_ok(), "fixture should be writable: {prepared:?}");
|
||||||
|
if prepared.is_ok() {
|
||||||
|
let result = load_fixture(&fixture);
|
||||||
|
assert_error_code(result, crate::ERROR_CODE_JSON_SYNTAX_INVALID);
|
||||||
|
}
|
||||||
|
cleanup_fixture(&fixture);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn invalid_schema_document_is_reported_before_instance_validation() {
|
||||||
|
let fixture = fixture_roots("invalid-schema");
|
||||||
|
let schema = r#"{
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"type": "definitely-not-a-json-schema-type"
|
||||||
|
}"#;
|
||||||
|
let prepared = prepare_fixture(&fixture, std::option::Option::Some("{}"), schema);
|
||||||
|
assert!(prepared.is_ok(), "fixture should be writable: {prepared:?}");
|
||||||
|
if prepared.is_ok() {
|
||||||
|
let result = load_fixture(&fixture);
|
||||||
|
assert_error_code(result, crate::ERROR_CODE_SCHEMA_INVALID);
|
||||||
|
}
|
||||||
|
cleanup_fixture(&fixture);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn schema_violation_is_distinct_from_json_syntax_failure() {
|
||||||
|
let fixture = fixture_roots("schema-violation");
|
||||||
|
let schema = r#"{
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"type": "object",
|
||||||
|
"required": ["required_field"],
|
||||||
|
"properties": {
|
||||||
|
"required_field": {"type": "string"}
|
||||||
|
}
|
||||||
|
}"#;
|
||||||
|
let prepared = prepare_fixture(&fixture, std::option::Option::Some("{}"), schema);
|
||||||
|
assert!(prepared.is_ok(), "fixture should be writable: {prepared:?}");
|
||||||
|
if prepared.is_ok() {
|
||||||
|
let result = load_fixture(&fixture);
|
||||||
|
assert_error_code(result, crate::ERROR_CODE_SCHEMA_VALIDATION_FAILED);
|
||||||
|
}
|
||||||
|
cleanup_fixture(&fixture);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn schema_valid_logging_document_can_still_fail_ksp_semantics() {
|
||||||
|
let fixture = fixture_roots("semantic-invalid");
|
||||||
|
let workspace = workspace_root();
|
||||||
|
let schema_source = std::fs::read_to_string(workspace.join("config/schemas/std.logging.schema.json"));
|
||||||
|
assert!(schema_source.is_ok(), "committed Logging schema should be readable: {schema_source:?}");
|
||||||
|
if let std::result::Result::Ok(schema_source) = schema_source {
|
||||||
|
let document = r#"{
|
||||||
|
"format_version": 1,
|
||||||
|
"logs_directory": "logs",
|
||||||
|
"default_profile": "duplicate-output",
|
||||||
|
"profiles": [
|
||||||
|
{
|
||||||
|
"profile_id": "duplicate-output",
|
||||||
|
"default_filter": "info",
|
||||||
|
"span_events": "off",
|
||||||
|
"console": {
|
||||||
|
"enabled": false,
|
||||||
|
"output": "stderr",
|
||||||
|
"ansi": false,
|
||||||
|
"format": "human",
|
||||||
|
"filter": {"level": "trace", "targets": ["*"], "domains": ["*"]}
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
{
|
||||||
|
"output_id": "file.same",
|
||||||
|
"enabled": true,
|
||||||
|
"path": "first.log",
|
||||||
|
"rotation": "daily",
|
||||||
|
"format": "human",
|
||||||
|
"ansi": false,
|
||||||
|
"filter": {"level": "info", "targets": ["*"], "domains": ["*"]}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"output_id": "file.same",
|
||||||
|
"enabled": true,
|
||||||
|
"path": "second.log",
|
||||||
|
"rotation": "daily",
|
||||||
|
"format": "human",
|
||||||
|
"ansi": false,
|
||||||
|
"filter": {"level": "info", "targets": ["*"], "domains": ["*"]}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"target_filters": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}"#;
|
||||||
|
let prepared = prepare_fixture(&fixture, std::option::Option::Some(document), schema_source.as_str());
|
||||||
|
assert!(prepared.is_ok(), "fixture should be writable: {prepared:?}");
|
||||||
|
if prepared.is_ok() {
|
||||||
|
let result = load_fixture(&fixture);
|
||||||
|
assert_error_code(result, crate::ERROR_CODE_DOCUMENT_SEMANTIC_INVALID);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cleanup_fixture(&fixture);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn duplicate_profile_ids_are_semantically_invalid() {
|
||||||
|
let fixture = fixture_roots("duplicate-profile-id");
|
||||||
|
let workspace = workspace_root();
|
||||||
|
let schema_source = std::fs::read_to_string(workspace.join("config/schemas/std.logging.schema.json"));
|
||||||
|
assert!(schema_source.is_ok(), "committed Logging schema should be readable: {schema_source:?}");
|
||||||
|
if let std::result::Result::Ok(schema_source) = schema_source {
|
||||||
|
let source = valid_logging_source_with_profiles("first", "first", "first");
|
||||||
|
let prepared = prepare_fixture(&fixture, std::option::Option::Some(source.as_str()), schema_source.as_str());
|
||||||
|
assert!(prepared.is_ok(), "duplicate profile fixture should be writable: {prepared:?}");
|
||||||
|
if prepared.is_ok() {
|
||||||
|
let result = load_fixture(&fixture);
|
||||||
|
assert_error_code(result, crate::ERROR_CODE_DOCUMENT_SEMANTIC_INVALID);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cleanup_fixture(&fixture);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn missing_default_profile_target_is_semantically_invalid() {
|
||||||
|
let fixture = fixture_roots("missing-default-profile");
|
||||||
|
let workspace = workspace_root();
|
||||||
|
let schema_source = std::fs::read_to_string(workspace.join("config/schemas/std.logging.schema.json"));
|
||||||
|
assert!(schema_source.is_ok(), "committed Logging schema should be readable: {schema_source:?}");
|
||||||
|
if let std::result::Result::Ok(schema_source) = schema_source {
|
||||||
|
let source = valid_logging_source_with_profiles("missing", "first", "second");
|
||||||
|
let prepared = prepare_fixture(&fixture, std::option::Option::Some(source.as_str()), schema_source.as_str());
|
||||||
|
assert!(prepared.is_ok(), "missing default profile fixture should be writable: {prepared:?}");
|
||||||
|
if prepared.is_ok() {
|
||||||
|
let result = load_fixture(&fixture);
|
||||||
|
assert_error_code(result, crate::ERROR_CODE_DOCUMENT_SEMANTIC_INVALID);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cleanup_fixture(&fixture);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn valid_logging_source_with_profiles(default_profile: &str, first_profile: &str, second_profile: &str) -> String {
|
||||||
|
return format!(
|
||||||
|
r#"{{
|
||||||
|
"format_version": 1,
|
||||||
|
"logs_directory": "logs",
|
||||||
|
"default_profile": "{default_profile}",
|
||||||
|
"profiles": [
|
||||||
|
{},
|
||||||
|
{}
|
||||||
|
]
|
||||||
|
}}"#,
|
||||||
|
valid_logging_profile(first_profile, "file.first"),
|
||||||
|
valid_logging_profile(second_profile, "file.second")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn valid_logging_profile(profile_id: &str, output_id: &str) -> String {
|
||||||
|
return format!(
|
||||||
|
r#"{{
|
||||||
|
"profile_id": "{profile_id}",
|
||||||
|
"default_filter": "info",
|
||||||
|
"span_events": "off",
|
||||||
|
"console": {{
|
||||||
|
"enabled": false,
|
||||||
|
"output": "stderr",
|
||||||
|
"ansi": false,
|
||||||
|
"format": "human",
|
||||||
|
"filter": {{"level": "trace", "targets": ["*"], "domains": ["*"]}}
|
||||||
|
}},
|
||||||
|
"files": [{{
|
||||||
|
"output_id": "{output_id}",
|
||||||
|
"enabled": true,
|
||||||
|
"path": "output.log",
|
||||||
|
"rotation": "daily",
|
||||||
|
"format": "human",
|
||||||
|
"ansi": false,
|
||||||
|
"filter": {{"level": "info", "targets": ["*"], "domains": ["*"]}}
|
||||||
|
}}],
|
||||||
|
"target_filters": []
|
||||||
|
}}"#
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_fixture(fixture: &FixtureRoots) -> ksp_core_lib::Result<super::ConfigJsonDocument> {
|
||||||
|
let bootstrap = crate::ConfigBootstrapOptions::from_paths(fixture.config.as_path(), fixture.schemas.as_path());
|
||||||
|
let bootstrap = match bootstrap {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let registry = crate::ConfigFileRegistry::defaults();
|
||||||
|
let registry = match registry {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let file_id = crate::ConfigFileId::new(crate::FILE_ID_STD_LOGGING);
|
||||||
|
let file_id = match file_id {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let engine = super::ConfigDocumentEngine::new(bootstrap, registry);
|
||||||
|
return engine.load_validated_document(&file_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn prepare_fixture(fixture: &FixtureRoots, document: std::option::Option<&str>, schema: &str) -> std::io::Result<()> {
|
||||||
|
cleanup_fixture(fixture);
|
||||||
|
let config = std::fs::create_dir_all(fixture.config.as_path());
|
||||||
|
if let std::result::Result::Err(error) = config {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
let schemas = std::fs::create_dir_all(fixture.schemas.as_path());
|
||||||
|
if let std::result::Result::Err(error) = schemas {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
let schema_write = std::fs::write(fixture.schemas.join(crate::DEFAULT_STD_LOGGING_SCHEMA_FILENAME), schema);
|
||||||
|
if let std::result::Result::Err(error) = schema_write {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
if let std::option::Option::Some(document) = document {
|
||||||
|
let document_write = std::fs::write(fixture.config.join(crate::DEFAULT_STD_LOGGING_FILENAME), document);
|
||||||
|
if let std::result::Result::Err(error) = document_write {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn valid_minimal_logging_schema() -> &'static str {
|
||||||
|
return r#"{
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"type": "object"
|
||||||
|
}"#;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn assert_error_code(result: ksp_core_lib::Result<super::ConfigJsonDocument>, expected: ksp_core_lib::ErrorCode) {
|
||||||
|
assert!(result.is_err(), "fixture should fail with {expected:?}: {result:?}");
|
||||||
|
if let std::result::Result::Err(error) = result {
|
||||||
|
assert_eq!(error.code(), expected);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn workspace_root() -> std::path::PathBuf {
|
||||||
|
return std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..");
|
||||||
|
}
|
||||||
|
|
||||||
|
struct FixtureRoots {
|
||||||
|
root: std::path::PathBuf,
|
||||||
|
config: std::path::PathBuf,
|
||||||
|
schemas: std::path::PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fixture_roots(name: &str) -> FixtureRoots {
|
||||||
|
let mut root = std::env::temp_dir();
|
||||||
|
root.push(format!("ksp-config-lib-pre008-{name}-{}", std::process::id()));
|
||||||
|
return FixtureRoots { config: root.join("config"), schemas: root.join("schemas"), root };
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cleanup_fixture(fixture: &FixtureRoots) {
|
||||||
|
let result = std::fs::remove_dir_all(fixture.root.as_path());
|
||||||
|
if let std::result::Result::Err(error) = result {
|
||||||
|
assert_eq!(error.kind(), std::io::ErrorKind::NotFound, "fixture cleanup should only ignore missing directories: {error}");
|
||||||
|
}
|
||||||
|
}
|
||||||
362
crates/ksp-config-lib/unit_tests/environment.rs
Normal file
362
crates/ksp-config-lib/unit_tests/environment.rs
Normal file
@@ -0,0 +1,362 @@
|
|||||||
|
// file: crates/ksp-config-lib/unit_tests/environment.rs
|
||||||
|
// version: 4
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn process_environment_wins_over_dotenv_and_fallback_even_when_empty() {
|
||||||
|
let mut process = std::collections::BTreeMap::<String, String>::new();
|
||||||
|
process.insert("KSP_LOGS_DIRECTORY".to_owned(), String::new());
|
||||||
|
let mut dotenv = std::collections::BTreeMap::<String, String>::new();
|
||||||
|
dotenv.insert("KSP_LOGS_DIRECTORY".to_owned(), "dotenv-logs".to_owned());
|
||||||
|
let environment = super::ConfigEnvironment::from_maps(process, dotenv);
|
||||||
|
let resolved = environment.resolve_variable("KSP_LOGS_DIRECTORY", std::option::Option::Some("fallback-logs"));
|
||||||
|
assert!(resolved.is_ok(), "process value should resolve");
|
||||||
|
let resolved = match resolved {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
assert_eq!(resolved.value(), "");
|
||||||
|
assert_eq!(resolved.source(), super::ConfigEnvironmentSource::Process);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dotenv_wins_over_fallback_when_process_value_is_absent() {
|
||||||
|
let process = std::collections::BTreeMap::<String, String>::new();
|
||||||
|
let mut dotenv = std::collections::BTreeMap::<String, String>::new();
|
||||||
|
dotenv.insert("KSP_LOGS_DIRECTORY".to_owned(), "dotenv-logs".to_owned());
|
||||||
|
let environment = super::ConfigEnvironment::from_maps(process, dotenv);
|
||||||
|
let resolved = environment.resolve_variable("KSP_LOGS_DIRECTORY", std::option::Option::Some("fallback-logs"));
|
||||||
|
assert!(resolved.is_ok(), "dotenv value should resolve");
|
||||||
|
let resolved = match resolved {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
assert_eq!(resolved.value(), "dotenv-logs");
|
||||||
|
assert_eq!(resolved.source(), super::ConfigEnvironmentSource::DotEnv);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty_dotenv_value_is_defined_and_beats_fallback() {
|
||||||
|
let mut dotenv = std::collections::BTreeMap::<String, String>::new();
|
||||||
|
dotenv.insert("KSP_LOGS_DIRECTORY".to_owned(), String::new());
|
||||||
|
let environment = super::ConfigEnvironment::from_maps(std::collections::BTreeMap::new(), dotenv);
|
||||||
|
let resolved = environment.resolve_variable("KSP_LOGS_DIRECTORY", std::option::Option::Some("fallback-logs"));
|
||||||
|
assert!(resolved.is_ok(), "empty dotenv value should resolve");
|
||||||
|
if let std::result::Result::Ok(resolved) = resolved {
|
||||||
|
assert_eq!(resolved.value(), "");
|
||||||
|
assert_eq!(resolved.source(), super::ConfigEnvironmentSource::DotEnv);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fallback_is_used_only_when_external_sources_are_absent() {
|
||||||
|
let environment = super::ConfigEnvironment::from_maps(std::collections::BTreeMap::new(), std::collections::BTreeMap::new());
|
||||||
|
let resolved = environment.resolve_variable("KSP_LOGS_DIRECTORY", std::option::Option::Some("fallback-logs"));
|
||||||
|
assert!(resolved.is_ok(), "fallback should resolve");
|
||||||
|
let resolved = match resolved {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
assert_eq!(resolved.value(), "fallback-logs");
|
||||||
|
assert_eq!(resolved.source(), super::ConfigEnvironmentSource::Fallback);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn missing_variable_without_fallback_is_a_distinct_error() {
|
||||||
|
let environment = super::ConfigEnvironment::from_maps(std::collections::BTreeMap::new(), std::collections::BTreeMap::new());
|
||||||
|
let resolved = environment.resolve_variable("KSP_LOGS_DIRECTORY", std::option::Option::None);
|
||||||
|
let error = match resolved {
|
||||||
|
std::result::Result::Ok(_) => return,
|
||||||
|
std::result::Result::Err(error) => error,
|
||||||
|
};
|
||||||
|
assert_eq!(error.code(), crate::ERROR_CODE_ENVIRONMENT_VARIABLE_MISSING);
|
||||||
|
assert!(!error.to_string().contains("fallback-logs"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ksp_and_kspb_namespaces_are_supported_but_external_names_are_rejected() {
|
||||||
|
let mut process = std::collections::BTreeMap::<String, String>::new();
|
||||||
|
process.insert("KSP_LOGS_DIRECTORY".to_owned(), "logs".to_owned());
|
||||||
|
let bot_variable = ["KSPB_", "TEST_KEY"].concat();
|
||||||
|
process.insert(bot_variable.clone(), "hidden".to_owned());
|
||||||
|
let environment = super::ConfigEnvironment::from_maps(process, std::collections::BTreeMap::new());
|
||||||
|
assert!(environment.resolve_variable("KSP_LOGS_DIRECTORY", std::option::Option::None).is_ok());
|
||||||
|
assert!(environment.resolve_variable(bot_variable.as_str(), std::option::Option::None).is_ok());
|
||||||
|
let external = environment.resolve_variable("OTHER_NETWORK", std::option::Option::None);
|
||||||
|
let error = match external {
|
||||||
|
std::result::Result::Ok(_) => return,
|
||||||
|
std::result::Result::Err(error) => error,
|
||||||
|
};
|
||||||
|
assert_eq!(error.code(), crate::ERROR_CODE_ENVIRONMENT_VARIABLE_INVALID);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn text_resolver_supports_multiple_placeholders_and_literal_fallbacks() {
|
||||||
|
let environment = super::ConfigEnvironment::from_maps(std::collections::BTreeMap::new(), std::collections::BTreeMap::new());
|
||||||
|
let resolved = environment.resolve_text("logs=${KSP_LOGS_DIRECTORY:-logs};second=${KSP_LOGS_DIRECTORY:-other}");
|
||||||
|
assert!(resolved.is_ok(), "multiple placeholders should resolve");
|
||||||
|
if let std::result::Result::Ok(resolved) = resolved {
|
||||||
|
assert_eq!(resolved, "logs=logs;second=other");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn malformed_or_nested_placeholders_are_rejected() {
|
||||||
|
let environment = super::ConfigEnvironment::from_maps(std::collections::BTreeMap::new(), std::collections::BTreeMap::new());
|
||||||
|
let unclosed = environment.resolve_text("${KSP_LOGS_DIRECTORY");
|
||||||
|
let unclosed = match unclosed {
|
||||||
|
std::result::Result::Ok(_) => return,
|
||||||
|
std::result::Result::Err(error) => error,
|
||||||
|
};
|
||||||
|
assert_eq!(unclosed.code(), crate::ERROR_CODE_ENVIRONMENT_PLACEHOLDER_INVALID);
|
||||||
|
let nested = environment.resolve_text("${KSP_LOGS_DIRECTORY:-${KSP_LOGS_DIRECTORY}}}");
|
||||||
|
let nested = match nested {
|
||||||
|
std::result::Result::Ok(_) => return,
|
||||||
|
std::result::Result::Err(error) => error,
|
||||||
|
};
|
||||||
|
assert_eq!(nested.code(), crate::ERROR_CODE_ENVIRONMENT_PLACEHOLDER_INVALID);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn json_resolver_walks_objects_and_arrays_without_changing_keys() {
|
||||||
|
let mut dotenv = std::collections::BTreeMap::<String, String>::new();
|
||||||
|
dotenv.insert("KSP_LOGS_DIRECTORY".to_owned(), "runtime-logs".to_owned());
|
||||||
|
let environment = super::ConfigEnvironment::from_maps(std::collections::BTreeMap::new(), dotenv);
|
||||||
|
let source = serde_json::json!({"path": "${KSP_LOGS_DIRECTORY}", "items": [1, "${KSP_LOGS_DIRECTORY}"], "enabled": true});
|
||||||
|
let resolved = environment.resolve_json(&source);
|
||||||
|
assert!(resolved.is_ok(), "recursive JSON resolution should succeed");
|
||||||
|
let resolved = match resolved {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
assert_eq!(resolved["path"], serde_json::Value::String("runtime-logs".to_owned()));
|
||||||
|
assert_eq!(resolved["items"][1], serde_json::Value::String("runtime-logs".to_owned()));
|
||||||
|
assert_eq!(resolved["enabled"], serde_json::Value::Bool(true));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dotenv_parser_supports_comments_export_quotes_empty_values_and_ignores_external_keys() {
|
||||||
|
let path = std::path::Path::new("fixture.env");
|
||||||
|
let content = "# comment\nexport KSP_LOGS_DIRECTORY = 'quoted logs'\nOTHER_TOOL=value\n";
|
||||||
|
let parsed = super::parse_dotenv_content(path, content);
|
||||||
|
assert!(parsed.is_ok(), "dotenv fixture should parse");
|
||||||
|
let parsed = match parsed {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
assert_eq!(parsed.get("KSP_LOGS_DIRECTORY").map(String::as_str), std::option::Option::Some("quoted logs"));
|
||||||
|
assert!(!parsed.contains_key("OTHER_TOOL"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dotenv_duplicate_ksp_key_is_rejected() {
|
||||||
|
let parsed = super::parse_dotenv_content(std::path::Path::new("fixture.env"), "KSP_LOGS_DIRECTORY=one\nKSP_LOGS_DIRECTORY=two\n");
|
||||||
|
let error = match parsed {
|
||||||
|
std::result::Result::Ok(_) => return,
|
||||||
|
std::result::Result::Err(error) => error,
|
||||||
|
};
|
||||||
|
assert_eq!(error.code(), crate::ERROR_CODE_DOTENV_SYNTAX_INVALID);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fake_process_collection_filters_unrelated_names_without_mutating_real_environment() {
|
||||||
|
let values = vec![
|
||||||
|
(std::ffi::OsString::from("KSP_LOGS_DIRECTORY"), std::ffi::OsString::from("process-logs")),
|
||||||
|
(std::ffi::OsString::from("OTHER_TOOL"), std::ffi::OsString::from("ignored")),
|
||||||
|
];
|
||||||
|
let collected = super::collect_process_environment(values);
|
||||||
|
assert!(collected.is_ok(), "fake process environment should collect");
|
||||||
|
let collected = match collected {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
assert_eq!(collected.len(), 1);
|
||||||
|
assert_eq!(collected.get("KSP_LOGS_DIRECTORY").map(String::as_str), std::option::Option::Some("process-logs"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn committed_logging_profile_resolves_environment_fallback_without_changing_source_profile() {
|
||||||
|
let workspace = workspace_root();
|
||||||
|
let bootstrap = crate::ConfigBootstrapOptions::from_paths(workspace.join("config"), workspace.join("config/schemas"));
|
||||||
|
assert!(bootstrap.is_ok(), "bootstrap should resolve committed roots");
|
||||||
|
let bootstrap = match bootstrap {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
let registry = crate::ConfigFileRegistry::defaults();
|
||||||
|
assert!(registry.is_ok(), "default registry should build");
|
||||||
|
let registry = match registry {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
let engine = crate::ConfigDocumentEngine::new(bootstrap, registry);
|
||||||
|
let file_id = crate::ConfigFileId::new(crate::FILE_ID_STD_LOGGING);
|
||||||
|
let file_id = match file_id {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
let profile = engine.load_resolved_profile(&file_id, std::option::Option::None);
|
||||||
|
assert!(profile.is_ok(), "committed Logging profile should resolve");
|
||||||
|
let profile = match profile {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
let environment = super::ConfigEnvironment::from_maps(std::collections::BTreeMap::new(), std::collections::BTreeMap::new());
|
||||||
|
let effective = profile.resolve_effective_environment(&environment);
|
||||||
|
assert!(effective.is_ok(), "committed Logging environment fallback should resolve");
|
||||||
|
let effective = match effective {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
assert_eq!(profile.effective().get("logs_directory").and_then(serde_json::Value::as_str), std::option::Option::Some("${KSP_LOGS_DIRECTORY:-logs}"));
|
||||||
|
assert_eq!(effective.get("logs_directory").and_then(serde_json::Value::as_str), std::option::Option::Some("logs"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn env_example_inventory_contains_current_runtime_variable_with_preceding_comment() {
|
||||||
|
let content = std::fs::read_to_string(workspace_root().join(crate::DEFAULT_DOTENV_EXAMPLE_PATH));
|
||||||
|
assert!(content.is_ok(), ".env.example must be committed at workspace root");
|
||||||
|
let content = match content {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
let lines = content.lines().collect::<std::vec::Vec<&str>>();
|
||||||
|
let mut found = false;
|
||||||
|
for index in 0..lines.len() {
|
||||||
|
if lines[index].starts_with("KSP_LOGS_DIRECTORY=") {
|
||||||
|
found = true;
|
||||||
|
assert!(index > 0, "environment entry must have a preceding comment");
|
||||||
|
assert!(lines[index - 1].trim_start().starts_with('#'), "environment entry must be immediately preceded by a comment");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert!(found, "current Config environment variable must appear in .env.example");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn workspace_root() -> std::path::PathBuf {
|
||||||
|
return std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn secret_environment_value_keeps_real_value_but_redacts_safe_and_debug_views() {
|
||||||
|
let canary = "KSP_SECRET_CANARY_91b7c6";
|
||||||
|
let mut process = std::collections::BTreeMap::<String, String>::new();
|
||||||
|
process.insert("KSP_SECRET_TEST_TOKEN".to_owned(), canary.to_owned());
|
||||||
|
let environment = super::ConfigEnvironment::from_maps(process, std::collections::BTreeMap::new());
|
||||||
|
let resolved = environment.resolve_variable("KSP_SECRET_TEST_TOKEN", std::option::Option::None);
|
||||||
|
assert!(resolved.is_ok(), "secret process value should resolve");
|
||||||
|
let resolved = match resolved {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
assert_eq!(resolved.value(), canary);
|
||||||
|
assert_eq!(resolved.safe_value(), crate::REDACTED_CONFIG_VALUE);
|
||||||
|
assert_eq!(resolved.sensitivity(), crate::ConfigSensitivity::Secret);
|
||||||
|
assert_eq!(resolved.source(), super::ConfigEnvironmentSource::Process);
|
||||||
|
let debug = format!("{resolved:?}");
|
||||||
|
assert!(!debug.contains(canary), "Debug must not reveal the secret canary");
|
||||||
|
assert!(debug.contains(crate::REDACTED_CONFIG_VALUE));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn detailed_text_redacts_only_secret_segments_and_keeps_ordered_provenance() {
|
||||||
|
let secret = "secret-canary-4a62";
|
||||||
|
let mut process = std::collections::BTreeMap::<String, String>::new();
|
||||||
|
process.insert("KSP_PUBLIC_HOST".to_owned(), "rpc.example.test".to_owned());
|
||||||
|
process.insert("KSP_SECRET_TOKEN".to_owned(), secret.to_owned());
|
||||||
|
let environment = super::ConfigEnvironment::from_maps(process, std::collections::BTreeMap::new());
|
||||||
|
let resolved = environment.resolve_text_detailed("https://${KSP_PUBLIC_HOST}/?token=${KSP_SECRET_TOKEN}");
|
||||||
|
assert!(resolved.is_ok(), "composed secret URL should resolve");
|
||||||
|
let resolved = match resolved {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
assert_eq!(resolved.value(), "https://rpc.example.test/?token=secret-canary-4a62");
|
||||||
|
assert_eq!(resolved.safe_value(), "https://rpc.example.test/?token=********");
|
||||||
|
assert_eq!(resolved.sensitivity(), crate::ConfigSensitivity::Secret);
|
||||||
|
assert_eq!(resolved.provenance().len(), 4);
|
||||||
|
assert_eq!(resolved.provenance()[0], crate::ConfigValueProvenance::DocumentLiteral);
|
||||||
|
assert_eq!(resolved.provenance()[1].variable_name(), std::option::Option::Some("KSP_PUBLIC_HOST"));
|
||||||
|
assert_eq!(resolved.provenance()[2], crate::ConfigValueProvenance::DocumentLiteral);
|
||||||
|
assert_eq!(resolved.provenance()[3].variable_name(), std::option::Option::Some("KSP_SECRET_TOKEN"));
|
||||||
|
let debug = format!("{resolved:?}");
|
||||||
|
assert!(!debug.contains(secret), "resolved text Debug must not reveal a secret segment");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn secret_fallback_inherits_secret_sensitivity_and_is_redacted() {
|
||||||
|
let environment = super::ConfigEnvironment::from_maps(std::collections::BTreeMap::new(), std::collections::BTreeMap::new());
|
||||||
|
let resolved = environment.resolve_text_detailed("token=${KSP_SECRET_TOKEN:-false-secret}");
|
||||||
|
assert!(resolved.is_ok(), "secret fallback should resolve");
|
||||||
|
let resolved = match resolved {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
assert_eq!(resolved.value(), "token=false-secret");
|
||||||
|
assert_eq!(resolved.safe_value(), "token=********");
|
||||||
|
assert_eq!(resolved.sensitivity(), crate::ConfigSensitivity::Secret);
|
||||||
|
assert_eq!(resolved.provenance()[1].environment_source(), std::option::Option::Some(super::ConfigEnvironmentSource::Fallback));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn detailed_json_preserves_safe_tree_sensitivity_and_pointer_provenance() {
|
||||||
|
let secret = "nested-secret-canary-2d11";
|
||||||
|
let mut dotenv = std::collections::BTreeMap::<String, String>::new();
|
||||||
|
dotenv.insert("KSP_SECRET_TOKEN".to_owned(), secret.to_owned());
|
||||||
|
let environment = super::ConfigEnvironment::from_maps(std::collections::BTreeMap::new(), dotenv);
|
||||||
|
let source = serde_json::json!({"transport": {"url": "https://host/?token=${KSP_SECRET_TOKEN}"}, "items": ["plain", 7]});
|
||||||
|
let resolved = environment.resolve_json_detailed(&source);
|
||||||
|
assert!(resolved.is_ok(), "detailed JSON should resolve");
|
||||||
|
let resolved = match resolved {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
assert_eq!(resolved.value()["transport"]["url"], serde_json::Value::String(format!("https://host/?token={secret}")));
|
||||||
|
assert_eq!(resolved.safe_value()["transport"]["url"], serde_json::Value::String("https://host/?token=********".to_owned()));
|
||||||
|
assert_eq!(resolved.sensitivity(), crate::ConfigSensitivity::Secret);
|
||||||
|
let provenance = resolved.provenance_at("/transport/url");
|
||||||
|
assert!(provenance.is_some(), "JSON pointer provenance should exist");
|
||||||
|
if let std::option::Option::Some(provenance) = provenance {
|
||||||
|
assert_eq!(provenance.last().and_then(crate::ConfigValueProvenance::variable_name), std::option::Option::Some("KSP_SECRET_TOKEN"));
|
||||||
|
}
|
||||||
|
let debug = format!("{resolved:?}");
|
||||||
|
assert!(!debug.contains(secret), "resolved JSON Debug must not reveal a secret canary");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn detailed_profile_environment_keeps_global_origin_and_adds_environment_provenance() {
|
||||||
|
let workspace = workspace_root();
|
||||||
|
let bootstrap = crate::ConfigBootstrapOptions::from_paths(workspace.join("config"), workspace.join("config/schemas"));
|
||||||
|
let bootstrap = match bootstrap {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
let registry = crate::ConfigFileRegistry::defaults();
|
||||||
|
let registry = match registry {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
let file_id = crate::ConfigFileId::new(crate::FILE_ID_STD_LOGGING);
|
||||||
|
let file_id = match file_id {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
let engine = crate::ConfigDocumentEngine::new(bootstrap, registry);
|
||||||
|
let profile = engine.load_resolved_profile(&file_id, std::option::Option::None);
|
||||||
|
let profile = match profile {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
let environment = super::ConfigEnvironment::from_maps(std::collections::BTreeMap::new(), std::collections::BTreeMap::new());
|
||||||
|
let effective = profile.resolve_effective_environment_detailed(&environment);
|
||||||
|
assert!(effective.is_ok(), "detailed committed Logging profile should resolve");
|
||||||
|
let effective = match effective {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
assert_eq!(profile.origin("logs_directory"), std::option::Option::Some(crate::ConfigValueOrigin::Global));
|
||||||
|
assert_eq!(effective.value()["logs_directory"], serde_json::Value::String("logs".to_owned()));
|
||||||
|
assert_eq!(effective.safe_value()["logs_directory"], serde_json::Value::String("logs".to_owned()));
|
||||||
|
assert_eq!(
|
||||||
|
effective.provenance_at("/logs_directory").and_then(|items| return items.last()).and_then(crate::ConfigValueProvenance::variable_name),
|
||||||
|
std::option::Option::Some("KSP_LOGS_DIRECTORY"),
|
||||||
|
);
|
||||||
|
}
|
||||||
329
crates/ksp-config-lib/unit_tests/logging.rs
Normal file
329
crates/ksp-config-lib/unit_tests/logging.rs
Normal file
@@ -0,0 +1,329 @@
|
|||||||
|
// file: crates/ksp-config-lib/unit_tests/logging.rs
|
||||||
|
// version: 1
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn committed_logging_profile_maps_complete_runtime_contract() {
|
||||||
|
let engine = committed_engine();
|
||||||
|
let engine = match engine {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
let environment = crate::ConfigEnvironment::from_maps(std::collections::BTreeMap::new(), std::collections::BTreeMap::new());
|
||||||
|
let resolved = engine.load_resolved_logging_config(std::option::Option::None, &environment);
|
||||||
|
assert!(resolved.is_ok(), "committed Logging Config should map");
|
||||||
|
let resolved = match resolved {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
assert_eq!(resolved.file_id().as_str(), crate::FILE_ID_STD_LOGGING);
|
||||||
|
assert_eq!(resolved.profile_id(), "local_dev");
|
||||||
|
assert_eq!(resolved.selection_source(), crate::ConfigProfileSelectionSource::DefaultProfile);
|
||||||
|
assert_eq!(resolved.logs_directory(), current_directory().join("logs").as_path());
|
||||||
|
assert_eq!(resolved.effective().value()["logs_directory"], serde_json::Value::String("logs".to_owned()));
|
||||||
|
let settings = resolved.settings();
|
||||||
|
assert_eq!(settings.default_filter(), ksp_logging_lib::LogFilterLevel::Warn);
|
||||||
|
assert_eq!(settings.span_events(), ksp_logging_lib::SpanEvents::NewAndClose);
|
||||||
|
assert_eq!(settings.target_filters().len(), 2);
|
||||||
|
assert_eq!(settings.target_filters()[0].target_prefix(), "ksp-config-lib");
|
||||||
|
assert_eq!(settings.target_filters()[0].level(), ksp_logging_lib::LogFilterLevel::Trace);
|
||||||
|
assert_eq!(settings.target_filters()[1].target_prefix(), "ksp-logging-lib");
|
||||||
|
assert_eq!(settings.target_filters()[1].level(), ksp_logging_lib::LogFilterLevel::Debug);
|
||||||
|
let console = settings.console();
|
||||||
|
assert!(console.is_some(), "committed Logging Config declares console settings");
|
||||||
|
let console = match console {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => return,
|
||||||
|
};
|
||||||
|
assert!(console.enabled());
|
||||||
|
assert_eq!(console.output(), ksp_logging_lib::ConsoleOutput::Stderr);
|
||||||
|
assert!(console.ansi());
|
||||||
|
assert_eq!(console.format(), ksp_logging_lib::LogFormat::Compact);
|
||||||
|
assert_eq!(console.filter().level(), ksp_logging_lib::LogFilterLevel::Debug);
|
||||||
|
assert_eq!(console.filter().targets(), &["*".to_owned()]);
|
||||||
|
assert_eq!(console.filter().domains(), &["*".to_owned()]);
|
||||||
|
assert_eq!(settings.files().len(), 2);
|
||||||
|
assert_file(
|
||||||
|
&settings.files()[0],
|
||||||
|
"file.all.debug",
|
||||||
|
current_directory().join("logs/debug").as_path(),
|
||||||
|
"ksp-debug.log",
|
||||||
|
ksp_logging_lib::FileRotation::Daily,
|
||||||
|
ksp_logging_lib::LogFormat::Human,
|
||||||
|
ksp_logging_lib::LogFilterLevel::Debug,
|
||||||
|
&["*"],
|
||||||
|
&["*"],
|
||||||
|
);
|
||||||
|
assert_file(
|
||||||
|
&settings.files()[1],
|
||||||
|
"file.config.error",
|
||||||
|
current_directory().join("logs/config").as_path(),
|
||||||
|
"ksp-config-errors.jsonl",
|
||||||
|
ksp_logging_lib::FileRotation::Daily,
|
||||||
|
ksp_logging_lib::LogFormat::Json,
|
||||||
|
ksp_logging_lib::LogFilterLevel::Error,
|
||||||
|
&["ksp-config-lib"],
|
||||||
|
&["config"],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn relative_logs_directory_is_anchored_to_process_current_directory() {
|
||||||
|
let engine = committed_engine();
|
||||||
|
let engine = match engine {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
let environment = environment_with_logs_directory("relative-ksp-logs");
|
||||||
|
let resolved = engine.load_resolved_logging_config(std::option::Option::None, &environment);
|
||||||
|
assert!(resolved.is_ok(), "relative Logging root should map");
|
||||||
|
if let std::result::Result::Ok(resolved) = resolved {
|
||||||
|
assert_eq!(resolved.logs_directory(), current_directory().join("relative-ksp-logs").as_path());
|
||||||
|
assert_eq!(resolved.settings().files()[0].directory(), current_directory().join("relative-ksp-logs/debug").as_path());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn absolute_logs_directory_is_preserved() {
|
||||||
|
let engine = committed_engine();
|
||||||
|
let engine = match engine {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
let absolute = std::env::temp_dir().join(format!("ksp-pre012-absolute-{}", std::process::id()));
|
||||||
|
let environment = environment_with_logs_directory(absolute.to_string_lossy().as_ref());
|
||||||
|
let resolved = engine.load_resolved_logging_config(std::option::Option::None, &environment);
|
||||||
|
assert!(resolved.is_ok(), "absolute Logging root should map even when it does not exist yet");
|
||||||
|
if let std::result::Result::Ok(resolved) = resolved {
|
||||||
|
assert_eq!(resolved.logs_directory(), absolute.as_path());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn effective_file_paths_cannot_escape_logging_root() {
|
||||||
|
assert!(super::relative_file_path_is_valid("debug/ksp.log"));
|
||||||
|
assert!(super::relative_file_path_is_valid("ksp.log"));
|
||||||
|
assert!(!super::relative_file_path_is_valid("../ksp.log"));
|
||||||
|
assert!(!super::relative_file_path_is_valid("./ksp.log"));
|
||||||
|
assert!(!super::relative_file_path_is_valid("/var/log/ksp.log"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn explicit_empty_logs_directory_is_invalid_instead_of_using_fallback() {
|
||||||
|
let engine = committed_engine();
|
||||||
|
let engine = match engine {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
let environment = environment_with_logs_directory("");
|
||||||
|
let resolved = engine.load_resolved_logging_config(std::option::Option::None, &environment);
|
||||||
|
let error = match resolved {
|
||||||
|
std::result::Result::Ok(_) => return,
|
||||||
|
std::result::Result::Err(error) => error,
|
||||||
|
};
|
||||||
|
assert_eq!(error.code(), crate::ERROR_CODE_EFFECTIVE_CONFIG_INVALID);
|
||||||
|
assert!(error.context().iter().any(|item| -> bool {
|
||||||
|
return item.key() == "field" && item.value() == "logs_directory";
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn existing_non_directory_logging_root_is_rejected_without_secret_leak() {
|
||||||
|
let engine = committed_engine();
|
||||||
|
let engine = match engine {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
let secret = format!("ksp-secret-path-canary-{}", std::process::id());
|
||||||
|
let path = std::env::temp_dir().join(secret.as_str());
|
||||||
|
let write = std::fs::write(path.as_path(), b"not a directory");
|
||||||
|
assert!(write.is_ok(), "secret canary file should be created");
|
||||||
|
let profile = load_committed_profile(&engine);
|
||||||
|
let profile = match profile {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => {
|
||||||
|
cleanup_file(path.as_path());
|
||||||
|
return;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let resolved = super::resolve_logs_directory(path.to_string_lossy().as_ref(), crate::REDACTED_CONFIG_VALUE, &profile);
|
||||||
|
cleanup_file(path.as_path());
|
||||||
|
let error = match resolved {
|
||||||
|
std::result::Result::Ok(_) => return,
|
||||||
|
std::result::Result::Err(error) => error,
|
||||||
|
};
|
||||||
|
assert_eq!(error.code(), crate::ERROR_CODE_EFFECTIVE_CONFIG_INVALID);
|
||||||
|
let debug = format!("{error:?}");
|
||||||
|
assert!(!debug.contains(secret.as_str()), "effective Config diagnostics must not reveal real secret-derived paths");
|
||||||
|
assert!(debug.contains(crate::REDACTED_CONFIG_VALUE));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn logging_adapter_rejects_secret_effective_values_without_exposing_canary() {
|
||||||
|
let engine = committed_engine();
|
||||||
|
let engine = match engine {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
let profile = load_committed_profile(&engine);
|
||||||
|
let profile = match profile {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
let canary = "ksp-pre012-secret-canary-c3e4";
|
||||||
|
let mut process = std::collections::BTreeMap::<String, String>::new();
|
||||||
|
process.insert("KSP_SECRET_LOGGING_CANARY".to_owned(), canary.to_owned());
|
||||||
|
let environment = crate::ConfigEnvironment::from_maps(process, std::collections::BTreeMap::new());
|
||||||
|
let effective = environment.resolve_json_detailed(&serde_json::json!({"canary": "${KSP_SECRET_LOGGING_CANARY}"}));
|
||||||
|
assert!(effective.is_ok(), "secret canary fixture should resolve");
|
||||||
|
let effective = match effective {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
let validation = super::validate_logging_sensitivity(&profile, &effective);
|
||||||
|
let error = match validation {
|
||||||
|
std::result::Result::Ok(()) => return,
|
||||||
|
std::result::Result::Err(error) => error,
|
||||||
|
};
|
||||||
|
assert_eq!(error.code(), crate::ERROR_CODE_EFFECTIVE_CONFIG_INVALID);
|
||||||
|
let debug = format!("{error:?}");
|
||||||
|
assert!(!debug.contains(canary), "Logging adapter diagnostics must not reveal secret canaries");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mapped_logging_settings_can_initialize_and_reinitialize_runtime() {
|
||||||
|
let engine = committed_engine();
|
||||||
|
let engine = match engine {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
let root = std::env::temp_dir().join(format!("ksp-pre012-runtime-{}", std::process::id()));
|
||||||
|
cleanup_directory(root.as_path());
|
||||||
|
let environment = environment_with_logs_directory(root.to_string_lossy().as_ref());
|
||||||
|
let resolved = engine.load_resolved_logging_config(std::option::Option::None, &environment);
|
||||||
|
assert!(resolved.is_ok(), "runtime Logging Config should map");
|
||||||
|
let resolved = match resolved {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
let guard = ksp_logging_lib::initialize(resolved.settings());
|
||||||
|
assert!(guard.is_ok(), "mapped Logging settings should initialize the Logging runtime");
|
||||||
|
let mut guard = match guard {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
assert!(root.join("debug").is_dir(), "Logging initialization should create the first configured file directory");
|
||||||
|
assert!(root.join("config").is_dir(), "Logging initialization should create the second configured file directory");
|
||||||
|
let reload = ksp_logging_lib::reinitialize(&mut guard, resolved.settings());
|
||||||
|
assert!(reload.is_ok(), "mapped Logging settings should support hot reload");
|
||||||
|
let disabled = ksp_logging_lib::LoggingSettings::new(
|
||||||
|
ksp_logging_lib::LogFilterLevel::Off,
|
||||||
|
ksp_logging_lib::SpanEvents::Off,
|
||||||
|
std::option::Option::None,
|
||||||
|
std::vec::Vec::new(),
|
||||||
|
);
|
||||||
|
let disable = ksp_logging_lib::reinitialize(&mut guard, &disabled);
|
||||||
|
assert!(disable.is_ok(), "test Logging runtime should disable outputs before cleanup");
|
||||||
|
drop(guard);
|
||||||
|
cleanup_directory(root.as_path());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolved_logging_debug_uses_safe_effective_view() {
|
||||||
|
let engine = committed_engine();
|
||||||
|
let engine = match engine {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
let root = std::env::temp_dir().join(format!("ksp-pre012-debug-{}", std::process::id()));
|
||||||
|
let environment = environment_with_logs_directory(root.to_string_lossy().as_ref());
|
||||||
|
let resolved = engine.load_resolved_logging_config(std::option::Option::None, &environment);
|
||||||
|
assert!(resolved.is_ok(), "Logging Config should map for Debug contract");
|
||||||
|
if let std::result::Result::Ok(resolved) = resolved {
|
||||||
|
let debug = format!("{resolved:?}");
|
||||||
|
assert!(debug.contains("ResolvedLoggingConfig"));
|
||||||
|
assert!(debug.contains("effective"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn assert_file(
|
||||||
|
file: &ksp_logging_lib::FileSettings,
|
||||||
|
output_id: &str,
|
||||||
|
directory: &std::path::Path,
|
||||||
|
file_name: &str,
|
||||||
|
rotation: ksp_logging_lib::FileRotation,
|
||||||
|
format: ksp_logging_lib::LogFormat,
|
||||||
|
level: ksp_logging_lib::LogFilterLevel,
|
||||||
|
targets: &[&str],
|
||||||
|
domains: &[&str],
|
||||||
|
) {
|
||||||
|
assert_eq!(file.output_id(), output_id);
|
||||||
|
assert!(file.enabled());
|
||||||
|
assert_eq!(file.directory(), directory);
|
||||||
|
assert_eq!(file.file_name_prefix(), file_name);
|
||||||
|
assert_eq!(file.rotation(), rotation);
|
||||||
|
assert_eq!(file.format(), format);
|
||||||
|
assert!(!file.ansi());
|
||||||
|
assert_eq!(file.filter().level(), level);
|
||||||
|
assert_eq!(file.filter().targets().iter().map(String::as_str).collect::<std::vec::Vec<&str>>(), targets.to_vec());
|
||||||
|
assert_eq!(file.filter().domains().iter().map(String::as_str).collect::<std::vec::Vec<&str>>(), domains.to_vec());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn committed_engine() -> ksp_core_lib::Result<crate::ConfigDocumentEngine> {
|
||||||
|
let workspace = workspace_root();
|
||||||
|
let bootstrap = crate::ConfigBootstrapOptions::from_paths(workspace.join("config"), workspace.join("config/schemas"));
|
||||||
|
let bootstrap = match bootstrap {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let registry = crate::ConfigFileRegistry::defaults();
|
||||||
|
let registry = match registry {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
return std::result::Result::Ok(crate::ConfigDocumentEngine::new(bootstrap, registry));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_committed_profile(engine: &crate::ConfigDocumentEngine) -> ksp_core_lib::Result<crate::ResolvedConfigProfile> {
|
||||||
|
let file_id = crate::ConfigFileId::new(crate::FILE_ID_STD_LOGGING);
|
||||||
|
let file_id = match file_id {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
return engine.load_resolved_profile(&file_id, std::option::Option::None);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn environment_with_logs_directory(value: &str) -> crate::ConfigEnvironment {
|
||||||
|
let mut process = std::collections::BTreeMap::<String, String>::new();
|
||||||
|
process.insert("KSP_LOGS_DIRECTORY".to_owned(), value.to_owned());
|
||||||
|
return crate::ConfigEnvironment::from_maps(process, std::collections::BTreeMap::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn workspace_root() -> std::path::PathBuf {
|
||||||
|
return std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn current_directory() -> std::path::PathBuf {
|
||||||
|
let current = std::env::current_dir();
|
||||||
|
return match current {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => std::path::PathBuf::new(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cleanup_file(path: &std::path::Path) {
|
||||||
|
let removal = std::fs::remove_file(path);
|
||||||
|
if let std::result::Result::Err(error) = removal
|
||||||
|
&& error.kind() != std::io::ErrorKind::NotFound
|
||||||
|
{
|
||||||
|
eprintln!("unable to cleanup Config Logging adapter file {}: {error}", path.display());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cleanup_directory(path: &std::path::Path) {
|
||||||
|
let removal = std::fs::remove_dir_all(path);
|
||||||
|
if let std::result::Result::Err(error) = removal
|
||||||
|
&& error.kind() != std::io::ErrorKind::NotFound
|
||||||
|
{
|
||||||
|
eprintln!("unable to cleanup Config Logging adapter directory {}: {error}", path.display());
|
||||||
|
}
|
||||||
|
}
|
||||||
357
crates/ksp-config-lib/unit_tests/management.rs
Normal file
357
crates/ksp-config-lib/unit_tests/management.rs
Normal file
@@ -0,0 +1,357 @@
|
|||||||
|
// file: crates/ksp-config-lib/unit_tests/management.rs
|
||||||
|
// version: 1
|
||||||
|
|
||||||
|
static NEXT_FIXTURE_ID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
|
||||||
|
|
||||||
|
struct ManagementFixture {
|
||||||
|
root: std::path::PathBuf,
|
||||||
|
config_path: std::path::PathBuf,
|
||||||
|
dotenv_path: std::path::PathBuf,
|
||||||
|
management: crate::ConfigManagement,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn raw_management_read_remains_available_for_schema_invalid_source() {
|
||||||
|
let fixture = management_fixture();
|
||||||
|
let fixture = match fixture {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
let invalid = "{\n \"format_version\": 1\n}\n";
|
||||||
|
let write = std::fs::write(fixture.config_path.as_path(), invalid.as_bytes());
|
||||||
|
assert!(write.is_ok(), "schema-invalid management fixture should be written");
|
||||||
|
let file_id = crate::ConfigFileId::new(crate::FILE_ID_STD_LOGGING);
|
||||||
|
let file_id = match file_id {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => {
|
||||||
|
cleanup_fixture(&fixture);
|
||||||
|
return;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let raw = fixture.management.read_source(&file_id);
|
||||||
|
assert!(raw.is_ok(), "raw management read should not require schema validity: {raw:?}");
|
||||||
|
if let std::result::Result::Ok(raw) = raw {
|
||||||
|
assert_eq!(raw.content(), invalid);
|
||||||
|
assert_eq!(raw.file_id(), &file_id);
|
||||||
|
let debug = format!("{raw:?}");
|
||||||
|
assert!(!debug.contains("format_version"), "raw source content must not be exposed by Debug");
|
||||||
|
}
|
||||||
|
let typed = fixture.management.load_logging_document();
|
||||||
|
assert!(typed.is_err(), "typed management load must still require a valid source document");
|
||||||
|
cleanup_fixture(&fixture);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn typed_logging_document_can_be_mutated_validated_and_persisted_atomically() {
|
||||||
|
let fixture = management_fixture();
|
||||||
|
let fixture = match fixture {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
let document = fixture.management.load_logging_document();
|
||||||
|
assert!(document.is_ok(), "committed Logging document should load through management: {document:?}");
|
||||||
|
let mut document = match document {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => {
|
||||||
|
cleanup_fixture(&fixture);
|
||||||
|
return;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
assert_eq!(document.format_version(), 1);
|
||||||
|
assert_eq!(document.default_profile(), "local_dev");
|
||||||
|
assert_eq!(document.profiles().len(), 1);
|
||||||
|
document.set_logs_directory("managed-logs");
|
||||||
|
if let std::option::Option::Some(profile) = document.profiles_mut().first_mut() {
|
||||||
|
profile.set_default_filter("info");
|
||||||
|
}
|
||||||
|
let report = fixture.management.save_logging_document(&document);
|
||||||
|
assert!(report.is_ok(), "valid typed Logging mutation should persist: {report:?}");
|
||||||
|
if let std::result::Result::Ok(report) = report {
|
||||||
|
assert!(report.source_changed());
|
||||||
|
assert!(report.reload_required());
|
||||||
|
}
|
||||||
|
let persisted = std::fs::read_to_string(fixture.config_path.as_path());
|
||||||
|
assert!(persisted.is_ok(), "persisted Logging document should remain readable");
|
||||||
|
if let std::result::Result::Ok(persisted) = persisted {
|
||||||
|
assert!(persisted.ends_with('\n'));
|
||||||
|
assert!(persisted.contains("\"logs_directory\": \"managed-logs\""));
|
||||||
|
assert!(persisted.contains("\"default_filter\": \"info\""));
|
||||||
|
}
|
||||||
|
let reloaded = fixture.management.load_logging_document();
|
||||||
|
assert!(reloaded.is_ok(), "persisted Logging document should remain valid: {reloaded:?}");
|
||||||
|
if let std::result::Result::Ok(reloaded) = reloaded {
|
||||||
|
assert_eq!(reloaded.logs_directory(), "managed-logs");
|
||||||
|
assert_eq!(reloaded.profiles()[0].default_filter(), "info");
|
||||||
|
}
|
||||||
|
cleanup_fixture(&fixture);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn invalid_logging_candidate_does_not_modify_existing_file() {
|
||||||
|
let fixture = management_fixture();
|
||||||
|
let fixture = match fixture {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
let before = std::fs::read(fixture.config_path.as_path());
|
||||||
|
let before = match before {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => {
|
||||||
|
cleanup_fixture(&fixture);
|
||||||
|
return;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let document = fixture.management.load_logging_document();
|
||||||
|
let mut document = match document {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => {
|
||||||
|
cleanup_fixture(&fixture);
|
||||||
|
return;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
document.set_logs_directory("");
|
||||||
|
let save = fixture.management.save_logging_document(&document);
|
||||||
|
assert!(save.is_err(), "schema-invalid candidate must be rejected before persistence");
|
||||||
|
let after = std::fs::read(fixture.config_path.as_path());
|
||||||
|
assert_eq!(after.ok(), std::option::Option::Some(before));
|
||||||
|
cleanup_fixture(&fixture);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unchanged_logging_candidate_reports_no_reload() {
|
||||||
|
let fixture = management_fixture();
|
||||||
|
let fixture = match fixture {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
let document = fixture.management.load_logging_document();
|
||||||
|
let document = match document {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => {
|
||||||
|
cleanup_fixture(&fixture);
|
||||||
|
return;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let first = fixture.management.save_logging_document(&document);
|
||||||
|
assert!(first.is_ok(), "normalization save should succeed: {first:?}");
|
||||||
|
let second = fixture.management.save_logging_document(&document);
|
||||||
|
assert!(second.is_ok(), "second identical save should succeed: {second:?}");
|
||||||
|
if let std::result::Result::Ok(second) = second {
|
||||||
|
assert!(!second.source_changed());
|
||||||
|
assert!(!second.reload_required());
|
||||||
|
}
|
||||||
|
cleanup_fixture(&fixture);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dotenv_create_update_and_remove_round_trip_through_management() {
|
||||||
|
let fixture = management_fixture();
|
||||||
|
let fixture = match fixture {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
let create = fixture.management.set_dotenv_value("KSP_PRE013_MANAGED_TEST_VALUE", "alpha");
|
||||||
|
assert!(create.is_ok(), "managed .env create should succeed: {create:?}");
|
||||||
|
if let std::result::Result::Ok(create) = create {
|
||||||
|
assert!(create.source_changed());
|
||||||
|
assert!(create.effective_changed());
|
||||||
|
assert!(!create.shadowed_by_process_environment());
|
||||||
|
assert!(create.reload_required());
|
||||||
|
}
|
||||||
|
let update = fixture.management.set_dotenv_value("KSP_PRE013_MANAGED_TEST_VALUE", "hello world # 2");
|
||||||
|
assert!(update.is_ok(), "managed .env update should support quoting: {update:?}");
|
||||||
|
let revealed = fixture.management.reveal_dotenv_value("KSP_PRE013_MANAGED_TEST_VALUE");
|
||||||
|
assert_eq!(revealed.ok(), std::option::Option::Some(std::option::Option::Some("hello world # 2".to_owned())));
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
let metadata = std::fs::metadata(fixture.dotenv_path.as_path());
|
||||||
|
assert!(metadata.is_ok(), "new managed .env permissions should be inspectable");
|
||||||
|
if let std::result::Result::Ok(metadata) = metadata {
|
||||||
|
let mode = <std::fs::Permissions as std::os::unix::fs::PermissionsExt>::mode(&metadata.permissions());
|
||||||
|
assert_eq!(mode & 0o777, 0o600, "new .env must be private on Unix");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let content = std::fs::read_to_string(fixture.dotenv_path.as_path());
|
||||||
|
assert!(content.is_ok(), "managed .env should be readable");
|
||||||
|
if let std::result::Result::Ok(content) = content {
|
||||||
|
assert!(content.contains("KSP_PRE013_MANAGED_TEST_VALUE=\"hello world # 2\""));
|
||||||
|
}
|
||||||
|
let remove = fixture.management.remove_dotenv_value("KSP_PRE013_MANAGED_TEST_VALUE");
|
||||||
|
assert!(remove.is_ok(), "managed .env remove should succeed: {remove:?}");
|
||||||
|
if let std::result::Result::Ok(remove) = remove {
|
||||||
|
assert!(remove.source_changed());
|
||||||
|
assert!(remove.effective_changed());
|
||||||
|
assert!(remove.reload_required());
|
||||||
|
}
|
||||||
|
assert_eq!(fixture.management.reveal_dotenv_value("KSP_PRE013_MANAGED_TEST_VALUE").ok(), std::option::Option::Some(std::option::Option::None));
|
||||||
|
cleanup_fixture(&fixture);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dotenv_comments_and_unrelated_entries_survive_targeted_mutation() {
|
||||||
|
let fixture = management_fixture();
|
||||||
|
let fixture = match fixture {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
let source = "# local header\nEXTERNAL_VALUE=keep\n# managed comment\nKSP_PRE013_MANAGED_TEST_VALUE=before\n";
|
||||||
|
let write = std::fs::write(fixture.dotenv_path.as_path(), source.as_bytes());
|
||||||
|
assert!(write.is_ok(), "dotenv preservation fixture should be written");
|
||||||
|
let update = fixture.management.set_dotenv_value("KSP_PRE013_MANAGED_TEST_VALUE", "after");
|
||||||
|
assert!(update.is_ok(), "targeted .env update should succeed: {update:?}");
|
||||||
|
let content = std::fs::read_to_string(fixture.dotenv_path.as_path());
|
||||||
|
assert!(content.is_ok(), "updated .env should remain readable");
|
||||||
|
if let std::result::Result::Ok(content) = content {
|
||||||
|
assert!(content.contains("# local header"));
|
||||||
|
assert!(content.contains("EXTERNAL_VALUE=keep"));
|
||||||
|
assert!(content.contains("# managed comment"));
|
||||||
|
assert!(content.contains("KSP_PRE013_MANAGED_TEST_VALUE=after"));
|
||||||
|
}
|
||||||
|
cleanup_fixture(&fixture);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn invalid_existing_dotenv_is_not_modified_by_management() {
|
||||||
|
let fixture = management_fixture();
|
||||||
|
let fixture = match fixture {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
let invalid = "KSP_BROKEN\n";
|
||||||
|
let write = std::fs::write(fixture.dotenv_path.as_path(), invalid.as_bytes());
|
||||||
|
assert!(write.is_ok(), "invalid .env fixture should be written");
|
||||||
|
let update = fixture.management.set_dotenv_value("KSP_PRE013_MANAGED_TEST_VALUE", "value");
|
||||||
|
assert!(update.is_err(), "management must reject mutation when existing .env syntax is invalid");
|
||||||
|
assert_eq!(std::fs::read_to_string(fixture.dotenv_path.as_path()).ok(), std::option::Option::Some(invalid.to_owned()));
|
||||||
|
cleanup_fixture(&fixture);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unsupported_environment_name_is_rejected_without_creating_dotenv() {
|
||||||
|
let fixture = management_fixture();
|
||||||
|
let fixture = match fixture {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
let update = fixture.management.set_dotenv_value("OTHER_TOKEN", "value");
|
||||||
|
assert!(update.is_err(), "non-KSP variable must be rejected");
|
||||||
|
assert!(!fixture.dotenv_path.exists(), "rejected mutation must not create .env");
|
||||||
|
cleanup_fixture(&fixture);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn management_report_redacts_secret_but_explicit_reveal_returns_real_value() {
|
||||||
|
let fixture = management_fixture();
|
||||||
|
let fixture = match fixture {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => return,
|
||||||
|
};
|
||||||
|
let canary = "SECRET-CANARY-PRE013";
|
||||||
|
let write = std::fs::write(fixture.dotenv_path.as_path(), format!("KSP_SECRET_PRE013_MANAGED_TEST_TOKEN={canary}\n"));
|
||||||
|
assert!(write.is_ok(), "secret management fixture should be written");
|
||||||
|
let reports = fixture.management.environment_report();
|
||||||
|
assert!(reports.is_ok(), "safe environment report should load: {reports:?}");
|
||||||
|
if let std::result::Result::Ok(reports) = reports {
|
||||||
|
let report = reports.iter().find(|report| -> bool {
|
||||||
|
return report.variable_name() == "KSP_SECRET_PRE013_MANAGED_TEST_TOKEN";
|
||||||
|
});
|
||||||
|
assert!(report.is_some(), "secret entry should appear in management report");
|
||||||
|
if let std::option::Option::Some(report) = report {
|
||||||
|
assert_eq!(report.sensitivity(), crate::ConfigSensitivity::Secret);
|
||||||
|
assert_eq!(report.desired_safe_value(), std::option::Option::Some(crate::REDACTED_CONFIG_VALUE));
|
||||||
|
assert_eq!(report.effective_safe_value(), std::option::Option::Some(crate::REDACTED_CONFIG_VALUE));
|
||||||
|
assert!(!report.shadowed_by_process_environment());
|
||||||
|
assert!(!format!("{report:?}").contains(canary));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let reveal = fixture.management.reveal_effective_environment_value("KSP_SECRET_PRE013_MANAGED_TEST_TOKEN");
|
||||||
|
assert_eq!(reveal.ok(), std::option::Option::Some(std::option::Option::Some(canary.to_owned())));
|
||||||
|
cleanup_fixture(&fixture);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn process_shadowing_report_distinguishes_desired_and_effective_changes() {
|
||||||
|
let mut process_before = std::collections::BTreeMap::<String, String>::new();
|
||||||
|
process_before.insert("KSP_MODE".to_owned(), "process".to_owned());
|
||||||
|
let mut dotenv_before = std::collections::BTreeMap::<String, String>::new();
|
||||||
|
dotenv_before.insert("KSP_MODE".to_owned(), "desired-a".to_owned());
|
||||||
|
let before = crate::ConfigEnvironment::from_maps(process_before.clone(), dotenv_before);
|
||||||
|
let mut dotenv_after = std::collections::BTreeMap::<String, String>::new();
|
||||||
|
dotenv_after.insert("KSP_MODE".to_owned(), "desired-b".to_owned());
|
||||||
|
let after = crate::ConfigEnvironment::from_maps(process_before, dotenv_after);
|
||||||
|
let report = super::environment_change_report("KSP_MODE", &before, &after);
|
||||||
|
assert!(report.source_changed());
|
||||||
|
assert!(!report.effective_changed());
|
||||||
|
assert!(report.shadowed_by_process_environment());
|
||||||
|
assert!(!report.reload_required());
|
||||||
|
let reports = super::build_environment_reports(&after);
|
||||||
|
assert!(reports.is_ok(), "shadow report fixture should build: {reports:?}");
|
||||||
|
if let std::result::Result::Ok(reports) = reports {
|
||||||
|
assert_eq!(reports.len(), 1);
|
||||||
|
assert_eq!(reports[0].desired_safe_value(), std::option::Option::Some("desired-b"));
|
||||||
|
assert_eq!(reports[0].effective_safe_value(), std::option::Option::Some("process"));
|
||||||
|
assert_eq!(reports[0].effective_source(), std::option::Option::Some(crate::ConfigEnvironmentSource::Process));
|
||||||
|
assert!(reports[0].shadowed_by_process_environment());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn management_fixture() -> ksp_core_lib::Result<ManagementFixture> {
|
||||||
|
let fixture_id = NEXT_FIXTURE_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||||
|
let root = std::env::temp_dir().join(format!("ksp-pre013-management-{}-{fixture_id}", std::process::id()));
|
||||||
|
let cleanup = std::fs::remove_dir_all(root.as_path());
|
||||||
|
if let std::result::Result::Err(error) = cleanup
|
||||||
|
&& error.kind() != std::io::ErrorKind::NotFound
|
||||||
|
{
|
||||||
|
return std::result::Result::Err(
|
||||||
|
ksp_core_lib::Error::new(crate::ERROR_CODE_PERSISTENCE_WRITE_FAILED, "unable to cleanup previous management fixture").with_source(error),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let config_root = root.join("config");
|
||||||
|
let schema_root = config_root.join("schemas");
|
||||||
|
let create = std::fs::create_dir_all(schema_root.as_path());
|
||||||
|
if let std::result::Result::Err(error) = create {
|
||||||
|
return std::result::Result::Err(
|
||||||
|
ksp_core_lib::Error::new(crate::ERROR_CODE_PERSISTENCE_WRITE_FAILED, "unable to create management fixture").with_source(error),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let workspace = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..");
|
||||||
|
let source_config = workspace.join("config/std.logging.json");
|
||||||
|
let source_schema = workspace.join("config/schemas/std.logging.schema.json");
|
||||||
|
let config_path = config_root.join("std.logging.json");
|
||||||
|
let schema_path = schema_root.join("std.logging.schema.json");
|
||||||
|
let copy_config = std::fs::copy(source_config.as_path(), config_path.as_path());
|
||||||
|
if let std::result::Result::Err(error) = copy_config {
|
||||||
|
return std::result::Result::Err(
|
||||||
|
ksp_core_lib::Error::new(crate::ERROR_CODE_PERSISTENCE_WRITE_FAILED, "unable to copy management Config fixture").with_source(error),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let copy_schema = std::fs::copy(source_schema.as_path(), schema_path.as_path());
|
||||||
|
if let std::result::Result::Err(error) = copy_schema {
|
||||||
|
return std::result::Result::Err(
|
||||||
|
ksp_core_lib::Error::new(crate::ERROR_CODE_PERSISTENCE_WRITE_FAILED, "unable to copy management schema fixture").with_source(error),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let bootstrap = crate::ConfigBootstrapOptions::from_paths(config_root.as_path(), schema_root.as_path());
|
||||||
|
let bootstrap = match bootstrap {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let registry = crate::ConfigFileRegistry::defaults();
|
||||||
|
let registry = match registry {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let engine = crate::ConfigDocumentEngine::new(bootstrap, registry);
|
||||||
|
let dotenv_path = root.join(".env");
|
||||||
|
let management = crate::ConfigManagement::with_dotenv_path(engine, dotenv_path.clone());
|
||||||
|
return std::result::Result::Ok(ManagementFixture { root, config_path, dotenv_path, management });
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cleanup_fixture(fixture: &ManagementFixture) {
|
||||||
|
let cleanup = std::fs::remove_dir_all(fixture.root.as_path());
|
||||||
|
if let std::result::Result::Err(error) = cleanup
|
||||||
|
&& error.kind() != std::io::ErrorKind::NotFound
|
||||||
|
{
|
||||||
|
eprintln!("unable to cleanup Config management fixture {}: {error}", fixture.root.display());
|
||||||
|
}
|
||||||
|
}
|
||||||
70
crates/ksp-config-lib/unit_tests/profile.rs
Normal file
70
crates/ksp-config-lib/unit_tests/profile.rs
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
// file: crates/ksp-config-lib/unit_tests/profile.rs
|
||||||
|
// version: 1
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn committed_default_profile_resolves_globals_profile_and_provenance() {
|
||||||
|
let engine = committed_engine();
|
||||||
|
let file_id = crate::ConfigFileId::new(crate::FILE_ID_STD_LOGGING);
|
||||||
|
assert!(engine.is_ok(), "committed Config engine should be constructible: {engine:?}");
|
||||||
|
assert!(file_id.is_ok(), "logging file_id should be valid: {file_id:?}");
|
||||||
|
if let (std::result::Result::Ok(engine), std::result::Result::Ok(file_id)) = (engine, file_id) {
|
||||||
|
let resolved = engine.load_resolved_profile(&file_id, std::option::Option::None);
|
||||||
|
assert!(resolved.is_ok(), "default profile should resolve: {resolved:?}");
|
||||||
|
if let std::result::Result::Ok(resolved) = resolved {
|
||||||
|
assert_eq!(resolved.profile_id(), "local_dev");
|
||||||
|
assert_eq!(resolved.selection_source(), super::ConfigProfileSelectionSource::DefaultProfile);
|
||||||
|
assert_eq!(resolved.globals().get("logs_directory").and_then(serde_json::Value::as_str), std::option::Option::Some("${KSP_LOGS_DIRECTORY:-logs}"));
|
||||||
|
assert_eq!(resolved.profile().get("default_filter").and_then(serde_json::Value::as_str), std::option::Option::Some("warn"));
|
||||||
|
assert_eq!(resolved.effective().get("default_filter").and_then(serde_json::Value::as_str), std::option::Option::Some("warn"));
|
||||||
|
assert_eq!(resolved.origin("logs_directory"), std::option::Option::Some(super::ConfigValueOrigin::Global));
|
||||||
|
assert_eq!(resolved.origin("default_filter"), std::option::Option::Some(super::ConfigValueOrigin::Profile));
|
||||||
|
assert!(!resolved.effective().contains_key("default_profile"));
|
||||||
|
assert!(!resolved.effective().contains_key("profiles"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn explicit_profile_selection_is_distinct_from_default_selection() {
|
||||||
|
let engine = committed_engine();
|
||||||
|
let file_id = crate::ConfigFileId::new(crate::FILE_ID_STD_LOGGING);
|
||||||
|
assert!(engine.is_ok(), "committed Config engine should be constructible: {engine:?}");
|
||||||
|
assert!(file_id.is_ok(), "logging file_id should be valid: {file_id:?}");
|
||||||
|
if let (std::result::Result::Ok(engine), std::result::Result::Ok(file_id)) = (engine, file_id) {
|
||||||
|
let resolved = engine.load_resolved_profile(&file_id, std::option::Option::Some("local_dev"));
|
||||||
|
assert!(resolved.is_ok(), "explicit committed profile should resolve: {resolved:?}");
|
||||||
|
if let std::result::Result::Ok(resolved) = resolved {
|
||||||
|
assert_eq!(resolved.profile_id(), "local_dev");
|
||||||
|
assert_eq!(resolved.selection_source(), super::ConfigProfileSelectionSource::Explicit);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unknown_explicit_profile_has_distinct_error_code() {
|
||||||
|
let engine = committed_engine();
|
||||||
|
let file_id = crate::ConfigFileId::new(crate::FILE_ID_STD_LOGGING);
|
||||||
|
assert!(engine.is_ok(), "committed Config engine should be constructible: {engine:?}");
|
||||||
|
assert!(file_id.is_ok(), "logging file_id should be valid: {file_id:?}");
|
||||||
|
if let (std::result::Result::Ok(engine), std::result::Result::Ok(file_id)) = (engine, file_id) {
|
||||||
|
let result = engine.load_resolved_profile(&file_id, std::option::Option::Some("does-not-exist"));
|
||||||
|
assert!(result.is_err(), "unknown explicit profile must be rejected: {result:?}");
|
||||||
|
if let std::result::Result::Err(error) = result {
|
||||||
|
assert_eq!(error.code(), crate::ERROR_CODE_PROFILE_NOT_FOUND);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn committed_engine() -> ksp_core_lib::Result<crate::ConfigDocumentEngine> {
|
||||||
|
let workspace = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..");
|
||||||
|
let bootstrap = crate::ConfigBootstrapOptions::from_paths(workspace.join("config"), workspace.join("config/schemas"));
|
||||||
|
let bootstrap = match bootstrap {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let registry = crate::ConfigFileRegistry::defaults();
|
||||||
|
return match registry {
|
||||||
|
std::result::Result::Ok(value) => std::result::Result::Ok(crate::ConfigDocumentEngine::new(bootstrap, value)),
|
||||||
|
std::result::Result::Err(error) => std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
}
|
||||||
220
crates/ksp-config-lib/unit_tests/registry.rs
Normal file
220
crates/ksp-config-lib/unit_tests/registry.rs
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
// file: crates/ksp-config-lib/unit_tests/registry.rs
|
||||||
|
// version: 3
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn defaults_register_logging_document_and_schema_with_distinct_roots() {
|
||||||
|
let registry = super::ConfigFileRegistry::defaults();
|
||||||
|
assert!(registry.is_ok(), "default registry should be valid: {registry:?}");
|
||||||
|
if let std::result::Result::Ok(registry) = registry {
|
||||||
|
let logging_id = super::ConfigFileId::new(super::FILE_ID_STD_LOGGING);
|
||||||
|
let schema_id = super::ConfigFileId::new(super::FILE_ID_SCHEMA_STD_LOGGING);
|
||||||
|
assert!(logging_id.is_ok(), "logging file_id should be valid: {logging_id:?}");
|
||||||
|
assert!(schema_id.is_ok(), "logging schema file_id should be valid: {schema_id:?}");
|
||||||
|
if let (std::result::Result::Ok(logging_id), std::result::Result::Ok(schema_id)) = (logging_id, schema_id) {
|
||||||
|
let logging = registry.descriptor(&logging_id);
|
||||||
|
let schema = registry.descriptor(&schema_id);
|
||||||
|
assert!(logging.is_ok(), "logging descriptor should exist: {logging:?}");
|
||||||
|
assert!(schema.is_ok(), "logging schema descriptor should exist: {schema:?}");
|
||||||
|
if let (std::result::Result::Ok(logging), std::result::Result::Ok(schema)) = (logging, schema) {
|
||||||
|
assert_eq!(logging.kind(), super::ConfigFileKind::Config);
|
||||||
|
assert_eq!(logging.filename(), std::path::Path::new(super::DEFAULT_STD_LOGGING_FILENAME));
|
||||||
|
let logging_schema = logging.schema_file_id();
|
||||||
|
assert!(logging_schema.is_some(), "logging document should declare its validation schema");
|
||||||
|
if let std::option::Option::Some(logging_schema) = logging_schema {
|
||||||
|
assert_eq!(logging_schema, &schema_id);
|
||||||
|
}
|
||||||
|
assert_eq!(schema.kind(), super::ConfigFileKind::Schema);
|
||||||
|
assert_eq!(schema.filename(), std::path::Path::new(super::DEFAULT_STD_LOGGING_SCHEMA_FILENAME));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_path_uses_descriptor_kind_to_select_bootstrap_root() {
|
||||||
|
let registry = super::ConfigFileRegistry::defaults();
|
||||||
|
let bootstrap = crate::ConfigBootstrapOptions::from_paths("runtime-config", "runtime-schemas");
|
||||||
|
assert!(registry.is_ok(), "default registry should be valid: {registry:?}");
|
||||||
|
assert!(bootstrap.is_ok(), "bootstrap paths should be valid: {bootstrap:?}");
|
||||||
|
if let (std::result::Result::Ok(registry), std::result::Result::Ok(bootstrap)) = (registry, bootstrap) {
|
||||||
|
let logging_id = super::ConfigFileId::new(super::FILE_ID_STD_LOGGING);
|
||||||
|
let schema_id = super::ConfigFileId::new(super::FILE_ID_SCHEMA_STD_LOGGING);
|
||||||
|
if let (std::result::Result::Ok(logging_id), std::result::Result::Ok(schema_id)) = (logging_id, schema_id) {
|
||||||
|
let logging = registry.resolve_path(&bootstrap, &logging_id);
|
||||||
|
let schema = registry.resolve_path(&bootstrap, &schema_id);
|
||||||
|
assert!(logging.is_ok(), "logging path should resolve: {logging:?}");
|
||||||
|
assert!(schema.is_ok(), "schema path should resolve: {schema:?}");
|
||||||
|
if let std::result::Result::Ok(logging) = logging {
|
||||||
|
assert_eq!(logging, std::path::PathBuf::from("runtime-config/std.logging.json"));
|
||||||
|
}
|
||||||
|
if let std::result::Result::Ok(schema) = schema {
|
||||||
|
assert_eq!(schema, std::path::PathBuf::from("runtime-schemas/std.logging.schema.json"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cli_filemap_override_replaces_filename_and_last_value_wins() {
|
||||||
|
let args = [
|
||||||
|
std::ffi::OsString::from("ksp-app"),
|
||||||
|
std::ffi::OsString::from("--filemap=cfg.std.logging=first.logging.json"),
|
||||||
|
std::ffi::OsString::from("--other-option"),
|
||||||
|
std::ffi::OsString::from("--filemap=cfg.std.logging=profiles/custom.logging.json"),
|
||||||
|
];
|
||||||
|
let registry = super::ConfigFileRegistry::from_args(&args);
|
||||||
|
assert!(registry.is_ok(), "filemap overrides should parse: {registry:?}");
|
||||||
|
if let std::result::Result::Ok(registry) = registry {
|
||||||
|
let file_id = super::ConfigFileId::new(super::FILE_ID_STD_LOGGING);
|
||||||
|
if let std::result::Result::Ok(file_id) = file_id {
|
||||||
|
let descriptor = registry.descriptor(&file_id);
|
||||||
|
assert!(descriptor.is_ok(), "logging descriptor should remain registered: {descriptor:?}");
|
||||||
|
if let std::result::Result::Ok(descriptor) = descriptor {
|
||||||
|
assert_eq!(descriptor.filename(), std::path::Path::new("profiles/custom.logging.json"));
|
||||||
|
assert_eq!(descriptor.kind(), super::ConfigFileKind::Config);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn programmatic_override_preserves_file_id_and_kind() {
|
||||||
|
let registry = super::ConfigFileRegistry::defaults();
|
||||||
|
let file_id = super::ConfigFileId::new(super::FILE_ID_SCHEMA_STD_LOGGING);
|
||||||
|
assert!(registry.is_ok(), "default registry should be valid: {registry:?}");
|
||||||
|
assert!(file_id.is_ok(), "schema file_id should be valid: {file_id:?}");
|
||||||
|
if let (std::result::Result::Ok(registry), std::result::Result::Ok(file_id)) = (registry, file_id) {
|
||||||
|
let overridden = registry.with_filename_override(&file_id, "alternate/logging.schema.json");
|
||||||
|
assert!(overridden.is_ok(), "programmatic override should be valid: {overridden:?}");
|
||||||
|
if let std::result::Result::Ok(overridden) = overridden {
|
||||||
|
let descriptor = overridden.descriptor(&file_id);
|
||||||
|
if let std::result::Result::Ok(descriptor) = descriptor {
|
||||||
|
assert_eq!(descriptor.file_id(), &file_id);
|
||||||
|
assert_eq!(descriptor.kind(), super::ConfigFileKind::Schema);
|
||||||
|
assert_eq!(descriptor.filename(), std::path::Path::new("alternate/logging.schema.json"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unknown_file_id_override_is_rejected() {
|
||||||
|
let args = [std::ffi::OsString::from("ksp-app"), std::ffi::OsString::from("--filemap=cfg.unknown=unknown.json")];
|
||||||
|
let result = super::ConfigFileRegistry::from_args(&args);
|
||||||
|
assert!(result.is_err(), "unknown logical files must not be introduced by CLI override");
|
||||||
|
if let std::result::Result::Err(error) = result {
|
||||||
|
assert_eq!(error.code(), crate::ERROR_CODE_FILE_ID_UNKNOWN);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn invalid_file_ids_are_rejected() {
|
||||||
|
for value in ["", ".cfg", "cfg.", "cfg..logging", "CFG.logging", "cfg/logging"] {
|
||||||
|
let result = super::ConfigFileId::new(value);
|
||||||
|
assert!(result.is_err(), "invalid file_id must be rejected: {value}");
|
||||||
|
if let std::result::Result::Err(error) = result {
|
||||||
|
assert_eq!(error.code(), crate::ERROR_CODE_FILE_ID_INVALID);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn absolute_and_traversing_filenames_are_rejected() {
|
||||||
|
let registry = super::ConfigFileRegistry::defaults();
|
||||||
|
let file_id = super::ConfigFileId::new(super::FILE_ID_STD_LOGGING);
|
||||||
|
assert!(registry.is_ok(), "default registry should be valid: {registry:?}");
|
||||||
|
assert!(file_id.is_ok(), "logging file_id should be valid: {file_id:?}");
|
||||||
|
if let (std::result::Result::Ok(registry), std::result::Result::Ok(file_id)) = (registry, file_id) {
|
||||||
|
let traversal = registry.clone().with_filename_override(&file_id, "../outside.json");
|
||||||
|
let current = registry.clone().with_filename_override(&file_id, "./logging.json");
|
||||||
|
let absolute = registry.with_filename_override(&file_id, absolute_fixture_path());
|
||||||
|
assert_mapping_invalid(traversal);
|
||||||
|
assert_mapping_invalid(current);
|
||||||
|
assert_mapping_invalid(absolute);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn malformed_filemap_arguments_are_rejected() {
|
||||||
|
for argument in ["--filemap", "--filemap=cfg.std.logging", "--filemap==logging.json", "--filemap=cfg.std.logging="] {
|
||||||
|
let args = [std::ffi::OsString::from("ksp-app"), std::ffi::OsString::from(argument)];
|
||||||
|
let result = super::ConfigFileRegistry::from_args(&args);
|
||||||
|
assert!(result.is_err(), "malformed filemap argument must be rejected: {argument}");
|
||||||
|
if let std::result::Result::Err(error) = result {
|
||||||
|
assert_eq!(error.code(), crate::ERROR_CODE_FILE_MAPPING_INVALID);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn duplicate_registry_ids_are_rejected() {
|
||||||
|
let first = super::ConfigFileDescriptor::new("cfg.duplicate", super::ConfigFileKind::Config, "first.json", std::option::Option::None);
|
||||||
|
let second = super::ConfigFileDescriptor::new("cfg.duplicate", super::ConfigFileKind::Config, "second.json", std::option::Option::None);
|
||||||
|
assert!(first.is_ok(), "first descriptor should be valid: {first:?}");
|
||||||
|
assert!(second.is_ok(), "second descriptor should be valid: {second:?}");
|
||||||
|
if let (std::result::Result::Ok(first), std::result::Result::Ok(second)) = (first, second) {
|
||||||
|
let result = super::build_registry([first, second]);
|
||||||
|
assert!(result.is_err(), "duplicate file_ids must be rejected");
|
||||||
|
if let std::result::Result::Err(error) = result {
|
||||||
|
assert_eq!(error.code(), crate::ERROR_CODE_FILE_ID_DUPLICATE);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn descriptor_kind_must_match_file_id_namespace() {
|
||||||
|
let result = super::ConfigFileDescriptor::new("schema.invalid-kind", super::ConfigFileKind::Config, "invalid.json", std::option::Option::None);
|
||||||
|
assert!(result.is_err(), "descriptor kind mismatch must be rejected");
|
||||||
|
if let std::result::Result::Err(error) = result {
|
||||||
|
assert_eq!(error.code(), crate::ERROR_CODE_FILE_MAPPING_INVALID);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn config_schema_association_must_reference_registered_schema_descriptor() {
|
||||||
|
let config = super::ConfigFileDescriptor::new("cfg.test", super::ConfigFileKind::Config, "test.json", std::option::Option::Some("schema.test"));
|
||||||
|
assert!(config.is_ok(), "config descriptor should be valid before registry association validation: {config:?}");
|
||||||
|
if let std::result::Result::Ok(config) = config {
|
||||||
|
let result = super::build_registry([config]);
|
||||||
|
assert!(result.is_err(), "registry must reject a missing schema association");
|
||||||
|
if let std::result::Result::Err(error) = result {
|
||||||
|
assert_eq!(error.code(), crate::ERROR_CODE_FILE_MAPPING_INVALID);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn assert_mapping_invalid<T: std::fmt::Debug>(result: ksp_core_lib::Result<T>) {
|
||||||
|
assert!(result.is_err(), "invalid filename must be rejected: {result:?}");
|
||||||
|
if let std::result::Result::Err(error) = result {
|
||||||
|
assert_eq!(error.code(), crate::ERROR_CODE_FILE_MAPPING_INVALID);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn absolute_fixture_path() -> std::path::PathBuf {
|
||||||
|
let mut path = std::env::temp_dir();
|
||||||
|
path.push("ksp-config-lib-absolute-mapping.json");
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn defaults_register_generic_composite_schema_without_runtime_composite() {
|
||||||
|
let registry = super::ConfigFileRegistry::defaults();
|
||||||
|
let schema_id = super::ConfigFileId::new(super::FILE_ID_SCHEMA_COMPOSITE);
|
||||||
|
let runtime_id = super::ConfigFileId::new("cfg.composite.ksp-app-wallet-desk");
|
||||||
|
assert!(registry.is_ok(), "default registry should be valid: {registry:?}");
|
||||||
|
assert!(schema_id.is_ok(), "composite schema file_id should be valid: {schema_id:?}");
|
||||||
|
assert!(runtime_id.is_ok(), "future composite runtime file_id syntax should be valid: {runtime_id:?}");
|
||||||
|
if let (std::result::Result::Ok(registry), std::result::Result::Ok(schema_id), std::result::Result::Ok(runtime_id)) = (registry, schema_id, runtime_id) {
|
||||||
|
let schema = registry.descriptor(&schema_id);
|
||||||
|
let runtime = registry.descriptor(&runtime_id);
|
||||||
|
assert!(schema.is_ok(), "generic composite schema should be registered: {schema:?}");
|
||||||
|
assert!(runtime.is_err(), "no fictitious runtime composite should be registered");
|
||||||
|
if let std::result::Result::Ok(schema) = schema {
|
||||||
|
assert_eq!(schema.kind(), super::ConfigFileKind::Schema);
|
||||||
|
assert_eq!(schema.filename(), std::path::Path::new(super::DEFAULT_COMPOSITE_SCHEMA_FILENAME));
|
||||||
|
}
|
||||||
|
if let std::result::Result::Err(error) = runtime {
|
||||||
|
assert_eq!(error.code(), crate::ERROR_CODE_FILE_ID_UNKNOWN);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
31
crates/ksp-config-lib/unit_tests/sensitivity.rs
Normal file
31
crates/ksp-config-lib/unit_tests/sensitivity.rs
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
// file: crates/ksp-config-lib/unit_tests/sensitivity.rs
|
||||||
|
// version: 1
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn environment_names_map_to_expected_sensitivity() {
|
||||||
|
assert_eq!(super::ConfigSensitivity::from_variable_name("KSP_PUBLIC_ENDPOINT").ok(), std::option::Option::Some(super::ConfigSensitivity::Public));
|
||||||
|
assert_eq!(super::ConfigSensitivity::from_variable_name("KSP_MODE").ok(), std::option::Option::Some(super::ConfigSensitivity::Internal));
|
||||||
|
assert_eq!(super::ConfigSensitivity::from_variable_name("KSP_SECRET_PASSWORD").ok(), std::option::Option::Some(super::ConfigSensitivity::Secret));
|
||||||
|
assert_eq!(super::ConfigSensitivity::from_variable_name("KSPB_PUBLIC_ENDPOINT").ok(), std::option::Option::Some(super::ConfigSensitivity::Public));
|
||||||
|
assert_eq!(super::ConfigSensitivity::from_variable_name("KSPB_MODE").ok(), std::option::Option::Some(super::ConfigSensitivity::Internal));
|
||||||
|
assert_eq!(super::ConfigSensitivity::from_variable_name("KSPB_SECRET_PASSWORD").ok(), std::option::Option::Some(super::ConfigSensitivity::Secret));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn strongest_sensitivity_follows_secret_internal_public_order() {
|
||||||
|
assert_eq!(super::ConfigSensitivity::Public.strongest(super::ConfigSensitivity::Internal), super::ConfigSensitivity::Internal);
|
||||||
|
assert_eq!(super::ConfigSensitivity::Internal.strongest(super::ConfigSensitivity::Secret), super::ConfigSensitivity::Secret);
|
||||||
|
assert_eq!(super::ConfigSensitivity::Secret.strongest(super::ConfigSensitivity::Public), super::ConfigSensitivity::Secret);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn provenance_exposes_names_and_sources_without_values() {
|
||||||
|
let process = super::ConfigValueProvenance::EnvironmentProcess { variable_name: "KSP_SECRET_TOKEN".to_owned() };
|
||||||
|
let dotenv = super::ConfigValueProvenance::EnvironmentDotEnv { variable_name: "KSP_MODE".to_owned() };
|
||||||
|
let fallback = super::ConfigValueProvenance::EnvironmentFallback { variable_name: "KSP_PUBLIC_HOST".to_owned() };
|
||||||
|
assert_eq!(process.variable_name(), std::option::Option::Some("KSP_SECRET_TOKEN"));
|
||||||
|
assert_eq!(process.environment_source(), std::option::Option::Some(crate::ConfigEnvironmentSource::Process));
|
||||||
|
assert_eq!(dotenv.environment_source(), std::option::Option::Some(crate::ConfigEnvironmentSource::DotEnv));
|
||||||
|
assert_eq!(fallback.environment_source(), std::option::Option::Some(crate::ConfigEnvironmentSource::Fallback));
|
||||||
|
assert_eq!(super::ConfigValueProvenance::DocumentLiteral.variable_name(), std::option::Option::None);
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
# file: crates/ksp-core-lib/Cargo.toml
|
# file: crates/ksp-core-lib/Cargo.toml
|
||||||
# version: 1
|
# version: 3
|
||||||
|
|
||||||
[package]
|
[package]
|
||||||
name = "ksp-core-lib"
|
name = "ksp-core-lib"
|
||||||
@@ -7,5 +7,8 @@ version.workspace = true
|
|||||||
edition.workspace = true
|
edition.workspace = true
|
||||||
repository.workspace = true
|
repository.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
solana-pubkey.workspace = true
|
||||||
|
|
||||||
[lints]
|
[lints]
|
||||||
workspace = true
|
workspace = true
|
||||||
|
|||||||
130
crates/ksp-core-lib/src/error.rs
Normal file
130
crates/ksp-core-lib/src/error.rs
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
// file: crates/ksp-core-lib/src/error.rs
|
||||||
|
// version: 1
|
||||||
|
|
||||||
|
/// Stable structured identifier for a KSP error.
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
||||||
|
pub struct ErrorCode {
|
||||||
|
domain: &'static str,
|
||||||
|
code: &'static str,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ErrorCode {
|
||||||
|
/// Creates an error code from a stable domain and code identifier.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn new(domain: &'static str, code: &'static str) -> Self {
|
||||||
|
return Self { domain, code };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the stable error domain identifier.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn domain(&self) -> &'static str {
|
||||||
|
return self.domain;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the stable error code identifier within the domain.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn code(&self) -> &'static str {
|
||||||
|
return self.code;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Structured contextual field attached to a KSP error.
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub struct ErrorContext {
|
||||||
|
key: &'static str,
|
||||||
|
value: std::string::String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ErrorContext {
|
||||||
|
/// Creates one contextual field from a stable key and an owned value.
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(key: &'static str, value: impl std::convert::Into<std::string::String>) -> Self {
|
||||||
|
return Self { key, value: value.into() };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the stable contextual key.
|
||||||
|
#[must_use]
|
||||||
|
pub fn key(&self) -> &'static str {
|
||||||
|
return self.key;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the contextual value.
|
||||||
|
#[must_use]
|
||||||
|
pub fn value(&self) -> &str {
|
||||||
|
return self.value.as_str();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Common KSP error carrying a stable code, human-readable message, structured context and optional source.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct Error {
|
||||||
|
code: crate::ErrorCode,
|
||||||
|
message: std::string::String,
|
||||||
|
context: std::vec::Vec<crate::ErrorContext>,
|
||||||
|
source: std::option::Option<std::boxed::Box<dyn std::error::Error + std::marker::Send + std::marker::Sync + 'static>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Error {
|
||||||
|
/// Creates a KSP error without context or external source.
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(code: crate::ErrorCode, message: impl std::convert::Into<std::string::String>) -> Self {
|
||||||
|
return Self { code, message: message.into(), context: std::vec::Vec::new(), source: std::option::Option::None };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the stable structured error code.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn code(&self) -> crate::ErrorCode {
|
||||||
|
return self.code;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the human-readable diagnostic message.
|
||||||
|
#[must_use]
|
||||||
|
pub fn message(&self) -> &str {
|
||||||
|
return self.message.as_str();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the contextual fields in insertion order.
|
||||||
|
#[must_use]
|
||||||
|
pub fn context(&self) -> &[crate::ErrorContext] {
|
||||||
|
return self.context.as_slice();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Appends one contextual field and returns the enriched error.
|
||||||
|
#[must_use]
|
||||||
|
pub fn with_context(mut self, key: &'static str, value: impl std::convert::Into<std::string::String>) -> Self {
|
||||||
|
self.context.push(crate::ErrorContext::new(key, value));
|
||||||
|
return self;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Attaches an external error as the standard source and returns the enriched error.
|
||||||
|
#[must_use]
|
||||||
|
pub fn with_source<E>(mut self, source: E) -> Self
|
||||||
|
where
|
||||||
|
E: std::error::Error + std::marker::Send + std::marker::Sync + 'static,
|
||||||
|
{
|
||||||
|
self.source = std::option::Option::Some(std::boxed::Box::new(source));
|
||||||
|
return self;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for Error {
|
||||||
|
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
return write!(formatter, "{}.{}: {}", self.code.domain(), self.code.code(), self.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for Error {
|
||||||
|
fn source(&self) -> std::option::Option<&(dyn std::error::Error + 'static)> {
|
||||||
|
return match self.source.as_deref() {
|
||||||
|
std::option::Option::Some(source) => std::option::Option::Some(source),
|
||||||
|
std::option::Option::None => std::option::Option::None,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Common result type returned by KSP APIs using [`crate::Error`].
|
||||||
|
pub type Result<T> = std::result::Result<T, crate::Error>;
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "../unit_tests/error.rs"]
|
||||||
|
mod tests;
|
||||||
@@ -1,7 +1,120 @@
|
|||||||
// file: crates/ksp-core-lib/src/lib.rs
|
// file: crates/ksp-core-lib/src/lib.rs
|
||||||
// version: 3
|
// version: 6
|
||||||
#![warn(missing_docs)]
|
#![warn(missing_docs)]
|
||||||
#![deny(unreachable_pub)]
|
#![deny(unreachable_pub)]
|
||||||
#![forbid(unsafe_code)]
|
#![forbid(unsafe_code)]
|
||||||
|
|
||||||
//! Minimal core-library skeleton for the KSP foundation phase.
|
//! Core contracts shared by the foundational KSP layers.
|
||||||
|
//!
|
||||||
|
//! `ksp-core-lib` owns the common KSP error contract, the Solana [`Pubkey`]
|
||||||
|
//! primitive used by the project, and the KSP-owned registry of fundamental
|
||||||
|
//! Solana Program IDs. Higher-level domains extend these contracts without
|
||||||
|
//! introducing reverse dependencies from Core.
|
||||||
|
|
||||||
|
mod error;
|
||||||
|
mod program_ids;
|
||||||
|
|
||||||
|
/// Common KSP error type used by higher-level crates.
|
||||||
|
pub use self::error::Error;
|
||||||
|
/// Stable structured code identifying a KSP error category and condition.
|
||||||
|
pub use self::error::ErrorCode;
|
||||||
|
/// Structured contextual field attached to a KSP error.
|
||||||
|
pub use self::error::ErrorContext;
|
||||||
|
/// Common KSP result alias using [`Error`].
|
||||||
|
pub use self::error::Result;
|
||||||
|
/// Canonical Address Lookup Table Program ID as Base58 text.
|
||||||
|
pub use self::program_ids::PRGID_SOLANA_ADDRESS_LOOKUP_TABLE;
|
||||||
|
/// Canonical Compute Budget Program ID as Base58 text.
|
||||||
|
pub use self::program_ids::PRGID_SOLANA_COMPUTE_BUDGET;
|
||||||
|
/// Canonical Config Program ID as Base58 text.
|
||||||
|
pub use self::program_ids::PRGID_SOLANA_CONFIG;
|
||||||
|
/// Canonical Feature Program ID as Base58 text.
|
||||||
|
pub use self::program_ids::PRGID_SOLANA_FEATURE;
|
||||||
|
/// Canonical upgradeable BPF Loader Program ID as Base58 text.
|
||||||
|
pub use self::program_ids::PRGID_SOLANA_LOADER_BPF_UPGRADEABLE;
|
||||||
|
/// Canonical deprecated BPF Loader Program ID as Base58 text.
|
||||||
|
pub use self::program_ids::PRGID_SOLANA_LOADER_BPF_V1;
|
||||||
|
/// Canonical BPF Loader v2 Program ID as Base58 text.
|
||||||
|
pub use self::program_ids::PRGID_SOLANA_LOADER_BPF_V2;
|
||||||
|
/// Canonical Native Loader Program ID as Base58 text.
|
||||||
|
pub use self::program_ids::PRGID_SOLANA_LOADER_NATIVE;
|
||||||
|
/// Canonical Loader v4 Program ID as Base58 text.
|
||||||
|
pub use self::program_ids::PRGID_SOLANA_LOADER_V4;
|
||||||
|
/// Canonical Ed25519 precompile Program ID as Base58 text.
|
||||||
|
pub use self::program_ids::PRGID_SOLANA_PRECOMPILE_ED25519;
|
||||||
|
/// Canonical Secp256k1 precompile Program ID as Base58 text.
|
||||||
|
pub use self::program_ids::PRGID_SOLANA_PRECOMPILE_SECP256K1;
|
||||||
|
/// Canonical Secp256r1 precompile Program ID as Base58 text.
|
||||||
|
pub use self::program_ids::PRGID_SOLANA_PRECOMPILE_SECP256R1;
|
||||||
|
/// Canonical Slashing Program ID as Base58 text.
|
||||||
|
pub use self::program_ids::PRGID_SOLANA_SLASHING;
|
||||||
|
/// Canonical Stake Program ID as Base58 text.
|
||||||
|
pub use self::program_ids::PRGID_SOLANA_STAKE;
|
||||||
|
/// Canonical System Program ID as Base58 text.
|
||||||
|
pub use self::program_ids::PRGID_SOLANA_SYSTEM;
|
||||||
|
/// Canonical Vote Program ID as Base58 text.
|
||||||
|
pub use self::program_ids::PRGID_SOLANA_VOTE;
|
||||||
|
/// Canonical ZK ElGamal Proof Program ID as Base58 text.
|
||||||
|
pub use self::program_ids::PRGID_SOLANA_ZK_ELGAMAL_PROOF;
|
||||||
|
/// Canonical ZK Token Proof Program ID as Base58 text.
|
||||||
|
pub use self::program_ids::PRGID_SOLANA_ZK_TOKEN_PROOF;
|
||||||
|
/// Canonical Address Lookup Table Program ID as a typed [`Pubkey`].
|
||||||
|
pub use self::program_ids::PRGIDPK_SOLANA_ADDRESS_LOOKUP_TABLE;
|
||||||
|
/// Canonical Compute Budget Program ID as a typed [`Pubkey`].
|
||||||
|
pub use self::program_ids::PRGIDPK_SOLANA_COMPUTE_BUDGET;
|
||||||
|
/// Canonical Config Program ID as a typed [`Pubkey`].
|
||||||
|
pub use self::program_ids::PRGIDPK_SOLANA_CONFIG;
|
||||||
|
/// Canonical Feature Program ID as a typed [`Pubkey`].
|
||||||
|
pub use self::program_ids::PRGIDPK_SOLANA_FEATURE;
|
||||||
|
/// Canonical upgradeable BPF Loader Program ID as a typed [`Pubkey`].
|
||||||
|
pub use self::program_ids::PRGIDPK_SOLANA_LOADER_BPF_UPGRADEABLE;
|
||||||
|
/// Canonical deprecated BPF Loader Program ID as a typed [`Pubkey`].
|
||||||
|
pub use self::program_ids::PRGIDPK_SOLANA_LOADER_BPF_V1;
|
||||||
|
/// Canonical BPF Loader v2 Program ID as a typed [`Pubkey`].
|
||||||
|
pub use self::program_ids::PRGIDPK_SOLANA_LOADER_BPF_V2;
|
||||||
|
/// Canonical Native Loader Program ID as a typed [`Pubkey`].
|
||||||
|
pub use self::program_ids::PRGIDPK_SOLANA_LOADER_NATIVE;
|
||||||
|
/// Canonical Loader v4 Program ID as a typed [`Pubkey`].
|
||||||
|
pub use self::program_ids::PRGIDPK_SOLANA_LOADER_V4;
|
||||||
|
/// Canonical Ed25519 precompile Program ID as a typed [`Pubkey`].
|
||||||
|
pub use self::program_ids::PRGIDPK_SOLANA_PRECOMPILE_ED25519;
|
||||||
|
/// Canonical Secp256k1 precompile Program ID as a typed [`Pubkey`].
|
||||||
|
pub use self::program_ids::PRGIDPK_SOLANA_PRECOMPILE_SECP256K1;
|
||||||
|
/// Canonical Secp256r1 precompile Program ID as a typed [`Pubkey`].
|
||||||
|
pub use self::program_ids::PRGIDPK_SOLANA_PRECOMPILE_SECP256R1;
|
||||||
|
/// Canonical Slashing Program ID as a typed [`Pubkey`].
|
||||||
|
pub use self::program_ids::PRGIDPK_SOLANA_SLASHING;
|
||||||
|
/// Canonical Stake Program ID as a typed [`Pubkey`].
|
||||||
|
pub use self::program_ids::PRGIDPK_SOLANA_STAKE;
|
||||||
|
/// Canonical System Program ID as a typed [`Pubkey`].
|
||||||
|
pub use self::program_ids::PRGIDPK_SOLANA_SYSTEM;
|
||||||
|
/// Canonical Vote Program ID as a typed [`Pubkey`].
|
||||||
|
pub use self::program_ids::PRGIDPK_SOLANA_VOTE;
|
||||||
|
/// Canonical ZK ElGamal Proof Program ID as a typed [`Pubkey`].
|
||||||
|
pub use self::program_ids::PRGIDPK_SOLANA_ZK_ELGAMAL_PROOF;
|
||||||
|
/// Canonical ZK Token Proof Program ID as a typed [`Pubkey`].
|
||||||
|
pub use self::program_ids::PRGIDPK_SOLANA_ZK_TOKEN_PROOF;
|
||||||
|
/// Immutable descriptor for one registered Program ID.
|
||||||
|
pub use self::program_ids::ProgramIdEntry;
|
||||||
|
/// Borrowed multi-axis filter for the canonical Program ID registry.
|
||||||
|
pub use self::program_ids::ProgramIdFilter;
|
||||||
|
/// Technical classification of one registered Program ID.
|
||||||
|
pub use self::program_ids::ProgramIdKind;
|
||||||
|
/// Returns the canonical Program ID registry.
|
||||||
|
pub use self::program_ids::entries;
|
||||||
|
/// Finds one registered Program ID by its Base58 representation.
|
||||||
|
pub use self::program_ids::find_program_id;
|
||||||
|
/// Finds one registered Program ID by its typed [`Pubkey`] representation.
|
||||||
|
pub use self::program_ids::find_program_pubkey;
|
||||||
|
/// Returns the Solana core/native Program ID view.
|
||||||
|
pub use self::program_ids::native_program_ids;
|
||||||
|
/// Returns Program IDs matching a multi-axis filter.
|
||||||
|
pub use self::program_ids::program_ids;
|
||||||
|
/// Returns Program IDs belonging to one domain.
|
||||||
|
pub use self::program_ids::program_ids_by_domain;
|
||||||
|
/// Returns Program IDs belonging to one family.
|
||||||
|
pub use self::program_ids::program_ids_by_family;
|
||||||
|
/// Returns Program IDs belonging to one protocol or project.
|
||||||
|
pub use self::program_ids::program_ids_by_protocol;
|
||||||
|
/// Solana account address primitive used by KSP Program IDs.
|
||||||
|
pub use solana_pubkey::Pubkey;
|
||||||
|
|||||||
492
crates/ksp-core-lib/src/program_ids.rs
Normal file
492
crates/ksp-core-lib/src/program_ids.rs
Normal file
@@ -0,0 +1,492 @@
|
|||||||
|
// file: crates/ksp-core-lib/src/program_ids.rs
|
||||||
|
// version: 2
|
||||||
|
|
||||||
|
const DOMAIN_SOLANA: &str = "solana";
|
||||||
|
const FAMILY_CONSENSUS: &str = "consensus";
|
||||||
|
const FAMILY_LOADER: &str = "loader";
|
||||||
|
const FAMILY_PRECOMPILE: &str = "precompile";
|
||||||
|
const FAMILY_PROOF: &str = "proof";
|
||||||
|
const FAMILY_RUNTIME: &str = "runtime";
|
||||||
|
const PROTOCOL_SOLANA: &str = "solana";
|
||||||
|
|
||||||
|
/// Declares one KSP-owned Solana Program ID as matching Base58 and typed constants.
|
||||||
|
///
|
||||||
|
/// The Base58 literal is written once and decoded at compile time through [`crate::Pubkey`].
|
||||||
|
#[macro_export]
|
||||||
|
macro_rules! declare_program_id {
|
||||||
|
($string_name:ident, $pubkey_name:ident, $value:literal) => {
|
||||||
|
#[doc = concat!("Base58 Program ID declared as `", stringify!($string_name), "`.")]
|
||||||
|
pub const $string_name: &str = $value;
|
||||||
|
#[doc = concat!("Typed Program ID corresponding to `", stringify!($string_name), "`.")]
|
||||||
|
pub const $pubkey_name: $crate::Pubkey = $crate::Pubkey::from_str_const($string_name);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
crate::declare_program_id!(PRGID_SOLANA_ADDRESS_LOOKUP_TABLE, PRGIDPK_SOLANA_ADDRESS_LOOKUP_TABLE, "AddressLookupTab1e1111111111111111111111111");
|
||||||
|
crate::declare_program_id!(PRGID_SOLANA_LOADER_BPF_V1, PRGIDPK_SOLANA_LOADER_BPF_V1, "BPFLoader1111111111111111111111111111111111");
|
||||||
|
crate::declare_program_id!(PRGID_SOLANA_LOADER_BPF_V2, PRGIDPK_SOLANA_LOADER_BPF_V2, "BPFLoader2111111111111111111111111111111111");
|
||||||
|
crate::declare_program_id!(PRGID_SOLANA_LOADER_BPF_UPGRADEABLE, PRGIDPK_SOLANA_LOADER_BPF_UPGRADEABLE, "BPFLoaderUpgradeab1e11111111111111111111111");
|
||||||
|
crate::declare_program_id!(PRGID_SOLANA_COMPUTE_BUDGET, PRGIDPK_SOLANA_COMPUTE_BUDGET, "ComputeBudget111111111111111111111111111111");
|
||||||
|
crate::declare_program_id!(PRGID_SOLANA_CONFIG, PRGIDPK_SOLANA_CONFIG, "Config1111111111111111111111111111111111111");
|
||||||
|
crate::declare_program_id!(PRGID_SOLANA_PRECOMPILE_ED25519, PRGIDPK_SOLANA_PRECOMPILE_ED25519, "Ed25519SigVerify111111111111111111111111111");
|
||||||
|
crate::declare_program_id!(PRGID_SOLANA_FEATURE, PRGIDPK_SOLANA_FEATURE, "Feature111111111111111111111111111111111111");
|
||||||
|
crate::declare_program_id!(PRGID_SOLANA_LOADER_V4, PRGIDPK_SOLANA_LOADER_V4, "LoaderV411111111111111111111111111111111111");
|
||||||
|
crate::declare_program_id!(PRGID_SOLANA_LOADER_NATIVE, PRGIDPK_SOLANA_LOADER_NATIVE, "NativeLoader1111111111111111111111111111111");
|
||||||
|
crate::declare_program_id!(PRGID_SOLANA_PRECOMPILE_SECP256K1, PRGIDPK_SOLANA_PRECOMPILE_SECP256K1, "KeccakSecp256k11111111111111111111111111111");
|
||||||
|
crate::declare_program_id!(PRGID_SOLANA_PRECOMPILE_SECP256R1, PRGIDPK_SOLANA_PRECOMPILE_SECP256R1, "Secp256r1SigVerify1111111111111111111111111");
|
||||||
|
crate::declare_program_id!(PRGID_SOLANA_SLASHING, PRGIDPK_SOLANA_SLASHING, "S1ashing11111111111111111111111111111111111");
|
||||||
|
crate::declare_program_id!(PRGID_SOLANA_STAKE, PRGIDPK_SOLANA_STAKE, "Stake11111111111111111111111111111111111111");
|
||||||
|
crate::declare_program_id!(PRGID_SOLANA_SYSTEM, PRGIDPK_SOLANA_SYSTEM, "11111111111111111111111111111111");
|
||||||
|
crate::declare_program_id!(PRGID_SOLANA_VOTE, PRGIDPK_SOLANA_VOTE, "Vote111111111111111111111111111111111111111");
|
||||||
|
crate::declare_program_id!(PRGID_SOLANA_ZK_ELGAMAL_PROOF, PRGIDPK_SOLANA_ZK_ELGAMAL_PROOF, "ZkE1Gama1Proof11111111111111111111111111111");
|
||||||
|
crate::declare_program_id!(PRGID_SOLANA_ZK_TOKEN_PROOF, PRGIDPK_SOLANA_ZK_TOKEN_PROOF, "ZkTokenProof1111111111111111111111111111111");
|
||||||
|
|
||||||
|
/// Technical classification of a registered Program ID.
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
||||||
|
pub enum ProgramIdKind {
|
||||||
|
/// A regular executable program belonging to the registered protocol surface.
|
||||||
|
Program,
|
||||||
|
/// A Solana program loader.
|
||||||
|
Loader,
|
||||||
|
/// A runtime precompile exposed through a Program ID.
|
||||||
|
Precompile,
|
||||||
|
/// An enshrined on-chain program deployed as part of the Solana protocol.
|
||||||
|
EnshrinedProgram,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Immutable descriptor for one KSP-owned Program ID.
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub struct ProgramIdEntry {
|
||||||
|
code: &'static str,
|
||||||
|
name: &'static str,
|
||||||
|
program_id: &'static str,
|
||||||
|
pubkey: crate::Pubkey,
|
||||||
|
domain: &'static str,
|
||||||
|
family: &'static str,
|
||||||
|
protocol: &'static str,
|
||||||
|
subfamily: std::option::Option<&'static str>,
|
||||||
|
program_version: std::option::Option<&'static str>,
|
||||||
|
kind: crate::ProgramIdKind,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ProgramIdEntry {
|
||||||
|
const fn new(code: &'static str, name: &'static str, program_id: &'static str, pubkey: crate::Pubkey) -> Self {
|
||||||
|
return Self {
|
||||||
|
code,
|
||||||
|
name,
|
||||||
|
program_id,
|
||||||
|
pubkey,
|
||||||
|
domain: "",
|
||||||
|
family: "",
|
||||||
|
protocol: "",
|
||||||
|
subfamily: std::option::Option::None,
|
||||||
|
program_version: std::option::Option::None,
|
||||||
|
kind: crate::ProgramIdKind::Program,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const fn with_taxonomy(
|
||||||
|
mut self,
|
||||||
|
domain: &'static str,
|
||||||
|
family: &'static str,
|
||||||
|
protocol: &'static str,
|
||||||
|
subfamily: std::option::Option<&'static str>,
|
||||||
|
program_version: std::option::Option<&'static str>,
|
||||||
|
kind: crate::ProgramIdKind,
|
||||||
|
) -> Self {
|
||||||
|
self.domain = domain;
|
||||||
|
self.family = family;
|
||||||
|
self.protocol = protocol;
|
||||||
|
self.subfamily = subfamily;
|
||||||
|
self.program_version = program_version;
|
||||||
|
self.kind = kind;
|
||||||
|
return self;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the stable KSP machine-readable code of this entry.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn code(&self) -> &'static str {
|
||||||
|
return self.code;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the human-readable program name.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn name(&self) -> &'static str {
|
||||||
|
return self.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the canonical Base58 Program ID.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn program_id(&self) -> &'static str {
|
||||||
|
return self.program_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the typed Solana Program ID.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn pubkey(&self) -> crate::Pubkey {
|
||||||
|
return self.pubkey;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the broad functional domain.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn domain(&self) -> &'static str {
|
||||||
|
return self.domain;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the functional family within the domain.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn family(&self) -> &'static str {
|
||||||
|
return self.family;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the owning protocol or project identifier.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn protocol(&self) -> &'static str {
|
||||||
|
return self.protocol;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the optional architectural branch or product subfamily.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn subfamily(&self) -> std::option::Option<&'static str> {
|
||||||
|
return self.subfamily;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the optional public generation of this program lineage.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn program_version(&self) -> std::option::Option<&'static str> {
|
||||||
|
return self.program_version;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the technical Program ID classification.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn kind(&self) -> crate::ProgramIdKind {
|
||||||
|
return self.kind;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Borrowed filter used to select Program IDs from the canonical KSP registry.
|
||||||
|
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
|
||||||
|
pub struct ProgramIdFilter<'a> {
|
||||||
|
domain: std::option::Option<&'a str>,
|
||||||
|
family: std::option::Option<&'a str>,
|
||||||
|
protocol: std::option::Option<&'a str>,
|
||||||
|
subfamily: std::option::Option<&'a str>,
|
||||||
|
program_version: std::option::Option<&'a str>,
|
||||||
|
kind: std::option::Option<crate::ProgramIdKind>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> ProgramIdFilter<'a> {
|
||||||
|
/// Creates an empty filter matching every registered Program ID.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn new() -> Self {
|
||||||
|
return Self {
|
||||||
|
domain: std::option::Option::None,
|
||||||
|
family: std::option::Option::None,
|
||||||
|
protocol: std::option::Option::None,
|
||||||
|
subfamily: std::option::Option::None,
|
||||||
|
program_version: std::option::Option::None,
|
||||||
|
kind: std::option::Option::None,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Restricts the filter to one functional domain.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn with_domain(mut self, domain: &'a str) -> Self {
|
||||||
|
self.domain = std::option::Option::Some(domain);
|
||||||
|
return self;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Restricts the filter to one functional family.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn with_family(mut self, family: &'a str) -> Self {
|
||||||
|
self.family = std::option::Option::Some(family);
|
||||||
|
return self;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Restricts the filter to one protocol or project.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn with_protocol(mut self, protocol: &'a str) -> Self {
|
||||||
|
self.protocol = std::option::Option::Some(protocol);
|
||||||
|
return self;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Restricts the filter to one architectural subfamily.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn with_subfamily(mut self, subfamily: &'a str) -> Self {
|
||||||
|
self.subfamily = std::option::Option::Some(subfamily);
|
||||||
|
return self;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Restricts the filter to one public program generation.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn with_program_version(mut self, program_version: &'a str) -> Self {
|
||||||
|
self.program_version = std::option::Option::Some(program_version);
|
||||||
|
return self;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Restricts the filter to one technical Program ID kind.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn with_kind(mut self, kind: crate::ProgramIdKind) -> Self {
|
||||||
|
self.kind = std::option::Option::Some(kind);
|
||||||
|
return self;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn matches(&self, entry: &crate::ProgramIdEntry) -> bool {
|
||||||
|
if let std::option::Option::Some(domain) = self.domain
|
||||||
|
&& entry.domain() != domain
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if let std::option::Option::Some(family) = self.family
|
||||||
|
&& entry.family() != family
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if let std::option::Option::Some(protocol) = self.protocol
|
||||||
|
&& entry.protocol() != protocol
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if let std::option::Option::Some(subfamily) = self.subfamily
|
||||||
|
&& entry.subfamily() != std::option::Option::Some(subfamily)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if let std::option::Option::Some(program_version) = self.program_version
|
||||||
|
&& entry.program_version() != std::option::Option::Some(program_version)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if let std::option::Option::Some(kind) = self.kind
|
||||||
|
&& entry.kind() != kind
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const PROGRAM_ID_ENTRIES: &[crate::ProgramIdEntry] = &[
|
||||||
|
crate::ProgramIdEntry::new(
|
||||||
|
"solana.address_lookup_table",
|
||||||
|
"Address Lookup Table Program",
|
||||||
|
crate::PRGID_SOLANA_ADDRESS_LOOKUP_TABLE,
|
||||||
|
crate::PRGIDPK_SOLANA_ADDRESS_LOOKUP_TABLE,
|
||||||
|
)
|
||||||
|
.with_taxonomy(DOMAIN_SOLANA, FAMILY_RUNTIME, PROTOCOL_SOLANA, std::option::Option::None, std::option::Option::None, crate::ProgramIdKind::Program),
|
||||||
|
crate::ProgramIdEntry::new("solana.loader.bpf.v1", "Deprecated BPF Loader", crate::PRGID_SOLANA_LOADER_BPF_V1, crate::PRGIDPK_SOLANA_LOADER_BPF_V1)
|
||||||
|
.with_taxonomy(
|
||||||
|
DOMAIN_SOLANA,
|
||||||
|
FAMILY_LOADER,
|
||||||
|
PROTOCOL_SOLANA,
|
||||||
|
std::option::Option::Some("bpf"),
|
||||||
|
std::option::Option::Some("v1"),
|
||||||
|
crate::ProgramIdKind::Loader,
|
||||||
|
),
|
||||||
|
crate::ProgramIdEntry::new("solana.loader.bpf.v2", "BPF Loader v2", crate::PRGID_SOLANA_LOADER_BPF_V2, crate::PRGIDPK_SOLANA_LOADER_BPF_V2).with_taxonomy(
|
||||||
|
DOMAIN_SOLANA,
|
||||||
|
FAMILY_LOADER,
|
||||||
|
PROTOCOL_SOLANA,
|
||||||
|
std::option::Option::Some("bpf"),
|
||||||
|
std::option::Option::Some("v2"),
|
||||||
|
crate::ProgramIdKind::Loader,
|
||||||
|
),
|
||||||
|
crate::ProgramIdEntry::new(
|
||||||
|
"solana.loader.bpf_upgradeable",
|
||||||
|
"Upgradeable BPF Loader",
|
||||||
|
crate::PRGID_SOLANA_LOADER_BPF_UPGRADEABLE,
|
||||||
|
crate::PRGIDPK_SOLANA_LOADER_BPF_UPGRADEABLE,
|
||||||
|
)
|
||||||
|
.with_taxonomy(
|
||||||
|
DOMAIN_SOLANA,
|
||||||
|
FAMILY_LOADER,
|
||||||
|
PROTOCOL_SOLANA,
|
||||||
|
std::option::Option::Some("bpf"),
|
||||||
|
std::option::Option::None,
|
||||||
|
crate::ProgramIdKind::Loader,
|
||||||
|
),
|
||||||
|
crate::ProgramIdEntry::new("solana.compute_budget", "Compute Budget Program", crate::PRGID_SOLANA_COMPUTE_BUDGET, crate::PRGIDPK_SOLANA_COMPUTE_BUDGET)
|
||||||
|
.with_taxonomy(DOMAIN_SOLANA, FAMILY_RUNTIME, PROTOCOL_SOLANA, std::option::Option::None, std::option::Option::None, crate::ProgramIdKind::Program),
|
||||||
|
crate::ProgramIdEntry::new("solana.config", "Config Program", crate::PRGID_SOLANA_CONFIG, crate::PRGIDPK_SOLANA_CONFIG).with_taxonomy(
|
||||||
|
DOMAIN_SOLANA,
|
||||||
|
FAMILY_RUNTIME,
|
||||||
|
PROTOCOL_SOLANA,
|
||||||
|
std::option::Option::None,
|
||||||
|
std::option::Option::None,
|
||||||
|
crate::ProgramIdKind::Program,
|
||||||
|
),
|
||||||
|
crate::ProgramIdEntry::new(
|
||||||
|
"solana.precompile.ed25519",
|
||||||
|
"Ed25519 Signature Verification Precompile",
|
||||||
|
crate::PRGID_SOLANA_PRECOMPILE_ED25519,
|
||||||
|
crate::PRGIDPK_SOLANA_PRECOMPILE_ED25519,
|
||||||
|
)
|
||||||
|
.with_taxonomy(
|
||||||
|
DOMAIN_SOLANA,
|
||||||
|
FAMILY_PRECOMPILE,
|
||||||
|
PROTOCOL_SOLANA,
|
||||||
|
std::option::Option::Some("ed25519"),
|
||||||
|
std::option::Option::None,
|
||||||
|
crate::ProgramIdKind::Precompile,
|
||||||
|
),
|
||||||
|
crate::ProgramIdEntry::new("solana.feature", "Feature Program", crate::PRGID_SOLANA_FEATURE, crate::PRGIDPK_SOLANA_FEATURE).with_taxonomy(
|
||||||
|
DOMAIN_SOLANA,
|
||||||
|
FAMILY_RUNTIME,
|
||||||
|
PROTOCOL_SOLANA,
|
||||||
|
std::option::Option::None,
|
||||||
|
std::option::Option::None,
|
||||||
|
crate::ProgramIdKind::Program,
|
||||||
|
),
|
||||||
|
crate::ProgramIdEntry::new("solana.loader.v4", "Loader v4", crate::PRGID_SOLANA_LOADER_V4, crate::PRGIDPK_SOLANA_LOADER_V4).with_taxonomy(
|
||||||
|
DOMAIN_SOLANA,
|
||||||
|
FAMILY_LOADER,
|
||||||
|
PROTOCOL_SOLANA,
|
||||||
|
std::option::Option::None,
|
||||||
|
std::option::Option::Some("v4"),
|
||||||
|
crate::ProgramIdKind::Loader,
|
||||||
|
),
|
||||||
|
crate::ProgramIdEntry::new("solana.loader.native", "Native Loader", crate::PRGID_SOLANA_LOADER_NATIVE, crate::PRGIDPK_SOLANA_LOADER_NATIVE).with_taxonomy(
|
||||||
|
DOMAIN_SOLANA,
|
||||||
|
FAMILY_LOADER,
|
||||||
|
PROTOCOL_SOLANA,
|
||||||
|
std::option::Option::Some("native"),
|
||||||
|
std::option::Option::None,
|
||||||
|
crate::ProgramIdKind::Loader,
|
||||||
|
),
|
||||||
|
crate::ProgramIdEntry::new(
|
||||||
|
"solana.precompile.secp256k1",
|
||||||
|
"Secp256k1 Signature Verification Precompile",
|
||||||
|
crate::PRGID_SOLANA_PRECOMPILE_SECP256K1,
|
||||||
|
crate::PRGIDPK_SOLANA_PRECOMPILE_SECP256K1,
|
||||||
|
)
|
||||||
|
.with_taxonomy(
|
||||||
|
DOMAIN_SOLANA,
|
||||||
|
FAMILY_PRECOMPILE,
|
||||||
|
PROTOCOL_SOLANA,
|
||||||
|
std::option::Option::Some("secp256k1"),
|
||||||
|
std::option::Option::None,
|
||||||
|
crate::ProgramIdKind::Precompile,
|
||||||
|
),
|
||||||
|
crate::ProgramIdEntry::new(
|
||||||
|
"solana.precompile.secp256r1",
|
||||||
|
"Secp256r1 Signature Verification Precompile",
|
||||||
|
crate::PRGID_SOLANA_PRECOMPILE_SECP256R1,
|
||||||
|
crate::PRGIDPK_SOLANA_PRECOMPILE_SECP256R1,
|
||||||
|
)
|
||||||
|
.with_taxonomy(
|
||||||
|
DOMAIN_SOLANA,
|
||||||
|
FAMILY_PRECOMPILE,
|
||||||
|
PROTOCOL_SOLANA,
|
||||||
|
std::option::Option::Some("secp256r1"),
|
||||||
|
std::option::Option::None,
|
||||||
|
crate::ProgramIdKind::Precompile,
|
||||||
|
),
|
||||||
|
crate::ProgramIdEntry::new("solana.slashing", "Slashing Program", crate::PRGID_SOLANA_SLASHING, crate::PRGIDPK_SOLANA_SLASHING).with_taxonomy(
|
||||||
|
DOMAIN_SOLANA,
|
||||||
|
FAMILY_CONSENSUS,
|
||||||
|
PROTOCOL_SOLANA,
|
||||||
|
std::option::Option::None,
|
||||||
|
std::option::Option::None,
|
||||||
|
crate::ProgramIdKind::EnshrinedProgram,
|
||||||
|
),
|
||||||
|
crate::ProgramIdEntry::new("solana.stake", "Stake Program", crate::PRGID_SOLANA_STAKE, crate::PRGIDPK_SOLANA_STAKE).with_taxonomy(
|
||||||
|
DOMAIN_SOLANA,
|
||||||
|
FAMILY_CONSENSUS,
|
||||||
|
PROTOCOL_SOLANA,
|
||||||
|
std::option::Option::None,
|
||||||
|
std::option::Option::None,
|
||||||
|
crate::ProgramIdKind::Program,
|
||||||
|
),
|
||||||
|
crate::ProgramIdEntry::new("solana.system", "System Program", crate::PRGID_SOLANA_SYSTEM, crate::PRGIDPK_SOLANA_SYSTEM).with_taxonomy(
|
||||||
|
DOMAIN_SOLANA,
|
||||||
|
FAMILY_RUNTIME,
|
||||||
|
PROTOCOL_SOLANA,
|
||||||
|
std::option::Option::None,
|
||||||
|
std::option::Option::None,
|
||||||
|
crate::ProgramIdKind::Program,
|
||||||
|
),
|
||||||
|
crate::ProgramIdEntry::new("solana.vote", "Vote Program", crate::PRGID_SOLANA_VOTE, crate::PRGIDPK_SOLANA_VOTE).with_taxonomy(
|
||||||
|
DOMAIN_SOLANA,
|
||||||
|
FAMILY_CONSENSUS,
|
||||||
|
PROTOCOL_SOLANA,
|
||||||
|
std::option::Option::None,
|
||||||
|
std::option::Option::None,
|
||||||
|
crate::ProgramIdKind::Program,
|
||||||
|
),
|
||||||
|
crate::ProgramIdEntry::new(
|
||||||
|
"solana.proof.zk_elgamal",
|
||||||
|
"ZK ElGamal Proof Program",
|
||||||
|
crate::PRGID_SOLANA_ZK_ELGAMAL_PROOF,
|
||||||
|
crate::PRGIDPK_SOLANA_ZK_ELGAMAL_PROOF,
|
||||||
|
)
|
||||||
|
.with_taxonomy(
|
||||||
|
DOMAIN_SOLANA,
|
||||||
|
FAMILY_PROOF,
|
||||||
|
PROTOCOL_SOLANA,
|
||||||
|
std::option::Option::Some("zk_elgamal"),
|
||||||
|
std::option::Option::None,
|
||||||
|
crate::ProgramIdKind::Program,
|
||||||
|
),
|
||||||
|
crate::ProgramIdEntry::new("solana.proof.zk_token", "ZK Token Proof Program", crate::PRGID_SOLANA_ZK_TOKEN_PROOF, crate::PRGIDPK_SOLANA_ZK_TOKEN_PROOF)
|
||||||
|
.with_taxonomy(
|
||||||
|
DOMAIN_SOLANA,
|
||||||
|
FAMILY_PROOF,
|
||||||
|
PROTOCOL_SOLANA,
|
||||||
|
std::option::Option::Some("zk_token"),
|
||||||
|
std::option::Option::None,
|
||||||
|
crate::ProgramIdKind::Program,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Returns the canonical KSP Program ID registry.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn entries() -> &'static [crate::ProgramIdEntry] {
|
||||||
|
return PROGRAM_ID_ENTRIES;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns a lazy view of Program IDs matching all configured filter axes.
|
||||||
|
pub fn program_ids<'a>(filter: crate::ProgramIdFilter<'a>) -> impl std::iter::Iterator<Item = &'static crate::ProgramIdEntry> + 'a {
|
||||||
|
return PROGRAM_ID_ENTRIES.iter().filter(move |entry| {
|
||||||
|
return filter.matches(entry);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns all Solana core/native Program IDs, including loaders, precompiles and enshrined programs.
|
||||||
|
pub fn native_program_ids() -> impl std::iter::Iterator<Item = &'static crate::ProgramIdEntry> {
|
||||||
|
return crate::program_ids(crate::ProgramIdFilter::new().with_domain(DOMAIN_SOLANA).with_protocol(PROTOCOL_SOLANA));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns a lazy view of Program IDs belonging to one functional domain.
|
||||||
|
pub fn program_ids_by_domain<'a>(domain: &'a str) -> impl std::iter::Iterator<Item = &'static crate::ProgramIdEntry> + 'a {
|
||||||
|
return crate::program_ids(crate::ProgramIdFilter::new().with_domain(domain));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns a lazy view of Program IDs belonging to one functional family.
|
||||||
|
pub fn program_ids_by_family<'a>(family: &'a str) -> impl std::iter::Iterator<Item = &'static crate::ProgramIdEntry> + 'a {
|
||||||
|
return crate::program_ids(crate::ProgramIdFilter::new().with_family(family));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns a lazy view of Program IDs belonging to one protocol or project.
|
||||||
|
pub fn program_ids_by_protocol<'a>(protocol: &'a str) -> impl std::iter::Iterator<Item = &'static crate::ProgramIdEntry> + 'a {
|
||||||
|
return crate::program_ids(crate::ProgramIdFilter::new().with_protocol(protocol));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Finds one registered Program ID by its canonical Base58 representation.
|
||||||
|
#[must_use]
|
||||||
|
pub fn find_program_id(program_id: &str) -> std::option::Option<&'static crate::ProgramIdEntry> {
|
||||||
|
return PROGRAM_ID_ENTRIES.iter().find(|entry| {
|
||||||
|
return entry.program_id() == program_id;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Finds one registered Program ID by its typed Solana representation.
|
||||||
|
#[must_use]
|
||||||
|
pub fn find_program_pubkey(program_id: &crate::Pubkey) -> std::option::Option<&'static crate::ProgramIdEntry> {
|
||||||
|
return PROGRAM_ID_ENTRIES.iter().find(|entry| {
|
||||||
|
return entry.pubkey() == *program_id;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "../unit_tests/program_ids.rs"]
|
||||||
|
mod tests;
|
||||||
67
crates/ksp-core-lib/tests/public_api.rs
Normal file
67
crates/ksp-core-lib/tests/public_api.rs
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
// file: crates/ksp-core-lib/tests/public_api.rs
|
||||||
|
// version: 4
|
||||||
|
|
||||||
|
//! Integration tests for the public `ksp-core-lib` contracts.
|
||||||
|
|
||||||
|
ksp_core_lib::declare_program_id!(TEST_PRGID_SYSTEM, TEST_PRGIDPK_SYSTEM, "11111111111111111111111111111111");
|
||||||
|
|
||||||
|
const TEST_ERROR_CODE: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("consumer", "failed");
|
||||||
|
|
||||||
|
fn public_result() -> ksp_core_lib::Result<()> {
|
||||||
|
let error = ksp_core_lib::Error::new(TEST_ERROR_CODE, "consumer failure").with_context("operation", "public_api");
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn error_contract_is_consumable_from_crate_root() {
|
||||||
|
let result = public_result();
|
||||||
|
assert!(result.is_err());
|
||||||
|
let error = match result {
|
||||||
|
std::result::Result::Ok(()) => return,
|
||||||
|
std::result::Result::Err(error) => error,
|
||||||
|
};
|
||||||
|
assert_eq!(error.code(), TEST_ERROR_CODE);
|
||||||
|
assert_eq!(error.message(), "consumer failure");
|
||||||
|
assert_eq!(error.context(), &[ksp_core_lib::ErrorContext::new("operation", "public_api")]);
|
||||||
|
assert_eq!(std::string::ToString::to_string(&error), "consumer.failed: consumer failure");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn program_id_contract_is_consumable_from_crate_root() {
|
||||||
|
assert_eq!(TEST_PRGID_SYSTEM, ksp_core_lib::PRGID_SOLANA_SYSTEM);
|
||||||
|
assert_eq!(TEST_PRGIDPK_SYSTEM, ksp_core_lib::PRGIDPK_SOLANA_SYSTEM);
|
||||||
|
assert_eq!(ksp_core_lib::entries().len(), 18);
|
||||||
|
assert_eq!(ksp_core_lib::native_program_ids().count(), 18);
|
||||||
|
let system = ksp_core_lib::find_program_id(ksp_core_lib::PRGID_SOLANA_SYSTEM);
|
||||||
|
let system = match system {
|
||||||
|
std::option::Option::Some(entry) => entry,
|
||||||
|
std::option::Option::None => return,
|
||||||
|
};
|
||||||
|
assert_eq!(system.code(), "solana.system");
|
||||||
|
assert_eq!(system.domain(), "solana");
|
||||||
|
assert_eq!(system.family(), "runtime");
|
||||||
|
assert_eq!(system.protocol(), "solana");
|
||||||
|
assert_eq!(system.pubkey(), ksp_core_lib::PRGIDPK_SOLANA_SYSTEM);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn program_id_registry_supports_public_taxonomy_filters() {
|
||||||
|
let loader_count = ksp_core_lib::program_ids_by_family("loader").count();
|
||||||
|
let precompile_count = ksp_core_lib::program_ids_by_family("precompile").count();
|
||||||
|
let bpf_v2_count = ksp_core_lib::program_ids(
|
||||||
|
ksp_core_lib::ProgramIdFilter::new()
|
||||||
|
.with_domain("solana")
|
||||||
|
.with_family("loader")
|
||||||
|
.with_protocol("solana")
|
||||||
|
.with_subfamily("bpf")
|
||||||
|
.with_program_version("v2")
|
||||||
|
.with_kind(ksp_core_lib::ProgramIdKind::Loader),
|
||||||
|
)
|
||||||
|
.count();
|
||||||
|
assert_eq!(loader_count, 5);
|
||||||
|
assert_eq!(precompile_count, 3);
|
||||||
|
assert_eq!(bpf_v2_count, 1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
78
crates/ksp-core-lib/unit_tests/error.rs
Normal file
78
crates/ksp-core-lib/unit_tests/error.rs
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
// file: crates/ksp-core-lib/unit_tests/error.rs
|
||||||
|
// version: 2
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct TestSource;
|
||||||
|
|
||||||
|
impl std::fmt::Display for TestSource {
|
||||||
|
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
return formatter.write_str("source failure");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for TestSource {}
|
||||||
|
|
||||||
|
fn assert_send_sync<T>(_: std::marker::PhantomData<T>)
|
||||||
|
where
|
||||||
|
T: std::marker::Send + std::marker::Sync,
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn error_code_preserves_domain_and_code() {
|
||||||
|
const CODE: crate::ErrorCode = crate::ErrorCode::new("core", "sample_failure");
|
||||||
|
assert_eq!(CODE.domain(), "core");
|
||||||
|
assert_eq!(CODE.code(), "sample_failure");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn error_context_preserves_key_and_value() {
|
||||||
|
let context = crate::ErrorContext::new("operation", "sample");
|
||||||
|
assert_eq!(context.key(), "operation");
|
||||||
|
assert_eq!(context.value(), "sample");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn error_preserves_code_message_and_context_order() {
|
||||||
|
let code = crate::ErrorCode::new("core", "sample_failure");
|
||||||
|
let error = crate::Error::new(code, "sample message").with_context("first", "one").with_context("second", "two");
|
||||||
|
assert_eq!(error.code(), code);
|
||||||
|
assert_eq!(error.message(), "sample message");
|
||||||
|
assert_eq!(error.context().len(), 2);
|
||||||
|
assert_eq!(error.context()[0].key(), "first");
|
||||||
|
assert_eq!(error.context()[0].value(), "one");
|
||||||
|
assert_eq!(error.context()[1].key(), "second");
|
||||||
|
assert_eq!(error.context()[1].value(), "two");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn display_contains_only_qualified_code_and_message() {
|
||||||
|
let error = crate::Error::new(crate::ErrorCode::new("core", "sample_failure"), "sample message")
|
||||||
|
.with_context("secret_free_context", "not rendered")
|
||||||
|
.with_source(TestSource);
|
||||||
|
assert_eq!(std::string::ToString::to_string(&error), "core.sample_failure: sample message");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn standard_source_is_preserved() {
|
||||||
|
let error = crate::Error::new(crate::ErrorCode::new("core", "sample_failure"), "sample message").with_source(TestSource);
|
||||||
|
let source = std::error::Error::source(&error);
|
||||||
|
assert!(source.is_some());
|
||||||
|
let source_message = match source {
|
||||||
|
std::option::Option::Some(value) => std::string::ToString::to_string(value),
|
||||||
|
std::option::Option::None => std::string::String::new(),
|
||||||
|
};
|
||||||
|
assert_eq!(source_message, "source failure");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn common_error_is_send_and_sync() {
|
||||||
|
assert_send_sync(std::marker::PhantomData::<crate::Error>);
|
||||||
|
return;
|
||||||
|
}
|
||||||
89
crates/ksp-core-lib/unit_tests/program_ids.rs
Normal file
89
crates/ksp-core-lib/unit_tests/program_ids.rs
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
// file: crates/ksp-core-lib/unit_tests/program_ids.rs
|
||||||
|
// version: 1
|
||||||
|
|
||||||
|
fn assert_program_id_types(program_id: &'static str, pubkey: crate::Pubkey) {
|
||||||
|
assert_eq!(pubkey, crate::Pubkey::from_str_const(program_id));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn declared_program_ids_have_matching_text_and_pubkey_forms() {
|
||||||
|
assert_program_id_types(crate::PRGID_SOLANA_SYSTEM, crate::PRGIDPK_SOLANA_SYSTEM);
|
||||||
|
assert_program_id_types(crate::PRGID_SOLANA_STAKE, crate::PRGIDPK_SOLANA_STAKE);
|
||||||
|
assert_program_id_types(crate::PRGID_SOLANA_VOTE, crate::PRGIDPK_SOLANA_VOTE);
|
||||||
|
assert_program_id_types(crate::PRGID_SOLANA_SLASHING, crate::PRGIDPK_SOLANA_SLASHING);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn registry_contains_the_eighteen_core_program_ids() {
|
||||||
|
assert_eq!(crate::entries().len(), 18);
|
||||||
|
assert_eq!(crate::native_program_ids().count(), 18);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn registry_codes_program_ids_and_pubkeys_are_unique() {
|
||||||
|
for left_index in 0..crate::entries().len() {
|
||||||
|
for right_index in (left_index + 1)..crate::entries().len() {
|
||||||
|
let left = &crate::entries()[left_index];
|
||||||
|
let right = &crate::entries()[right_index];
|
||||||
|
assert_ne!(left.code(), right.code());
|
||||||
|
assert_ne!(left.program_id(), right.program_id());
|
||||||
|
assert_ne!(left.pubkey(), right.pubkey());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn registry_pubkeys_match_their_owned_base58_values() {
|
||||||
|
for entry in crate::entries() {
|
||||||
|
assert_eq!(entry.pubkey(), crate::Pubkey::from_str_const(entry.program_id()));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn filters_combine_domain_family_protocol_subfamily_version_and_kind() {
|
||||||
|
let bpf_v2 = crate::program_ids(
|
||||||
|
crate::ProgramIdFilter::new()
|
||||||
|
.with_domain("solana")
|
||||||
|
.with_family("loader")
|
||||||
|
.with_protocol("solana")
|
||||||
|
.with_subfamily("bpf")
|
||||||
|
.with_program_version("v2")
|
||||||
|
.with_kind(crate::ProgramIdKind::Loader),
|
||||||
|
)
|
||||||
|
.collect::<std::vec::Vec<_>>();
|
||||||
|
assert_eq!(bpf_v2.len(), 1);
|
||||||
|
assert_eq!(bpf_v2[0].program_id(), crate::PRGID_SOLANA_LOADER_BPF_V2);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn family_views_cover_expected_core_groups() {
|
||||||
|
assert_eq!(crate::program_ids_by_family("runtime").count(), 5);
|
||||||
|
assert_eq!(crate::program_ids_by_family("consensus").count(), 3);
|
||||||
|
assert_eq!(crate::program_ids_by_family("loader").count(), 5);
|
||||||
|
assert_eq!(crate::program_ids_by_family("precompile").count(), 3);
|
||||||
|
assert_eq!(crate::program_ids_by_family("proof").count(), 2);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn direct_lookup_supports_text_and_typed_program_ids() {
|
||||||
|
let by_text = crate::find_program_id(crate::PRGID_SOLANA_SLASHING);
|
||||||
|
let by_pubkey = crate::find_program_pubkey(&crate::PRGIDPK_SOLANA_SLASHING);
|
||||||
|
assert_eq!(by_text.map(crate::ProgramIdEntry::code), std::option::Option::Some("solana.slashing"));
|
||||||
|
assert_eq!(by_pubkey.map(crate::ProgramIdEntry::code), std::option::Option::Some("solana.slashing"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn non_program_well_known_accounts_are_absent() {
|
||||||
|
assert!(crate::find_program_id("1nc1nerator11111111111111111111111111111111").is_none());
|
||||||
|
assert!(crate::find_program_id("StakeConfig11111111111111111111111111111111").is_none());
|
||||||
|
assert!(crate::find_program_id("SysvarC1ock11111111111111111111111111111111").is_none());
|
||||||
|
return;
|
||||||
|
}
|
||||||
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());
|
||||||
|
}
|
||||||
285
deltas/0.1.1/pre.001-fix.001.md
Normal file
285
deltas/0.1.1/pre.001-fix.001.md
Normal file
@@ -0,0 +1,285 @@
|
|||||||
|
<!-- file: deltas/0.1.1/pre.001-fix.001.md -->
|
||||||
|
<!-- version: 1 -->
|
||||||
|
|
||||||
|
# Delta 0.1.1-pre.001-fix.001
|
||||||
|
|
||||||
|
## Base requise
|
||||||
|
|
||||||
|
Livraison précédente appliquée et commitée :
|
||||||
|
|
||||||
|
```text
|
||||||
|
0.1.1-pre.001
|
||||||
|
```
|
||||||
|
|
||||||
|
La base stable initiale `khadhroony-solana-project-v0.0.3.zip` provient directement de Gitea depuis le tag `v0.0.3`. L'absence de `.git` dans cette archive n'impose donc aucune vérification supplémentaire du tag pour le cadrage de cette session.
|
||||||
|
|
||||||
|
## Type de livraison
|
||||||
|
|
||||||
|
```text
|
||||||
|
ksp-doc-0.1.1-pre.001-fix.001.zip
|
||||||
|
```
|
||||||
|
|
||||||
|
## Objectif
|
||||||
|
|
||||||
|
Corriger le plan de `0.1.1-pre.001` après validation du brainstorming Program IDs, sans ouvrir `pre.002` et sans modifier de code/runtime.
|
||||||
|
|
||||||
|
Ce correctif :
|
||||||
|
|
||||||
|
- confirme `solana-pubkey` comme dépendance Solana fondamentale candidate de Core ;
|
||||||
|
- interdit `solana-sdk-ids` comme dépendance KSP, y compris de développement ;
|
||||||
|
- fait posséder à KSP ses chaînes Base58 et représentations `Pubkey` de Program IDs ;
|
||||||
|
- fixe les préfixes `PRGID_` et `PRGIDPK_` ;
|
||||||
|
- fixe la structure générale de nomenclature `<PREFIX>_<DOMAIN>_<SUBDOMAIN?>_<NAME>_<VERSION?>` ;
|
||||||
|
- prévoit une macro KSP `declare_program_id!` produisant les deux représentations depuis une déclaration unique ;
|
||||||
|
- réintroduit et améliore le concept de registre descriptif enumerable inspiré de l'ancien `ks-program-ids` ;
|
||||||
|
- supprime le nombre arbitrairement figé de 17 Program IDs avant l'inventaire final de `pre.003` ;
|
||||||
|
- maintient la séparation stricte entre Program IDs et well-known accounts.
|
||||||
|
|
||||||
|
## Version Cargo
|
||||||
|
|
||||||
|
Aucun fichier participant au code, build, runtime, à la configuration exécutable ou aux migrations n'est modifié.
|
||||||
|
|
||||||
|
Conformément à `VER-ID-008`, `workspace.package.version` reste donc :
|
||||||
|
|
||||||
|
```text
|
||||||
|
0.1.1-pre.1
|
||||||
|
```
|
||||||
|
|
||||||
|
Le correctif possède néanmoins son identifiant de livraison/commit propre :
|
||||||
|
|
||||||
|
```text
|
||||||
|
0.1.1-pre.001-fix.001
|
||||||
|
```
|
||||||
|
|
||||||
|
## Fichiers ajoutés
|
||||||
|
|
||||||
|
- `deltas/0.1.1/pre.001-fix.001.md`
|
||||||
|
|
||||||
|
## Fichiers modifiés
|
||||||
|
|
||||||
|
- `docs/plans/003-V0_1_1_CORE_FOUNDATION_PLAN.md` — version documentaire 1 -> 2.
|
||||||
|
|
||||||
|
## Fichiers supprimés
|
||||||
|
|
||||||
|
Aucun.
|
||||||
|
|
||||||
|
## Corrections et décisions incorporées
|
||||||
|
|
||||||
|
### Provenance de la base stable
|
||||||
|
|
||||||
|
La réserve de `pre.001` liée à l'absence de `.git` dans l'archive Gitea est retirée du plan actif.
|
||||||
|
|
||||||
|
Dans le workflow KSP fourni, une archive nommée `khadhroony-solana-project-vX.Y.Z.zip` est produite directement par Gitea depuis le tag correspondant. `khadhroony-solana-project-v0.0.3.zip` est donc acceptée comme base stable/taguée `v0.0.3`.
|
||||||
|
|
||||||
|
Le delta `pre.001` déjà livré n'est pas réécrit ; ce correctif trace explicitement la correction.
|
||||||
|
|
||||||
|
### Dépendances Solana/Anza
|
||||||
|
|
||||||
|
Direction acquise :
|
||||||
|
|
||||||
|
```text
|
||||||
|
ksp-core-lib -> solana-pubkey
|
||||||
|
```
|
||||||
|
|
||||||
|
lorsque `pre.003` implémentera réellement la surface Program IDs.
|
||||||
|
|
||||||
|
En revanche :
|
||||||
|
|
||||||
|
```text
|
||||||
|
ksp-core-lib -X-> solana-sdk-ids
|
||||||
|
```
|
||||||
|
|
||||||
|
s'applique aux dépendances runtime **et** de développement.
|
||||||
|
|
||||||
|
`solana-sdk-ids` peut être consultée comme source officielle externe lors des audits, mais elle ne doit pas entrer dans le graphe Cargo KSP.
|
||||||
|
|
||||||
|
### Ownership et représentations des Program IDs
|
||||||
|
|
||||||
|
KSP possède la valeur Base58 canonique de chaque Program ID retenu.
|
||||||
|
|
||||||
|
Chaque ID expose deux représentations publiques liées :
|
||||||
|
|
||||||
|
```text
|
||||||
|
PRGID_<SUFFIXE> : &'static str
|
||||||
|
PRGIDPK_<SUFFIXE> : Pubkey
|
||||||
|
```
|
||||||
|
|
||||||
|
Le suffixe doit être strictement identique entre les deux formes.
|
||||||
|
|
||||||
|
La nomenclature générale est :
|
||||||
|
|
||||||
|
```text
|
||||||
|
<PREFIX>_<DOMAIN>_<SUBDOMAIN?>_<NAME>_<VERSION?>
|
||||||
|
```
|
||||||
|
|
||||||
|
Exemples de convention :
|
||||||
|
|
||||||
|
```text
|
||||||
|
PRGID_SOLANA_SYSTEM
|
||||||
|
PRGIDPK_SOLANA_SYSTEM
|
||||||
|
|
||||||
|
PRGID_SOLANA_LOADER_BPF_V2
|
||||||
|
PRGIDPK_SOLANA_LOADER_BPF_V2
|
||||||
|
|
||||||
|
PRGID_SOLANA_PRECOMPILE_ED25519
|
||||||
|
PRGIDPK_SOLANA_PRECOMPILE_ED25519
|
||||||
|
|
||||||
|
PRGID_SPL_MEMO_V3
|
||||||
|
PRGIDPK_SPL_MEMO_V3
|
||||||
|
```
|
||||||
|
|
||||||
|
L'exemple SPL Memo définit uniquement la convention future ; il n'ouvre pas SPL dans le périmètre fonctionnel de `0.1.1`.
|
||||||
|
|
||||||
|
### Macro de déclaration
|
||||||
|
|
||||||
|
Le plan prévoit une macro publique KSP initialement nommée :
|
||||||
|
|
||||||
|
```text
|
||||||
|
declare_program_id!
|
||||||
|
```
|
||||||
|
|
||||||
|
Elle doit prendre une seule valeur Base58 canonique et produire les deux constantes `PRGID_*` et `PRGIDPK_*` correspondantes à la compilation.
|
||||||
|
|
||||||
|
La macro doit s'inspirer de la mécanique compile-time de `solana_address::declare_id!`/des primitives accessibles via la génération retenue de `solana-pubkey`, tout en conservant une API KSP adaptée à plusieurs Program IDs dans la même crate.
|
||||||
|
|
||||||
|
Elle ne doit notamment pas imposer des symboles génériques `ID`, `id()` ou `check_id()` qui entreraient en collision entre plusieurs déclarations.
|
||||||
|
|
||||||
|
### Registre descriptif enumerable
|
||||||
|
|
||||||
|
Le rejet initial d'un `ProgramIdEntry` enumerable est annulé.
|
||||||
|
|
||||||
|
L'ancien `ks-program-ids` fournissait notamment :
|
||||||
|
|
||||||
|
```text
|
||||||
|
ProgramIdEntry
|
||||||
|
entries()
|
||||||
|
registered_program_ids()
|
||||||
|
native_program_ids()
|
||||||
|
native_well_known_account_ids()
|
||||||
|
find_registered_program_id()
|
||||||
|
```
|
||||||
|
|
||||||
|
La surface KSP doit reprendre/améliorer les capacités utiles sans reprendre les redondances historiques.
|
||||||
|
|
||||||
|
Direction de `pre.003` :
|
||||||
|
|
||||||
|
```text
|
||||||
|
ProgramIdEntry
|
||||||
|
entries()
|
||||||
|
native_program_ids()
|
||||||
|
find_program_id()
|
||||||
|
```
|
||||||
|
|
||||||
|
Une recherche typée par `Pubkey` reste autorisée si son utilité est démontrée pendant l'implémentation.
|
||||||
|
|
||||||
|
`registered_program_ids()` n'est pas repris automatiquement s'il ne fait que dupliquer `entries()`.
|
||||||
|
|
||||||
|
`ProgramIdEntry` doit pouvoir exposer au minimum un code KSP stable, les formes `PRGID_*`/`PRGIDPK_*` et une classification descriptive minimale permettant les sous-ensembles utiles sans dupliquer plusieurs registres.
|
||||||
|
|
||||||
|
### Program IDs fondamentaux
|
||||||
|
|
||||||
|
La liste de `pre.001` n'est plus figée à 17 entrées.
|
||||||
|
|
||||||
|
L'inventaire final sera confirmé dans `pre.003` contre les sources officielles actuelles, en couvrant notamment :
|
||||||
|
|
||||||
|
- System, Stake, Vote, Config, Feature et Compute Budget ;
|
||||||
|
- Address Lookup Table ;
|
||||||
|
- loaders BPF historiques/actuels, Loader v4 et Native Loader ;
|
||||||
|
- précompiles Ed25519, Secp256k1 et Secp256r1 ;
|
||||||
|
- programmes ZK fondamentaux encore pertinents ;
|
||||||
|
- toute surface native/historique supplémentaire réellement justifiée.
|
||||||
|
|
||||||
|
L'ancien `ks-program-ids` reste un inventaire historique utile. Son entrée `slashing` doit par exemple être réévaluée selon son statut officiel actuel plutôt que retenue ou rejetée uniquement parce qu'elle figurait dans bot3.
|
||||||
|
|
||||||
|
### Program IDs et well-known accounts
|
||||||
|
|
||||||
|
Les Program IDs exécutables et les well-known account IDs restent deux concepts distincts.
|
||||||
|
|
||||||
|
`PRGID_*` / `PRGIDPK_*` ne doivent jamais nommer un compte connu non exécutable.
|
||||||
|
|
||||||
|
Le concept historique `native_well_known_account_ids()` est conservé comme direction architecturale possible, mais `0.1.1` ne crée pas une API vide pour ce domaine si aucun well-known account n'est retenu dans sa surface réelle.
|
||||||
|
|
||||||
|
## Impact sur les prereleases suivantes
|
||||||
|
|
||||||
|
`pre.002` ne change pas :
|
||||||
|
|
||||||
|
```text
|
||||||
|
Error / Result
|
||||||
|
```
|
||||||
|
|
||||||
|
`pre.003` est précisé :
|
||||||
|
|
||||||
|
```text
|
||||||
|
Pubkey
|
||||||
|
+ declare_program_id!
|
||||||
|
+ PRGID_* / PRGIDPK_*
|
||||||
|
+ inventaire final des Program IDs fondamentaux
|
||||||
|
+ ProgramIdEntry / entries() / native_program_ids() / find_program_id()
|
||||||
|
+ tests de conformité/unicité
|
||||||
|
```
|
||||||
|
|
||||||
|
Aucune dépendance `solana-sdk-ids` ne doit y être ajoutée.
|
||||||
|
|
||||||
|
## Hors scope inchangé
|
||||||
|
|
||||||
|
Le correctif n'ouvre toujours pas :
|
||||||
|
|
||||||
|
- Logging ;
|
||||||
|
- Config ;
|
||||||
|
- Tauri ;
|
||||||
|
- Wallet/signing ;
|
||||||
|
- codecs wire ;
|
||||||
|
- Interface ;
|
||||||
|
- Program decoding/dispatch registry/`ProgramExecutionPreparer` ;
|
||||||
|
- execution policy/orchestration ;
|
||||||
|
- Transport ;
|
||||||
|
- Store ;
|
||||||
|
- Materializer ;
|
||||||
|
- workers/jobs/pipelines ;
|
||||||
|
- scenarios ;
|
||||||
|
- trading/ML.
|
||||||
|
|
||||||
|
## Validations exécutées
|
||||||
|
|
||||||
|
- relecture des règles `VERSION_WORKFLOW.md` et `FILE_CONTRACTS.md` de la base stable ;
|
||||||
|
- confirmation qu'un fix purement documentaire ne modifie pas la version Cargo ;
|
||||||
|
- réaudit ciblé de l'ancien `ks-program-ids` fourni dans l'archive bot3 de référence : `ProgramIdEntry`, `entries()`, `registered_program_ids()`, `native_program_ids()`, `native_well_known_account_ids()` et `find_registered_program_id()` ;
|
||||||
|
- relecture du plan `003-V0_1_1_CORE_FOUNDATION_PLAN.md` après correction ;
|
||||||
|
- contrôle statique du header/version des fichiers livrés ;
|
||||||
|
- contrôle des fins de fichiers ;
|
||||||
|
- contrôle de la structure et du contenu de l'archive ;
|
||||||
|
- vérification de l'absence de fichier Cargo/code/runtime dans ce correctif.
|
||||||
|
|
||||||
|
## Validations non exécutées
|
||||||
|
|
||||||
|
Aucune validation Cargo n'est déclarée pour ce correctif documentaire.
|
||||||
|
|
||||||
|
Les commandes suivantes ne sont pas nécessaires pour démontrer le contenu de ce delta, qui ne modifie aucun artefact compilé :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo fmt --all
|
||||||
|
cargo check --workspace
|
||||||
|
cargo test --workspace
|
||||||
|
cargo clippy --workspace --all-targets
|
||||||
|
```
|
||||||
|
|
||||||
|
Elles restent les validations attendues dès la prochaine tranche Rust applicable.
|
||||||
|
|
||||||
|
## Questions ouvertes
|
||||||
|
|
||||||
|
Les décisions nécessaires pour quitter `pre.001` sont considérées validées.
|
||||||
|
|
||||||
|
Les points d'implémentation suivants sont volontairement reportés à leur tranche propriétaire sans bloquer `pre.002` :
|
||||||
|
|
||||||
|
- structure Rust exacte et classification minimale de `ProgramIdEntry` en `pre.003` ;
|
||||||
|
- présence éventuelle d'une recherche dédiée par `Pubkey` ;
|
||||||
|
- inventaire final des IDs natifs/historiques à partir des sources officielles actuelles ;
|
||||||
|
- détail d'expansion de `declare_program_id!` selon l'API exacte de la version `solana-pubkey` retenue.
|
||||||
|
|
||||||
|
## Suite
|
||||||
|
|
||||||
|
Après validation/commit de ce correctif :
|
||||||
|
|
||||||
|
```text
|
||||||
|
0.1.1-pre.002 — Error/Result et fondation API
|
||||||
|
```
|
||||||
277
deltas/0.1.1/pre.001-fix.002.md
Normal file
277
deltas/0.1.1/pre.001-fix.002.md
Normal file
@@ -0,0 +1,277 @@
|
|||||||
|
<!-- file: deltas/0.1.1/pre.001-fix.002.md -->
|
||||||
|
<!-- version: 1 -->
|
||||||
|
|
||||||
|
# Delta 0.1.1-pre.001-fix.002
|
||||||
|
|
||||||
|
## Base requise
|
||||||
|
|
||||||
|
Livraison précédente appliquée et commitée :
|
||||||
|
|
||||||
|
```text
|
||||||
|
0.1.1-pre.001-fix.001
|
||||||
|
```
|
||||||
|
|
||||||
|
## Type de livraison
|
||||||
|
|
||||||
|
```text
|
||||||
|
ksp-doc-0.1.1-pre.001-fix.002.zip
|
||||||
|
```
|
||||||
|
|
||||||
|
## Objectif
|
||||||
|
|
||||||
|
Compléter le cadrage Program IDs de `pre.001` avant ouverture de `pre.002`, après réaudit de l'ancien registre/IDLs bot3 et confrontation à des Program IDs publics actuels.
|
||||||
|
|
||||||
|
Ce correctif reste purement documentaire. Il fixe l'architecture de classification/recherche nécessaire pour que le registre KSP puisse ultérieurement retrouver les programmes par domaine, famille, protocole, sous-famille et génération sans dupliquer plusieurs registres statiques.
|
||||||
|
|
||||||
|
## Version Cargo
|
||||||
|
|
||||||
|
Aucun fichier de code/build/runtime n'est modifié.
|
||||||
|
|
||||||
|
`workspace.package.version` reste donc :
|
||||||
|
|
||||||
|
```text
|
||||||
|
0.1.1-pre.1
|
||||||
|
```
|
||||||
|
|
||||||
|
Identifiant de livraison/commit :
|
||||||
|
|
||||||
|
```text
|
||||||
|
0.1.1-pre.001-fix.002
|
||||||
|
```
|
||||||
|
|
||||||
|
## Fichiers ajoutés
|
||||||
|
|
||||||
|
- `deltas/0.1.1/pre.001-fix.002.md`
|
||||||
|
|
||||||
|
## Fichiers modifiés
|
||||||
|
|
||||||
|
- `docs/plans/003-V0_1_1_CORE_FOUNDATION_PLAN.md` — version documentaire 2 -> 3.
|
||||||
|
|
||||||
|
## Décisions acquises
|
||||||
|
|
||||||
|
### Registre canonique unique
|
||||||
|
|
||||||
|
KSP ne doit pas maintenir des tableaux indépendants pour chaque vue (`native`, `amm`, protocole, etc.).
|
||||||
|
|
||||||
|
`entries()` reste la source canonique et les vues/recherches sont dérivées de la classification de chaque `ProgramIdEntry`.
|
||||||
|
|
||||||
|
### Taxonomie minimale
|
||||||
|
|
||||||
|
`ProgramIdEntry` doit être conçu pour porter au minimum les axes descriptifs suivants :
|
||||||
|
|
||||||
|
```text
|
||||||
|
domain
|
||||||
|
family
|
||||||
|
protocol
|
||||||
|
subfamily?
|
||||||
|
program_version?
|
||||||
|
kind
|
||||||
|
```
|
||||||
|
|
||||||
|
auxquels s'ajoutent le code KSP unique, la chaîne Base58 `PRGID_*` et le `Pubkey` `PRGIDPK_*`.
|
||||||
|
|
||||||
|
Les vocabulaires de classification restent extensibles. Core ne doit pas posséder une enum fermée de tous les protocoles/familles futurs.
|
||||||
|
|
||||||
|
### AMM comme famille agrégatrice
|
||||||
|
|
||||||
|
Le réaudit de bot3 montre que les anciens préfixes `AMM`, `CPMM`, `CLMM`, `DLMM`, `STABLE_SWAP` et `WEIGHTED_SWAP` représentent plusieurs spécialisations d'une même grande famille utile pour la recherche.
|
||||||
|
|
||||||
|
La direction KSP devient donc :
|
||||||
|
|
||||||
|
```text
|
||||||
|
family = amm
|
||||||
|
```
|
||||||
|
|
||||||
|
avec des `subfamily` optionnelles telles que :
|
||||||
|
|
||||||
|
```text
|
||||||
|
cpmm
|
||||||
|
clmm
|
||||||
|
dlmm
|
||||||
|
damm
|
||||||
|
stable_swap
|
||||||
|
weighted_swap
|
||||||
|
gamma
|
||||||
|
ssl
|
||||||
|
```
|
||||||
|
|
||||||
|
lorsqu'elles correspondent réellement à une branche/architecture reconnue.
|
||||||
|
|
||||||
|
Une future `amm_program_ids()` doit filtrer le registre canonique sur cette famille et inclure toutes ces sous-familles.
|
||||||
|
|
||||||
|
### `subfamily` et `program_version` sont deux axes indépendants
|
||||||
|
|
||||||
|
La version ne doit pas être détournée en sous-famille.
|
||||||
|
|
||||||
|
Cas audités :
|
||||||
|
|
||||||
|
- SPL Memo : trois Program IDs v1/v3/v4, même lignée fonctionnelle ;
|
||||||
|
- Aldrin AMM : v1/v2, même famille/protocole ;
|
||||||
|
- Meteora DAMM : sous-famille `damm`, versions v1/v2 ;
|
||||||
|
- Meteora DLMM : sous-famille `dlmm` distincte de DAMM ;
|
||||||
|
- Jupiter Aggregator : même lignée, versions v4/v6 ;
|
||||||
|
- GooseFX : GAMMA et SSL sont des branches distinctes ; `SSL v2` possède une version, tandis qu'aucun `v1` ne doit être inventé pour GAMMA.
|
||||||
|
|
||||||
|
### Nom du champ de version
|
||||||
|
|
||||||
|
Le champ retenu conceptuellement est :
|
||||||
|
|
||||||
|
```text
|
||||||
|
program_version
|
||||||
|
```
|
||||||
|
|
||||||
|
et non `protocol_version`.
|
||||||
|
|
||||||
|
Raison : un protocole peut posséder simultanément plusieurs programmes/composants dont les versions évoluent indépendamment. La version doit être attachée à la lignée du Program ID concerné, pas au protocole entier.
|
||||||
|
|
||||||
|
`program_version` est un label de génération reconnu (`v1`, `v2`, `v4`, `v6`, `v0.5`, etc.), pas nécessairement un SemVer.
|
||||||
|
|
||||||
|
### Version d'IDL explicitement distincte
|
||||||
|
|
||||||
|
Une version d'IDL/schema n'est pas une version de Program ID.
|
||||||
|
|
||||||
|
Exemples observés dans les IDLs archivées bot3 :
|
||||||
|
|
||||||
|
- Jupiter V6 : IDL `0.1.0` ;
|
||||||
|
- GooseFX SSL V2 : IDL `0.3.0` ;
|
||||||
|
- GooseFX GAMMA : IDL/schema `0.2.0` ;
|
||||||
|
- Meteora DAMM V2 : IDL/schema distinct de la génération publique `v2`.
|
||||||
|
|
||||||
|
La provenance/version des IDLs appartiendra à la couche Interface/decoder lorsqu'elle sera ouverte ; elle n'entre pas dans `ProgramIdEntry` de Core en `0.1.1`.
|
||||||
|
|
||||||
|
### Nomenclature des constantes
|
||||||
|
|
||||||
|
La nomenclature Rust est séparée de la taxonomie fonctionnelle.
|
||||||
|
|
||||||
|
Le premier segment après `PRGID_`/`PRGIDPK_` est désormais nommé `NAMESPACE`, afin de ne pas confondre le nom public stable avec le champ taxonomique `domain` :
|
||||||
|
|
||||||
|
```text
|
||||||
|
PRGID_<NAMESPACE>_<PROGRAM_OR_FAMILY>_<VARIANT?>_<VERSION?>
|
||||||
|
PRGIDPK_<NAMESPACE>_<PROGRAM_OR_FAMILY>_<VARIANT?>_<VERSION?>
|
||||||
|
```
|
||||||
|
|
||||||
|
Exemples :
|
||||||
|
|
||||||
|
```text
|
||||||
|
PRGID_SPL_MEMO_V3
|
||||||
|
PRGIDPK_SPL_MEMO_V3
|
||||||
|
|
||||||
|
PRGID_METEORA_DAMM_V2
|
||||||
|
PRGIDPK_METEORA_DAMM_V2
|
||||||
|
|
||||||
|
PRGID_GOOSEFX_GAMMA
|
||||||
|
PRGIDPK_GOOSEFX_GAMMA
|
||||||
|
|
||||||
|
PRGID_GOOSEFX_SSL_V2
|
||||||
|
PRGIDPK_GOOSEFX_SSL_V2
|
||||||
|
```
|
||||||
|
|
||||||
|
Le symbole public ne doit pas être renommé uniquement parce qu'une classification fonctionnelle est affinée plus tard.
|
||||||
|
|
||||||
|
### Recherches et vues prévues
|
||||||
|
|
||||||
|
Direction conceptuelle :
|
||||||
|
|
||||||
|
```text
|
||||||
|
ProgramIdEntry
|
||||||
|
ProgramIdFilter
|
||||||
|
entries()
|
||||||
|
program_ids(filter)
|
||||||
|
native_program_ids()
|
||||||
|
find_program_id()
|
||||||
|
```
|
||||||
|
|
||||||
|
Le filtre doit pouvoir combiner plusieurs critères.
|
||||||
|
|
||||||
|
Des helpers de recherche peuvent être proposés :
|
||||||
|
|
||||||
|
```text
|
||||||
|
program_ids_by_domain(...)
|
||||||
|
program_ids_by_family(...)
|
||||||
|
program_ids_by_protocol(...)
|
||||||
|
```
|
||||||
|
|
||||||
|
`native_program_ids()` reste une vue Core réelle de `0.1.1`.
|
||||||
|
|
||||||
|
`amm_program_ids()` est explicitement prévue pour la surface future possédant des IDs AMM, mais n'est pas créée vide pendant `0.1.1` puisque les AMM sont hors scope fonctionnel de la release.
|
||||||
|
|
||||||
|
Les vues doivent pouvoir être des iterators/views du registre canonique afin d'éviter la duplication de tableaux statiques.
|
||||||
|
|
||||||
|
## Réaudit effectué
|
||||||
|
|
||||||
|
### Archive bot3
|
||||||
|
|
||||||
|
Le réaudit a porté sur :
|
||||||
|
|
||||||
|
- `ks-program-ids` et ses 137 entrées historiques ;
|
||||||
|
- `ProgramIdEntry`, `entries()`, `native_program_ids()` et la recherche historique ;
|
||||||
|
- les familles historiques AMM/CLMM/CPMM/DLMM/router/orderbook/etc. ;
|
||||||
|
- les IDLs archivées, notamment GooseFX GAMMA/V2, Meteora DAMM/DLMM, Jupiter v4/v6, CCTP v1/v2, Raydium CLMM/CPMM, OpenBook v2 et Marginfi v2.
|
||||||
|
|
||||||
|
L'inventaire montre également qu'un même protocole traverse plusieurs familles : Jupiter, Raydium, Meteora, Kamino, MetaDAO, Pump, Metaplex, Orca, etc. `protocol` doit donc être un axe séparé de `family`.
|
||||||
|
|
||||||
|
### Sources externes actuelles
|
||||||
|
|
||||||
|
Contrôles représentatifs effectués le 2026-08-14 :
|
||||||
|
|
||||||
|
- Agave runtime `fetch-spl.sh` : Memo 1.0.0, 3.0.0 et 4.0.0 sont associés à trois Program IDs distincts ;
|
||||||
|
- `spl-memo-interface` actuel : modules `v1`, `v3`, `v4` distincts ;
|
||||||
|
- Solana Explorer et Solscan : présence/identification des Program IDs Memo et de programmes DEX audités ;
|
||||||
|
- GooseFX officiel : GAMMA est une lignée AMM distincte ; l'écosystème publie également la lignée SSL ;
|
||||||
|
- Meteora officiel : DAMM v1, DAMM v2 et DLMM sont des surfaces distinctes ;
|
||||||
|
- Jupiter officiel : plusieurs générations du Swap Aggregator sont distinguées par Program ID.
|
||||||
|
|
||||||
|
Ces contrôles sont utilisés comme validation de taxonomie, pas comme dépendances KSP.
|
||||||
|
|
||||||
|
## Impact sur `pre.003`
|
||||||
|
|
||||||
|
`pre.003` devra désormais :
|
||||||
|
|
||||||
|
- implémenter `ProgramIdEntry` avec une taxonomie compatible avec les axes acquis ;
|
||||||
|
- implémenter `ProgramIdFilter` ou une forme équivalente permettant les intersections ;
|
||||||
|
- dériver `native_program_ids()` du registre canonique ;
|
||||||
|
- tester les filtres par domaine/famille/protocole/sous-famille/version/kind ;
|
||||||
|
- ne jamais confondre génération du programme et version d'IDL ;
|
||||||
|
- préserver la possibilité d'ajouter plus tard `amm_program_ids()` sans modifier la structure fondamentale du registre.
|
||||||
|
|
||||||
|
## Hors scope inchangé
|
||||||
|
|
||||||
|
Ce correctif n'ajoute aucun Program ID SPL/DEX au code de `0.1.1` et n'ouvre toujours pas :
|
||||||
|
|
||||||
|
- Logging ;
|
||||||
|
- Config ;
|
||||||
|
- Tauri ;
|
||||||
|
- Wallet/signing ;
|
||||||
|
- codecs wire ;
|
||||||
|
- Interface/IDL runtime ;
|
||||||
|
- Program decoding/dispatch ;
|
||||||
|
- execution ;
|
||||||
|
- Transport ;
|
||||||
|
- Store ;
|
||||||
|
- Materializer ;
|
||||||
|
- workers/jobs/pipelines ;
|
||||||
|
- scenarios ;
|
||||||
|
- trading/ML.
|
||||||
|
|
||||||
|
## Validations exécutées
|
||||||
|
|
||||||
|
- réaudit du registre `ks-program-ids` de l'archive bot3 ;
|
||||||
|
- inventaire des Program IDs versionnés et des protocoles présents dans plusieurs familles ;
|
||||||
|
- inspection ciblée des IDLs multi-version/à plusieurs sous-familles ;
|
||||||
|
- confrontation représentative avec Agave/SPL, Solana Explorer, Solscan, GooseFX, Meteora et Jupiter ;
|
||||||
|
- relecture du plan après modification ;
|
||||||
|
- contrôle statique des headers/version documentaire ;
|
||||||
|
- contrôle des fins de fichiers ;
|
||||||
|
- contrôle de l'absence de modification Cargo/code/runtime dans ce fix.
|
||||||
|
|
||||||
|
## Validations non exécutées
|
||||||
|
|
||||||
|
Aucune validation Cargo n'est requise pour ce fix exclusivement documentaire et aucune n'est déclarée réussie.
|
||||||
|
|
||||||
|
## Suite
|
||||||
|
|
||||||
|
Après application et commit de ce correctif, le cadrage `pre.001` peut être considéré comme clôturé et la session peut passer à :
|
||||||
|
|
||||||
|
```text
|
||||||
|
0.1.1-pre.002 — Error/Result et fondation API
|
||||||
|
```
|
||||||
219
deltas/0.1.1/pre.001.md
Normal file
219
deltas/0.1.1/pre.001.md
Normal file
@@ -0,0 +1,219 @@
|
|||||||
|
<!-- file: deltas/0.1.1/pre.001.md -->
|
||||||
|
<!-- version: 1 -->
|
||||||
|
|
||||||
|
# Delta 0.1.1-pre.001
|
||||||
|
|
||||||
|
## Base requise
|
||||||
|
|
||||||
|
Release stable/taguée attendue :
|
||||||
|
|
||||||
|
```text
|
||||||
|
v0.0.3
|
||||||
|
```
|
||||||
|
|
||||||
|
L'archive de base fournie contient bien la version Cargo stable `0.0.3` et le delta final `deltas/0.0.3/rel.001.md`.
|
||||||
|
|
||||||
|
Elle ne contient pas `.git` : le working tree réel, le commit et le tag `v0.0.3` doivent être vérifiés sur le dépôt cible avant commit de ce delta.
|
||||||
|
|
||||||
|
## Objectif
|
||||||
|
|
||||||
|
Ouvrir `0.1.1` par la prerelease obligatoire de brainstorming, audit et planification, sans développement fonctionnel Core.
|
||||||
|
|
||||||
|
Cette tranche :
|
||||||
|
|
||||||
|
- inventorie la surface réelle de `ksp-core-lib` ;
|
||||||
|
- borne les contrats N1 de la release ;
|
||||||
|
- propose le contrat ouvert `Error` / `Result` ;
|
||||||
|
- borne les Program IDs fondamentaux ;
|
||||||
|
- audite les primitives Solana/Anza actuelles nécessaires ;
|
||||||
|
- fixe la stratégie d'API, tests et dépendances ;
|
||||||
|
- dimensionne `pre.002` à `pre.005` ;
|
||||||
|
- confirme les hors-scope.
|
||||||
|
|
||||||
|
## Version Cargo
|
||||||
|
|
||||||
|
`workspace.package.version` passe de :
|
||||||
|
|
||||||
|
```text
|
||||||
|
0.0.3
|
||||||
|
```
|
||||||
|
|
||||||
|
à :
|
||||||
|
|
||||||
|
```text
|
||||||
|
0.1.1-pre.1
|
||||||
|
```
|
||||||
|
|
||||||
|
L'identifiant Cargo respecte SemVer sans zéro initial ; l'identifiant de livraison reste `0.1.1-pre.001`.
|
||||||
|
|
||||||
|
Le header de `Cargo.toml` passe de version 17 à 18.
|
||||||
|
|
||||||
|
## Fichiers ajoutés
|
||||||
|
|
||||||
|
- `docs/plans/003-V0_1_1_CORE_FOUNDATION_PLAN.md`
|
||||||
|
- `deltas/0.1.1/pre.001.md`
|
||||||
|
|
||||||
|
## Fichiers modifiés
|
||||||
|
|
||||||
|
- `Cargo.toml`
|
||||||
|
- `ROADMAP.md`
|
||||||
|
- `docs/plans/000-README.md`
|
||||||
|
|
||||||
|
## Fichiers supprimés
|
||||||
|
|
||||||
|
Aucun.
|
||||||
|
|
||||||
|
## Inventaire Core
|
||||||
|
|
||||||
|
`ksp-core-lib` est encore un squelette volontairement minimal :
|
||||||
|
|
||||||
|
- aucun module fonctionnel ;
|
||||||
|
- aucune dépendance externe ;
|
||||||
|
- aucun type public autre que la documentation de crate ;
|
||||||
|
- aucun test ;
|
||||||
|
- aucune surface Error/Result ou Program IDs existante à préserver pour compatibilité.
|
||||||
|
|
||||||
|
Cette situation permet de définir le contrat sans dette de compatibilité interne KSP.
|
||||||
|
|
||||||
|
## Décisions de planification
|
||||||
|
|
||||||
|
### Error/Result
|
||||||
|
|
||||||
|
Le modèle historique bot3 avec enum centrale de domaines n'est pas migré.
|
||||||
|
|
||||||
|
La direction retenue pour `pre.002` est :
|
||||||
|
|
||||||
|
- `Error` structuré ;
|
||||||
|
- `ErrorCode` ouvert avec domaine/code statiques ;
|
||||||
|
- `ErrorContext` structuré ;
|
||||||
|
- message lisible ;
|
||||||
|
- cause standard optionnelle `Send + Sync` ;
|
||||||
|
- alias `Result<T>` ;
|
||||||
|
- aucune connaissance dans Core des futurs domaines Config/Logging/Wallet/Transport/Store/Tauri/protocoles ;
|
||||||
|
- aucune liste centrale de conversions d'erreurs externes.
|
||||||
|
|
||||||
|
Le détail complet et les invariants figurent dans `docs/plans/003-V0_1_1_CORE_FOUNDATION_PLAN.md`.
|
||||||
|
|
||||||
|
### Solana/Anza
|
||||||
|
|
||||||
|
Sources officielles consultées le 2026-08-14 : dépôt `anza-xyz/solana-sdk`, notamment la crate Pubkey, la primitive Address, `sdk-ids` et le manifest workspace.
|
||||||
|
|
||||||
|
État vérifié :
|
||||||
|
|
||||||
|
```text
|
||||||
|
solana-pubkey 4.3.0
|
||||||
|
solana-address 2.7.0
|
||||||
|
solana-sdk-ids 3.1.0 package
|
||||||
|
Solana SDK workspace MSRV 1.89.0
|
||||||
|
```
|
||||||
|
|
||||||
|
Direction retenue :
|
||||||
|
|
||||||
|
- runtime Core : `solana-pubkey` seulement, lorsque `pre.003` implémentera réellement les Program IDs ;
|
||||||
|
- `default-features = false` tant qu'aucune feature supplémentaire n'est démontrée nécessaire ;
|
||||||
|
- `Pubkey` réexporté depuis `ksp_core_lib` ;
|
||||||
|
- pas de dépendance directe KSP à `solana-address` ;
|
||||||
|
- `solana-sdk-ids` seulement comme dev-dependency candidate pour les tests de conformité, pas comme dépendance runtime ;
|
||||||
|
- aucune autre primitive Solana autorisée n'est ajoutée par anticipation.
|
||||||
|
|
||||||
|
Les versions seront revérifiées juste avant leur ajout réel au manifeste.
|
||||||
|
|
||||||
|
### Program IDs
|
||||||
|
|
||||||
|
Première surface proposée : 17 Program IDs fondamentaux exposés par la source officielle Solana SDK, incluant les loaders et précompiles de la frontière runtime :
|
||||||
|
|
||||||
|
- Address Lookup Table ;
|
||||||
|
- BPF Loader ;
|
||||||
|
- BPF Loader deprecated ;
|
||||||
|
- BPF Loader Upgradeable ;
|
||||||
|
- Compute Budget ;
|
||||||
|
- Config ;
|
||||||
|
- Ed25519 precompile ;
|
||||||
|
- Feature ;
|
||||||
|
- Loader v4 ;
|
||||||
|
- Native Loader ;
|
||||||
|
- Secp256k1 precompile ;
|
||||||
|
- Secp256r1 precompile ;
|
||||||
|
- Stake ;
|
||||||
|
- System ;
|
||||||
|
- Vote ;
|
||||||
|
- ZK ElGamal Proof ;
|
||||||
|
- ZK Token Proof.
|
||||||
|
|
||||||
|
Sont exclus : sysvars, incinerator, stake config account, SPL/protocoles, registre enumerable et alias bot3 historiques.
|
||||||
|
|
||||||
|
### Autres primitives
|
||||||
|
|
||||||
|
Aucune autre primitive commune n'est justifiée maintenant.
|
||||||
|
|
||||||
|
`Hash`, `Nonce`, Keypair, Signer, identité/version de module et provenance restent reportés jusqu'à un besoin concret.
|
||||||
|
|
||||||
|
## Prereleases prévues
|
||||||
|
|
||||||
|
```text
|
||||||
|
pre.001 audit + brainstorming + plan
|
||||||
|
pre.002 Error/Result + tests publics
|
||||||
|
pre.003 Pubkey + Program IDs + conformité Solana
|
||||||
|
pre.004 intégration Core + audits + compléments strictement justifiés
|
||||||
|
pre.005 validations finales + docs/cleanup + prompt 0.1.2
|
||||||
|
```
|
||||||
|
|
||||||
|
Le découpage reste souple ; une tranche trop large sera scindée plutôt que surchargée.
|
||||||
|
|
||||||
|
## Hors scope confirmé
|
||||||
|
|
||||||
|
- Logging ;
|
||||||
|
- Config ;
|
||||||
|
- Tauri ;
|
||||||
|
- Wallet/signing ;
|
||||||
|
- codecs wire ;
|
||||||
|
- Interface ;
|
||||||
|
- Program decoding/registry/preparation ;
|
||||||
|
- Execution ;
|
||||||
|
- Transport ;
|
||||||
|
- Store ;
|
||||||
|
- Materializer ;
|
||||||
|
- workers/jobs/pipelines ;
|
||||||
|
- scenarios ;
|
||||||
|
- trading/ML.
|
||||||
|
|
||||||
|
## Validations exécutées
|
||||||
|
|
||||||
|
Dans l'environnement de préparation de ce delta :
|
||||||
|
|
||||||
|
- lecture/audit de l'archive complète `0.0.3` fournie ;
|
||||||
|
- vérification de la version stable `0.0.3` dans le manifest ;
|
||||||
|
- vérification de la présence du delta `0.0.3/rel.001` et du prompt final `0.1.1` ;
|
||||||
|
- inventaire de `ksp-core-lib` ;
|
||||||
|
- lecture des règles, plans et documents d'architecture requis par le prompt ;
|
||||||
|
- audit de l'ancien `ks-core` / `ks-program-ids` de l'archive bot3 fournie comme référence historique, sans le traiter comme source de vérité KSP ;
|
||||||
|
- vérification des versions et surfaces actuelles sur les sources officielles Anza/Solana ;
|
||||||
|
- 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.
|
||||||
|
|
||||||
|
## 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
|
||||||
|
```
|
||||||
|
|
||||||
|
Elles doivent être exécutées sur le dépôt réel après application du delta. Aucun succès Cargo n'est déclaré par ce delta.
|
||||||
|
|
||||||
|
Le tag Git et le working tree ne peuvent pas non plus être vérifiés depuis l'archive fournie, qui ne contient pas `.git`.
|
||||||
|
|
||||||
|
## Questions ouvertes
|
||||||
|
|
||||||
|
- validation par le user du modèle Error/Result proposé avant `pre.002` ;
|
||||||
|
- choix exact des méthodes ergonomiques de contexte et du format `Display`, à stabiliser par tests en `pre.002` ;
|
||||||
|
- revérification de la version Solana et du MSRV juste avant `pre.003` ;
|
||||||
|
- confirmation de l'utilité de `solana-sdk-ids` comme dev-dependency de conformité au moment où les tests sont écrits.
|
||||||
|
|
||||||
|
Aucune question ouverte ne justifie de commencer le développement fonctionnel avant validation de ce plan.
|
||||||
127
deltas/0.1.1/pre.002-fix.001.md
Normal file
127
deltas/0.1.1/pre.002-fix.001.md
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
<!-- file: deltas/0.1.1/pre.002-fix.001.md -->
|
||||||
|
<!-- version: 1 -->
|
||||||
|
|
||||||
|
# Delta 0.1.1-pre.002-fix.001
|
||||||
|
|
||||||
|
## Base requise
|
||||||
|
|
||||||
|
Commit de livraison attendu :
|
||||||
|
|
||||||
|
```text
|
||||||
|
v0.1.1-pre.002
|
||||||
|
```
|
||||||
|
|
||||||
|
La version Cargo reste :
|
||||||
|
|
||||||
|
```text
|
||||||
|
0.1.1-pre.2
|
||||||
|
```
|
||||||
|
|
||||||
|
Ce correctif ne modifie aucun contrat public de `ksp-core-lib` et ne justifie donc aucune nouvelle prerelease Cargo.
|
||||||
|
|
||||||
|
## Objectif
|
||||||
|
|
||||||
|
Corriger les deux warnings remontés par les validations réelles de `0.1.1-pre.002` avant d'ouvrir `0.1.1-pre.003`.
|
||||||
|
|
||||||
|
Les validations exécutées sur le dépôt cible ont confirmé que :
|
||||||
|
|
||||||
|
- `cargo fmt --all` réussit ;
|
||||||
|
- `cargo check --workspace` réussit ;
|
||||||
|
- `cargo test --workspace` réussit avec 6 tests unitaires, 1 test d'intégration et 0 échec ;
|
||||||
|
- `cargo clippy --workspace --all-targets` termine sans erreur mais remonte deux warnings à corriger.
|
||||||
|
|
||||||
|
Warnings observés :
|
||||||
|
|
||||||
|
1. `clippy::extra_unused_type_parameters` sur le helper de vérification `Send + Sync` ;
|
||||||
|
2. `missing_docs` sur la crate de test d'intégration `tests/public_api.rs`.
|
||||||
|
|
||||||
|
## Fichiers modifiés
|
||||||
|
|
||||||
|
- `crates/ksp-core-lib/unit_tests/error.rs`
|
||||||
|
- `crates/ksp-core-lib/tests/public_api.rs`
|
||||||
|
|
||||||
|
## Fichier ajouté
|
||||||
|
|
||||||
|
- `deltas/0.1.1/pre.002-fix.001.md`
|
||||||
|
|
||||||
|
## Fichiers supprimés
|
||||||
|
|
||||||
|
Aucun.
|
||||||
|
|
||||||
|
## Correction `Send + Sync`
|
||||||
|
|
||||||
|
Le helper de test :
|
||||||
|
|
||||||
|
```text
|
||||||
|
assert_send_sync<T>()
|
||||||
|
```
|
||||||
|
|
||||||
|
utilisait `T` uniquement dans ses bornes de trait. Clippy considère alors le paramètre de type comme inutilisé avec `extra_unused_type_parameters`.
|
||||||
|
|
||||||
|
Le helper reçoit désormais un `std::marker::PhantomData<T>` :
|
||||||
|
|
||||||
|
```text
|
||||||
|
assert_send_sync<T>(PhantomData<T>)
|
||||||
|
```
|
||||||
|
|
||||||
|
et le test fournit `PhantomData<crate::Error>`.
|
||||||
|
|
||||||
|
Cette forme conserve exactement l'objectif du test de compilation : l'appel ne compile que si `crate::Error` satisfait `Send + Sync`, tout en utilisant réellement le paramètre générique et sans ajouter de dépendance, d'import ou de logique runtime significative.
|
||||||
|
|
||||||
|
## Correction `missing_docs`
|
||||||
|
|
||||||
|
Le test d'intégration `crates/ksp-core-lib/tests/public_api.rs` constitue une crate Rust indépendante lors de sa compilation.
|
||||||
|
|
||||||
|
Une rustdoc crate-level est ajoutée :
|
||||||
|
|
||||||
|
```text
|
||||||
|
//! Integration tests for the public `ksp-core-lib` error contract.
|
||||||
|
```
|
||||||
|
|
||||||
|
Cela satisfait le lint workspace `missing_docs = warn` sans désactiver le lint et sans documenter artificiellement les helpers privés du test.
|
||||||
|
|
||||||
|
## Headers de fichiers
|
||||||
|
|
||||||
|
Les deux fichiers Rust modifiés passent de :
|
||||||
|
|
||||||
|
```text
|
||||||
|
version: 1
|
||||||
|
```
|
||||||
|
|
||||||
|
à :
|
||||||
|
|
||||||
|
```text
|
||||||
|
version: 2
|
||||||
|
```
|
||||||
|
|
||||||
|
## Contrat public
|
||||||
|
|
||||||
|
Aucun changement.
|
||||||
|
|
||||||
|
Les éléments suivants restent strictement identiques à `0.1.1-pre.002` :
|
||||||
|
|
||||||
|
```text
|
||||||
|
ksp_core_lib::Error
|
||||||
|
ksp_core_lib::ErrorCode
|
||||||
|
ksp_core_lib::ErrorContext
|
||||||
|
ksp_core_lib::Result<T>
|
||||||
|
```
|
||||||
|
|
||||||
|
Le plan `docs/plans/003-V0_1_1_CORE_FOUNDATION_PLAN.md` reste en version documentaire 5 : aucune décision d'architecture ou d'API n'est modifiée par ce correctif.
|
||||||
|
|
||||||
|
## Dépendances
|
||||||
|
|
||||||
|
Aucune dépendance ajoutée ou modifiée.
|
||||||
|
|
||||||
|
## Validations à exécuter après application
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo fmt --all
|
||||||
|
cargo check --workspace
|
||||||
|
cargo test --workspace
|
||||||
|
cargo clippy --workspace --all-targets
|
||||||
|
```
|
||||||
|
|
||||||
|
Le résultat attendu de ce correctif est l'absence des deux warnings qui ont motivé `pre.002-fix.001`.
|
||||||
|
|
||||||
|
Aucune validation du correctif lui-même n'est déclarée réussie tant que ces commandes n'ont pas été exécutées sur le dépôt cible.
|
||||||
215
deltas/0.1.1/pre.002.md
Normal file
215
deltas/0.1.1/pre.002.md
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
<!-- file: deltas/0.1.1/pre.002.md -->
|
||||||
|
<!-- version: 1 -->
|
||||||
|
|
||||||
|
# Delta 0.1.1-pre.002
|
||||||
|
|
||||||
|
## Base requise
|
||||||
|
|
||||||
|
Commit de livraison attendu :
|
||||||
|
|
||||||
|
```text
|
||||||
|
v0.1.1-pre.001-fix.002
|
||||||
|
```
|
||||||
|
|
||||||
|
Le plan actif est `docs/plans/003-V0_1_1_CORE_FOUNDATION_PLAN.md` version documentaire 4, incluant le réalignement du tableau des cas représentatifs effectué avant le commit du correctif précédent.
|
||||||
|
|
||||||
|
## Objectif
|
||||||
|
|
||||||
|
Implémenter la première surface fonctionnelle de `ksp-core-lib` : le contrat commun ouvert `Error` / `Result` validé pendant `pre.001`.
|
||||||
|
|
||||||
|
Cette tranche reste strictement bornée à l'erreur commune et n'ouvre aucune dépendance Solana ni aucun Program ID.
|
||||||
|
|
||||||
|
## Version Cargo
|
||||||
|
|
||||||
|
`workspace.package.version` passe de :
|
||||||
|
|
||||||
|
```text
|
||||||
|
0.1.1-pre.1
|
||||||
|
```
|
||||||
|
|
||||||
|
à :
|
||||||
|
|
||||||
|
```text
|
||||||
|
0.1.1-pre.2
|
||||||
|
```
|
||||||
|
|
||||||
|
L'identifiant Cargo respecte SemVer sans zéro initial ; l'identifiant de livraison reste `0.1.1-pre.002`.
|
||||||
|
|
||||||
|
Le header de `Cargo.toml` passe de version 18 à 19.
|
||||||
|
|
||||||
|
## Fichiers ajoutés
|
||||||
|
|
||||||
|
- `crates/ksp-core-lib/src/error.rs`
|
||||||
|
- `crates/ksp-core-lib/unit_tests/error.rs`
|
||||||
|
- `crates/ksp-core-lib/tests/public_api.rs`
|
||||||
|
- `deltas/0.1.1/pre.002.md`
|
||||||
|
|
||||||
|
## Fichiers modifiés
|
||||||
|
|
||||||
|
- `Cargo.toml`
|
||||||
|
- `crates/ksp-core-lib/src/lib.rs`
|
||||||
|
- `docs/plans/003-V0_1_1_CORE_FOUNDATION_PLAN.md`
|
||||||
|
|
||||||
|
## Fichiers supprimés
|
||||||
|
|
||||||
|
Aucun.
|
||||||
|
|
||||||
|
## Contrat implémenté
|
||||||
|
|
||||||
|
### `ErrorCode`
|
||||||
|
|
||||||
|
`ErrorCode` contient uniquement :
|
||||||
|
|
||||||
|
```text
|
||||||
|
domain: &'static str
|
||||||
|
code: &'static str
|
||||||
|
```
|
||||||
|
|
||||||
|
Décisions :
|
||||||
|
|
||||||
|
- `ErrorCode::new(...)` est `const` afin que chaque crate supérieure puisse définir ses propres codes statiques ;
|
||||||
|
- Core ne possède aucune enum centrale des domaines ;
|
||||||
|
- `domain()` et `code()` exposent les deux identifiants stables ;
|
||||||
|
- `ErrorCode` est `Copy`, `Clone`, `Eq`, `PartialEq`, `Hash` et `Debug` parce qu'il ne contient que deux chaînes statiques.
|
||||||
|
|
||||||
|
### `ErrorContext`
|
||||||
|
|
||||||
|
`ErrorContext` contient :
|
||||||
|
|
||||||
|
```text
|
||||||
|
key: &'static str
|
||||||
|
value: String
|
||||||
|
```
|
||||||
|
|
||||||
|
Le contexte conserve son ordre d'insertion dans `Error`.
|
||||||
|
|
||||||
|
L'API publique expose `ErrorContext::new(...)`, `key()` et `value()`.
|
||||||
|
|
||||||
|
### `Error`
|
||||||
|
|
||||||
|
`Error` contient :
|
||||||
|
|
||||||
|
```text
|
||||||
|
code: ErrorCode
|
||||||
|
message: String
|
||||||
|
context: Vec<ErrorContext>
|
||||||
|
source: Option<Box<dyn std::error::Error + Send + Sync + 'static>>
|
||||||
|
```
|
||||||
|
|
||||||
|
Décisions stabilisées :
|
||||||
|
|
||||||
|
- `Error::new(...)` construit l'erreur minimale ;
|
||||||
|
- `with_context(...)` consomme `self`, ajoute un champ puis retourne l'erreur enrichie ;
|
||||||
|
- `with_source(...)` suit le même modèle pour une cause externe ;
|
||||||
|
- aucune méthode mutable publique parallèle n'est ajoutée ;
|
||||||
|
- aucune conversion générique `From<ExternalError>` n'est introduite ;
|
||||||
|
- `Error` ne dérive pas `Clone`, `Eq` ou `PartialEq`, afin de ne pas affaiblir le support d'une vraie cause externe ;
|
||||||
|
- `Error` implémente `std::fmt::Display` et `std::error::Error` ;
|
||||||
|
- le rendu `Display` est exactement :
|
||||||
|
|
||||||
|
```text
|
||||||
|
<domain>.<code>: <message>
|
||||||
|
```
|
||||||
|
|
||||||
|
Le contexte et la chaîne de causes ne sont pas injectés automatiquement dans ce rendu.
|
||||||
|
|
||||||
|
### `Result<T>`
|
||||||
|
|
||||||
|
La façade expose :
|
||||||
|
|
||||||
|
```text
|
||||||
|
ksp_core_lib::Result<T> = std::result::Result<T, ksp_core_lib::Error>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Façade Core
|
||||||
|
|
||||||
|
`crates/ksp-core-lib/src/lib.rs` ouvre le module d'implémentation en privé puis réexporte explicitement :
|
||||||
|
|
||||||
|
```text
|
||||||
|
ksp_core_lib::Error
|
||||||
|
ksp_core_lib::ErrorCode
|
||||||
|
ksp_core_lib::ErrorContext
|
||||||
|
ksp_core_lib::Result
|
||||||
|
```
|
||||||
|
|
||||||
|
Aucun `pub mod` n'est introduit.
|
||||||
|
|
||||||
|
## Dépendances
|
||||||
|
|
||||||
|
Aucune dépendance n'est ajoutée à `ksp-core-lib` pendant cette tranche.
|
||||||
|
|
||||||
|
En particulier, `pre.002` n'introduit ni `thiserror`, ni `anyhow`, ni crate Solana, ni codec wire.
|
||||||
|
|
||||||
|
## Tests ajoutés
|
||||||
|
|
||||||
|
### Tests unitaires externes
|
||||||
|
|
||||||
|
`crates/ksp-core-lib/unit_tests/error.rs` vérifie :
|
||||||
|
|
||||||
|
- conservation de `domain` et `code` ;
|
||||||
|
- possibilité de déclarer un `ErrorCode` constant ;
|
||||||
|
- conservation des champs `ErrorContext` ;
|
||||||
|
- conservation du message et de l'ordre du contexte ;
|
||||||
|
- rendu exact de `Display` ;
|
||||||
|
- absence du contexte et de la cause dans le rendu ;
|
||||||
|
- conservation de la cause via `std::error::Error::source()` ;
|
||||||
|
- propriété `Send + Sync` de l'erreur commune.
|
||||||
|
|
||||||
|
Le fichier est rattaché au module privé de production via `#[cfg(test)]` et `#[path = "../unit_tests/error.rs"]`.
|
||||||
|
|
||||||
|
### Test d'intégration
|
||||||
|
|
||||||
|
`crates/ksp-core-lib/tests/public_api.rs` consomme exclusivement la façade crate-root et vérifie que `Error`, `ErrorCode`, `ErrorContext` et `Result` sont utilisables depuis une crate externe.
|
||||||
|
|
||||||
|
## Documentation de plan
|
||||||
|
|
||||||
|
Le plan passe de version documentaire 4 à 5 afin de remplacer les deux questions désormais résolues par les décisions réellement implémentées :
|
||||||
|
|
||||||
|
- `with_context(...)` consomme `self` ;
|
||||||
|
- `Display` utilise la forme stable `<domain>.<code>: <message>`.
|
||||||
|
|
||||||
|
Les questions `pre.003` concernant `solana-pubkey` restent ouvertes et inchangées.
|
||||||
|
|
||||||
|
## Validations exécutées
|
||||||
|
|
||||||
|
Dans l'environnement de préparation :
|
||||||
|
|
||||||
|
- reconstruction de la base `0.1.1-pre.001-fix.002` depuis la release `0.0.3` et les deltas successifs ;
|
||||||
|
- prise en compte de la version 4 du plan fournie après réalignement manuel du tableau ;
|
||||||
|
- contrôle du périmètre des fichiers modifiés/ajoutés ;
|
||||||
|
- parsing TOML statique du manifest racine ;
|
||||||
|
- contrôle des headers `file:` / `version:` et des fins de ligne des fichiers livrés ;
|
||||||
|
- recherche statique des usages interdits `unsafe`, `unwrap`, `expect`, `panic` et opérateur `?` dans le code de production ajouté ;
|
||||||
|
- contrôle de l'absence de `use` dans le code Rust ajouté ;
|
||||||
|
- contrôle de l'absence de nouvelle dépendance Cargo.
|
||||||
|
|
||||||
|
## 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
|
||||||
|
```
|
||||||
|
|
||||||
|
Elles doivent être exécutées sur le dépôt réel avant validation du commit. Aucun succès Cargo n'est déclaré par ce delta.
|
||||||
|
|
||||||
|
## Décisions prises
|
||||||
|
|
||||||
|
- Le contrat Error/Result ouvert de `pre.001` est retenu sans enum centrale de domaines.
|
||||||
|
- `ErrorCode::new(...)` est `const`.
|
||||||
|
- Les champs des types publics restent privés et sont accessibles par API explicite.
|
||||||
|
- Le contexte est ordonné et enrichi par consommation de `self`.
|
||||||
|
- La cause standard est conservée avec les bornes `Error + Send + Sync + 'static`.
|
||||||
|
- `Display` est volontairement court et stable ; Logging décidera plus tard comment exploiter contexte et causes.
|
||||||
|
- Core ne possède aucune conversion vers les erreurs des domaines supérieurs.
|
||||||
|
|
||||||
|
## Questions ouvertes
|
||||||
|
|
||||||
|
Aucune question bloquante pour `pre.002`.
|
||||||
|
|
||||||
|
Les questions relatives à `Pubkey`, aux Program IDs et à leur registre restent réservées à `0.1.1-pre.003` conformément au plan actif.
|
||||||
120
deltas/0.1.1/pre.003-fix.001.md
Normal file
120
deltas/0.1.1/pre.003-fix.001.md
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
<!-- file: deltas/0.1.1/pre.003-fix.001.md -->
|
||||||
|
<!-- version: 1 -->
|
||||||
|
|
||||||
|
# Delta `0.1.1-pre.003-fix.001` — Cargo workspace + Clippy
|
||||||
|
|
||||||
|
## Statut
|
||||||
|
|
||||||
|
Correctif de `0.1.1-pre.003` après application et validation partielle de la tranche par le user.
|
||||||
|
|
||||||
|
Version technique du correctif :
|
||||||
|
|
||||||
|
```text
|
||||||
|
workspace.package.version = "0.1.1-pre.3.fix.1"
|
||||||
|
```
|
||||||
|
|
||||||
|
Le périmètre fonctionnel de `pre.003` ne change pas.
|
||||||
|
|
||||||
|
## Base et validations reçues
|
||||||
|
|
||||||
|
Sur `0.1.1-pre.3`, le user a exécuté avec succès :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo fmt --all
|
||||||
|
cargo check --workspace
|
||||||
|
cargo test --workspace
|
||||||
|
cargo tree -p ksp-core-lib
|
||||||
|
cargo tree -p ksp-core-lib -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Résultats communiqués :
|
||||||
|
|
||||||
|
- 14 tests unitaires passent ;
|
||||||
|
- 3 tests d'intégration `public_api` passent ;
|
||||||
|
- `cargo tree` résout `solana-pubkey 4.3.0` puis `solana-address 2.7.0` ;
|
||||||
|
- `cargo tree -d` ne rapporte aucun doublon.
|
||||||
|
|
||||||
|
`cargo clippy --workspace --all-targets` échoue sur trois closures de `program_ids.rs` à cause de la règle workspace `clippy::implicit_return = deny`.
|
||||||
|
|
||||||
|
## Correction Cargo
|
||||||
|
|
||||||
|
La déclaration directe suivante dans `crates/ksp-core-lib/Cargo.toml` est supprimée :
|
||||||
|
|
||||||
|
```toml
|
||||||
|
solana-pubkey = { version = "4.3.0", default-features = false }
|
||||||
|
```
|
||||||
|
|
||||||
|
La dépendance appartient désormais au manifeste workspace :
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[workspace.dependencies]
|
||||||
|
solana-pubkey = { version = "^4.3", default-features = false }
|
||||||
|
```
|
||||||
|
|
||||||
|
La crate propriétaire la consomme uniquement par héritage :
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[dependencies]
|
||||||
|
solana-pubkey.workspace = true
|
||||||
|
```
|
||||||
|
|
||||||
|
La contrainte `^4.3` exprime la génération compatible voulue par KSP ; le patch concret reste résolu par Cargo/lockfile.
|
||||||
|
|
||||||
|
## Règles ajoutées
|
||||||
|
|
||||||
|
`docs/rules/RULES_DEPENDENCIES.md` ajoute `DEP-CARGO-001` à `DEP-CARGO-005` afin de rendre obligatoire :
|
||||||
|
|
||||||
|
- la centralisation des dépendances externes sous `[workspace.dependencies]` ;
|
||||||
|
- l'usage de `.workspace = true` dans les crates membres ;
|
||||||
|
- la centralisation des contraintes de version et options communes ;
|
||||||
|
- la convention de contrainte caret `^M.m` pour une génération majeure/mineure compatible ;
|
||||||
|
- la distinction entre contrainte de manifeste et résolution concrète du lockfile.
|
||||||
|
|
||||||
|
## Correction Clippy
|
||||||
|
|
||||||
|
Les trois closures signalées utilisent maintenant un `return` explicite dans leur corps :
|
||||||
|
|
||||||
|
- filtre générique de `program_ids(...)` ;
|
||||||
|
- recherche texte `find_program_id(...)` ;
|
||||||
|
- recherche typée `find_program_pubkey(...)`.
|
||||||
|
|
||||||
|
Aucune signature ni sémantique de l'API publique ne change.
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
`docs/plans/003-V0_1_1_CORE_FOUNDATION_PLAN.md` est aligné sur la nouvelle règle Cargo et documente la contrainte workspace `^4.3` au lieu d'une déclaration locale `4.3.0`.
|
||||||
|
|
||||||
|
## Fichiers modifiés
|
||||||
|
|
||||||
|
```text
|
||||||
|
Cargo.toml
|
||||||
|
crates/ksp-core-lib/Cargo.toml
|
||||||
|
crates/ksp-core-lib/src/program_ids.rs
|
||||||
|
docs/plans/003-V0_1_1_CORE_FOUNDATION_PLAN.md
|
||||||
|
docs/rules/RULES_DEPENDENCIES.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## Fichier ajouté
|
||||||
|
|
||||||
|
```text
|
||||||
|
deltas/0.1.1/pre.003-fix.001.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## Validations de ce correctif
|
||||||
|
|
||||||
|
Non exécutées dans l'environnement de préparation du delta, qui ne fournit pas la toolchain Cargo/Rust du dépôt.
|
||||||
|
|
||||||
|
À exécuter après application :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo fmt --all
|
||||||
|
cargo check --workspace
|
||||||
|
cargo test --workspace
|
||||||
|
cargo clippy --workspace --all-targets
|
||||||
|
cargo tree -p ksp-core-lib
|
||||||
|
cargo tree -p ksp-core-lib -d
|
||||||
|
```
|
||||||
|
|
||||||
|
## Hors scope
|
||||||
|
|
||||||
|
Ce correctif n'ajoute ni Program ID, ni dépendance, ni primitive Core supplémentaire et ne démarre pas `0.1.1-pre.004`.
|
||||||
284
deltas/0.1.1/pre.003.md
Normal file
284
deltas/0.1.1/pre.003.md
Normal file
@@ -0,0 +1,284 @@
|
|||||||
|
<!-- file: deltas/0.1.1/pre.003.md -->
|
||||||
|
<!-- version: 1 -->
|
||||||
|
|
||||||
|
# Delta `0.1.1-pre.003` — Pubkey + Program IDs
|
||||||
|
|
||||||
|
## Statut
|
||||||
|
|
||||||
|
Tranche fonctionnelle `0.1.1-pre.003` préparée après validation réussie par le user de `0.1.1-pre.002-fix.001`.
|
||||||
|
|
||||||
|
La base utilisateur observée avant ce delta utilise :
|
||||||
|
|
||||||
|
```text
|
||||||
|
workspace.package.version = "0.1.1-pre.2.fix.1"
|
||||||
|
```
|
||||||
|
|
||||||
|
Le présent delta ouvre :
|
||||||
|
|
||||||
|
```text
|
||||||
|
workspace.package.version = "0.1.1-pre.3"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Validation de la base précédente
|
||||||
|
|
||||||
|
Le user a exécuté avec succès avant ce delta :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo fmt --all
|
||||||
|
cargo check --workspace
|
||||||
|
cargo clippy --workspace --all-targets
|
||||||
|
cargo test --workspace
|
||||||
|
```
|
||||||
|
|
||||||
|
Les six tests unitaires Error et le test d'intégration public Error passent sans warning dans cette validation.
|
||||||
|
|
||||||
|
## Audit Solana/Anza revérifié
|
||||||
|
|
||||||
|
Au 2026-08-14 :
|
||||||
|
|
||||||
|
- `solana-pubkey 4.3.0` est la version publiée courante observée sur crates.io ;
|
||||||
|
- son MSRV publié est Rust `1.89.0` ;
|
||||||
|
- la validation précédente du user expose une génération Clippy `rust-1.94.0`, donc la toolchain observée satisfait ce MSRV ;
|
||||||
|
- `Pubkey` reste la façade de compatibilité officielle sur l'`Address` Solana actuel ;
|
||||||
|
- `Pubkey::from_str_const` permet le décodage Base58 compile-time nécessaire à la macro KSP ;
|
||||||
|
- `solana-sdk-ids` est consulté uniquement comme source d'audit et n'entre pas dans le graphe Cargo KSP.
|
||||||
|
|
||||||
|
Sources externes revérifiées pendant cette tranche :
|
||||||
|
|
||||||
|
- crates.io / docs.rs pour `solana-pubkey 4.3.0` ;
|
||||||
|
- `anza-xyz/solana-sdk`, `sdk-ids/src/lib.rs`, pour les identifiants fondamentaux actuellement publiés ;
|
||||||
|
- SIMD-0204 et la documentation Anza pour le Slashing Program `S1ashing11111111111111111111111111111111111`.
|
||||||
|
|
||||||
|
## Dépendance Core
|
||||||
|
|
||||||
|
`crates/ksp-core-lib/Cargo.toml` ajoute uniquement :
|
||||||
|
|
||||||
|
```toml
|
||||||
|
solana-pubkey = { version = "4.3.0", default-features = false }
|
||||||
|
```
|
||||||
|
|
||||||
|
Aucun codec wire, client RPC, umbrella SDK ou registre `solana-sdk-ids` n'est ajouté.
|
||||||
|
|
||||||
|
`ksp_core_lib::Pubkey` réexporte `solana_pubkey::Pubkey` depuis la façade Core.
|
||||||
|
|
||||||
|
## Macro KSP de Program ID
|
||||||
|
|
||||||
|
La macro publique :
|
||||||
|
|
||||||
|
```text
|
||||||
|
ksp_core_lib::declare_program_id!
|
||||||
|
```
|
||||||
|
|
||||||
|
possède la chaîne Base58 une seule fois et produit simultanément :
|
||||||
|
|
||||||
|
```text
|
||||||
|
PRGID_* : &'static str
|
||||||
|
PRGIDPK_* : Pubkey
|
||||||
|
```
|
||||||
|
|
||||||
|
La représentation typée est construite avec `Pubkey::from_str_const`, sans parsing runtime, `unwrap`, `expect`, `panic` ni opérateur `?`.
|
||||||
|
|
||||||
|
La macro ne génère pas de symboles génériques `ID`, `id()` ou `check_id()` et peut donc être utilisée plusieurs fois dans une même crate/module.
|
||||||
|
|
||||||
|
## Première surface Program IDs Core
|
||||||
|
|
||||||
|
La tranche fixe le premier registre à 18 Program IDs.
|
||||||
|
|
||||||
|
Les 17 valeurs de la surface officielle actuelle Anza `solana-sdk-ids` sont possédées localement par KSP :
|
||||||
|
|
||||||
|
1. Address Lookup Table ;
|
||||||
|
2. BPF Loader historique v1 ;
|
||||||
|
3. BPF Loader v2 ;
|
||||||
|
4. BPF Loader Upgradeable ;
|
||||||
|
5. Compute Budget ;
|
||||||
|
6. Config ;
|
||||||
|
7. Ed25519 precompile ;
|
||||||
|
8. Feature ;
|
||||||
|
9. Loader v4 ;
|
||||||
|
10. Native Loader ;
|
||||||
|
11. Secp256k1 precompile ;
|
||||||
|
12. Secp256r1 precompile ;
|
||||||
|
13. Stake ;
|
||||||
|
14. System ;
|
||||||
|
15. Vote ;
|
||||||
|
16. ZK ElGamal Proof ;
|
||||||
|
17. ZK Token Proof.
|
||||||
|
|
||||||
|
Le dix-huitième identifiant est :
|
||||||
|
|
||||||
|
```text
|
||||||
|
PRGID_SOLANA_SLASHING = "S1ashing11111111111111111111111111111111111"
|
||||||
|
```
|
||||||
|
|
||||||
|
Son statut de programme enshrined et son adresse sont confirmés séparément par SIMD-0204/Anza ; sa présence ne dépend donc pas de l'ancien registre bot3.
|
||||||
|
|
||||||
|
Les sysvars, `StakeConfig`, l'incinerator et les autres well-known accounts restent exclus du registre Program IDs.
|
||||||
|
|
||||||
|
## Nomenclature publique
|
||||||
|
|
||||||
|
Chaque entrée possède une paire `PRGID_*` / `PRGIDPK_*` au crate-root.
|
||||||
|
|
||||||
|
Exemples :
|
||||||
|
|
||||||
|
```text
|
||||||
|
PRGID_SOLANA_SYSTEM
|
||||||
|
PRGIDPK_SOLANA_SYSTEM
|
||||||
|
|
||||||
|
PRGID_SOLANA_LOADER_BPF_V1
|
||||||
|
PRGIDPK_SOLANA_LOADER_BPF_V1
|
||||||
|
|
||||||
|
PRGID_SOLANA_PRECOMPILE_ED25519
|
||||||
|
PRGIDPK_SOLANA_PRECOMPILE_ED25519
|
||||||
|
|
||||||
|
PRGID_SOLANA_SLASHING
|
||||||
|
PRGIDPK_SOLANA_SLASHING
|
||||||
|
```
|
||||||
|
|
||||||
|
Le suffixe est strictement identique entre les deux représentations.
|
||||||
|
|
||||||
|
## Registre canonique
|
||||||
|
|
||||||
|
`ProgramIdEntry` porte :
|
||||||
|
|
||||||
|
```text
|
||||||
|
code
|
||||||
|
name
|
||||||
|
program_id
|
||||||
|
pubkey
|
||||||
|
domain
|
||||||
|
family
|
||||||
|
protocol
|
||||||
|
subfamily
|
||||||
|
program_version
|
||||||
|
kind
|
||||||
|
```
|
||||||
|
|
||||||
|
Les axes fonctionnels restent extensibles sous forme de chaînes. Aucun enum central fermé des domaines, familles ou protocoles futurs n'est créé.
|
||||||
|
|
||||||
|
`ProgramIdKind` est limité à la classification technique :
|
||||||
|
|
||||||
|
```text
|
||||||
|
Program
|
||||||
|
Loader
|
||||||
|
Precompile
|
||||||
|
EnshrinedProgram
|
||||||
|
```
|
||||||
|
|
||||||
|
La première taxonomie Core utilise :
|
||||||
|
|
||||||
|
```text
|
||||||
|
domain = solana
|
||||||
|
protocol = solana
|
||||||
|
|
||||||
|
family = runtime
|
||||||
|
family = consensus
|
||||||
|
family = loader
|
||||||
|
family = precompile
|
||||||
|
family = proof
|
||||||
|
```
|
||||||
|
|
||||||
|
`program_version` reste distinct de `subfamily`. Les BPF loaders v1/v2 et Loader v4 utilisent l'axe version ; la branche BPF utilise séparément `subfamily = bpf`.
|
||||||
|
|
||||||
|
## API de recherche et vues
|
||||||
|
|
||||||
|
La façade expose :
|
||||||
|
|
||||||
|
```text
|
||||||
|
entries()
|
||||||
|
program_ids(filter)
|
||||||
|
native_program_ids()
|
||||||
|
program_ids_by_domain(...)
|
||||||
|
program_ids_by_family(...)
|
||||||
|
program_ids_by_protocol(...)
|
||||||
|
find_program_id(...)
|
||||||
|
find_program_pubkey(...)
|
||||||
|
```
|
||||||
|
|
||||||
|
`ProgramIdFilter` peut combiner :
|
||||||
|
|
||||||
|
```text
|
||||||
|
domain
|
||||||
|
family
|
||||||
|
protocol
|
||||||
|
subfamily
|
||||||
|
program_version
|
||||||
|
kind
|
||||||
|
```
|
||||||
|
|
||||||
|
Toutes les vues sont construites à partir du registre canonique unique. Elles retournent des iterators paresseux sans dupliquer un tableau statique par catégorie.
|
||||||
|
|
||||||
|
Une future fonction `amm_program_ids()` pourra donc devenir une vue `family = amm` sans changement structurel de `ProgramIdEntry`.
|
||||||
|
|
||||||
|
## Tests ajoutés
|
||||||
|
|
||||||
|
`unit_tests/program_ids.rs` vérifie notamment :
|
||||||
|
|
||||||
|
- cohérence texte/`Pubkey` des déclarations ;
|
||||||
|
- présence exacte des 18 Program IDs Core ;
|
||||||
|
- unicité des codes, Base58 et `Pubkey` ;
|
||||||
|
- correspondance de chaque `Pubkey` avec la Base58 KSP ;
|
||||||
|
- intersection des six axes de filtre ;
|
||||||
|
- tailles attendues des familles Core actuelles ;
|
||||||
|
- recherche texte et `Pubkey` ;
|
||||||
|
- absence de l'incinerator, `StakeConfig` et du Clock sysvar.
|
||||||
|
|
||||||
|
`tests/public_api.rs` vérifie en consommateur externe :
|
||||||
|
|
||||||
|
- la macro `declare_program_id!` ;
|
||||||
|
- les constantes `PRGID_*` / `PRGIDPK_*` ;
|
||||||
|
- `Pubkey` ;
|
||||||
|
- les vues du registre ;
|
||||||
|
- les getters de `ProgramIdEntry` ;
|
||||||
|
- le filtrage public combiné.
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
`docs/plans/003-V0_1_1_CORE_FOUNDATION_PLAN.md` passe en version documentaire 6 afin de :
|
||||||
|
|
||||||
|
- fixer l'inventaire Core à 18 IDs ;
|
||||||
|
- tracer la vérification du Slashing Program ;
|
||||||
|
- enregistrer `solana-pubkey 4.3.0` comme dépendance effectivement introduite ;
|
||||||
|
- fermer la question de MSRV de `pre.003` ;
|
||||||
|
- fixer `ProgramIdKind` et les retours iterator des vues ;
|
||||||
|
- compléter l'API publique réellement implémentée.
|
||||||
|
|
||||||
|
## Hors scope préservé
|
||||||
|
|
||||||
|
Cette tranche n'ajoute toujours pas :
|
||||||
|
|
||||||
|
- SPL Token/Token-2022/ATA/Memo ;
|
||||||
|
- Metaplex ou DEX ;
|
||||||
|
- well-known accounts ;
|
||||||
|
- codecs Borsh/Wincode ;
|
||||||
|
- decoder/executor/IDL ;
|
||||||
|
- RPC/WS ;
|
||||||
|
- Wallet ;
|
||||||
|
- Logging ;
|
||||||
|
- Config ;
|
||||||
|
- Store ;
|
||||||
|
- applications.
|
||||||
|
|
||||||
|
## Validations à exécuter sur le dépôt cible
|
||||||
|
|
||||||
|
Cette livraison ne déclare aucune validation Cargo non exécutée dans l'environnement de génération.
|
||||||
|
|
||||||
|
Après application :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo fmt --all
|
||||||
|
cargo check --workspace
|
||||||
|
cargo test --workspace
|
||||||
|
cargo clippy --workspace --all-targets
|
||||||
|
cargo tree -p ksp-core-lib
|
||||||
|
cargo tree -p ksp-core-lib -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Les deux commandes `cargo tree` doivent notamment confirmer l'absence de `solana-sdk-ids` et permettre de contrôler le graphe/features réellement résolus.
|
||||||
|
|
||||||
|
## Suite
|
||||||
|
|
||||||
|
Si les validations sont propres, la tranche suivante reste :
|
||||||
|
|
||||||
|
```text
|
||||||
|
0.1.1-pre.004 — intégration Core + audits
|
||||||
|
```
|
||||||
120
deltas/0.1.1/pre.004.md
Normal file
120
deltas/0.1.1/pre.004.md
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
<!-- file: deltas/0.1.1/pre.004.md -->
|
||||||
|
<!-- version: 1 -->
|
||||||
|
|
||||||
|
# Delta `0.1.1-pre.004` — intégration Core + audits
|
||||||
|
|
||||||
|
## Statut
|
||||||
|
|
||||||
|
Tranche d'intégration préparée après validation réussie par le user de `0.1.1-pre.003-fix.001`.
|
||||||
|
|
||||||
|
La base validée utilise :
|
||||||
|
|
||||||
|
```text
|
||||||
|
workspace.package.version = "0.1.1-pre.3.fix.1"
|
||||||
|
```
|
||||||
|
|
||||||
|
Le présent delta ouvre :
|
||||||
|
|
||||||
|
```text
|
||||||
|
workspace.package.version = "0.1.1-pre.4"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Validation de la base précédente
|
||||||
|
|
||||||
|
Le user a exécuté avec succès :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo fmt --all
|
||||||
|
cargo check --workspace
|
||||||
|
cargo test --workspace
|
||||||
|
cargo clippy --workspace --all-targets
|
||||||
|
cargo tree -p ksp-core-lib
|
||||||
|
cargo tree -p ksp-core-lib -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Résultats communiqués :
|
||||||
|
|
||||||
|
- 14 tests unitaires passent ;
|
||||||
|
- 3 tests d'intégration publics passent ;
|
||||||
|
- Clippy ne rapporte plus de warning ou d'erreur ;
|
||||||
|
- `cargo tree` résout `solana-pubkey 4.3.0` puis `solana-address 2.7.0` et leurs dépendances fondamentales ;
|
||||||
|
- `cargo tree -d` ne rapporte aucun doublon.
|
||||||
|
|
||||||
|
## Audit d'intégration Core
|
||||||
|
|
||||||
|
L'audit conjoint de `Error` / `Result`, `Pubkey` et Program IDs ne démontre aucun besoin de primitive N1 supplémentaire dans `0.1.1`.
|
||||||
|
|
||||||
|
La façade conserve les propriétés attendues :
|
||||||
|
|
||||||
|
- les modules d'implémentation restent privés ;
|
||||||
|
- les contrats consommables sont réexportés explicitement au crate-root ;
|
||||||
|
- `ksp-core-lib` ne dépend d'aucune couche KSP supérieure ;
|
||||||
|
- `solana-pubkey` reste l'unique dépendance externe directe de Core ;
|
||||||
|
- aucun codec wire, RPC/client, signer/keypair, store, logging ou configuration n'est introduit ;
|
||||||
|
- le registre Program IDs reste descriptif et distinct de tout registry de decoder/executor.
|
||||||
|
|
||||||
|
## Rustdocs
|
||||||
|
|
||||||
|
La rustdoc crate-level de `ksp-core-lib` est complétée afin de rendre explicites :
|
||||||
|
|
||||||
|
- le contrat d'erreur commun ;
|
||||||
|
- la propriété de `Pubkey` dans la façade Core ;
|
||||||
|
- la propriété KSP du registre de Program IDs fondamentaux ;
|
||||||
|
- l'absence de dépendance inverse vers les domaines supérieurs.
|
||||||
|
|
||||||
|
## Test public renforcé
|
||||||
|
|
||||||
|
`tests/public_api.rs` déclare désormais :
|
||||||
|
|
||||||
|
```text
|
||||||
|
const TEST_ERROR_CODE: ksp_core_lib::ErrorCode = ...
|
||||||
|
```
|
||||||
|
|
||||||
|
Le test confirme ainsi depuis une crate consommatrice que `ErrorCode::new(...)` est réellement utilisable en contexte `const`, ce qui permettra aux futures crates de domaine de posséder leurs codes sans faire connaître leurs domaines à Core.
|
||||||
|
|
||||||
|
Aucune signature publique n'est modifiée.
|
||||||
|
|
||||||
|
## Documentation du plan
|
||||||
|
|
||||||
|
`docs/plans/003-V0_1_1_CORE_FOUNDATION_PLAN.md` passe en version documentaire 8 pour enregistrer le résultat de l'audit `pre.004` et ajouter le contrôle explicite des features résolues :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo tree -p ksp-core-lib -e features
|
||||||
|
```
|
||||||
|
|
||||||
|
## Fichiers modifiés
|
||||||
|
|
||||||
|
```text
|
||||||
|
Cargo.toml
|
||||||
|
crates/ksp-core-lib/src/lib.rs
|
||||||
|
crates/ksp-core-lib/tests/public_api.rs
|
||||||
|
docs/plans/003-V0_1_1_CORE_FOUNDATION_PLAN.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## Fichier ajouté
|
||||||
|
|
||||||
|
```text
|
||||||
|
deltas/0.1.1/pre.004.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## Validations à exécuter sur le dépôt cible
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo fmt --all
|
||||||
|
cargo check --workspace
|
||||||
|
cargo test --workspace
|
||||||
|
cargo clippy --workspace --all-targets
|
||||||
|
cargo tree -p ksp-core-lib
|
||||||
|
cargo tree -p ksp-core-lib -d
|
||||||
|
cargo tree -p ksp-core-lib -e features
|
||||||
|
```
|
||||||
|
|
||||||
|
Aucune validation non exécutée dans l'environnement de préparation n'est déclarée réussie pour ce delta.
|
||||||
|
|
||||||
|
## Suite
|
||||||
|
|
||||||
|
Si cette tranche est propre, la suite prévue est :
|
||||||
|
|
||||||
|
```text
|
||||||
|
0.1.1-pre.005 — clôture, documentation finale et prompt 0.1.2
|
||||||
|
```
|
||||||
191
deltas/0.1.1/pre.005.md
Normal file
191
deltas/0.1.1/pre.005.md
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
<!-- file: deltas/0.1.1/pre.005.md -->
|
||||||
|
<!-- version: 1 -->
|
||||||
|
|
||||||
|
# Delta `0.1.1-pre.005` — clôture Core et prompt Logging
|
||||||
|
|
||||||
|
## Base requise
|
||||||
|
|
||||||
|
`v0.1.1-pre.004` au sens du commit de livraison correspondant, avec :
|
||||||
|
|
||||||
|
```text
|
||||||
|
workspace.package.version = "0.1.1-pre.4"
|
||||||
|
```
|
||||||
|
|
||||||
|
Le présent delta ouvre :
|
||||||
|
|
||||||
|
```text
|
||||||
|
workspace.package.version = "0.1.1-pre.5"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Objectif
|
||||||
|
|
||||||
|
Clôturer la phase de développement `0.1.1` sans élargir la surface Core : enregistrer les validations finales de `pre.004`, confirmer la politique de features `solana-pubkey`, réaligner les documents de référence et produire le prompt final de démarrage `0.1.2`.
|
||||||
|
|
||||||
|
La publication stable reste un delta `0.1.1-rel.001` séparé après validation de cette prerelease.
|
||||||
|
|
||||||
|
## Validation de la base précédente
|
||||||
|
|
||||||
|
Le user a exécuté avec succès le 2026-08-14 :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo fmt --all
|
||||||
|
cargo check --workspace
|
||||||
|
cargo test --workspace
|
||||||
|
cargo clippy --workspace --all-targets
|
||||||
|
cargo tree -p ksp-core-lib
|
||||||
|
cargo tree -p ksp-core-lib -d
|
||||||
|
cargo tree -p ksp-core-lib -e features
|
||||||
|
```
|
||||||
|
|
||||||
|
Résultats communiqués :
|
||||||
|
|
||||||
|
- 14 tests unitaires passent ;
|
||||||
|
- 3 tests d'intégration publics passent ;
|
||||||
|
- Clippy passe sans warning communiqué ;
|
||||||
|
- `cargo tree` conserve `solana-pubkey 4.3.0` comme unique dépendance externe directe de `ksp-core-lib` ;
|
||||||
|
- `solana-pubkey` résout `solana-address 2.7.0` puis ses dépendances fondamentales ;
|
||||||
|
- `cargo tree -d` ne rapporte aucun doublon ;
|
||||||
|
- `cargo tree -e features` a été inspecté.
|
||||||
|
|
||||||
|
## Décision finale sur les features `solana-pubkey`
|
||||||
|
|
||||||
|
Aucune feature optionnelle supplémentaire n'est activée dans `0.1.1`.
|
||||||
|
|
||||||
|
La déclaration workspace reste :
|
||||||
|
|
||||||
|
```toml
|
||||||
|
solana-pubkey = { version = "^4.3", default-features = false }
|
||||||
|
```
|
||||||
|
|
||||||
|
Les features optionnelles `alloc`, `borsh`, `bytemuck`, `curve25519`, `rand`, `serde`, `sha2`, `std` et `wincode` ne correspondent à aucun besoin du contrat Core actuellement livré.
|
||||||
|
|
||||||
|
Les features `solana-address` visibles dans le graphe résolu (`copy`, `decode`, `error`, `sanitize`, `syscalls`, `default`) appartiennent à la composition interne de la génération actuelle de `solana-pubkey`/`solana-address`. Elles ne justifient pas une activation KSP supplémentaire.
|
||||||
|
|
||||||
|
Les futures releases activent une feature uniquement lorsque leur propriétaire fonctionnel démontre un besoin concret. En particulier, `borsh`/`wincode` ne sont pas activées dans Core par anticipation d'une future surface wire.
|
||||||
|
|
||||||
|
## Documentation finale
|
||||||
|
|
||||||
|
Le plan `0.1.1` est consolidé pour :
|
||||||
|
|
||||||
|
- refléter la surface réellement implémentée ;
|
||||||
|
- enregistrer les validations réussies de `pre.004` ;
|
||||||
|
- fermer la question des features `solana-pubkey` ;
|
||||||
|
- confirmer qu'aucune primitive N1 supplémentaire n'est nécessaire ;
|
||||||
|
- confirmer qu'aucun `README.md`/`USAGE.md` spécifique à la crate n'est nécessaire pour cette petite surface ;
|
||||||
|
- confirmer que le dépôt ne possède actuellement aucun changelog général à synchroniser.
|
||||||
|
|
||||||
|
La séquence fonctionnelle est mise à jour pour remplacer le périmètre candidat de `0.1.1` par la surface effectivement stabilisée et son lifecycle réellement suivi.
|
||||||
|
|
||||||
|
Les index de documentation/plans/prompts sont réalignés avec les fichiers présents.
|
||||||
|
|
||||||
|
## Prompt `0.1.2`
|
||||||
|
|
||||||
|
Ajout :
|
||||||
|
|
||||||
|
```text
|
||||||
|
prompts/002-V0_1_2_START_PROMPT.md
|
||||||
|
```
|
||||||
|
|
||||||
|
Ce prompt ouvre :
|
||||||
|
|
||||||
|
```text
|
||||||
|
0.1.2 — Logging foundation
|
||||||
|
```
|
||||||
|
|
||||||
|
après publication stable de `0.1.1`.
|
||||||
|
|
||||||
|
Il conserve notamment les décisions suivantes :
|
||||||
|
|
||||||
|
- `ksp-logging-lib` est la façade KSP unique de logging/tracing runtime ;
|
||||||
|
- Logging peut dépendre de `ksp-core-lib`, jamais l'inverse ;
|
||||||
|
- `pre.001` de Logging reste une phase d'audit/brainstorming/planification ;
|
||||||
|
- la stack `tracing` et ses features sont revérifiées depuis les sources officielles avant ajout ;
|
||||||
|
- l'API doit préserver les callsites réels ;
|
||||||
|
- Logging possède ses settings runtime sans dépendre de Config ;
|
||||||
|
- les secrets ne sont jamais loggés automatiquement ;
|
||||||
|
- les dépendances externes restent centralisées sous `[workspace.dependencies]`.
|
||||||
|
|
||||||
|
## Fichiers ajoutés
|
||||||
|
|
||||||
|
```text
|
||||||
|
deltas/0.1.1/pre.005.md
|
||||||
|
prompts/002-V0_1_2_START_PROMPT.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## Fichiers modifiés
|
||||||
|
|
||||||
|
```text
|
||||||
|
Cargo.toml
|
||||||
|
docs/000-README.md
|
||||||
|
docs/plans/000-README.md
|
||||||
|
docs/plans/002-FUNCTIONAL_RELEASE_SEQUENCE.md
|
||||||
|
docs/plans/003-V0_1_1_CORE_FOUNDATION_PLAN.md
|
||||||
|
prompts/000-README.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## Fichiers supprimés
|
||||||
|
|
||||||
|
Aucun.
|
||||||
|
|
||||||
|
## Nettoyage/archivage
|
||||||
|
|
||||||
|
Aucun fichier temporaire ou obsolète supplémentaire n'est identifié comme devant être supprimé dans cette tranche.
|
||||||
|
|
||||||
|
Les deltas historiques et le prompt `0.1.1` restent conservés comme historique utile.
|
||||||
|
|
||||||
|
## Validations exécutées pendant la préparation
|
||||||
|
|
||||||
|
Contrôles statiques hors Cargo :
|
||||||
|
|
||||||
|
- parsing TOML ;
|
||||||
|
- headers `file:` / `version:` des fichiers modifiés/ajoutés ;
|
||||||
|
- terminaison EOF ;
|
||||||
|
- liens Markdown locaux ;
|
||||||
|
- cohérence des index documentaires ;
|
||||||
|
- cohérence de la version `0.1.1-pre.5` ;
|
||||||
|
- absence d'ajout de feature `solana-pubkey` ;
|
||||||
|
- intégrité de l'archive delta.
|
||||||
|
|
||||||
|
## Validations non exécutées pendant la préparation
|
||||||
|
|
||||||
|
L'environnement de préparation ne fournit pas `cargo`/`rustc`.
|
||||||
|
|
||||||
|
Après application du delta, exécuter sur le dépôt cible :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo fmt --all
|
||||||
|
cargo check --workspace
|
||||||
|
cargo test --workspace
|
||||||
|
cargo clippy --workspace --all-targets
|
||||||
|
cargo tree -p ksp-core-lib
|
||||||
|
cargo tree -p ksp-core-lib -d
|
||||||
|
cargo tree -p ksp-core-lib -e features
|
||||||
|
```
|
||||||
|
|
||||||
|
Aucune de ces validations de `pre.005` n'est déclarée réussie avant exécution par le user.
|
||||||
|
|
||||||
|
## Publication suivante
|
||||||
|
|
||||||
|
Si `pre.005` est propre, préparer :
|
||||||
|
|
||||||
|
```text
|
||||||
|
0.1.1-rel.001
|
||||||
|
```
|
||||||
|
|
||||||
|
avec :
|
||||||
|
|
||||||
|
```text
|
||||||
|
workspace.package.version = "0.1.1"
|
||||||
|
```
|
||||||
|
|
||||||
|
Le commit de `rel.001` validé comme stable reçoit ensuite le tag :
|
||||||
|
|
||||||
|
```text
|
||||||
|
v0.1.1
|
||||||
|
```
|
||||||
|
|
||||||
|
La session suivante peut alors démarrer avec :
|
||||||
|
|
||||||
|
```text
|
||||||
|
prompts/002-V0_1_2_START_PROMPT.md
|
||||||
|
```
|
||||||
150
deltas/0.1.1/rel.001.md
Normal file
150
deltas/0.1.1/rel.001.md
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
<!-- file: deltas/0.1.1/rel.001.md -->
|
||||||
|
<!-- version: 1 -->
|
||||||
|
|
||||||
|
# Delta `0.1.1-rel.001` — publication stable Core
|
||||||
|
|
||||||
|
## Base requise
|
||||||
|
|
||||||
|
`v0.1.1-pre.005` au sens du commit de livraison correspondant, avec :
|
||||||
|
|
||||||
|
```text
|
||||||
|
workspace.package.version = "0.1.1-pre.5"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Objectif
|
||||||
|
|
||||||
|
Publier la release stable `0.1.1`, clôturer `Core foundation` et préparer l'ouverture de `0.1.2 — Logging foundation` sans modifier la surface fonctionnelle de `ksp-core-lib`.
|
||||||
|
|
||||||
|
## Version Cargo
|
||||||
|
|
||||||
|
`workspace.package.version` passe de :
|
||||||
|
|
||||||
|
```text
|
||||||
|
0.1.1-pre.5
|
||||||
|
```
|
||||||
|
|
||||||
|
à :
|
||||||
|
|
||||||
|
```text
|
||||||
|
0.1.1
|
||||||
|
```
|
||||||
|
|
||||||
|
Le header de `Cargo.toml` passe de version 24 à 25.
|
||||||
|
|
||||||
|
La politique de dépendance reste inchangée :
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[workspace.dependencies]
|
||||||
|
solana-pubkey = { version = "^4.3", default-features = false }
|
||||||
|
```
|
||||||
|
|
||||||
|
Aucune feature optionnelle supplémentaire n'est activée.
|
||||||
|
|
||||||
|
## Validations finales exécutées par le user
|
||||||
|
|
||||||
|
Commandes exécutées avec succès le 2026-08-14 sur `0.1.1-pre.5` :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo fmt --all
|
||||||
|
cargo check --workspace
|
||||||
|
cargo test --workspace
|
||||||
|
cargo clippy --workspace --all-targets
|
||||||
|
cargo tree -p ksp-core-lib
|
||||||
|
cargo tree -p ksp-core-lib -d
|
||||||
|
cargo tree -p ksp-core-lib -e features
|
||||||
|
```
|
||||||
|
|
||||||
|
Résultats communiqués :
|
||||||
|
|
||||||
|
- `cargo check --workspace` : succès ;
|
||||||
|
- `cargo test --workspace` : 14 tests unitaires réussis, 3 tests d'intégration publics réussis, doc-tests réussis ;
|
||||||
|
- `cargo clippy --workspace --all-targets` : succès sans warning communiqué ;
|
||||||
|
- `cargo tree -p ksp-core-lib` : dépendance externe directe unique `solana-pubkey 4.3.0`, résolvant `solana-address 2.7.0` ;
|
||||||
|
- `cargo tree -p ksp-core-lib -d` : aucun doublon ;
|
||||||
|
- `cargo tree -p ksp-core-lib -e features` : graphe inspecté, sans besoin d'activer une feature optionnelle `solana-pubkey` supplémentaire.
|
||||||
|
|
||||||
|
## Surface stable publiée
|
||||||
|
|
||||||
|
`0.1.1` stabilise notamment :
|
||||||
|
|
||||||
|
- `ksp_core_lib::ErrorCode`, `ErrorContext`, `Error` et `Result<T>` ;
|
||||||
|
- `ksp_core_lib::Pubkey` ;
|
||||||
|
- les 18 Program IDs fondamentaux possédés par KSP et leurs paires `PRGID_*` / `PRGIDPK_*` ;
|
||||||
|
- `declare_program_id!` ;
|
||||||
|
- `ProgramIdEntry`, `ProgramIdFilter`, `ProgramIdKind` ;
|
||||||
|
- le registre canonique enumerable/recherchable et ses vues par taxonomie ;
|
||||||
|
- `native_program_ids()`, recherches texte/`Pubkey` et filtres combinables ;
|
||||||
|
- la séparation `subfamily` / `program_version` ;
|
||||||
|
- les règles Cargo workspace introduites pendant la release.
|
||||||
|
|
||||||
|
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.1` réalisée dans `ROADMAP.md` ;
|
||||||
|
- conserve `003-V0_1_1_CORE_FOUNDATION_PLAN.md` comme plan historique clôturé ;
|
||||||
|
- réaligne la séquence fonctionnelle sur la publication stable ;
|
||||||
|
- réaligne les index de documentation/plans ;
|
||||||
|
- conserve `prompts/002-V0_1_2_START_PROMPT.md` comme prompt de démarrage de la release suivante.
|
||||||
|
|
||||||
|
Aucun changelog général n'existe dans la base actuelle ; aucun changelog artificiel n'est créé.
|
||||||
|
|
||||||
|
## Fichiers ajoutés
|
||||||
|
|
||||||
|
```text
|
||||||
|
deltas/0.1.1/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/003-V0_1_1_CORE_FOUNDATION_PLAN.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## Fichiers supprimés
|
||||||
|
|
||||||
|
Aucun.
|
||||||
|
|
||||||
|
## Décisions
|
||||||
|
|
||||||
|
Aucune nouvelle décision architecturale.
|
||||||
|
|
||||||
|
La publication stable confirme les décisions et contrats stabilisés pendant les prereleases `0.1.1` et leurs fixes.
|
||||||
|
|
||||||
|
## Publication Git
|
||||||
|
|
||||||
|
Après application de ce delta :
|
||||||
|
|
||||||
|
1. vérifier que le working tree ne contient que les modifications attendues ;
|
||||||
|
2. exécuter au minimum un `cargo check --workspace` final sur la version Cargo stable `0.1.1` ;
|
||||||
|
3. créer le commit de release `v0.1.1-rel.001` ;
|
||||||
|
4. marquer ce commit comme release stable avec le tag :
|
||||||
|
|
||||||
|
```text
|
||||||
|
v0.1.1
|
||||||
|
```
|
||||||
|
|
||||||
|
Aucun autre tag n'est requis pour les prereleases/fixes historiques.
|
||||||
|
|
||||||
|
## Suite
|
||||||
|
|
||||||
|
Après le tag stable `v0.1.1`, ouvrir :
|
||||||
|
|
||||||
|
```text
|
||||||
|
0.1.2-pre.001
|
||||||
|
```
|
||||||
|
|
||||||
|
avec :
|
||||||
|
|
||||||
|
```text
|
||||||
|
prompts/002-V0_1_2_START_PROMPT.md
|
||||||
|
```
|
||||||
|
|
||||||
|
La première prerelease de `0.1.2` reste une phase de brainstorming, audit et planification avant développement fonctionnel de `ksp-logging-lib`.
|
||||||
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.
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user