44 Commits

Author SHA1 Message Date
513f57dd21 v0.1.3-rel.001 2026-08-16 08:06:38 +02:00
8279241144 v0.1.3-pre.015-fix.002 2026-08-16 07:56:38 +02:00
ee8fdedf86 v0.1.3-pre.015-fix.001 2026-08-16 07:54:49 +02:00
64136c99ad v0.1.3-pre.015 2026-08-16 07:52:59 +02:00
92982667ac v0.1.3-pre.014 2026-08-16 06:18:16 +02:00
299a0cb2fc v0.1.3-pre.013-fix.001 2026-08-16 06:06:51 +02:00
9549303ed5 v0.1.3-pre.013 2026-08-16 06:04:06 +02:00
a9405ec7ff v0.1.3-pre.012 2026-08-16 05:01:33 +02:00
6a13cea614 v0.1.3-pre.011-fix.001 2026-08-16 04:44:25 +02:00
274428bd2f v0.1.3-pre.011 2026-08-16 04:35:23 +02:00
695068acea v0.1.3-pre.010-fix.001 2026-08-15 22:02:29 +02:00
1fc002c978 v0.1.3-pre.010 2026-08-15 21:48:54 +02:00
97a07ce683 v0.1.3-pre.009-fix.001 2026-08-15 21:32:56 +02:00
a5b4c748ea v0.1.3-pre.009 2026-08-15 21:25:08 +02:00
d1196c03e5 v0.1.3-pre.008 2026-08-15 21:20:56 +02:00
96753e4ba1 v0.1.3-pre.007 2026-08-15 20:57:13 +02:00
b7323fe961 v0.1.3-pre.006 2026-08-15 20:40:38 +02:00
3a479e4f43 v0.1.3-pre.005-fix-001 2026-08-15 20:26:24 +02:00
063b24ee1c v0.1.3-pre.005 2026-08-15 20:22:02 +02:00
29660fd9f0 v0.1.3-pre.004 2026-08-15 19:58:18 +02:00
0629a48e97 v0.1.3-pre.003 2026-08-15 19:17:09 +02:00
9207919d47 v0.1.3-pre.002-fix.001 2026-08-15 19:11:30 +02:00
8360f25f59 v0.1.3-pre.002 2026-08-15 08:40:11 +02:00
ff94762b10 v0.1.3-pre.001-fix.003 2026-08-15 08:36:51 +02:00
532f56eaad v0.1.3-pre.001-fix.002 2026-08-15 08:35:36 +02:00
5cba2beb64 v0.1.3-pre.001-fix.001 2026-08-15 01:47:06 +02:00
ba384ec3d7 v0.1.3-pre.001 2026-08-15 00:12:35 +02:00
ba158ad375 v0.1.3-pre.001 2026-08-15 00:12:27 +02:00
3a7219fa59 v0.1.2-rel.001 2026-08-14 20:41:23 +02:00
77f6eaf487 v0.1.2-pre.006-fix.001 2026-08-14 20:34:54 +02:00
8a0c119878 v0.1.2-pre.006 2026-08-14 20:29:48 +02:00
d96f41fb8e v0.1.2-pre.005-fix.001 2026-08-14 20:08:18 +02:00
ea87a58e32 v0.1.2-pre.005 2026-08-14 19:54:58 +02:00
20c5643701 v0.1.2-pre.004-fix.004 2026-08-14 19:39:42 +02:00
d55903b13e v0.1.2-pre.004-fix.003 2026-08-14 19:37:37 +02:00
37f53f3080 v0.1.2-pre.004-fix.002 2026-08-14 19:33:59 +02:00
9f23eb950c v0.1.2-pre.004-fix.001 2026-08-14 19:22:56 +02:00
151590422d v0.1.2-pre.004 2026-08-14 18:43:54 +02:00
488d8ae0a8 v0.1.2-pre.003-fix.001 2026-08-14 18:29:56 +02:00
6e06802e38 v0.1.2-pre.003 2026-08-14 18:24:28 +02:00
697527675a v0.1.2-pre.002-fix 2026-08-14 18:10:38 +02:00
7b4444fb07 v0.1.2-pre.002 2026-08-14 18:03:20 +02:00
bd8401c0d0 v0.1.2-pre.001-fix.001 2026-08-14 17:52:34 +02:00
18ae0e4873 v0.1.2-pre.001 2026-08-14 16:43:34 +02:00
117 changed files with 24139 additions and 70 deletions

6
.env.example Normal file
View 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
View 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.

View File

@@ -1,12 +1,12 @@
# file: Cargo.toml # file: Cargo.toml
# version: 25 # 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.1.1" version = "0.1.3"
edition = "2024" edition = "2024"
license = "MIT" license = "MIT"
repository = "https://git.sasedev.com/Sasedev/khadhroony-solana-project" repository = "https://git.sasedev.com/Sasedev/khadhroony-solana-project"
@@ -14,7 +14,14 @@ authors = ["SinuS von SifriduS <sinus@sasedev.net>"]
publish = false publish = false
[workspace.dependencies] [workspace.dependencies]
serde = { version = "^1.0", features = ["derive"] }
serde_json = { version = "^1.0" }
jsonschema = { version = "^0.49", default-features = false }
solana-pubkey = { version = "^4.3", default-features = false } solana-pubkey = { version = "^4.3", default-features = false }
tracing = { version = "^0.1", default-features = false, features = ["std"] }
tracing-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"

View File

@@ -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 ;

View File

@@ -1,5 +1,5 @@
<!-- file: ROADMAP.md --> <!-- file: ROADMAP.md -->
<!-- version: 13 --> <!-- version: 16 -->
# Roadmap KSP # Roadmap KSP
@@ -32,11 +32,11 @@ Regrouper les releases consacrées aux fondations N1. Chaque release concrète e
### Releases concrètes ### Releases concrètes
- [X] `0.1.1` — Stabiliser `ksp-core-lib` : `Error`/`Result`, Program IDs fondamentaux et primitives réellement N1. - [X] `0.1.1` — Stabiliser `ksp-core-lib` : `Error`/`Result`, Program IDs fondamentaux et primitives réellement N1.
- [ ] `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.

View File

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

View File

@@ -0,0 +1,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": []
}
]
}

View 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
}
}
}
}
}

View 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
View 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"
}
]
}
]
}

View 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

View 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.

View 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`.

View 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` | §12 |
| 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, §78 |
| Sensibilité/provenance | `ConfigSensitivity`, `ConfigValueProvenance`, `ResolvedConfigText`, `ResolvedConfigJson`, `REDACTED_CONFIG_VALUE` | §3 |
| Logging effectif | `ResolvedLoggingConfig` | §4 |
| Management | `ConfigManagement`, `ConfigManagedSource`, `ConfigDocumentChangeReport`, `ConfigEnvironmentReport`, `ConfigEnvironmentChangeReport` | §67 |
| 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`.

View 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;

View 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;

View 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;

View 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;

View 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");

View 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;

View 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;

File diff suppressed because it is too large Load Diff

View 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);
}

View 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;

View 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(&registry);
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;

View 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;

View 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",
);
}
}
}

View 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);
}

View 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;
}

View 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());
}
}

View 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}");
}
}

View 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"),
);
}

View 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());
}
}

View 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());
}
}

View 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),
};
}

View 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);
}
}
}

View 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);
}

View 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

View 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.

View 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.

View 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.

View 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;

View 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");

View 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;

View 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)+))
}};
}

View 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;

View 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;

View 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;

View 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;

View 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));
}

View 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:?}"
);
}

View 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());
}
}
}

View 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);
}

View 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());
}

View 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"));
}

View 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);
}

View 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()]));
}

View 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);
}

View 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());
}

View 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));
}

View 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());
}

View 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
View 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`.

View 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
View 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.

View 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
View 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.

View 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
```

View 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
```

View 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
```

View 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
View 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.

View 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
View 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
```

View 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
View 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
View 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`.

View 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`.

View 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`.

View 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 1520 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 1520 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
View 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 dinté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.

View 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
View File

@@ -0,0 +1,233 @@
<!-- file: deltas/0.1.3/pre.002.md -->
<!-- version: 1 -->
# Delta 0.1.3-pre.002
## Base requise
Livraison documentaire précédente validée :
```text
0.1.3-pre.001-fix.003
```
La base porte :
```text
workspace.package.version = "0.1.3-pre.1"
Cargo.toml header version = 40
```
Le plan `docs/plans/005-V0_1_3_CONFIG_FOUNDATION_PLAN.md` est en version `4` et borne `pre.002` à la création de `ksp-config-lib` et au bootstrap `cfgpath` / `schemapath` uniquement.
## Objet de pre.002
Cette tranche ouvre le développement fonctionnel de Config sans anticiper les tranches suivantes.
Elle :
- crée `ksp-config-lib` ;
- ajoute la crate au workspace ;
- fixe les deux racines bootstrap non récursives ;
- expose leur équivalent programmatique ;
- possède le parsing des deux arguments CLI correspondants ;
- valide les chemins bootstrap ;
- introduit uniquement les erreurs Config nécessaires à cette surface ;
- ajoute les tests unitaires et d'intégration de cette API publique.
Elle n'introduit pas encore :
- le registre `file_id -> filename` ;
- `--filemap` ;
- `serde`, `serde_json` ou `jsonschema` ;
- les documents JSON runtime ;
- les profils/composites ;
- `.env` ou `std::env::var` ;
- l'interpolation `${...}` ;
- les secrets ;
- la persistence ;
- une dépendance directe à `ksp-logging-lib` tant qu'aucun événement Config ne l'utilise réellement.
## Crate `ksp-config-lib`
La nouvelle crate hérite de la version, de l'édition, du repository et des lints du workspace.
Sa seule dépendance est actuellement :
```toml
ksp-core-lib = { path = "../ksp-core-lib" }
```
Cela respecte la règle d'ajout des dépendances uniquement lorsqu'elles sont réellement utilisées. La direction architecturale future reste `ksp-config-lib -> ksp-logging-lib`, mais cette dépendance n'est pas ajoutée prématurément dans `pre.002`.
## Bootstrap non récursif
Les défauts KSP sont codés dans Config :
```text
DEFAULT_CFG_PATH = "config"
DEFAULT_SCHEMA_PATH = "config/schemas"
```
Ils ne dépendent d'aucun document Config, `.env` ou variable applicative.
La surface publique introduite est :
```text
ConfigBootstrapOptions::defaults()
ConfigBootstrapOptions::from_paths(...)
ConfigBootstrapOptions::from_args(...)
ConfigBootstrapOptions::cfg_path()
ConfigBootstrapOptions::schema_path()
ConfigBootstrapOptions::with_cfg_path(...)
ConfigBootstrapOptions::with_schema_path(...)
```
Les arguments possédés par Config sont :
```text
--cfgpath
--schemapath
```
Le parser accepte les deux formes :
```text
--cfgpath=/path/to/configs
--cfgpath /path/to/configs
--schemapath=/path/to/schemas
--schemapath /path/to/schemas
```
Les arguments étrangers sont ignorés afin qu'une application puisse transmettre son vecteur d'arguments complet à Config. Si un même path est fourni plusieurs fois, le dernier override explicite gagne.
Les deux roots restent indépendants : remplacer `cfgpath` ne modifie pas `schemapath`, et inversement.
## Validation des chemins
`pre.002` applique seulement les garanties qui sont valides avant la création des premiers documents runtime :
- path vide : refusé ;
- path existant et répertoire : accepté ;
- path existant mais non répertoire : refusé ;
- path inexistant : accepté, car `config/` et `config/schemas/` ne sont créés que dans une tranche ultérieure ;
- path relatif ou absolu : accepté.
Un argument séparé sans valeur, ou immédiatement suivi d'une autre option `--...`, produit une erreur dédiée.
## Erreurs Config initiales
Les codes restent possédés par `ksp-config-lib` avec le domaine `config` :
```text
config.bootstrap_argument_missing_value
config.bootstrap_invalid_path
```
Ils utilisent les contrats existants de Core :
```text
ksp_core_lib::Error
ksp_core_lib::ErrorCode
ksp_core_lib::Result<T>
```
Core ne reçoit aucune connaissance métier Config.
## Tests ajoutés
Les tests unitaires couvrent notamment :
- les deux défauts hardcodés ;
- l'indépendance des overrides cfg/schema ;
- les formes CLI inline et séparées ;
- la règle du dernier override ;
- l'ignorance des arguments étrangers ;
- l'absence de valeur ;
- le refus d'un path vide ;
- l'acceptation d'un path programmatique inexistant ;
- le refus d'un path existant qui est un fichier.
Les tests d'intégration vérifient la façade publique au crate-root et le parsing consommable par une crate externe.
## Version technique
La prerelease devient :
```text
workspace.package.version = "0.1.3-pre.2"
```
Le manifest racine devient :
```text
# version: 41
```
Le plan Config devient :
```text
<!-- version: 5 -->
```
## Fichiers ajoutés
```text
crates/ksp-config-lib/Cargo.toml
crates/ksp-config-lib/src/bootstrap.rs
crates/ksp-config-lib/src/error.rs
crates/ksp-config-lib/src/lib.rs
crates/ksp-config-lib/unit_tests/bootstrap.rs
crates/ksp-config-lib/tests/public_api.rs
deltas/0.1.3/pre.002.md
```
## Fichiers modifiés
```text
Cargo.toml
docs/plans/005-V0_1_3_CONFIG_FOUNDATION_PLAN.md
```
## Fichiers supprimés
Aucun.
## Validations non exécutées à faire dans l'environnement utilisateur
```bash
cargo fmt --all
cargo check --workspace
cargo clippy --workspace --all-targets
cargo test --workspace
cargo tree -p ksp-config-lib
cargo tree -p ksp-config-lib -d
cargo tree -p ksp-config-lib -e features
```
Aucun script d'audit Rust/KSP exécutable n'est présent dans la base reconstruite de cette tranche.
L'environnement de génération du delta ne fournit pas `cargo`, `rustc` ni `rustfmt`. Aucune commande Cargo ci-dessus n'est donc déclarée réussie avant la validation dans l'environnement utilisateur.
## Validations exécutées avant livraison
Des contrôles statiques ont vérifié avant livraison :
- headers `file:` / `version:` présents sur les nouveaux fichiers ;
- aucune ligne Rust supérieure à 160 colonnes avant formatage ;
- aucun `unsafe`, `unwrap`, `expect`, `panic!` ou opérateur `?` dans `src/` ;
- aucun `use` de non-trait ;
- aucune lecture de variable applicative par `std::env` dans `src/` ;
- aucune dépendance externe nouvelle ;
- aucun `Cargo.lock` ajouté au delta.
## Suite après validation
Si `pre.002` est validée, la prochaine tranche prévue est :
```text
0.1.3-pre.003 — registre logique file_id -> filename + --filemap
```
Elle ne doit pas encore lire les documents JSON ou leurs schemas.

311
deltas/0.1.3/pre.003.md Normal file
View File

@@ -0,0 +1,311 @@
<!-- file: deltas/0.1.3/pre.003.md -->
<!-- version: 1 -->
# Delta 0.1.3-pre.003
## Base requise
Livraison précédente validée :
```text
0.1.3-pre.002-fix.001
```
Version technique de cette base :
```text
workspace.package.version = "0.1.3-pre.2.fix.1"
Cargo.toml header version = 42
```
Les validations utilisateur de cette base sont propres : formatage, check, Clippy sans warning, tests workspace et vues `cargo tree` de `ksp-config-lib`.
## Objet de pre.003
Cette tranche introduit uniquement le registre logique des fichiers possédés par Config et le remplacement bootstrap de leurs noms physiques.
Elle ajoute :
- `ConfigFileId` ;
- `ConfigFileKind` ;
- `ConfigFileDescriptor` ;
- `ConfigFileRegistry` ;
- les mappings par défaut actuellement connus ;
- `--filemap=<file_id>=<filename>` répétable ;
- l'override programmatique équivalent ;
- la résolution d'un `file_id` sous `cfgpath` ou `schemapath` ;
- les validations d'identité, d'unicité, de kind et de filename relatif ;
- les erreurs Config et tests strictement nécessaires à cette surface.
Elle n'ajoute pas encore :
- `serde`, `serde_json` ou `jsonschema` ;
- lecture ou écriture JSON ;
- fichiers runtime `config/` ;
- validation de schema ;
- profils ou composites ;
- `.env` ou environnement du processus ;
- interpolation `${...}` ;
- secrets ;
- persistence ;
- modification de `ksp-logging-lib`.
## Identité logique et mappings par défaut
Les deux seuls fichiers que Config connaît actuellement sont :
```text
cfg.std.logging -> std.logging.json
schema.std.logging -> std.logging.schema.json
```
Les fichiers physiques ne sont pas encore créés ni lus dans cette tranche. Leur identité logique est introduite maintenant afin que les futurs consumers et composites ne dépendent jamais directement de leur filename.
Les constantes publiques associées sont :
```text
FILE_ID_STD_LOGGING
FILE_ID_SCHEMA_STD_LOGGING
DEFAULT_STD_LOGGING_FILENAME
DEFAULT_STD_LOGGING_SCHEMA_FILENAME
```
## `ConfigFileId`
`ConfigFileId` est une valeur typée possédée par Config.
La syntaxe initiale accepte uniquement :
- ASCII minuscule ;
- chiffres ;
- `_` et `-` dans les segments ;
- `.` comme séparateur de segments ;
- aucun segment vide.
Exemples valides :
```text
cfg.std.logging
schema.std.logging
cfg.composite.ksp-app-wallet-desk
```
Exemples refusés :
```text
CFG.std.logging
cfg..logging
cfg/logging
.cfg.logging
```
## Kind et racines
`ConfigFileKind` distingue :
```text
Config -> ConfigBootstrapOptions::cfg_path()
Schema -> ConfigBootstrapOptions::schema_path()
```
Un descriptor `Config` doit appartenir au namespace `cfg.*` et un descriptor `Schema` au namespace `schema.*`.
Cette relation est fixée au registre ; un override de filename ne peut jamais changer le kind ni déplacer un schema sous `cfgpath` ou un document Config sous `schemapath`.
## Override CLI `--filemap`
Le contrat initial est volontairement atomique :
```text
--filemap=<file_id>=<filename>
```
Exemples :
```text
--filemap=cfg.std.logging=my.logging.json
--filemap=schema.std.logging=my.logging.schema.json
```
L'argument est répétable. Lorsque le même `file_id` est fourni plusieurs fois, le dernier filename gagne.
Les arguments étrangers sont ignorés, comme pour le bootstrap des roots.
La forme séparée suivante n'est pas supportée :
```text
--filemap cfg.std.logging=my.logging.json
```
Un `--filemap` sans valeur inline est donc une erreur de mapping plutôt qu'un argument ignoré.
Un override ne peut pas enregistrer un nouveau `file_id`. Le `file_id` doit déjà appartenir au registre KSP ; seul son filename physique est remplaçable.
## Filenames et confinement lexical
Un filename mappé :
- doit être non vide ;
- doit être relatif ;
- ne peut contenir ni `.` ni `..` comme composants ;
- ne peut contenir de root/prefix absolu ;
- peut contenir des sous-répertoires relatifs normaux.
Ainsi :
```text
std.logging.json
profiles/my.logging.json
```
sont acceptés, tandis que :
```text
../std.logging.json
./std.logging.json
/opt/ksp/std.logging.json
```
sont refusés.
Cette tranche garantit le confinement **lexical** sous `cfgpath`/`schemapath`. Les garanties filesystem/canonicalisation/symlink nécessaires à la lecture et à la persistence seront ajoutées avec les opérations I/O correspondantes, pas simulées avant leur existence.
## API publique
La surface publique ajoute notamment :
```text
ConfigFileId::new(...)
ConfigFileId::as_str()
ConfigFileRegistry::defaults()
ConfigFileRegistry::from_args(...)
ConfigFileRegistry::descriptor(...)
ConfigFileRegistry::resolve_path(...)
ConfigFileRegistry::with_filename_override(...)
```
`resolve_path(...)` ne lit rien : il sélectionne simplement la bonne root bootstrap selon le kind et y joint le filename validé.
## Erreurs Config ajoutées
Les codes restent possédés par `ksp-config-lib` :
```text
config.file_id_invalid
config.file_id_unknown
config.file_id_duplicate
config.file_mapping_invalid
```
Ils réutilisent toujours `ksp_core_lib::Error`, `ErrorCode` et `Result<T>` sans introduire de connaissance Config dans Core.
## Dépendances
Aucune dépendance ajoutée ou modifiée.
`ksp-config-lib` dépend toujours directement uniquement de :
```text
ksp-core-lib
```
## Tests ajoutés
Les tests unitaires couvrent notamment :
- les deux mappings par défaut ;
- la séparation `cfgpath` / `schemapath` par kind ;
- l'override CLI ;
- la règle du dernier override ;
- l'override programmatique ;
- la conservation du `file_id` et du kind ;
- le refus d'un ID inconnu ;
- la syntaxe des IDs ;
- le refus des paths absolus et traversants ;
- le refus des arguments `--filemap` mal formés ;
- le refus des IDs dupliqués ;
- la cohérence namespace/kind.
Le test d'intégration public vérifie également que le registre, les deux IDs Logging, les overrides de document/schema et leur résolution sont consommables depuis le crate-root.
## Version technique
La prerelease devient :
```text
workspace.package.version = "0.1.3-pre.3"
```
Le manifest racine devient :
```text
# version: 43
```
Le plan Config devient :
```text
<!-- version: 6 -->
```
## Fichiers ajoutés
```text
crates/ksp-config-lib/src/registry.rs
crates/ksp-config-lib/unit_tests/registry.rs
deltas/0.1.3/pre.003.md
```
## Fichiers modifiés
```text
Cargo.toml
crates/ksp-config-lib/src/error.rs
crates/ksp-config-lib/src/lib.rs
crates/ksp-config-lib/tests/public_api.rs
docs/plans/005-V0_1_3_CONFIG_FOUNDATION_PLAN.md
```
## Fichiers supprimés
Aucun.
## Validations utilisateur attendues
```bash
cargo fmt --all
cargo check --workspace
cargo clippy --workspace --all-targets
cargo test --workspace
cargo tree -p ksp-config-lib
cargo tree -p ksp-config-lib -d
cargo tree -p ksp-config-lib -e features
```
Exécuter également tout script d'audit réellement présent dans le dépôt utilisateur.
## Validations exécutées avant livraison
L'environnement de génération ne fournit pas `cargo`, `rustc` ou `rustfmt`; aucune validation Cargo n'est donc déclarée réussie sur `pre.003` avant les tests utilisateur.
Les contrôles statiques de préparation vérifient :
- headers `file:` / `version:` ;
- version Cargo `0.1.3-pre.3` ;
- aucune ligne Rust supérieure à 160 colonnes avant formatage ;
- aucun `unsafe`, `unwrap`, `expect`, `panic!` ou opérateur `?` dans le nouveau code ;
- aucune lecture de variable applicative ;
- aucune dépendance externe nouvelle ;
- aucune surface JSON/schema I/O, `.env` ou Logging ouverte ;
- delta limité aux fichiers ajoutés/modifiés de cette tranche.
## Suite après validation
Si `pre.003` est validée, la tranche suivante reste :
```text
0.1.3-pre.004 — ksp-logging-lib : modèle public multi-output
```
Elle modifiera les contrats/settings Logging avant que `std.logging.schema.json` ne soit figé en `pre.006`.

337
deltas/0.1.3/pre.004.md Normal file
View File

@@ -0,0 +1,337 @@
<!-- file: deltas/0.1.3/pre.004.md -->
<!-- version: 1 -->
# Delta 0.1.3-pre.004
## Base requise
Livraison précédente validée :
```text
0.1.3-pre.003
```
Version technique de cette base :
```text
workspace.package.version = "0.1.3-pre.3"
Cargo.toml header version = 43
```
Validations utilisateur de `pre.003` :
```text
cargo fmt --all OK
cargo check --workspace OK
cargo clippy --workspace --all-targets OK
cargo test --workspace OK
```
Les tests Config livrés en `pre.003` réussissent : 21 tests unitaires et 4 tests d'API publique.
## Objet de pre.004
Cette tranche complète uniquement les **contrats/settings publics** de `ksp-logging-lib` nécessaires au futur `config/std.logging.json`.
Elle ne livre pas encore le runtime multi-sink complet. Le but est de stabiliser ce que Logging sait représenter avant de figer le schema JSON Config.
## Modèle public ajouté
### `LogFormat`
Formats publics représentables :
```text
Human
Compact
Pretty
Json
```
L'activation backend de tous ces formats appartient à la tranche runtime suivante.
### `OutputFilter`
Chaque sink peut maintenant déclarer un filtre propre contenant :
```text
level
targets[]
domains[]
```
Les selectors `targets` sont des préfixes de targets KSP ou le wildcard `*`.
Les selectors `domains` sont des préfixes de domain ou le wildcard `*`.
Le wildcard doit être utilisé seul dans sa dimension. Les listes vides, selectors vides, doublons et targets externes à KSP sont refusés par la validation publique.
`OutputFilter::unrestricted()` représente l'absence de restriction supplémentaire par rapport au takeover global :
```text
level = Trace
targets = ["*"]
domains = ["*"]
```
## Console explicite
`ConsoleSettings` représente désormais explicitement :
```text
enabled
output = stdout | stderr
ansi
format
filter
```
Les helpers :
```text
ConsoleSettings::stdout()
ConsoleSettings::stderr()
```
restent disponibles et produisent un contrat compatible avec le runtime `0.1.2` : console activée, format `Human`, ANSI désactivé et `OutputFilter::unrestricted()`.
## Plusieurs outputs fichier
`LoggingSettings` ne contient plus un unique :
```text
Option<FileSettings>
```
mais :
```text
Vec<FileSettings>
```
Chaque `FileSettings` porte :
```text
output_id
enabled
directory
file_name_prefix
rotation
format
ansi
filter
```
`output_id` est une identité Logging stable, unique parmi les fichiers d'un même `LoggingSettings`.
Il ne doit pas être confondu avec le `file_id` de `ksp-config-lib` :
```text
file_id -> identité d'un document/schema Config
output_id -> identité d'une sortie Logging
```
La syntaxe initiale d'un `output_id` accepte l'ASCII minuscule, chiffres, `_`, `-` et des segments séparés par `.` sans segment vide.
Exemples :
```text
file.debug
file.config.error
file.ksp-config-lib.error
```
## ANSI fichier
Une sortie persistante avec :
```text
ansi = true
```
est refusée par `LoggingSettings::validate()`.
Le contrat KSP conserve donc l'invariant qu'un fichier de logs ne persiste pas de séquences ANSI.
## Deux niveaux de filtrage conservés
Le nouveau filtre par sink ne remplace pas la politique existante.
`LoggingSettings` conserve :
```text
default_filter
target_filters[]
```
qui définissent le takeover global KSP.
Puis chaque console/fichier possède son `OutputFilter`, appliqué conceptuellement **en plus** du takeover global.
Cette séparation permet de conserver les overrides généraux tout en préparant un routing indépendant par sink, target, domain et niveau.
## Compatibilité runtime transitoire
Cette tranche ne doit jamais accepter silencieusement un contrat que le runtime `0.1.2` ne sait pas encore appliquer.
Après la validation structurelle, `initialize/reinitialize` refusent donc temporairement :
- plusieurs fichiers **actifs** simultanément ;
- ANSI console ;
- format console différent de `Human` ;
- format fichier différent de `Human` ;
- filtre propre à un output différent de `OutputFilter::unrestricted()`.
Les outputs désactivés peuvent déjà porter leur future configuration sans influencer le runtime actif.
Le refus utilise actuellement `logging.invalid_settings` avec un contexte `runtime_contract = single-output-compatibility`.
Ce garde-fou transitoire sera supprimé/remplacé lorsque `pre.005` implémentera réellement les capacités représentées.
## Lifecycle Logging préservé
Cette tranche ne modifie pas les responsabilités suivantes :
```text
initialize(...)
reinitialize(...)
LoggingGuard
subscriber global unique
takeover KSP
hot reload transactionnel
writers non bloquants
DroppedLines agrégé console/fichier
```
`DroppedLines` reste temporairement agrégé par type de sink ; sa généralisation éventuelle par `output_id` appartient à `pre.005`.
## Frontière Config préservée
Aucune dépendance inverse n'est introduite :
```text
ksp-logging-lib -X-> ksp-config-lib
```
`ksp-logging-lib` ne lit toujours aucun fichier JSON, aucun profil, aucun `.env` et aucune variable applicative.
Le futur `ksp-config-lib` construira ces contrats publics après résolution de `std.logging.json`.
## Dépendances
Aucune dépendance externe ou KSP supplémentaire n'est ajoutée.
Le graphe direct de `ksp-config-lib` reste inchangé dans cette tranche.
## Tests modifiés/ajoutés
Les tests settings couvrent notamment :
- les quatre formats ;
- niveau/targets/domains de `OutputFilter` ;
- wildcard unrestricted ;
- console enabled/output/ANSI/format/filter ;
- plusieurs `FileSettings` ;
- unicité et syntaxe des `output_id` ;
- fichiers sans ANSI ;
- selectors invalides, doublons et targets externes ;
- outputs déclarés mais désactivés.
Les tests runtime couvrent également le garde-fou transitoire :
- une console utilisant ANSI/format/routing enrichi est structurellement valide mais refusée par le backend courant ;
- plusieurs fichiers actifs sont structurellement valides mais refusés par le backend courant ;
- les contrats historiques compatibles continuent d'être préparés normalement.
Le test d'intégration d'API publique construit un contrat avec console enrichie et fichier JSON filtré afin de vérifier que la nouvelle surface est bien accessible depuis le crate-root.
## Documentation Logging
`README.md`, `USAGE.md` et `TODO.md` sont alignés sur la séparation :
```text
pre.004 = contrat public multi-output
pre.005 = activation runtime multi-sink/routing
```
## Version technique
La prerelease devient :
```text
workspace.package.version = "0.1.3-pre.4"
```
Le manifest racine devient :
```text
# version: 44
```
Le plan Config devient :
```text
<!-- version: 7 -->
```
## Fichiers ajoutés
```text
deltas/0.1.3/pre.004.md
```
## Fichiers modifiés
```text
Cargo.toml
crates/ksp-logging-lib/README.md
crates/ksp-logging-lib/TODO.md
crates/ksp-logging-lib/USAGE.md
crates/ksp-logging-lib/src/lib.rs
crates/ksp-logging-lib/src/runtime.rs
crates/ksp-logging-lib/src/settings.rs
crates/ksp-logging-lib/tests/public_api.rs
crates/ksp-logging-lib/tests/runtime.rs
crates/ksp-logging-lib/unit_tests/runtime.rs
crates/ksp-logging-lib/unit_tests/settings.rs
docs/plans/005-V0_1_3_CONFIG_FOUNDATION_PLAN.md
```
## Hors scope confirmé
Cette tranche n'implémente pas :
- le runtime multi-sink complet ;
- le routing réel par target/domain de chaque sink ;
- les formatters Compact/Pretty/JSON runtime ;
- l'ANSI console runtime ;
- la généralisation complète des guards/drop counters par `output_id` ;
- `serde`, `serde_json` ou `jsonschema` ;
- `std.logging.json` ou son schema ;
- profils Config ;
- `.env` ou interpolation ;
- modification de `ksp-config-lib`.
## Validations à exécuter par l'utilisateur
```bash
cargo fmt --all
cargo check --workspace
cargo clippy --workspace --all-targets
cargo test --workspace
cargo tree -p ksp-logging-lib
cargo tree -p ksp-logging-lib -d
cargo tree -p ksp-logging-lib -e features
```
Les commandes Cargo ne sont pas déclarées réussies dans le delta tant qu'elles n'ont pas été exécutées sur l'environnement utilisateur.
## Suite
Après validation de cette tranche :
```text
0.1.3-pre.005 — ksp-logging-lib : runtime multi-sink + routing
```
Cette tranche suivante devra activer réellement les contrats stabilisés ici sans réintroduire Config dans Logging.

View File

@@ -0,0 +1,117 @@
<!-- file: deltas/0.1.3/pre.005-fix.001.md -->
<!-- version: 1 -->
# Delta 0.1.3-pre.005-fix.001
## Base requise
Livraison précédente :
```text
0.1.3-pre.005
```
Version technique de cette base :
```text
workspace.package.version = "0.1.3-pre.5"
Cargo.toml header version = 45
```
Validations utilisateur exécutées le 2026-08-15 :
```text
cargo fmt --all OK
cargo check --workspace OK
cargo clippy --workspace --all-targets OK
cargo test --workspace FAILED — 1 test d'intégration Logging
cargo tree -p ksp-logging-lib OK
cargo tree -p ksp-logging-lib -d OK — aucune duplication
cargo tree -p ksp-logging-lib -e features OK
```
Échec isolé :
```text
global_runtime_supports_takeover_non_blocking_outputs_hot_reload_and_single_initialization
assertion failed: json_text.contains("logging info marker")
```
## Cause
Le test utilisait le même event pour vérifier deux comportements différents :
```text
logging info <ANSI>marker</ANSI>
```
Cet event était envoyé à la fois au sink `Human` et au sink `Json`.
Pour le sink humain, `StripAnsiWriter` reçoit les octets ANSI bruts après formatage et les supprime, ce qui produit bien :
```text
logging info marker
```
Pour le formatter JSON, les caractères de contrôle présents dans la valeur structurée sont échappés par le JSON avant l'écriture. La représentation persistée contient donc une forme JSON échappée de l'ESC plutôt qu'une séquence terminal brute ; la sous-chaîne littérale `logging info marker` n'est alors plus contiguë.
L'échec provenait donc d'une hypothèse incorrecte du test et non d'une perte de l'event par le routing multi-sink.
## Correction
Le test d'intégration sépare désormais les responsabilités :
- `LOGGING_TARGET` continue de vérifier le sink `Human` et le stripping ANSI persistant ;
- un target KSP dédié `JSON_KSP_TARGET = "ksp-logging-json-test"` alimente le sink JSON ;
- les events JSON utilisent des messages neutres : `json info marker` et `json error marker` ;
- les assertions JSON vérifient ces marqueurs et le target JSON dédié ;
- le routing indépendant par target reste ainsi testé explicitement sans dépendre de la représentation d'échappement JSON d'un caractère de contrôle.
Aucun code runtime de `ksp-logging-lib` n'est modifié.
## Version technique
Comme un fichier Rust de test est modifié :
```text
workspace.package.version = "0.1.3-pre.5.fix.1"
Cargo.toml header version = 46
crates/ksp-logging-lib/tests/runtime.rs header version = 7
```
## Fichiers modifiés
```text
Cargo.toml
crates/ksp-logging-lib/tests/runtime.rs
```
## Fichier ajouté
```text
deltas/0.1.3/pre.005-fix.001.md
```
## Hors scope
Ce fix n'ouvre pas `pre.006` et ne modifie pas :
- le runtime multi-sink ;
- le routing `level`/`target` ;
- le futur routing structuré `domain` ;
- Config, JSON Config ou JSON Schema ;
- les dépendances ou features Cargo.
## Validations à exécuter
```bash
cargo fmt --all
cargo check --workspace
cargo clippy --workspace --all-targets
cargo test --workspace
cargo tree -p ksp-logging-lib
cargo tree -p ksp-logging-lib -d
cargo tree -p ksp-logging-lib -e features
```
Aucune validation Cargo n'est déclarée réussie tant qu'elle n'a pas été exécutée sur le workspace utilisateur.

313
deltas/0.1.3/pre.005.md Normal file
View File

@@ -0,0 +1,313 @@
<!-- file: deltas/0.1.3/pre.005.md -->
<!-- version: 1 -->
# Delta 0.1.3-pre.005
## Base requise
Livraison précédente validée :
```text
0.1.3-pre.004
```
Version technique de cette base :
```text
workspace.package.version = "0.1.3-pre.4"
Cargo.toml header version = 44
```
Validations utilisateur de `pre.004` exécutées le 2026-08-15 :
```text
cargo fmt --all OK
cargo check --workspace OK
cargo clippy --workspace --all-targets OK
cargo test --workspace OK
cargo tree -p ksp-logging-lib OK
cargo tree -p ksp-logging-lib -d OK — aucune duplication
cargo tree -p ksp-logging-lib -e features OK
```
## Objet de pre.005
Cette tranche active le runtime multi-sink derrière les contrats publics stabilisés en `pre.004`, mais reste bornée aux dimensions de routing directement portées par les métadonnées des events/spans :
```text
level
target
```
Le routing par champ structuré `domain` est scindé en `pre.006` plutôt que d'être approximé ou assimilé au target.
## Multi-sink runtime
`RuntimeOutputs` possède maintenant :
```text
console: Option<RuntimeOutput>
files: Vec<RuntimeFileOutput>
```
Tous les `FileSettings` actifs sont préparés et installés simultanément.
Chaque fichier conserve :
```text
output_id
WorkerGuard
ErrorCounter
```
Le lifecycle de hot reload reste celui stabilisé en `0.1.2` :
1. validation et préparation complète des nouvelles sorties ;
2. swap du groupe de layers via le handle reload existant ;
3. conservation des compteurs des sorties retirées ;
4. destruction des anciens layers ;
5. destruction des anciens guards/writers.
Un échec de préparation avant le swap laisse la configuration active intacte.
## Routing par output : level + target
Chaque formatter utilise désormais un `RouteMakeWriter` interne.
Lors de `make_writer_for(metadata)` :
- `OutputFilter.level` décide si le niveau est accepté ;
- `OutputFilter.targets[]` accepte le wildcard `*` ou un préfixe de target KSP ;
- un writer désactivé absorbe la sortie sans écrire dans le sink concerné.
Le takeover global existant reste distinct :
```text
default_filter + TargetFilter[]
groupe de sinks
OutputFilter level/target propre à chaque sink
```
Le routing par output ne transforme pas les layers en `Filtered` reloadables. La composition `Targets.and_then(output_layers)` stabilisée pendant `0.1.2` est conservée.
## Scission du routing `domain`
`domain` est un champ structuré déclaré par les callers, par exemple :
```rust
ksp_logging_lib::info!(
target: "ksp-config-lib",
domain = "config",
"configuration event"
);
```
Il n'est pas une propriété des métadonnées de target.
Pour respecter le budget de tranche et éviter une sémantique incorrecte, `pre.005` refuse explicitement un output **actif** dont :
```text
domains != ["*"]
```
Le refus utilise `logging.invalid_settings` avec le contexte :
```text
runtime_contract = metadata-routing-before-domain-routing
```
Les contrats publics de `pre.004` restent inchangés : un output désactivé peut déjà porter son futur filtre domain et `LoggingSettings::validate()` continue de le valider structurellement.
`pre.006` est insérée pour traiter :
- domain porté directement par un event ;
- domain d'un span ;
- héritage vers les events sans champ direct ;
- spans imbriqués ;
- lifecycle `SpanEvents` ;
- interaction avec les quatre formats et plusieurs sinks.
## Formats runtime
Les quatre formats publics sont maintenant actifs :
```text
Human
Compact
Pretty
Json
```
Le manifest racine active pour `tracing-subscriber` :
```toml
features = ["fmt", "json", "ansi"]
```
Aucune nouvelle dépendance directe n'est ajoutée au workspace.
## ANSI
La console applique désormais réellement `ConsoleSettings::ansi()` pour les formats humains.
Les fichiers restent construits derrière `StripAnsiWriter` et persistent sans ANSI.
La combinaison :
```text
console.format = Json
console.ansi = true
```
est refusée par `LoggingSettings::validate()` afin de ne pas accepter une option qui serait sans effet.
## Compteurs par output fichier
La vue agrégée existante reste disponible :
```rust
LoggingGuard::dropped_lines() -> DroppedLines
```
avec :
```text
console
file
total
```
`pre.005` ajoute :
```rust
LoggingGuard::dropped_file_lines(output_id: &str) -> Option<usize>
```
Le compteur est cumulatif à travers les reloads pour un même `LoggingGuard` et un même `output_id`, y compris après retrait du sink.
Un `output_id` qui n'a jamais été actif retourne `None`.
## Tests
Les tests unitaires couvrent notamment :
- préparation de plusieurs fichiers simultanés ;
- console ANSI + format Compact ;
- fichiers Pretty et Json ;
- refus explicite d'un domain spécifique ;
- mapping des niveaux de routing ;
- writer désactivé ;
- rejet JSON + ANSI console ;
- conservation des tests de takeover, rotation, saturation et span lifecycle.
Le test global runtime est étendu pour vérifier en un seul subscriber global :
- échec transactionnel d'une configuration domain non encore supportée ;
- quatre fichiers simultanés ;
- formats Human, Compact, Pretty et Json ;
- routing indépendant par target et niveau ;
- silence des targets externes ;
- stripping ANSI fichier ;
- counters par `output_id` après retrait des sinks ;
- hot reload concurrent existant ;
- impossibilité d'une seconde initialisation globale.
## Plan de release regranularisé
La scission explicite décale le reste du cycle :
```text
pre.005 Logging : runtime multi-sink + level/target/formats
pre.006 Logging : routing structuré domain
pre.007 JSON/JSON Schema + std.logging.json
pre.008 globals + profils + default_profile
pre.009 compositions génériques par file_id
pre.010 .env + process env + resolver ${...}
pre.011 sensibilité + real/safe/provenance
pre.012 adapter Config -> Logging
pre.013 management + persistence JSON/.env
pre.014 ownership audits + robustesse
pre.015 clôture
```
Le schema `std.logging.schema.json` n'est donc pas figé avant validation du routing domain.
## Version technique
La prerelease devient :
```text
workspace.package.version = "0.1.3-pre.5"
```
Le manifest racine devient :
```text
# version: 45
```
## Fichier ajouté
```text
deltas/0.1.3/pre.005.md
```
## Fichiers modifiés
```text
Cargo.toml
crates/ksp-logging-lib/README.md
crates/ksp-logging-lib/TODO.md
crates/ksp-logging-lib/USAGE.md
crates/ksp-logging-lib/src/lib.rs
crates/ksp-logging-lib/src/runtime.rs
crates/ksp-logging-lib/src/settings.rs
crates/ksp-logging-lib/src/writer.rs
crates/ksp-logging-lib/tests/public_api.rs
crates/ksp-logging-lib/tests/runtime.rs
crates/ksp-logging-lib/unit_tests/runtime.rs
crates/ksp-logging-lib/unit_tests/settings.rs
crates/ksp-logging-lib/unit_tests/writer.rs
docs/plans/000-README.md
docs/plans/002-FUNCTIONAL_RELEASE_SEQUENCE.md
docs/plans/005-V0_1_3_CONFIG_FOUNDATION_PLAN.md
```
## Hors scope confirmé
Cette tranche n'implémente pas :
- le routing runtime par champ structuré `domain` ;
- JSON Config ou JSON Schema ;
- `std.logging.json` ;
- profils/composites Config ;
- `.env` ou interpolation ;
- secrets/redaction Config ;
- modification de `ksp-config-lib` ;
- rotation par taille/rétention/compression.
## Validations à exécuter par l'utilisateur
```bash
cargo fmt --all
cargo check --workspace
cargo clippy --workspace --all-targets
cargo test --workspace
cargo tree -p ksp-logging-lib
cargo tree -p ksp-logging-lib -d
cargo tree -p ksp-logging-lib -e features
```
Les commandes Cargo ne sont pas déclarées réussies dans ce delta tant qu'elles n'ont pas été exécutées sur l'environnement utilisateur.
## Suite
Après validation de cette tranche :
```text
0.1.3-pre.006 — ksp-logging-lib : routing structuré domain
```
Cette tranche devra fermer la dernière dimension du contrat `OutputFilter` avant l'introduction de `std.logging.schema.json`.

226
deltas/0.1.3/pre.006.md Normal file
View File

@@ -0,0 +1,226 @@
<!-- file: deltas/0.1.3/pre.006.md -->
<!-- version: 1 -->
# Delta 0.1.3-pre.006
## Base requise
Livraison précédente validée :
```text
0.1.3-pre.005-fix.001
```
Version technique de cette base :
```text
workspace.package.version = "0.1.3-pre.5.fix.1"
Cargo.toml header version = 46
```
Validations utilisateur exécutées le 2026-08-15 :
```text
cargo fmt --all OK
cargo check --workspace OK
cargo clippy --workspace --all-targets OK
cargo test --workspace OK
cargo tree -p ksp-logging-lib OK
cargo tree -p ksp-logging-lib -d OK — aucune duplication
cargo tree -p ksp-logging-lib -e features OK
```
Le fix JSON de `pre.005` est donc clos avant ouverture de cette tranche.
## Objet de pre.006
Fermer la troisième dimension de routing déjà représentée par `OutputFilter` :
```text
level
target
domain
```
`domain` reste un champ structuré indépendant du target. Cette tranche ne change pas les contrats Config et ne crée encore aucun document JSON Config.
## Domain effectif
Le runtime applique la sémantique suivante :
1. un event portant directement `domain` utilise cette valeur ;
2. sinon l'event hérite du domain effectif de son span ;
3. un span portant directement `domain` définit son domain effectif ;
4. un span sans `domain` hérite du domain effectif de son parent au moment de sa création ;
5. un span enfant explicite peut donc remplacer le domain hérité ;
6. les lifecycle events `NEW`, `ENTER`, `EXIT` et `CLOSE` utilisent le domain effectif du span concerné.
Un domain direct d'event ne modifie pas le domain mémorisé du span.
## Routing par output
Le writer routé applique maintenant conjointement :
```text
OutputFilter.level
OutputFilter.targets[]
OutputFilter.domains[]
```
Pour `domains[]` :
- `["*"]` signifie aucune restriction et accepte aussi un event/span sans domain ;
- un selector nommé correspond par préfixe ;
- un selector nommé ne correspond jamais à une entrée sans domain.
La politique globale existante reste distincte :
```text
default_filter + TargetFilter[]
DomainContextLayer + groupe de sinks
OutputFilter level/target/domain de chaque sink
```
Aucune convention de pseudo-target domain n'est introduite.
## Couche interne de contexte
`DomainContextLayer` est une couche privée de `ksp-logging-lib` installée avant les formatters lorsque le runtime possède au moins un output actif.
Elle :
- capture le champ `domain` des events et spans ;
- conserve le domain effectif d'un span dans ses extensions runtime ;
- expose uniquement au routing interne le domain effectif correspondant au callback en cours ;
- conserve les champs originaux pour les formatters Human/Compact/Pretty/Json ;
- n'ajoute aucun type public et ne change pas la façade consommateur.
Le contexte courant utilisé par le routing reste interne au thread de dispatch Logging et n'est pas une variable globale Config/applicative.
## Runtime préservé
Cette tranche conserve sans changement de contrat :
- subscriber global unique ;
- takeover des targets externes ;
- multi-sink ;
- formats Human/Compact/Pretty/Json ;
- ANSI console et stripping fichier ;
- writers non bloquants ;
- guards et compteurs par `output_id` ;
- hot reload transactionnel ;
- comportement de saturation.
Le refus transitoire `domains != ["*"]` introduit en `pre.005` est supprimé puisque la dimension domain est maintenant exécutée réellement.
## Tests
Les tests unitaires couvrent :
- wildcard domain avec ou sans domain courant ;
- selector nommé absent sans domain ;
- correspondance par préfixe ;
- acceptation runtime d'un filtre domain spécifique.
Le test d'intégration global ajoute trois sinks simultanés :
```text
file.domain.logging -> domains = ["logging"]
file.domain.store -> domains = ["store"]
file.domain.any -> domains = ["*"]
```
Il couvre :
- domain direct d'event ;
- event sans domain ;
- héritage depuis un span parent ;
- override direct d'event ;
- enfant sans domain ;
- enfant avec domain explicite ;
- lifecycle de spans Logging/Store ;
- maintien des compteurs à zéro dans le scénario nominal.
## Dépendances
Aucune nouvelle dépendance et aucune nouvelle feature Cargo ne sont ajoutées.
`ksp-logging-lib` reste le seul propriétaire direct de la stack `tracing*` et ne dépend toujours pas de `ksp-config-lib`.
## Plan
Les trois tranches Logging nécessaires avant le premier schema Config sont désormais fonctionnellement couvertes :
```text
pre.004 contrats/settings multi-output
pre.005 runtime multi-sink + level/target/formats
pre.006 routing structuré domain
```
Après validation utilisateur de `pre.006`, la prochaine tranche est :
```text
pre.007 JSON/JSON Schema + std.logging.json
```
## Version technique
La prerelease devient :
```text
workspace.package.version = "0.1.3-pre.6"
Cargo.toml header version = 47
```
## Fichiers ajoutés
```text
crates/ksp-logging-lib/src/domain.rs
crates/ksp-logging-lib/unit_tests/domain.rs
deltas/0.1.3/pre.006.md
```
## Fichiers modifiés
```text
Cargo.toml
crates/ksp-logging-lib/README.md
crates/ksp-logging-lib/TODO.md
crates/ksp-logging-lib/USAGE.md
crates/ksp-logging-lib/src/lib.rs
crates/ksp-logging-lib/src/runtime.rs
crates/ksp-logging-lib/src/writer.rs
crates/ksp-logging-lib/tests/runtime.rs
crates/ksp-logging-lib/unit_tests/runtime.rs
docs/plans/000-README.md
docs/plans/002-FUNCTIONAL_RELEASE_SEQUENCE.md
docs/plans/005-V0_1_3_CONFIG_FOUNDATION_PLAN.md
```
## Hors scope
Cette tranche ne modifie pas :
- `ksp-config-lib` ;
- le registre `file_id` ;
- JSON/JSON Schema ;
- `std.logging.json` ;
- profils/composites ;
- `.env` ou les variables d'environnement ;
- persistence Config.
## Validations à exécuter
```bash
cargo fmt --all
cargo check --workspace
cargo clippy --workspace --all-targets
cargo test --workspace
cargo tree -p ksp-logging-lib
cargo tree -p ksp-logging-lib -d
cargo tree -p ksp-logging-lib -e features
```
Aucune validation Cargo n'est déclarée réussie tant qu'elle n'a pas été exécutée sur le workspace utilisateur.

347
deltas/0.1.3/pre.007.md Normal file
View File

@@ -0,0 +1,347 @@
<!-- file: deltas/0.1.3/pre.007.md -->
<!-- version: 1 -->
# Delta 0.1.3-pre.007
## Base requise
Livraison précédente validée :
```text
0.1.3-pre.006
```
Version technique de cette base :
```text
workspace.package.version = "0.1.3-pre.6"
Cargo.toml header version = 47
```
Validations utilisateur exécutées le 2026-08-15 :
```text
cargo fmt --all OK
cargo check --workspace OK
cargo clippy --workspace --all-targets OK
cargo test --workspace OK
cargo tree -p ksp-logging-lib OK
cargo tree -p ksp-logging-lib -d OK — aucune duplication
cargo tree -p ksp-logging-lib -e features OK
```
Les tranches Logging préalables au premier schema Config sont donc closes.
## Objet de pre.007
Introduire la première surface JSON/JSON Schema réelle de `ksp-config-lib` sans ouvrir encore la résolution de profils, les composites ou l'environnement :
```text
registry file_id
-> resolved Config path
-> JSON parse
-> registered schema file_id
-> schema parse + meta-schema validation
-> document validation
-> bounded document semantics
```
Le premier document concret est :
```text
cfg.std.logging -> config/std.logging.json
```
validé par :
```text
schema.std.logging -> config/schemas/std.logging.schema.json
```
## Dépendances
Versions vérifiées au moment de l'ajout depuis les sources officielles `crates.io` / `docs.rs` :
```text
serde 1.0.229 -> workspace constraint ^1.0
serde_json 1.0.151 -> workspace constraint ^1.0
jsonschema 0.49.9 -> workspace constraint ^0.49
```
`jsonschema` est déclaré avec :
```text
default-features = false
```
La première surface KSP utilise un schema autonome avec références JSON Pointer internes uniquement ; aucune récupération HTTP/file de schemas distants n'est nécessaire.
Les trois dépendances sont déclarées uniquement sous `[workspace.dependencies]`, puis consommées par `ksp-config-lib` avec `.workspace = true`.
## Association document -> schema
`ConfigFileDescriptor` possède maintenant :
```text
schema_file_id: Option<ConfigFileId>
```
Le mapping par défaut devient conceptuellement :
```text
cfg.std.logging
kind = Config
filename = std.logging.json
schema_file_id = schema.std.logging
schema.std.logging
kind = Schema
filename = std.logging.schema.json
schema_file_id = none
```
Le registre valide :
- qu'un schema référencé utilise le namespace `schema.*` ;
- que son descriptor existe réellement ;
- qu'il possède `ConfigFileKind::Schema` ;
- qu'un descriptor Schema ne référence pas lui-même un schema Config.
Un `--filemap` change uniquement le filename physique et conserve cette association logique.
Aucun `schema.composite` n'est encore créé : il sera ajouté avec la tranche composite qui en a réellement besoin.
## Moteur JSON générique
Nouvelle façade :
```text
ConfigDocumentEngine
ConfigJsonDocument
```
`ConfigDocumentEngine::load_validated_document(file_id)` :
1. vérifie que le `file_id` désigne un document Config ;
2. résout son path via `ConfigBootstrapOptions` + `ConfigFileRegistry` ;
3. lit et parse le JSON ;
4. charge le schema associé par son propre `file_id` ;
5. valide le schema contre son meta-schema ;
6. valide le document contre le schema Draft 2020-12 ;
7. exécute les invariants sémantiques actuellement connus pour ce type de document ;
8. retourne le JSON validé sans transférer aux consumers la responsabilité de lecture/validation filesystem.
`ConfigJsonDocument` expose uniquement :
```text
file_id
resolved path
validated serde_json::Value
```
Les autres crates restent interdites de lecture directe des fichiers Config.
## Diagnostics
Les nouveaux codes Config distinguent :
```text
config.json_file_read_failed
config.json_syntax_invalid
config.schema_invalid
config.schema_validation_failed
config.document_semantic_invalid
```
Les erreurs enregistrent le `file_id` et le path concernés, mais jamais le contenu JSON complet.
## Premier schema Logging
`config/schemas/std.logging.schema.json` utilise JSON Schema Draft 2020-12 et couvre la surface Logging stabilisée en `pre.004/.005/.006` :
```text
format_version
logs_directory
default_profile
profiles[]
profile_id
default_filter
span_events
console
enabled
output
ansi
format
filter.level
filter.targets[]
filter.domains[]
files[]
output_id
enabled
path
rotation
format
ansi = false
filter.level
filter.targets[]
filter.domains[]
target_filters[]
```
Le schema encode notamment :
- niveaux `off/error/warn/info/debug/trace` ;
- lifecycle `off/new_and_close/full` ;
- formats `human/compact/pretty/json` ;
- rotation `never/hourly/daily` ;
- console `stdout/stderr` ;
- targets KSP ou wildcard `*` ;
- selectors uniques, wildcard utilisé seul ;
- ANSI interdit pour les fichiers ;
- ANSI + JSON console interdit.
## Document runtime et exemple
Ajouts :
```text
config/std.logging.json
config/schemas/std.logging.schema.json
config/examples/std.logging.example.json
```
Le runtime utilise déjà :
```text
"logs_directory": "${KSP_LOGS_DIRECTORY:-logs}"
```
mais `pre.007` ne résout encore aucune variable. Le placeholder reste une valeur source string valide jusqu'au resolver de `pre.010`.
Le runtime démontre :
- console configurable ;
- plusieurs fichiers ;
- formats humain et JSON ;
- filtres par level/target/domain ;
- target overrides globaux ;
- `output_id` distinct du `file_id` Config.
## Validation sémantique de base
Après JSON Schema, Config vérifie déjà les invariants directement liés à la surface Logging :
- `format_version = 1` ;
- chaînes globales requises non vides ;
- `output_id` fichier conforme ;
- `output_id` uniques dans un même profil ;
- path fichier relatif à `logs_directory`, sans traversal ;
- ANSI fichier interdit ;
- ANSI + JSON console interdit ;
- selectors non vides/uniques ;
- wildcard seul ;
- target selector et global `target_prefix` limités aux targets `ksp-*`.
Ne sont volontairement **pas encore** traités dans cette tranche :
- unicité des `profile_id` ;
- résolution de `default_profile` ;
- sélection explicite d'un profil ;
- construction d'une configuration effective globals + profil.
Ces responsabilités appartiennent à `pre.008`.
## Tests ajoutés
Les tests unitaires Config couvrent :
- document Logging runtime commité valide ;
- fichier Config absent ;
- syntaxe JSON invalide ;
- schema lui-même invalide ;
- document ne satisfaisant pas son schema ;
- document schema-valide mais sémantiquement invalide ;
- association document -> schema présente dans le registre ;
- association vers schema absent refusée.
Le test d'API publique vérifie que `ConfigDocumentEngine` charge le vrai `std.logging.json` depuis les racines Config du workspace.
## Hors scope
Cette tranche ne fait pas encore :
- résolution globals/profils/`default_profile` ;
- composite ;
- `.env` ;
- `std::env` ;
- interpolation `${...}` ;
- classification secret/public/internal ;
- redaction ;
- adaptation vers `ksp_logging_lib::LoggingSettings` ;
- mutation/persistence.
`ksp-config-lib` ne dépend donc toujours pas de `ksp-logging-lib` dans `pre.007`.
## Version technique
La prerelease devient :
```text
workspace.package.version = "0.1.3-pre.7"
Cargo.toml header version = 48
```
## Fichiers ajoutés
```text
config/std.logging.json
config/schemas/std.logging.schema.json
config/examples/std.logging.example.json
crates/ksp-config-lib/src/document.rs
crates/ksp-config-lib/unit_tests/document.rs
deltas/0.1.3/pre.007.md
```
## Fichiers modifiés
```text
Cargo.toml
crates/ksp-config-lib/Cargo.toml
crates/ksp-config-lib/src/error.rs
crates/ksp-config-lib/src/lib.rs
crates/ksp-config-lib/src/registry.rs
crates/ksp-config-lib/tests/public_api.rs
crates/ksp-config-lib/unit_tests/registry.rs
docs/plans/000-README.md
docs/plans/002-FUNCTIONAL_RELEASE_SEQUENCE.md
docs/plans/005-V0_1_3_CONFIG_FOUNDATION_PLAN.md
docs/rules/FILE_CONTRACTS.md
```
## Contrôles exécutés dans l'environnement de génération
Les fichiers JSON ont été parsés avec `python -m json.tool`.
Le schema Draft 2020-12 et le document runtime ont également été validés avec l'implémentation Python `jsonschema` disponible dans l'environnement de génération.
Aucune commande Cargo n'est disponible dans cet environnement ; aucune validation Rust n'est donc déclarée réussie ici.
## Validations utilisateur à exécuter
```bash
cargo fmt --all
cargo check --workspace
cargo clippy --workspace --all-targets
cargo test --workspace
cargo tree -p ksp-config-lib
cargo tree -p ksp-config-lib -d
cargo tree -p ksp-config-lib -e features
```
Une attention particulière doit être portée au graphe `jsonschema` avec `default-features = false` afin de confirmer qu'aucun stack HTTP/TLS de résolution distante n'est introduit inutilement.
Après validation de `pre.007`, la prochaine tranche est :
```text
0.1.3-pre.008 — globals + profils + default_profile
```

268
deltas/0.1.3/pre.008.md Normal file
View File

@@ -0,0 +1,268 @@
<!-- file: deltas/0.1.3/pre.008.md -->
<!-- version: 1 -->
# Delta 0.1.3-pre.008
## Base requise
Livraison précédente validée :
```text
0.1.3-pre.007
```
Version technique de cette base :
```text
workspace.package.version = "0.1.3-pre.7"
Cargo.toml header version = 48
```
Validations utilisateur exécutées le 2026-08-15 :
```text
cargo fmt --all OK
cargo check --workspace OK
cargo clippy --workspace --all-targets OK
cargo test --workspace OK
cargo tree -p ksp-config-lib OK
cargo tree -p ksp-config-lib -e features OK
```
`cargo tree -p ksp-config-lib -d` ne signale qu'une coexistence transitive `syn 2.0.119` / `syn 3.0.3`, portée par l'écosystème `jsonschema`/ICU/proc-macros. Le graphe observé ne contient pas de stack HTTP/TLS de résolution distante avec `jsonschema default-features = false`; aucun changement de dépendance n'est donc justifié dans cette tranche.
## Objet de pre.008
Fermer la résolution des paramètres globaux, profils et `default_profile` avant d'introduire les composites :
```text
validated standard document
-> validate profile identities/default_profile
-> select default or explicit profile
-> retain globals
-> retain selected profile
-> build effective top-level view
-> preserve Global/Profile provenance
```
Cette tranche reste générique : le moteur de profils n'est pas un type Logging. `std.logging.json` est uniquement le premier document standard qui l'exerce.
## Contrat générique des profils
Un document standard profilé possède deux clés structurelles réservées :
```text
default_profile
profiles
```
Toutes les autres propriétés top-level sont considérées comme des paramètres globaux du document.
Garanties désormais validées après JSON Schema :
- `profiles` contient au moins un objet ;
- chaque profil possède un `profile_id` non vide ;
- les `profile_id` sont uniques dans le document ;
- `default_profile` est autonome au niveau global ;
- `default_profile` référence obligatoirement un `profile_id` existant.
Un document qui viole ces invariants retourne :
```text
config.document_semantic_invalid
```
## Résolution publique
`ConfigDocumentEngine` expose maintenant :
```text
load_resolved_profile(file_id, None)
-> sélectionne default_profile
load_resolved_profile(file_id, Some(profile_id))
-> sélection explicite
```
Un profil explicitement demandé mais absent retourne un diagnostic distinct :
```text
config.profile_not_found
```
Cette erreur ne signifie pas que le document source est invalide : le document peut être parfaitement valide tout en ne contenant pas le profil demandé par le caller.
## `ResolvedConfigProfile`
La nouvelle surface publique conserve :
```text
file_id
source path
profile_id sélectionné
selection_source = DefaultProfile | Explicit
globals
profile
effective
origin(key) = Global | Profile
```
`globals` exclut volontairement :
```text
default_profile
profiles
```
`profile` conserve l'objet du profil sélectionné, y compris son `profile_id`.
`effective` est une vue top-level déterministe :
1. insertion des globals ;
2. insertion des propriétés du profil sélectionné.
Si un futur schema permet une même clé aux deux niveaux, la valeur du profil est prioritaire et sa provenance devient `Profile`. Les schemas spécialisés restent libres d'interdire ce cas lorsqu'une propriété doit rester strictement globale.
La provenance documentaire commune reste disponible par :
```text
file_id
path
```
et la provenance de chaque valeur top-level par :
```text
ConfigValueOrigin::Global
ConfigValueOrigin::Profile
```
Les futures couches composite/env/sensibilité devront enrichir cette provenance sans la perdre.
## `std.logging.json`
Le fichier runtime et son schema ne nécessitent aucun changement dans cette tranche.
Le document commité :
```text
default_profile = local_dev
```
résout maintenant réellement le profil `local_dev`.
Exemples de provenance :
```text
logs_directory -> Global
default_filter -> Profile
```
Le placeholder :
```text
${KSP_LOGS_DIRECTORY:-logs}
```
reste non interprété en `pre.008`; il sera résolu par la tranche environnement prévue plus tard.
## Tests
Les tests ajoutés couvrent :
- résolution du `default_profile` commité ;
- sélection explicite d'un profil existant ;
- distinction `DefaultProfile` / `Explicit` ;
- conservation séparée des globals et du profil ;
- construction de la vue effective ;
- provenance `Global` / `Profile` ;
- profil explicite inconnu -> `config.profile_not_found` ;
- `profile_id` dupliqués -> document sémantiquement invalide ;
- `default_profile` ne référençant aucun profil -> document sémantiquement invalide ;
- consommation de la nouvelle surface depuis le crate-root dans le test d'intégration public.
## Hors scope
Cette tranche n'introduit pas encore :
- document composite ;
- `schema.composite` ;
- override de profil depuis un composite ;
- `.env` ;
- lecture `std::env` applicative ;
- interpolation `${...}` ;
- `Public/Internal/Secret` ;
- redaction ;
- adaptation vers `ksp_logging_lib::LoggingSettings` ;
- mutation/persistence.
## Dépendances
Aucune dépendance externe ou feature Cargo supplémentaire n'est ajoutée.
`ksp-config-lib` ne dépend toujours pas de `ksp-logging-lib` dans cette tranche.
## Version technique
La prerelease devient :
```text
workspace.package.version = "0.1.3-pre.8"
Cargo.toml header version = 49
```
## Fichiers ajoutés
```text
crates/ksp-config-lib/src/profile.rs
crates/ksp-config-lib/unit_tests/profile.rs
deltas/0.1.3/pre.008.md
```
## Fichiers modifiés
```text
Cargo.toml
crates/ksp-config-lib/src/document.rs
crates/ksp-config-lib/src/error.rs
crates/ksp-config-lib/src/lib.rs
crates/ksp-config-lib/tests/public_api.rs
crates/ksp-config-lib/unit_tests/document.rs
docs/plans/000-README.md
docs/plans/002-FUNCTIONAL_RELEASE_SEQUENCE.md
docs/plans/005-V0_1_3_CONFIG_FOUNDATION_PLAN.md
docs/rules/FILE_CONTRACTS.md
```
## Fichiers supprimés
Aucun.
## Contrôles exécutés dans l'environnement de génération
- comparaison du delta avec la base `pre.007` ;
- contrôle des headers/version modifiés ;
- contrôle de l'absence de nouvelle dépendance Cargo ;
- contrôle statique des interdictions `unsafe`, `unwrap`, `expect`, opérateur `?` dans les nouveaux sources de production ;
- contrôle de la composition exacte de l'archive delta ;
- réapplication du delta sur une copie de `pre.007` pour vérifier la reproduction de l'arbre livré.
`cargo`, `rustc` et `rustfmt` ne sont pas disponibles dans l'environnement de génération. Aucune validation Rust n'est déclarée réussie ici.
## Validations utilisateur à exécuter
```bash
cargo fmt --all
cargo check --workspace
cargo clippy --workspace --all-targets
cargo test --workspace
cargo tree -p ksp-config-lib
cargo tree -p ksp-config-lib -d
cargo tree -p ksp-config-lib -e features
```
Après validation de `pre.008`, la prochaine tranche planifiée est :
```text
0.1.3-pre.009 — compositions génériques par file_id
```

View File

@@ -0,0 +1,25 @@
<!-- file: deltas/0.1.3/pre.009-fix.001.md -->
<!-- version: 1 -->
# Delta 0.1.3-pre.009-fix.001
## Objectif
Corriger le warning Clippy `collapsible_if` introduit dans le nettoyage des fixtures composites de `ksp-config-lib`, sans modifier le comportement fonctionnel de Config.
## Modifications
- `workspace.package.version` passe de `0.1.3-pre.9` à `0.1.3-pre.9.fix.1` ;
- `crates/ksp-config-lib/unit_tests/composite.rs` replie le double `if` de `cleanup_fixture` en une seule condition `if let ... && ...` conforme à Clippy ;
- aucun contrat public, schema, fichier Config, dépendance ou comportement runtime n'est modifié.
## Validation attendue
```bash
cargo fmt --all
cargo check --workspace
cargo clippy --workspace --all-targets
cargo test --workspace
```
Le résultat attendu est l'absence du warning `clippy::collapsible_if` observé sur `unit_tests/composite.rs`.

307
deltas/0.1.3/pre.009.md Normal file
View File

@@ -0,0 +1,307 @@
<!-- file: deltas/0.1.3/pre.009.md -->
<!-- version: 1 -->
# Delta 0.1.3-pre.009
## Base requise
Livraison précédente validée :
```text
0.1.3-pre.008
```
Version technique de cette base :
```text
workspace.package.version = "0.1.3-pre.8"
Cargo.toml header version = 49
```
Validations utilisateur exécutées le 2026-08-15 :
```text
cargo fmt --all OK
cargo check --workspace OK
cargo clippy --workspace --all-targets OK
cargo test --workspace OK
cargo tree -p ksp-config-lib OK
cargo tree -p ksp-config-lib -e features OK
```
`cargo test --workspace` confirme notamment 33 tests unitaires + 6 tests publics pour `ksp-config-lib`. `cargo tree -p ksp-config-lib -d` ne signale que la coexistence transitive déjà connue `syn 2.0.119` / `syn 3.0.3` portée par l'écosystème `jsonschema`; aucune correction de dépendance KSP n'est justifiée dans cette tranche.
## Objet de pre.009
Introduire le contrat de composition générique avant la résolution environnement :
```text
composite file_id
-> schema.composite
-> composite profile
-> document references by file_id
-> default or composite-selected document profile
-> ResolvedConfigProfile per component
-> preserve Global/Profile provenance
```
Aucun composite runtime d'application fictif n'est créé.
## `schema.composite`
Le registre Config connaît désormais :
```text
schema.composite -> composite.schema.json
```
Le mapping est résolu sous `schemapath` et peut être remplacé comme les autres fichiers connus :
```text
--filemap=schema.composite=my.composite.schema.json
```
En revanche, le registre par défaut n'introduit aucun :
```text
cfg.composite.<consumer>
```
Un tel descriptor sera ajouté seulement lorsqu'un consumer concret existera.
## Contrat composite
Le schema générique versionné est :
```text
config/schemas/composite.schema.json
```
Un exemple non runtime est fourni sous :
```text
config/examples/composite.example.json
```
Structure :
```text
format_version
default_profile
profiles[]
profile_id
documents[]
component_id
file_id
profile_id? # optionnel
```
Règles :
- le composite utilise le même modèle `default_profile` / `profiles` que les documents standards ;
- `component_id` est unique à l'intérieur d'un profil composite ;
- une référence cible exclusivement un `cfg.std.*` connu du registre ;
- une référence ne contient jamais de filename physique ;
- l'imbrication de composites n'est pas ouverte dans cette première surface ;
- `profile_id` absent conserve le `default_profile` autonome du document référencé ;
- `profile_id` présent impose ce profil depuis la composition.
## Résolution publique
`ConfigDocumentEngine` expose maintenant :
```text
load_resolved_composite(file_id, requested_profile)
```
Le caller peut sélectionner :
```text
None -> default_profile du composite
Some(profile_id) -> profil composite explicite
```
Le résultat `ResolvedConfigComposite` conserve :
```text
file_id du composite
path source
profile_id composite sélectionné
selection_source
components par component_id
```
Chaque `ResolvedCompositeComponent` conserve le `ResolvedConfigProfile` du document standard référencé.
Ainsi les informations déjà stabilisées en `pre.008` restent disponibles :
```text
globals
profile
effective
origin(key) = Global | Profile
```
## Provenance de sélection
`ConfigProfileSelectionSource` possède désormais :
```text
DefaultProfile
Explicit
Composite
```
Lorsqu'un composite déclare explicitement :
```json
{
"file_id": "cfg.std.logging",
"profile_id": "local_dev"
}
```
le `ResolvedConfigProfile` correspondant porte :
```text
selection_source = Composite
```
Si `profile_id` est absent, le document référencé conserve :
```text
selection_source = DefaultProfile
```
La provenance `Global/Profile` de chaque valeur top-level n'est pas remplacée par cette provenance de sélection.
## Validation sémantique
Un composite enregistré est soumis à :
1. JSON syntax ;
2. `schema.composite` ;
3. contrat générique `default_profile` / `profiles` ;
4. unicité des `component_id` par profil ;
5. validité et enregistrement des `file_id` référencés ;
6. restriction aux documents standards `cfg.std.*` ;
7. validation/résolution du profil référencé, y compris le `default_profile` lorsqu'aucun override n'est fourni.
Un document référencé invalide ou inconnu utilise le nouveau diagnostic :
```text
config.composite_reference_invalid
```
Un profil document ou composite demandé mais absent conserve :
```text
config.profile_not_found
```
## Tests
Les nouveaux tests couvrent notamment :
- enregistrement de `schema.composite` sans composite runtime fictif ;
- validation du schema/example composite versionné ;
- résolution du profil composite par défaut ;
- utilisation du `default_profile` du document référencé ;
- sélection explicite d'un profil composite ;
- provenance `Composite` lorsqu'un profil documentaire est imposé par le composite ;
- profil composite inconnu ;
- référence vers un `file_id` standard inconnu ;
- duplication de `component_id` dans un profil composite ;
- disponibilité des constantes/schema/provenance depuis le crate-root.
Les tests de résolution utilisent un descriptor composite privé à la crate pointant vers l'exemple versionné ; il ne fait pas partie de `ConfigFileRegistry::defaults()`.
## Hors scope
Cette tranche n'introduit pas encore :
- composite runtime d'une application réelle ;
- `.env` ;
- lecture de l'environnement du processus ;
- interpolation `${NAME}` / `${NAME:-fallback}` ;
- `Public/Internal/Secret` ;
- redaction ;
- adaptation vers `ksp_logging_lib::LoggingSettings` ;
- mutation/persistence.
## Dépendances
Aucune dépendance externe ou feature Cargo supplémentaire n'est ajoutée.
`ksp-config-lib` ne dépend toujours pas de `ksp-logging-lib` dans cette tranche.
## Version technique
La prerelease devient :
```text
workspace.package.version = "0.1.3-pre.9"
Cargo.toml header version = 50
```
## Fichiers ajoutés
```text
config/examples/composite.example.json
config/schemas/composite.schema.json
crates/ksp-config-lib/src/composite.rs
crates/ksp-config-lib/unit_tests/composite.rs
deltas/0.1.3/pre.009.md
```
## Fichiers modifiés
```text
Cargo.toml
crates/ksp-config-lib/src/document.rs
crates/ksp-config-lib/src/error.rs
crates/ksp-config-lib/src/lib.rs
crates/ksp-config-lib/src/profile.rs
crates/ksp-config-lib/src/registry.rs
crates/ksp-config-lib/tests/public_api.rs
crates/ksp-config-lib/unit_tests/registry.rs
docs/plans/000-README.md
docs/plans/002-FUNCTIONAL_RELEASE_SEQUENCE.md
docs/plans/005-V0_1_3_CONFIG_FOUNDATION_PLAN.md
docs/rules/FILE_CONTRACTS.md
```
## Fichiers supprimés
Aucun.
## Contrôles exécutés dans l'environnement de génération
- parsing JSON du schema et de l'exemple composite ;
- validation du schema Draft 2020-12 et de l'exemple avec l'implémentation JSON Schema disponible dans l'environnement ;
- comparaison du delta avec la base `pre.008` ;
- contrôle des headers/version modifiés ;
- contrôle de l'absence de nouvelle dépendance Cargo ;
- contrôle statique des interdictions `unsafe`, `unwrap`, `expect`, `panic`, opérateur `?` et accès environnement applicatif dans les nouveaux sources de production ;
- contrôle de la composition exacte de l'archive delta ;
- réapplication du delta sur une copie de `pre.008` pour vérifier la reproduction de l'arbre livré.
`cargo`, `rustc` et `rustfmt` ne sont pas disponibles dans l'environnement de génération. Aucune validation Rust n'est déclarée réussie ici.
## Validations utilisateur à exécuter
```bash
cargo fmt --all
cargo check --workspace
cargo clippy --workspace --all-targets
cargo test --workspace
cargo tree -p ksp-config-lib
cargo tree -p ksp-config-lib -d
cargo tree -p ksp-config-lib -e features
```
Après validation de `pre.009`, la prochaine tranche planifiée est :
```text
0.1.3-pre.010 — .env + process env + resolver ${...}
```

View File

@@ -0,0 +1,68 @@
<!-- file: deltas/0.1.3/pre.010-fix.001.md -->
<!-- version: 1 -->
# Delta 0.1.3-pre.010-fix.001
## Objectif
Corriger deux tests unitaires de `pre.010` qui dépendaient à tort du répertoire courant du binaire de test pour retrouver les fichiers versionnés du workspace.
Le resolver environnemental, le parser `.env`, `.env.example`, les contrats publics et les dépendances restent inchangés.
## Cause
Les tests :
```text
committed_logging_profile_resolves_environment_fallback_without_changing_source_profile
env_example_inventory_contains_current_runtime_variable_with_preceding_comment
```
utilisaient respectivement :
```text
config/
config/schemas/
.env.example
```
comme chemins relatifs au current working directory du processus de test. Cette hypothèse n'est pas un contrat Cargo fiable et différait déjà des tests document/profile existants, qui dérivent la racine du workspace depuis `CARGO_MANIFEST_DIR`.
## Modifications
- `workspace.package.version` passe de `0.1.3-pre.10` à `0.1.3-pre.10.fix.1` ;
- le header du `Cargo.toml` racine passe de `52` à `53` ;
- `crates/ksp-config-lib/unit_tests/environment.rs` passe de la version de fichier `1` à `2` ;
- les deux tests concernés utilisent maintenant une fonction locale `workspace_root()` basée sur :
```text
env!("CARGO_MANIFEST_DIR")/../..
```
- le test du profil Logging ouvre donc les répertoires `config/` et `config/schemas/` sous la racine du workspace ;
- le test d'inventaire ouvre `.env.example` via `DEFAULT_DOTENV_EXAMPLE_PATH` sous cette même racine ;
- aucun fichier runtime Config, schema, `.env.example`, contrat public, resolver ou dépendance n'est modifié.
## Validation utilisateur de pre.010
Avant ce fix :
```text
cargo fmt --all OK
cargo check --workspace OK
cargo clippy --workspace --all-targets OK
cargo test --workspace 51/53 tests Config OK, 2 tests échoués
```
Les deux échecs sont ceux listés ci-dessus. Les autres tests, dont la priorité `process > .env > fallback`, le parser `.env`, les placeholders et les composites, passent.
## Validation attendue
```bash
cargo fmt --all
cargo check --workspace
cargo clippy --workspace --all-targets
cargo test --workspace
```
Le résultat attendu est que les 53 tests unitaires de `ksp-config-lib` passent, ainsi que les 7 tests d'API publique et l'ensemble du workspace.

298
deltas/0.1.3/pre.010.md Normal file
View File

@@ -0,0 +1,298 @@
<!-- file: deltas/0.1.3/pre.010.md -->
<!-- version: 1 -->
# Delta 0.1.3-pre.010
## Base requise
Livraison précédente validée :
```text
0.1.3-pre.009-fix.001
```
Version technique de cette base :
```text
workspace.package.version = "0.1.3-pre.9.fix.1"
Cargo.toml header version = 51
```
Validations utilisateur exécutées le 2026-08-15 :
```text
cargo fmt --all OK
cargo check --workspace OK
cargo clippy --workspace --all-targets OK
cargo test --workspace OK
cargo tree -p ksp-config-lib OK
cargo tree -p ksp-config-lib -d OK, doublon transitif syn 2/3 déjà connu
cargo tree -p ksp-config-lib -e features OK
```
`cargo test --workspace` confirme notamment 39 tests unitaires + 7 tests publics pour `ksp-config-lib`.
## Objet de pre.010
Introduire la première résolution environnementale Config réellement consommable :
```text
process environment
>
./.env
>
placeholder/API fallback
->
ConfigEnvironmentValue
->
${NAME} / ${NAME:-fallback}
->
JSON effective values
```
La sensibilité et les représentations sûres restent volontairement réservées à `pre.011`.
## Propriété et sources
`ConfigEnvironment` est le snapshot possédé par `ksp-config-lib`.
`ConfigEnvironment::load()` :
1. capture les variables supportées héritées par le processus ;
2. lit `./.env` s'il existe ;
3. ne modifie jamais l'environnement du processus, du shell, de systemd ou du parent ;
4. considère l'absence de `.env` comme une source locale vide.
Une erreur de lecture réelle de `.env` reste distincte de son absence.
Les seuls namespaces applicatifs acceptés sont :
```text
KSP_*
KSPB_*
```
ce qui couvre naturellement :
```text
KSP_PUBLIC_*
KSP_SECRET_*
KSPB_PUBLIC_*
KSPB_SECRET_*
```
## Priorité et chaîne vide
`ConfigEnvironment::resolve_variable(name, fallback)` applique :
```text
process > .env > fallback > missing
```
Une chaîne vide explicitement présente dans le processus ou `.env` reste une valeur définie. Elle ne déclenche pas le fallback.
`ConfigEnvironmentValue` expose :
```text
variable_name
value réelle
source = Process | DotEnv | Fallback
```
Le type n'implémente volontairement pas `Debug` afin de ne pas créer une voie de fuite accidentelle avant l'introduction de la sensibilité/redaction en `pre.011`.
## Resolver `${...}`
Config supporte :
```text
${KSP_VAR}
${KSP_VAR:-fallback}
```
Règles :
- plusieurs placeholders peuvent apparaître dans une même string ;
- le fallback n'est utilisé que si la variable est absente ;
- le fallback est littéral dans cette tranche et n'est pas récursivement interprété ;
- les placeholders imbriqués/malformés sont refusés ;
- les clés JSON ne sont pas interpolées ; seules les valeurs string le sont ;
- objets et tableaux JSON sont parcourus récursivement.
APIs principales :
```text
ConfigEnvironment::resolve_variable(...)
ConfigEnvironment::resolve_text(...)
ConfigEnvironment::resolve_json(...)
ConfigEnvironment::resolve_map(...)
ResolvedConfigProfile::resolve_effective_environment(...)
```
La résolution d'environnement d'un profil produit une nouvelle map effective et ne modifie pas la source, le `profile_id`, la provenance `Global/Profile` ni la sélection du profil déjà résolu.
## Variable manquante
Une référence sans valeur process, sans valeur `.env` et sans fallback retourne :
```text
config.environment_variable_missing
```
Config émet également un warning via la façade KSP :
```text
target = ksp-config-lib
domain = config.environment
```
Le warning et l'erreur contiennent le nom de la variable mais jamais sa valeur.
`ksp-config-lib` dépend donc maintenant réellement de `ksp-logging-lib`. La dépendance inverse Logging -> Config reste interdite.
## `.env`
Le parser Config de cette tranche accepte un sous-ensemble déterministe adapté au fichier KSP géré :
- lignes vides et commentaires `#` ;
- préfixe optionnel `export ` ;
- `NAME=value` ;
- valeur non quotée ;
- valeur entre quotes simples ;
- valeur entre quotes doubles avec escapes bornés `\\`, `\"`, `\n`, `\r`, `\t` ;
- commentaire inline d'une valeur non quotée lorsqu'il commence par ` #` ;
- chaîne vide ;
- clés étrangères valides ignorées par Config ;
- doublon d'une clé KSP/KSPB refusé pour éviter une résolution locale ambiguë.
Aucune interpolation interne du fichier `.env` n'est ajoutée dans cette tranche.
## `.env.example` — nouvelle règle durable
Le dépôt possède désormais à la racine :
```text
.env.example
```
Ce fichier est versionné et sert d'inventaire canonique des variables runtime KSP/KSPB utilisées par les fichiers Config ou le code opérationnel.
Règles enregistrées :
- toute nouvelle variable runtime KSP/KSPB est ajoutée à `.env.example` dans le même delta que sa première utilisation ;
- chaque variable est précédée d'un commentaire expliquant son usage/utilité ;
- une entrée peut être active avec une valeur par défaut sûre, porter une valeur générique non secrète ou être commentée ;
- aucun vrai secret n'y est stocké ;
- le fichier local `.env` reste non versionné et non échangé ;
- l'objectif est de pouvoir créer `.env` depuis `.env.example` et repérer localement les nouvelles clés par diff.
La première entrée est :
```text
KSP_LOGS_DIRECTORY=logs
```
car `config/std.logging.json` est actuellement le seul fichier runtime utilisant une variable d'environnement.
`.gitignore` possédait déjà :
```text
.env
.env.*
!.env.example
```
et n'a donc pas besoin d'être modifié.
## Tests
Les nouveaux tests couvrent notamment :
- priorité process > `.env` > fallback ;
- chaîne vide process et `.env` considérée comme définie ;
- variable manquante sans fallback ;
- namespaces KSP/KSPB ;
- résolution de plusieurs placeholders ;
- placeholder malformé/imbriqué ;
- résolution récursive object/array JSON ;
- parsing `.env` avec commentaires, `export` et quotes ;
- refus des doublons KSP dans `.env` ;
- collecte process synthétique sans mutation du vrai environnement ;
- résolution du fallback `${KSP_LOGS_DIRECTORY:-logs}` du document Logging commité ;
- présence commentée de `KSP_LOGS_DIRECTORY` dans `.env.example` ;
- disponibilité de la surface environment depuis le crate-root.
Les tests n'appellent pas `std::env::set_var` / `remove_var`; aucune mutation unsafe de l'environnement n'est nécessaire en Rust 2024.
## Dépendances
Aucune nouvelle dépendance externe n'est ajoutée.
Dépendance KSP désormais utilisée :
```text
ksp-config-lib -> ksp-logging-lib
```
pour les warnings/diagnostics runtime Config.
Les dépendances externes `serde`, `serde_json` et `jsonschema` restent inchangées.
## Version technique
La prerelease devient :
```text
workspace.package.version = "0.1.3-pre.10"
Cargo.toml header version = 52
```
## Fichiers ajoutés
```text
.env.example
crates/ksp-config-lib/src/environment.rs
crates/ksp-config-lib/unit_tests/environment.rs
deltas/0.1.3/pre.010.md
```
## Fichiers modifiés
```text
Cargo.toml
crates/ksp-config-lib/Cargo.toml
crates/ksp-config-lib/src/error.rs
crates/ksp-config-lib/src/lib.rs
crates/ksp-config-lib/src/profile.rs
crates/ksp-config-lib/tests/public_api.rs
docs/architecture/005-DEPENDENCY_GRAPH.md
docs/plans/000-README.md
docs/plans/002-FUNCTIONAL_RELEASE_SEQUENCE.md
docs/plans/005-V0_1_3_CONFIG_FOUNDATION_PLAN.md
docs/rules/FILE_CONTRACTS.md
docs/rules/RULES_KSP.md
```
## Fichiers supprimés
Aucun.
## Contrôles exécutés dans l'environnement de génération
- parsing TOML du manifest racine et de `ksp-config-lib` ;
- audit statique des accès `std::env::*` : seul `crates/ksp-config-lib/src/environment.rs` lit l'environnement applicatif ;
- audit statique des variables runtime exactes dans `crates/*/src` + `config/` : `KSP_LOGS_DIRECTORY` est la seule clé actuelle et elle est présente dans `.env.example` ;
- absence de `unsafe`, `unwrap`, `expect`, `panic!` et `?` dans le nouveau code de production Config ;
- contrôle des headers/version et des lignes Rust ajoutées/modifiées ;
- comparaison du delta avec la base `pre.009-fix.001` ;
- aucun `Cargo.lock` ajouté.
Le toolchain Rust n'est pas disponible dans l'environnement de génération. `cargo fmt/check/clippy/test` doivent donc être exécutés par l'utilisateur.
## Étape suivante
Après validation utilisateur :
```text
0.1.3-pre.011 — sensibilité Public/Internal/Secret + real/safe/provenance + redaction par segment
```

View File

@@ -0,0 +1,42 @@
<!-- file: deltas/0.1.3/pre.011-fix.001.md -->
<!-- version: 1 -->
# 0.1.3-pre.011-fix.001 — Clippy test closure fix
## Objectif
Corriger l'unique erreur Clippy observée après validation de `0.1.3-pre.011`, sans modifier le comportement fonctionnel de `ksp-config-lib`.
## Correction
Le test `detailed_profile_environment_keeps_global_origin_and_adds_environment_provenance` utilisait une closure avec retour implicite :
```rust
.and_then(|items| items.last())
```
Le workspace impose `clippy::implicit_return = "deny"`. La closure utilise désormais un `return` explicite :
```rust
.and_then(|items| return items.last())
```
## Impact
- aucun changement du resolver d'environnement ;
- aucun changement de la classification `Public` / `Internal` / `Secret` ;
- aucun changement de redaction, provenance ou représentation sûre ;
- aucune nouvelle dépendance ;
- aucun changement de fichier Config, schema ou `.env.example` ;
- signal Cargo mis à jour vers `0.1.3-pre.11.fix.1`.
## Validation attendue
```bash
cargo fmt --all
cargo check --workspace
cargo clippy --workspace --all-targets
cargo test --workspace
```
`0.1.3-pre.011` avait déjà passé les 61 tests unitaires Config et les 9 tests d'API publique ; ce fix vise uniquement la conformité Clippy.

329
deltas/0.1.3/pre.011.md Normal file
View File

@@ -0,0 +1,329 @@
<!-- file: deltas/0.1.3/pre.011.md -->
<!-- version: 1 -->
# Delta 0.1.3-pre.011
## Base requise
Livraison précédente validée :
```text
0.1.3-pre.010-fix.001
```
Version technique de cette base :
```text
workspace.package.version = "0.1.3-pre.10.fix.1"
Cargo.toml header version = 53
```
Validations utilisateur exécutées le 2026-08-15 :
```text
cargo fmt --all OK
cargo check --workspace OK
cargo clippy --workspace --all-targets OK
cargo test --workspace OK
cargo tree -p ksp-config-lib OK
cargo tree -p ksp-config-lib -d OK, doublon transitif syn 2/3 déjà connu
cargo tree -p ksp-config-lib -e features OK
```
`cargo test --workspace` confirme notamment 53 tests unitaires + 8 tests publics pour `ksp-config-lib`.
## Objet de pre.011
Ajouter la couche de sécurité/provenance qui manquait au resolver environnemental :
```text
Config source string / JSON
+
process > .env > fallback
->
real runtime value
safe diagnostic value
Public | Internal | Secret
ordered / JSON-Pointer provenance
```
Cette tranche ne modifie pas encore les documents Config, ne construit pas `LoggingSettings` et ne persiste rien.
## Classification de sensibilité
`ConfigSensitivity` expose :
```text
Public
Internal
Secret
```
Classification nominale :
```text
KSP_SECRET_* / KSPB_SECRET_* -> Secret
KSP_PUBLIC_* / KSPB_PUBLIC_* -> Public
autre KSP_* / KSPB_* -> Internal
```
Ordre :
```text
Secret > Internal > Public
```
La sensibilité d'une chaîne contenant des placeholders est la sensibilité la plus forte des placeholders effectivement référencés. Une chaîne littérale sans placeholder est `Internal`.
Un fallback hérite toujours de la sensibilité du nom de variable référencé. Ainsi :
```text
${KSP_SECRET_TOKEN:-false-secret}
```
reste `Secret` même si la valeur réelle vient du fallback.
## Valeur réelle et représentation sûre
`ConfigEnvironmentValue` conserve désormais :
```text
variable_name
value réel runtime
safe_value diagnostic sûr
sensitivity
source Process | DotEnv | Fallback
provenance
```
Pour `Secret` :
```text
value = valeur réelle
safe_value = ********
```
Pour `Public` et `Internal`, la représentation sûre conserve la valeur réelle dans cette tranche.
`ConfigEnvironmentValue` possède un `Debug` manuel qui n'affiche jamais `value`; il affiche `safe_value`, sensibilité, source et nom de variable.
## Chaînes composées
Nouveau contrat :
```text
ResolvedConfigText
```
Il conserve :
```text
value
safe_value
sensitivity
provenance[]
```
Exemple :
```text
source:
https://${KSP_PUBLIC_HOST}/?token=${KSP_SECRET_TOKEN}
real:
https://rpc.example.test/?token=secret-canary
safe:
https://rpc.example.test/?token=********
```
La redaction est réalisée par segment de substitution. Les fragments littéraux et non secrets restent visibles dans la représentation sûre.
`ConfigEnvironment::resolve_text_detailed()` expose ce contrat.
L'API existante :
```text
resolve_text(...)
```
reste compatible et retourne uniquement la valeur réelle.
## Provenance
`ConfigValueProvenance` distingue :
```text
DocumentLiteral
EnvironmentProcess { variable_name }
EnvironmentDotEnv { variable_name }
EnvironmentFallback { variable_name }
```
La provenance n'embarque jamais la valeur d'environnement elle-même.
Une chaîne composée conserve l'ordre des segments/références ayant participé à sa construction.
## JSON détaillé
Nouveau contrat :
```text
ResolvedConfigJson
```
Il conserve :
```text
value arbre JSON réel
safe_value arbre JSON redacted
sensitivity plus forte sensibilité contenue
provenance map JSON Pointer -> provenance[]
```
`ConfigEnvironment::resolve_json_detailed()` parcourt récursivement objets et tableaux sans modifier les clés.
Les JSON Pointer suivent RFC 6901 pour les clés contenant `~` ou `/`.
L'API existante :
```text
resolve_json(...)
resolve_map(...)
```
reste compatible et retourne uniquement la valeur réelle.
## Profil effectif
`ResolvedConfigProfile` ajoute :
```text
resolve_effective_environment_detailed(...)
```
La provenance top-level déjà existante :
```text
Global
Profile
```
reste attachée au profil source via `origin(key)`.
La résolution détaillée ajoute séparément la provenance de la couche environnement :
```text
DocumentLiteral
Process
DotEnv
Fallback
```
L'ancienne méthode `resolve_effective_environment()` reste disponible et retourne seulement la map réelle.
## Non-divulgation
Les tests canary vérifient notamment :
- secret venant du process : réel disponible, safe redacted, `Debug` sans canary ;
- secret venant d'un fallback : fallback réel disponible mais safe redacted ;
- URL composée public + secret : seul le segment secret est masqué ;
- JSON imbriqué : arbre réel complet, arbre safe redacted, `Debug` sans canary ;
- provenance de la variable sans transport de la valeur elle-même.
`ksp-logging-lib` ne reçoit aucune logique générique de redaction de messages arbitraires. La valeur sûre est construite par Config avant qu'un diagnostic ne l'utilise.
## Clarification `KSP_LOGS_DIRECTORY`
La décision préparatoire de l'adapter `pre.012` est enregistrée dans le plan :
- après interpolation, `logs_directory` pourra être absolu ou relatif ;
- un chemin relatif sera interprété relativement au current working directory du processus qui initialise Logging, pas automatiquement au répertoire du binaire ;
- le fallback `logs` s'applique seulement si `KSP_LOGS_DIRECTORY` est absent ;
- une valeur explicitement présente mais invalide ne déclenche pas le fallback et devra produire un diagnostic de configuration effective invalide ;
- les paths de sinks restent relatifs sous `logs_directory` sans traversal.
Aucun code de validation/conversion Logging correspondant n'est ajouté dans `pre.011`; cette responsabilité appartient à `pre.012`.
## `.env.example`
Aucune nouvelle variable runtime n'est introduite.
`.env.example` reste donc inchangé et contient toujours la seule variable actuellement utilisée :
```text
KSP_LOGS_DIRECTORY=logs
```
## Dépendances
Aucune dépendance ou feature Cargo n'est ajoutée/modifiée.
La direction reste :
```text
ksp-config-lib -> ksp-logging-lib -> ksp-core-lib
ksp-config-lib -> ksp-core-lib
```
Logging ne dépend toujours pas de Config.
## Version technique
La prerelease devient :
```text
workspace.package.version = "0.1.3-pre.11"
Cargo.toml header version = 54
```
## Fichiers ajoutés
```text
crates/ksp-config-lib/src/sensitivity.rs
crates/ksp-config-lib/unit_tests/sensitivity.rs
deltas/0.1.3/pre.011.md
```
## Fichiers modifiés
```text
Cargo.toml
crates/ksp-config-lib/src/environment.rs
crates/ksp-config-lib/src/lib.rs
crates/ksp-config-lib/src/profile.rs
crates/ksp-config-lib/tests/public_api.rs
crates/ksp-config-lib/unit_tests/environment.rs
docs/plans/000-README.md
docs/plans/002-FUNCTIONAL_RELEASE_SEQUENCE.md
docs/plans/005-V0_1_3_CONFIG_FOUNDATION_PLAN.md
docs/rules/RULES_KSP.md
```
## Fichiers supprimés
Aucun.
## Contrôles exécutés dans l'environnement de génération
- comparaison statique avec la base validée `pre.010-fix.001` ;
- absence de nouvelle dépendance/feature Cargo ;
- absence de nouvelle variable runtime nécessitant une modification de `.env.example` ;
- audit statique du nouveau code Config contre `unsafe`, `unwrap`, `expect`, `panic!` et opérateur `?` de production ;
- contrôle des headers/version ;
- contrôle des lignes Rust à 160 colonnes maximum ;
- contrôle canary dans les tests : les représentations safe/Debug attendues ne contiennent pas le secret ;
- reproduction du delta sur la base précédente avant packaging.
Le toolchain Rust n'est pas disponible dans l'environnement de génération. `cargo fmt/check/clippy/test` doivent donc être exécutés par l'utilisateur.
## Étape suivante
Après validation utilisateur :
```text
0.1.3-pre.012 — adapter Config -> Logging
```
Cette tranche devra notamment valider/résoudre `logs_directory`, convertir le profil Logging effectif vers les contrats publics `ksp_logging_lib::*` et démontrer `initialize/reinitialize` sans créer de dépendance Logging -> Config.

315
deltas/0.1.3/pre.012.md Normal file
View File

@@ -0,0 +1,315 @@
<!-- file: deltas/0.1.3/pre.012.md -->
<!-- version: 1 -->
# Delta 0.1.3-pre.012
## Base requise
Livraison précédente validée :
```text
0.1.3-pre.011-fix.001
```
Version technique de cette base :
```text
workspace.package.version = "0.1.3-pre.11.fix.1"
Cargo.toml header version = 55
```
Validations utilisateur exécutées le 2026-08-16 :
```text
cargo fmt --all OK
cargo check --workspace OK
cargo clippy --workspace --all-targets OK
cargo test --workspace OK
cargo tree -p ksp-config-lib OK
cargo tree -p ksp-config-lib -d OK, doublon transitif syn 2/3 déjà connu
cargo tree -p ksp-logging-lib -d OK, aucun doublon
```
`cargo test --workspace` confirme notamment 61 tests unitaires + 9 tests publics pour `ksp-config-lib`.
## Objet de pre.012
Construire la première frontière runtime complète possédée par Config :
```text
cfg.std.logging
-> JSON + schema + invariants source
-> profil sélectionné
-> process > .env > fallback
-> real/safe/sensitivity/provenance
-> validation effective
-> ksp_logging_lib::LoggingSettings
```
Cette tranche ne modifie pas encore les documents Config et ne persiste rien.
## `ResolvedLoggingConfig`
Nouveau contrat public :
```text
ResolvedLoggingConfig
```
Il conserve :
```text
file_id
source_path
profile_id
selection_source
effective ResolvedConfigJson
logs_directory résolu
LoggingSettings
```
Son `Debug` manuel n'affiche pas directement `LoggingSettings` ni le root réel. Il s'appuie sur `ResolvedConfigJson::Debug`, donc sur l'arbre sûr/redacted.
Les consumers légitimes peuvent obtenir :
```text
settings()
into_settings()
logs_directory()
effective()
```
Le `LoggingGuard` n'est jamais stocké par Config : il reste détenu par l'orchestration qui appelle `ksp_logging_lib::initialize/reinitialize`.
## Entrée de l'adapter
`ConfigDocumentEngine` ajoute :
```text
load_resolved_logging_config(requested_profile, environment)
```
La méthode :
1. charge `cfg.std.logging` ;
2. applique le profil par défaut ou le profil explicite ;
3. résout les placeholders avec le snapshot `ConfigEnvironment` ;
4. conserve la vue réelle/sûre et la provenance ;
5. valide la configuration effective ;
6. mappe les valeurs vers les contrats publics `ksp_logging_lib::*` ;
7. exécute enfin `LoggingSettings::validate()`.
## Mapping Logging
Le mapping couvre explicitement :
```text
LogFilterLevel
SpanEvents
ConsoleOutput
LogFormat
FileRotation
OutputFilter
TargetFilter
ConsoleSettings
FileSettings[]
LoggingSettings
```
Le document commité `local_dev` doit donc produire les deux sinks fichier déjà déclarés et leurs filtres `level/target/domain`, ainsi que les deux overrides globaux de target.
## `logs_directory`
Après interpolation :
- un chemin absolu est conservé ;
- un chemin relatif est ancré sur le current working directory du processus au moment de l'adaptation ;
- un root inexistant est accepté : Logging créera les répertoires requis lors de l'initialisation des appenders ;
- un root existant qui n'est pas un directory est rejeté ;
- une erreur filesystem autre que `NotFound` pendant l'inspection est rejetée.
Le fallback `${KSP_LOGS_DIRECTORY:-logs}` s'applique uniquement si `KSP_LOGS_DIRECTORY` est absent.
Une valeur explicitement présente mais vide :
```text
KSP_LOGS_DIRECTORY=
```
reste une valeur présente et produit :
```text
config.effective_config_invalid
```
Elle ne retombe jamais silencieusement sur `logs`.
## Chemins des sinks
Les `files[].path` restent relatifs sous `logs_directory`.
L'invariant est désormais contrôlé deux fois :
1. sur le document source ;
2. après interpolation environnementale.
La seconde validation empêche par exemple une valeur environnementale de transformer dynamiquement un path relatif en :
```text
../outside.log
/var/log/outside.log
```
L'adapter sépare ensuite le path relatif en :
```text
directory relatif
file-name prefix
```
puis construit `FileSettings` sous le root Logging résolu.
## Frontière secrets
Le document standard Logging n'a aucun besoin fonctionnel de secret.
L'adapter refuse donc toute configuration effective dont `ResolvedConfigJson::sensitivity()` est `Secret`.
Cette règle évite de transmettre une vraie valeur secrète à `LoggingSettings`, puis potentiellement à des diagnostics filesystem de `ksp-logging-lib`.
Les erreurs finales de `LoggingSettings::validate()` sont encapsulées dans `config.effective_config_invalid` avec seulement :
```text
logging_error_domain
logging_error_code
```
Les contextes internes de l'erreur Logging ne sont pas recopiés par Config.
## Démonstration runtime
Un test Config utilise le document commité, un root temporaire et le vrai runtime Logging afin de démontrer :
```text
Config -> LoggingSettings -> initialize -> reinitialize
```
Le test vérifie aussi que l'initialisation des appenders crée les sous-répertoires configurés lorsqu'ils n'existent pas, puis recharge une configuration sans outputs avant cleanup.
## Diagnostics et erreurs
Nouveau code stable :
```text
config.effective_config_invalid
```
Il distingue une source JSON/schema valide d'une configuration devenue invalide après environnement/adaptation runtime.
Les diagnostics Config de paths utilisent une représentation sûre lorsque la valeur pourrait provenir d'un resolver détaillé.
## Règles documentaires
Deux règles KSP deviennent durables :
```text
KSP-CONFIG-012
KSP-CONFIG-013
```
Elles fixent respectivement :
- la sémantique absolu/relatif/CWD/fallback de `logs_directory` ;
- l'interdiction de secrets dans le standard Logging effectif.
`FILE_CONTRACTS.md` enregistre également la revalidation post-interpolation de `files[].path`.
## `.env.example`
Aucune nouvelle variable runtime n'est introduite.
`.env.example` reste inchangé :
```text
KSP_LOGS_DIRECTORY=logs
```
## Dépendances
Aucune nouvelle dépendance Cargo.
La direction reste :
```text
ksp-config-lib -> ksp-logging-lib
ksp-logging-lib -X-> ksp-config-lib
```
## Tests ajoutés
Le nouveau module `unit_tests/logging.rs` couvre notamment :
- mapping complet du profil `local_dev` ;
- root relatif ancré au CWD ;
- root absolu conservé ;
- valeur explicite vide rejetée sans fallback ;
- root existant non-directory rejeté ;
- paths fichier effectifs sans escape ;
- frontière Secret de Logging et canary sans fuite ;
- `Debug` de `ResolvedLoggingConfig` ;
- `initialize/reinitialize` réel et création des répertoires de sinks.
La surface publique ajoute un test d'adressabilité de `ResolvedLoggingConfig`, de la méthode adapter et du nouveau code d'erreur.
Après ajout, la crate contient 70 tests unitaires Config et 10 tests publics à exécuter chez l'utilisateur.
## Fichiers ajoutés
```text
crates/ksp-config-lib/src/logging.rs
crates/ksp-config-lib/unit_tests/logging.rs
deltas/0.1.3/pre.012.md
```
## Fichiers modifiés
```text
Cargo.toml
crates/ksp-config-lib/src/environment.rs
crates/ksp-config-lib/src/error.rs
crates/ksp-config-lib/src/lib.rs
crates/ksp-config-lib/tests/public_api.rs
docs/rules/RULES_KSP.md
docs/rules/FILE_CONTRACTS.md
docs/plans/000-README.md
docs/plans/002-FUNCTIONAL_RELEASE_SEQUENCE.md
docs/plans/005-V0_1_3_CONFIG_FOUNDATION_PLAN.md
```
## Hors scope
Toujours hors `pre.012` :
- mutation/persistence JSON ;
- create/update/remove `.env` ;
- reveal secret de management ;
- application desktop Config ;
- watcher/reload automatique de Config ;
- autres documents standard Store/Wallet/Transport.
Ces sujets commencent avec `pre.013` ou les releases prévues ultérieurement.
## Validation demandée
```bash
cargo fmt --all
cargo check --workspace
cargo clippy --workspace --all-targets
cargo test --workspace
cargo tree -p ksp-config-lib
cargo tree -p ksp-config-lib -d
cargo tree -p ksp-config-lib -e features
cargo tree -p ksp-logging-lib -d
```
Aucune validation Cargo locale n'est revendiquée dans l'environnement de génération de ce delta.

View File

@@ -0,0 +1,59 @@
<!-- file: deltas/0.1.3/pre.013-fix.001.md -->
<!-- version: 1 -->
# Delta 0.1.3-pre.013-fix.001
## Base requise
Cette correction s'applique après `0.1.3-pre.013`.
La validation utilisateur de `pre.013` a détecté une erreur de compilation dans `crates/ksp-config-lib/src/persistence.rs` :
```text
error: expected `,`, found `:`
...
ksp_logging_lib::warn!(target: "ksp-config-lib", domain: "config.persistence", ...)
```
## Correction
L'appel de la façade `ksp-logging-lib` utilise désormais la syntaxe structurée supportée par les macros KSP/tracing :
```rust
ksp_logging_lib::warn!(
target: "ksp-config-lib",
domain = "config.persistence",
path = %path.to_string_lossy(),
error = %error,
"unable to cleanup temporary Config file"
);
```
Le champ `domain` est un champ structuré de l'événement et utilise donc `=`. La forme `domain:` introduite dans `pre.013` était invalide.
## Périmètre
Aucun comportement de management/persistence n'est modifié. Aucun changement de dépendance, schema, Config, `.env` ou `.env.example`.
La version technique devient :
```text
workspace.package.version = "0.1.3-pre.13.fix.1"
```
## Fichiers modifiés
```text
Cargo.toml
crates/ksp-config-lib/src/persistence.rs
deltas/0.1.3/pre.013-fix.001.md
```
## Validation utilisateur demandée
```bash
cargo fmt --all
cargo check --workspace
cargo clippy --workspace --all-targets
cargo test --workspace
```

348
deltas/0.1.3/pre.013.md Normal file
View File

@@ -0,0 +1,348 @@
<!-- file: deltas/0.1.3/pre.013.md -->
<!-- version: 1 -->
# Delta 0.1.3-pre.013
## Base requise
Livraison précédente validée :
```text
0.1.3-pre.012
```
Version technique de cette base :
```text
workspace.package.version = "0.1.3-pre.12"
Cargo.toml header version = 56
```
Validations utilisateur exécutées le 2026-08-16 :
```text
cargo fmt --all OK
cargo check --workspace OK
cargo clippy --workspace --all-targets OK
cargo test --workspace OK
cargo tree -p ksp-config-lib OK
cargo tree -p ksp-config-lib -d OK, doublon transitif syn 2/3 déjà connu
cargo tree -p ksp-config-lib -e features OK
cargo tree -p ksp-logging-lib -d OK, aucun doublon
```
`cargo test --workspace` confirme notamment 70 tests unitaires + 10 tests publics pour `ksp-config-lib` et 34 tests unitaires Logging avec toutes ses intégrations vertes.
## Objet de pre.013
Fermer la première surface de management/persistence Config sans introduire Tauri ni permettre aux applications de contourner `ksp-config-lib` :
```text
registered file_id
-> management source read
-> typed std.logging mutation
-> candidate validation
-> atomic persistence
process env (read-only) + .env (read/write)
-> desired/effective/shadow report
-> explicit reveal when UI management intentionally needs the real value
```
## `ConfigManagement`
Nouveau contrat public :
```text
ConfigManagement
```
Il possède un `ConfigDocumentEngine` et utilise par défaut le `.env` conventionnel :
```text
./.env
```
Il n'ajoute aucun path runtime Config supplémentaire et ne modifie jamais l'environnement hérité du processus.
L'authentification/autorisation de l'utilisateur final reste une responsabilité de la future application `ksp-app-config-desk`. Config fournit une frontière explicite qui évite l'exposition accidentelle mais ne prétend pas être un système d'authentification intra-processus.
## Lecture management
`ConfigManagement::read_source(file_id)` lit le texte brut d'un document **Config enregistré** par son `file_id`.
Cette lecture :
- ne prend jamais un path arbitraire fourni par le caller ;
- refuse un `file_id` de kind Schema ;
- reste possible lorsque le document est syntaxiquement/schema/sémantiquement invalide, afin qu'une UI de management puisse afficher et corriger sa source ;
- retourne `ConfigManagedSource`, dont `Debug` n'expose pas le contenu brut.
Aucune primitive publique générique « save raw JSON to path » n'est ajoutée.
## Mutation typée de `std.logging.json`
Nouveaux contrats source publics :
```text
LoggingConfigDocument
LoggingProfileConfig
LoggingConsoleConfig
LoggingFileConfig
LoggingOutputFilterConfig
LoggingTargetFilterConfig
```
Ils représentent la forme source de `cfg.std.logging` et permettent une mutation structurée en mémoire.
La persistance suit obligatoirement :
```text
LoggingConfigDocument
-> serde_json candidate
-> registered std.logging schema
-> KSP profile/logging semantic invariants
-> pretty JSON + newline final
-> atomic commit
```
Un candidate invalide retourne l'erreur de validation existante et ne touche pas au fichier destination.
`ConfigDocumentEngine` reçoit uniquement un helper interne `validate_candidate(...)`; la validation reste possédée par Config et n'est pas dupliquée dans la couche management.
## Persistence atomique
Nouveau module privé :
```text
src/persistence.rs
```
Stratégie :
1. créer un fichier temporaire avec `create_new` dans le même répertoire que la destination ;
2. appliquer les permissions requises ;
3. écrire tout le contenu ;
4. `sync_all` le fichier temporaire ;
5. fermer le handle ;
6. remplacer la destination par `rename` ;
7. nettoyer le temporaire si une étape pré-commit échoue.
La destination reste donc l'ancien fichier complet jusqu'au commit final.
Les permissions du fichier existant sont conservées. Sur Unix, un nouveau `.env` créé par Config reçoit explicitement :
```text
0600
```
Les documents JSON ne reçoivent pas artificiellement ce mode privé ; un fichier existant conserve son mode.
Nouveau code d'erreur :
```text
config.persistence_write_failed
```
## Management du `.env`
Nouvelles opérations :
```text
set_dotenv_value(name, value)
remove_dotenv_value(name)
reveal_dotenv_value(name)
reveal_effective_environment_value(name)
environment_report()
```
Mutation autorisée uniquement pour les namespaces déjà possédés par Config :
```text
KSP_*
KSPB_*
```
Une mutation ne touche jamais `std::env::set_var/remove_var` et ne prétend donc jamais modifier le shell parent, systemd, Docker/Kubernetes ou l'environnement déjà hérité du processus.
Avant mutation, le `.env` existant doit être interprétable par la grammaire Config actuelle. Un `.env` invalide est refusé sans altération.
L'éditeur ciblé :
- conserve les lignes/commentaires non concernés ;
- remplace uniquement l'assignment ciblé ;
- encode les valeurs nécessitant espaces, `#`, quotes, backslash ou contrôles avec la forme double-quoted déjà comprise par Config ;
- normalise un fichier modifié avec newline final ;
- ne réécrit pas un assignment si la valeur persistée est déjà identique ;
- ne crée pas le fichier lors d'un remove d'une clé absente.
## Desired / effective / shadow
`ConfigEnvironmentReport` n'embarque aucune valeur réelle secrète.
Il expose :
```text
variable_name
sensitivity
desired_safe_value # valeur .env persistée
effective_safe_value # process sinon .env
effective_source
shadowed_by_process_environment
```
Une entrée `.env` est `desired`; la valeur héritée du process reste prioritaire et peut donc la shadow.
`ConfigEnvironmentChangeReport` expose après create/update/remove :
```text
source_changed
effective_changed
shadowed_by_process_environment
reload_required
```
Si le process shadow une modification `.env`, `source_changed = true` mais `effective_changed = false` et `reload_required = false` pour le processus courant.
## Reveal explicite et secrets
Les rapports management ordinaires utilisent la représentation sûre :
```text
KSP_SECRET_* / KSPB_SECRET_* -> ********
```
La valeur réelle n'est accessible qu'en appelant explicitement :
```text
reveal_dotenv_value(...)
reveal_effective_environment_value(...)
```
Ces méthodes sont destinées à une surface UI management légitimement autorisée. Elles ne changent pas les règles suivantes :
- jamais de secret réel dans `Debug` ;
- jamais de secret réel dans les logs ;
- jamais de secret réel dans les diagnostics génériques ;
- l'application reste propriétaire de l'autorisation utilisateur.
## Règles durables
Ajout de :
```text
KSP-CONFIG-014
KSP-CONFIG-015
KSP-CONFIG-016
```
pour figer respectivement :
- la persistence validée/atomique bornée aux ressources Config connues ;
- la distinction process read-only / `.env` read-write et le reporting shadow ;
- la séparation report sûr / reveal explicite des valeurs sensibles.
`FILE_CONTRACTS.md` documente également la stratégie de persistence, le newline JSON, la preservation des permissions et le mode `0600` d'un nouveau `.env` sur Unix.
## `.env.example`
Aucune nouvelle variable runtime n'est introduite par cette tranche.
`.env.example` reste donc inchangé :
```text
KSP_LOGS_DIRECTORY=logs
```
Les variables utilisées uniquement comme canaries/tests ne font pas partie de l'inventaire runtime.
## Dépendances
Aucune nouvelle dépendance Cargo.
La direction reste :
```text
ksp-config-lib -> ksp-core-lib
ksp-config-lib -> ksp-logging-lib
ksp-logging-lib -X-> ksp-config-lib
```
## Tests ajoutés
Le nouveau module `unit_tests/management.rs` ajoute 10 tests couvrant notamment :
- lecture raw d'un source schema-invalide ;
- mutation typée + persistance + reload de `std.logging.json` ;
- candidate Logging invalide sans altération du fichier ;
- save identique sans reload ;
- create/update/remove `.env` ;
- quoting et preservation de commentaires/lignes externes ;
- `.env` invalide non modifié ;
- namespace externe refusé sans création de `.env` ;
- report secret redacted + reveal explicite du canary ;
- process shadowing desired `.env` sans faux changement effectif ;
- mode `0600` du nouveau `.env` sur Unix.
La surface publique ajoute un test d'adressabilité des contrats management.
Après ajout, `ksp-config-lib` doit compter :
```text
80 tests unitaires
11 tests publics
```
à exécuter chez l'utilisateur.
## Fichiers ajoutés
```text
crates/ksp-config-lib/src/management.rs
crates/ksp-config-lib/src/persistence.rs
crates/ksp-config-lib/unit_tests/management.rs
deltas/0.1.3/pre.013.md
```
## Fichiers modifiés
```text
Cargo.toml
crates/ksp-config-lib/src/document.rs
crates/ksp-config-lib/src/environment.rs
crates/ksp-config-lib/src/error.rs
crates/ksp-config-lib/src/lib.rs
crates/ksp-config-lib/tests/public_api.rs
docs/rules/RULES_KSP.md
docs/rules/FILE_CONTRACTS.md
docs/plans/000-README.md
docs/plans/002-FUNCTIONAL_RELEASE_SEQUENCE.md
docs/plans/005-V0_1_3_CONFIG_FOUNDATION_PLAN.md
```
## Hors scope
Toujours hors `pre.013` :
- application desktop Config/Tauri ;
- watcher automatique de fichiers ;
- rechargement automatique du runtime après save ;
- mutation générique de documents arbitraires ;
- management d'un environnement parent/systemd/container ;
- autres documents standard Store/Wallet/Transport ;
- audit global empêchant tous les futurs contournements Config hors crate, prévu en `pre.014`.
## Validation demandée
```bash
cargo fmt --all
cargo check --workspace
cargo clippy --workspace --all-targets
cargo test --workspace
cargo tree -p ksp-config-lib
cargo tree -p ksp-config-lib -d
cargo tree -p ksp-config-lib -e features
cargo tree -p ksp-logging-lib -d
```
Aucune validation Cargo locale n'est revendiquée dans l'environnement de génération de ce delta.

245
deltas/0.1.3/pre.014.md Normal file
View File

@@ -0,0 +1,245 @@
<!-- file: deltas/0.1.3/pre.014.md -->
<!-- version: 1 -->
# Delta 0.1.3-pre.014
## Base requise
Livraison précédente validée :
```text
0.1.3-pre.013-fix.001
```
Version technique de cette base :
```text
workspace.package.version = "0.1.3-pre.13.fix.1"
Cargo.toml header version = 58
```
Validations utilisateur exécutées le 2026-08-16 :
```text
cargo fmt --all OK
cargo check --workspace OK
cargo clippy --workspace --all-targets OK
cargo test --workspace OK
cargo tree -p ksp-config-lib OK
cargo tree -p ksp-config-lib -d OK, doublon transitif syn 2/3 déjà connu
cargo tree -p ksp-config-lib -e features OK
cargo tree -p ksp-logging-lib OK
```
`cargo test --workspace` confirme notamment 80 tests unitaires + 11 tests publics pour `ksp-config-lib`, 34 tests unitaires Logging et toutes les intégrations existantes vertes.
## Objet de pre.014
Transformer les principales frontières d'ownership Config en audits exécutables avant la clôture :
```text
workspace source/manifests/config
-> ownership audit
-> fail cargo test on boundary regression
config JSON + production Rust
-> concrete KSP/KSPB environment names
-> .env.example inventory coverage
```
Cette tranche ne crée aucun nouveau runtime Config et ne modifie aucune API métier.
## Audit de direction des dépendances fondatrices
Le nouveau test d'intégration vérifie explicitement que :
```text
ksp-core-lib -X-> ksp-config-lib
ksp-logging-lib -X-> ksp-config-lib
```
La direction autorisée reste :
```text
ksp-config-lib -> ksp-core-lib
ksp-config-lib -> ksp-logging-lib
```
L'audit est volontairement limité aux deux fondations qui ne doivent jamais dépendre de Config. Les futures applications/crates pourront, elles, dépendre normalement de `ksp-config-lib`.
## Audit des lectures d'environnement KSP/KSPB
Pour toutes les crates autres que `ksp-config-lib`, l'audit recherche les bypass directs simples de la frontière Config :
```text
std::env::var("KSP_...")
std::env::var("KSPB_...")
std::env::var_os("KSP_...")
std::env::var_os("KSPB_...")
env::var(...)
env::var_os(...)
std::env::vars()
std::env::vars_os()
```
L'audit ne prétend pas interdire toute utilisation possible de `std::env` pour des informations système non applicatives. Il verrouille spécifiquement la frontière des variables KSP/KSPB et l'énumération globale qui permettrait de la contourner.
## Audit des noms physiques gérés
Les crates hors Config ne doivent pas coder en dur les ressources physiques déjà possédées par Config :
```text
.env
std.logging.json
std.logging.schema.json
composite.schema.json
```
Un consumer doit utiliser les contrats publics, constantes et `file_id` Config plutôt que recréer localement la connaissance du layout physique.
L'audit ignore les lignes de commentaire Rust afin qu'une documentation locale ne soit pas confondue avec un accès runtime.
## Inventaire `.env.example`
Le test collecte automatiquement les noms KSP/KSPB concrets trouvés dans :
```text
config/**/*.json
crates/*/src/**/*.rs
```
Les préfixes génériques tels que `KSP_SECRET_` ou `KSPB_PUBLIC_` ne sont pas considérés comme des variables concrètes.
Chaque variable runtime concrète détectée doit apparaître dans `.env.example` sous forme d'assignment active ou commentée et cette assignment doit être précédée d'un commentaire explicatif distinct des headers de fichier/version.
Dans l'état actuel, l'inventaire concret reste :
```text
KSP_LOGS_DIRECTORY
```
Aucune nouvelle variable runtime n'est introduite par `pre.014`.
## Réaudit de robustesse existante
Les tests déjà acquis avant `pre.014` couvrent notamment :
- JSON malformed, schema invalid et document schema-invalide ;
- invariants sémantiques Logging ;
- profils dupliqués/default_profile invalide/profil explicite absent ;
- composite references et component ids invalides ;
- filenames/mappings `file_id` invalides ;
- `.env` syntaxiquement invalide, doublons, namespaces externes et valeurs vides ;
- placeholders malformés, variables absentes et fallbacks ;
- priorité process > `.env` > fallback ;
- sensibilité `Public/Internal/Secret`, real/safe/provenance et canaries de non-divulgation ;
- validation effective `logs_directory` et confinement des `files[].path` après interpolation ;
- rejet d'un secret dans `std.logging` ;
- management desired/effective/shadow, reveal explicite et persistence atomique ;
- candidate JSON invalide sans modification du destination ;
- permissions `.env` privées à la création Unix.
Aucune lacune nécessitant une nouvelle API publique n'a été identifiée dans cette tranche. Les responsabilités restent séparées entre :
```text
ConfigBootstrapOptions / ConfigFileRegistry
ConfigDocumentEngine
ConfigEnvironment
ConfigManagement
ResolvedLoggingConfig adapter
ksp-logging-lib runtime ownership
```
## Règles durables
Ajout de :
```text
KSP-CONFIG-017
KSP-CONFIG-018
```
pour figer :
- les audits exécutables d'ownership Config au niveau workspace ;
- la vérification automatique de la couverture `.env.example`.
## Dépendances
Aucune nouvelle dépendance Cargo.
Le seul doublon transitif attendu dans le graphe Config reste `syn 2` / `syn 3` via `jsonschema`. Aucun contournement de cette dépendance n'est introduit ici.
## Tests ajoutés
Nouveau fichier :
```text
crates/ksp-config-lib/tests/ownership.rs
```
Quatre tests d'intégration workspace :
```text
foundational_dependency_direction_does_not_point_back_to_config
workspace_crates_do_not_read_ksp_environment_directly
workspace_crates_do_not_hardcode_config_managed_physical_files
dotenv_example_covers_runtime_environment_names_with_comments
```
Les 80 tests unitaires et 11 tests de `tests/public_api.rs` restent inchangés ; `cargo test --workspace` exécute désormais en plus ce nouveau binaire d'intégration à 4 tests.
## Version technique
```text
workspace.package.version = "0.1.3-pre.14"
Cargo.toml header version = 59
```
La version change car la tranche ajoute du code Rust de test participant au build/test du workspace.
## Fichiers ajoutés
```text
crates/ksp-config-lib/tests/ownership.rs
deltas/0.1.3/pre.014.md
```
## Fichiers modifiés
```text
Cargo.toml
docs/rules/RULES_KSP.md
docs/plans/000-README.md
docs/plans/002-FUNCTIONAL_RELEASE_SEQUENCE.md
docs/plans/005-V0_1_3_CONFIG_FOUNDATION_PLAN.md
```
## Contrôles statiques réalisés avant livraison
Dans l'environnement de préparation :
- reconstruction de la base par application ordonnée de tous les deltas jusqu'à `pre.013-fix.001` ;
- inventaire workspace : aucune lecture directe `std::env::var*` KSP/KSPB hors Config détectée ;
- aucune référence physique gérée interdite détectée hors Config ;
- `KSP_LOGS_DIRECTORY` est la seule variable runtime concrète détectée et figure dans `.env.example` avec commentaire ;
- aucun `.env` runtime local n'est inclus dans le delta ;
- aucune nouvelle dépendance Cargo ;
- réapplication du delta sur la base reconstruite vérifiée séparément.
Aucune commande Cargo n'est déclarée réussie dans l'environnement de préparation lorsqu'elle n'y est pas disponible.
## Validation demandée
```bash
cargo fmt --all
cargo check --workspace
cargo clippy --workspace --all-targets
cargo test --workspace
cargo tree -p ksp-config-lib
cargo tree -p ksp-config-lib -d
cargo tree -p ksp-config-lib -e features
cargo tree -p ksp-logging-lib -d
```
Après validation, `0.1.3-pre.015` pourra ouvrir la clôture : validations finales, documentation durable, changelog, nettoyage/archivage et prompt `0.1.4 — ksp-app-config-desk`.

Some files were not shown because too many files have changed in this diff Show More