74 Commits

Author SHA1 Message Date
fff69ead9c v0.2.2-rel.001 2026-08-18 10:14:43 +02:00
c93a833bac v0.2.2-pre.007-fix.002 2026-08-18 10:04:18 +02:00
05e0e96083 v0.2.2-pre.007-fix.001 2026-08-18 09:56:55 +02:00
b57f796187 v0.2.2-pre.007 2026-08-18 09:48:11 +02:00
ecfb9500eb v0.2.2-pre.006-fix.001 2026-08-18 09:37:03 +02:00
e14ce1c36f v0.2.2-pre.006 2026-08-18 08:27:42 +02:00
83cb861e54 v0.2.2-pre.005 2026-08-18 08:17:10 +02:00
1d1bc6a4d6 v0.2.2-pre.004 2026-08-18 08:09:20 +02:00
c4636ac8b9 v0.2.2-pre.003-fix.001 2026-08-18 07:53:17 +02:00
f15448ff6e v0.2.2-pre.003 2026-08-18 07:31:07 +02:00
bec1f2ec08 v0.2.2-pre.002-fix.002 2026-08-18 07:20:44 +02:00
f625ee5979 v0.2.2-pre.002-fix.001 2026-08-18 07:17:49 +02:00
e68f073505 v0.2.2-pre.002 2026-08-18 07:15:59 +02:00
bffb4f9a31 v0.2.2-pre.001-fix.001 2026-08-18 06:46:59 +02:00
9059a2dc45 v0.2.2-pre.001 2026-08-18 06:41:41 +02:00
6c3ecf1f18 v0.2.1-rel.001 2026-08-17 22:50:56 +02:00
79b67f8eae v0.2.1-pre.007 2026-08-17 22:43:07 +02:00
e0a7ac0bf8 v0.2.1-pre.006-fix.002 2026-08-17 21:41:20 +02:00
598474438b v0.2.1-pre.006-fix.001 2026-08-17 21:16:26 +02:00
f5d98c4e69 v0.2.1-pre.006 2026-08-17 21:03:51 +02:00
14bcbf2cfb v0.2.1-pre.005-fix.001 2026-08-17 20:40:08 +02:00
ac1b1033c4 v0.2.1-pre.005 2026-08-17 20:27:44 +02:00
bc71fba289 v0.2.1-pre.004-fix.002 2026-08-17 19:47:24 +02:00
9c1568ee1c v0.2.1-pre.004-fix.001 2026-08-17 19:40:12 +02:00
c1cea6e813 v0.2.1-pre.004 2026-08-17 19:26:26 +02:00
ed978179d8 v0.2.1-pre.003-fix.001 2026-08-17 18:59:01 +02:00
babe7d9f2b v0.2.1-pre.003 2026-08-17 18:55:14 +02:00
d3fc0c6d69 v0.2.1-pre.002-fix.001 2026-08-17 18:46:52 +02:00
0cff0406ab v0.2.1-pre.002 2026-08-17 18:02:18 +02:00
d98d152f08 v0.2.1-pre.001-fix.001 2026-08-17 17:36:35 +02:00
624202c363 0.2.1-pre.1 2026-08-17 17:20:41 +02:00
24cf2c5a11 v0.2.0-rel.001 2026-08-17 16:21:04 +02:00
e721464a7c v0.2.0-pre.003 2026-08-17 16:05:58 +02:00
259bff6707 v0.2.0-pre.002 2026-08-17 13:33:37 +02:00
7d40de3249 v0.2.0-pre.1 2026-08-17 10:48:02 +02:00
a3f44702a2 v0.1.4-rel.001 2026-08-17 09:24:35 +02:00
9aa5f171f7 v0.1.4-pre.019-fix.001 2026-08-17 09:04:34 +02:00
8099b6cb39 v0.1.4-pre.019 2026-08-17 08:55:24 +02:00
8f7df2d06e v0.1.4-pre.018-fix.001 2026-08-16 20:34:34 +02:00
035e25cb1f v0.1.4-pre.018 2026-08-16 20:15:58 +02:00
af5e828864 v0.1.4-pre.017 remove false file 2026-08-16 19:51:51 +02:00
4216b6b783 v0.1.4-pre.017 2026-08-16 19:49:21 +02:00
fd200f5335 v0.1.4-pre.016-fix.002 2026-08-16 19:16:19 +02:00
3021996658 v0.1.4-pre.016-fix.001 2026-08-16 19:13:48 +02:00
5aa538ed5d v0.1.4-pre.016 2026-08-16 19:00:49 +02:00
d92eb01bc2 v0.1.4-pre.015-fix.003 2026-08-16 18:28:56 +02:00
9ad61b40e1 v0.1.4-pre.015-fix.002 2026-08-16 18:20:32 +02:00
05c65a12f4 v0.1.4-pre.015-fix.001 2026-08-16 17:46:48 +02:00
d7185905d8 v0.1.4-pre.015 2026-08-16 17:38:23 +02:00
789ffb16ce v0.1.4-pre.014 2026-08-16 17:03:07 +02:00
e0f2586400 v0.1.4-pre.013 2026-08-16 16:51:11 +02:00
2b343c25de v0.1.4-pre.012-fix.001 2026-08-16 16:35:51 +02:00
13a2e0038a v0.1.4-pre.012 2026-08-16 16:26:03 +02:00
8609d335a3 v0.1.4-pre.011-fix.001 2026-08-16 15:18:02 +02:00
00361f22f4 v0.1.4-pre.011 2026-08-16 15:14:37 +02:00
f0f2954236 v0.1.4-pre.010 2026-08-16 15:04:58 +02:00
cb10fd354b v0.1.4-pre.009 2026-08-16 14:39:36 +02:00
675350f5bf v0.1.4-pre.008-fix.003 2026-08-16 13:46:12 +02:00
60d3cb5606 v0.1.4-pre.008-fix.002 2026-08-16 13:35:12 +02:00
73d05862af v0.1.4-pre.008-fix.001 2026-08-16 13:06:36 +02:00
1825676ff9 v0.1.4-pre.008 2026-08-16 12:32:11 +02:00
ac8867d5a1 v0.1.4-pre.007 2026-08-16 12:21:28 +02:00
e11006e96a v0.1.4-pre.006-fix.001 2026-08-16 11:44:02 +02:00
34f6f448fb v0.1.4-pre.006 2026-08-16 11:38:07 +02:00
abe3f8daa6 v0.1.4-pre.005-fix.001 2026-08-16 11:18:13 +02:00
f719821392 v0.1.4-pre.005 2026-08-16 11:11:09 +02:00
db80e58ff1 v0.1.4-pre.004-fix.001 2026-08-16 10:44:33 +02:00
fcc1e9913d v0.1.4-pre.004 2026-08-16 10:40:23 +02:00
fa6d4e1039 v0.1.4-pre.003 2026-08-16 10:21:15 +02:00
4a68a54d85 v0.1.4-pre.002 2026-08-16 10:01:04 +02:00
839664377f v0.1.4-pre.001-fix.003 2026-08-16 09:35:09 +02:00
f8db9f7fb1 v0.1.4-pre.001-fix.002 2026-08-16 09:29:51 +02:00
169ed47fea v0.1.4-pre.001-fix.001 2026-08-16 09:10:38 +02:00
6bd0b4e9b5 v0.1.4-pre.001 2026-08-16 08:52:09 +02:00
306 changed files with 41965 additions and 3099 deletions

View File

@@ -1,6 +1,24 @@
# file: .env.example # file: .env.example
# version: 1 # version: 3
# KSP Logging root directory. Used by config/std.logging.json for relative log output paths. # 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. # The current Config document fallback is "logs" when neither the process environment nor .env defines this variable.
KSP_LOGS_DIRECTORY=logs KSP_LOGS_DIRECTORY=logs
# Optional public Solana Devnet HTTP endpoint override used by config/std.transport.json.
# The committed Transport document falls back to https://api.devnet.solana.com when this variable is absent.
KSP_PUBLIC_SOLANA_DEVNET_HTTP_URL=https://api.devnet.solana.com
# Optional public Solana Mainnet HTTP endpoint override used by config/std.transport.json and its example.
# The committed Transport document falls back to https://api.mainnet-beta.solana.com when this variable is absent.
KSP_PUBLIC_SOLANA_MAINNET_HTTP_URL=https://api.mainnet-beta.solana.com
# Optional complete private-provider HTTP endpoint URL used only by the Transport example when explicitly selected.
# Keep provider credentials in a KSP_SECRET_* variable; do not copy a real credential-bearing URL into committed JSON.
# KSP_SECRET_SOLANA_HTTP_URL=https://provider.example/?api-key=replace-me
# Minimum time in milliseconds that a KSP desk splash remains visible after its frontend is ready.
KSP_DESK_SPLASH_MINIMUM_MS=1200
# Fade-in/fade-out duration in milliseconds used by the common KSP desk splash lifecycle.
KSP_DESK_SPLASH_FADE_MS=300

4
.gitignore vendored
View File

@@ -1,5 +1,5 @@
# file: .gitignore # file: .gitignore
# version: 2 # version: 3
# Rust build artifacts # Rust build artifacts
# /target/ # target is outside. # /target/ # target is outside.
@@ -16,6 +16,8 @@ pnpm-debug.log*
# Node / frontend dependencies and generated artifacts # Node / frontend dependencies and generated artifacts
**/node_modules/ **/node_modules/
**/dist/ **/dist/
bindings/
gen/
package-lock.json package-lock.json
pnpm-lock.yaml pnpm-lock.yaml
yarn.lock yarn.lock

View File

@@ -1,10 +1,26 @@
<!-- file: CHANGELOG.md --> <!-- file: CHANGELOG.md -->
<!-- version: 2 --> <!-- version: 6 -->
# Changelog KSP # 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/`. 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.2.2 — HTTP Accounts + Tokens + Cluster — 2026-08-18
`0.2.2` complète la surface HTTP typée de `ksp-onchain-transport-lib` avec 22 wrappers supplémentaires : 5 Accounts, 5 Tokens et 12 Cluster, portant la couverture typée totale à 26 méthodes avec les quatre canaris foundation de `0.2.1`. La release stabilise les DTOs/configs wire communs, préserve les formes Account encodées/`jsonParsed`, les `null` et champs optionnels, les selectors Token exclusifs, les contextes RPC, les structures Cluster/leader/vote et les limites locales auditées (`getMultipleAccounts <= 100`, `getProgramAccounts <= 4` filtres, `memcmp` raw <= 128 octets, `getSlotLeaders` entre 1 et 5000). Les 22 descriptors restent `Read / RetrySafe` et passent exclusivement par le flux central `descriptor -> execute_standard_rpc -> pool/admission -> executor HTTP`. La release n'ajoute aucune dépendance externe, ne crée aucun client HTTP parallèle et conserve le firewall Transport -> Config/Store/Program. Elle ajoute un smoke Devnet pur à Transport avec settings programmatiques couvrant Accounts/Tokens/Cluster, conserve séparément le smoke historique Config -> Transport comme exception transitoire, et valide les deux explicitement avant publication. Les canaries figent toujours 52 méthodes HTTP courantes, 14 historiques Deprecated/Removed et la partition typed `4 / 22 / 11 / 15`. Le prompt `prompts/008-V0_2_3_START_PROMPT.md` ouvre `0.2.3 — HTTP Transactions`.
## 0.2.1 — HTTP Solana foundation — 2026-08-17
`0.2.1` stabilise `ksp-onchain-transport-lib` comme foundation HTTP JSON-RPC Solana provider-neutral : settings publics, endpoints/pool/rôles, priorités et fairness, RPS/burst/concurrence/cooldown, deadline commune, retry/backoff, classification no-resend après dispatch ambigu, snapshots sûrs et exécution HTTP réelle via `reqwest`/rustls. La release fige un registre audité de 52 méthodes HTTP courantes et 14 méthodes historiques Deprecated/Removed, avec quatre wrappers typés canari (`getBalance`, `getGenesisHash`, `getHealth`, `getVersion`) et une partition explicite des 48 méthodes restantes sur `0.2.2``0.2.4`. Elle ajoute `std.transport`, son schema/exemple et l'adapter `ksp-config-lib -> ksp-onchain-transport-lib`, sans dépendance inverse, ainsi que la redaction des URLs/provider credentials, la neutralisation des URLs contenues dans les `reqwest::Error`, un sink Logging Transport dédié à `info`, des fixtures HTTP déterministes, des canaries de complétude et un smoke Devnet opt-in validant la composition Config -> Transport. Le smoke cross-crates reste temporairement hébergé dans Config et doit migrer vers une future surface d'intégration/orchestration ; ce placement n'est pas un modèle pour les futurs smokes `Config + autre crate`. Le prompt `prompts/007-V0_2_2_START_PROMPT.md` ouvre `0.2.2 — HTTP Accounts + Tokens + Cluster`.
## 0.2.0 — Audit bot3 et planification de la série `0.2.x` — 2026-08-17
`0.2.0` stabilise le cadrage de la prochaine phase fonctionnelle de KSP après audit de `khadhroony-bot3`. La release fixe l'ordre `0.2.1+` autour du transport HTTP Solana, du Wallet `.kspwallet`, de Wallet Desk, des transports WebSocket/LaserStream/Yellowstone, du transport off-chain de prix, de `ksp-interface-lib` et de `ksp-program-api`; elle impose la couverture exhaustive des surfaces Transport documentées avec warnings KSP pour les opérations deprecated/obsolete encore fonctionnelles et unstable/experimental. Elle stabilise également la progression durable `RAW -> CORE -> DECODE -> SPECIALIZED`, RAW/CORE sans décodage Program, puis des vertical slices complets par groupe à partir de DECODE, avec priorité Solana Core, SPL token/trading, metadata token, Anchor, Meteora/Raydium/Pump/Orca, routing et Market Desk progressive. Le prompt `prompts/006-V0_2_1_START_PROMPT.md` ouvre `0.2.1 — ksp-onchain-transport-lib / HTTP Solana foundation` avec un gate de sizing imposant qu'une release concrète reste clôturable dans une seule session.
## 0.1.4 — Config Desk — 2026-08-17
`0.1.4` stabilise `ksp-app-config-desk` comme première application desktop/Tauri spécialisée et modèle de référence des futures applications KSP. La release valide de bout en bout les contrats de `ksp-config-lib` et le lifecycle de `ksp-logging-lib` : shell splash/main, inventaire et diagnostics des documents Config, profils et provenance sûre, management `.env` avec shadowing et reveal Secret privilégié, éditeur Logging typé multi-profils/multi-sinks, persistence atomique, hot reload transactionnel, rollback, sélection runtime explicite, génération observable, fichiers de logs distincts par lancement, bridge frontend vers la façade KSP et panneau Test Logging pour démontrer le routing niveau/target/domain. Elle ajoute également les audits desktop/ownership/sécurité, un registre extensible `file_id -> éditeur spécialisé`, une baseline Logging de release `info`/`warn`, et prépare `0.2.0` comme release intermédiaire daudit de `khadhroony-bot3` et de planification du reste de `0.2.x`.
## 0.1.3 — Configuration foundation — 2026-08-16 ## 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.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.

View File

@@ -1,12 +1,12 @@
# file: Cargo.toml # file: Cargo.toml
# version: 61 # version: 119
[workspace] [workspace]
resolver = "3" resolver = "3"
members = ["crates/ksp-config-lib", "crates/ksp-core-lib", "crates/ksp-logging-lib"] members = ["crates/ksp-app-config-desk", "crates/ksp-config-lib", "crates/ksp-core-lib", "crates/ksp-logging-lib", "crates/ksp-onchain-transport-lib"]
[workspace.package] [workspace.package]
version = "0.1.3" version = "0.2.2"
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,14 +14,21 @@ authors = ["SinuS von SifriduS <sinus@sasedev.net>"]
publish = false publish = false
[workspace.dependencies] [workspace.dependencies]
serde = { version = "^1.0", features = ["derive"] } fs2 = { version = "^0.4" }
serde = { version = "^1.0" }
serde_json = { version = "^1.0" } serde_json = { version = "^1.0" }
jsonschema = { version = "^0.49", default-features = false } jsonschema = { version = "^0.49", default-features = false }
reqwest = { version = "^0.13", default-features = false }
solana-pubkey = { version = "^4.3", default-features = false } solana-pubkey = { version = "^4.3", default-features = false }
tracing = { version = "^0.1", default-features = false, features = ["std"] } tracing = { version = "^0.1", default-features = false }
tracing-subscriber = { version = "^0.3", default-features = false, features = ["fmt", "json", "ansi"] } tracing-subscriber = { version = "^0.3", default-features = false }
tracing-appender = { version = "^0.2", default-features = false } tracing-appender = { version = "^0.2", default-features = false }
tokio = { version = "^1.53", default-features = false, features = ["rt", "rt-multi-thread", "macros"] } tokio = { version = "^1.53", default-features = false }
chrono = { version = "^0.4", default-features = false }
tauri = { version = "^2.11" }
tauri-build = { version = "^2.6" }
tauri-plugin-tracing = { version = "^0.3" }
ts-rs = { version = "^12.0" }
[workspace.lints.rust] [workspace.lints.rust]
missing_docs = "warn" missing_docs = "warn"

View File

@@ -1,11 +1,9 @@
<!-- file: ROADMAP.md --> <!-- file: ROADMAP.md -->
<!-- version: 16 --> <!-- version: 37 -->
# Roadmap KSP # Roadmap KSP
Le roadmap décrit les objectifs à atteindre et les grandes étapes prévues pour y parvenir. Une série `X.Y.x` regroupe une famille fonctionnelle de travaux ; elle peut contenir plusieurs releases concrètes et plusieurs sessions. Le roadmap décrit les objectifs à atteindre et les grandes étapes prévues. Une série `X.Y.x` regroupe une famille fonctionnelle ; chaque release concrète reste une unité de développement/session distincte.
Les décisions architecturales négatives ou de prudence n'apparaissent pas comme des tâches à cocher. Elles sont conservées dans les règles et documents d'architecture.
## Légende ## Légende
@@ -15,6 +13,14 @@ Les décisions architecturales négatives ou de prudence n'apparaissent pas comm
- `[C]` — annulé ; - `[C]` — annulé ;
- `[R]` — reporté. - `[R]` — reporté.
## Discipline de livraison
- Une prerelease vise environ **15 à 20 minutes de travail effectif**.
- Une release concrète doit être dimensionnée pour pouvoir être ouverte et clôturée dans **une seule session de chat**.
- Cette règle fixe une durée maximale par release, pas une obligation de changer de session après chaque release : si une release est clôturée plus vite que prévu, la même session peut ouvrir puis clôturer la release suivante si son propre sizing reste positif et si toutes les frontières version/delta/validation sont conservées.
- Si `pre.001` révèle qu'une release est trop grosse, elle est scindée avant implémentation fonctionnelle lourde.
- À partir des couches Program/Decode, KSP progresse verticalement groupe par groupe plutôt que par grandes vagues horizontales de decoders/materializers/executors séparés.
## 0.0.x — Fondation ## 0.0.x — Fondation
- [X] `0.0.1` — Initialiser le dépôt. - [X] `0.0.1` — Initialiser le dépôt.
@@ -25,89 +31,127 @@ Les décisions architecturales négatives ou de prudence n'apparaissent pas comm
## 0.1.x — Fondations N1 ## 0.1.x — Fondations N1
### Objectifs - [X] `0.1.1``ksp-core-lib` : Error/Result, Program IDs fondamentaux et primitives N1.
- [X] `0.1.2``ksp-logging-lib` : façade KSP de tracing.
- [X] `0.1.3``ksp-config-lib` : documents, profils, environnement, persistence et adapters.
- [X] `0.1.4``ksp-app-config-desk` : première application Tauri de référence.
Regrouper les releases consacrées aux fondations N1. Chaque release concrète est une unité de développement/session distincte et commence par son propre `pre.001` de brainstorming/audit/planification. ## 0.2.x — Accès Solana, Wallet et contrats d'extension initiaux
### Releases concrètes ### Cadrage
- [X] `0.1.1`Stabiliser `ksp-core-lib` : `Error`/`Result`, Program IDs fondamentaux et primitives réellement N1. - [X] `0.2.0`Audit bot3, ordre fonctionnel de `0.2.x`, architecture durable, discipline de sizing et pipeline RAW/CORE/DECODE/SPECIALIZED stabilisés.
- [X] `0.1.2`Introduire `ksp-logging-lib` comme façade KSP de `tracing`, `tracing-appender` et `tracing-subscriber`. - [X] `0.2.1`HTTP foundation stable : matrice 52+14, runtime/routing/résilience/exécution HTTP, 4 canaris typés, `std.transport`, adapter Config -> Transport, canaries de clôture, smoke Devnet opt-in et documentation durable validés ; les 48 wrappers typés restants sont reportés à `0.2.2``0.2.4`.
- [X] `0.1.3` — Stabiliser `ksp-config-lib` : documents, profils, résolution, validation, environnement KSP/KSPB, management/persistence et adapter Logging.
- [ ] `0.1.4` — Introduire `ksp-app-config-desk` pour valider réellement Config et la frontière Tauri.
`0.1.1`, `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. ### Releases fonctionnelles décidées/pressenties
Les contrats publics supplémentaires ne sont introduits que lorsqu'une release concrète en démontre le besoin. - [X] `0.2.1`**HTTP transport foundation réduite par le gate `pre.001`** : crate/settings/JSON-RPC/registry 52 current + 14 deprecated historiques, pool/rôles/limites/retry, Config adapter, documentation et 4 méthodes typées canari (`getBalance`, `getGenesisHash`, `getHealth`, `getVersion`) publiés stables.
- [X] `0.2.2` — HTTP Accounts + Tokens + Cluster : 22 wrappers typés (5 Accounts + 5 Tokens + 12 Cluster), canaries de complétude 52+14, smoke Devnet Transport pur et smoke historique Config -> Transport validés, documentation durable et prompt `0.2.3` publiés stables.
- [ ] `0.2.3` — Compléter les 11 méthodes HTTP Transactions, y compris write/submission technique avec politique no-resend ambigu.
- [ ] `0.2.4` — Compléter les 10 méthodes HTTP Blocks + 5 Economics et exécuter la compliance finale de toute la surface HTTP 52 current + 14 deprecated historiques.
- [ ] `0.2.5` — Introduire `ksp-wallet-lib`, le format `.kspwallet`, la gestion sûre des secrets et une architecture d'import/export extensible ; exclure `WalletPolicy`.
- [ ] `0.2.6` — Introduire `ksp-app-wallet-desk` utilisant Config composite + Wallet + transport HTTP, notamment pour afficher l'identité et le solde d'un wallet.
- [ ] `0.2.7` — Étendre `ksp-onchain-transport-lib` au WebSocket Solana standard complet ; permettre plusieurs sessions sur une même URL sans imposer encore un pool automatique complexe.
- [ ] `0.2.8` — Ajouter Helius LaserStream WebSocket comme extension du moteur WebSocket standard, sans duplication de client.
- [ ] `0.2.9` — Ajouter une première fondation Yellowstone gRPC standard/provider-neutral ; dimensionner la surface exacte à `pre.001` selon la documentation normative actuelle.
- [ ] `0.2.10` — Introduire `ksp-offchain-transport-lib` avec un premier lecteur de prix, au minimum SOL/USD et SOL/EUR.
- [ ] `0.2.11` — Introduire une petite application desk de visualisation des prix.
- [ ] `0.2.12` — Introduire la première surface de `ksp-interface-lib`, comprenant une API wire publique utilisable par les implémentations officielles et externes.
- [ ] `0.2.13` — Introduire `ksp-program-api` comme premier contrat Program extensible, sans imposer encore `ksp-program-lib` complet.
## 0.2.x — Accès Solana et fondation programmes ### Règles Transport pour toute la série
- [ ] Introduire `ksp-onchain-transport-lib` avec des modèles de transport homogènes indépendants du store. - [ ] Couvrir toutes les méthodes/opérations documentées pour la surface normative ciblée par chaque release.
- [ ] Introduire `ksp-wallet-lib` et `ksp-app-wallet-desk`. - [ ] Conserver les méthodes deprecated/obsolete encore fonctionnelles et émettre un `warn` KSP lors de leur utilisation.
- [ ] Développer la première surface utile de `ksp-interface-lib`. - [ ] Implémenter les méthodes unstable/experimental ciblées et émettre un `warn` KSP lors de leur utilisation.
- [ ] Introduire `ksp-program-api` puis `ksp-program-lib`. - [ ] Centraliser la metadata de statut des méthodes plutôt que disperser des warnings ad hoc.
- [ ] Définir `ksp-execution-policy-api` comme contrat de policy commun à plusieurs contextes. - [ ] Garder `ksp-onchain-transport-lib` indépendant de `ksp-config-lib`, du Store et des modèles Program/métier.
- [ ] Introduire `ksp-execution-lib` lorsque le premier cycle d'exécution réel justifie l'orchestration programme/policy/wallet/transport.
- [ ] Introduire `ksp-offchain-transport-lib` seulement au premier besoin réel.
## 0.3.x — Données, stockage et acquisition raw ## Architecture de données — progression canonique
- [ ] Introduire `ksp-materializer-api` / `ksp-materializer-lib`. La chaîne durable cible est :
- [ ] Introduire `ksp-store-api` / `ksp-store-lib` avec PostgreSQL de référence.
- [ ] Établir les niveaux durables D1 Raw, D2 Core, D3 journal de matérialisation générique et D4 projections de domaine.
- [ ] Garantir des replays indépendants D1 -> D2, D2 -> D3 et D3 -> D4.
- [ ] Introduire `ksp-app-store-desk`.
- [ ] Introduire `ksp-worker-api` et `ksp-worker-raw-retriever`.
- [ ] Introduire `ksp-worker-control-lib` lorsque le manager W1 crée le premier besoin concret.
- [ ] Introduire `ksp-job-api` et `ksp-job-backfill`.
- [ ] Introduire les pipelines spécialisés raw ingestion, Core processing, generic materialization et domain projection lorsque leurs premières frontières fonctionnelles sont développées.
- [ ] Introduire les jobs de replay indépendants D1 -> D2, D2 -> D3 et D3 -> D4.
- [ ] Normaliser les notifications de données persistées indépendamment de leur producteur et conserver le Store comme source de vérité du backlog.
- [ ] Mettre en place claim/lease, outcomes durables et reprise après crash pour les traitements concurrents.
## 0.4.x — Baseline Solana, SPL et metadata ```text
RAW -> CORE -> DECODE -> SPECIALIZED
```
- [ ] Ajouter progressivement les decoders et `ProgramExecutionPreparer` Core/SPL nécessaires. - **RAW** et **CORE** ne nécessitent aucun décodage Program.
- [ ] Ajouter Token, Token-2022, ATA et metadata utiles. - À la fin de chaque couche horizontale RAW/CORE, ajouter les jobs/workers/apps nécessaires pour la rendre réellement exploitable avant d'ouvrir la couche suivante.
- [ ] Introduire `ksp-offchain-transport-lib` au plus tard au premier besoin externe. - À partir de **DECODE**, avancer verticalement groupe par groupe : wire -> decode -> matérialisation -> projection spécialisée si utile -> préparation d'exécution -> policy -> execution -> scénarios Devnet.
- [ ] Ajouter materializers et jobs ponctuels nécessaires.
- [ ] Ajouter les crates `ksp-scenario-<domain>-lib` spécialisées.
- [ ] Ajouter les demos `ksp-app-scenario-<domain>-<environment>-desk-demo` correspondantes.
- [ ] Garder Memo, Token, ATA, Token-2022 séparés ; regrouper uniquement Metaplex Token Metadata + Token-2022 Metadata dans la famille metadata ; garder SPM séparé.
## 0.5.x — Anchor et protocoles trading ## 0.3.x — RAW / acquisition persistée
- [ ] Introduire Anchor. - [ ] `0.3.1` — Introduire `ksp-store-api` + `ksp-store-lib` avec PostgreSQL de référence et **modèles/persistence RAW uniquement**.
- [ ] Étendre Meteora par surfaces bornées. - [ ] `0.3.2` — Étendre `ksp-interface-lib` avec les wires génériques cessaires aux acquisitions et à la future normalisation CORE.
- [ ] Étendre Raydium par surfaces bornées. - [ ] `0.3.3` — Introduire `ksp-job-api` et un job de backfill historique concret.
- [ ] Ajouter progressivement Pump, Orca, Jupiter, OKX et autres intégrations utiles. - [ ] `0.3.4` — Introduire une application spécialisée de backfill/inspection RAW.
- [ ] Ajouter interfaces, program implementations, materializers, jobs/scénarios/demos nécessaires pour chaque surface. - [ ] Compléter ensuite la couche RAW avec le worker/service live, son contrôle et les outils d'exploitation réellement nécessaires avant de passer à CORE.
## 0.6.x — Processing autonome et orchestration ## Série CORE suivante
- [ ] Introduire `ksp-worker-core-processor` pour D1 Raw -> D2 Core canonique. - [ ] Définir la persistence CORE canonique Solana générique.
- [ ] Introduire `ksp-worker-generic-materializer` pour D2 Core -> D3 journal de matérialisation générique. - [ ] Implémenter `RAW -> CORE` sans decoder Program : blocs, slots, signatures, transactions/messages, comptes, instructions/CPI brutes, logs/meta et relations structurelles.
- [ ] Introduire `ksp-worker-domain-projector` pour D3 -> D4 projections spécialisées ; nom révisable. - [ ] Ajouter replay/backfill RAW -> CORE.
- [ ] Exploiter les mêmes pipelines spécialisés pour les workers live et les jobs de replay afin d'éviter la duplication des frontières de processing. - [ ] Ajouter worker/service CORE.
- [ ] Étendre `ksp-worker-control-lib` à la gouvernance de plusieurs workers autonomes. - [ ] Ajouter l'application de contrôle/inspection CORE utile.
- [ ] Fournir pour chaque worker un mode service autonome, avec logique réutilisable séparée du binaire d'enveloppe.
- [ ] Construire d'abord les applications spécialisées nécessaires au développement, test et exploitation de chaque capacité.
- [ ] Garder jobs et workers sous des lifecycle APIs séparées.
## 0.7.x — Trading Intelligence ## Séries DECODE/SPECIALIZED/EXECUTION — progression verticale
- [ ] Statistiques et métriques. ### Priorité 1 — Solana Core Programs
- [ ] Features et datasets historiques.
- [ ] Signaux et risque. - [ ] Wire/decoding des programmes Core nécessaires transversalement.
- [ ] Matérialisation et projections utiles.
- [ ] Préparation d'exécution, policy et scénarios Devnet pour les opérations retenues.
### Priorité 2 — SPL token/trading
- [ ] SPL Token.
- [ ] Associated Token Account.
- [ ] Token-2022 et extensions pertinentes.
- [ ] Pour chaque famille : decode -> materialize -> specialized -> prepare -> policy -> execute -> scenarios.
### Priorité 3 — Metadata token
- [ ] Metaplex Token Metadata.
- [ ] Token-2022 Metadata.
- [R] Solana Program Metadata (SPM) — redéveloppement plus tard avec le décodage généraliste.
### Priorité 4 — Anchor
- [ ] Introduire les contrats et mécanismes Anchor nécessaires aux protocoles trading suivants.
### Priorité 5 — DEX à fort intérêt
- [ ] Meteora, y compris vaults/fees/positions/états auxiliaires nécessaires à son groupe.
- [ ] Raydium, y compris programmes satellites nécessaires.
- [ ] Pump, y compris fee program et composants launch/bonding/pool nécessaires.
- [ ] Orca, y compris programmes satellites nécessaires.
- [ ] Chaque groupe est terminé verticalement avant de devenir secondaire au profit du suivant.
### Market Desk V1
- [ ] Après les premiers groupes Meteora/Raydium/Pump/Orca, introduire une petite `ksp-app-market-desk` spécialisée.
- [ ] Visualiser tokens, pools/markets, liquidité, swaps/trades, prix, volumes, OHLC/candles et activité live/récente lorsque disponible.
- [ ] Lire les projections SPECIALIZED KSP ; ne pas reconstruire la logique protocolaire dans l'UI.
### Routing
- [ ] Jupiter.
- [ ] OKX et autres routeurs selon besoin réel.
- [ ] Enrichir Market Desk avec routes, legs, DEX impliqués, fees/slippage et comparaison quote/execution lorsque disponible.
### Trading-adjacent puis décodage généraliste
- [ ] Ajouter ensuite les programmes indépendants utiles au trading : oracles, locks/vesting indépendants, lifecycle token, risk/signaux, etc.
- [ ] Ne jamais y repousser un satellite appartenant à un groupe DEX déjà ciblé.
- [ ] Étendre enfin le décodage au reste de Solana selon valeur fonctionnelle.
## Trading Intelligence et produits ultérieurs
- [ ] Statistiques/features/datasets historiques.
- [ ] Signaux, risque, anomalies et patterns.
- [ ] Replay analytique/backtests. - [ ] Replay analytique/backtests.
- [ ] Patterns/anomalies. - [ ] XGBoost puis autres modèles lorsque les données et contrats sont stables.
- [ ] XGBoost puis autres modèles lorsque les contrats sont stables. - [ ] Construire ensuite les produits de trading opérationnel au-dessus de ces couches.
- [ ] Faire évoluer Market Desk vers davantage d'analyse sans la confondre avec l'application globale ou l'orchestrateur.
## 0.8.x et suivantes — Trading opérationnel et expansion produits - [ ] Étudier plus tard les autres applications Wallet : mobile, extensions navigateur et web.
- [ ] Construire les couches puis l'application de trading monoposte au-dessus de Trading Intelligence.
- [ ] Étendre l'automatisation de trading.
- [ ] Étendre continuellement Program IDs, decoders, execution preparers et materializers.
- [ ] Construire progressivement l'explorer Solana.
- [ ] Construire progressivement l'explorer/analyse DEX.
- [ ] Étudier plus tard d'autres applications utilisant `ksp-wallet-lib`, notamment mobile, extensions navigateur et web.

View File

@@ -0,0 +1,64 @@
{
"format_version": 1,
"retry": {
"max_retries": 3,
"initial_backoff_ms": 150,
"max_backoff_ms": 3000
},
"default_profile": "mainnet_mixed",
"profiles": [
{
"profile_id": "mainnet_mixed",
"endpoints": [
{
"name": "mainnet_public",
"enabled": true,
"provider": "solana-public",
"cluster": "mainnet-beta",
"url": "${KSP_PUBLIC_SOLANA_MAINNET_HTTP_URL:-https://api.mainnet-beta.solana.com}",
"connect_timeout_ms": 5000,
"request_timeout_ms": 15000,
"max_idle_connections_per_host": 8,
"roles": [
{
"role": "default",
"enabled": true,
"request_kinds": ["*"],
"priority": 200,
"limits": {
"requests_per_second": 5,
"burst_capacity": 10,
"max_concurrent_requests": 8,
"pause_after_rate_limit_ms": 1000
}
}
]
},
{
"name": "mainnet_private",
"enabled": true,
"provider": "private-provider",
"cluster": "mainnet-beta",
"url": "${KSP_SECRET_SOLANA_HTTP_URL:-https://example.invalid}",
"connect_timeout_ms": 5000,
"request_timeout_ms": 10000,
"max_idle_connections_per_host": 16,
"roles": [
{
"role": "default",
"enabled": true,
"request_kinds": ["*"],
"priority": 100,
"limits": {
"requests_per_second": 20,
"burst_capacity": 40,
"max_concurrent_requests": 16,
"pause_after_rate_limit_ms": 750
}
}
]
}
]
}
]
}

View File

@@ -0,0 +1,124 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "urn:ksp:schema:std.transport:v1",
"title": "KSP standard HTTP Transport configuration",
"type": "object",
"additionalProperties": false,
"required": ["format_version", "retry", "default_profile", "profiles"],
"properties": {
"format_version": {"const": 1},
"retry": {"$ref": "#/$defs/retry"},
"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._-]*$"
},
"descriptor": {
"type": "string",
"minLength": 1,
"pattern": "^\\S(?:.*\\S)?$"
},
"positiveMs": {
"type": "integer",
"minimum": 1,
"maximum": 4294967295
},
"positiveU32": {
"type": "integer",
"minimum": 1,
"maximum": 4294967295
},
"retry": {
"type": "object",
"additionalProperties": false,
"required": ["max_retries", "initial_backoff_ms", "max_backoff_ms"],
"properties": {
"max_retries": {"type": "integer", "minimum": 0, "maximum": 100},
"initial_backoff_ms": {"$ref": "#/$defs/positiveMs"},
"max_backoff_ms": {"$ref": "#/$defs/positiveMs"}
}
},
"limits": {
"type": "object",
"additionalProperties": false,
"properties": {
"requests_per_second": {"$ref": "#/$defs/positiveU32"},
"burst_capacity": {"$ref": "#/$defs/positiveU32"},
"max_concurrent_requests": {"$ref": "#/$defs/positiveU32"},
"pause_after_rate_limit_ms": {"$ref": "#/$defs/positiveMs"}
}
},
"role": {
"type": "object",
"additionalProperties": false,
"required": ["role", "enabled", "request_kinds", "priority", "limits"],
"properties": {
"role": {"$ref": "#/$defs/descriptor"},
"enabled": {"type": "boolean"},
"request_kinds": {
"type": "array",
"minItems": 1,
"uniqueItems": true,
"items": {"$ref": "#/$defs/descriptor"},
"allOf": [
{
"if": {"contains": {"const": "*"}},
"then": {"maxItems": 1}
}
]
},
"priority": {"type": "integer", "minimum": 0, "maximum": 4294967295},
"limits": {"$ref": "#/$defs/limits"}
}
},
"endpoint": {
"type": "object",
"additionalProperties": false,
"required": [
"name",
"enabled",
"provider",
"cluster",
"url",
"connect_timeout_ms",
"request_timeout_ms",
"roles"
],
"properties": {
"name": {"$ref": "#/$defs/descriptor"},
"enabled": {"type": "boolean"},
"provider": {"$ref": "#/$defs/descriptor"},
"cluster": {"$ref": "#/$defs/descriptor"},
"url": {"type": "string", "minLength": 1},
"connect_timeout_ms": {"$ref": "#/$defs/positiveMs"},
"request_timeout_ms": {"$ref": "#/$defs/positiveMs"},
"max_idle_connections_per_host": {"type": "integer", "minimum": 1},
"roles": {
"type": "array",
"minItems": 1,
"items": {"$ref": "#/$defs/role"}
}
}
},
"profile": {
"type": "object",
"additionalProperties": false,
"required": ["profile_id", "endpoints"],
"properties": {
"profile_id": {"$ref": "#/$defs/profileId"},
"endpoints": {
"type": "array",
"minItems": 1,
"items": {"$ref": "#/$defs/endpoint"}
}
}
}
}
}

View File

@@ -13,7 +13,7 @@
"ansi": true, "ansi": true,
"format": "compact", "format": "compact",
"filter": { "filter": {
"level": "debug", "level": "info",
"targets": [ "targets": [
"*" "*"
], ],
@@ -24,14 +24,14 @@
}, },
"files": [ "files": [
{ {
"output_id": "file.all.debug", "output_id": "file.all.info",
"enabled": true, "enabled": true,
"path": "debug/ksp-debug.log", "path": "info/ksp-info.log",
"rotation": "daily", "rotation": "daily",
"format": "human", "format": "human",
"ansi": false, "ansi": false,
"filter": { "filter": {
"level": "debug", "level": "info",
"targets": [ "targets": [
"*" "*"
], ],
@@ -40,6 +40,23 @@
] ]
} }
}, },
{
"output_id": "file.onchain_transport.info",
"enabled": true,
"path": "transport/onchain/ksp-onchain-transport.log",
"rotation": "daily",
"format": "human",
"ansi": false,
"filter": {
"level": "info",
"targets": [
"ksp-onchain-transport-lib"
],
"domains": [
"*"
]
}
},
{ {
"output_id": "file.config.error", "output_id": "file.config.error",
"enabled": true, "enabled": true,
@@ -61,11 +78,19 @@
"target_filters": [ "target_filters": [
{ {
"target_prefix": "ksp-config-lib", "target_prefix": "ksp-config-lib",
"level": "trace" "level": "info"
}, },
{ {
"target_prefix": "ksp-logging-lib", "target_prefix": "ksp-logging-lib",
"level": "debug" "level": "info"
},
{
"target_prefix": "ksp-app-config-desk",
"level": "info"
},
{
"target_prefix": "ksp-onchain-transport-lib",
"level": "info"
} }
] ]
} }

69
config/std.transport.json Normal file
View File

@@ -0,0 +1,69 @@
{
"format_version": 1,
"retry": {
"max_retries": 2,
"initial_backoff_ms": 100,
"max_backoff_ms": 2000
},
"default_profile": "devnet_public",
"profiles": [
{
"profile_id": "devnet_public",
"endpoints": [
{
"name": "solana_devnet_public",
"enabled": true,
"provider": "solana-public",
"cluster": "devnet",
"url": "${KSP_PUBLIC_SOLANA_DEVNET_HTTP_URL:-https://api.devnet.solana.com}",
"connect_timeout_ms": 5000,
"request_timeout_ms": 15000,
"max_idle_connections_per_host": 8,
"roles": [
{
"role": "default",
"enabled": true,
"request_kinds": ["*"],
"priority": 100,
"limits": {
"requests_per_second": 5,
"burst_capacity": 10,
"max_concurrent_requests": 8,
"pause_after_rate_limit_ms": 1000
}
}
]
}
]
},
{
"profile_id": "mainnet_public",
"endpoints": [
{
"name": "solana_mainnet_public",
"enabled": true,
"provider": "solana-public",
"cluster": "mainnet-beta",
"url": "${KSP_PUBLIC_SOLANA_MAINNET_HTTP_URL:-https://api.mainnet-beta.solana.com}",
"connect_timeout_ms": 5000,
"request_timeout_ms": 15000,
"max_idle_connections_per_host": 8,
"roles": [
{
"role": "default",
"enabled": true,
"request_kinds": ["*"],
"priority": 100,
"limits": {
"requests_per_second": 5,
"burst_capacity": 10,
"max_concurrent_requests": 8,
"pause_after_rate_limit_ms": 1000
}
}
]
}
]
}
]
}

View File

@@ -0,0 +1,38 @@
# file: crates/ksp-app-config-desk/Cargo.toml
# version: 8
[package]
name = "ksp-app-config-desk"
version.workspace = true
edition.workspace = true
license.workspace = true
repository.workspace = true
publish.workspace = true
[lib]
name = "ksp_app_config_desk_lib"
path = "src/lib.rs"
crate-type = ["staticlib", "cdylib", "rlib"]
[[bin]]
name = "ksp-app-config-desk"
path = "src/main.rs"
[build-dependencies]
tauri-build.workspace = true
[dependencies]
fs2.workspace = true
ksp-config-lib = { path = "../ksp-config-lib" }
ksp-core-lib = { path = "../ksp-core-lib" }
ksp-logging-lib = { path = "../ksp-logging-lib" }
serde = { workspace = true, features = ["derive"] }
serde_json.workspace = true
tauri.workspace = true
tauri-plugin-tracing.workspace = true
chrono = { workspace = true, features = ["std", "now"] }
tokio = { workspace = true, features = ["time"] }
ts-rs.workspace = true
[lints]
workspace = true

View File

@@ -0,0 +1,196 @@
<!-- file: crates/ksp-app-config-desk/README.md -->
<!-- version: 24 -->
# `ksp-app-config-desk`
`ksp-app-config-desk` est l'application desktop spécialisée de management et de validation de la configuration KSP.
La crate est un package Tauri mixte :
```text
package : ksp-app-config-desk
lib : ksp_app_config_desk_lib
bin : ksp-app-config-desk
```
## Responsabilités
L'application reste une couche d'interface/composition. Les responsabilités de parsing, validation, profils, environnement, `.env`, secrets et persistence restent dans `ksp-config-lib`; le runtime Logging restera possédé par `ksp-logging-lib`.
Le gabarit desktop actif fournit maintenant :
- le verrou single-instance ;
- l'assemblage Tauri centralisé dans `src/tauri.rs` ;
- les fenêtres `splash` et `main`, orchestrées par `tw_splash.rs` et `tw_main.rs` ;
- le frontend Vanilla TypeScript + Vite sous `frontend/` ;
- SCSS, Bootstrap et Font Awesome ;
- SimpleBar + `resize-observer-polyfill` pour le scrolling du shell ;
- DataTables 3 + Select 4 pour linventaire interactif des documents Config ;
- le panneau Documents générique alimenté par le registre `ksp-config-lib`, avec diagnostics backend et réparation validée ;
- le panneau Profils générique pour `default_profile`, sélection explicite, vues global/profil/effective sûre et provenance ;
- le panneau Environnement alimenté uniquement par `ConfigManagement`, avec rapport desired/effective sûr, source/shadowing, management atomique `.env` et reveal Secret privilégié/transitoire ;
- le panneau Logging typé, alimenté par `ConfigManagement::load_logging_document()` / `save_logging_document()`, avec brouillon éditable, profils, console, fichiers, filtres, targets et domains sans parsing JSON frontend ;
- lobservabilité du runtime Logging actif : profil, source de sélection, génération, console, compteurs de drop, identité de lancement et prefixes fichiers effectifs ;
- lapplication explicite dun profil Logging persisté sans modifier `default_profile` ni le document source ;
- `tauri-plugin-tracing` côté Rust et `@fltsci/tauri-plugin-tracing` côté frontend ;
- les ports dédiés `1430` pour Vite HTTP et `1431` pour le WebSocket de développement ;
- la destination frontend externe commune à Tauri et Vite;
- le bootstrap Config/Logging réel, un `AppState` durable et le premier contrat Tauri/TS-RS `get_app_snapshot`;
- le bridge de logging frontend KSP `frontend_log.ts`, routé par `emit_frontend_log` vers des targets statiques whitelistés de `ksp-logging-lib` ;
- le lifecycle configurable `splash -> main` avec navigation monofenêtre et instrumentation `debug`/`trace` des interactions frontend.
## Layout frontend
```text
frontend/
├── imgs/
├── sass/
│ ├── _app.scss
│ ├── _bootswatch.scss
│ ├── _fontawesome.scss
│ ├── _simplebar.scss
│ ├── _variables.scss
│ ├── main.scss
│ └── splash.scss
├── ts/
│ ├── documents.ts
│ ├── environment.ts
│ ├── frontend_log.ts
│ ├── invoke.ts
│ ├── logging.ts
│ ├── logging_test.ts
│ ├── main.ts
│ ├── profiles.ts
│ ├── secret_reveal.ts
│ ├── shell_registry.ts
│ └── splash.ts
├── main.html
└── splash.html
```
Les premiers bindings TS-RS sont générés par les tests `ts-rs` sous :
```text
frontend/ts/bindings/ksp_app_config_desk/...
```
Les DTO Rust applicatifs restent la source de vérité et les bindings générés ne sont pas versionnés.
## Robustesse desktop et extensibilité
`pre.018` rend le fallback de bootstrap Logging testable avant installation du subscriber : la résolution produit d'abord un plan `managed` ou `fallback`, puis seulement l'initialisation runtime est tentée. Une source Logging invalide peut ainsi être couverte par test sans installer un subscriber global dans le processus de tests. Le fallback reste transitoire, console-only, niveau `Info`, sans file sink.
Le frontend possède désormais `shell_registry.ts`. Le registre décrit les vues du shell et les adapters d'éditeurs spécialisés par `file_id`. Le panneau Documents reste générique ; lorsqu'un document possède un adapter enregistré, **Ouvrir l'éditeur spécialisé** déclenche la navigation par événement de registre. `cfg.std.logging -> Logging` est le premier adapter. Ajouter un futur éditeur ne demande donc pas de réécrire le moteur Documents ni la logique générale d'activation des panneaux.
Deux audits d'intégration applicatifs complètent les audits Config/Logging existants : contrat Tauri/Vite/package scripts, centralisation des commandes Tauri, absence de `tauri-plugin-log`, interdiction des dialogues navigateur natifs et absence de stockage persistant pour le reveal Secret. Le script frontend `npm run check` exécute uniquement `tsc --noEmit`. Le build Vite de production nest pas lancé séparément : il appartient au `beforeBuildCommand` de `cargo tauri build`, qui reste la dernière validation après tous les contrôles Rust, le type-check frontend et le parcours fonctionnel `tauri dev`.
## Artefacts frontend
Les artefacts frontend construits ne sont pas versionnés. `tauri.conf.json` fixe :
```text
../../../builds/khadhroony-solana-project/ksp-app-config-desk/dist
```
`vite.config.ts` résout cette même destination depuis la racine de la crate, ce qui maintient `dist` hors du workspace source et l'aligne avec la stratégie `.cargo/config.toml` pour les artefacts Rust.
## Ports desktop
`ksp-app-config-desk` utilise :
```text
Vite HTTP : 1430
Vite WS : 1431
```
Le port HTTP est strict. Les futures applications desk utilisent les couples suivants `1432/1433`, `1434/1435`, etc.
## Dépendances frontend
Les bibliothèques consommées par le bundle applicatif sont déclarées sous `dependencies` : `@fltsci/tauri-plugin-tracing`, `@fortawesome/fontawesome-free`, `@tauri-apps/api`, `bootstrap`, `simplebar` et `resize-observer-polyfill`.
Les outils de compilation/développement et déclarations de types appartiennent à `devDependencies`, notamment `@tauri-apps/cli`, `@types/bootstrap`, `@types/node`, `sass-embedded`, `typescript` et `vite`. Les commandes npm directes sont réservées à l'installation ou à la mise à jour de ces dépendances ; le cycle normal de développement et de build est lancé depuis la racine du workspace par Tauri avec la configuration explicite de l'application.
## Logging frontend KSP
Le frontend utilise `frontend/ts/frontend_log.ts`. Les helpers `frontendTrace`, `frontendDebug`, `frontendInfo`, `frontendWarn` et `frontendError`, ainsi que le bridge `console.*`, invoquent la commande Tauri `emit_frontend_log`.
Le payload ne transporte pas de target tracing libre. Il utilise uniquement les identifiants logiques :
```text
frontend -> ksp-app-config-desk.frontend
main -> ksp-app-config-desk.frontend.main
splash -> ksp-app-config-desk.frontend.splash
```
Rust valide le niveau et le `targetId`, choisit un callsite statique puis émet exclusivement via les macros de `ksp-logging-lib`. Le package applicatif n'importe pas directement `tracing`. Le pont actuel garantit donc le trajet WebView -> Rust tout en conservant l'affichage local des appels `console.*` dans la console WebKit. Le retour général Rust -> console WebKit est volontairement hors périmètre de `0.1.4` : une future intégration devra passer par une couche possédée par `ksp-logging-lib`, sans second subscriber, double émission ni boucle avec le bridge KSP. Le panneau **Test Logging** complète désormais ce bridge : il peut émettre des événements backend via `ksp-logging-lib` avec target KSP statique et domain contrôlé, ou réutiliser le bridge frontend existant avec son target/domain fixes.
## Panneau Test Logging
`pre.017` ajoute une surface de validation volontairement explicite. Le champ **Message** est le contenu qui sera réellement journalisé ; il ne doit donc jamais recevoir de Secret. **Log backend** appelle `emit_logging_test`, qui valide le niveau (`trace/debug/info/warn/error/tous`), choisit un target statique (`ksp-app-config-desk` ou `ksp-app-config-desk.logging-test`) et applique un domain absent, connu ou personnalisé borné avant d'émettre exclusivement avec les macros `ksp-logging-lib`. Le résultat retourne uniquement des métadonnées sûres : nombre d'événements, niveau demandé, target/domain effectifs et génération runtime.
**Log via bridge frontend** réutilise le bridge déjà installé. Son contrat reste volontairement fixe : `target=ksp-app-config-desk.frontend.main`, `domain=frontend`. Cela permet de comparer dans le même panneau le routing backend et le chemin WebView -> Rust -> `ksp-logging-lib`, avant et après modification/hot reload des filtres.
## Bootstrap Config et Logging
Au démarrage, l'application construit `ConfigBootstrapOptions`, `ConfigFileRegistry`, `ConfigDocumentEngine` et `ConfigManagement` à partir des arguments complets du processus. En développement, le launcher normalise d'abord le current working directory Rust vers la racine du workspace : `cargo tauri dev -c ...` peut sinon lancer le binaire depuis la crate Tauri, ce qui ferait manquer les chemins Config relatifs `config/`, `config/schemas/` et le `.env` racine. Cette normalisation ne lit ni ne parse aucun fichier Config elle-même ; l'ownership reste intégralement dans `ksp-config-lib`. Elle tente ensuite de résoudre le profil Logging par défaut avec un snapshot `ConfigEnvironment` frais.
Si le document Logging, sa résolution environnementale ou l'initialisation du runtime configuré échoue avant installation du subscriber, Config Desk installe un fallback **transitoire** en mémoire : console stderr, niveau `Info`, aucun fichier, aucun span lifecycle. Ce fallback n'est jamais persisté et son diagnostic frontend est limité à `domain`, `code` et `message`.
Le `LoggingGuard` est conservé dans `AppState` et sert désormais au hot reload transactionnel du runtime Logging après sauvegarde.
## Lifecycle des fenêtres
`tw_splash.rs` valide l'origine de `splash_frontend_ready`, ignore les readiness dupliquées et orchestre le passage à `main`. Les timings sont résolus par `ksp-config-lib` depuis :
```text
KSP_DESK_SPLASH_MINIMUM_MS=1200
KSP_DESK_SPLASH_FADE_MS=300
```
Après la readiness frontend, Rust émet `fade_in`, attend la durée minimale, émet `fade_out`, attend la durée de fade puis `tw_main.rs` affiche/focalise `main` avant destruction du splash. Le backend journalise en `debug` les valeurs résolues, leur provenance (`process`, `dotenv` ou `fallback`), chaque attente réellement observée et la durée totale. Avec `12000/3000`, la durée backend attendue entre readiness et activation de `main` est donc d'environ `15000 ms`. Une valeur splash invalide ne bloque pas le manager : des timings de fallback sûrs restent en mémoire afin que la future surface `.env` puisse permettre la réparation.
Le shell principal expose les cinq routes de référence `Vue d'ensemble`, `Documents`, `Profils`, `Environnement / .env` et `Logging`. Le logo porte déjà l'identité KSP ; le texte du header suit donc la forme `Config Desk — <vue active>` au lieu de répéter `KSP`. Les quelques commandes principales restent des pills/tabs à droite ; un dropdown sera préféré lorsqu'une application possède trop de commandes pour conserver ce format lisible. La route Logging expose maintenant un brouillon typé éditable, sa persistence atomique et lapplication runtime immédiate par hot reload.
## Panneau Profils
La vue **Profils** inspecte les documents validés qui exposent le contrat standard `default_profile` / `profiles`. Elle ne revalide ni ne fusionne les valeurs dans le frontend : le backend utilise `ConfigDocumentEngine::load_resolved_profile()` puis `ResolvedConfigProfile::resolve_effective_environment_detailed()`.
L'inspection expose le profil par défaut ou une sélection explicite, les vues source `globals` et `profile`, ainsi que l'effective après résolution environnementale. Cette dernière est toujours sérialisée depuis `ResolvedConfigJson::safe_value()` afin qu'une substitution Secret soit redacted. La provenance top-level distingue `global` / `profile`; la provenance environnement n'expose que JSON Pointer, nom de variable, source `process`/`.env`/fallback et sensibilité, jamais la valeur résolue.
## Panneau Logging — édition typée et persistence
La vue **Logging** charge le document standard exclusivement avec `ConfigManagement::load_logging_document()`. Rust mappe ensuite les types publics `LoggingConfigDocument`, `LoggingProfileConfig`, `LoggingConsoleConfig`, `LoggingFileConfig`, `LoggingOutputFilterConfig` et `LoggingTargetFilterConfig` vers des DTO TS-RS dédiés. Le frontend ne parse donc ni le JSON source ni son schema.
Le panneau expose `format_version`, `logs_directory`, `default_profile`, tous les profils, la console, les fichiers persistants, les filtres locaux, les target overrides et les listes de targets/domains. Le frontend maintient un brouillon typé : create/clone/rename/delete de profils, 0/1/N file sinks et target filters restent locaux jusqu'à **Sauvegarder et appliquer**. Le backend reconstruit les types publics Config et appelle `ConfigManagement::save_logging_document()`, qui valide la totalité du candidat avant remplacement atomique. Après persistence, Config Desk recharge un `ConfigEnvironment` frais, résout le `default_profile`, puis appelle `ksp_logging_lib::reinitialize()` sur le `LoggingGuard` actif. Le hot reload est immédiat et `logging_generation` avance uniquement après succès. Si l'application runtime échoue, l'ancien runtime reste actif et la source précédente est restaurée. **Recharger le document** ne modifie que le brouillon/source persistée.
`pre.016` distingue en plus le **profil default persistant** du **profil runtime actif**. La section **Runtime actif** expose le profil actuellement appliqué, `selection_source` (`default_profile`, `explicit` ou `fallback`), la génération, l'état console, les compteurs de lignes abandonnées et les file sinks réellement actifs. Un profil déjà persisté peut être appliqué explicitement sans modifier `default_profile` ni écrire le document ; cette action est désactivée tant que le brouillon contient des changements non sauvegardés.
Chaque lancement de Config Desk crée aussi une `LoggingRuntimeIdentity` stable : `application_id` + timestamp UTC de démarrage + PID. `ksp-logging-lib` utilise cette identité pour préfixer les noms des fichiers actifs et la conserve pendant tous les hot reloads du même processus. Deux lancements distincts ne partagent donc plus le même fichier persistant, même avec une rotation `daily`. Avec la configuration de release `info/ksp-info.log`, un prefix effectif peut être `ksp-app-config-desk.20260816-182519.123Z-p4242.ksp-info.log`. Le path Config reste inchangé ; l'identité appartient au runtime, pas au document source.
## Baseline Logging de release
La configuration canonique livrée avec `0.1.4` revient à une baseline opératoire `info` conformément à KSP-APP-031 : console `info`, sink général `info/ksp-info.log` au niveau `info`, et overrides `ksp-config-lib`, `ksp-logging-lib`, `ksp-app-config-desk` à `info`. Le `default_filter` reste `warn` pour les autres targets KSP. Les niveaux `debug`/`trace` restent disponibles et peuvent être remontés temporairement depuis Config Desk lors dun développement ou diagnostic, puis redescendus avant la release suivante.
## Traçabilité frontend
Les actions utilisateur significatives et changements d'état sont journalisés en `debug`; les opérations plus fines (rendu/remplacement DOM, étapes IPC, animations et événements fréquents) en `trace`. Le wrapper `invoke.ts` trace début/fin des commandes sans journaliser leurs arguments, afin de ne pas créer ultérieurement de fuite de valeurs sensibles.
## Police du splash
Le splash utilise `DOS_Amazigh.ttf` (`font-family: Dos Amazigh`) pour son titre, via le `@font-face` déclaré dans `frontend/sass/splash.scss`. Le répertoire `frontend/fonts/` documente la provenance et l'empreinte de l'asset de référence. Le lifecycle du splash est désormais actif et utilise cette police pendant la transition vers la fenêtre principale.
## Panneau Environnement / `.env`
La vue **Environnement / `.env`** expose la projection sûre de `ConfigManagement::environment_report()` puis une zone de management qui appelle exclusivement `ConfigManagement::set_dotenv_value()` et `ConfigManagement::remove_dotenv_value()`. Elle ne lit jamais directement le process environment ni le fichier `.env`. Pour chaque variable KSP/KSPB actuellement présente dans le process ou `.env`, le frontend reçoit uniquement : nom, namespace, sensibilité, valeur desired sûre, valeur effective sûre, source effective et indicateur de shadowing.
Les mutations acceptent un nom existant ou nouveau sous `KSP_*` / `KSPB_*`, présentent explicitement `source_changed`, `effective_changed`, `shadowed_by_process_environment` et `reload_required`, puis rechargent le rapport depuis Config. Une ligne du tableau ne précharge que le nom de variable : aucune ancienne valeur réelle n'est injectée dans le formulaire. Pour un namespace Secret, la nouvelle saisie est masquée. Les boutons **Révéler effective** et **Révéler .env** ouvrent un flux privilégié distinct : confirmation Bootstrap explicite, commande Tauri dédiée, appel exclusif aux méthodes `ConfigManagement::reveal_*`, puis affichage temporaire masqué par défaut. Le backend refuse ce flux pour les namespaces non-Secret.
La suppression demande une confirmation via un modal Bootstrap intégré au shell. Les dialogues navigateur natifs `window.alert` / `window.confirm` / `window.prompt` ne sont pas utilisés pour ce flux.
La valeur révélée n'est jamais stockée dans `AppState`, n'est jamais incluse dans un snapshot/diagnostic/log et est effacée du DOM à la fermeture du modal ou au changement de vue. Aucun `localStorage`/`sessionStorage` n'est utilisé.
Les valeurs `KSP_SECRET_*` / `KSPB_SECRET_*` restent redacted par `ksp-config-lib` dans le rapport. La priorité `process > .env > fallback` reste visible : une mutation `.env` shadowed peut changer le desired sans modifier l'effective. Les logs frontend/backend n'enregistrent jamais la valeur soumise, seulement le nom de variable, l'opération et les booléens du rapport de changement.

View File

@@ -0,0 +1,10 @@
<!-- file: crates/ksp-app-config-desk/TODO.md -->
<!-- version: 19 -->
# TODO `ksp-app-config-desk`
Aucun TODO bloquant ne reste ouvert pour la release `0.1.4`.
La première version fonctionnelle de Config Desk couvre le shell Tauri, le bootstrap Config/Logging, linventaire et la réparation des documents, linspection des profils/provenance, le management `.env`, le reveal Secret privilégié, léditeur Logging typé, la persistence atomique, le hot reload transactionnel, la sélection explicite du profil runtime, les fichiers de logs isolés par lancement, le panneau Test Logging backend/frontend, le registre déditeurs spécialisés et les audits desktop.
Le retour général **Rust -> console WebKit** via une `WebviewLayer` du plugin tracing est volontairement reporté hors `0.1.4`. Le trajet **WebView -> Rust -> ksp-logging-lib** est déjà validé et le panneau Test Logging couvre le routing runtime. Une future intégration Rust -> WebKit devra être conçue dans `ksp-logging-lib` afin de préserver le subscriber unique, déviter les doubles émissions et de ne pas créer de boucle avec le bridge `console.*` existant. Le sujet est conservé dans `docs/IDEAS.md`.

View File

@@ -0,0 +1,336 @@
<!-- file: crates/ksp-app-config-desk/USAGE.md -->
<!-- version: 24 -->
# Utilisation de `ksp-app-config-desk`
## État actuel
Le gabarit Rust/Tauri et le frontend Vite/TypeScript/SCSS sont présents. Le backend initialise Config, le runtime Logging et `AppState`. Les panneaux Documents, Profils et Environnement/`.env` sont fonctionnels, y compris les mutations `.env` create/update/remove et les reveals Secrets privilégiés. Logging expose maintenant un brouillon typé éditable, une persistence atomique, un hot reload sans redémarrage, un profil runtime sélectionnable indépendamment du `default_profile`, une identité de lancement observable pour isoler les fichiers persistants et un panneau de test du routing backend/frontend. Le shell possède aussi un registre d'éditeurs spécialisés par `file_id`, utilisé aujourd'hui pour `cfg.std.logging`.
La fenêtre `splash` est visible au démarrage. Après readiness du frontend et temporisation résolue par Config, l'application effectue le fade-out, affiche/focalise `main` puis détruit `splash`. La fenêtre principale expose immédiatement la navigation monofenêtre de référence.
## Première installation des dépendances frontend
Depuis :
```text
crates/ksp-app-config-desk
```
installer les dépendances déclarées avec la commande de gestion prévue par le projet :
```bash
npm i -D
cd ../../
```
`npm i -D` sans nom de package installe les dépendances déclarées du package ; la classification `dependencies` / `devDependencies` reste celle de `package.json`. Les lockfiles frontend restent ignorés et ne sont pas versionnés. Après cette installation, les commandes Cargo/Tauri sont exécutées depuis la racine du workspace.
## Développement normal
Le frontend n'est pas lancé directement avec npm. KSP est un workspace Rust multi-app : depuis la racine du workspace, la configuration Tauri de l'application doit être sélectionnée explicitement :
```bash
cargo tauri dev -c crates/ksp-app-config-desk/tauri.conf.json
```
Tauri exécute alors le hook :
```text
npm run dev
```
Vite écoute strictement sur :
```text
HTTP : 1430
WS : 1431
```
Si le port HTTP est déjà occupé, le démarrage doit échouer au lieu de sélectionner silencieusement un autre port.
## Build frontend
Le build de l'application passe également par Tauri. Depuis la racine du workspace :
```bash
cargo tauri build -c crates/ksp-app-config-desk/tauri.conf.json
```
`beforeBuildCommand` déclenche :
```text
npm run build
```
Vite construit les pages `main.html` et `splash.html` vers :
```text
../../../builds/khadhroony-solana-project/ksp-app-config-desk/dist
```
Cette destination est résolue depuis la racine de la crate dans `vite.config.ts` et correspond au `frontendDist` de `tauri.conf.json`.
## Tracing desktop
`tauri-plugin-tracing` est enregistré côté Rust et la capability contient `tracing:default`. Le package frontend associé reste disponible comme adaptateur Tauri, sans installer de subscriber concurrent.
Les logs techniques du frontend passent maintenant par `frontend/ts/frontend_log.ts` puis la commande Tauri `emit_frontend_log`. Les identifiants de target autorisés sont :
```text
frontend
main
splash
```
Ils sont convertis côté Rust vers des targets KSP statiques `ksp-app-config-desk.frontend*` et émis uniquement via `ksp-logging-lib`. Un niveau différent de `trace`, `debug`, `info`, `warn` ou `error`, ou un `targetId` non whitelisté, est rejeté avec un `CommandErrorDto` sûr.
`main.ts` et `splash.ts` installent aussi le bridge `console.*`; l'échec éventuel d'un `invoke` est écrit uniquement sur la console WebView originale afin d'éviter une boucle de logging. Ce bridge conserve les messages JavaScript dans la console WebKit et les transmet vers Rust. Le retour général Rust -> console WebKit est reporté hors `0.1.4`; il devra être ajouté ultérieurement sous ownership de `ksp-logging-lib`, sans installer de subscriber Tauri parallèle ni créer de boucle avec ce bridge.
## Bindings TS-RS
Le layout cible est :
```text
frontend/ts/bindings/ksp_app_config_desk/...
```
Les bindings sont générés au premier DTO Tauri réel ; aucune structure factice n'est ajoutée uniquement pour créer le répertoire.
## Bootstrap backend
Les arguments `--cfgpath`, `--schemapath` et `--filemap=...` sont transmis tels quels à `ksp-config-lib`. En build debug, le launcher replace le current working directory Rust à la racine du workspace avant ce bootstrap afin que les defaults relatifs `config/`, `config/schemas/` et `.env` désignent les ressources racine même lorsque Tauri lance `cargo run` depuis la crate de l'application. Le launcher ne lit pas ces ressources lui-même. Le profil Logging initial est le `default_profile` de `std.logging.json`.
Si cette configuration ne peut pas être utilisée, l'application doit rester démarrable pour permettre sa réparation : elle utilise alors un fallback Logging console/stderr en mémoire. Le fallback n'écrit aucun fichier de configuration et n'écrase aucune valeur utilisateur.
La commande Tauri `get_app_snapshot` expose un état sûr du bootstrap. Ses types TypeScript sont générés par `cargo test -p ksp-app-config-desk` sous `frontend/ts/bindings/`.
## Lifecycle et timings du splash
Les variables communes sont :
```text
KSP_DESK_SPLASH_MINIMUM_MS=1200
KSP_DESK_SPLASH_FADE_MS=300
```
La priorité est celle de Config : process > `.env` > fallback. Les valeurs sont lues uniquement via `ksp-config-lib`. Une valeur non numérique ou hors borne déclenche un fallback runtime en mémoire plutôt qu'un refus de démarrer Config Desk.
Le frontend `splash.ts` installe son listener puis appelle `splash_frontend_ready`. Le backend vérifie que l'appel provient réellement de la WebView `splash`; une readiness dupliquée (par exemple après reload Vite) est ignorée. La durée minimale commence à cette readiness : Rust émet le fade-in, attend `minimum`, émet le fade-out, attend `fade`, puis active `main`. Ainsi `KSP_DESK_SPLASH_MINIMUM_MS=12000` et `KSP_DESK_SPLASH_FADE_MS=3000` donnent environ `15000 ms` de lifecycle backend. Des logs `debug` indiquent source des deux valeurs, attentes configurées/réelles et durée totale afin de vérifier ce contrat.
## Navigation principale
La barre principale contient :
```text
Vue d'ensemble
Documents
Profils
Environnement / .env
Logging
```
Les cinq vues sont fonctionnelles. Logging charge le contrat typé `std.logging.json`, permet de modifier son brouillon, de le persister via Config puis de l'appliquer immédiatement au runtime KSP sans redémarrage. Le header affiche `Config Desk — <vue active>` : le logo fournit déjà l'identité KSP. Les commandes principales peu nombreuses utilisent des pills/tabs alignées à droite ; une application plus chargée devra préférer un dropdown. Chaque clic de tab est tracé en `trace`, l'activation utilisateur significative reste tracée en `debug`, et chaque remplacement/rendu de section en `trace`. Les appels Tauri partagés utilisent `frontend/ts/invoke.ts`, qui journalise le début et la fin d'une commande sans journaliser ses arguments.
## Asset font du splash
La police bot3 de référence est `DOS_Amazigh.ttf`. Vérifier l'asset local contre l'empreinte documentée dans `frontend/fonts/README.md` ; `frontend/sass/splash.scss` l'applique au titre du splash.
## Panneau Documents
La vue **Documents** liste les documents de kind `Config` depuis le registre `ksp-config-lib`; les schemas ne sont pas codés en dur dans le frontend. DataTables fournit tri, recherche, pagination et sélection simple.
Pour chaque document, le backend expose :
- `file_id` ;
- filename mappé ;
- schema associé ;
- path résolu par Config ;
- état valide/invalide ;
- étape de diagnostic `read`, `json`, `schema`, `semantic`, `effective` ou `other`.
La sélection charge le source brut via `ConfigManagement::read_source()`, y compris lorsque le document est invalide mais lisible. Le bouton **Valider et sauvegarder** envoie le texte à `ConfigManagement::save_source_candidate()` : aucune écriture frontend directe du fichier nest effectuée. Un candidat invalide est refusé avant persistence et le diagnostic backend reste lautorité de validation.
Les clics, sélections, chargements, remplacements DOM et sauvegardes sont tracés via le bridge frontend KSP sans journaliser le contenu du source.
### Ouvrir un éditeur spécialisé depuis Documents
Le panneau Documents demeure générique. Lorsqu'un `file_id` possède un adapter déclaré dans `shell_registry.ts`, le détail affiche **Ouvrir l'éditeur spécialisé**. Pour `cfg.std.logging`, ce bouton active la vue Logging sans dupliquer la lecture, la validation ou la persistence Config. Un document sans adapter spécialisé reste entièrement inspectable/réparable dans le panneau Documents et n'affiche pas ce bouton.
## Panneau Profils
La vue **Profils** charge l'inventaire des documents Config validés qui possèdent `default_profile` et `profiles`. Pour le document sélectionné :
- **Profil par défaut** appelle le backend sans `profile_id` et doit afficher `source de sélection = default_profile` ;
- changer **Profil inspecté** effectue une sélection explicite et doit afficher `source de sélection = sélection explicite` ;
- **Globals source** et **Profil source** affichent les objets validés correspondants ;
- **Effective sûre** affiche le résultat de résolution environnementale redacted par Config ;
- **Provenance top-level** indique si chaque clé effective vient des globals ou du profil ;
- **Provenance environnement** indique JSON Pointer, variable, source et sensibilité sans valeur réelle.
Avec le `std.logging.json` actuel et `KSP_LOGS_DIRECTORY` défini dans `.env`, la provenance attendue pour `/logs_directory` est `KSP_LOGS_DIRECTORY`, source `dotenv`, sensibilité `Internal`. Le contenu des vues JSON n'est pas recopié dans les logs ; seuls les identifiants de document/profil et métadonnées de résolution sont tracés.
## Panneau Environnement / `.env`
La vue **Environnement / `.env`** charge `get_environment_report`, qui projette exclusivement `ConfigManagement::environment_report()`. La table affiche :
- nom de variable ;
- namespace KSP/KSPB ;
- sensibilité ;
- valeur persisted `.env` sûre (`desired`) ;
- valeur effective process-ou-`.env` sûre ;
- source effective ;
- indicateur `shadowed` lorsqu'une valeur `.env` existe mais que le process hérité gagne.
Une valeur Secret doit apparaître redacted (`********`) dans les colonnes desired/effective. Le bouton **Rafraîchir** relit le rapport Config.
La zone **Management / test .env** accepte une variable existante ou un nouveau nom `KSP_*` / `KSPB_*`. **Créer / Modifier** appelle `ConfigManagement::set_dotenv_value()` et **Supprimer** appelle `ConfigManagement::remove_dotenv_value()` ; aucune écriture `.env` n'est implémentée dans le frontend ou dans la couche Tauri. Après chaque mutation, le rapport est rechargé et les quatre indicateurs Config sont affichés :
```text
source_changed
effective_changed
shadowed_by_process_environment
reload_required
```
Cliquer une ligne du rapport remplit uniquement le nom de variable. L'ancienne valeur n'est jamais préchargée dans le formulaire. Une saisie `KSP_SECRET_*` / `KSPB_SECRET_*` est masquée. Pour une variable Secret sélectionnée, **Révéler effective** et **Révéler .env** deviennent disponibles. Ils ouvrent un modal Bootstrap de confirmation avant toute commande Tauri privilégiée.
Le reveal utilise un DTO et une commande séparés des rapports ordinaires. Après confirmation, la valeur réelle est affichée dans un champ transitoire `password`, masqué par défaut ; **Afficher/Masquer** ne modifie que le contrôle local. Fermer le modal ou quitter la vue Environnement efface immédiatement la valeur du DOM. Le backend refuse le reveal des namespaces Public/Internal et ne journalise que le nom, la source demandée et la présence/absence d'une valeur.
Le bouton **Supprimer** ouvre un modal Bootstrap indiquant la variable ciblée. **Annuler**, la fermeture du modal ou `Esc` abandonnent l'opération ; **Supprimer** confirme ensuite l'appel à `ConfigManagement::remove_dotenv_value()`. Aucun `window.confirm()` natif n'est utilisé.
Pour valider le shadowing, lancer ponctuellement l'application avec `KSP_LOGS_DIRECTORY` fourni par le process puis modifier la même variable dans `.env` depuis le panneau. Le résultat attendu est `source_changed=true`, `effective_changed=false`, `shadowed_by_process_environment=true`, `reload_required=false`. Le process hérité reste prioritaire et n'est jamais modifié par Config Desk.
## Panneau Logging — édition typée
La vue **Logging** appelle `get_logging_document`. Le backend charge le document avec `ConfigManagement::load_logging_document()` et expose :
- `file_id`, path résolu, `format_version`, `logs_directory`, `default_profile` ;
- chaque profil avec `default_filter` et `span_events` ;
- la console (`enabled`, output, ANSI, format, level, targets, domains) ;
- tous les fichiers (`output_id`, path, rotation, format, ANSI et filtre) ;
- tous les `target_filters` globaux.
Le sélecteur **Profil édité** travaille sur un brouillon local. `logs_directory`, `default_profile`, profils, console, file sinks, filtres et target overrides sont modifiables. **Créer**, **Cloner**, **Renommer** et **Supprimer** agissent d'abord sur le brouillon ; la suppression de profil est confirmée par modal Bootstrap. **Sauvegarder et appliquer** envoie un candidat typé à `save_logging_document`, qui reconstruit les contrats Config, persiste atomiquement après validation, recharge un `ConfigEnvironment` frais, résout le `default_profile` et hot-reload le `LoggingGuard`. Si le runtime ne peut pas être préparé/rechargé, l'ancien runtime reste actif et la source précédente est restaurée. **Recharger le document** resynchronise uniquement le brouillon depuis la source persistée et demande confirmation si des changements non sauvegardés existent. Les messages Cargo/Tauri/Vite affichés par `cargo tauri dev` sont externes au runtime Logging KSP et ne dépendent pas de `console.enabled`.
## Test Logging — routing backend et bridge frontend
La section **Test Logging — routing contrôlé** utilise un message explicitement destiné aux logs. Ne jamais y copier une valeur `KSP_SECRET_*` / `KSPB_SECRET_*`.
Pour le backend :
1. choisir `trace`, `debug`, `info`, `warn`, `error` ou `tous` ;
2. choisir `ksp-app-config-desk` ou `ksp-app-config-desk.logging-test` ;
3. choisir un domain `absent`, `connu` (`config.logging_test`, `config.logging_runtime`, `frontend`) ou `personnalisé` ;
4. cliquer **Log backend**.
La commande `emit_logging_test` renvoie le nombre d'événements émis et la génération runtime active, mais ne duplique pas le message dans son DTO de résultat. Le test `tous` émet exactement cinq événements, un par niveau.
**Log via bridge frontend** réutilise le même message et le même niveau, mais garde le contrat du bridge : `target=ksp-app-config-desk.frontend.main`, `domain=frontend`. En mode `tous`, cinq événements sont envoyés. Cette voie permet de vérifier que les changements de console/file sinks/target/domain appliqués par hot reload affectent aussi les événements issus de la WebView.
Scénario conseillé : émettre `tous`, modifier les filtres du profil actif, **Sauvegarder et appliquer**, réémettre exactement le même test et comparer les sinks réellement alimentés sans redémarrer l'application.
## Runtime Logging — profil actif, génération et fichiers par lancement
La section **Runtime actif** du panneau Logging ne décrit pas le brouillon : elle interroge l'état réellement installé dans `ksp-logging-lib`.
Elle expose :
- `active_profile` ;
- `selection_source` : `default_profile`, `explicit` ou `fallback` ;
- `generation` ;
- console active/inactive ;
- compteurs cumulés de lignes abandonnées ;
- `application_id` ;
- `launch_timestamp` ;
- chaque file sink actif avec son directory, son prefix effectif et sa rotation.
Au démarrage de Config Desk, l'identité de lancement est construite une seule fois avec un token de la forme :
```text
20260816-182519.123Z-p4242
```
Avec la baseline de release configurée sur `info/ksp-info.log`, le runtime peut exposer un prefix comme :
```text
ksp-app-config-desk.20260816-182519.123Z-p4242.ksp-info.log
```
Le même `launch_timestamp` doit rester affiché après chaque hot reload du processus courant. Après fermeture puis nouveau lancement de l'application, il doit changer. La rotation `daily` ou `hourly` peut ajouter sa composante de rotation, mais deux lancements ne doivent plus partager le même prefix applicatif.
### Sélection explicite du profil runtime
`default_profile` reste une propriété persistée de `std.logging.json`. Le sélecteur **Profil runtime à appliquer** travaille uniquement avec les profils déjà persistés et ne modifie pas ce champ.
Scénario attendu avec deux profils persistés `local_dev` et `local_test` :
1. garder `default_profile=local_dev` ;
2. sélectionner `local_test` dans **Profil runtime à appliquer** ;
3. cliquer **Appliquer le profil** ;
4. vérifier `active_profile=local_test`, `selection_source=explicit` et `generation + 1` ;
5. cliquer **Recharger le document** : `default_profile` doit toujours être `local_dev` ;
6. sélectionner/appliquer `local_dev` explicitement si souhaité ;
7. **Sauvegarder et appliquer** un document valide : le runtime revient au `default_profile` du document et `selection_source=default_profile`.
Le bouton d'application explicite est désactivé lorsque le brouillon est sale afin qu'un profil affiché mais non persisté ne soit jamais confondu avec un profil réellement chargeable par Config.
Si la résolution ou la préparation du profil explicite échoue, `ksp_logging_lib::reinitialize()` ne remplace pas les layers actifs et la génération ne doit pas avancer. Une sauvegarde de document qui échoue pendant l'application runtime conserve également l'ancien runtime et restaure la source précédente lorsqu'elle avait été modifiée.
## Baseline Logging de release
Après les tests `debug`/`trace`, la configuration canonique `0.1.4` revient à :
```text
default_filter = warn
console.filter.level = info
file.all.info = info/ksp-info.log @ info
ksp-config-lib = info
ksp-logging-lib = info
ksp-app-config-desk = info
```
Pour rouvrir un diagnostic, augmenter temporairement un niveau depuis léditeur Logging, utiliser **Sauvegarder et appliquer**, puis restaurer `info`/`warn` avant la prochaine clôture de release.
## Validation desktop de robustesse
La validation suit un ordre strict. Depuis la racine du workspace, valider dabord Rust :
```bash
cargo fmt --all
cargo check --workspace
cargo clippy --workspace --all-targets
cargo test -p ksp-app-config-desk
cargo test -p ksp-config-lib
cargo test -p ksp-logging-lib
```
Puis effectuer uniquement le type-check frontend depuis `crates/ksp-app-config-desk` :
```bash
npm run check
```
`npm run check` exécute seulement `tsc --noEmit`. Il ne lance pas `vite build` : le build frontend de production est déjà la responsabilité de Tauri via `beforeBuildCommand = npm run build`.
Revenir ensuite à la racine et effectuer le contrôle fonctionnel :
```bash
cargo tauri dev -c crates/ksp-app-config-desk/tauri.conf.json
```
**Seulement lorsque tous les contrôles précédents sont validés**, exécuter en toute dernière opération :
```bash
cargo tauri build -c crates/ksp-app-config-desk/tauri.conf.json
```
Cette dernière commande déclenche elle-même `npm run build`, donc `tsc && vite build`, avant le build/bundling Tauri. Aucun build Vite séparé nest nécessaire dans le cycle de validation.
Un source Logging invalide au démarrage doit sélectionner le fallback transitoire sans persistence automatique. Après réparation du document, un nouveau lancement doit reprendre la configuration managée.

View File

@@ -0,0 +1,12 @@
// file: crates/ksp-app-config-desk/build.rs
// version: 1
//! Build script for the KSP configuration desktop application.
#![forbid(unsafe_code)]
#![deny(unreachable_pub)]
#![warn(missing_docs)]
fn main() {
tauri_build::build()
}

View File

@@ -0,0 +1,13 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Default capability for KSP Config Desk",
"windows": [
"splash",
"main"
],
"permissions": [
"core:default",
"tracing:default"
]
}

View File

@@ -0,0 +1,31 @@
<!-- file: crates/ksp-app-config-desk/frontend/fonts/README.md -->
<!-- version: 2 -->
# Fonts du splash
Le gabarit de splash repris de `khadhroony-bot3` utilise la police locale suivante :
```text
DOS_Amazigh.ttf
font-family: Dos Amazigh
```
Source de référence auditée :
```text
kb-app-demo-desktop/frontend/fonts/DOS_Amazigh.ttf
```
Destination KSP attendue :
```text
crates/ksp-app-config-desk/frontend/fonts/DOS_Amazigh.ttf
```
Empreinte SHA-256 de l'asset bot3 audité :
```text
a01ff4b5a0d699db7cefcf5336bf0e379ff6fbf0d096087571a13c8782f9aa02
```
Le fichier est un asset tiers/historique du gabarit et doit être repris depuis la copie bot3 possédée par le projet. `splash.scss` déclare désormais explicitement `Dos Amazigh` avec `@font-face` et l'applique au titre `#app-name`. Le lifecycle visuel complet reste traité dans la tranche splash dédiée.

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 611 KiB

View File

@@ -0,0 +1,651 @@
<!-- file: crates/ksp-app-config-desk/frontend/main.html -->
<!-- version: 16 -->
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta content="width=device-width, initial-scale=1.0" name="viewport">
<link rel="icon" href="imgs/logo.png">
<link rel="stylesheet" href="sass/main.scss">
<title>Config Desk — Vue d'ensemble</title>
</head>
<body>
<header class="app-header">
<nav class="navbar h-100 py-0 bg-light text-dark">
<div class="container-fluid px-4 flex-nowrap">
<div class="navbar-brand d-flex align-items-center me-4 flex-nowrap">
<img alt="KSP" src="imgs/logo.png" class="app-logo">
<span class="ps-2 fs-4 fw-semibold text-primary text-nowrap">Config Desk</span>
<span class="mx-2 fs-5 text-body-secondary" aria-hidden="true"></span>
<span id="headerViewTitle" class="fs-5 text-body text-nowrap">Vue d'ensemble</span>
</div>
<div class="app-nav nav nav-pills flex-nowrap gap-1 ms-auto" aria-label="Navigation Config Desk">
<button class="nav-link app-nav-button active" type="button" data-view="overview">Vue d'ensemble</button>
<button class="nav-link app-nav-button" type="button" data-view="documents">Documents</button>
<button class="nav-link app-nav-button" type="button" data-view="profiles">Profils</button>
<button class="nav-link app-nav-button" type="button" data-view="environment">Environnement / .env</button>
<button class="nav-link app-nav-button" type="button" data-view="logging">Logging</button>
</div>
</div>
</nav>
</header>
<main class="app-main">
<div class="app-scrollable" data-simplebar>
<div class="container-fluid px-4 py-4">
<div class="card app-shell-card mx-auto shadow-sm border-0">
<div class="card-body p-4">
<h1 id="viewTitle" class="h3 mb-2">Vue d'ensemble</h1>
<p id="viewDescription" class="text-body-secondary mb-4">État sûr du bootstrap Config Desk et du runtime Logging actuellement actif.</p>
<section id="overviewPanel" aria-label="Vue d'ensemble">
<dl class="row mb-4 app-overview-list">
<dt class="col-sm-5">Version application</dt>
<dd id="overviewVersion" class="col-sm-7 text-sm-end"></dd>
<dt class="col-sm-5">Descripteurs Config enregistrés</dt>
<dd id="overviewDocuments" class="col-sm-7 text-sm-end"></dd>
<dt class="col-sm-5">Profil Logging actif</dt>
<dd id="overviewLoggingProfile" class="col-sm-7 text-sm-end"></dd>
<dt class="col-sm-5">Génération Logging</dt>
<dd id="overviewLoggingGeneration" class="col-sm-7 text-sm-end"></dd>
<dt class="col-sm-5 mb-0">Fallback Logging actif</dt>
<dd id="overviewLoggingFallback" class="col-sm-7 text-sm-end mb-0"></dd>
</dl>
</section>
<section id="documentsPanel" hidden aria-label="Documents Config">
<div class="d-flex flex-wrap gap-2 align-items-center justify-content-between mb-3">
<div>
<h2 class="h5 mb-1">Documents enregistrés</h2>
<p class="text-body-secondary small mb-0">Le registre, la validation et la persistence restent exclusivement sous contrôle de ksp-config-lib.</p>
</div>
<button id="refreshDocuments" class="btn btn-outline-primary btn-sm" type="button">
<i class="fa-solid fa-rotate me-1" aria-hidden="true"></i>
Rafraîchir
</button>
</div>
<div class="table-responsive mb-4">
<table id="configDocumentsTable" class="table table-striped table-hover align-middle w-100">
<thead>
<tr>
<th scope="col">file_id</th>
<th scope="col">Fichier</th>
<th scope="col">Schema</th>
<th scope="col">État</th>
<th scope="col">Étape</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
<section id="documentDetailPanel" hidden class="border rounded p-3 mb-4 bg-body-tertiary" aria-label="Détail du document Config">
<div class="d-flex flex-wrap gap-2 justify-content-between align-items-start mb-3">
<div>
<h2 class="h5 mb-1">Détail du document</h2>
<span id="documentDetailStatus" class="badge text-bg-secondary"></span>
</div>
<div class="d-flex gap-2">
<button id="openSpecializedEditor" class="btn btn-outline-primary btn-sm" type="button" hidden>Ouvrir l'éditeur spécialisé</button>
<button id="reloadDocumentSource" class="btn btn-outline-secondary btn-sm" type="button">Recharger</button>
<button id="saveDocumentSource" class="btn btn-primary btn-sm" type="button">Valider et sauvegarder</button>
</div>
</div>
<dl class="row small mb-3 app-document-metadata">
<dt class="col-lg-3">file_id</dt>
<dd id="documentDetailFileId" class="col-lg-9"></dd>
<dt class="col-lg-3">Fichier mappé</dt>
<dd id="documentDetailFilename" class="col-lg-9"></dd>
<dt class="col-lg-3">Schema</dt>
<dd id="documentDetailSchema" class="col-lg-9"></dd>
<dt class="col-lg-3">Path résolu</dt>
<dd id="documentDetailPath" class="col-lg-9"></dd>
<dt class="col-lg-3">Étape diagnostic</dt>
<dd id="documentDetailStage" class="col-lg-9"></dd>
<dt class="col-lg-3 mb-0">Diagnostic</dt>
<dd id="documentDetailDiagnostic" class="col-lg-9 mb-0"></dd>
</dl>
<label class="form-label fw-semibold" for="documentSourceEditor">Source brut Config</label>
<textarea id="documentSourceEditor" class="form-control app-document-source" rows="18" spellcheck="false" autocomplete="off"></textarea>
<div class="form-text">La sauvegarde est refusée tant que le candidat échoue au parsing JSON, au schema ou aux invariants sémantiques backend.</div>
</section>
<div id="documentsStatus" class="alert alert-primary mb-0" role="status" aria-live="polite">Chargement de l'inventaire Config...</div>
</section>
<section id="profilesPanel" hidden aria-label="Profils Config">
<div class="d-flex flex-wrap gap-3 align-items-end justify-content-between mb-3">
<div class="flex-grow-1">
<h2 class="h5 mb-2">Inspection des profils</h2>
<p class="text-body-secondary small mb-3">La sélection, la fusion global/profil et la résolution environnementale restent sous contrôle de ksp-config-lib.</p>
<div class="row g-2">
<div class="col-xl-6">
<label class="form-label" for="profileDocumentSelect">Document Config</label>
<select id="profileDocumentSelect" class="form-select" disabled></select>
</div>
<div class="col-xl-6">
<label class="form-label" for="profileIdSelect">Profil inspecté</label>
<select id="profileIdSelect" class="form-select" disabled></select>
</div>
</div>
</div>
<div class="d-flex flex-wrap gap-2">
<button id="inspectDefaultProfile" class="btn btn-outline-secondary btn-sm" type="button">Profil par défaut</button>
<button id="reloadProfileDetail" class="btn btn-outline-secondary btn-sm" type="button">Recharger</button>
<button id="refreshProfiles" class="btn btn-outline-primary btn-sm" type="button">
<i class="fa-solid fa-rotate me-1" aria-hidden="true"></i>
Rafraîchir
</button>
</div>
</div>
<section id="profileDetailPanel" hidden class="border rounded p-3 mb-4 bg-body-tertiary" aria-label="Détail du profil Config">
<dl class="row small mb-4 app-profile-metadata">
<dt class="col-lg-3">file_id</dt>
<dd id="profileDetailFileId" class="col-lg-9"></dd>
<dt class="col-lg-3">Path résolu</dt>
<dd id="profileDetailPath" class="col-lg-9"></dd>
<dt class="col-lg-3">default_profile</dt>
<dd id="profileDetailDefault" class="col-lg-9"></dd>
<dt class="col-lg-3">Profil sélectionné</dt>
<dd id="profileDetailSelected" class="col-lg-9"></dd>
<dt class="col-lg-3">Source de sélection</dt>
<dd id="profileDetailSelectionSource" class="col-lg-9"></dd>
<dt class="col-lg-3 mb-0">Sensibilité effective</dt>
<dd id="profileDetailSensitivity" class="col-lg-9 mb-0"></dd>
</dl>
<div class="row g-3 mb-4">
<div class="col-xxl-4">
<h3 class="h6">Globals source</h3>
<pre id="profileGlobalsJson" class="app-profile-json mb-0"></pre>
</div>
<div class="col-xxl-4">
<h3 class="h6">Profil source</h3>
<pre id="profileSourceJson" class="app-profile-json mb-0"></pre>
</div>
<div class="col-xxl-4">
<h3 class="h6">Effective sûre</h3>
<pre id="profileEffectiveJson" class="app-profile-json mb-0"></pre>
</div>
</div>
<div class="row g-3">
<div class="col-xl-5">
<h3 class="h6">Provenance top-level</h3>
<div class="table-responsive">
<table id="profileOriginsTable" class="table table-sm table-striped align-middle mb-0">
<thead>
<tr><th scope="col">Clé</th><th scope="col">Origine</th></tr>
</thead>
<tbody></tbody>
</table>
</div>
</div>
<div class="col-xl-7">
<h3 class="h6">Provenance environnement</h3>
<div class="table-responsive">
<table id="profileEnvironmentProvenanceTable" class="table table-sm table-striped align-middle mb-0">
<thead>
<tr><th scope="col">JSON Pointer</th><th scope="col">Variable</th><th scope="col">Source</th><th scope="col">Sensibilité</th></tr>
</thead>
<tbody></tbody>
</table>
</div>
</div>
</div>
<div class="form-text mt-3">L'effective affichée utilise exclusivement la représentation sûre/redacted de Config ; aucune valeur réelle Secret n'est projetée.</div>
</section>
<div id="profilesStatus" class="alert alert-primary mb-0" role="status" aria-live="polite">Chargement des profils Config...</div>
</section>
<section id="environmentPanel" hidden aria-label="Environnement Config">
<div class="d-flex flex-wrap gap-2 align-items-center justify-content-between mb-3">
<div>
<h2 class="h5 mb-1">Rapport Environnement / .env</h2>
<p class="text-body-secondary small mb-0">Valeurs desired/effective sûres, provenance externe, shadowing et mutations persistées exclusivement par ksp-config-lib.</p>
</div>
<button id="refreshEnvironment" class="btn btn-outline-primary btn-sm" type="button">
<i class="fa-solid fa-rotate me-1" aria-hidden="true"></i>
Rafraîchir
</button>
</div>
<div class="alert alert-light border small" role="note">
Priorité effective : <code>process &gt; .env &gt; fallback</code>. Une mutation ne modifie jamais l'environnement process déjà hérité. Les valeurs Secret restent redacted dans le rapport.
</div>
<div class="table-responsive mb-4">
<table id="environmentReportTable" class="table table-striped table-hover align-middle w-100">
<thead>
<tr>
<th scope="col">Variable</th>
<th scope="col">Namespace</th>
<th scope="col">Sensibilité</th>
<th scope="col">Desired .env (safe)</th>
<th scope="col">Effective (safe)</th>
<th scope="col">Source</th>
<th scope="col">Shadowed</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
<div id="environmentStatus" class="alert alert-primary mb-4" role="status" aria-live="polite">Chargement du rapport environnement...</div>
<section class="border rounded p-3 bg-body-tertiary" aria-label="Management .env">
<div class="d-flex flex-wrap gap-2 align-items-center justify-content-between mb-3">
<div>
<h2 class="h5 mb-1">Management / test .env</h2>
<p class="text-body-secondary small mb-0">Cliquer une ligne du rapport sélectionne son nom uniquement. Aucune ancienne valeur réelle n'est préchargée dans le formulaire.</p>
</div>
<button id="clearEnvironmentEditor" class="btn btn-outline-secondary btn-sm" type="button">Vider le formulaire</button>
</div>
<div class="row g-3 align-items-end mb-3">
<div class="col-xl-5">
<label class="form-label" for="environmentVariableName">Variable KSP/KSPB</label>
<input id="environmentVariableName" class="form-control font-monospace" type="text" list="environmentVariableSuggestions" autocomplete="off" placeholder="KSP_... ou KSPB_...">
<datalist id="environmentVariableSuggestions"></datalist>
</div>
<div class="col-xl-5">
<label class="form-label" for="environmentVariableValue">Nouvelle valeur</label>
<input id="environmentVariableValue" class="form-control font-monospace" type="text" autocomplete="new-password" spellcheck="false">
<div id="environmentEditorSensitivity" class="form-text">La valeur saisie n'est jamais préchargée depuis .env.</div>
</div>
<div class="col-xl-2 d-grid gap-2">
<button id="setEnvironmentValue" class="btn btn-primary btn-sm" type="button">Créer / Modifier</button>
<button id="removeEnvironmentValue" class="btn btn-outline-danger btn-sm" type="button">Supprimer</button>
</div>
</div>
<div class="d-flex flex-wrap gap-2 align-items-center mb-3">
<span class="small text-body-secondary me-1">Reveal Secret privilégié :</span>
<button id="revealEnvironmentEffective" class="btn btn-outline-warning btn-sm" type="button" disabled>Révéler effective</button>
<button id="revealEnvironmentDotenv" class="btn btn-outline-warning btn-sm" type="button" disabled>Révéler .env</button>
<span class="form-text mb-0">Disponible uniquement pour KSP_SECRET_* / KSPB_SECRET_* et jamais préchargé automatiquement.</span>
</div>
<div id="environmentMutationStatus" class="alert alert-secondary mb-3" role="status" aria-live="polite">
Aucune mutation effectuée pendant ce lancement.
</div>
<div id="environmentMutationResult" hidden>
<h3 class="h6">Dernier résultat ConfigEnvironmentChangeReport</h3>
<dl class="row small mb-0 app-profile-metadata">
<dt class="col-md-4">Opération</dt>
<dd id="environmentMutationOperation" class="col-md-8"></dd>
<dt class="col-md-4">Variable</dt>
<dd id="environmentMutationVariable" class="col-md-8 font-monospace"></dd>
<dt class="col-md-4">source_changed</dt>
<dd id="environmentMutationSourceChanged" class="col-md-8 font-monospace"></dd>
<dt class="col-md-4">effective_changed</dt>
<dd id="environmentMutationEffectiveChanged" class="col-md-8 font-monospace"></dd>
<dt class="col-md-4">shadowed_by_process</dt>
<dd id="environmentMutationShadowed" class="col-md-8 font-monospace"></dd>
<dt class="col-md-4 mb-0">reload_required</dt>
<dd id="environmentMutationReload" class="col-md-8 mb-0 font-monospace"></dd>
</dl>
<div class="form-text mt-2">Le rapport est rechargé après chaque mutation. Un consumer qui conserve son propre snapshot doit respecter <code>reload_required</code>.</div>
</div>
</section>
</section>
<section id="loggingPanel" hidden aria-label="Logging Config">
<div class="d-flex flex-wrap gap-2 align-items-center justify-content-between mb-3">
<div>
<h2 class="h5 mb-1">Logging editor — mutations/persistence/hot reload</h2>
<p class="text-body-secondary small mb-0">Brouillon typé de <code>std.logging.json</code>, validé et persisté atomiquement par <code>ksp-config-lib</code>. <strong>Sauvegarder et appliquer</strong> hot-reload le runtime KSP sans redémarrage ; <strong>Recharger le document</strong> ne touche qu'au brouillon.</p>
</div>
<div class="d-flex gap-2">
<button id="refreshLoggingDocument" class="btn btn-outline-primary btn-sm" type="button">
<i class="fa-solid fa-rotate me-1" aria-hidden="true"></i>
Recharger le document
</button>
<button id="saveLoggingDocument" class="btn btn-primary btn-sm" type="button" disabled>
<i class="fa-solid fa-floppy-disk me-1" aria-hidden="true"></i>
Sauvegarder et appliquer
</button>
</div>
</div>
<dl class="row small mb-3 app-profile-metadata">
<dt class="col-lg-3">file_id</dt>
<dd id="loggingFileId" class="col-lg-9 font-monospace"></dd>
<dt class="col-lg-3">Path résolu</dt>
<dd id="loggingPath" class="col-lg-9 font-monospace text-break"></dd>
<dt class="col-lg-3 mb-0">format_version</dt>
<dd id="loggingFormatVersion" class="col-lg-9 mb-0 font-monospace"></dd>
</dl>
<section class="border rounded p-3 mb-4 bg-body-tertiary" aria-label="Runtime Logging actif">
<div class="d-flex flex-wrap gap-2 align-items-start justify-content-between mb-3">
<div>
<h3 class="h6 mb-1">Runtime actif</h3>
<p class="text-body-secondary small mb-0">Le profil runtime peut être appliqué explicitement sans modifier <code>default_profile</code>. L'identité de lancement reste stable pendant tous les hot reloads du processus.</p>
</div>
<button id="refreshLoggingRuntime" class="btn btn-outline-secondary btn-sm" type="button">Rafraîchir runtime</button>
</div>
<dl class="row small mb-3 app-profile-metadata">
<dt class="col-xl-3">Profil actif</dt><dd id="loggingRuntimeActiveProfile" class="col-xl-3 font-monospace"></dd>
<dt class="col-xl-3">Source sélection</dt><dd id="loggingRuntimeSelectionSource" class="col-xl-3 font-monospace"></dd>
<dt class="col-xl-3">Génération</dt><dd id="loggingRuntimeGeneration" class="col-xl-3 font-monospace"></dd>
<dt class="col-xl-3">Console</dt><dd id="loggingRuntimeConsole" class="col-xl-3"></dd>
<dt class="col-xl-3">Fallback</dt><dd id="loggingRuntimeFallback" class="col-xl-3"></dd>
<dt class="col-xl-3">Dropped lines</dt><dd id="loggingRuntimeDropped" class="col-xl-3 font-monospace"></dd>
<dt class="col-xl-3">Application</dt><dd id="loggingRuntimeApplicationId" class="col-xl-3 font-monospace"></dd>
<dt class="col-xl-3">Launch timestamp</dt><dd id="loggingRuntimeLaunchTimestamp" class="col-xl-3 font-monospace"></dd>
</dl>
<div class="row g-2 align-items-end mb-3">
<div class="col-xl-8">
<label class="form-label" for="loggingRuntimeProfileSelect">Profil persisté à appliquer explicitement</label>
<select id="loggingRuntimeProfileSelect" class="form-select" disabled></select>
<div class="form-text">Disponible uniquement lorsque le brouillon est synchronisé avec la source persistée.</div>
</div>
<div class="col-xl-4">
<button id="applyLoggingRuntimeProfile" class="btn btn-outline-primary w-100" type="button" disabled>Appliquer ce profil au runtime</button>
</div>
</div>
<div class="table-responsive">
<table id="loggingRuntimeFilesTable" class="table table-sm align-middle mb-0 w-100">
<thead><tr><th>output_id</th><th>Répertoire</th><th>Préfixe fichier effectif</th><th>Rotation</th></tr></thead>
<tbody></tbody>
</table>
</div>
</section>
<section class="border rounded p-3 mb-4" aria-label="Test Logging">
<div class="d-flex flex-wrap gap-2 align-items-start justify-content-between mb-3">
<div>
<h3 class="h6 mb-1">Test Logging — routing contrôlé</h3>
<p class="text-body-secondary small mb-0">Émet des événements volontairement journalisés pour vérifier niveaux, targets, domains, sinks et hot reload. Ne colle jamais de Secret dans le message de test.</p>
</div>
</div>
<div class="row g-3 mb-3">
<div class="col-xl-6">
<label class="form-label" for="loggingTestMessage">Message</label>
<input id="loggingTestMessage" class="form-control" type="text" maxlength="500" autocomplete="off" value="Config Desk Logging routing test">
</div>
<div class="col-md-3 col-xl-2">
<label class="form-label" for="loggingTestLevel">Niveau</label>
<select id="loggingTestLevel" class="form-select">
<option value="trace">trace</option><option value="debug">debug</option><option value="info" selected>info</option><option value="warn">warn</option><option value="error">error</option><option value="all">tous</option>
</select>
</div>
<div class="col-md-5 col-xl-4">
<label class="form-label" for="loggingTestTarget">Target backend contrôlé</label>
<select id="loggingTestTarget" class="form-select font-monospace">
<option value="app">ksp-app-config-desk</option>
<option value="logging_test" selected>ksp-app-config-desk.logging-test</option>
</select>
</div>
<div class="col-md-3">
<label class="form-label" for="loggingTestDomainMode">Domain</label>
<select id="loggingTestDomainMode" class="form-select">
<option value="absent">absent</option>
<option value="known" selected>connu</option>
<option value="custom">personnalisé</option>
</select>
</div>
<div class="col-md-4">
<label class="form-label" for="loggingTestKnownDomain">Domain connu</label>
<select id="loggingTestKnownDomain" class="form-select font-monospace">
<option value="config.logging_test">config.logging_test</option>
<option value="config.logging_runtime">config.logging_runtime</option>
<option value="frontend">frontend</option>
</select>
</div>
<div class="col-md-5">
<label class="form-label" for="loggingTestCustomDomain">Domain personnalisé</label>
<input id="loggingTestCustomDomain" class="form-control font-monospace" type="text" maxlength="80" autocomplete="off" placeholder="custom.routing" disabled>
</div>
</div>
<div class="d-flex flex-wrap gap-2 mb-3">
<button id="emitLoggingTestBackend" class="btn btn-outline-primary" type="button">
<i class="fa-solid fa-terminal me-1" aria-hidden="true"></i>
Log backend
</button>
<button id="emitLoggingTestFrontend" class="btn btn-outline-secondary" type="button">
<i class="fa-solid fa-code me-1" aria-hidden="true"></i>
Log via bridge frontend
</button>
</div>
<div class="form-text mb-3">Le test backend respecte le target/domain choisis. Le bridge frontend conserve volontairement son contrat fixe <code>target=ksp-app-config-desk.frontend.main</code>, <code>domain=frontend</code> et réutilise uniquement le message/niveau.</div>
<div id="loggingTestStatus" class="alert alert-secondary mb-0" role="status" aria-live="polite">Aucun événement de test émis pendant ce lancement.</div>
</section>
<section class="border rounded p-3 mb-4 bg-body-tertiary" aria-label="Globals Logging">
<h3 class="h6 mb-3">Globals</h3>
<div class="row g-3">
<div class="col-xl-7">
<label class="form-label" for="loggingLogsDirectoryInput">logs_directory</label>
<input id="loggingLogsDirectoryInput" class="form-control font-monospace" type="text" autocomplete="off" disabled>
</div>
<div class="col-xl-5">
<label class="form-label" for="loggingDefaultProfileSelect">default_profile</label>
<select id="loggingDefaultProfileSelect" class="form-select" disabled></select>
</div>
</div>
</section>
<section class="border rounded p-3 mb-4" aria-label="Gestion des profils Logging">
<div class="row g-3 align-items-end">
<div class="col-xl-4">
<label class="form-label" for="loggingProfileSelect">Profil édité</label>
<select id="loggingProfileSelect" class="form-select" disabled></select>
</div>
<div class="col-xl-4">
<label class="form-label" for="loggingProfileIdInput">ID cible create/clone/rename</label>
<input id="loggingProfileIdInput" class="form-control font-monospace" type="text" autocomplete="off" placeholder="local_test" disabled>
</div>
<div class="col-xl-4">
<div class="d-flex flex-wrap gap-2">
<button id="createLoggingProfile" class="btn btn-outline-primary btn-sm" type="button" disabled>Créer</button>
<button id="cloneLoggingProfile" class="btn btn-outline-primary btn-sm" type="button" disabled>Cloner</button>
<button id="renameLoggingProfile" class="btn btn-outline-secondary btn-sm" type="button" disabled>Renommer</button>
<button id="deleteLoggingProfile" class="btn btn-outline-danger btn-sm" type="button" disabled>Supprimer</button>
</div>
</div>
</div>
</section>
<div class="row g-3 mb-4">
<div class="col-xl-6">
<label class="form-label" for="loggingProfileDefaultFilter">default_filter</label>
<select id="loggingProfileDefaultFilter" class="form-select" disabled>
<option value="off">off</option><option value="error">error</option><option value="warn">warn</option><option value="info">info</option><option value="debug">debug</option><option value="trace">trace</option>
</select>
</div>
<div class="col-xl-6">
<label class="form-label" for="loggingProfileSpanEvents">span_events</label>
<select id="loggingProfileSpanEvents" class="form-select" disabled>
<option value="off">off</option><option value="new_and_close">new_and_close</option><option value="full">full</option>
</select>
</div>
</div>
<section class="border rounded p-3 mb-4 bg-body-tertiary" aria-label="Console Logging">
<h3 class="h6 mb-3">Console</h3>
<div class="row g-3">
<div class="col-md-2 form-check ms-2">
<input id="loggingConsoleEnabled" class="form-check-input" type="checkbox" disabled>
<label class="form-check-label" for="loggingConsoleEnabled">Enabled</label>
</div>
<div class="col-md-2 form-check ms-2">
<input id="loggingConsoleAnsi" class="form-check-input" type="checkbox" disabled>
<label class="form-check-label" for="loggingConsoleAnsi">ANSI</label>
</div>
<div class="col-md-3">
<label class="form-label" for="loggingConsoleOutput">Output</label>
<select id="loggingConsoleOutput" class="form-select" disabled><option value="stdout">stdout</option><option value="stderr">stderr</option></select>
</div>
<div class="col-md-4">
<label class="form-label" for="loggingConsoleFormat">Format</label>
<select id="loggingConsoleFormat" class="form-select" disabled><option value="human">human</option><option value="compact">compact</option><option value="pretty">pretty</option><option value="json">json</option></select>
</div>
<div class="col-md-3">
<label class="form-label" for="loggingConsoleLevel">Level</label>
<select id="loggingConsoleLevel" class="form-select" disabled><option value="off">off</option><option value="error">error</option><option value="warn">warn</option><option value="info">info</option><option value="debug">debug</option><option value="trace">trace</option></select>
</div>
<div class="col-md-4">
<label class="form-label" for="loggingConsoleTargets">Targets</label>
<input id="loggingConsoleTargets" class="form-control font-monospace" type="text" autocomplete="off" disabled>
</div>
<div class="col-md-5">
<label class="form-label" for="loggingConsoleDomains">Domains</label>
<input id="loggingConsoleDomains" class="form-control font-monospace" type="text" autocomplete="off" disabled>
</div>
</div>
</section>
<section class="mb-4" aria-label="Fichiers Logging">
<div class="d-flex align-items-center justify-content-between gap-2 mb-2">
<div>
<h3 class="h6 mb-0">Fichiers</h3>
<div class="form-text">0, 1 ou plusieurs sinks persistants. Les suppressions restent dans le brouillon jusqu'à Sauvegarder et appliquer.</div>
</div>
<button id="addLoggingFile" class="btn btn-outline-primary btn-sm" type="button" disabled>Ajouter un fichier</button>
</div>
<div class="table-responsive">
<table id="loggingFilesTable" class="table table-sm align-middle w-100">
<thead><tr><th>output_id</th><th>On</th><th>Path</th><th>Rotation</th><th>Format</th><th>Level</th><th>Targets</th><th>Domains</th><th></th></tr></thead>
<tbody></tbody>
</table>
</div>
</section>
<section class="mb-4" aria-label="Target filters Logging">
<div class="d-flex align-items-center justify-content-between gap-2 mb-2">
<h3 class="h6 mb-0">Target filters globaux</h3>
<button id="addLoggingTargetFilter" class="btn btn-outline-primary btn-sm" type="button" disabled>Ajouter un target filter</button>
</div>
<div class="table-responsive">
<table id="loggingTargetFiltersTable" class="table table-sm align-middle w-100">
<thead><tr><th>target_prefix</th><th>Level</th><th></th></tr></thead>
<tbody></tbody>
</table>
</div>
</section>
<div class="alert alert-warning small py-2 mb-2" role="note">
<strong>Runtime actif :</strong> <strong>Sauvegarder et appliquer</strong> persiste le candidat puis applique son <code>default_profile</code>. <strong>Appliquer ce profil au runtime</strong> sélectionne un profil persisté sans modifier le document. En cas d'échec, l'ancien runtime et sa génération restent actifs. <strong>Recharger le document</strong> resynchronise uniquement le brouillon.
</div>
<div id="loggingSaveReport" class="small text-body-secondary mb-2">Aucune sauvegarde/application effectuée pendant ce lancement.</div>
<div id="loggingStatus" class="alert alert-primary mb-0" role="status" aria-live="polite">Chargement du document Logging typé...</div>
</section>
<div class="alert alert-primary mb-0" role="status">
<i class="fa-solid fa-desktop me-2" aria-hidden="true"></i>
<span id="shellStatus" class="app-shell-status">Initialisation du frontend...</span>
</div>
</div>
</div>
</div>
</div>
</main>
<div id="loggingDiscardDraftModal" class="modal fade" tabindex="-1" aria-labelledby="loggingDiscardDraftTitle" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h2 id="loggingDiscardDraftTitle" class="modal-title fs-5">Abandonner le brouillon Logging</h2>
<button class="btn-close" type="button" data-bs-dismiss="modal" aria-label="Annuler"></button>
</div>
<div class="modal-body">
<p class="mb-0">Des modifications non sauvegardées existent. Recharger le document depuis la source persistée les abandonnera. Cette action ne recharge pas le runtime Logging.</p>
</div>
<div class="modal-footer">
<button class="btn btn-outline-secondary" type="button" data-bs-dismiss="modal">Annuler</button>
<button id="loggingDiscardDraftConfirm" class="btn btn-danger" type="button">Abandonner et recharger le document</button>
</div>
</div>
</div>
</div>
<div id="loggingProfileDeleteModal" class="modal fade" tabindex="-1" aria-labelledby="loggingProfileDeleteTitle" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h2 id="loggingProfileDeleteTitle" class="modal-title fs-5">Supprimer le profil du brouillon</h2>
<button class="btn-close" type="button" data-bs-dismiss="modal" aria-label="Annuler"></button>
</div>
<div class="modal-body">
<p class="mb-2">Retirer le profil <code id="loggingProfileDeleteName" class="font-monospace"></code> du brouillon Logging ?</p>
<p class="text-body-secondary small mb-0">Le fichier n'est pas modifié tant que Sauvegarder et appliquer n'est pas déclenché.</p>
</div>
<div class="modal-footer">
<button class="btn btn-outline-secondary" type="button" data-bs-dismiss="modal">Annuler</button>
<button id="loggingProfileDeleteConfirm" class="btn btn-danger" type="button">Retirer du brouillon</button>
</div>
</div>
</div>
</div>
<div id="environmentRemoveConfirmModal" class="modal fade" tabindex="-1" aria-labelledby="environmentRemoveConfirmTitle" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h2 id="environmentRemoveConfirmTitle" class="modal-title fs-5">Confirmer la suppression .env</h2>
<button class="btn-close" type="button" data-bs-dismiss="modal" aria-label="Annuler"></button>
</div>
<div class="modal-body">
<p class="mb-2">Supprimer l'entrée <code id="environmentRemoveConfirmVariable" class="font-monospace"></code> de <code>.env</code> ?</p>
<p class="text-body-secondary small mb-0">L'environnement process déjà hérité ne sera pas modifié.</p>
</div>
<div class="modal-footer">
<button class="btn btn-outline-secondary" type="button" data-bs-dismiss="modal">Annuler</button>
<button id="environmentRemoveConfirmAction" class="btn btn-danger" type="button">Supprimer</button>
</div>
</div>
</div>
</div>
<div id="secretRevealModal" class="modal fade" tabindex="-1" aria-labelledby="secretRevealModalLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h2 id="secretRevealModalLabel" class="modal-title fs-5">Reveal Secret privilégié</h2>
<button class="btn-close" type="button" data-bs-dismiss="modal" aria-label="Fermer"></button>
</div>
<div class="modal-body">
<div id="secretRevealConfirmPanel">
<p class="mb-2">Variable : <code id="secretRevealVariable" class="font-monospace"></code></p>
<p class="mb-3">Source demandée : <code id="secretRevealSource"></code></p>
<div class="alert alert-warning small mb-3" role="note">
Cette action expose temporairement la valeur réelle dans la WebView. Elle n'est ni journalisée, ni ajoutée à AppState, ni persistée par le frontend.
</div>
</div>
<div id="secretRevealResultPanel" hidden>
<label class="form-label fw-semibold" for="secretRevealValue">Valeur réelle transitoire</label>
<div class="input-group">
<input id="secretRevealValue" class="form-control font-monospace" type="password" readonly autocomplete="off" spellcheck="false">
<button id="secretRevealToggleVisibility" class="btn btn-outline-secondary" type="button">Afficher</button>
</div>
<div id="secretRevealValueAbsent" class="alert alert-secondary small mb-0" hidden>Aucune valeur n'existe dans cette source.</div>
</div>
<p id="secretRevealStatus" class="text-body-secondary small mt-3 mb-0" aria-live="polite"></p>
</div>
<div class="modal-footer">
<button class="btn btn-outline-secondary" type="button" data-bs-dismiss="modal">Fermer</button>
<button id="secretRevealConfirmAction" class="btn btn-warning" type="button">Révéler</button>
</div>
</div>
</div>
</div>
<footer class="app-footer bg-dark text-light">
<div class="container h-100 d-flex align-items-center justify-content-center">
<small>&copy; 2026 SASEDEV</small>
</div>
</footer>
<script type="module" src="ts/main.ts" defer></script>
</body>
</html>

View File

@@ -0,0 +1,120 @@
// file: crates/ksp-app-config-desk/frontend/sass/_app.scss
// version: 5
$app-header-height: 72px;
$app-footer-height: 42px;
html,
body {
width: 100%;
height: 100%;
}
body {
margin: 0;
overflow: hidden;
background: $gray-100;
}
.app-header {
position: fixed;
inset: 0 0 auto;
height: $app-header-height;
z-index: 1020;
border-bottom: 2px solid rgba($primary, 0.3);
}
.app-footer {
position: fixed;
inset: auto 0 0;
height: $app-footer-height;
z-index: 1020;
border-top: 2px solid rgba($primary, 0.3);
}
.app-main {
position: relative;
height: calc(100vh - $app-header-height - $app-footer-height);
margin-top: $app-header-height;
margin-bottom: $app-footer-height;
overflow: hidden;
}
.app-scrollable {
height: 100%;
max-height: 100%;
}
.app-logo {
display: block;
width: auto;
height: 42px;
}
.app-shell-card {
max-width: 1320px;
}
.app-shell-status {
font-family: var(--bs-font-monospace);
}
.app-nav {
min-width: 0;
overflow-x: auto;
scrollbar-width: thin;
}
.app-nav-button {
white-space: nowrap;
}
.app-overview-list dd {
font-family: var(--bs-font-monospace);
}
.app-document-metadata dd,
.app-document-source,
#configDocumentsTable td:first-child,
#configDocumentsTable td:nth-child(2),
#configDocumentsTable td:nth-child(3) {
font-family: var(--bs-font-monospace);
}
.app-document-source {
min-height: 320px;
resize: vertical;
white-space: pre;
tab-size: 4;
}
#configDocumentsTable tbody tr {
cursor: pointer;
}
.app-profile-metadata dd,
.app-profile-json,
#profileOriginsTable td:first-child,
#profileEnvironmentProvenanceTable td:first-child,
#profileEnvironmentProvenanceTable td:nth-child(2) {
font-family: var(--bs-font-monospace);
}
.app-profile-json {
min-height: 280px;
max-height: 460px;
overflow: auto;
padding: 0.875rem;
border: 1px solid var(--bs-border-color);
border-radius: var(--bs-border-radius);
background: var(--bs-body-bg);
white-space: pre;
}
#environmentReportTable td:first-child,
#environmentReportTable td:nth-child(4),
#environmentReportTable td:nth-child(5) {
font-family: var(--bs-font-monospace);
}

View File

@@ -0,0 +1,160 @@
// file: crates/ksp-app-config-desk/frontend/sass/_bootswatch.scss
// version: 2
// Pulse 5.3.8
// Bootswatch
// Variables
// Buttons
.btn {
&:focus,
&:active,
&:active:focus,
&.active:focus {
outline: none;
}
&-secondary {
color: $gray-900;
background-color: $white;
border-color: #ccc;
&:hover {
color: $gray-900;
background-color: $gray-300;
border-color: $gray-500;
}
&.disabled {
color: tint-color($gray-900, 5%);
background-color: $white;
border-color: tint-color(#ccc, 5%);
}
}
&-warning {
color: $white;
}
&-primary:focus {
box-shadow: 0 0 5px tint-color($primary, 10%);
}
&-secondary:focus {
box-shadow: 0 0 5px $gray-400;
}
&-success:focus {
box-shadow: 0 0 5px tint-color($success, 10%);
}
&-info:focus {
box-shadow: 0 0 5px tint-color($info, 10%);
}
&-warning:focus {
box-shadow: 0 0 5px tint-color($warning, 10%);
}
&-danger:focus {
box-shadow: 0 0 5px tint-color($danger, 10%);
}
&.disabled:focus {
box-shadow: none;
}
}
// Tables
.table .thead-dark th {
background-color: $secondary;
border-color: $table-border-color;
}
.table-primary,
.table-secondary,
.table-success,
.table-warning,
.table-danger,
.table-info,
.table-light {
--#{$prefix}table-color: #{$body-color};
}
// Forms
.form-control:focus {
box-shadow: 0 0 5px rgba(100, 65, 164, .4);
}
// Navs
.nav-tabs {
.nav-link,
.nav-link.active {
border-width: 0 0 1px;
}
.nav-link:hover,
.nav-link.active,
.nav-link.active:hover,
.nav-link.active:focus {
border-bottom: 1px solid $primary;
}
.nav-item+.nav-item {
margin-left: 0;
}
}
.breadcrumb {
&-item.active {
color: $gray-700;
}
}
// Indicators
.badge {
&.bg-light {
color: $dark;
}
}
// Progress bars
.progress {
height: 8px;
}
// Containers
.list-group {
&-item {
color: rgba(255, 255, 255, .8);
&.active,
&:hover,
&:focus {
color: $white;
}
&.active {
font-weight: 700;
&:hover {
background-color: $list-group-hover-bg;
}
}
&.disabled:hover {
color: $list-group-disabled-color;
}
}
}

View File

@@ -0,0 +1,19 @@
// file: crates/ksp-app-config-desk/frontend/sass/_fontawesome.scss
// version: 2
//@use '@fortawesome/fontawesome-free/scss/variables' with (
// // customizing $font-path - make sure it points to where your webfonts are stored in your project
// $font-path: '../webfonts',
//);
@use '@fortawesome/fontawesome-free/scss/variables' with (
// use fonts from @fortawesome/fontawesome-free
$font-path: '@fortawesome/fontawesome-free/webfonts',
);
// load Font Awesome core
@use '@fortawesome/fontawesome-free/scss/fontawesome';
// load and make available Font Awesome helpers (mixins, functions, and variables)
@use '@fortawesome/fontawesome-free/scss/fa' as fa;
@use '@fortawesome/fontawesome-free/scss/brands' as fa-brands;
@use '@fortawesome/fontawesome-free/scss/regular' as fa-regular;
@use '@fortawesome/fontawesome-free/scss/solid' as fa-solid;

View File

@@ -0,0 +1,248 @@
// file: crates/ksp-app-config-desk/frontend/sass/_simplebar.scss
// version: 2
/* Rtl support */
[data-simplebar] {
position: relative;
flex-direction: column;
flex-wrap: wrap;
justify-content: flex-start;
align-content: flex-start;
align-items: flex-start;
}
.simplebar-wrapper {
overflow: hidden;
width: inherit;
height: inherit;
max-width: inherit;
max-height: inherit;
}
.simplebar-mask {
direction: inherit;
position: absolute;
overflow: hidden;
padding: 0;
margin: 0;
left: 0;
top: 0;
bottom: 0;
right: 0;
width: auto !important;
height: auto !important;
// z-index: 0;
inset: 0;
}
.simplebar-offset {
direction: inherit !important;
box-sizing: inherit !important;
resize: none !important;
position: absolute;
top: 0;
left: 0;
bottom: 0;
right: 0;
padding: 0;
margin: 0;
-webkit-overflow-scrolling: touch;
inset: 0;
}
.simplebar-content-wrapper {
direction: inherit;
box-sizing: border-box !important;
position: relative;
display: block;
height: 100%;
width: auto;
max-width: 100%;
max-height: 100%;
overflow: auto;
scrollbar-width: none;
-ms-overflow-style: none;
&::-webkit-scrollbar {
display: none;
width: 0;
height: 0;
}
}
.simplebar-hide-scrollbar {
&::-webkit-scrollbar {
display: none;
width: 0;
height: 0;
}
position: fixed;
left: 0;
visibility: hidden;
overflow-y: scroll;
scrollbar-width: none;
-ms-overflow-style: none;
}
.simplebar-content {
&:before {
content: ' ';
display: table;
}
&:after {
content: ' ';
display: table;
}
}
.simplebar-placeholder {
max-height: 100%;
max-width: 100%;
width: 100%;
pointer-events: none;
}
.simplebar-height-auto-observer-wrapper {
box-sizing: inherit !important;
height: 100%;
width: 100%;
max-width: 1px;
position: relative;
float: left;
max-height: 1px;
overflow: hidden;
// z-index: -1;
padding: 0;
margin: 0;
pointer-events: none;
flex-grow: inherit;
flex-shrink: 0;
flex-basis: 0;
}
.simplebar-height-auto-observer {
box-sizing: inherit;
display: block;
opacity: 0;
position: absolute;
top: 0;
left: 0;
height: 1000%;
width: 1000%;
min-height: 1px;
min-width: 1px;
overflow: hidden;
pointer-events: none;
// z-index: -1;
}
.simplebar-track {
// z-index: 1;
position: absolute;
right: 0;
bottom: 0;
pointer-events: none;
overflow: hidden;
}
[data-simplebar].simplebar-dragging {
pointer-events: none;
-webkit-touch-callout: none;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
.simplebar-content {
pointer-events: none;
-webkit-touch-callout: none;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
}
.simplebar-track {
pointer-events: all;
}
}
.simplebar-scrollbar {
position: absolute;
left: 0;
right: 0;
min-height: 10px;
&:before {
position: absolute;
content: '';
background: black;
border-radius: 7px;
left: 2px;
right: 2px;
opacity: 0;
transition: opacity 0.2s 0.5s linear;
top: 2px;
bottom: 2px;
}
}
.simplebar-scrollbar.simplebar-visible {
&:before {
opacity: 0.5;
transition-delay: 0s;
transition-duration: 0s;
}
}
.simplebar-track.simplebar-vertical {
top: 0;
width: 11px;
}
.simplebar-track.simplebar-horizontal {
left: 0;
height: 11px;
.simplebar-scrollbar {
right: auto;
left: 0;
top: 0;
bottom: 0;
min-height: 0;
min-width: 10px;
width: auto;
}
}
[data-simplebar-direction='rtl'] {
.simplebar-track.simplebar-vertical {
right: auto;
left: 0;
}
}
.simplebar-dummy-scrollbar-size {
direction: rtl;
position: fixed;
opacity: 0;
visibility: hidden;
height: 500px;
width: 500px;
overflow-y: hidden;
overflow-x: scroll;
-ms-overflow-style: scrollbar !important;
>div {
width: 200%;
height: 200%;
margin: 10px 0;
}
}
.simplebar-hover {
cursor: pointer;
}

View File

@@ -0,0 +1,95 @@
// file: crates/ksp-app-config-desk/frontend/sass/_variables.scss
// version: 2
// Pulse 5.3.8
// Bootswatch
$theme: "pulse" !default;
//
// Color system
//
$white: #fff !default;
$gray-100: #fafafa !default;
$gray-200: #f9f8fc !default;
$gray-300: #ededed !default;
$gray-400: #cbc8d0 !default;
$gray-500: #adb5bd !default;
$gray-600: #868e96 !default;
$gray-700: #444 !default;
$gray-800: #343a40 !default;
$gray-900: #17141f !default;
$black: #000 !default;
$blue: #007bff !default;
$indigo: #6610f2 !default;
$purple: #593196 !default;
$pink: #e83e8c !default;
$red: #fc3939 !default;
$orange: #fd7e14 !default;
$yellow: #efa31d !default;
$green: #13b955 !default;
$teal: #20c997 !default;
$cyan: #009cdc !default;
$primary: $purple !default;
$secondary: #a991d4 !default;
$success: $green !default;
$info: $cyan !default;
$warning: $yellow !default;
$danger: $red !default;
$light: $gray-200 !default;
$dark: $gray-900 !default;
$min-contrast-ratio: 2.1 !default;
// Options
$enable-rounded: false !default;
// Body
$body-color: $gray-700 !default;
// Links
$link-hover-color: $primary !default;
// Tables
$table-color: initial !default;
$table-border-color: rgba(0, 0, 0, .05) !default;
// Forms
$input-focus-border-color: $primary !default;
// Dropdowns
$dropdown-link-hover-color: $white !default;
$dropdown-link-hover-bg: $primary !default;
// Navs
$nav-tabs-border-color: $gray-300 !default;
$nav-tabs-link-hover-border-color: $primary !default;
// Navbar
$navbar-padding-y: 1.2rem !default;
// Progress bars
$progress-bg: $gray-300 !default;
$progress-bar-bg: $primary !default;
// List group
$list-group-bg: $gray-900 !default;
$list-group-border-color: transparent !default;
$list-group-hover-bg: lighten($list-group-bg, 10%) !default;
$list-group-active-color: $white !default;
$list-group-active-bg: $list-group-bg !default;
$list-group-disabled-color: lighten($list-group-bg, 30%) !default;

View File

@@ -0,0 +1,10 @@
// file: crates/ksp-app-config-desk/frontend/sass/main.scss
// version: 1
@import "bootstrap/scss/functions";
@import "variables";
@import "fontawesome";
@import "simplebar";
@import "bootstrap/scss/bootstrap";
@import "bootswatch";
@import "app";

View File

@@ -0,0 +1,65 @@
// file: crates/ksp-app-config-desk/frontend/sass/splash.scss
// version: 3
@font-face {
font-family: "Dos Amazigh";
src: url("../fonts/DOS_Amazigh.ttf") format("truetype");
font-weight: normal;
font-style: normal;
font-display: swap;
}
body {
display: flex;
width: 100vw;
height: 100vh;
margin: 0;
padding: 0;
overflow: hidden;
align-items: center;
justify-content: center;
background: transparent;
font-family: Arial, sans-serif;
}
#splash-container {
position: relative;
width: 960px;
height: 637px;
opacity: 0;
}
#splash-image {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
z-index: 1;
}
#app-name {
position: absolute;
top: 50%;
left: 50%;
z-index: 2;
transform: translate(-50%, -50%);
color: #fff;
font-family: "Dos Amazigh", sans-serif;
font-size: 76px;
font-weight: bold;
text-align: center;
text-shadow: 0 0 10px rgba(0, 0, 0, 0.5);
}
#splash-status {
position: absolute;
right: 1rem;
bottom: 1rem;
left: 1rem;
z-index: 3;
padding: 0.55rem 0.75rem;
color: #fff;
background: rgba(0, 0, 0, 0.35);
font-family: monospace;
font-size: 0.82rem;
}

View File

@@ -0,0 +1,22 @@
<!-- file: crates/ksp-app-config-desk/frontend/splash.html -->
<!-- version: 1 -->
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta content="width=device-width, initial-scale=1.0" name="viewport">
<link rel="stylesheet" href="sass/splash.scss">
<title>KSP Config Desk — Chargement</title>
</head>
<body>
<div id="splash-container">
<img id="splash-image" src="imgs/splash.png" alt="Chargement de KSP Config Desk">
<div id="app-name">KSP</div>
<div id="splash-status" aria-live="polite">Chargement du shell frontend...</div>
</div>
<script type="module" src="ts/splash.ts" defer></script>
</body>
</html>

View File

@@ -0,0 +1,295 @@
// file: crates/ksp-app-config-desk/frontend/ts/documents.ts
// version: 2
//! Documents panel backed exclusively by ksp-config-lib inventory, validation and management APIs.
import DataTable from "datatables.net-bs5";
import "datatables.net-select-bs5";
import "datatables.net-bs5/css/dataTables.bootstrap5.css";
import "datatables.net-select-bs5/css/select.bootstrap5.css";
import type { ConfigDocumentDetailDto } from "./bindings/ksp_app_config_desk/documents/ConfigDocumentDetailDto";
import type { ConfigDocumentErrorDto } from "./bindings/ksp_app_config_desk/documents/ConfigDocumentErrorDto";
import type { ConfigDocumentSaveResultDto } from "./bindings/ksp_app_config_desk/documents/ConfigDocumentSaveResultDto";
import type { ConfigDocumentSummaryDto } from "./bindings/ksp_app_config_desk/documents/ConfigDocumentSummaryDto";
import { frontendDebug, frontendTrace, frontendWarn } from "./frontend_log";
import { invokeKsp } from "./invoke";
import { requestViewActivation, specializedEditorForFileId } from "./shell_registry";
interface DocumentsDataTable {
destroy(): unknown;
on(event: string, callback: (event: unknown, api: unknown, type: string, indexes: number[]) => void): unknown;
row(selector: number): { node(): Node | null };
}
let documentsTable: DocumentsDataTable | null = null;
let activeDocument: ConfigDocumentDetailDto | null = null;
const stageLabels: Record<string, string> = {
valid: "Valide",
read: "Lecture",
json: "JSON",
schema: "Schema",
semantic: "Sémantique",
effective: "Effective",
other: "Autre",
};
function stageLabel(stage: string): string {
return stageLabels[stage] ?? stage;
}
function setText(selector: string, value: string): void {
const element = document.querySelector<HTMLElement>(selector);
if (element) {
element.textContent = value;
}
}
function setDocumentsStatus(message: string, tone: "primary" | "success" | "warning" | "danger" = "primary"): void {
const status = document.querySelector<HTMLElement>("#documentsStatus");
if (!status) {
return;
}
status.className = `alert alert-${tone} mb-0`;
status.textContent = message;
frontendTrace("main", "Documents panel status replaced", { tone });
}
function diagnosticMessage(summary: ConfigDocumentSummaryDto): string {
if (!summary.diagnostic) {
return "Aucun diagnostic backend.";
}
return `${summary.diagnostic.domain}.${summary.diagnostic.code}${summary.diagnostic.message}`;
}
function renderDetail(detail: ConfigDocumentDetailDto): void {
activeDocument = detail;
setText("#documentDetailFileId", detail.summary.fileId);
setText("#documentDetailFilename", detail.summary.filename);
setText("#documentDetailSchema", detail.summary.schemaFileId ?? "—");
setText("#documentDetailPath", detail.summary.path);
setText("#documentDetailStage", stageLabel(detail.summary.diagnosticStage));
setText("#documentDetailDiagnostic", diagnosticMessage(detail.summary));
const badge = document.querySelector<HTMLElement>("#documentDetailStatus");
if (badge) {
const valid = detail.summary.validationStatus === "valid";
badge.className = `badge ${valid ? "text-bg-success" : "text-bg-danger"}`;
badge.textContent = valid ? "Valide" : "Invalide";
}
const editor = document.querySelector<HTMLTextAreaElement>("#documentSourceEditor");
if (editor) {
editor.value = detail.source ?? "";
editor.disabled = detail.source === null;
}
const saveButton = document.querySelector<HTMLButtonElement>("#saveDocumentSource");
if (saveButton) {
saveButton.disabled = detail.source === null;
}
const specializedEditor = specializedEditorForFileId(detail.summary.fileId);
const specializedButton = document.querySelector<HTMLButtonElement>("#openSpecializedEditor");
if (specializedButton) {
specializedButton.hidden = specializedEditor === null;
specializedButton.textContent = specializedEditor?.label ?? "Ouvrir l'éditeur spécialisé";
}
const detailPanel = document.querySelector<HTMLElement>("#documentDetailPanel");
if (detailPanel) {
detailPanel.hidden = false;
}
frontendTrace("main", "Config document detail rendered", {
fileId: detail.summary.fileId,
validationStatus: detail.summary.validationStatus,
diagnosticStage: detail.summary.diagnosticStage,
sourceAvailable: detail.source !== null,
});
}
function clearDetail(): void {
activeDocument = null;
const detailPanel = document.querySelector<HTMLElement>("#documentDetailPanel");
if (detailPanel) {
detailPanel.hidden = true;
}
frontendTrace("main", "Config document detail cleared");
}
async function loadDetail(fileId: string, source: "selection" | "reload"): Promise<void> {
frontendDebug("main", "Config document detail requested", { fileId, source });
try {
const detail = await invokeKsp<ConfigDocumentDetailDto>("main", "get_config_document_detail", { fileId });
renderDetail(detail);
setDocumentsStatus(`Document ${fileId} chargé depuis Config.`, "success");
} catch (caughtError) {
const error = asDocumentError(caughtError);
const message = error
? `${stageLabel(error.diagnosticStage)} : ${error.error.domain}.${error.error.code}${error.error.message}`
: "Le détail du document n'a pas pu être chargé.";
setDocumentsStatus(message, "danger");
}
}
function populateTableRows(documents: ConfigDocumentSummaryDto[]): void {
const body = document.querySelector<HTMLTableSectionElement>("#configDocumentsTable tbody");
if (!body) {
return;
}
body.replaceChildren();
for (const summary of documents) {
const row = document.createElement("tr");
row.dataset.fileId = summary.fileId;
appendCell(row, summary.fileId);
appendCell(row, summary.filename);
appendCell(row, summary.schemaFileId ?? "—");
appendCell(row, summary.validationStatus === "valid" ? "Valide" : "Invalide");
appendCell(row, stageLabel(summary.diagnosticStage));
body.append(row);
}
frontendTrace("main", "Config document inventory table DOM replaced", { documentCount: documents.length });
}
function appendCell(row: HTMLTableRowElement, text: string): void {
const cell = document.createElement("td");
cell.textContent = text;
row.append(cell);
}
function initializeDataTable(): void {
if (documentsTable) {
documentsTable.destroy();
documentsTable = null;
}
const table = new DataTable("#configDocumentsTable", {
order: [[0, "asc"]],
pageLength: 10,
select: { style: "single" },
language: {
emptyTable: "Aucun document Config enregistré.",
info: "_START_ à _END_ sur _TOTAL_ document(s)",
infoEmpty: "0 document",
lengthMenu: "Afficher _MENU_",
search: "Filtrer :",
zeroRecords: "Aucun document correspondant.",
},
}) as unknown as DocumentsDataTable;
table.on("select", (event, api, type, indexes) => {
void event;
void api;
if (type !== "row" || indexes.length === 0) {
return;
}
const node = table.row(indexes[0]).node();
if (!(node instanceof HTMLTableRowElement)) {
return;
}
const fileId = node.dataset.fileId;
frontendTrace("main", "Config document table row selected", { fileId: fileId ?? null });
if (fileId) {
void loadDetail(fileId, "selection");
}
});
documentsTable = table;
frontendTrace("main", "DataTables Documents initialized", { selection: "single" });
}
async function refreshInventory(): Promise<void> {
frontendDebug("main", "Config document inventory refresh requested");
clearDetail();
try {
const documents = await invokeKsp<ConfigDocumentSummaryDto[]>("main", "get_config_documents");
populateTableRows(documents);
initializeDataTable();
setDocumentsStatus(`${documents.length} document(s) Config évalué(s) par le backend.`, "success");
} catch {
setDocumentsStatus("L'inventaire Config n'a pas pu être chargé.", "danger");
}
}
async function reloadActiveDocument(): Promise<void> {
if (!activeDocument) {
return;
}
frontendDebug("main", "Config document reload clicked", { fileId: activeDocument.summary.fileId });
await loadDetail(activeDocument.summary.fileId, "reload");
}
async function saveActiveDocument(): Promise<void> {
if (!activeDocument) {
return;
}
const editor = document.querySelector<HTMLTextAreaElement>("#documentSourceEditor");
if (!editor) {
return;
}
const fileId = activeDocument.summary.fileId;
frontendDebug("main", "Config document validated save clicked", { fileId });
setDocumentsStatus(`Validation et sauvegarde de ${fileId} en cours...`, "primary");
try {
const result = await invokeKsp<ConfigDocumentSaveResultDto>("main", "save_config_document_source", {
fileId,
source: editor.value,
});
renderDetail(result.document);
const outcome = result.sourceChanged ? "source modifiée" : "source inchangée";
setDocumentsStatus(`Sauvegarde validée par Config : ${outcome}; reload_required=${result.reloadRequired}.`, "success");
frontendDebug("main", "Config document validated save completed", {
fileId,
sourceChanged: result.sourceChanged,
reloadRequired: result.reloadRequired,
});
} catch (caughtError) {
const error = asDocumentError(caughtError);
if (error) {
setDocumentsStatus(
`${stageLabel(error.diagnosticStage)} : ${error.error.domain}.${error.error.code}${error.error.message}`,
"danger",
);
frontendWarn("main", "Config document candidate rejected by backend", { fileId, diagnosticStage: error.diagnosticStage });
return;
}
setDocumentsStatus("Le candidat Config a été rejeté sans diagnostic exploitable.", "danger");
}
}
function asDocumentError(value: unknown): ConfigDocumentErrorDto | null {
if (typeof value !== "object" || value === null) {
return null;
}
const candidate = value as Record<string, unknown>;
if (typeof candidate.diagnosticStage !== "string" || typeof candidate.error !== "object" || candidate.error === null) {
return null;
}
const error = candidate.error as Record<string, unknown>;
if (typeof error.domain !== "string" || typeof error.code !== "string" || typeof error.message !== "string") {
return null;
}
return value as ConfigDocumentErrorDto;
}
function bindDocumentActions(): void {
document.querySelector<HTMLButtonElement>("#refreshDocuments")?.addEventListener("click", () => {
frontendTrace("main", "Documents refresh button clicked");
void refreshInventory();
});
document.querySelector<HTMLButtonElement>("#reloadDocumentSource")?.addEventListener("click", () => {
frontendTrace("main", "Document reload button clicked");
void reloadActiveDocument();
});
document.querySelector<HTMLButtonElement>("#saveDocumentSource")?.addEventListener("click", () => {
frontendTrace("main", "Document save button clicked");
void saveActiveDocument();
});
document.querySelector<HTMLButtonElement>("#openSpecializedEditor")?.addEventListener("click", () => {
const fileId = activeDocument?.summary.fileId ?? "";
const descriptor = specializedEditorForFileId(fileId);
if (!descriptor) {
return;
}
frontendDebug("main", "Specialized Config editor requested", { fileId, viewId: descriptor.viewId });
requestViewActivation(descriptor.viewId);
});
frontendTrace("main", "Documents panel handlers installed");
}
export function initializeDocumentsPanel(): void {
bindDocumentActions();
void refreshInventory();
}

View File

@@ -0,0 +1,422 @@
// file: crates/ksp-app-config-desk/frontend/ts/environment.ts
// version: 4
//! Safe environment report and `.env` management panel backed exclusively by ConfigManagement.
import { Modal } from "bootstrap";
import DataTable from "datatables.net-bs5";
import "datatables.net-bs5/css/dataTables.bootstrap5.css";
import type { ConfigEnvironmentChangeDto } from "./bindings/ksp_app_config_desk/environment/ConfigEnvironmentChangeDto";
import type { ConfigEnvironmentReportDto } from "./bindings/ksp_app_config_desk/environment/ConfigEnvironmentReportDto";
import { frontendDebug, frontendTrace } from "./frontend_log";
import { invokeKsp } from "./invoke";
import { initializeSecretReveal, requestSecretReveal } from "./secret_reveal";
interface EnvironmentDataTable {
destroy(): unknown;
}
let environmentTable: EnvironmentDataTable | null = null;
let currentReports: ConfigEnvironmentReportDto[] = [];
let mutationControlsDisabled = false;
function setEnvironmentStatus(message: string, tone: "primary" | "success" | "warning" | "danger" = "primary"): void {
const status = document.querySelector<HTMLElement>("#environmentStatus");
if (!status) {
return;
}
status.className = `alert alert-${tone} mb-0`;
status.textContent = message;
frontendTrace("main", "Environment panel status replaced", { tone });
}
function setMutationStatus(message: string, tone: "secondary" | "success" | "warning" | "danger" = "secondary"): void {
const status = document.querySelector<HTMLElement>("#environmentMutationStatus");
if (!status) {
return;
}
status.className = `alert alert-${tone} mb-0`;
status.textContent = message;
frontendTrace("main", "Environment mutation status replaced", { tone });
}
function sensitivityLabel(value: string): string {
if (value === "public") {
return "Public";
}
if (value === "internal") {
return "Internal";
}
if (value === "secret") {
return "Secret";
}
return value;
}
function sourceLabel(value: string): string {
if (value === "process") {
return "process";
}
if (value === "dotenv") {
return "dotenv";
}
if (value === "fallback") {
return "fallback";
}
return "—";
}
function safeValue(value: string | null): string {
return value ?? "—";
}
function appendCell(row: HTMLTableRowElement, text: string, className?: string): void {
const cell = document.createElement("td");
cell.textContent = text;
if (className) {
cell.className = className;
}
row.append(cell);
}
function populateTableRows(reports: ConfigEnvironmentReportDto[]): void {
const body = document.querySelector<HTMLTableSectionElement>("#environmentReportTable tbody");
if (!body) {
return;
}
body.replaceChildren();
for (const report of reports) {
const row = document.createElement("tr");
row.dataset.variableName = report.variableName;
appendCell(row, report.variableName, "font-monospace");
appendCell(row, report.namespace);
appendCell(row, sensitivityLabel(report.sensitivity));
appendCell(row, safeValue(report.desiredSafeValue), "font-monospace text-break");
appendCell(row, safeValue(report.effectiveSafeValue), "font-monospace text-break");
appendCell(row, sourceLabel(report.effectiveSource));
appendCell(row, report.shadowedByProcessEnvironment ? "oui" : "non");
body.append(row);
}
frontendTrace("main", "Environment report table DOM replaced", { variableCount: reports.length });
}
function populateVariableSuggestions(reports: ConfigEnvironmentReportDto[]): void {
const list = document.querySelector<HTMLDataListElement>("#environmentVariableSuggestions");
if (!list) {
return;
}
list.replaceChildren();
for (const report of reports) {
const option = document.createElement("option");
option.value = report.variableName;
list.append(option);
}
frontendTrace("main", "Environment variable suggestions replaced", { variableCount: reports.length });
}
function destroyEnvironmentDataTable(): void {
if (environmentTable) {
environmentTable.destroy();
environmentTable = null;
frontendTrace("main", "DataTables Environment destroyed before report replacement");
}
}
function initializeDataTable(): void {
environmentTable = new DataTable("#environmentReportTable", {
order: [[0, "asc"]],
pageLength: 10,
language: {
emptyTable: "Aucune variable KSP/KSPB présente dans process ou .env.",
info: "_START_ à _END_ sur _TOTAL_ variable(s)",
infoEmpty: "0 variable",
lengthMenu: "Afficher _MENU_",
search: "Filtrer :",
zeroRecords: "Aucune variable correspondante.",
},
}) as unknown as EnvironmentDataTable;
frontendTrace("main", "DataTables Environment initialized");
}
function classifyEditorSensitivity(variableName: string): "public" | "internal" | "secret" | "invalid" {
if (variableName.startsWith("KSP_SECRET_") || variableName.startsWith("KSPB_SECRET_")) {
return "secret";
}
if (variableName.startsWith("KSP_PUBLIC_") || variableName.startsWith("KSPB_PUBLIC_")) {
return "public";
}
if (variableName.startsWith("KSP_") || variableName.startsWith("KSPB_")) {
return "internal";
}
return "invalid";
}
function updateEditorSensitivity(): void {
const variable = document.querySelector<HTMLInputElement>("#environmentVariableName");
const value = document.querySelector<HTMLInputElement>("#environmentVariableValue");
const note = document.querySelector<HTMLElement>("#environmentEditorSensitivity");
if (!variable || !value || !note) {
return;
}
const sensitivity = classifyEditorSensitivity(variable.value.trim());
value.type = sensitivity === "secret" ? "password" : "text";
note.textContent = sensitivity === "secret"
? "Secret : la nouvelle saisie est masquée et l'ancienne valeur réelle n'est jamais préchargée."
: sensitivity === "invalid"
? "Saisis un nom KSP_* ou KSPB_* ; Config reste l'autorité de validation."
: `${sensitivityLabel(sensitivity)} : la valeur saisie n'est jamais préchargée depuis .env.`;
updateRevealButtons(sensitivity);
frontendTrace("main", "Environment mutation editor sensitivity updated", { sensitivity });
}
function updateRevealButtons(sensitivity = classifyEditorSensitivity(document.querySelector<HTMLInputElement>("#environmentVariableName")?.value.trim() ?? "")): void {
const revealDisabled = mutationControlsDisabled || sensitivity !== "secret";
document.querySelector<HTMLButtonElement>("#revealEnvironmentEffective")?.toggleAttribute("disabled", revealDisabled);
document.querySelector<HTMLButtonElement>("#revealEnvironmentDotenv")?.toggleAttribute("disabled", revealDisabled);
}
function selectVariableForMutation(variableName: string, source: "table" | "input"): void {
const variable = document.querySelector<HTMLInputElement>("#environmentVariableName");
const value = document.querySelector<HTMLInputElement>("#environmentVariableValue");
if (!variable || !value) {
return;
}
variable.value = variableName;
value.value = "";
updateEditorSensitivity();
frontendDebug("main", "Environment mutation variable selected", { variableName, source });
}
function renderMutationResult(result: ConfigEnvironmentChangeDto): void {
const operation = document.querySelector<HTMLElement>("#environmentMutationOperation");
const variable = document.querySelector<HTMLElement>("#environmentMutationVariable");
const sourceChanged = document.querySelector<HTMLElement>("#environmentMutationSourceChanged");
const effectiveChanged = document.querySelector<HTMLElement>("#environmentMutationEffectiveChanged");
const shadowed = document.querySelector<HTMLElement>("#environmentMutationShadowed");
const reload = document.querySelector<HTMLElement>("#environmentMutationReload");
const panel = document.querySelector<HTMLElement>("#environmentMutationResult");
if (operation) {
operation.textContent = result.operation === "remove" ? "suppression" : "création / modification";
}
if (variable) {
variable.textContent = result.variableName;
}
if (sourceChanged) {
sourceChanged.textContent = result.sourceChanged ? "true" : "false";
}
if (effectiveChanged) {
effectiveChanged.textContent = result.effectiveChanged ? "true" : "false";
}
if (shadowed) {
shadowed.textContent = result.shadowedByProcessEnvironment ? "true" : "false";
}
if (reload) {
reload.textContent = result.reloadRequired ? "true" : "false";
}
if (panel) {
panel.hidden = false;
}
frontendTrace("main", "Environment mutation result rendered", {
operation: result.operation,
variableName: result.variableName,
sourceChanged: result.sourceChanged,
effectiveChanged: result.effectiveChanged,
shadowedByProcessEnvironment: result.shadowedByProcessEnvironment,
reloadRequired: result.reloadRequired,
});
}
function setMutationControlsDisabled(disabled: boolean): void {
mutationControlsDisabled = disabled;
document.querySelector<HTMLButtonElement>("#setEnvironmentValue")?.toggleAttribute("disabled", disabled);
document.querySelector<HTMLButtonElement>("#removeEnvironmentValue")?.toggleAttribute("disabled", disabled);
updateRevealButtons();
}
function confirmEnvironmentRemoval(variableName: string): Promise<boolean> {
const modalElement = document.querySelector<HTMLElement>("#environmentRemoveConfirmModal");
const variable = document.querySelector<HTMLElement>("#environmentRemoveConfirmVariable");
const confirmButton = document.querySelector<HTMLButtonElement>("#environmentRemoveConfirmAction");
if (!modalElement || !variable || !confirmButton) {
frontendDebug("main", "Environment removal confirmation modal is unavailable", { variableName });
return Promise.resolve(false);
}
variable.textContent = variableName;
const modal = Modal.getOrCreateInstance(modalElement);
frontendDebug("main", "Environment .env removal confirmation opened", { variableName });
return new Promise<boolean>(resolve => {
let confirmed = false;
const onConfirm = (): void => {
confirmed = true;
modal.hide();
};
const onHidden = (): void => {
confirmButton.removeEventListener("click", onConfirm);
variable.textContent = "";
frontendDebug("main", "Environment .env removal confirmation answered", { variableName, confirmed });
resolve(confirmed);
};
confirmButton.addEventListener("click", onConfirm, { once: true });
modalElement.addEventListener("hidden.bs.modal", onHidden, { once: true });
modal.show();
});
}
async function refreshEnvironmentReport(): Promise<void> {
frontendDebug("main", "Config environment report refresh requested");
setEnvironmentStatus("Chargement du rapport environnement sûr...", "primary");
try {
const reports = await invokeKsp<ConfigEnvironmentReportDto[]>("main", "get_environment_report");
currentReports = reports;
destroyEnvironmentDataTable();
populateTableRows(reports);
populateVariableSuggestions(reports);
initializeDataTable();
const shadowedCount = reports.filter(report => report.shadowedByProcessEnvironment).length;
setEnvironmentStatus(`${reports.length} variable(s) KSP/KSPB évaluée(s) par Config ; ${shadowedCount} valeur(s) .env shadowed.`, "success");
frontendDebug("main", "Config environment report refresh completed", {
variableCount: reports.length,
shadowedCount,
});
} catch {
setEnvironmentStatus("Le rapport environnement n'a pas pu être chargé par Config.", "danger");
}
}
async function setEnvironmentValue(): Promise<void> {
const variable = document.querySelector<HTMLInputElement>("#environmentVariableName");
const value = document.querySelector<HTMLInputElement>("#environmentVariableValue");
if (!variable || !value) {
return;
}
const variableName = variable.value.trim();
if (!variableName) {
setMutationStatus("Saisis un nom de variable KSP_* ou KSPB_*.", "warning");
return;
}
const sensitivity = classifyEditorSensitivity(variableName);
frontendDebug("main", "Environment .env mutation requested", { operation: "set", variableName, sensitivity });
setMutationControlsDisabled(true);
setMutationStatus("Validation et persistence atomique par Config...", "secondary");
try {
const result = await invokeKsp<ConfigEnvironmentChangeDto>("main", "set_environment_value", { variableName, value: value.value });
renderMutationResult(result);
value.value = "";
setMutationStatus("Mutation .env acceptée par Config ; rapport rechargé depuis la source persistée.", "success");
await refreshEnvironmentReport();
frontendDebug("main", "Environment .env mutation completed", {
operation: result.operation,
variableName: result.variableName,
sourceChanged: result.sourceChanged,
effectiveChanged: result.effectiveChanged,
shadowedByProcessEnvironment: result.shadowedByProcessEnvironment,
reloadRequired: result.reloadRequired,
});
} catch {
setMutationStatus("La mutation .env a été refusée ou n'a pas pu être persistée par Config.", "danger");
} finally {
setMutationControlsDisabled(false);
}
}
async function removeEnvironmentValue(): Promise<void> {
const variable = document.querySelector<HTMLInputElement>("#environmentVariableName");
const value = document.querySelector<HTMLInputElement>("#environmentVariableValue");
if (!variable || !value) {
return;
}
const variableName = variable.value.trim();
if (!variableName) {
setMutationStatus("Saisis ou sélectionne la variable .env à supprimer.", "warning");
return;
}
setMutationControlsDisabled(true);
const confirmed = await confirmEnvironmentRemoval(variableName);
if (!confirmed) {
setMutationControlsDisabled(false);
return;
}
const sensitivity = classifyEditorSensitivity(variableName);
frontendDebug("main", "Environment .env mutation requested", { operation: "remove", variableName, sensitivity });
setMutationStatus("Suppression atomique par Config...", "secondary");
try {
const result = await invokeKsp<ConfigEnvironmentChangeDto>("main", "remove_environment_value", { variableName });
renderMutationResult(result);
value.value = "";
setMutationStatus("Suppression .env traitée par Config ; rapport rechargé depuis la source persistée.", "success");
await refreshEnvironmentReport();
frontendDebug("main", "Environment .env mutation completed", {
operation: result.operation,
variableName: result.variableName,
sourceChanged: result.sourceChanged,
effectiveChanged: result.effectiveChanged,
shadowedByProcessEnvironment: result.shadowedByProcessEnvironment,
reloadRequired: result.reloadRequired,
});
} catch {
setMutationStatus("La suppression .env a été refusée ou n'a pas pu être persistée par Config.", "danger");
} finally {
setMutationControlsDisabled(false);
}
}
function bindEnvironmentActions(): void {
document.querySelector<HTMLButtonElement>("#refreshEnvironment")?.addEventListener("click", () => {
frontendTrace("main", "Environment refresh button clicked");
void refreshEnvironmentReport();
});
document.querySelector<HTMLInputElement>("#environmentVariableName")?.addEventListener("input", () => {
updateEditorSensitivity();
});
document.querySelector<HTMLInputElement>("#environmentVariableName")?.addEventListener("change", event => {
const input = event.currentTarget as HTMLInputElement;
const exact = currentReports.find(report => report.variableName === input.value.trim());
if (exact) {
selectVariableForMutation(exact.variableName, "input");
}
});
document.querySelector<HTMLTableElement>("#environmentReportTable")?.addEventListener("click", event => {
const target = event.target as HTMLElement;
const row = target.closest<HTMLTableRowElement>("tbody tr");
const variableName = row?.dataset.variableName;
if (variableName) {
selectVariableForMutation(variableName, "table");
}
});
document.querySelector<HTMLButtonElement>("#setEnvironmentValue")?.addEventListener("click", () => {
frontendTrace("main", "Environment set value button clicked");
void setEnvironmentValue();
});
document.querySelector<HTMLButtonElement>("#removeEnvironmentValue")?.addEventListener("click", () => {
frontendTrace("main", "Environment remove value button clicked");
void removeEnvironmentValue();
});
document.querySelector<HTMLButtonElement>("#revealEnvironmentEffective")?.addEventListener("click", () => {
const variableName = document.querySelector<HTMLInputElement>("#environmentVariableName")?.value.trim() ?? "";
requestSecretReveal(variableName, "effective");
});
document.querySelector<HTMLButtonElement>("#revealEnvironmentDotenv")?.addEventListener("click", () => {
const variableName = document.querySelector<HTMLInputElement>("#environmentVariableName")?.value.trim() ?? "";
requestSecretReveal(variableName, "dotenv");
});
document.querySelector<HTMLButtonElement>("#clearEnvironmentEditor")?.addEventListener("click", () => {
const variable = document.querySelector<HTMLInputElement>("#environmentVariableName");
const value = document.querySelector<HTMLInputElement>("#environmentVariableValue");
if (variable) {
variable.value = "";
}
if (value) {
value.value = "";
}
updateEditorSensitivity();
frontendTrace("main", "Environment mutation editor cleared");
});
frontendTrace("main", "Environment panel handlers installed");
}
export function initializeEnvironmentPanel(): void {
initializeSecretReveal();
bindEnvironmentActions();
updateEditorSensitivity();
void refreshEnvironmentReport();
}

View File

@@ -0,0 +1,125 @@
// file: crates/ksp-app-config-desk/frontend/ts/frontend_log.ts
// version: 2
//! Frontend logging helpers routed through the KSP Logging facade.
import { invoke } from "@tauri-apps/api/core";
import type { FrontendLogPayloadDto } from "./bindings/ksp_app_config_desk/frontend_logging/FrontendLogPayloadDto.ts";
export type FrontendLogLevel = "trace" | "debug" | "info" | "warn" | "error";
export type FrontendLogTargetId = "frontend" | "main" | "splash";
type ConsoleMethod = (...items: unknown[]) => void;
const originalConsole = {
trace: console.trace.bind(console),
debug: console.debug.bind(console),
log: console.log.bind(console),
info: console.info.bind(console),
warn: console.warn.bind(console),
error: console.error.bind(console),
};
function stringifyItem(item: unknown): string {
if (item instanceof Error) {
return item.stack ?? item.message;
}
if (typeof item === "string") {
return item;
}
try {
const serialized = JSON.stringify(item);
return serialized ?? String(item);
} catch {
return String(item);
}
}
function formatMessage(items: unknown[]): string {
return items.map(item => stringifyItem(item)).join(" ");
}
async function sendFrontendLog(level: FrontendLogLevel, targetId: FrontendLogTargetId, message: string): Promise<void> {
const payload: FrontendLogPayloadDto = {
level,
targetId,
message,
};
await invoke("emit_frontend_log", { payload });
}
function writeOriginalConsole(level: FrontendLogLevel, message: string): void {
return level === "trace"
? originalConsole.trace(message)
: level === "debug"
? originalConsole.debug(message)
: level === "info"
? originalConsole.info(message)
: level === "warn"
? originalConsole.warn(message)
: originalConsole.error(message);
}
/// Emits one frontend event and resolves only after the Rust bridge accepted it.
export async function emitFrontendLog(level: FrontendLogLevel, targetId: FrontendLogTargetId, message: string): Promise<void> {
writeOriginalConsole(level, message);
await sendFrontendLog(level, targetId, message);
}
export function frontendTrace(targetId: FrontendLogTargetId, ...items: unknown[]): void {
const message = formatMessage(items);
originalConsole.trace(message);
void sendFrontendLog("trace", targetId, message).catch(caughtError =>
originalConsole.error("KSP frontend logging bridge failed", caughtError),
);
}
export function frontendDebug(targetId: FrontendLogTargetId, ...items: unknown[]): void {
const message = formatMessage(items);
originalConsole.debug(message);
void sendFrontendLog("debug", targetId, message).catch(caughtError =>
originalConsole.error("KSP frontend logging bridge failed", caughtError),
);
}
export function frontendInfo(targetId: FrontendLogTargetId, ...items: unknown[]): void {
const message = formatMessage(items);
originalConsole.info(message);
void sendFrontendLog("info", targetId, message).catch(caughtError =>
originalConsole.error("KSP frontend logging bridge failed", caughtError),
);
}
export function frontendWarn(targetId: FrontendLogTargetId, ...items: unknown[]): void {
const message = formatMessage(items);
originalConsole.warn(message);
void sendFrontendLog("warn", targetId, message).catch(caughtError =>
originalConsole.error("KSP frontend logging bridge failed", caughtError),
);
}
export function frontendError(targetId: FrontendLogTargetId, ...items: unknown[]): void {
const message = formatMessage(items);
originalConsole.error(message);
void sendFrontendLog("error", targetId, message).catch(caughtError =>
originalConsole.error("KSP frontend logging bridge failed", caughtError),
);
}
function buildConsoleBridge(level: FrontendLogLevel, targetId: FrontendLogTargetId, original: ConsoleMethod): ConsoleMethod {
return (...items: unknown[]) => {
original(...items);
void sendFrontendLog(level, targetId, formatMessage(items)).catch(caughtError =>
originalConsole.error("KSP frontend logging bridge failed", caughtError),
);
};
}
export function installFrontendConsoleBridge(targetId: FrontendLogTargetId): void {
console.trace = buildConsoleBridge("trace", targetId, originalConsole.trace);
console.debug = buildConsoleBridge("debug", targetId, originalConsole.debug);
console.log = buildConsoleBridge("info", targetId, originalConsole.log);
console.info = buildConsoleBridge("info", targetId, originalConsole.info);
console.warn = buildConsoleBridge("warn", targetId, originalConsole.warn);
console.error = buildConsoleBridge("error", targetId, originalConsole.error);
}

View File

@@ -0,0 +1,19 @@
// file: crates/ksp-app-config-desk/frontend/ts/invoke.ts
// version: 1
//! Shared Tauri invoke wrapper with bounded frontend lifecycle tracing.
import { invoke } from "@tauri-apps/api/core";
import { frontendDebug, frontendError, frontendTrace, type FrontendLogTargetId } from "./frontend_log";
export async function invokeKsp<T>(targetId: FrontendLogTargetId, command: string, args?: Record<string, unknown>): Promise<T> {
frontendDebug(targetId, "Frontend IPC command requested", { command });
try {
const result = await invoke<T>(command, args);
frontendTrace(targetId, "Frontend IPC command completed", { command });
return result;
} catch (caughtError) {
frontendError(targetId, "Frontend IPC command failed", { command });
throw caughtError;
}
}

View File

@@ -0,0 +1,779 @@
// file: crates/ksp-app-config-desk/frontend/ts/logging.ts
// version: 6
//! Typed Logging editor backed by ConfigManagement persistence and KSP-owned runtime hot reload.
import { Modal } from "bootstrap";
import type { LoggingDocumentCandidateDto } from "./bindings/ksp_app_config_desk/logging/LoggingDocumentCandidateDto";
import type { LoggingDocumentDto } from "./bindings/ksp_app_config_desk/logging/LoggingDocumentDto";
import type { LoggingDocumentSaveResultDto } from "./bindings/ksp_app_config_desk/logging/LoggingDocumentSaveResultDto";
import type { LoggingFileDto } from "./bindings/ksp_app_config_desk/logging/LoggingFileDto";
import type { LoggingProfileDto } from "./bindings/ksp_app_config_desk/logging/LoggingProfileDto";
import type { LoggingTargetFilterDto } from "./bindings/ksp_app_config_desk/logging/LoggingTargetFilterDto";
import type { LoggingRuntimeStatusDto } from "./bindings/ksp_app_config_desk/logging_runtime/LoggingRuntimeStatusDto";
import { frontendDebug, frontendTrace } from "./frontend_log";
import { invokeKsp } from "./invoke";
import { initializeLoggingTestPanel } from "./logging_test";
const LEVELS = ["off", "error", "warn", "info", "debug", "trace"] as const;
const ROTATIONS = ["never", "hourly", "daily"] as const;
const FORMATS = ["human", "compact", "pretty", "json"] as const;
let draft: LoggingDocumentCandidateDto | null = null;
let persistedDocument: LoggingDocumentDto | null = null;
let selectedProfileId = "";
let dirty = false;
function setLoggingStatus(message: string, tone: "primary" | "success" | "warning" | "danger" = "primary"): void {
const status = document.querySelector<HTMLElement>("#loggingStatus");
if (!status) {
return;
}
status.className = `alert alert-${tone} mb-0`;
status.textContent = message;
frontendTrace("main", "Logging editor status replaced", { tone });
}
function setText(selector: string, value: string): void {
const element = document.querySelector<HTMLElement>(selector);
if (element) {
element.textContent = value;
}
}
function inputValue(selector: string): string {
return document.querySelector<HTMLInputElement>(selector)?.value ?? "";
}
function selectedValue(selector: string): string {
return document.querySelector<HTMLSelectElement>(selector)?.value ?? "";
}
function checkboxValue(selector: string): boolean {
return document.querySelector<HTMLInputElement>(selector)?.checked ?? false;
}
function setInputValue(selector: string, value: string): void {
const input = document.querySelector<HTMLInputElement>(selector);
if (input) {
input.value = value;
}
}
function setSelectValue(selector: string, value: string): void {
const select = document.querySelector<HTMLSelectElement>(selector);
if (select) {
select.value = value;
}
}
function setCheckboxValue(selector: string, value: boolean): void {
const input = document.querySelector<HTMLInputElement>(selector);
if (input) {
input.checked = value;
}
}
function selectorText(values: string[]): string {
return values.join(", ");
}
function parseSelectors(value: string): string[] {
const result: string[] = [];
for (const part of value.split(",")) {
const trimmed = part.trim();
if (trimmed.length > 0 && !result.includes(trimmed)) {
result.push(trimmed);
}
}
return result;
}
function cloneProfile(profile: LoggingProfileDto): LoggingProfileDto {
return structuredClone(profile);
}
function selectedProfile(): LoggingProfileDto | null {
if (!draft) {
return null;
}
return draft.profiles.find(profile => profile.profileId === selectedProfileId) ?? null;
}
function markDirty(reason: string): void {
dirty = true;
const saveButton = document.querySelector<HTMLButtonElement>("#saveLoggingDocument");
if (saveButton) {
saveButton.disabled = false;
}
setLoggingStatus("Brouillon modifié. Sauvegarde nécessaire pour persister std.logging.json.", "warning");
updateRuntimeActionState();
frontendTrace("main", "Logging draft changed", { reason, selectedProfileId });
}
function setEditorEnabled(enabled: boolean): void {
const selectors = [
"#loggingLogsDirectoryInput",
"#loggingDefaultProfileSelect",
"#loggingProfileSelect",
"#loggingProfileIdInput",
"#createLoggingProfile",
"#cloneLoggingProfile",
"#renameLoggingProfile",
"#deleteLoggingProfile",
"#loggingProfileDefaultFilter",
"#loggingProfileSpanEvents",
"#loggingConsoleEnabled",
"#loggingConsoleAnsi",
"#loggingConsoleOutput",
"#loggingConsoleFormat",
"#loggingConsoleLevel",
"#loggingConsoleTargets",
"#loggingConsoleDomains",
"#addLoggingFile",
"#addLoggingTargetFilter",
];
for (const selector of selectors) {
const element = document.querySelector<HTMLInputElement | HTMLSelectElement | HTMLButtonElement>(selector);
if (element) {
element.disabled = !enabled;
}
}
for (const element of document.querySelectorAll<HTMLInputElement | HTMLSelectElement | HTMLButtonElement>(
"#loggingFilesTable input, #loggingFilesTable select, #loggingFilesTable button, #loggingTargetFiltersTable input, #loggingTargetFiltersTable select, #loggingTargetFiltersTable button",
)) {
element.disabled = !enabled;
}
const save = document.querySelector<HTMLButtonElement>("#saveLoggingDocument");
if (save) {
save.disabled = !enabled || !dirty;
}
}
function renderProfileSelects(): void {
const profileSelect = document.querySelector<HTMLSelectElement>("#loggingProfileSelect");
const defaultSelect = document.querySelector<HTMLSelectElement>("#loggingDefaultProfileSelect");
if (!draft || !profileSelect || !defaultSelect) {
return;
}
profileSelect.replaceChildren();
defaultSelect.replaceChildren();
for (const profile of draft.profiles) {
const profileOption = document.createElement("option");
profileOption.value = profile.profileId;
profileOption.textContent = profile.profileId === draft.defaultProfile ? `${profile.profileId} (default)` : profile.profileId;
profileSelect.append(profileOption);
const defaultOption = document.createElement("option");
defaultOption.value = profile.profileId;
defaultOption.textContent = profile.profileId;
defaultSelect.append(defaultOption);
}
if (!draft.profiles.some(profile => profile.profileId === selectedProfileId)) {
selectedProfileId = draft.profiles[0]?.profileId ?? "";
}
profileSelect.value = selectedProfileId;
defaultSelect.value = draft.defaultProfile;
frontendTrace("main", "Logging profile selectors replaced", { profileCount: draft.profiles.length });
}
function createSelect(values: readonly string[], value: string, onChange: (next: string) => void): HTMLSelectElement {
const select = document.createElement("select");
select.className = "form-select form-select-sm";
for (const item of values) {
const option = document.createElement("option");
option.value = item;
option.textContent = item;
select.append(option);
}
select.value = value;
select.addEventListener("change", () => onChange(select.value));
return select;
}
function createTextInput(value: string, onInput: (next: string) => void, monospace = false): HTMLInputElement {
const input = document.createElement("input");
input.type = "text";
input.autocomplete = "off";
input.className = `form-control form-control-sm${monospace ? " font-monospace" : ""}`;
input.value = value;
input.addEventListener("input", () => onInput(input.value));
return input;
}
function createCheckbox(value: boolean, onChange: (next: boolean) => void): HTMLInputElement {
const input = document.createElement("input");
input.type = "checkbox";
input.className = "form-check-input";
input.checked = value;
input.addEventListener("change", () => onChange(input.checked));
return input;
}
function appendControlCell(row: HTMLTableRowElement, control: HTMLElement): void {
const cell = document.createElement("td");
cell.append(control);
row.append(cell);
}
function renderFiles(profile: LoggingProfileDto): void {
const body = document.querySelector<HTMLTableSectionElement>("#loggingFilesTable tbody");
if (!body) {
return;
}
body.replaceChildren();
profile.files.forEach((file, index) => {
const row = document.createElement("tr");
appendControlCell(row, createTextInput(file.outputId, value => { file.outputId = value; markDirty("file.output_id"); }, true));
appendControlCell(row, createCheckbox(file.enabled, value => { file.enabled = value; markDirty("file.enabled"); }));
appendControlCell(row, createTextInput(file.path, value => { file.path = value; markDirty("file.path"); }, true));
appendControlCell(row, createSelect(ROTATIONS, file.rotation, value => { file.rotation = value; markDirty("file.rotation"); }));
appendControlCell(row, createSelect(FORMATS, file.format, value => { file.format = value; markDirty("file.format"); }));
appendControlCell(row, createSelect(LEVELS, file.filter.level, value => { file.filter.level = value; markDirty("file.filter.level"); }));
appendControlCell(row, createTextInput(selectorText(file.filter.targets), value => { file.filter.targets = parseSelectors(value); markDirty("file.filter.targets"); }, true));
appendControlCell(row, createTextInput(selectorText(file.filter.domains), value => { file.filter.domains = parseSelectors(value); markDirty("file.filter.domains"); }, true));
const remove = document.createElement("button");
remove.type = "button";
remove.className = "btn btn-outline-danger btn-sm";
remove.textContent = "Retirer";
remove.addEventListener("click", () => {
profile.files.splice(index, 1);
markDirty("file.remove");
renderFiles(profile);
frontendDebug("main", "Logging file sink removed from draft", { profileId: profile.profileId, fileIndex: index });
});
appendControlCell(row, remove);
body.append(row);
});
frontendTrace("main", "Logging file sink editor DOM replaced", { fileCount: profile.files.length });
}
function renderTargetFilters(profile: LoggingProfileDto): void {
const body = document.querySelector<HTMLTableSectionElement>("#loggingTargetFiltersTable tbody");
if (!body) {
return;
}
body.replaceChildren();
profile.targetFilters.forEach((filter, index) => {
const row = document.createElement("tr");
appendControlCell(row, createTextInput(filter.targetPrefix, value => { filter.targetPrefix = value; markDirty("target_filter.prefix"); }, true));
appendControlCell(row, createSelect(LEVELS, filter.level, value => { filter.level = value; markDirty("target_filter.level"); }));
const remove = document.createElement("button");
remove.type = "button";
remove.className = "btn btn-outline-danger btn-sm";
remove.textContent = "Retirer";
remove.addEventListener("click", () => {
profile.targetFilters.splice(index, 1);
markDirty("target_filter.remove");
renderTargetFilters(profile);
frontendDebug("main", "Logging target filter removed from draft", { profileId: profile.profileId, filterIndex: index });
});
appendControlCell(row, remove);
body.append(row);
});
frontendTrace("main", "Logging target filter editor DOM replaced", { targetFilterCount: profile.targetFilters.length });
}
function renderSelectedProfile(source: "load" | "selection" | "mutation"): void {
const profile = selectedProfile();
if (!profile) {
setEditorEnabled(false);
return;
}
setSelectValue("#loggingProfileDefaultFilter", profile.defaultFilter);
setSelectValue("#loggingProfileSpanEvents", profile.spanEvents);
setCheckboxValue("#loggingConsoleEnabled", profile.console.enabled);
setCheckboxValue("#loggingConsoleAnsi", profile.console.ansi);
setSelectValue("#loggingConsoleOutput", profile.console.output);
setSelectValue("#loggingConsoleFormat", profile.console.format);
setSelectValue("#loggingConsoleLevel", profile.console.filter.level);
setInputValue("#loggingConsoleTargets", selectorText(profile.console.filter.targets));
setInputValue("#loggingConsoleDomains", selectorText(profile.console.filter.domains));
renderFiles(profile);
renderTargetFilters(profile);
setEditorEnabled(true);
frontendTrace("main", "Logging profile editor rendered", {
profileId: profile.profileId,
source,
fileCount: profile.files.length,
targetFilterCount: profile.targetFilters.length,
});
}
function updateRuntimeActionState(): void {
const select = document.querySelector<HTMLSelectElement>("#loggingRuntimeProfileSelect");
const apply = document.querySelector<HTMLButtonElement>("#applyLoggingRuntimeProfile");
if (select) {
select.disabled = persistedDocument === null || dirty;
}
if (apply) {
apply.disabled = persistedDocument === null || dirty || !select || select.value.length === 0;
}
}
function renderRuntimeProfileSelect(): void {
const select = document.querySelector<HTMLSelectElement>("#loggingRuntimeProfileSelect");
if (!select || !persistedDocument) {
updateRuntimeActionState();
return;
}
const previous = select.value;
select.replaceChildren();
for (const profile of persistedDocument.profiles) {
const option = document.createElement("option");
option.value = profile.profileId;
option.textContent = profile.profileId === persistedDocument.defaultProfile ? `${profile.profileId} (default)` : profile.profileId;
select.append(option);
}
if (persistedDocument.profiles.some(profile => profile.profileId === previous)) {
select.value = previous;
} else {
select.value = persistedDocument.defaultProfile;
}
updateRuntimeActionState();
}
function renderRuntimeFiles(status: LoggingRuntimeStatusDto): void {
const body = document.querySelector<HTMLTableSectionElement>("#loggingRuntimeFilesTable tbody");
if (!body) {
return;
}
body.replaceChildren();
for (const file of status.files) {
const row = document.createElement("tr");
const values: Array<{ value: string; monospace: boolean }> = [
{ value: file.outputId, monospace: true },
{ value: file.directory, monospace: true },
{ value: file.fileNamePrefix, monospace: true },
{ value: file.rotation, monospace: false },
];
for (const item of values) {
const cell = document.createElement("td");
cell.textContent = item.value;
if (item.monospace) {
cell.classList.add("font-monospace");
}
row.append(cell);
}
body.append(row);
}
}
function renderRuntimeStatus(status: LoggingRuntimeStatusDto): void {
setText("#loggingRuntimeActiveProfile", status.activeProfile ?? "fallback transitoire");
setText("#loggingRuntimeSelectionSource", status.selectionSource);
setText("#loggingRuntimeGeneration", status.generation.toString());
setText("#loggingRuntimeFallback", status.fallbackActive ? "oui" : "non");
setText("#loggingRuntimeApplicationId", status.applicationId);
setText("#loggingRuntimeLaunchTimestamp", status.launchTimestamp);
setText("#loggingRuntimeConsole", status.consoleEnabled ? "active" : "désactivée");
setText("#loggingRuntimeDropped", `${status.droppedTotalLines} total · console=${status.droppedConsoleLines} · fichiers=${status.droppedFileLines}`);
renderRuntimeFiles(status);
const select = document.querySelector<HTMLSelectElement>("#loggingRuntimeProfileSelect");
if (select && status.activeProfile && persistedDocument?.profiles.some(profile => profile.profileId === status.activeProfile)) {
select.value = status.activeProfile;
}
updateRuntimeActionState();
frontendTrace("main", "Logging runtime metadata rendered", {
generation: status.generation,
selectionSource: status.selectionSource,
fileCount: status.files.length,
consoleEnabled: status.consoleEnabled,
});
}
async function refreshRuntimeStatus(source: "startup" | "save" | "explicit" | "user"): Promise<void> {
if (source === "user") {
frontendDebug("main", "Logging runtime metadata refresh requested");
}
try {
const status = await invokeKsp<LoggingRuntimeStatusDto>("main", "get_logging_runtime_status");
renderRuntimeStatus(status);
frontendDebug("main", "Logging runtime metadata refresh completed", { generation: status.generation, activeProfile: status.activeProfile });
} catch {
setText("#loggingRuntimeActiveProfile", "indisponible");
}
}
async function applyRuntimeProfile(): Promise<void> {
if (dirty) {
setLoggingStatus("Sauvegarde ou abandonne d'abord le brouillon avant d'appliquer un profil runtime explicite.", "warning");
return;
}
const profileId = selectedValue("#loggingRuntimeProfileSelect");
if (profileId.length === 0) {
return;
}
const apply = document.querySelector<HTMLButtonElement>("#applyLoggingRuntimeProfile");
if (apply) {
apply.disabled = true;
}
setLoggingStatus(`Application explicite du profil runtime ${profileId}...`);
frontendDebug("main", "Explicit Logging runtime profile apply requested", { profileId });
try {
const status = await invokeKsp<LoggingRuntimeStatusDto>("main", "apply_logging_profile", { profileId });
renderRuntimeStatus(status);
window.dispatchEvent(new CustomEvent("ksp:logging-runtime-updated"));
setLoggingStatus(`Profil ${profileId} appliqué au runtime sans modifier default_profile. Génération ${status.generation}.`, "success");
frontendDebug("main", "Explicit Logging runtime profile apply completed", { profileId, generation: status.generation });
} catch {
updateRuntimeActionState();
setLoggingStatus("Application explicite refusée. Le runtime précédent et sa génération sont conservés.", "danger");
}
}
function renderDocument(documentDto: LoggingDocumentDto): void {
persistedDocument = structuredClone(documentDto);
draft = {
logsDirectory: documentDto.logsDirectory,
defaultProfile: documentDto.defaultProfile,
profiles: documentDto.profiles.map(profile => cloneProfile(profile)),
};
dirty = false;
selectedProfileId = draft.profiles.some(profile => profile.profileId === selectedProfileId) ? selectedProfileId : draft.defaultProfile;
if (!draft.profiles.some(profile => profile.profileId === selectedProfileId)) {
selectedProfileId = draft.profiles[0]?.profileId ?? "";
}
setText("#loggingFileId", documentDto.fileId);
setText("#loggingPath", documentDto.path);
setText("#loggingFormatVersion", documentDto.formatVersion.toString());
setInputValue("#loggingLogsDirectoryInput", draft.logsDirectory);
renderProfileSelects();
renderRuntimeProfileSelect();
renderSelectedProfile("load");
setText("#loggingSaveReport", "Aucune sauvegarde effectuée depuis le dernier chargement.");
frontendTrace("main", "Logging document editor rendered", { profileCount: documentDto.profiles.length });
}
function defaultProfile(profileId: string): LoggingProfileDto {
return {
profileId,
defaultFilter: "warn",
spanEvents: "new_and_close",
console: {
enabled: true,
output: "stderr",
ansi: true,
format: "compact",
filter: { level: "info", targets: ["*"], domains: ["*"] },
},
files: [],
targetFilters: [],
};
}
function targetProfileId(): string {
return inputValue("#loggingProfileIdInput").trim();
}
function profileIdAvailable(profileId: string, exceptProfileId?: string): boolean {
if (!draft || profileId.length === 0) {
return false;
}
return !draft.profiles.some(profile => profile.profileId === profileId && profile.profileId !== exceptProfileId);
}
function createProfile(): void {
if (!draft) {
return;
}
const profileId = targetProfileId();
if (!profileIdAvailable(profileId)) {
setLoggingStatus("L'ID cible est vide ou existe déjà dans le brouillon.", "danger");
return;
}
draft.profiles.push(defaultProfile(profileId));
selectedProfileId = profileId;
setInputValue("#loggingProfileIdInput", "");
markDirty("profile.create");
renderProfileSelects();
renderSelectedProfile("mutation");
frontendDebug("main", "Logging profile created in draft", { profileId });
}
function cloneSelectedProfile(): void {
if (!draft) {
return;
}
const source = selectedProfile();
const profileId = targetProfileId();
if (!source || !profileIdAvailable(profileId)) {
setLoggingStatus("Sélectionne un profil et fournis un ID cible libre pour le clone.", "danger");
return;
}
const clone = cloneProfile(source);
clone.profileId = profileId;
draft.profiles.push(clone);
selectedProfileId = profileId;
setInputValue("#loggingProfileIdInput", "");
markDirty("profile.clone");
renderProfileSelects();
renderSelectedProfile("mutation");
frontendDebug("main", "Logging profile cloned in draft", { sourceProfileId: source.profileId, profileId });
}
function renameSelectedProfile(): void {
if (!draft) {
return;
}
const profile = selectedProfile();
const profileId = targetProfileId();
if (!profile || !profileIdAvailable(profileId, profile.profileId)) {
setLoggingStatus("Fournis un nouvel ID de profil libre avant de renommer.", "danger");
return;
}
const previousId = profile.profileId;
profile.profileId = profileId;
if (draft.defaultProfile === previousId) {
draft.defaultProfile = profileId;
}
selectedProfileId = profileId;
setInputValue("#loggingProfileIdInput", "");
markDirty("profile.rename");
renderProfileSelects();
renderSelectedProfile("mutation");
frontendDebug("main", "Logging profile renamed in draft", { previousId, profileId });
}
function confirmDeleteSelectedProfile(): void {
if (!draft || draft.profiles.length <= 1) {
setLoggingStatus("Le document Logging doit conserver au moins un profil.", "danger");
return;
}
const profile = selectedProfile();
const modalElement = document.querySelector<HTMLElement>("#loggingProfileDeleteModal");
if (!profile || !modalElement) {
return;
}
setText("#loggingProfileDeleteName", profile.profileId);
modalElement.dataset.profileId = profile.profileId;
frontendDebug("main", "Logging profile draft deletion confirmation opened", { profileId: profile.profileId });
Modal.getOrCreateInstance(modalElement).show();
}
function deleteConfirmedProfile(): void {
if (!draft) {
return;
}
const modalElement = document.querySelector<HTMLElement>("#loggingProfileDeleteModal");
const profileId = modalElement?.dataset.profileId ?? "";
const index = draft.profiles.findIndex(profile => profile.profileId === profileId);
if (index < 0 || draft.profiles.length <= 1) {
return;
}
draft.profiles.splice(index, 1);
if (draft.defaultProfile === profileId) {
draft.defaultProfile = draft.profiles[0].profileId;
}
selectedProfileId = draft.profiles[Math.min(index, draft.profiles.length - 1)].profileId;
markDirty("profile.delete");
renderProfileSelects();
renderSelectedProfile("mutation");
if (modalElement) {
Modal.getOrCreateInstance(modalElement).hide();
delete modalElement.dataset.profileId;
}
frontendDebug("main", "Logging profile removed from draft", { profileId });
}
function addFile(): void {
const profile = selectedProfile();
if (!profile) {
return;
}
const file: LoggingFileDto = {
outputId: `file.new.${profile.files.length + 1}`,
enabled: true,
path: `new/output-${profile.files.length + 1}.log`,
rotation: "daily",
format: "human",
ansi: false,
filter: { level: "info", targets: ["*"], domains: ["*"] },
};
profile.files.push(file);
markDirty("file.add");
renderFiles(profile);
frontendDebug("main", "Logging file sink added to draft", { profileId: profile.profileId, fileCount: profile.files.length });
}
function addTargetFilter(): void {
const profile = selectedProfile();
if (!profile) {
return;
}
const filter: LoggingTargetFilterDto = { targetPrefix: "ksp-", level: "info" };
profile.targetFilters.push(filter);
markDirty("target_filter.add");
renderTargetFilters(profile);
frontendDebug("main", "Logging target filter added to draft", { profileId: profile.profileId, targetFilterCount: profile.targetFilters.length });
}
function syncStaticProfileFields(): void {
const profile = selectedProfile();
if (!profile) {
return;
}
profile.defaultFilter = selectedValue("#loggingProfileDefaultFilter");
profile.spanEvents = selectedValue("#loggingProfileSpanEvents");
profile.console.enabled = checkboxValue("#loggingConsoleEnabled");
profile.console.ansi = checkboxValue("#loggingConsoleAnsi");
profile.console.output = selectedValue("#loggingConsoleOutput");
profile.console.format = selectedValue("#loggingConsoleFormat");
profile.console.filter.level = selectedValue("#loggingConsoleLevel");
profile.console.filter.targets = parseSelectors(inputValue("#loggingConsoleTargets"));
profile.console.filter.domains = parseSelectors(inputValue("#loggingConsoleDomains"));
}
async function saveLoggingDocument(): Promise<void> {
if (!draft) {
return;
}
syncStaticProfileFields();
draft.logsDirectory = inputValue("#loggingLogsDirectoryInput");
draft.defaultProfile = selectedValue("#loggingDefaultProfileSelect");
setEditorEnabled(false);
setLoggingStatus("Validation, sauvegarde atomique et application hot reload du Logging...");
frontendDebug("main", "Typed Logging document persistence and runtime apply requested", {
profileCount: draft.profiles.length,
defaultProfile: draft.defaultProfile,
fileCount: draft.profiles.reduce((count, profile) => count + profile.files.length, 0),
});
try {
const result = await invokeKsp<LoggingDocumentSaveResultDto>("main", "save_logging_document", { candidate: draft });
renderDocument(result.document);
await refreshRuntimeStatus("save");
window.dispatchEvent(new CustomEvent("ksp:logging-runtime-updated"));
setText(
"#loggingSaveReport",
`source_changed=${result.sourceChanged} · reload_required=${result.reloadRequired} · runtime_applied=${result.runtimeApplied} · generation=${result.loggingGeneration} · active_profile=${result.activeProfile}`,
);
setLoggingStatus(
`Document Logging validé, persisté et appliqué sans redémarrage. Runtime génération ${result.loggingGeneration}, profil ${result.activeProfile}.`,
"success",
);
frontendDebug("main", "Typed Logging document persistence and runtime apply completed", {
sourceChanged: result.sourceChanged,
reloadRequired: result.reloadRequired,
runtimeApplied: result.runtimeApplied,
loggingGeneration: result.loggingGeneration,
activeProfile: result.activeProfile,
profileCount: result.document.profiles.length,
});
} catch {
setEditorEnabled(true);
setLoggingStatus(
"Sauvegarde/application Logging refusée. Le runtime précédent est conservé ; si la persistence avait commencé, la transaction restaure la source précédente ou remonte une erreur de rollback.",
"danger",
);
}
}
function requestReload(): void {
if (!dirty) {
void refreshLoggingDocument("user");
return;
}
const modalElement = document.querySelector<HTMLElement>("#loggingDiscardDraftModal");
if (!modalElement) {
return;
}
frontendDebug("main", "Logging dirty draft discard confirmation opened");
Modal.getOrCreateInstance(modalElement).show();
}
function confirmReload(): void {
const modalElement = document.querySelector<HTMLElement>("#loggingDiscardDraftModal");
if (modalElement) {
Modal.getOrCreateInstance(modalElement).hide();
}
frontendDebug("main", "Logging dirty draft discard confirmed");
void refreshLoggingDocument("user");
}
async function refreshLoggingDocument(source: "startup" | "user"): Promise<void> {
if (source === "user") {
frontendDebug("main", "Logging document reload requested", { dirty });
}
setEditorEnabled(false);
setLoggingStatus("Chargement du document Logging typé...");
try {
const documentDto = await invokeKsp<LoggingDocumentDto>("main", "get_logging_document");
renderDocument(documentDto);
setLoggingStatus(
`${documentDto.profiles.length} profil(s) Logging chargé(s). Le brouillon est synchronisé avec la source persistée ; cette action ne modifie pas le runtime actif.`,
"success",
);
frontendDebug("main", "Logging document reload completed", {
profileCount: documentDto.profiles.length,
defaultProfile: documentDto.defaultProfile,
});
await refreshRuntimeStatus(source === "startup" ? "startup" : "user");
} catch {
draft = null;
persistedDocument = null;
setEditorEnabled(false);
updateRuntimeActionState();
setLoggingStatus("Le document Logging typé n'a pas pu être chargé. Consulte les diagnostics backend.", "danger");
}
}
function bindStaticDraftFields(): void {
document.querySelector<HTMLInputElement>("#loggingLogsDirectoryInput")?.addEventListener("input", () => {
if (draft) {
draft.logsDirectory = inputValue("#loggingLogsDirectoryInput");
markDirty("logs_directory");
}
});
document.querySelector<HTMLSelectElement>("#loggingDefaultProfileSelect")?.addEventListener("change", () => {
if (draft) {
draft.defaultProfile = selectedValue("#loggingDefaultProfileSelect");
markDirty("default_profile");
renderProfileSelects();
}
});
const profileSelectors = ["#loggingProfileDefaultFilter", "#loggingProfileSpanEvents", "#loggingConsoleOutput", "#loggingConsoleFormat", "#loggingConsoleLevel"];
for (const selector of profileSelectors) {
document.querySelector<HTMLSelectElement>(selector)?.addEventListener("change", () => {
syncStaticProfileFields();
markDirty(selector);
});
}
for (const selector of ["#loggingConsoleEnabled", "#loggingConsoleAnsi"]) {
document.querySelector<HTMLInputElement>(selector)?.addEventListener("change", () => {
syncStaticProfileFields();
markDirty(selector);
});
}
for (const selector of ["#loggingConsoleTargets", "#loggingConsoleDomains"]) {
document.querySelector<HTMLInputElement>(selector)?.addEventListener("input", () => {
syncStaticProfileFields();
markDirty(selector);
});
}
}
/// Initializes the typed Logging editor surface.
export function initializeLoggingPanel(): void {
bindStaticDraftFields();
document.querySelector<HTMLButtonElement>("#refreshLoggingDocument")?.addEventListener("click", requestReload);
document.querySelector<HTMLButtonElement>("#saveLoggingDocument")?.addEventListener("click", () => void saveLoggingDocument());
document.querySelector<HTMLButtonElement>("#refreshLoggingRuntime")?.addEventListener("click", () => void refreshRuntimeStatus("user"));
document.querySelector<HTMLButtonElement>("#applyLoggingRuntimeProfile")?.addEventListener("click", () => void applyRuntimeProfile());
document.querySelector<HTMLSelectElement>("#loggingRuntimeProfileSelect")?.addEventListener("change", updateRuntimeActionState);
document.querySelector<HTMLSelectElement>("#loggingProfileSelect")?.addEventListener("change", event => {
syncStaticProfileFields();
selectedProfileId = (event.currentTarget as HTMLSelectElement).value;
frontendDebug("main", "Logging profile selection changed", { profileId: selectedProfileId });
renderSelectedProfile("selection");
});
document.querySelector<HTMLButtonElement>("#createLoggingProfile")?.addEventListener("click", createProfile);
document.querySelector<HTMLButtonElement>("#cloneLoggingProfile")?.addEventListener("click", cloneSelectedProfile);
document.querySelector<HTMLButtonElement>("#renameLoggingProfile")?.addEventListener("click", renameSelectedProfile);
document.querySelector<HTMLButtonElement>("#deleteLoggingProfile")?.addEventListener("click", confirmDeleteSelectedProfile);
document.querySelector<HTMLButtonElement>("#loggingProfileDeleteConfirm")?.addEventListener("click", deleteConfirmedProfile);
document.querySelector<HTMLButtonElement>("#loggingDiscardDraftConfirm")?.addEventListener("click", confirmReload);
document.querySelector<HTMLButtonElement>("#addLoggingFile")?.addEventListener("click", addFile);
document.querySelector<HTMLButtonElement>("#addLoggingTargetFilter")?.addEventListener("click", addTargetFilter);
initializeLoggingTestPanel();
frontendTrace("main", "Logging editor mutation handlers installed");
void refreshLoggingDocument("startup");
}

View File

@@ -0,0 +1,122 @@
// file: crates/ksp-app-config-desk/frontend/ts/logging_test.ts
// version: 1
//! Controlled Logging test panel for proving backend routing and the frontend bridge.
import type { LoggingTestRequestDto } from "./bindings/ksp_app_config_desk/logging_test/LoggingTestRequestDto";
import type { LoggingTestResultDto } from "./bindings/ksp_app_config_desk/logging_test/LoggingTestResultDto";
import { emitFrontendLog, frontendTrace, type FrontendLogLevel } from "./frontend_log";
import { invokeKsp } from "./invoke";
const FRONTEND_BRIDGE_TARGET = "ksp-app-config-desk.frontend.main";
const FRONTEND_BRIDGE_DOMAIN = "frontend";
function inputValue(selector: string): string {
return document.querySelector<HTMLInputElement>(selector)?.value ?? "";
}
function selectedValue(selector: string): string {
return document.querySelector<HTMLSelectElement>(selector)?.value ?? "";
}
function setStatus(message: string, tone: "secondary" | "success" | "warning" | "danger" = "secondary"): void {
const status = document.querySelector<HTMLElement>("#loggingTestStatus");
if (!status) {
return;
}
status.className = `alert alert-${tone} mb-0`;
status.textContent = message;
}
function updateDomainControls(): void {
const mode = selectedValue("#loggingTestDomainMode");
const known = document.querySelector<HTMLSelectElement>("#loggingTestKnownDomain");
const custom = document.querySelector<HTMLInputElement>("#loggingTestCustomDomain");
if (known) {
known.disabled = mode !== "known";
}
if (custom) {
custom.disabled = mode !== "custom";
}
}
function effectiveDomain(): string {
const mode = selectedValue("#loggingTestDomainMode");
if (mode === "known") {
return selectedValue("#loggingTestKnownDomain");
}
if (mode === "custom") {
return inputValue("#loggingTestCustomDomain");
}
return "";
}
function buildRequest(): LoggingTestRequestDto {
return {
level: selectedValue("#loggingTestLevel"),
targetId: selectedValue("#loggingTestTarget"),
domainMode: selectedValue("#loggingTestDomainMode"),
domain: effectiveDomain(),
message: inputValue("#loggingTestMessage"),
};
}
async function emitBackendTest(): Promise<void> {
const request = buildRequest();
setStatus("Émission backend en cours...");
try {
const result = await invokeKsp<LoggingTestResultDto>("main", "emit_logging_test", { request });
const domain = result.domain ?? "<absent>";
const summary = `${result.emittedEventCount} événement(s) backend émis · niveau=${result.requestedLevel} · target=${result.target}`
+ ` · domain=${domain} · génération=${result.loggingGeneration}.`;
setStatus(summary, "success");
} catch {
setStatus("Émission backend refusée. Vérifie le message, le target et le domain sélectionnés.", "danger");
}
}
async function emitFrontendLevel(level: FrontendLogLevel, message: string): Promise<void> {
await emitFrontendLog(level, "main", message);
}
async function emitFrontendBridgeTest(): Promise<void> {
const message = inputValue("#loggingTestMessage").trim();
if (message.length === 0 || message.length > 500) {
setStatus("Le message de test frontend doit contenir entre 1 et 500 caractères.", "danger");
return;
}
const level = selectedValue("#loggingTestLevel");
setStatus("Émission via bridge frontend en cours...");
try {
if (level === "all") {
for (const item of ["trace", "debug", "info", "warn", "error"] as const) {
await emitFrontendLevel(item, `[logging-test frontend/${item}] ${message}`);
}
setStatus(
`5 événements acceptés par le bridge frontend · target=${FRONTEND_BRIDGE_TARGET} · domain=${FRONTEND_BRIDGE_DOMAIN}.`,
"success",
);
return;
}
if (level === "trace" || level === "debug" || level === "info" || level === "warn" || level === "error") {
await emitFrontendLevel(level, `[logging-test frontend/${level}] ${message}`);
setStatus(
`1 événement accepté par le bridge frontend · target=${FRONTEND_BRIDGE_TARGET} · domain=${FRONTEND_BRIDGE_DOMAIN}.`,
"success",
);
return;
}
setStatus("Le niveau de test frontend sélectionné n'est pas supporté.", "danger");
} catch {
setStatus("Le bridge frontend n'a pas accepté l'événement de test.", "danger");
}
}
/// Initializes the controlled Logging test surface.
export function initializeLoggingTestPanel(): void {
document.querySelector<HTMLSelectElement>("#loggingTestDomainMode")?.addEventListener("change", updateDomainControls);
document.querySelector<HTMLButtonElement>("#emitLoggingTestBackend")?.addEventListener("click", () => void emitBackendTest());
document.querySelector<HTMLButtonElement>("#emitLoggingTestFrontend")?.addEventListener("click", () => void emitFrontendBridgeTest());
updateDomainControls();
frontendTrace("main", "Logging test panel handlers installed");
}

View File

@@ -0,0 +1,144 @@
// file: crates/ksp-app-config-desk/frontend/ts/main.ts
// version: 13
import "bootstrap";
import ResizeObserver from "resize-observer-polyfill";
import "simplebar";
import { getCurrentWindow } from "@tauri-apps/api/window";
import type { AppSnapshotDto } from "./bindings/ksp_app_config_desk/dto_common/AppSnapshotDto.ts";
import { frontendDebug, frontendInfo, frontendTrace, installFrontendConsoleBridge } from "./frontend_log";
import { initializeDocumentsPanel } from "./documents";
import { initializeEnvironmentPanel } from "./environment";
import { initializeLoggingPanel } from "./logging";
import { initializeProfilesPanel } from "./profiles";
import { invokeKsp } from "./invoke";
import { isViewId, registeredViewIds, viewRegistry, type ViewId } from "./shell_registry";
import { clearTransientSecretReveal } from "./secret_reveal";
(window as Window & typeof globalThis & { ResizeObserver?: typeof ResizeObserver }).ResizeObserver = ResizeObserver;
installFrontendConsoleBridge("main");
function activateView(viewId: ViewId, source: "startup" | "user" | "registry"): void {
if (viewId !== "environment") {
clearTransientSecretReveal();
}
if (source !== "startup") {
frontendDebug("main", "Main navigation activated", { viewId, source });
}
const descriptor = viewRegistry[viewId];
const title = document.querySelector<HTMLElement>("#viewTitle");
const headerTitle = document.querySelector<HTMLElement>("#headerViewTitle");
const description = document.querySelector<HTMLElement>("#viewDescription");
if (title) {
title.textContent = descriptor.title;
}
if (headerTitle) {
headerTitle.textContent = descriptor.title;
}
document.title = `Config Desk — ${descriptor.title}`;
if (description) {
description.textContent = descriptor.description;
}
document.querySelectorAll<HTMLButtonElement>("[data-view]").forEach(button => {
const active = button.dataset.view === viewId;
button.classList.toggle("active", active);
button.setAttribute("aria-current", active ? "page" : "false");
});
frontendTrace("main", "Main navigation tab state changed", { viewId, source });
for (const registeredViewId of registeredViewIds()) {
const panel = document.querySelector<HTMLElement>(`#${viewRegistry[registeredViewId].panelId}`);
if (panel) {
panel.hidden = registeredViewId !== viewId;
}
}
frontendTrace("main", "Main view DOM updated", { viewId, source });
}
function bindNavigation(): void {
document.querySelectorAll<HTMLButtonElement>("[data-view]").forEach(button => {
button.addEventListener("click", () => {
const requestedView = button.dataset.view;
frontendTrace("main", "Main navigation tab clicked", { requestedView: requestedView ?? null });
if (requestedView && isViewId(requestedView)) {
activateView(requestedView, "user");
}
});
});
window.addEventListener("ksp:activate-view", event => {
const detail = event instanceof CustomEvent ? event.detail : null;
const requestedView = typeof detail === "object" && detail !== null && "viewId" in detail ? String(detail.viewId) : "";
if (isViewId(requestedView)) {
activateView(requestedView, "registry");
}
});
frontendTrace("main", "Main navigation handlers installed");
}
function renderSnapshot(snapshot: AppSnapshotDto): void {
const version = document.querySelector<HTMLElement>("#overviewVersion");
const documents = document.querySelector<HTMLElement>("#overviewDocuments");
const profile = document.querySelector<HTMLElement>("#overviewLoggingProfile");
const generation = document.querySelector<HTMLElement>("#overviewLoggingGeneration");
const fallback = document.querySelector<HTMLElement>("#overviewLoggingFallback");
if (version) {
version.textContent = snapshot.applicationVersion;
}
if (documents) {
documents.textContent = snapshot.configDocumentCount.toString();
}
if (profile) {
profile.textContent = snapshot.activeLoggingProfile ?? "fallback transitoire";
}
if (generation) {
generation.textContent = snapshot.loggingGeneration.toString();
}
if (fallback) {
fallback.textContent = snapshot.fallbackLoggingActive ? "oui" : "non";
}
frontendTrace("main", "Application snapshot rendered", {
loggingGeneration: snapshot.loggingGeneration,
fallbackLoggingActive: snapshot.fallbackLoggingActive,
});
}
async function loadSnapshot(): Promise<void> {
const status = document.querySelector<HTMLElement>("#shellStatus");
const snapshot = await invokeKsp<AppSnapshotDto>("main", "get_app_snapshot");
renderSnapshot(snapshot);
if (status) {
status.textContent = "Shell principal prêt.";
}
frontendTrace("main", "Main shell status replaced", { status: "ready" });
}
function bindRuntimeSnapshotRefresh(): void {
window.addEventListener("ksp:logging-runtime-updated", () => {
frontendDebug("main", "Application snapshot refresh requested after Logging runtime update");
void loadSnapshot();
});
}
async function initializeMain(): Promise<void> {
const windowLabel = getCurrentWindow().label;
frontendInfo("main", "Config Desk main frontend loaded", { windowLabel });
bindNavigation();
bindRuntimeSnapshotRefresh();
initializeDocumentsPanel();
initializeProfilesPanel();
initializeEnvironmentPanel();
initializeLoggingPanel();
activateView("overview", "startup");
try {
await loadSnapshot();
} catch {
const status = document.querySelector<HTMLElement>("#shellStatus");
if (status) {
status.textContent = "Le snapshot initial n'a pas pu être chargé.";
}
frontendTrace("main", "Main shell status replaced", { status: "snapshot_error" });
}
}
document.addEventListener("DOMContentLoaded", () => {
void initializeMain();
});

View File

@@ -0,0 +1,261 @@
// file: crates/ksp-app-config-desk/frontend/ts/profiles.ts
// version: 1
import type { ConfigProfileDetailDto } from "./bindings/ksp_app_config_desk/profiles/ConfigProfileDetailDto.ts";
import type { ConfigProfileDocumentDto } from "./bindings/ksp_app_config_desk/profiles/ConfigProfileDocumentDto.ts";
import { frontendDebug, frontendTrace } from "./frontend_log";
import { invokeKsp } from "./invoke";
let profileDocuments: ConfigProfileDocumentDto[] = [];
let activeDetail: ConfigProfileDetailDto | null = null;
function setProfilesStatus(message: string, tone: "primary" | "success" | "warning" | "danger" = "primary"): void {
const status = document.querySelector<HTMLElement>("#profilesStatus");
if (!status) {
return;
}
status.className = `alert alert-${tone} mb-0`;
status.textContent = message;
frontendTrace("main", "Profiles panel status replaced", { tone });
}
function setText(selector: string, value: string): void {
const element = document.querySelector<HTMLElement>(selector);
if (element) {
element.textContent = value;
}
}
function setCode(selector: string, value: string): void {
const element = document.querySelector<HTMLElement>(selector);
if (element) {
element.textContent = value;
}
}
function sourceLabel(source: string): string {
if (source === "default_profile") {
return "default_profile";
}
if (source === "explicit") {
return "sélection explicite";
}
if (source === "composite") {
return "composite";
}
return source;
}
function sensitivityLabel(value: string): string {
if (value === "public") {
return "Public";
}
if (value === "internal") {
return "Internal";
}
if (value === "secret") {
return "Secret";
}
return value;
}
function populateDocumentSelect(documents: ConfigProfileDocumentDto[]): void {
const select = document.querySelector<HTMLSelectElement>("#profileDocumentSelect");
if (!select) {
return;
}
select.replaceChildren();
for (const item of documents) {
const option = document.createElement("option");
option.value = item.fileId;
option.textContent = `${item.fileId}${item.defaultProfile}`;
select.append(option);
}
select.disabled = documents.length === 0;
frontendTrace("main", "Profile document select DOM replaced", { documentCount: documents.length });
}
function populateProfileSelect(detail: ConfigProfileDetailDto): void {
const select = document.querySelector<HTMLSelectElement>("#profileIdSelect");
if (!select) {
return;
}
select.replaceChildren();
for (const profileId of detail.profileIds) {
const option = document.createElement("option");
option.value = profileId;
option.textContent = profileId === detail.defaultProfile ? `${profileId} (défaut)` : profileId;
option.selected = profileId === detail.selectedProfile;
select.append(option);
}
select.disabled = detail.profileIds.length === 0;
frontendTrace("main", "Profile id select DOM replaced", { profileCount: detail.profileIds.length });
}
function renderValueOrigins(detail: ConfigProfileDetailDto): void {
const body = document.querySelector<HTMLTableSectionElement>("#profileOriginsTable tbody");
if (!body) {
return;
}
body.replaceChildren();
for (const entry of detail.valueOrigins) {
const row = document.createElement("tr");
const key = document.createElement("td");
const origin = document.createElement("td");
key.textContent = entry.key;
origin.textContent = entry.origin === "global" ? "global" : entry.origin === "profile" ? "profil" : entry.origin;
row.append(key, origin);
body.append(row);
}
frontendTrace("main", "Profile top-level provenance table DOM replaced", { entryCount: detail.valueOrigins.length });
}
function renderEnvironmentProvenance(detail: ConfigProfileDetailDto): void {
const body = document.querySelector<HTMLTableSectionElement>("#profileEnvironmentProvenanceTable tbody");
if (!body) {
return;
}
body.replaceChildren();
for (const entry of detail.environmentProvenance) {
const row = document.createElement("tr");
for (const value of [entry.jsonPointer, entry.variableName, entry.source, sensitivityLabel(entry.sensitivity)]) {
const cell = document.createElement("td");
cell.textContent = value;
row.append(cell);
}
body.append(row);
}
if (detail.environmentProvenance.length === 0) {
const row = document.createElement("tr");
const cell = document.createElement("td");
cell.colSpan = 4;
cell.className = "text-body-secondary text-center";
cell.textContent = "Aucune substitution d'environnement dans l'effective sélectionnée.";
row.append(cell);
body.append(row);
}
frontendTrace("main", "Profile environment provenance table DOM replaced", { entryCount: detail.environmentProvenance.length });
}
function renderDetail(detail: ConfigProfileDetailDto): void {
activeDetail = detail;
populateProfileSelect(detail);
setText("#profileDetailFileId", detail.fileId);
setText("#profileDetailPath", detail.path);
setText("#profileDetailDefault", detail.defaultProfile);
setText("#profileDetailSelected", detail.selectedProfile);
setText("#profileDetailSelectionSource", sourceLabel(detail.selectionSource));
setText("#profileDetailSensitivity", sensitivityLabel(detail.effectiveSensitivity));
setCode("#profileGlobalsJson", detail.globalsJson);
setCode("#profileSourceJson", detail.profileJson);
setCode("#profileEffectiveJson", detail.effectiveSafeJson);
renderValueOrigins(detail);
renderEnvironmentProvenance(detail);
const detailPanel = document.querySelector<HTMLElement>("#profileDetailPanel");
if (detailPanel) {
detailPanel.hidden = false;
}
frontendTrace("main", "Config profile detail rendered", {
fileId: detail.fileId,
selectedProfile: detail.selectedProfile,
selectionSource: detail.selectionSource,
effectiveSensitivity: detail.effectiveSensitivity,
});
}
function activeDocumentId(): string | null {
const select = document.querySelector<HTMLSelectElement>("#profileDocumentSelect");
if (!select || select.value.length === 0) {
return null;
}
return select.value;
}
async function loadProfile(fileId: string, profileId: string | null, source: "default" | "explicit" | "reload"): Promise<void> {
frontendDebug("main", "Config profile inspection requested", { fileId, profileId, source });
setProfilesStatus(`Résolution du profil ${profileId ?? "par défaut"}...`, "primary");
try {
const detail = await invokeKsp<ConfigProfileDetailDto>("main", "get_config_profile_detail", {
fileId,
profileId,
});
renderDetail(detail);
setProfilesStatus(
`${detail.fileId} / ${detail.selectedProfile} résolu via ${sourceLabel(detail.selectionSource)}; effective sûre affichée.`,
"success",
);
frontendDebug("main", "Config profile inspection completed", {
fileId: detail.fileId,
selectedProfile: detail.selectedProfile,
selectionSource: detail.selectionSource,
});
} catch {
setProfilesStatus("Le profil Config n'a pas pu être résolu par le backend.", "danger");
}
}
async function refreshProfileDocuments(): Promise<void> {
frontendDebug("main", "Config profile document inventory refresh requested");
try {
profileDocuments = await invokeKsp<ConfigProfileDocumentDto[]>("main", "get_config_profile_documents");
populateDocumentSelect(profileDocuments);
if (profileDocuments.length === 0) {
activeDetail = null;
const detailPanel = document.querySelector<HTMLElement>("#profileDetailPanel");
if (detailPanel) {
detailPanel.hidden = true;
}
setProfilesStatus("Aucun document Config avec profils standard n'est enregistré.", "warning");
return;
}
await loadProfile(profileDocuments[0].fileId, null, "default");
} catch {
setProfilesStatus("L'inventaire des profils Config n'a pas pu être chargé.", "danger");
}
}
function bindProfileActions(): void {
document.querySelector<HTMLButtonElement>("#refreshProfiles")?.addEventListener("click", () => {
frontendTrace("main", "Profiles refresh button clicked");
void refreshProfileDocuments();
});
document.querySelector<HTMLSelectElement>("#profileDocumentSelect")?.addEventListener("change", event => {
const select = event.currentTarget;
if (!(select instanceof HTMLSelectElement) || select.value.length === 0) {
return;
}
frontendTrace("main", "Profile document selection changed", { fileId: select.value });
void loadProfile(select.value, null, "default");
});
document.querySelector<HTMLSelectElement>("#profileIdSelect")?.addEventListener("change", event => {
const select = event.currentTarget;
const fileId = activeDocumentId();
if (!(select instanceof HTMLSelectElement) || !fileId || select.value.length === 0) {
return;
}
frontendTrace("main", "Explicit profile selection changed", { fileId, profileId: select.value });
void loadProfile(fileId, select.value, "explicit");
});
document.querySelector<HTMLButtonElement>("#inspectDefaultProfile")?.addEventListener("click", () => {
const fileId = activeDocumentId();
if (!fileId) {
return;
}
frontendTrace("main", "Default profile inspection button clicked", { fileId });
void loadProfile(fileId, null, "default");
});
document.querySelector<HTMLButtonElement>("#reloadProfileDetail")?.addEventListener("click", () => {
if (!activeDetail) {
return;
}
const explicit = activeDetail.selectionSource === "explicit" ? activeDetail.selectedProfile : null;
frontendTrace("main", "Profile detail reload button clicked", { fileId: activeDetail.fileId, explicitProfile: explicit });
void loadProfile(activeDetail.fileId, explicit, "reload");
});
frontendTrace("main", "Profiles panel handlers installed");
}
export function initializeProfilesPanel(): void {
bindProfileActions();
void refreshProfileDocuments();
}

View File

@@ -0,0 +1,206 @@
// file: crates/ksp-app-config-desk/frontend/ts/secret_reveal.ts
// version: 1
import { Modal } from "bootstrap";
import type { SecretRevealRequestDto } from "./bindings/ksp_app_config_desk/secrets/SecretRevealRequestDto";
import type { SecretRevealResponseDto } from "./bindings/ksp_app_config_desk/secrets/SecretRevealResponseDto";
import { frontendDebug, frontendTrace } from "./frontend_log";
import { invokeKsp } from "./invoke";
export type SecretRevealSource = "effective" | "dotenv";
let pendingRequest: SecretRevealRequestDto | null = null;
let revealInFlight = false;
let revealGeneration = 0;
function sourceLabel(source: SecretRevealSource): string {
return source === "effective" ? "effective (process prioritaire sur .env)" : ".env persisté";
}
function modalElement(): HTMLElement | null {
return document.querySelector<HTMLElement>("#secretRevealModal");
}
function clearSecretValue(): void {
const input = document.querySelector<HTMLInputElement>("#secretRevealValue");
const absent = document.querySelector<HTMLElement>("#secretRevealValueAbsent");
const result = document.querySelector<HTMLElement>("#secretRevealResultPanel");
const confirmation = document.querySelector<HTMLElement>("#secretRevealConfirmPanel");
const confirm = document.querySelector<HTMLButtonElement>("#secretRevealConfirmAction");
const toggle = document.querySelector<HTMLButtonElement>("#secretRevealToggleVisibility");
if (input) {
input.value = "";
input.type = "password";
}
if (absent) {
absent.hidden = true;
}
if (result) {
result.hidden = true;
}
if (confirmation) {
confirmation.hidden = false;
}
if (confirm) {
confirm.hidden = false;
confirm.disabled = false;
}
if (toggle) {
toggle.hidden = true;
toggle.textContent = "Afficher";
}
}
function renderRevealResponse(response: SecretRevealResponseDto): void {
const input = document.querySelector<HTMLInputElement>("#secretRevealValue");
const absent = document.querySelector<HTMLElement>("#secretRevealValueAbsent");
const result = document.querySelector<HTMLElement>("#secretRevealResultPanel");
const confirmation = document.querySelector<HTMLElement>("#secretRevealConfirmPanel");
const confirm = document.querySelector<HTMLButtonElement>("#secretRevealConfirmAction");
const toggle = document.querySelector<HTMLButtonElement>("#secretRevealToggleVisibility");
if (confirmation) {
confirmation.hidden = true;
}
if (confirm) {
confirm.hidden = true;
}
if (result) {
result.hidden = false;
}
if (response.value === null) {
if (input) {
input.value = "";
input.hidden = true;
}
if (absent) {
absent.hidden = false;
}
if (toggle) {
toggle.hidden = true;
}
} else {
if (input) {
input.hidden = false;
input.type = "password";
input.value = response.value;
}
if (absent) {
absent.hidden = true;
}
if (toggle) {
toggle.hidden = false;
toggle.textContent = "Afficher";
}
}
frontendDebug("main", "Privileged Secret reveal rendered", {
variableName: response.variableName,
source: response.source,
valuePresent: response.value !== null,
});
}
async function executeReveal(): Promise<void> {
if (!pendingRequest || revealInFlight) {
return;
}
const request = pendingRequest;
const requestGeneration = revealGeneration;
const confirm = document.querySelector<HTMLButtonElement>("#secretRevealConfirmAction");
const status = document.querySelector<HTMLElement>("#secretRevealStatus");
revealInFlight = true;
if (confirm) {
confirm.disabled = true;
}
if (status) {
status.textContent = "Reveal privilégié en cours via ksp-config-lib...";
}
frontendDebug("main", "Privileged Secret reveal requested", { variableName: request.variableName, source: request.source });
try {
const response = await invokeKsp<SecretRevealResponseDto>("main", "reveal_environment_value", { request });
if (requestGeneration !== revealGeneration || !pendingRequest) {
frontendDebug("main", "Privileged Secret reveal response discarded after modal state changed", {
variableName: request.variableName,
source: request.source,
});
return;
}
renderRevealResponse(response);
if (status) {
status.textContent = response.value === null ? "Aucune valeur n'existe dans la source demandée." : "Valeur révélée temporairement. Elle sera effacée à la fermeture.";
}
} catch {
if (status) {
status.textContent = "Le reveal a été refusé ou a échoué.";
}
if (confirm) {
confirm.disabled = false;
}
} finally {
if (requestGeneration === revealGeneration) {
revealInFlight = false;
}
}
}
function toggleRevealVisibility(): void {
const input = document.querySelector<HTMLInputElement>("#secretRevealValue");
const toggle = document.querySelector<HTMLButtonElement>("#secretRevealToggleVisibility");
if (!input || !toggle || input.hidden) {
return;
}
const show = input.type === "password";
input.type = show ? "text" : "password";
toggle.textContent = show ? "Masquer" : "Afficher";
frontendDebug("main", "Transient Secret reveal visibility changed", { visible: show });
}
export function requestSecretReveal(variableName: string, source: SecretRevealSource): void {
const modal = modalElement();
const variable = document.querySelector<HTMLElement>("#secretRevealVariable");
const sourceElement = document.querySelector<HTMLElement>("#secretRevealSource");
const status = document.querySelector<HTMLElement>("#secretRevealStatus");
if (!modal || !variable || !sourceElement || !status) {
frontendDebug("main", "Privileged Secret reveal modal is unavailable", { variableName, source });
return;
}
revealGeneration += 1;
clearSecretValue();
pendingRequest = { variableName, source };
variable.textContent = variableName;
sourceElement.textContent = sourceLabel(source);
status.textContent = "Confirmation explicite requise avant tout accès à la valeur réelle.";
frontendDebug("main", "Privileged Secret reveal confirmation opened", { variableName, source });
Modal.getOrCreateInstance(modal).show();
}
export function clearTransientSecretReveal(): void {
const modal = modalElement();
const variableName = pendingRequest?.variableName ?? null;
revealGeneration += 1;
clearSecretValue();
pendingRequest = null;
revealInFlight = false;
if (modal) {
Modal.getOrCreateInstance(modal).hide();
}
frontendTrace("main", "Transient Secret reveal state cleared", { variableName });
}
export function initializeSecretReveal(): void {
const modal = modalElement();
document.querySelector<HTMLButtonElement>("#secretRevealConfirmAction")?.addEventListener("click", () => {
void executeReveal();
});
document.querySelector<HTMLButtonElement>("#secretRevealToggleVisibility")?.addEventListener("click", () => {
toggleRevealVisibility();
});
modal?.addEventListener("hidden.bs.modal", () => {
const variableName = pendingRequest?.variableName ?? null;
revealGeneration += 1;
clearSecretValue();
pendingRequest = null;
revealInFlight = false;
frontendDebug("main", "Privileged Secret reveal closed and value cleared", { variableName });
});
frontendTrace("main", "Privileged Secret reveal handlers installed");
}

View File

@@ -0,0 +1,70 @@
// file: crates/ksp-app-config-desk/frontend/ts/shell_registry.ts
// version: 1
//! Declarative shell and specialized-editor registry for Config Desk frontend extensibility.
export type ViewId = "overview" | "documents" | "profiles" | "environment" | "logging";
export interface ViewDescriptor {
title: string;
description: string;
panelId: string;
}
export interface SpecializedEditorDescriptor {
fileId: string;
viewId: ViewId;
label: string;
}
export const viewRegistry: Record<ViewId, ViewDescriptor> = {
overview: {
title: "Vue d'ensemble",
description: "État sûr du bootstrap Config Desk et du runtime Logging actuellement actif.",
panelId: "overviewPanel",
},
documents: {
title: "Documents",
description: "Inventaire, diagnostics et réparation validée des documents Config enregistrés.",
panelId: "documentsPanel",
},
profiles: {
title: "Profils",
description: "Sélection default/explicite, vues global/profil/effective sûre et provenance Config.",
panelId: "profilesPanel",
},
environment: {
title: "Environnement / .env",
description: "Rapport sûr desired/effective/source/shadow et management atomique des variables .env KSP/KSPB.",
panelId: "environmentPanel",
},
logging: {
title: "Logging",
description: "Édition typée, hot reload runtime, profils actifs et panneau de test du routing Logging KSP.",
panelId: "loggingPanel",
},
};
const specializedEditorRegistry: SpecializedEditorDescriptor[] = [
{
fileId: "cfg.std.logging",
viewId: "logging",
label: "Ouvrir l'éditeur Logging",
},
];
export function registeredViewIds(): ViewId[] {
return Object.keys(viewRegistry) as ViewId[];
}
export function isViewId(value: string): value is ViewId {
return Object.prototype.hasOwnProperty.call(viewRegistry, value);
}
export function specializedEditorForFileId(fileId: string): SpecializedEditorDescriptor | null {
return specializedEditorRegistry.find(descriptor => descriptor.fileId === fileId) ?? null;
}
export function requestViewActivation(viewId: ViewId): void {
window.dispatchEvent(new CustomEvent("ksp:activate-view", { detail: { viewId } }));
}

View File

@@ -0,0 +1,113 @@
// file: crates/ksp-app-config-desk/frontend/ts/splash.ts
// version: 3
import { listen } from "@tauri-apps/api/event";
import { getCurrentWindow } from "@tauri-apps/api/window";
import type { SplashOrderDto } from "./bindings/ksp_app_config_desk/splash/SplashOrderDto.ts";
import { frontendDebug, frontendError, frontendInfo, frontendTrace, installFrontendConsoleBridge } from "./frontend_log";
import { invokeKsp } from "./invoke";
installFrontendConsoleBridge("splash");
const defaultFadeDurationMs = 300;
let activeOpacityFrame: number | null = null;
function normalizeDurationMs(value: number | null | undefined): number {
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
return defaultFadeDurationMs;
}
return value;
}
function easeInOut(progress: number): number {
if (progress < 0.5) {
return 2 * progress * progress;
}
return 1 - Math.pow(-2 * progress + 2, 2) / 2;
}
async function animateOpacity(element: HTMLElement, fromOpacity: number, toOpacity: number, durationMs: number): Promise<void> {
frontendTrace("splash", "Splash opacity animation started", { fromOpacity, toOpacity, durationMs });
if (activeOpacityFrame !== null) {
cancelAnimationFrame(activeOpacityFrame);
activeOpacityFrame = null;
}
element.style.opacity = fromOpacity.toString();
element.style.willChange = "opacity";
await new Promise<void>(resolve => requestAnimationFrame(() => resolve()));
await new Promise<void>(resolve => {
const startedAt = performance.now();
const opacityDelta = toOpacity - fromOpacity;
const updateOpacity = (currentTime: number): void => {
const elapsedMs = currentTime - startedAt;
const rawProgress = Math.min(elapsedMs / durationMs, 1);
element.style.opacity = (fromOpacity + opacityDelta * easeInOut(rawProgress)).toString();
if (rawProgress >= 1) {
element.style.opacity = toOpacity.toString();
element.style.willChange = "auto";
activeOpacityFrame = null;
resolve();
return;
}
activeOpacityFrame = requestAnimationFrame(updateOpacity);
};
activeOpacityFrame = requestAnimationFrame(updateOpacity);
});
frontendTrace("splash", "Splash opacity animation completed", { toOpacity, durationMs });
}
function replaceStatus(message: string | null): void {
if (!message) {
return;
}
const status = document.querySelector<HTMLElement>("#splash-status");
if (status) {
status.textContent = message;
frontendTrace("splash", "Splash status replaced", { message });
}
}
async function handleSplashOrder(order: SplashOrderDto): Promise<void> {
frontendTrace("splash", "Splash order received", { action: order.action });
const container = document.querySelector<HTMLElement>("#splash-container");
replaceStatus(order.message);
if (!container) {
return;
}
if (order.action === "fade_in") {
await animateOpacity(container, 0, 1, normalizeDurationMs(order.durationMs));
return;
}
if (order.action === "fade_out") {
await animateOpacity(container, 1, 0, normalizeDurationMs(order.durationMs));
}
}
async function initializeSplash(): Promise<void> {
const windowLabel = getCurrentWindow().label;
frontendInfo("splash", "Config Desk splash frontend loaded", { windowLabel });
const container = document.querySelector<HTMLElement>("#splash-container");
if (container) {
container.style.opacity = "0";
container.style.willChange = "opacity";
frontendTrace("splash", "Splash container prepared for managed fade-in");
}
await listen<SplashOrderDto>("ksp-splash-order", event => {
void handleSplashOrder(event.payload);
});
frontendDebug("splash", "Splash lifecycle listener installed");
try {
await invokeKsp<void>("splash", "splash_frontend_ready");
} catch {
replaceStatus("Le lifecycle du splash n'a pas pu démarrer.");
if (container) {
container.style.opacity = "1";
container.style.willChange = "auto";
}
frontendError("splash", "Splash readiness command failed");
}
}
document.addEventListener("DOMContentLoaded", () => {
void initializeSplash();
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 448 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

View File

@@ -0,0 +1,31 @@
{
"name": "ksp-app-config-desk",
"private": true,
"version": "0.1.4",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"tauri": "tauri",
"check": "tsc --noEmit"
},
"dependencies": {
"@fltsci/tauri-plugin-tracing": "^0.3",
"@fortawesome/fontawesome-free": "^7.3",
"@tauri-apps/api": "^2.11",
"bootstrap": "^5.3",
"datatables.net-bs5": "^3.0",
"datatables.net-select-bs5": "^4.0",
"resize-observer-polyfill": "^1.5",
"simplebar": "^6.3"
},
"devDependencies": {
"@tauri-apps/cli": "^2.11",
"@types/bootstrap": "^5.2",
"@types/node": "^26.1",
"sass-embedded": "^1.102",
"typescript": "^7.0",
"vite": "^8.2"
}
}

View File

@@ -0,0 +1,208 @@
// file: crates/ksp-app-config-desk/src/app_state.rs
// version: 6
//! Shared backend state owned by the Tauri application.
struct LoggingRuntimeState {
guard: ksp_logging_lib::LoggingGuard,
active_profile_id: std::option::Option<String>,
selection_source: String,
generation: u32,
fallback_active: bool,
startup_diagnostic: std::option::Option<crate::CommandErrorDto>,
}
/// Shared Config Desk application state managed by Tauri.
pub(crate) struct AppState {
config_management: ksp_config_lib::ConfigManagement,
logging_runtime: std::sync::Mutex<LoggingRuntimeState>,
splash_settings: crate::SplashSettings,
splash_sequence_started: std::sync::atomic::AtomicBool,
}
impl AppState {
/// Initializes Config ownership, the Logging runtime and durable application state.
pub(crate) fn initialize(arguments: &[std::ffi::OsString]) -> ksp_core_lib::Result<Self> {
let config_management = crate::config_management(arguments);
let config_management = match config_management {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let runtime_identity = crate::logging_runtime::launch_identity();
let runtime_identity = match runtime_identity {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let logging_startup = crate::initialize_logging(&config_management, &runtime_identity);
let logging_startup = match logging_startup {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let splash_settings = crate::SplashSettings::load();
let splash_settings = match splash_settings {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => {
ksp_logging_lib::warn!(
target: crate::TRACING_TARGET,
domain = crate::TRACING_DOMAIN_WINDOWS,
error_domain = error.code().domain(),
error_code = error.code().code(),
"managed splash timings are invalid; using transient in-memory defaults"
);
crate::SplashSettings::fallback()
},
};
ksp_logging_lib::debug!(
target: crate::TRACING_TARGET,
domain = crate::TRACING_DOMAIN_WINDOWS,
minimum_ms = splash_settings.minimum_ms(),
minimum_source = splash_settings.minimum_source(),
fade_ms = splash_settings.fade_ms(),
fade_source = splash_settings.fade_source(),
expected_backend_lifecycle_ms = splash_settings.expected_backend_lifecycle_ms(),
"resolved Config Desk splash timings"
);
return std::result::Result::Ok(Self {
config_management,
logging_runtime: std::sync::Mutex::new(LoggingRuntimeState {
guard: logging_startup.guard,
active_profile_id: logging_startup.active_profile_id,
selection_source: logging_startup.selection_source,
generation: 1,
fallback_active: logging_startup.fallback_active,
startup_diagnostic: logging_startup.startup_diagnostic,
}),
splash_settings,
splash_sequence_started: std::sync::atomic::AtomicBool::new(false),
});
}
/// Builds a safe initial frontend snapshot without exposing resolved secret values or arbitrary error context.
pub(crate) fn snapshot(&self) -> ksp_core_lib::Result<crate::AppSnapshotDto> {
let document_count = self.config_management.engine().registry().descriptors().count();
let document_count = u32::try_from(document_count);
let document_count = match document_count {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => {
return std::result::Result::Err(
ksp_core_lib::Error::new(crate::ERROR_CODE_APP_STATE_INVALID, "Config registry contains too many descriptors for the desktop DTO")
.with_source(error),
);
},
};
let runtime = self.logging_runtime.lock();
let runtime = match runtime {
std::result::Result::Ok(value) => value,
std::result::Result::Err(_) => {
return std::result::Result::Err(ksp_core_lib::Error::new(
crate::ERROR_CODE_APP_STATE_LOCK_FAILED,
"Config Desk Logging runtime state lock is poisoned",
));
},
};
let _keep_guard_alive = &runtime.guard;
return std::result::Result::Ok(crate::AppSnapshotDto {
application_version: env!("CARGO_PKG_VERSION").to_owned(),
config_document_count: document_count,
active_logging_profile: runtime.active_profile_id.clone(),
logging_generation: runtime.generation,
fallback_logging_active: runtime.fallback_active,
startup_diagnostic: runtime.startup_diagnostic.clone(),
});
}
/// Returns the Config management facade owned by the application state.
#[must_use]
pub(crate) const fn config_management(&self) -> &ksp_config_lib::ConfigManagement {
return &self.config_management;
}
/// Replaces the active Logging runtime with freshly resolved settings and advances the runtime generation only after success.
pub(crate) fn reinitialize_logging_runtime(
&self,
profile_id: &str,
selection_source: &str,
settings: &ksp_logging_lib::LoggingSettings,
) -> ksp_core_lib::Result<u32> {
let runtime = self.logging_runtime.lock();
let mut runtime = match runtime {
std::result::Result::Ok(value) => value,
std::result::Result::Err(_) => {
return std::result::Result::Err(ksp_core_lib::Error::new(
crate::ERROR_CODE_APP_STATE_LOCK_FAILED,
"Config Desk Logging runtime state lock is poisoned",
));
},
};
let reinitialized = ksp_logging_lib::reinitialize(&mut runtime.guard, settings);
if let std::result::Result::Err(error) = reinitialized {
return std::result::Result::Err(error);
}
runtime.active_profile_id = std::option::Option::Some(profile_id.to_owned());
runtime.selection_source = selection_source.to_owned();
runtime.generation = runtime.generation.saturating_add(1);
runtime.fallback_active = false;
runtime.startup_diagnostic = std::option::Option::None;
return std::result::Result::Ok(runtime.generation);
}
/// Returns a safe observable snapshot of the currently active Logging runtime.
pub(crate) fn logging_runtime_status(&self) -> ksp_core_lib::Result<crate::LoggingRuntimeStatusDto> {
let runtime = self.logging_runtime.lock();
let runtime = match runtime {
std::result::Result::Ok(value) => value,
std::result::Result::Err(_) => {
return std::result::Result::Err(ksp_core_lib::Error::new(
crate::ERROR_CODE_APP_STATE_LOCK_FAILED,
"Config Desk Logging runtime state lock is poisoned",
));
},
};
let identity = runtime.guard.runtime_identity();
let identity = match identity {
std::option::Option::Some(value) => value,
std::option::Option::None => {
return std::result::Result::Err(ksp_core_lib::Error::new(
crate::ERROR_CODE_APP_STATE_INVALID,
"Config Desk Logging runtime is missing its application launch identity",
));
},
};
let console_enabled = match runtime.guard.settings().console() {
std::option::Option::Some(console) => console.enabled(),
std::option::Option::None => false,
};
let dropped = runtime.guard.dropped_lines();
let mut files = std::vec::Vec::<crate::LoggingRuntimeFileDto>::new();
for metadata in runtime.guard.active_file_outputs() {
files.push(crate::logging_runtime::project_file(&metadata));
}
return std::result::Result::Ok(crate::LoggingRuntimeStatusDto {
active_profile: runtime.active_profile_id.clone(),
selection_source: runtime.selection_source.clone(),
generation: runtime.generation,
fallback_active: runtime.fallback_active,
application_id: identity.application_id().to_owned(),
launch_timestamp: identity.launch_timestamp().to_owned(),
console_enabled,
dropped_console_lines: crate::logging_runtime::count_to_u64(dropped.console()),
dropped_file_lines: crate::logging_runtime::count_to_u64(dropped.file()),
dropped_total_lines: crate::logging_runtime::count_to_u64(dropped.total()),
files,
});
}
/// Returns the resolved splash timings captured during application bootstrap.
#[must_use]
pub(crate) const fn splash_settings(&self) -> crate::SplashSettings {
return self.splash_settings;
}
/// Marks the one-shot splash lifecycle as started and reports whether this caller won the transition.
pub(crate) fn begin_splash_sequence(&self) -> bool {
return self
.splash_sequence_started
.compare_exchange(false, true, std::sync::atomic::Ordering::AcqRel, std::sync::atomic::Ordering::Acquire)
.is_ok();
}
}

View File

@@ -0,0 +1,146 @@
// file: crates/ksp-app-config-desk/src/bootstrap.rs
// version: 3
//! Config and Logging bootstrap for the desktop application.
pub(crate) struct LoggingStartup {
pub(crate) guard: ksp_logging_lib::LoggingGuard,
pub(crate) active_profile_id: std::option::Option<String>,
pub(crate) selection_source: String,
pub(crate) fallback_active: bool,
pub(crate) startup_diagnostic: std::option::Option<crate::CommandErrorDto>,
}
enum LoggingStartupPlan {
Managed {
active_profile_id: String,
settings: ksp_logging_lib::LoggingSettings,
},
Fallback {
initial_error: ksp_core_lib::Error,
diagnostic: crate::CommandErrorDto,
settings: ksp_logging_lib::LoggingSettings,
},
}
pub(crate) fn config_management(arguments: &[std::ffi::OsString]) -> ksp_core_lib::Result<ksp_config_lib::ConfigManagement> {
let bootstrap = ksp_config_lib::ConfigBootstrapOptions::from_args(arguments);
let bootstrap = match bootstrap {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let registry = ksp_config_lib::ConfigFileRegistry::from_args(arguments);
let registry = match registry {
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);
return std::result::Result::Ok(ksp_config_lib::ConfigManagement::new(engine));
}
pub(crate) fn initialize_logging(
management: &ksp_config_lib::ConfigManagement,
runtime_identity: &ksp_logging_lib::LoggingRuntimeIdentity,
) -> ksp_core_lib::Result<LoggingStartup> {
let plan = resolve_logging_startup(management);
return match plan {
LoggingStartupPlan::Managed { active_profile_id, settings } => {
let guard = ksp_logging_lib::initialize_with_identity(&settings, runtime_identity);
match guard {
std::result::Result::Ok(guard) => {
ksp_logging_lib::info!(
target: crate::TRACING_TARGET,
domain = crate::TRACING_DOMAIN_BOOTSTRAP,
active_profile = active_profile_id.as_str(),
"initialized Config Desk logging from managed configuration"
);
std::result::Result::Ok(LoggingStartup {
guard,
active_profile_id: std::option::Option::Some(active_profile_id),
selection_source: "default_profile".to_owned(),
fallback_active: false,
startup_diagnostic: std::option::Option::None,
})
},
std::result::Result::Err(error) => initialize_fallback_logging(error, runtime_identity),
}
},
LoggingStartupPlan::Fallback { initial_error, diagnostic, settings } => {
initialize_planned_fallback_logging(initial_error, diagnostic, settings, runtime_identity)
},
};
}
fn resolve_logging_startup(management: &ksp_config_lib::ConfigManagement) -> LoggingStartupPlan {
let environment = ksp_config_lib::ConfigEnvironment::load();
let environment = match environment {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return fallback_startup_plan(error),
};
let resolved = management.engine().load_resolved_logging_config(std::option::Option::None, &environment);
let resolved = match resolved {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return fallback_startup_plan(error),
};
return LoggingStartupPlan::Managed { active_profile_id: resolved.profile_id().to_owned(), settings: resolved.into_settings() };
}
fn fallback_startup_plan(initial_error: ksp_core_lib::Error) -> LoggingStartupPlan {
let diagnostic = crate::CommandErrorDto::from_error(&initial_error);
return LoggingStartupPlan::Fallback { initial_error, diagnostic, settings: fallback_logging_settings() };
}
fn initialize_fallback_logging(
initial_error: ksp_core_lib::Error,
runtime_identity: &ksp_logging_lib::LoggingRuntimeIdentity,
) -> ksp_core_lib::Result<LoggingStartup> {
let diagnostic = crate::CommandErrorDto::from_error(&initial_error);
return initialize_planned_fallback_logging(initial_error, diagnostic, fallback_logging_settings(), runtime_identity);
}
fn initialize_planned_fallback_logging(
initial_error: ksp_core_lib::Error,
diagnostic: crate::CommandErrorDto,
settings: ksp_logging_lib::LoggingSettings,
runtime_identity: &ksp_logging_lib::LoggingRuntimeIdentity,
) -> ksp_core_lib::Result<LoggingStartup> {
let guard = ksp_logging_lib::initialize_with_identity(&settings, runtime_identity);
let guard = match guard {
std::result::Result::Ok(value) => value,
std::result::Result::Err(fallback_error) => {
return std::result::Result::Err(
ksp_core_lib::Error::new(crate::ERROR_CODE_LOGGING_BOOTSTRAP_FAILED, "Cannot initialize Config Desk fallback Logging runtime")
.with_context("initial_error_domain", initial_error.code().domain())
.with_context("initial_error_code", initial_error.code().code())
.with_source(fallback_error),
);
},
};
ksp_logging_lib::warn!(
target: crate::TRACING_TARGET,
domain = crate::TRACING_DOMAIN_BOOTSTRAP,
error_domain = diagnostic.domain.as_str(),
error_code = diagnostic.code.as_str(),
"managed Logging configuration is unavailable; using transient in-memory fallback"
);
return std::result::Result::Ok(LoggingStartup {
guard,
active_profile_id: std::option::Option::None,
selection_source: "fallback".to_owned(),
fallback_active: true,
startup_diagnostic: std::option::Option::Some(diagnostic),
});
}
pub(crate) fn fallback_logging_settings() -> ksp_logging_lib::LoggingSettings {
return ksp_logging_lib::LoggingSettings::new(
ksp_logging_lib::LogFilterLevel::Info,
ksp_logging_lib::SpanEvents::Off,
std::option::Option::Some(ksp_logging_lib::ConsoleSettings::stderr()),
std::vec::Vec::new(),
);
}
#[cfg(test)]
#[path = "../unit_tests/bootstrap.rs"]
mod tests;

View File

@@ -0,0 +1,35 @@
// file: crates/ksp-app-config-desk/src/constants.rs
// version: 9
//! Application-owned tracing targets and domains.
/// Owning target for backend events emitted by Config Desk.
pub(crate) const TRACING_TARGET: &str = "ksp-app-config-desk";
/// Owning target for generic frontend events emitted through the KSP bridge.
pub(crate) const TRACING_TARGET_FRONTEND: &str = "ksp-app-config-desk.frontend";
/// Owning target for main-window frontend events.
pub(crate) const TRACING_TARGET_FRONTEND_MAIN: &str = "ksp-app-config-desk.frontend.main";
/// Owning target for splash-window frontend events.
pub(crate) const TRACING_TARGET_FRONTEND_SPLASH: &str = "ksp-app-config-desk.frontend.splash";
/// Dedicated target used by the controlled Logging test panel.
pub(crate) const TRACING_TARGET_LOGGING_TEST: &str = "ksp-app-config-desk.logging-test";
/// Structured domain used while bootstrapping Config and Logging.
pub(crate) const TRACING_DOMAIN_BOOTSTRAP: &str = "config.bootstrap";
/// Structured domain used by technical frontend events.
pub(crate) const TRACING_DOMAIN_FRONTEND: &str = "frontend";
/// Structured domain used by Tauri window lifecycle operations.
pub(crate) const TRACING_DOMAIN_WINDOWS: &str = "desktop.window";
/// Structured domain for Config document inventory, diagnostics and repair.
pub(crate) const TRACING_DOMAIN_DOCUMENTS: &str = "config.documents";
/// Structured domain for Config profile selection and provenance inspection.
pub(crate) const TRACING_DOMAIN_PROFILES: &str = "config.profiles";
/// Structured domain for safe Config environment reports.
pub(crate) const TRACING_DOMAIN_ENVIRONMENT: &str = "config.environment";
/// Structured domain for explicit privileged Secret reveal operations.
pub(crate) const TRACING_DOMAIN_SECRETS: &str = "config.secrets";
/// Structured domain for typed Logging editor inspection.
pub(crate) const TRACING_DOMAIN_LOGGING_EDITOR: &str = "config.logging_editor";
/// Structured domain for active Logging runtime metadata and explicit profile application.
pub(crate) const TRACING_DOMAIN_LOGGING_RUNTIME: &str = "config.logging_runtime";
/// Structured domain used by controlled Logging test events.
pub(crate) const TRACING_DOMAIN_LOGGING_TEST: &str = "config.logging_test";

View File

@@ -0,0 +1,275 @@
// file: crates/ksp-app-config-desk/src/documents.rs
// version: 1
//! Generic Config document inventory, diagnostics and validated repair services.
use ts_rs::TS; // rust-rules: derive-import
const STATUS_VALID: &str = "valid";
const STATUS_INVALID: &str = "invalid";
const STAGE_VALID: &str = "valid";
const STAGE_READ: &str = "read";
const STAGE_JSON: &str = "json";
const STAGE_SCHEMA: &str = "schema";
const STAGE_SEMANTIC: &str = "semantic";
const STAGE_EFFECTIVE: &str = "effective";
const STAGE_OTHER: &str = "other";
/// Safe inventory row for one Config document registered by `ksp-config-lib`.
#[derive(Clone, Debug, serde::Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "../frontend/ts/bindings/ksp_app_config_desk/documents/ConfigDocumentSummaryDto.ts")]
pub(crate) struct ConfigDocumentSummaryDto {
/// Stable logical Config identifier.
pub(crate) file_id: String,
/// Current Config-managed relative filename mapping.
pub(crate) filename: String,
/// Registered validation schema identifier.
pub(crate) schema_file_id: std::option::Option<String>,
/// Resolved filesystem path controlled by Config.
pub(crate) path: String,
/// `valid` or `invalid` according to the backend Config authority.
pub(crate) validation_status: String,
/// Backend diagnostic stage: valid/read/json/schema/semantic/effective/other.
pub(crate) diagnostic_stage: String,
/// Safe bounded diagnostic when the document is invalid.
pub(crate) diagnostic: std::option::Option<crate::CommandErrorDto>,
}
/// Detailed management view for one registered Config document.
#[derive(Clone, Debug, serde::Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "../frontend/ts/bindings/ksp_app_config_desk/documents/ConfigDocumentDetailDto.ts")]
pub(crate) struct ConfigDocumentDetailDto {
/// Current validation/inventory summary.
pub(crate) summary: ConfigDocumentSummaryDto,
/// Raw Config-managed source when the file can be read, even if its JSON/schema/semantics are invalid.
pub(crate) source: std::option::Option<String>,
}
/// Result of one validated raw-source repair attempt that reached persistence successfully.
#[derive(Clone, Debug, serde::Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "../frontend/ts/bindings/ksp_app_config_desk/documents/ConfigDocumentSaveResultDto.ts")]
pub(crate) struct ConfigDocumentSaveResultDto {
/// Whether persisted source bytes changed.
pub(crate) source_changed: bool,
/// Whether Config consumers need a reload to observe the changed document.
pub(crate) reload_required: bool,
/// Fresh detail reloaded after persistence.
pub(crate) document: ConfigDocumentDetailDto,
}
/// Safe document-specific command error with backend-owned diagnostic classification.
#[derive(Clone, Debug, serde::Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "../frontend/ts/bindings/ksp_app_config_desk/documents/ConfigDocumentErrorDto.ts")]
pub(crate) struct ConfigDocumentErrorDto {
/// Backend diagnostic stage associated with the failure.
pub(crate) diagnostic_stage: String,
/// Bounded KSP error projection without arbitrary context/source values.
pub(crate) error: crate::CommandErrorDto,
}
impl ConfigDocumentErrorDto {
fn from_error(error: &ksp_core_lib::Error) -> Self {
return Self { diagnostic_stage: classify_error(error).to_owned(), error: crate::CommandErrorDto::from_error(error) };
}
}
/// Lists all Config-kind documents from the Config registry and evaluates their current backend validation state.
pub(crate) fn inventory(state: &crate::AppState) -> std::vec::Vec<ConfigDocumentSummaryDto> {
let management = state.config_management();
let engine = management.engine();
let mut documents = std::vec::Vec::new();
for descriptor in engine.registry().descriptors() {
if descriptor.kind() != ksp_config_lib::ConfigFileKind::Config {
continue;
}
documents.push(build_summary(management, descriptor));
}
ksp_logging_lib::debug!(
target: crate::TRACING_TARGET,
domain = crate::TRACING_DOMAIN_DOCUMENTS,
document_count = documents.len(),
"Config document inventory evaluated"
);
return documents;
}
/// Loads one Config document detail, including raw source when Config can read it.
pub(crate) fn detail(state: &crate::AppState, file_id_text: &str) -> std::result::Result<ConfigDocumentDetailDto, ConfigDocumentErrorDto> {
let file_id = parse_config_file_id(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(ConfigDocumentErrorDto::from_error(&error)),
};
let management = state.config_management();
let descriptor = management.engine().registry().descriptor(&file_id);
let descriptor = match descriptor {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(ConfigDocumentErrorDto::from_error(&error)),
};
if descriptor.kind() != ksp_config_lib::ConfigFileKind::Config {
let error = ksp_core_lib::Error::new(crate::ERROR_CODE_DOCUMENT_KIND_INVALID, "requested document is not a Config-kind document");
return std::result::Result::Err(ConfigDocumentErrorDto::from_error(&error));
}
let summary = build_summary(management, descriptor);
let source = management.read_source(&file_id);
let source = match source {
std::result::Result::Ok(value) => std::option::Option::Some(value.content().to_owned()),
std::result::Result::Err(_) => std::option::Option::None,
};
ksp_logging_lib::debug!(
target: crate::TRACING_TARGET,
domain = crate::TRACING_DOMAIN_DOCUMENTS,
file_id = file_id.as_str(),
validation_status = summary.validation_status.as_str(),
diagnostic_stage = summary.diagnostic_stage.as_str(),
source_available = source.is_some(),
"Config document detail loaded"
);
return std::result::Result::Ok(ConfigDocumentDetailDto { summary, source });
}
/// Validates and atomically persists one raw Config source candidate, then reloads the document detail.
pub(crate) fn save_source(
state: &crate::AppState,
file_id_text: &str,
source: &str,
) -> std::result::Result<ConfigDocumentSaveResultDto, ConfigDocumentErrorDto> {
let file_id = parse_config_file_id(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(ConfigDocumentErrorDto::from_error(&error)),
};
let report = state.config_management().save_source_candidate(&file_id, source);
let report = match report {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(ConfigDocumentErrorDto::from_error(&error)),
};
let document = detail(state, file_id.as_str());
let document = match document {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
ksp_logging_lib::info!(
target: crate::TRACING_TARGET,
domain = crate::TRACING_DOMAIN_DOCUMENTS,
file_id = file_id.as_str(),
source_changed = report.source_changed(),
reload_required = report.reload_required(),
"validated Config document source candidate persisted"
);
return std::result::Result::Ok(ConfigDocumentSaveResultDto {
source_changed: report.source_changed(),
reload_required: report.reload_required(),
document,
});
}
fn parse_config_file_id(value: &str) -> ksp_core_lib::Result<ksp_config_lib::ConfigFileId> {
let file_id = ksp_config_lib::ConfigFileId::new(value.to_owned());
let file_id = match file_id {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
return std::result::Result::Ok(file_id);
}
fn build_summary(management: &ksp_config_lib::ConfigManagement, descriptor: &ksp_config_lib::ConfigFileDescriptor) -> ConfigDocumentSummaryDto {
let engine = management.engine();
let path = engine.registry().resolve_path(engine.bootstrap(), descriptor.file_id());
let path = match path {
std::result::Result::Ok(value) => value.to_string_lossy().into_owned(),
std::result::Result::Err(_) => descriptor.filename().to_string_lossy().into_owned(),
};
let validation = validate_effective_document(management, descriptor.file_id());
let (validation_status, diagnostic_stage, diagnostic) = match validation {
std::result::Result::Ok(()) => (STATUS_VALID.to_owned(), STAGE_VALID.to_owned(), std::option::Option::None),
std::result::Result::Err(error) => {
(STATUS_INVALID.to_owned(), classify_error(&error).to_owned(), std::option::Option::Some(crate::CommandErrorDto::from_error(&error)))
},
};
return ConfigDocumentSummaryDto {
file_id: descriptor.file_id().as_str().to_owned(),
filename: descriptor.filename().to_string_lossy().into_owned(),
schema_file_id: descriptor.schema_file_id().map(|value| {
return value.as_str().to_owned();
}),
path,
validation_status,
diagnostic_stage,
diagnostic,
};
}
fn validate_effective_document(management: &ksp_config_lib::ConfigManagement, file_id: &ksp_config_lib::ConfigFileId) -> ksp_core_lib::Result<()> {
let validated = management.engine().load_validated_document(file_id);
match validated {
std::result::Result::Ok(_) => {},
std::result::Result::Err(error) => return std::result::Result::Err(error),
}
if file_id.as_str() != ksp_config_lib::FILE_ID_STD_LOGGING {
return std::result::Result::Ok(());
}
let environment = ksp_config_lib::ConfigEnvironment::load();
let environment = match environment {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let resolved = management.engine().load_resolved_logging_config(std::option::Option::None, &environment);
return match resolved {
std::result::Result::Ok(_) => std::result::Result::Ok(()),
std::result::Result::Err(error) => std::result::Result::Err(error),
};
}
fn classify_error(error: &ksp_core_lib::Error) -> &'static str {
let code = error.code();
if code == ksp_config_lib::ERROR_CODE_JSON_FILE_READ_FAILED {
if error_context_file_id_is_schema(error) {
return STAGE_SCHEMA;
}
return STAGE_READ;
}
if code == ksp_config_lib::ERROR_CODE_JSON_SYNTAX_INVALID {
if error_context_file_id_is_schema(error) {
return STAGE_SCHEMA;
}
return STAGE_JSON;
}
if code == ksp_config_lib::ERROR_CODE_SCHEMA_INVALID || code == ksp_config_lib::ERROR_CODE_SCHEMA_VALIDATION_FAILED {
return STAGE_SCHEMA;
}
if code == ksp_config_lib::ERROR_CODE_DOCUMENT_SEMANTIC_INVALID
|| code == ksp_config_lib::ERROR_CODE_PROFILE_NOT_FOUND
|| code == ksp_config_lib::ERROR_CODE_COMPOSITE_REFERENCE_INVALID
{
return STAGE_SEMANTIC;
}
if code == ksp_config_lib::ERROR_CODE_EFFECTIVE_CONFIG_INVALID
|| code == ksp_config_lib::ERROR_CODE_ENVIRONMENT_PLACEHOLDER_INVALID
|| code == ksp_config_lib::ERROR_CODE_ENVIRONMENT_VALUE_INVALID
|| code == ksp_config_lib::ERROR_CODE_ENVIRONMENT_VARIABLE_INVALID
|| code == ksp_config_lib::ERROR_CODE_ENVIRONMENT_VARIABLE_MISSING
|| code == ksp_config_lib::ERROR_CODE_DOTENV_FILE_READ_FAILED
|| code == ksp_config_lib::ERROR_CODE_DOTENV_SYNTAX_INVALID
{
return STAGE_EFFECTIVE;
}
return STAGE_OTHER;
}
fn error_context_file_id_is_schema(error: &ksp_core_lib::Error) -> bool {
for field in error.context() {
if field.key() == "file_id" {
return field.value().starts_with("schema.");
}
}
return false;
}
#[cfg(test)]
#[path = "../unit_tests/documents.rs"]
mod tests;

View File

@@ -0,0 +1,54 @@
// file: crates/ksp-app-config-desk/src/dto_common.rs
// version: 1
//! Common Tauri DTOs shared by Config Desk commands.
use ts_rs::TS; // rust-rules: derive-import
/// Safe command error projection that never serializes arbitrary KSP error context or source values.
#[derive(Clone, Debug, serde::Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "../frontend/ts/bindings/ksp_app_config_desk/dto_common/CommandErrorDto.ts")]
pub(crate) struct CommandErrorDto {
/// Stable KSP error domain.
pub(crate) domain: String,
/// Stable KSP error code within the domain.
pub(crate) code: String,
/// Human-readable error message without arbitrary context fields.
pub(crate) message: String,
}
impl CommandErrorDto {
/// Builds a bounded safe projection from a KSP error.
#[must_use]
pub(crate) fn from_error(error: &ksp_core_lib::Error) -> Self {
return Self {
domain: error.code().domain().to_owned(),
code: error.code().code().to_owned(),
message: error.message().to_owned(),
};
}
}
/// Initial application/runtime snapshot exposed to the frontend.
#[derive(Clone, Debug, serde::Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "../frontend/ts/bindings/ksp_app_config_desk/dto_common/AppSnapshotDto.ts")]
pub(crate) struct AppSnapshotDto {
/// Cargo application version.
pub(crate) application_version: String,
/// Number of files registered by the Config registry.
pub(crate) config_document_count: u32,
/// Active configured Logging profile, or `None` while the transient fallback runtime is active.
pub(crate) active_logging_profile: std::option::Option<String>,
/// Monotonic runtime generation, initialized to one after the first Logging runtime install.
pub(crate) logging_generation: u32,
/// Whether Config Desk had to install its transient in-memory Logging fallback.
pub(crate) fallback_logging_active: bool,
/// Safe startup diagnostic that caused fallback Logging, when applicable.
pub(crate) startup_diagnostic: std::option::Option<CommandErrorDto>,
}
#[cfg(test)]
#[path = "../unit_tests/dto_common.rs"]
mod tests;

View File

@@ -0,0 +1,231 @@
// file: crates/ksp-app-config-desk/src/environment.rs
// version: 2
//! Safe Config environment reports and `.env` management projections for Config Desk.
use ts_rs::TS; // rust-rules: derive-import
/// Safe desired/effective environment view exposed to the ordinary Config Desk frontend.
#[derive(Clone, Debug, serde::Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "../frontend/ts/bindings/ksp_app_config_desk/environment/ConfigEnvironmentReportDto.ts")]
pub(crate) struct ConfigEnvironmentReportDto {
/// Supported KSP/KSPB variable name.
pub(crate) variable_name: String,
/// Namespace family derived from the variable name without inspecting its value.
pub(crate) namespace: String,
/// `public`, `internal` or `secret`.
pub(crate) sensitivity: String,
/// Persisted `.env` value using Config's safe/redacted representation.
pub(crate) desired_safe_value: std::option::Option<String>,
/// Currently effective process-or-`.env` value using Config's safe/redacted representation.
pub(crate) effective_safe_value: std::option::Option<String>,
/// Winning external source: `process`, `dotenv`, or `none`.
pub(crate) effective_source: String,
/// Whether the persisted `.env` value is shadowed by the inherited process environment.
pub(crate) shadowed_by_process_environment: bool,
}
/// Safe result metadata for one `.env` mutation.
#[derive(Clone, Debug, serde::Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "../frontend/ts/bindings/ksp_app_config_desk/environment/ConfigEnvironmentChangeDto.ts")]
pub(crate) struct ConfigEnvironmentChangeDto {
/// Variable targeted by the operation; never its value.
pub(crate) variable_name: String,
/// Stable operation label: `set` or `remove`.
pub(crate) operation: String,
/// Sensitivity classified by Config from the variable namespace.
pub(crate) sensitivity: String,
/// Whether the persisted `.env` source bytes changed.
pub(crate) source_changed: bool,
/// Whether the effective process-or-`.env` value changed for the running process.
pub(crate) effective_changed: bool,
/// Whether the resulting `.env` entry is shadowed by inherited process environment.
pub(crate) shadowed_by_process_environment: bool,
/// Whether consumers holding an environment snapshot must reload to observe the effective change.
pub(crate) reload_required: bool,
}
#[derive(Clone, Copy)]
enum EnvironmentMutationOperation {
Set,
Remove,
}
impl EnvironmentMutationOperation {
const fn label(self) -> &'static str {
return match self {
Self::Set => "set",
Self::Remove => "remove",
};
}
}
/// Returns the safe environment report owned by Config.
pub(crate) fn report(state: &crate::AppState) -> ksp_core_lib::Result<std::vec::Vec<ConfigEnvironmentReportDto>> {
return report_from_management(state.config_management());
}
/// Creates or replaces one `.env` entry exclusively through ConfigManagement.
pub(crate) fn set_value(state: &crate::AppState, variable_name: &str, value: &str) -> ksp_core_lib::Result<ConfigEnvironmentChangeDto> {
return set_value_from_management(state.config_management(), variable_name, value);
}
/// Removes one `.env` entry exclusively through ConfigManagement.
pub(crate) fn remove_value(state: &crate::AppState, variable_name: &str) -> ksp_core_lib::Result<ConfigEnvironmentChangeDto> {
return remove_value_from_management(state.config_management(), variable_name);
}
fn report_from_management(management: &ksp_config_lib::ConfigManagement) -> ksp_core_lib::Result<std::vec::Vec<ConfigEnvironmentReportDto>> {
let reports = management.environment_report();
let reports = match reports {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let mut result = std::vec::Vec::<ConfigEnvironmentReportDto>::with_capacity(reports.len());
let mut process_count = 0usize;
let mut dotenv_count = 0usize;
let mut shadowed_count = 0usize;
let mut secret_count = 0usize;
for report in reports {
let source = report.effective_source();
match source {
std::option::Option::Some(ksp_config_lib::ConfigEnvironmentSource::Process) => process_count += 1,
std::option::Option::Some(ksp_config_lib::ConfigEnvironmentSource::DotEnv) => dotenv_count += 1,
std::option::Option::Some(ksp_config_lib::ConfigEnvironmentSource::Fallback) | std::option::Option::None => {},
}
if report.shadowed_by_process_environment() {
shadowed_count += 1;
}
if report.sensitivity() == ksp_config_lib::ConfigSensitivity::Secret {
secret_count += 1;
}
result.push(ConfigEnvironmentReportDto {
variable_name: report.variable_name().to_owned(),
namespace: namespace_label(report.variable_name()).to_owned(),
sensitivity: sensitivity_label(report.sensitivity()).to_owned(),
desired_safe_value: report.desired_safe_value().map(str::to_owned),
effective_safe_value: report.effective_safe_value().map(str::to_owned),
effective_source: environment_source_label(source).to_owned(),
shadowed_by_process_environment: report.shadowed_by_process_environment(),
});
}
ksp_logging_lib::debug!(
target: crate::TRACING_TARGET,
domain = crate::TRACING_DOMAIN_ENVIRONMENT,
variable_count = result.len(),
process_count,
dotenv_count,
shadowed_count,
secret_count,
"Config environment report evaluated"
);
return std::result::Result::Ok(result);
}
fn set_value_from_management(
management: &ksp_config_lib::ConfigManagement,
variable_name: &str,
value: &str,
) -> ksp_core_lib::Result<ConfigEnvironmentChangeDto> {
let sensitivity = mutation_sensitivity(variable_name);
let sensitivity = match sensitivity {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let report = management.set_dotenv_value(variable_name, value);
let report = match report {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
return std::result::Result::Ok(mutation_result(variable_name, EnvironmentMutationOperation::Set, sensitivity, report));
}
fn remove_value_from_management(management: &ksp_config_lib::ConfigManagement, variable_name: &str) -> ksp_core_lib::Result<ConfigEnvironmentChangeDto> {
let sensitivity = mutation_sensitivity(variable_name);
let sensitivity = match sensitivity {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let report = management.remove_dotenv_value(variable_name);
let report = match report {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
return std::result::Result::Ok(mutation_result(variable_name, EnvironmentMutationOperation::Remove, sensitivity, report));
}
fn mutation_result(
variable_name: &str,
operation: EnvironmentMutationOperation,
sensitivity: ksp_config_lib::ConfigSensitivity,
report: ksp_config_lib::ConfigEnvironmentChangeReport,
) -> ConfigEnvironmentChangeDto {
let result = ConfigEnvironmentChangeDto {
variable_name: variable_name.to_owned(),
operation: operation.label().to_owned(),
sensitivity: sensitivity_label(sensitivity).to_owned(),
source_changed: report.source_changed(),
effective_changed: report.effective_changed(),
shadowed_by_process_environment: report.shadowed_by_process_environment(),
reload_required: report.reload_required(),
};
ksp_logging_lib::debug!(
target: crate::TRACING_TARGET,
domain = crate::TRACING_DOMAIN_ENVIRONMENT,
variable_name = result.variable_name.as_str(),
operation = result.operation.as_str(),
sensitivity = result.sensitivity.as_str(),
source_changed = result.source_changed,
effective_changed = result.effective_changed,
shadowed_by_process_environment = result.shadowed_by_process_environment,
reload_required = result.reload_required,
"Config .env mutation completed"
);
return result;
}
fn mutation_sensitivity(variable_name: &str) -> ksp_core_lib::Result<ksp_config_lib::ConfigSensitivity> {
return ksp_config_lib::ConfigSensitivity::from_variable_name(variable_name);
}
const fn environment_source_label(source: std::option::Option<ksp_config_lib::ConfigEnvironmentSource>) -> &'static str {
return match source {
std::option::Option::Some(ksp_config_lib::ConfigEnvironmentSource::Process) => "process",
std::option::Option::Some(ksp_config_lib::ConfigEnvironmentSource::DotEnv) => "dotenv",
std::option::Option::Some(ksp_config_lib::ConfigEnvironmentSource::Fallback) => "fallback",
std::option::Option::None => "none",
};
}
const fn sensitivity_label(sensitivity: ksp_config_lib::ConfigSensitivity) -> &'static str {
return match sensitivity {
ksp_config_lib::ConfigSensitivity::Public => "public",
ksp_config_lib::ConfigSensitivity::Internal => "internal",
ksp_config_lib::ConfigSensitivity::Secret => "secret",
};
}
fn namespace_label(variable_name: &str) -> &'static str {
if variable_name.starts_with("KSPB_SECRET_") {
return "KSPB_SECRET";
}
if variable_name.starts_with("KSPB_PUBLIC_") {
return "KSPB_PUBLIC";
}
if variable_name.starts_with("KSPB_") {
return "KSPB";
}
if variable_name.starts_with("KSP_SECRET_") {
return "KSP_SECRET";
}
if variable_name.starts_with("KSP_PUBLIC_") {
return "KSP_PUBLIC";
}
return "KSP";
}
#[cfg(test)]
#[path = "../unit_tests/environment.rs"]
mod tests;

View File

@@ -0,0 +1,44 @@
// file: crates/ksp-app-config-desk/src/errors.rs
// version: 9
//! Application-local error codes for the configuration desktop shell.
/// Tauri runtime assembly or execution failed.
pub(crate) const ERROR_CODE_TAURI_RUNTIME_FAILED: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config_desk", "tauri_runtime_failed");
/// Config Desk could not install the managed Logging runtime or its safe fallback.
pub(crate) const ERROR_CODE_LOGGING_BOOTSTRAP_FAILED: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config_desk", "logging_bootstrap_failed");
/// Config Desk persisted a Logging candidate but could not restore the previous source after runtime application failed.
pub(crate) const ERROR_CODE_LOGGING_SOURCE_ROLLBACK_FAILED: ksp_core_lib::ErrorCode =
ksp_core_lib::ErrorCode::new("config_desk", "logging_source_rollback_failed");
/// Shared Config Desk application state is internally inconsistent.
pub(crate) const ERROR_CODE_APP_STATE_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config_desk", "app_state_invalid");
/// Shared Config Desk runtime state cannot be locked safely.
pub(crate) const ERROR_CODE_APP_STATE_LOCK_FAILED: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config_desk", "app_state_lock_failed");
/// Frontend logging requested an unsupported level.
pub(crate) const ERROR_CODE_FRONTEND_LOG_LEVEL_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config_desk", "frontend_log_level_invalid");
/// Frontend logging requested a target outside the application whitelist.
pub(crate) const ERROR_CODE_FRONTEND_LOG_TARGET_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config_desk", "frontend_log_target_invalid");
/// Controlled Logging test request contains an unsupported or invalid selector.
pub(crate) const ERROR_CODE_LOGGING_TEST_REQUEST_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config_desk", "logging_test_request_invalid");
/// A validated Config document does not expose the standard profile contract expected by the Profiles panel.
pub(crate) const ERROR_CODE_PROFILE_CONTRACT_MISSING: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config_desk", "profile_contract_missing");
/// Config profile data could not be projected safely for the frontend.
pub(crate) const ERROR_CODE_PROFILE_PROJECTION_FAILED: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config_desk", "profile_projection_failed");
/// A KSP desk splash environment duration is malformed or exceeds its safety bound.
pub(crate) const ERROR_CODE_SPLASH_SETTING_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config_desk", "splash_setting_invalid");
/// Splash readiness was invoked from a window other than the splash window.
pub(crate) const ERROR_CODE_SPLASH_ORIGIN_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config_desk", "splash_origin_invalid");
/// A required Tauri window is missing from the configured application runtime.
pub(crate) const ERROR_CODE_TAURI_WINDOW_MISSING: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config_desk", "tauri_window_missing");
/// A Tauri window show/focus/destroy/event operation failed.
pub(crate) const ERROR_CODE_TAURI_WINDOW_OPERATION_FAILED: ksp_core_lib::ErrorCode =
ksp_core_lib::ErrorCode::new("config_desk", "tauri_window_operation_failed");
/// A Documents command requested a registered file that is not a Config-kind document.
pub(crate) const ERROR_CODE_DOCUMENT_KIND_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config_desk", "document_kind_invalid");
/// Privileged reveal was requested for a non-Secret KSP/KSPB namespace.
pub(crate) const ERROR_CODE_SECRET_REVEAL_REQUIRES_SECRET: ksp_core_lib::ErrorCode =
ksp_core_lib::ErrorCode::new("config_desk", "secret_reveal_requires_secret");
/// Privileged reveal requested an unsupported source selector.
pub(crate) const ERROR_CODE_SECRET_REVEAL_SOURCE_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config_desk", "secret_reveal_source_invalid");

View File

@@ -0,0 +1,205 @@
// file: crates/ksp-app-config-desk/src/frontend_logging.rs
// version: 1
//! KSP-owned bridge for technical log events emitted by Config Desk frontend scripts.
use ts_rs::TS; // rust-rules: derive-import
/// Log payload sent by Config Desk frontend scripts.
#[derive(Clone, Debug, serde::Deserialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "../frontend/ts/bindings/ksp_app_config_desk/frontend_logging/FrontendLogPayloadDto.ts")]
pub(crate) struct FrontendLogPayloadDto {
/// Lowercase KSP log level.
pub(crate) level: String,
/// Whitelisted logical frontend target identifier.
pub(crate) target_id: String,
/// Rendered technical frontend message.
pub(crate) message: String,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum FrontendLogLevel {
Trace,
Debug,
Info,
Warn,
Error,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum FrontendLogTarget {
Frontend,
Main,
Splash,
}
/// Emits one validated frontend event through the KSP Logging facade.
pub(crate) fn emit_frontend_log_event(payload: FrontendLogPayloadDto) -> ksp_core_lib::Result<()> {
let level = parse_level(payload.level.as_str());
let level = match level {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let target = parse_target(payload.target_id.as_str());
let target = match target {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
emit_validated_frontend_log(level, target, payload.message.as_str());
return std::result::Result::Ok(());
}
fn parse_level(level: &str) -> ksp_core_lib::Result<FrontendLogLevel> {
return match level.trim().to_ascii_lowercase().as_str() {
"trace" => std::result::Result::Ok(FrontendLogLevel::Trace),
"debug" => std::result::Result::Ok(FrontendLogLevel::Debug),
"info" => std::result::Result::Ok(FrontendLogLevel::Info),
"warn" => std::result::Result::Ok(FrontendLogLevel::Warn),
"error" => std::result::Result::Ok(FrontendLogLevel::Error),
_ => std::result::Result::Err(ksp_core_lib::Error::new(crate::ERROR_CODE_FRONTEND_LOG_LEVEL_INVALID, "Frontend log level is not supported")),
};
}
fn parse_target(target_id: &str) -> ksp_core_lib::Result<FrontendLogTarget> {
return match target_id.trim().to_ascii_lowercase().as_str() {
"frontend" => std::result::Result::Ok(FrontendLogTarget::Frontend),
"main" => std::result::Result::Ok(FrontendLogTarget::Main),
"splash" => std::result::Result::Ok(FrontendLogTarget::Splash),
_ => {
std::result::Result::Err(ksp_core_lib::Error::new(crate::ERROR_CODE_FRONTEND_LOG_TARGET_INVALID, "Frontend log target identifier is not supported"))
},
};
}
fn emit_validated_frontend_log(level: FrontendLogLevel, target: FrontendLogTarget, message: &str) {
return match target {
FrontendLogTarget::Frontend => emit_frontend_target(level, message),
FrontendLogTarget::Main => emit_main_target(level, message),
FrontendLogTarget::Splash => emit_splash_target(level, message),
};
}
fn emit_frontend_target(level: FrontendLogLevel, message: &str) {
return match level {
FrontendLogLevel::Trace => ksp_logging_lib::trace!(
target: crate::TRACING_TARGET_FRONTEND,
domain = crate::TRACING_DOMAIN_FRONTEND,
action = "frontend_log",
target_id = "frontend",
"{message}"
),
FrontendLogLevel::Debug => ksp_logging_lib::debug!(
target: crate::TRACING_TARGET_FRONTEND,
domain = crate::TRACING_DOMAIN_FRONTEND,
action = "frontend_log",
target_id = "frontend",
"{message}"
),
FrontendLogLevel::Info => ksp_logging_lib::info!(
target: crate::TRACING_TARGET_FRONTEND,
domain = crate::TRACING_DOMAIN_FRONTEND,
action = "frontend_log",
target_id = "frontend",
"{message}"
),
FrontendLogLevel::Warn => ksp_logging_lib::warn!(
target: crate::TRACING_TARGET_FRONTEND,
domain = crate::TRACING_DOMAIN_FRONTEND,
action = "frontend_log",
target_id = "frontend",
"{message}"
),
FrontendLogLevel::Error => ksp_logging_lib::error!(
target: crate::TRACING_TARGET_FRONTEND,
domain = crate::TRACING_DOMAIN_FRONTEND,
action = "frontend_log",
target_id = "frontend",
"{message}"
),
};
}
fn emit_main_target(level: FrontendLogLevel, message: &str) {
return match level {
FrontendLogLevel::Trace => ksp_logging_lib::trace!(
target: crate::TRACING_TARGET_FRONTEND_MAIN,
domain = crate::TRACING_DOMAIN_FRONTEND,
action = "frontend_log",
target_id = "main",
"{message}"
),
FrontendLogLevel::Debug => ksp_logging_lib::debug!(
target: crate::TRACING_TARGET_FRONTEND_MAIN,
domain = crate::TRACING_DOMAIN_FRONTEND,
action = "frontend_log",
target_id = "main",
"{message}"
),
FrontendLogLevel::Info => ksp_logging_lib::info!(
target: crate::TRACING_TARGET_FRONTEND_MAIN,
domain = crate::TRACING_DOMAIN_FRONTEND,
action = "frontend_log",
target_id = "main",
"{message}"
),
FrontendLogLevel::Warn => ksp_logging_lib::warn!(
target: crate::TRACING_TARGET_FRONTEND_MAIN,
domain = crate::TRACING_DOMAIN_FRONTEND,
action = "frontend_log",
target_id = "main",
"{message}"
),
FrontendLogLevel::Error => ksp_logging_lib::error!(
target: crate::TRACING_TARGET_FRONTEND_MAIN,
domain = crate::TRACING_DOMAIN_FRONTEND,
action = "frontend_log",
target_id = "main",
"{message}"
),
};
}
fn emit_splash_target(level: FrontendLogLevel, message: &str) {
return match level {
FrontendLogLevel::Trace => ksp_logging_lib::trace!(
target: crate::TRACING_TARGET_FRONTEND_SPLASH,
domain = crate::TRACING_DOMAIN_FRONTEND,
action = "frontend_log",
target_id = "splash",
"{message}"
),
FrontendLogLevel::Debug => ksp_logging_lib::debug!(
target: crate::TRACING_TARGET_FRONTEND_SPLASH,
domain = crate::TRACING_DOMAIN_FRONTEND,
action = "frontend_log",
target_id = "splash",
"{message}"
),
FrontendLogLevel::Info => ksp_logging_lib::info!(
target: crate::TRACING_TARGET_FRONTEND_SPLASH,
domain = crate::TRACING_DOMAIN_FRONTEND,
action = "frontend_log",
target_id = "splash",
"{message}"
),
FrontendLogLevel::Warn => ksp_logging_lib::warn!(
target: crate::TRACING_TARGET_FRONTEND_SPLASH,
domain = crate::TRACING_DOMAIN_FRONTEND,
action = "frontend_log",
target_id = "splash",
"{message}"
),
FrontendLogLevel::Error => ksp_logging_lib::error!(
target: crate::TRACING_TARGET_FRONTEND_SPLASH,
domain = crate::TRACING_DOMAIN_FRONTEND,
action = "frontend_log",
target_id = "splash",
"{message}"
),
};
}
#[cfg(test)]
#[path = "../unit_tests/frontend_logging.rs"]
mod tests;

View File

@@ -0,0 +1,88 @@
// file: crates/ksp-app-config-desk/src/lib.rs
// version: 14
//! Tauri desktop application for managing and validating KSP configuration.
#![forbid(unsafe_code)]
#![deny(unreachable_pub)]
#![warn(missing_docs)]
mod app_state;
mod bootstrap;
mod constants;
mod documents;
mod dto_common;
mod environment;
mod errors;
mod frontend_logging;
mod logging_editor;
mod logging_runtime;
mod logging_test;
mod profiles;
mod secrets;
mod splash;
mod tauri;
mod tw_main;
mod tw_splash;
/// Runs the KSP configuration desktop application.
pub use self::tauri::run;
pub(crate) use self::app_state::AppState;
pub(crate) use self::bootstrap::config_management;
pub(crate) use self::bootstrap::initialize_logging;
pub(crate) use self::constants::TRACING_DOMAIN_BOOTSTRAP;
pub(crate) use self::constants::TRACING_DOMAIN_DOCUMENTS;
pub(crate) use self::constants::TRACING_DOMAIN_ENVIRONMENT;
pub(crate) use self::constants::TRACING_DOMAIN_FRONTEND;
pub(crate) use self::constants::TRACING_DOMAIN_LOGGING_EDITOR;
pub(crate) use self::constants::TRACING_DOMAIN_LOGGING_RUNTIME;
pub(crate) use self::constants::TRACING_DOMAIN_LOGGING_TEST;
pub(crate) use self::constants::TRACING_DOMAIN_PROFILES;
pub(crate) use self::constants::TRACING_DOMAIN_SECRETS;
pub(crate) use self::constants::TRACING_DOMAIN_WINDOWS;
pub(crate) use self::constants::TRACING_TARGET;
pub(crate) use self::constants::TRACING_TARGET_FRONTEND;
pub(crate) use self::constants::TRACING_TARGET_FRONTEND_MAIN;
pub(crate) use self::constants::TRACING_TARGET_FRONTEND_SPLASH;
pub(crate) use self::constants::TRACING_TARGET_LOGGING_TEST;
pub(crate) use self::documents::ConfigDocumentDetailDto;
pub(crate) use self::documents::ConfigDocumentErrorDto;
pub(crate) use self::documents::ConfigDocumentSaveResultDto;
pub(crate) use self::documents::ConfigDocumentSummaryDto;
pub(crate) use self::dto_common::AppSnapshotDto;
pub(crate) use self::dto_common::CommandErrorDto;
pub(crate) use self::environment::ConfigEnvironmentChangeDto;
pub(crate) use self::environment::ConfigEnvironmentReportDto;
pub(crate) use self::errors::ERROR_CODE_APP_STATE_INVALID;
pub(crate) use self::errors::ERROR_CODE_APP_STATE_LOCK_FAILED;
pub(crate) use self::errors::ERROR_CODE_DOCUMENT_KIND_INVALID;
pub(crate) use self::errors::ERROR_CODE_FRONTEND_LOG_LEVEL_INVALID;
pub(crate) use self::errors::ERROR_CODE_FRONTEND_LOG_TARGET_INVALID;
pub(crate) use self::errors::ERROR_CODE_LOGGING_BOOTSTRAP_FAILED;
pub(crate) use self::errors::ERROR_CODE_LOGGING_SOURCE_ROLLBACK_FAILED;
pub(crate) use self::errors::ERROR_CODE_LOGGING_TEST_REQUEST_INVALID;
pub(crate) use self::errors::ERROR_CODE_PROFILE_CONTRACT_MISSING;
pub(crate) use self::errors::ERROR_CODE_PROFILE_PROJECTION_FAILED;
pub(crate) use self::errors::ERROR_CODE_SECRET_REVEAL_REQUIRES_SECRET;
pub(crate) use self::errors::ERROR_CODE_SECRET_REVEAL_SOURCE_INVALID;
pub(crate) use self::errors::ERROR_CODE_SPLASH_ORIGIN_INVALID;
pub(crate) use self::errors::ERROR_CODE_SPLASH_SETTING_INVALID;
pub(crate) use self::errors::ERROR_CODE_TAURI_RUNTIME_FAILED;
pub(crate) use self::errors::ERROR_CODE_TAURI_WINDOW_MISSING;
pub(crate) use self::errors::ERROR_CODE_TAURI_WINDOW_OPERATION_FAILED;
pub(crate) use self::frontend_logging::FrontendLogPayloadDto;
pub(crate) use self::frontend_logging::emit_frontend_log_event;
pub(crate) use self::logging_editor::LoggingDocumentCandidateDto;
pub(crate) use self::logging_editor::LoggingDocumentDto;
pub(crate) use self::logging_editor::LoggingDocumentSaveResultDto;
pub(crate) use self::logging_runtime::{LoggingRuntimeFileDto, LoggingRuntimeStatusDto};
pub(crate) use self::logging_test::{LoggingTestRequestDto, LoggingTestResultDto};
pub(crate) use self::profiles::ConfigProfileDetailDto;
pub(crate) use self::profiles::ConfigProfileDocumentDto;
pub(crate) use self::secrets::SecretRevealRequestDto;
pub(crate) use self::secrets::SecretRevealResponseDto;
pub(crate) use self::splash::SplashOrderDto;
pub(crate) use self::splash::SplashSettings;
pub(crate) use self::tw_main::show_and_focus as show_main_window;
pub(crate) use self::tw_splash::frontend_ready as splash_frontend_ready_service;

View File

@@ -0,0 +1,369 @@
// file: crates/ksp-app-config-desk/src/logging_editor.rs
// version: 4
//! Typed Logging document projection and validated persistence for the Config Desk Logging editor.
use ts_rs::TS; // rust-rules: derive-import
/// One Logging sink selector/filter exposed to the editor.
#[derive(Clone, Debug, serde::Deserialize, serde::Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "../frontend/ts/bindings/ksp_app_config_desk/logging/LoggingOutputFilterDto.ts")]
pub(crate) struct LoggingOutputFilterDto {
/// Filter level source text.
pub(crate) level: String,
/// Target selectors in source order.
pub(crate) targets: std::vec::Vec<String>,
/// Structured domain selectors in source order.
pub(crate) domains: std::vec::Vec<String>,
}
/// Console sink configuration exposed to the editor.
#[derive(Clone, Debug, serde::Deserialize, serde::Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "../frontend/ts/bindings/ksp_app_config_desk/logging/LoggingConsoleDto.ts")]
pub(crate) struct LoggingConsoleDto {
/// Whether the console sink is enabled.
pub(crate) enabled: bool,
/// `stdout` or `stderr` source text.
pub(crate) output: String,
/// Whether ANSI output is requested.
pub(crate) ansi: bool,
/// Console format source text.
pub(crate) format: String,
/// Sink-local selectors.
pub(crate) filter: LoggingOutputFilterDto,
}
/// One persistent file sink exposed to the editor.
#[derive(Clone, Debug, serde::Deserialize, serde::Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "../frontend/ts/bindings/ksp_app_config_desk/logging/LoggingFileDto.ts")]
pub(crate) struct LoggingFileDto {
/// Stable sink identifier.
pub(crate) output_id: String,
/// Whether the file sink is enabled.
pub(crate) enabled: bool,
/// Source path relative to the Logging root.
pub(crate) path: String,
/// Rotation source text.
pub(crate) rotation: String,
/// File format source text.
pub(crate) format: String,
/// Whether ANSI output is requested.
pub(crate) ansi: bool,
/// Sink-local selectors.
pub(crate) filter: LoggingOutputFilterDto,
}
/// One global Logging target override exposed to the editor.
#[derive(Clone, Debug, serde::Deserialize, serde::Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "../frontend/ts/bindings/ksp_app_config_desk/logging/LoggingTargetFilterDto.ts")]
pub(crate) struct LoggingTargetFilterDto {
/// KSP target prefix.
pub(crate) target_prefix: String,
/// Override level source text.
pub(crate) level: String,
}
/// One typed Logging profile exposed to the editor.
#[derive(Clone, Debug, serde::Deserialize, serde::Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "../frontend/ts/bindings/ksp_app_config_desk/logging/LoggingProfileDto.ts")]
pub(crate) struct LoggingProfileDto {
/// Stable profile identifier.
pub(crate) profile_id: String,
/// Profile-level default filter source text.
pub(crate) default_filter: String,
/// Span lifecycle source text.
pub(crate) span_events: String,
/// Console sink configuration.
pub(crate) console: LoggingConsoleDto,
/// Persistent file sinks in source order.
pub(crate) files: std::vec::Vec<LoggingFileDto>,
/// Global target overrides in source order.
pub(crate) target_filters: std::vec::Vec<LoggingTargetFilterDto>,
}
/// Complete typed Logging document exposed to the editor.
#[derive(Clone, Debug, serde::Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "../frontend/ts/bindings/ksp_app_config_desk/logging/LoggingDocumentDto.ts")]
pub(crate) struct LoggingDocumentDto {
/// Stable Config file identifier.
pub(crate) file_id: String,
/// Physical path resolved by Config.
pub(crate) path: String,
/// Source format version.
pub(crate) format_version: u32,
/// Source Logging root expression/path.
pub(crate) logs_directory: String,
/// Autonomous default profile identifier.
pub(crate) default_profile: String,
/// Typed profiles in source order.
pub(crate) profiles: std::vec::Vec<LoggingProfileDto>,
}
/// Complete editable Logging candidate accepted from the frontend.
#[derive(Clone, Debug, serde::Deserialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "../frontend/ts/bindings/ksp_app_config_desk/logging/LoggingDocumentCandidateDto.ts")]
pub(crate) struct LoggingDocumentCandidateDto {
/// Source Logging root expression/path.
pub(crate) logs_directory: String,
/// Autonomous default profile identifier.
pub(crate) default_profile: String,
/// Typed profiles in source order.
pub(crate) profiles: std::vec::Vec<LoggingProfileDto>,
}
/// Result of one validated typed Logging persistence operation.
#[derive(Clone, Debug, serde::Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "../frontend/ts/bindings/ksp_app_config_desk/logging/LoggingDocumentSaveResultDto.ts")]
pub(crate) struct LoggingDocumentSaveResultDto {
/// Whether `std.logging.json` bytes changed.
pub(crate) source_changed: bool,
/// Whether Config consumers must reload the document.
pub(crate) reload_required: bool,
/// Whether the active KSP Logging runtime was hot-reloaded from the persisted candidate.
pub(crate) runtime_applied: bool,
/// Runtime generation after the successful hot reload.
pub(crate) logging_generation: u32,
/// Profile selected and applied to the runtime.
pub(crate) active_profile: String,
/// Fresh typed document loaded after successful persistence and runtime application.
pub(crate) document: LoggingDocumentDto,
}
/// Loads the typed Logging source through ConfigManagement and projects it for the frontend.
pub(crate) fn document(state: &crate::AppState) -> ksp_core_lib::Result<LoggingDocumentDto> {
return document_from_management(state.config_management());
}
/// Validates, atomically persists and hot-reloads one typed Logging candidate through ConfigManagement and `ksp-logging-lib`.
pub(crate) fn save(state: &crate::AppState, candidate: LoggingDocumentCandidateDto) -> ksp_core_lib::Result<LoggingDocumentSaveResultDto> {
let file_id = ksp_config_lib::ConfigFileId::new(ksp_config_lib::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 previous = state.config_management().read_source(&file_id);
let previous = match previous {
std::result::Result::Ok(value) => value.content().to_owned(),
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let source = config_candidate(candidate);
let report = state.config_management().save_logging_document(&source);
let report = match report {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let applied = apply_persisted_runtime(state);
let (active_profile, logging_generation) = match applied {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => {
return rollback_after_runtime_failure(state.config_management(), &file_id, previous.as_str(), report.source_changed(), error);
},
};
let document = document_from_management(state.config_management());
let document = match document {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
ksp_logging_lib::debug!(
target: crate::TRACING_TARGET,
domain = crate::TRACING_DOMAIN_LOGGING_EDITOR,
source_changed = report.source_changed(),
reload_required = report.reload_required(),
runtime_applied = true,
logging_generation,
active_profile = active_profile.as_str(),
profile_count = document.profiles.len(),
default_profile = document.default_profile.as_str(),
"typed Logging document persistence and runtime hot reload completed"
);
return std::result::Result::Ok(LoggingDocumentSaveResultDto {
source_changed: report.source_changed(),
reload_required: report.reload_required(),
runtime_applied: true,
logging_generation,
active_profile,
document,
});
}
fn apply_persisted_runtime(state: &crate::AppState) -> ksp_core_lib::Result<(String, u32)> {
let environment = ksp_config_lib::ConfigEnvironment::load();
let environment = match environment {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let resolved = state.config_management().engine().load_resolved_logging_config(std::option::Option::None, &environment);
let resolved = match resolved {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let profile_id = resolved.profile_id().to_owned();
let settings = resolved.into_settings();
let generation = state.reinitialize_logging_runtime(profile_id.as_str(), "default_profile", &settings);
return match generation {
std::result::Result::Ok(value) => std::result::Result::Ok((profile_id, value)),
std::result::Result::Err(error) => std::result::Result::Err(error),
};
}
fn rollback_after_runtime_failure(
management: &ksp_config_lib::ConfigManagement,
file_id: &ksp_config_lib::ConfigFileId,
previous_source: &str,
source_changed: bool,
runtime_error: ksp_core_lib::Error,
) -> ksp_core_lib::Result<LoggingDocumentSaveResultDto> {
if !source_changed {
return std::result::Result::Err(runtime_error);
}
let rollback = management.save_source_candidate(file_id, previous_source);
return match rollback {
std::result::Result::Ok(_) => std::result::Result::Err(runtime_error),
std::result::Result::Err(rollback_error) => std::result::Result::Err(
ksp_core_lib::Error::new(
crate::ERROR_CODE_LOGGING_SOURCE_ROLLBACK_FAILED,
"Logging runtime hot reload failed and the previous persisted source could not be restored",
)
.with_context("runtime_error_domain", runtime_error.code().domain())
.with_context("runtime_error_code", runtime_error.code().code())
.with_source(rollback_error),
),
};
}
fn document_from_management(management: &ksp_config_lib::ConfigManagement) -> ksp_core_lib::Result<LoggingDocumentDto> {
let source = management.load_logging_document();
let source = match source {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let file_id = ksp_config_lib::ConfigFileId::new(ksp_config_lib::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 path = management.engine().registry().resolve_path(management.engine().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 mut profiles = std::vec::Vec::<LoggingProfileDto>::with_capacity(source.profiles().len());
let mut file_count = 0usize;
let mut target_filter_count = 0usize;
for profile in source.profiles() {
file_count += profile.files().len();
target_filter_count += profile.target_filters().len();
profiles.push(project_profile(profile));
}
let result = LoggingDocumentDto {
file_id: file_id.as_str().to_owned(),
path: path.display().to_string(),
format_version: source.format_version(),
logs_directory: source.logs_directory().to_owned(),
default_profile: source.default_profile().to_owned(),
profiles,
};
ksp_logging_lib::debug!(
target: crate::TRACING_TARGET,
domain = crate::TRACING_DOMAIN_LOGGING_EDITOR,
file_id = result.file_id.as_str(),
profile_count = result.profiles.len(),
file_count,
target_filter_count,
"typed Logging document projected for editor"
);
return std::result::Result::Ok(result);
}
fn config_candidate(candidate: LoggingDocumentCandidateDto) -> ksp_config_lib::LoggingConfigDocument {
let mut profiles = std::vec::Vec::<ksp_config_lib::LoggingProfileConfig>::with_capacity(candidate.profiles.len());
for profile in candidate.profiles {
profiles.push(config_profile(profile));
}
return ksp_config_lib::LoggingConfigDocument::new(candidate.logs_directory, candidate.default_profile, profiles);
}
fn config_profile(profile: LoggingProfileDto) -> ksp_config_lib::LoggingProfileConfig {
let mut files = std::vec::Vec::<ksp_config_lib::LoggingFileConfig>::with_capacity(profile.files.len());
for file in profile.files {
let mut config =
ksp_config_lib::LoggingFileConfig::new(file.output_id, file.enabled, file.path, file.rotation, file.format, config_output_filter(file.filter));
config.set_ansi(file.ansi);
files.push(config);
}
let mut target_filters = std::vec::Vec::<ksp_config_lib::LoggingTargetFilterConfig>::with_capacity(profile.target_filters.len());
for target_filter in profile.target_filters {
target_filters.push(ksp_config_lib::LoggingTargetFilterConfig::new(target_filter.target_prefix, target_filter.level));
}
return ksp_config_lib::LoggingProfileConfig::new(
profile.profile_id,
profile.default_filter,
profile.span_events,
ksp_config_lib::LoggingConsoleConfig::new(
profile.console.enabled,
profile.console.output,
profile.console.ansi,
profile.console.format,
config_output_filter(profile.console.filter),
),
files,
target_filters,
);
}
fn config_output_filter(filter: LoggingOutputFilterDto) -> ksp_config_lib::LoggingOutputFilterConfig {
return ksp_config_lib::LoggingOutputFilterConfig::new(filter.level, filter.targets, filter.domains);
}
fn project_profile(profile: &ksp_config_lib::LoggingProfileConfig) -> LoggingProfileDto {
let mut files = std::vec::Vec::<LoggingFileDto>::with_capacity(profile.files().len());
for file in profile.files() {
files.push(LoggingFileDto {
output_id: file.output_id().to_owned(),
enabled: file.enabled(),
path: file.path().to_owned(),
rotation: file.rotation().to_owned(),
format: file.format().to_owned(),
ansi: file.ansi(),
filter: project_output_filter(file.filter()),
});
}
let mut target_filters = std::vec::Vec::<LoggingTargetFilterDto>::with_capacity(profile.target_filters().len());
for target_filter in profile.target_filters() {
target_filters.push(LoggingTargetFilterDto { target_prefix: target_filter.target_prefix().to_owned(), level: target_filter.level().to_owned() });
}
return LoggingProfileDto {
profile_id: profile.profile_id().to_owned(),
default_filter: profile.default_filter().to_owned(),
span_events: profile.span_events().to_owned(),
console: LoggingConsoleDto {
enabled: profile.console().enabled(),
output: profile.console().output().to_owned(),
ansi: profile.console().ansi(),
format: profile.console().format().to_owned(),
filter: project_output_filter(profile.console().filter()),
},
files,
target_filters,
};
}
fn project_output_filter(filter: &ksp_config_lib::LoggingOutputFilterConfig) -> LoggingOutputFilterDto {
return LoggingOutputFilterDto {
level: filter.level().to_owned(),
targets: filter.targets().to_vec(),
domains: filter.domains().to_vec(),
};
}
#[cfg(test)]
#[path = "../unit_tests/logging_editor.rs"]
mod tests;

View File

@@ -0,0 +1,118 @@
// file: crates/ksp-app-config-desk/src/logging_runtime.rs
// version: 2
//! Runtime Logging metadata, launch identity and explicit profile application for Config Desk.
use ts_rs::TS; // rust-rules: derive-import
/// Metadata for one currently active persistent Logging file output.
#[derive(Clone, Debug, serde::Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "../frontend/ts/bindings/ksp_app_config_desk/logging_runtime/LoggingRuntimeFileDto.ts")]
pub(crate) struct LoggingRuntimeFileDto {
/// Stable configured output identifier.
pub(crate) output_id: String,
/// Resolved runtime directory.
pub(crate) directory: String,
/// Effective per-launch filename prefix passed to the rolling appender.
pub(crate) file_name_prefix: String,
/// Effective file rotation cadence.
pub(crate) rotation: String,
}
/// Safe observable state of the currently active KSP Logging runtime.
#[derive(Clone, Debug, serde::Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "../frontend/ts/bindings/ksp_app_config_desk/logging_runtime/LoggingRuntimeStatusDto.ts")]
pub(crate) struct LoggingRuntimeStatusDto {
/// Active profile identifier, or `None` while fallback Logging is active.
pub(crate) active_profile: std::option::Option<String>,
/// Source that selected the active runtime profile.
pub(crate) selection_source: String,
/// Monotonic runtime generation.
pub(crate) generation: u32,
/// Whether transient fallback Logging is active.
pub(crate) fallback_active: bool,
/// Stable application identifier embedded in launch-specific file names.
pub(crate) application_id: String,
/// Stable launch timestamp token reused by all hot reloads in this process.
pub(crate) launch_timestamp: String,
/// Whether the currently active console sink is enabled.
pub(crate) console_enabled: bool,
/// Cumulative console lines dropped by non-blocking outputs.
pub(crate) dropped_console_lines: u64,
/// Cumulative file lines dropped by non-blocking outputs.
pub(crate) dropped_file_lines: u64,
/// Cumulative lines dropped across all outputs.
pub(crate) dropped_total_lines: u64,
/// Active persistent file outputs and their launch-specific prefixes.
pub(crate) files: std::vec::Vec<LoggingRuntimeFileDto>,
}
/// Creates the stable runtime identity for this Config Desk process launch.
pub(crate) fn launch_identity() -> ksp_core_lib::Result<ksp_logging_lib::LoggingRuntimeIdentity> {
let timestamp = format!("{}-p{}", chrono::Utc::now().format("%Y%m%d-%H%M%S%.3fZ"), std::process::id());
return ksp_logging_lib::LoggingRuntimeIdentity::new(crate::TRACING_TARGET, timestamp);
}
/// Returns safe metadata for the currently active Logging runtime.
pub(crate) fn status(state: &crate::AppState) -> ksp_core_lib::Result<LoggingRuntimeStatusDto> {
return state.logging_runtime_status();
}
/// Applies one persisted Logging profile explicitly without changing `default_profile` or the source document.
pub(crate) fn apply_profile(state: &crate::AppState, profile_id: &str) -> ksp_core_lib::Result<LoggingRuntimeStatusDto> {
let environment = ksp_config_lib::ConfigEnvironment::load();
let environment = match environment {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let resolved = state.config_management().engine().load_resolved_logging_config(std::option::Option::Some(profile_id), &environment);
let resolved = match resolved {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let active_profile = resolved.profile_id().to_owned();
let settings = resolved.into_settings();
let generation = state.reinitialize_logging_runtime(active_profile.as_str(), "explicit", &settings);
if let std::result::Result::Err(error) = generation {
return std::result::Result::Err(error);
}
ksp_logging_lib::debug!(
target: crate::TRACING_TARGET,
domain = crate::TRACING_DOMAIN_LOGGING_RUNTIME,
active_profile = active_profile.as_str(),
selection_source = "explicit",
"persisted Logging profile applied explicitly to runtime"
);
return state.logging_runtime_status();
}
pub(crate) fn project_file(metadata: &ksp_logging_lib::RuntimeFileMetadata) -> LoggingRuntimeFileDto {
return LoggingRuntimeFileDto {
output_id: metadata.output_id().to_owned(),
directory: metadata.directory().display().to_string(),
file_name_prefix: metadata.file_name_prefix().to_owned(),
rotation: rotation_label(metadata.rotation()).to_owned(),
};
}
pub(crate) const fn rotation_label(rotation: ksp_logging_lib::FileRotation) -> &'static str {
return match rotation {
ksp_logging_lib::FileRotation::Never => "never",
ksp_logging_lib::FileRotation::Hourly => "hourly",
ksp_logging_lib::FileRotation::Daily => "daily",
};
}
pub(crate) fn count_to_u64(value: usize) -> u64 {
let converted = u64::try_from(value);
return match converted {
std::result::Result::Ok(value) => value,
std::result::Result::Err(_) => u64::MAX,
};
}
#[cfg(test)]
#[path = "../unit_tests/logging_runtime.rs"]
mod tests;

View File

@@ -0,0 +1,428 @@
// file: crates/ksp-app-config-desk/src/logging_test.rs
// version: 1
//! Controlled Logging test events for validating KSP runtime routing from Config Desk.
use ts_rs::TS; // rust-rules: derive-import
/// Controlled request emitted through the backend KSP Logging facade.
#[derive(Clone, Debug, serde::Deserialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "../frontend/ts/bindings/ksp_app_config_desk/logging_test/LoggingTestRequestDto.ts")]
pub(crate) struct LoggingTestRequestDto {
/// Requested level: trace/debug/info/warn/error/all.
pub(crate) level: String,
/// Controlled backend target identifier.
pub(crate) target_id: String,
/// Domain selection mode: absent/known/custom.
pub(crate) domain_mode: String,
/// Selected known domain or custom domain token.
pub(crate) domain: String,
/// Human test message emitted as the event body.
pub(crate) message: String,
}
/// Safe result metadata for one controlled backend Logging test request.
#[derive(Clone, Debug, serde::Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "../frontend/ts/bindings/ksp_app_config_desk/logging_test/LoggingTestResultDto.ts")]
pub(crate) struct LoggingTestResultDto {
/// Number of Logging events emitted by the request.
pub(crate) emitted_event_count: u32,
/// Requested level selector.
pub(crate) requested_level: String,
/// Effective static KSP target used by the emitted events.
pub(crate) target: String,
/// Effective domain, or `None` when domain routing is intentionally absent.
pub(crate) domain: std::option::Option<String>,
/// Logging runtime generation active while the events were emitted.
pub(crate) logging_generation: u32,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum LoggingTestLevel {
Trace,
Debug,
Info,
Warn,
Error,
All,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum LoggingTestTarget {
App,
Dedicated,
}
/// Emits one controlled backend test request through `ksp-logging-lib` only.
pub(crate) fn emit(state: &crate::AppState, request: LoggingTestRequestDto) -> ksp_core_lib::Result<LoggingTestResultDto> {
let level = parse_level(request.level.as_str());
let level = match level {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let target = parse_target(request.target_id.as_str());
let target = match target {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let domain = resolve_domain(request.domain_mode.as_str(), request.domain.as_str());
let domain = match domain {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let message = validate_message(request.message.as_str());
let message = match message {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let runtime_status = state.logging_runtime_status();
let runtime_status = match runtime_status {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let emitted_event_count = emit_levels(level, target, domain.as_deref(), message);
return std::result::Result::Ok(LoggingTestResultDto {
emitted_event_count,
requested_level: request.level.trim().to_ascii_lowercase(),
target: target_value(target).to_owned(),
domain,
logging_generation: runtime_status.generation,
});
}
fn parse_level(level: &str) -> ksp_core_lib::Result<LoggingTestLevel> {
return match level.trim().to_ascii_lowercase().as_str() {
"trace" => std::result::Result::Ok(LoggingTestLevel::Trace),
"debug" => std::result::Result::Ok(LoggingTestLevel::Debug),
"info" => std::result::Result::Ok(LoggingTestLevel::Info),
"warn" => std::result::Result::Ok(LoggingTestLevel::Warn),
"error" => std::result::Result::Ok(LoggingTestLevel::Error),
"all" => std::result::Result::Ok(LoggingTestLevel::All),
_ => invalid_request("Logging test level is not supported"),
};
}
fn parse_target(target_id: &str) -> ksp_core_lib::Result<LoggingTestTarget> {
return match target_id.trim().to_ascii_lowercase().as_str() {
"app" => std::result::Result::Ok(LoggingTestTarget::App),
"logging_test" => std::result::Result::Ok(LoggingTestTarget::Dedicated),
_ => invalid_request("Logging test target is not supported"),
};
}
fn resolve_domain(domain_mode: &str, domain: &str) -> ksp_core_lib::Result<std::option::Option<String>> {
return match domain_mode.trim().to_ascii_lowercase().as_str() {
"absent" => std::result::Result::Ok(std::option::Option::None),
"known" => resolve_known_domain(domain),
"custom" => resolve_custom_domain(domain),
_ => invalid_request("Logging test domain mode is not supported"),
};
}
fn resolve_known_domain(domain: &str) -> ksp_core_lib::Result<std::option::Option<String>> {
return match domain.trim() {
crate::TRACING_DOMAIN_LOGGING_TEST | crate::TRACING_DOMAIN_LOGGING_RUNTIME | crate::TRACING_DOMAIN_FRONTEND => {
std::result::Result::Ok(std::option::Option::Some(domain.trim().to_owned()))
},
_ => invalid_request("Logging test known domain is not supported"),
};
}
fn resolve_custom_domain(domain: &str) -> ksp_core_lib::Result<std::option::Option<String>> {
let domain = domain.trim();
if domain.is_empty() || domain.len() > 80 || !domain.chars().all(is_domain_character) {
return invalid_request("Logging test custom domain is invalid");
}
return std::result::Result::Ok(std::option::Option::Some(domain.to_owned()));
}
fn is_domain_character(character: char) -> bool {
return character.is_ascii_alphanumeric() || character == '.' || character == '_' || character == '-';
}
fn validate_message(message: &str) -> ksp_core_lib::Result<&str> {
let message = message.trim();
if message.is_empty() || message.len() > 500 {
return invalid_request("Logging test message must contain between 1 and 500 bytes");
}
return std::result::Result::Ok(message);
}
fn invalid_request<T>(message: &str) -> ksp_core_lib::Result<T> {
return std::result::Result::Err(ksp_core_lib::Error::new(crate::ERROR_CODE_LOGGING_TEST_REQUEST_INVALID, message));
}
const fn target_value(target: LoggingTestTarget) -> &'static str {
return match target {
LoggingTestTarget::App => crate::TRACING_TARGET,
LoggingTestTarget::Dedicated => crate::TRACING_TARGET_LOGGING_TEST,
};
}
fn emit_levels(level: LoggingTestLevel, target: LoggingTestTarget, domain: std::option::Option<&str>, message: &str) -> u32 {
return match level {
LoggingTestLevel::Trace => {
emit_one(LoggingTestLevel::Trace, target, domain, message);
1
},
LoggingTestLevel::Debug => {
emit_one(LoggingTestLevel::Debug, target, domain, message);
1
},
LoggingTestLevel::Info => {
emit_one(LoggingTestLevel::Info, target, domain, message);
1
},
LoggingTestLevel::Warn => {
emit_one(LoggingTestLevel::Warn, target, domain, message);
1
},
LoggingTestLevel::Error => {
emit_one(LoggingTestLevel::Error, target, domain, message);
1
},
LoggingTestLevel::All => {
emit_one(LoggingTestLevel::Trace, target, domain, message);
emit_one(LoggingTestLevel::Debug, target, domain, message);
emit_one(LoggingTestLevel::Info, target, domain, message);
emit_one(LoggingTestLevel::Warn, target, domain, message);
emit_one(LoggingTestLevel::Error, target, domain, message);
5
},
};
}
fn emit_one(level: LoggingTestLevel, target: LoggingTestTarget, domain: std::option::Option<&str>, message: &str) {
return match target {
LoggingTestTarget::App => emit_app_target(level, domain, message),
LoggingTestTarget::Dedicated => emit_dedicated_target(level, domain, message),
};
}
fn emit_app_target(level: LoggingTestLevel, domain: std::option::Option<&str>, message: &str) {
return match level {
LoggingTestLevel::Trace => emit_app_trace(domain, message),
LoggingTestLevel::Debug => emit_app_debug(domain, message),
LoggingTestLevel::Info => emit_app_info(domain, message),
LoggingTestLevel::Warn => emit_app_warn(domain, message),
LoggingTestLevel::Error => emit_app_error(domain, message),
LoggingTestLevel::All => (),
};
}
fn emit_dedicated_target(level: LoggingTestLevel, domain: std::option::Option<&str>, message: &str) {
return match level {
LoggingTestLevel::Trace => emit_dedicated_trace(domain, message),
LoggingTestLevel::Debug => emit_dedicated_debug(domain, message),
LoggingTestLevel::Info => emit_dedicated_info(domain, message),
LoggingTestLevel::Warn => emit_dedicated_warn(domain, message),
LoggingTestLevel::Error => emit_dedicated_error(domain, message),
LoggingTestLevel::All => (),
};
}
fn emit_app_trace(domain: std::option::Option<&str>, message: &str) {
return match domain {
std::option::Option::Some(domain) => ksp_logging_lib::trace!(
target: crate::TRACING_TARGET,
domain = domain,
action = "logging_test",
test_source = "backend",
test_level = "trace",
"{message}"
),
std::option::Option::None => ksp_logging_lib::trace!(
target: crate::TRACING_TARGET,
action = "logging_test",
test_source = "backend",
test_level = "trace",
"{message}"
),
};
}
fn emit_app_debug(domain: std::option::Option<&str>, message: &str) {
return match domain {
std::option::Option::Some(domain) => ksp_logging_lib::debug!(
target: crate::TRACING_TARGET,
domain = domain,
action = "logging_test",
test_source = "backend",
test_level = "debug",
"{message}"
),
std::option::Option::None => ksp_logging_lib::debug!(
target: crate::TRACING_TARGET,
action = "logging_test",
test_source = "backend",
test_level = "debug",
"{message}"
),
};
}
fn emit_app_info(domain: std::option::Option<&str>, message: &str) {
return match domain {
std::option::Option::Some(domain) => ksp_logging_lib::info!(
target: crate::TRACING_TARGET,
domain = domain,
action = "logging_test",
test_source = "backend",
test_level = "info",
"{message}"
),
std::option::Option::None => ksp_logging_lib::info!(
target: crate::TRACING_TARGET,
action = "logging_test",
test_source = "backend",
test_level = "info",
"{message}"
),
};
}
fn emit_app_warn(domain: std::option::Option<&str>, message: &str) {
return match domain {
std::option::Option::Some(domain) => ksp_logging_lib::warn!(
target: crate::TRACING_TARGET,
domain = domain,
action = "logging_test",
test_source = "backend",
test_level = "warn",
"{message}"
),
std::option::Option::None => ksp_logging_lib::warn!(
target: crate::TRACING_TARGET,
action = "logging_test",
test_source = "backend",
test_level = "warn",
"{message}"
),
};
}
fn emit_app_error(domain: std::option::Option<&str>, message: &str) {
return match domain {
std::option::Option::Some(domain) => ksp_logging_lib::error!(
target: crate::TRACING_TARGET,
domain = domain,
action = "logging_test",
test_source = "backend",
test_level = "error",
"{message}"
),
std::option::Option::None => ksp_logging_lib::error!(
target: crate::TRACING_TARGET,
action = "logging_test",
test_source = "backend",
test_level = "error",
"{message}"
),
};
}
fn emit_dedicated_trace(domain: std::option::Option<&str>, message: &str) {
return match domain {
std::option::Option::Some(domain) => ksp_logging_lib::trace!(
target: crate::TRACING_TARGET_LOGGING_TEST,
domain = domain,
action = "logging_test",
test_source = "backend",
test_level = "trace",
"{message}"
),
std::option::Option::None => ksp_logging_lib::trace!(
target: crate::TRACING_TARGET_LOGGING_TEST,
action = "logging_test",
test_source = "backend",
test_level = "trace",
"{message}"
),
};
}
fn emit_dedicated_debug(domain: std::option::Option<&str>, message: &str) {
return match domain {
std::option::Option::Some(domain) => ksp_logging_lib::debug!(
target: crate::TRACING_TARGET_LOGGING_TEST,
domain = domain,
action = "logging_test",
test_source = "backend",
test_level = "debug",
"{message}"
),
std::option::Option::None => ksp_logging_lib::debug!(
target: crate::TRACING_TARGET_LOGGING_TEST,
action = "logging_test",
test_source = "backend",
test_level = "debug",
"{message}"
),
};
}
fn emit_dedicated_info(domain: std::option::Option<&str>, message: &str) {
return match domain {
std::option::Option::Some(domain) => ksp_logging_lib::info!(
target: crate::TRACING_TARGET_LOGGING_TEST,
domain = domain,
action = "logging_test",
test_source = "backend",
test_level = "info",
"{message}"
),
std::option::Option::None => ksp_logging_lib::info!(
target: crate::TRACING_TARGET_LOGGING_TEST,
action = "logging_test",
test_source = "backend",
test_level = "info",
"{message}"
),
};
}
fn emit_dedicated_warn(domain: std::option::Option<&str>, message: &str) {
return match domain {
std::option::Option::Some(domain) => ksp_logging_lib::warn!(
target: crate::TRACING_TARGET_LOGGING_TEST,
domain = domain,
action = "logging_test",
test_source = "backend",
test_level = "warn",
"{message}"
),
std::option::Option::None => ksp_logging_lib::warn!(
target: crate::TRACING_TARGET_LOGGING_TEST,
action = "logging_test",
test_source = "backend",
test_level = "warn",
"{message}"
),
};
}
fn emit_dedicated_error(domain: std::option::Option<&str>, message: &str) {
return match domain {
std::option::Option::Some(domain) => ksp_logging_lib::error!(
target: crate::TRACING_TARGET_LOGGING_TEST,
domain = domain,
action = "logging_test",
test_source = "backend",
test_level = "error",
"{message}"
),
std::option::Option::None => ksp_logging_lib::error!(
target: crate::TRACING_TARGET_LOGGING_TEST,
action = "logging_test",
test_source = "backend",
test_level = "error",
"{message}"
),
};
}
#[cfg(test)]
#[path = "../unit_tests/logging_test.rs"]
mod tests;

View File

@@ -0,0 +1,58 @@
// file: crates/ksp-app-config-desk/src/main.rs
// version: 3
//! Binary entry point for the KSP configuration desktop application.
#![forbid(unsafe_code)]
#![deny(unreachable_pub)]
#![warn(missing_docs)]
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
use fs2::FileExt; // rust-rules: trait-import
fn main() -> std::process::ExitCode {
let working_directory = configure_runtime_working_directory();
if let std::result::Result::Err(error) = working_directory {
eprintln!("cannot configure Config Desk runtime working directory: {error}");
return std::process::ExitCode::FAILURE;
}
let mut lock_path = std::env::temp_dir();
lock_path.push("com_sasedev_ksp_app_config_desk.lock");
let lock_file = match std::fs::OpenOptions::new().read(true).write(true).create(true).truncate(false).open(&lock_path) {
std::result::Result::Ok(file) => file,
std::result::Result::Err(error) => {
eprintln!("cannot create application lock '{}': {error}", lock_path.display());
return std::process::ExitCode::FAILURE;
},
};
if let std::result::Result::Err(error) = lock_file.try_lock_exclusive() {
if error.kind() == std::io::ErrorKind::WouldBlock {
eprintln!("another ksp-app-config-desk instance is already running");
return std::process::ExitCode::FAILURE;
}
eprintln!("cannot acquire application lock '{}': {error}", lock_path.display());
return std::process::ExitCode::FAILURE;
}
let _lock_file = lock_file;
let arguments = std::env::args_os().collect::<std::vec::Vec<std::ffi::OsString>>();
let run_result = ksp_app_config_desk_lib::run(arguments.as_slice());
return match run_result {
std::result::Result::Ok(()) => std::process::ExitCode::SUCCESS,
std::result::Result::Err(error) => {
eprintln!("application error: {error}");
std::process::ExitCode::FAILURE
},
};
}
fn configure_runtime_working_directory() -> std::io::Result<()> {
#[cfg(debug_assertions)]
{
let workspace_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
return std::env::set_current_dir(workspace_root);
}
#[cfg(not(debug_assertions))]
{
return std::result::Result::Ok(());
}
}

View File

@@ -0,0 +1,351 @@
// file: crates/ksp-app-config-desk/src/profiles.rs
// version: 1
//! Safe Config profile inspection and provenance projections for Config Desk.
use ts_rs::TS; // rust-rules: derive-import
/// One Config document that exposes the standard `default_profile` / `profiles` contract.
#[derive(Clone, Debug, serde::Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "../frontend/ts/bindings/ksp_app_config_desk/profiles/ConfigProfileDocumentDto.ts")]
pub(crate) struct ConfigProfileDocumentDto {
/// Stable Config file identifier.
pub(crate) file_id: String,
/// Physical path resolved by Config.
pub(crate) path: String,
/// Autonomous default profile declared by the document.
pub(crate) default_profile: String,
/// Available unique profile identifiers in source order.
pub(crate) profile_ids: std::vec::Vec<String>,
}
/// Top-level origin of one effective profile key.
#[derive(Clone, Debug, serde::Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "../frontend/ts/bindings/ksp_app_config_desk/profiles/ConfigProfileValueOriginDto.ts")]
pub(crate) struct ConfigProfileValueOriginDto {
/// Effective top-level key.
pub(crate) key: String,
/// `global` or `profile`.
pub(crate) origin: String,
}
/// Safe provenance record for one environment contribution in the resolved effective profile.
#[derive(Clone, Debug, serde::Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "../frontend/ts/bindings/ksp_app_config_desk/profiles/ConfigProfileEnvironmentProvenanceDto.ts")]
pub(crate) struct ConfigProfileEnvironmentProvenanceDto {
/// RFC 6901 pointer of the resolved value.
pub(crate) json_pointer: String,
/// Referenced environment variable name; never its value.
pub(crate) variable_name: String,
/// Winning source: `process`, `dotenv` or `fallback`.
pub(crate) source: String,
/// Sensitivity derived from the variable namespace.
pub(crate) sensitivity: String,
}
/// Safe detailed view of one resolved Config profile.
#[derive(Clone, Debug, serde::Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "../frontend/ts/bindings/ksp_app_config_desk/profiles/ConfigProfileDetailDto.ts")]
pub(crate) struct ConfigProfileDetailDto {
/// Stable Config file identifier.
pub(crate) file_id: String,
/// Physical path resolved by Config.
pub(crate) path: String,
/// Autonomous default profile declared by the document.
pub(crate) default_profile: String,
/// Available profile identifiers.
pub(crate) profile_ids: std::vec::Vec<String>,
/// Profile selected for this inspection.
pub(crate) selected_profile: String,
/// `default_profile` or `explicit`.
pub(crate) selection_source: String,
/// Pretty-printed document-global source values.
pub(crate) globals_json: String,
/// Pretty-printed selected source profile object.
pub(crate) profile_json: String,
/// Pretty-printed environment-resolved effective tree using only the safe/redacted representation.
pub(crate) effective_safe_json: String,
/// Strongest sensitivity present in the resolved effective tree.
pub(crate) effective_sensitivity: String,
/// Global/Profile provenance for effective top-level keys.
pub(crate) value_origins: std::vec::Vec<ConfigProfileValueOriginDto>,
/// Environment provenance records without resolved values.
pub(crate) environment_provenance: std::vec::Vec<ConfigProfileEnvironmentProvenanceDto>,
}
/// Lists validated Config documents that expose standard profiles.
pub(crate) fn inventory(state: &crate::AppState) -> ksp_core_lib::Result<std::vec::Vec<ConfigProfileDocumentDto>> {
return inventory_from_management(state.config_management());
}
/// Resolves one default or explicitly selected profile into a safe inspection DTO.
pub(crate) fn detail(state: &crate::AppState, file_id: &str, requested_profile: std::option::Option<&str>) -> ksp_core_lib::Result<ConfigProfileDetailDto> {
return detail_from_management(state.config_management(), file_id, requested_profile);
}
fn inventory_from_management(management: &ksp_config_lib::ConfigManagement) -> ksp_core_lib::Result<std::vec::Vec<ConfigProfileDocumentDto>> {
let mut result = std::vec::Vec::<ConfigProfileDocumentDto>::new();
for descriptor in management.engine().registry().descriptors() {
if descriptor.kind() != ksp_config_lib::ConfigFileKind::Config {
continue;
}
let document = management.engine().load_validated_document(descriptor.file_id());
let document = match document {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let contract = profile_contract(&document);
let contract = match contract {
std::result::Result::Ok(std::option::Option::Some(value)) => value,
std::result::Result::Ok(std::option::Option::None) => continue,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
result.push(ConfigProfileDocumentDto {
file_id: descriptor.file_id().as_str().to_owned(),
path: document.path().to_string_lossy().into_owned(),
default_profile: contract.default_profile,
profile_ids: contract.profile_ids,
});
}
ksp_logging_lib::debug!(
target: crate::TRACING_TARGET,
domain = crate::TRACING_DOMAIN_PROFILES,
profile_document_count = result.len(),
"Config profile document inventory evaluated"
);
return std::result::Result::Ok(result);
}
fn detail_from_management(
management: &ksp_config_lib::ConfigManagement,
file_id: &str,
requested_profile: std::option::Option<&str>,
) -> ksp_core_lib::Result<ConfigProfileDetailDto> {
let file_id = ksp_config_lib::ConfigFileId::new(file_id.to_owned());
let file_id = match file_id {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let document = management.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 contract = profile_contract(&document);
let contract = match contract {
std::result::Result::Ok(std::option::Option::Some(value)) => value,
std::result::Result::Ok(std::option::Option::None) => {
return std::result::Result::Err(
ksp_core_lib::Error::new(crate::ERROR_CODE_PROFILE_CONTRACT_MISSING, "Config document does not expose the standard profile contract")
.with_context("file_id", file_id.as_str()),
);
},
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let resolved = management.engine().load_resolved_profile(&file_id, requested_profile);
let resolved = match resolved {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let environment = ksp_config_lib::ConfigEnvironment::load();
let environment = match environment {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let effective = resolved.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 globals_json = pretty_json(resolved.globals());
let globals_json = match globals_json {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let profile_json = pretty_json(resolved.profile());
let profile_json = match profile_json {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let effective_safe_json = serde_json::to_string_pretty(effective.safe_value());
let effective_safe_json = match effective_safe_json {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => {
return std::result::Result::Err(
ksp_core_lib::Error::new(crate::ERROR_CODE_PROFILE_PROJECTION_FAILED, "Cannot serialize safe effective Config profile").with_source(error),
);
},
};
let value_origins = value_origins(&resolved);
let environment_provenance = environment_provenance(&effective);
let environment_provenance = match environment_provenance {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let selection_source = selection_source_label(resolved.selection_source()).to_owned();
ksp_logging_lib::debug!(
target: crate::TRACING_TARGET,
domain = crate::TRACING_DOMAIN_PROFILES,
file_id = file_id.as_str(),
profile_id = resolved.profile_id(),
selection_source = selection_source.as_str(),
effective_sensitivity = sensitivity_label(effective.sensitivity()),
environment_provenance_count = environment_provenance.len(),
"Config profile detail resolved"
);
return std::result::Result::Ok(ConfigProfileDetailDto {
file_id: file_id.as_str().to_owned(),
path: resolved.path().to_string_lossy().into_owned(),
default_profile: contract.default_profile,
profile_ids: contract.profile_ids,
selected_profile: resolved.profile_id().to_owned(),
selection_source,
globals_json,
profile_json,
effective_safe_json,
effective_sensitivity: sensitivity_label(effective.sensitivity()).to_owned(),
value_origins,
environment_provenance,
});
}
struct ProfileContract {
default_profile: String,
profile_ids: std::vec::Vec<String>,
}
fn profile_contract(document: &ksp_config_lib::ConfigJsonDocument) -> ksp_core_lib::Result<std::option::Option<ProfileContract>> {
let root = match document.value().as_object() {
std::option::Option::Some(value) => value,
std::option::Option::None => return std::result::Result::Ok(std::option::Option::None),
};
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(std::option::Option::None);
}
let default_profile = match default_profile.and_then(serde_json::Value::as_str) {
std::option::Option::Some(value) => value.to_owned(),
std::option::Option::None => {
return std::result::Result::Err(
ksp_core_lib::Error::new(crate::ERROR_CODE_PROFILE_PROJECTION_FAILED, "Validated Config profile document has no readable default_profile")
.with_context("file_id", document.file_id().as_str()),
);
},
};
let profiles = match profiles.and_then(serde_json::Value::as_array) {
std::option::Option::Some(value) => value,
std::option::Option::None => {
return std::result::Result::Err(
ksp_core_lib::Error::new(crate::ERROR_CODE_PROFILE_PROJECTION_FAILED, "Validated Config profile document has no readable profiles array")
.with_context("file_id", document.file_id().as_str()),
);
},
};
let mut profile_ids = std::vec::Vec::<String>::new();
for profile in profiles {
let profile_id = profile
.as_object()
.and_then(|value| {
return value.get("profile_id");
})
.and_then(serde_json::Value::as_str);
let profile_id = match profile_id {
std::option::Option::Some(value) => value,
std::option::Option::None => {
return std::result::Result::Err(
ksp_core_lib::Error::new(crate::ERROR_CODE_PROFILE_PROJECTION_FAILED, "Validated Config profile entry has no readable profile_id")
.with_context("file_id", document.file_id().as_str()),
);
},
};
profile_ids.push(profile_id.to_owned());
}
return std::result::Result::Ok(std::option::Option::Some(ProfileContract { default_profile, profile_ids }));
}
fn pretty_json(value: &serde_json::Map<String, serde_json::Value>) -> ksp_core_lib::Result<String> {
let serialized = serde_json::to_string_pretty(value);
return match serialized {
std::result::Result::Ok(value) => std::result::Result::Ok(value),
std::result::Result::Err(error) => std::result::Result::Err(
ksp_core_lib::Error::new(crate::ERROR_CODE_PROFILE_PROJECTION_FAILED, "Cannot serialize Config profile source projection").with_source(error),
),
};
}
fn value_origins(resolved: &ksp_config_lib::ResolvedConfigProfile) -> std::vec::Vec<ConfigProfileValueOriginDto> {
let mut result = std::vec::Vec::<ConfigProfileValueOriginDto>::new();
for key in resolved.effective().keys() {
let origin = resolved.origin(key.as_str());
let origin = match origin {
std::option::Option::Some(ksp_config_lib::ConfigValueOrigin::Global) => "global",
std::option::Option::Some(ksp_config_lib::ConfigValueOrigin::Profile) => "profile",
std::option::Option::None => "unknown",
};
result.push(ConfigProfileValueOriginDto { key: key.clone(), origin: origin.to_owned() });
}
return result;
}
fn environment_provenance(effective: &ksp_config_lib::ResolvedConfigJson) -> ksp_core_lib::Result<std::vec::Vec<ConfigProfileEnvironmentProvenanceDto>> {
let mut result = std::vec::Vec::<ConfigProfileEnvironmentProvenanceDto>::new();
for (json_pointer, provenance) in effective.provenance() {
for segment in provenance {
let variable_name = segment.variable_name();
let variable_name = match variable_name {
std::option::Option::Some(value) => value,
std::option::Option::None => continue,
};
let source = segment.environment_source();
let source = match source {
std::option::Option::Some(value) => environment_source_label(value),
std::option::Option::None => continue,
};
let sensitivity = ksp_config_lib::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),
};
result.push(ConfigProfileEnvironmentProvenanceDto {
json_pointer: json_pointer.clone(),
variable_name: variable_name.to_owned(),
source: source.to_owned(),
sensitivity: sensitivity_label(sensitivity).to_owned(),
});
}
}
return std::result::Result::Ok(result);
}
const fn selection_source_label(source: ksp_config_lib::ConfigProfileSelectionSource) -> &'static str {
return match source {
ksp_config_lib::ConfigProfileSelectionSource::DefaultProfile => "default_profile",
ksp_config_lib::ConfigProfileSelectionSource::Explicit => "explicit",
ksp_config_lib::ConfigProfileSelectionSource::Composite => "composite",
};
}
const fn environment_source_label(source: ksp_config_lib::ConfigEnvironmentSource) -> &'static str {
return match source {
ksp_config_lib::ConfigEnvironmentSource::Process => "process",
ksp_config_lib::ConfigEnvironmentSource::DotEnv => "dotenv",
ksp_config_lib::ConfigEnvironmentSource::Fallback => "fallback",
};
}
const fn sensitivity_label(sensitivity: ksp_config_lib::ConfigSensitivity) -> &'static str {
return match sensitivity {
ksp_config_lib::ConfigSensitivity::Public => "public",
ksp_config_lib::ConfigSensitivity::Internal => "internal",
ksp_config_lib::ConfigSensitivity::Secret => "secret",
};
}
#[cfg(test)]
#[path = "../unit_tests/profiles.rs"]
mod tests;

View File

@@ -0,0 +1,105 @@
// file: crates/ksp-app-config-desk/src/secrets.rs
// version: 1
//! Privileged, explicitly requested Config Secret reveal boundary for Config Desk.
use ts_rs::TS; // rust-rules: derive-import
/// Privileged reveal request kept separate from ordinary environment-report DTOs.
#[derive(serde::Deserialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "../frontend/ts/bindings/ksp_app_config_desk/secrets/SecretRevealRequestDto.ts")]
pub(crate) struct SecretRevealRequestDto {
/// Secret KSP/KSPB variable to reveal.
pub(crate) variable_name: String,
/// Requested source: `effective` or `dotenv`.
pub(crate) source: String,
}
/// Privileged reveal response. This type deliberately does not derive `Clone` or `Debug`.
#[derive(serde::Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "../frontend/ts/bindings/ksp_app_config_desk/secrets/SecretRevealResponseDto.ts")]
pub(crate) struct SecretRevealResponseDto {
/// Secret variable that was explicitly requested.
pub(crate) variable_name: String,
/// Source that was explicitly requested.
pub(crate) source: String,
/// Real value when the requested source contains one; never include this field in logs or ordinary application state.
pub(crate) value: std::option::Option<String>,
}
#[derive(Clone, Copy, Eq, PartialEq)]
enum SecretRevealSource {
Effective,
DotEnv,
}
impl SecretRevealSource {
fn parse(value: &str) -> ksp_core_lib::Result<Self> {
return match value {
"effective" => std::result::Result::Ok(Self::Effective),
"dotenv" => std::result::Result::Ok(Self::DotEnv),
_ => std::result::Result::Err(ksp_core_lib::Error::new(
crate::ERROR_CODE_SECRET_REVEAL_SOURCE_INVALID,
"Secret reveal source must be effective or dotenv",
)),
};
}
const fn label(self) -> &'static str {
return match self {
Self::Effective => "effective",
Self::DotEnv => "dotenv",
};
}
}
/// Reveals one real Secret value only after the dedicated Tauri command has been explicitly invoked.
pub(crate) fn reveal(state: &crate::AppState, request: SecretRevealRequestDto) -> ksp_core_lib::Result<SecretRevealResponseDto> {
return reveal_from_management(state.config_management(), request);
}
fn reveal_from_management(management: &ksp_config_lib::ConfigManagement, request: SecretRevealRequestDto) -> ksp_core_lib::Result<SecretRevealResponseDto> {
let source = validate_request(request.variable_name.as_str(), request.source.as_str());
let source = match source {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let value = match source {
SecretRevealSource::Effective => management.reveal_effective_environment_value(request.variable_name.as_str()),
SecretRevealSource::DotEnv => management.reveal_dotenv_value(request.variable_name.as_str()),
};
let value = match value {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
ksp_logging_lib::debug!(
target: crate::TRACING_TARGET,
domain = crate::TRACING_DOMAIN_SECRETS,
variable_name = request.variable_name.as_str(),
source = source.label(),
value_present = value.is_some(),
"privileged Config Secret reveal completed"
);
return std::result::Result::Ok(SecretRevealResponseDto { variable_name: request.variable_name, source: source.label().to_owned(), value });
}
fn validate_request(variable_name: &str, source: &str) -> ksp_core_lib::Result<SecretRevealSource> {
let sensitivity = ksp_config_lib::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),
};
if sensitivity != ksp_config_lib::ConfigSensitivity::Secret {
return std::result::Result::Err(ksp_core_lib::Error::new(
crate::ERROR_CODE_SECRET_REVEAL_REQUIRES_SECRET,
"Privileged reveal is restricted to KSP/KSPB Secret namespaces",
));
}
return SecretRevealSource::parse(source);
}
#[cfg(test)]
#[path = "../unit_tests/secrets.rs"]
mod tests;

View File

@@ -0,0 +1,171 @@
// file: crates/ksp-app-config-desk/src/splash.rs
// version: 3
//! Common splash settings and frontend event contracts for Config Desk.
use ts_rs::TS; // rust-rules: derive-import
const ENV_SPLASH_MINIMUM_MS: &str = "KSP_DESK_SPLASH_MINIMUM_MS";
const ENV_SPLASH_FADE_MS: &str = "KSP_DESK_SPLASH_FADE_MS";
const DEFAULT_SPLASH_MINIMUM_MS: u64 = 1200;
const DEFAULT_SPLASH_FADE_MS: u32 = 300;
const MAX_SPLASH_MINIMUM_MS: u64 = 60_000;
const MAX_SPLASH_FADE_MS: u32 = 10_000;
/// Runtime timings used by the common desk splash lifecycle.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) struct SplashSettings {
minimum_ms: u64,
fade_ms: u32,
minimum_source: ksp_config_lib::ConfigEnvironmentSource,
fade_source: ksp_config_lib::ConfigEnvironmentSource,
}
impl SplashSettings {
/// Resolves splash timings through the Config-owned environment snapshot.
pub(crate) fn load() -> ksp_core_lib::Result<Self> {
let environment = ksp_config_lib::ConfigEnvironment::load();
let environment = match environment {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let minimum = environment.resolve_variable(ENV_SPLASH_MINIMUM_MS, std::option::Option::Some("1200"));
let minimum = match minimum {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let fade = environment.resolve_variable(ENV_SPLASH_FADE_MS, std::option::Option::Some("300"));
let fade = match fade {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let minimum_ms = parse_u64_setting(ENV_SPLASH_MINIMUM_MS, minimum.value(), MAX_SPLASH_MINIMUM_MS);
let minimum_ms = match minimum_ms {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let fade_ms = parse_u32_setting(ENV_SPLASH_FADE_MS, fade.value(), MAX_SPLASH_FADE_MS);
let fade_ms = match fade_ms {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
return std::result::Result::Ok(Self { minimum_ms, fade_ms, minimum_source: minimum.source(), fade_source: fade.source() });
}
/// Returns safe in-memory timings used when the managed environment cannot be resolved.
#[must_use]
pub(crate) const fn fallback() -> Self {
return Self {
minimum_ms: DEFAULT_SPLASH_MINIMUM_MS,
fade_ms: DEFAULT_SPLASH_FADE_MS,
minimum_source: ksp_config_lib::ConfigEnvironmentSource::Fallback,
fade_source: ksp_config_lib::ConfigEnvironmentSource::Fallback,
};
}
/// Returns the minimum visible duration after splash frontend readiness.
#[must_use]
pub(crate) const fn minimum_ms(self) -> u64 {
return self.minimum_ms;
}
/// Returns the fade duration used for both fade-in and fade-out.
#[must_use]
pub(crate) const fn fade_ms(self) -> u32 {
return self.fade_ms;
}
/// Returns the safe provenance code for the minimum duration.
#[must_use]
pub(crate) const fn minimum_source(self) -> &'static str {
return environment_source_code(self.minimum_source);
}
/// Returns the safe provenance code for the fade duration.
#[must_use]
pub(crate) const fn fade_source(self) -> &'static str {
return environment_source_code(self.fade_source);
}
/// Returns the minimum backend lifecycle duration from readiness until main activation.
#[must_use]
pub(crate) fn expected_backend_lifecycle_ms(self) -> u64 {
return self.minimum_ms + u64::from(self.fade_ms);
}
}
const fn environment_source_code(source: ksp_config_lib::ConfigEnvironmentSource) -> &'static str {
return match source {
ksp_config_lib::ConfigEnvironmentSource::Process => "process",
ksp_config_lib::ConfigEnvironmentSource::DotEnv => "dotenv",
ksp_config_lib::ConfigEnvironmentSource::Fallback => "fallback",
};
}
/// Command emitted by Rust to the splash frontend.
#[derive(Clone, Debug, serde::Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "../frontend/ts/bindings/ksp_app_config_desk/splash/SplashOrderDto.ts")]
pub(crate) struct SplashOrderDto {
/// Splash action name.
pub(crate) action: String,
/// Optional status text.
pub(crate) message: std::option::Option<String>,
/// Optional animation duration in milliseconds.
pub(crate) duration_ms: std::option::Option<u32>,
}
impl SplashOrderDto {
#[must_use]
pub(crate) fn new(action: &str, message: std::option::Option<&str>, duration_ms: std::option::Option<u32>) -> Self {
return Self { action: action.to_owned(), message: message.map(std::string::ToString::to_string), duration_ms };
}
}
fn parse_u64_setting(variable_name: &str, value: &str, maximum: u64) -> ksp_core_lib::Result<u64> {
let parsed = value.parse::<u64>();
let parsed = match parsed {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => {
return std::result::Result::Err(
ksp_core_lib::Error::new(crate::ERROR_CODE_SPLASH_SETTING_INVALID, "KSP desk splash duration is not a valid unsigned integer")
.with_context("variable_name", variable_name)
.with_source(error),
);
},
};
if parsed > maximum {
return std::result::Result::Err(
ksp_core_lib::Error::new(crate::ERROR_CODE_SPLASH_SETTING_INVALID, "KSP desk splash duration exceeds the allowed bound")
.with_context("variable_name", variable_name)
.with_context("maximum_ms", maximum.to_string()),
);
}
return std::result::Result::Ok(parsed);
}
fn parse_u32_setting(variable_name: &str, value: &str, maximum: u32) -> ksp_core_lib::Result<u32> {
let parsed = value.parse::<u32>();
let parsed = match parsed {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => {
return std::result::Result::Err(
ksp_core_lib::Error::new(crate::ERROR_CODE_SPLASH_SETTING_INVALID, "KSP desk splash duration is not a valid unsigned integer")
.with_context("variable_name", variable_name)
.with_source(error),
);
},
};
if parsed > maximum {
return std::result::Result::Err(
ksp_core_lib::Error::new(crate::ERROR_CODE_SPLASH_SETTING_INVALID, "KSP desk splash duration exceeds the allowed bound")
.with_context("variable_name", variable_name)
.with_context("maximum_ms", maximum.to_string()),
);
}
return std::result::Result::Ok(parsed);
}
#[cfg(test)]
#[path = "../unit_tests/splash.rs"]
mod tests;

View File

@@ -0,0 +1,252 @@
// file: crates/ksp-app-config-desk/src/tauri.rs
// version: 15
//! Tauri runtime assembly for the KSP configuration desktop application.
/// Runs the configuration desktop application.
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run(arguments: &[std::ffi::OsString]) -> ksp_core_lib::Result<()> {
let app_state = crate::AppState::initialize(arguments);
let app_state = match app_state {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let mut builder = tauri::Builder::default();
builder = configure_state(builder, app_state);
builder = configure_plugins(builder);
builder = configure_commands(builder);
builder = configure_setup(builder);
let run_result = builder.run(tauri::generate_context!());
return match run_result {
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_TAURI_RUNTIME_FAILED, "Cannot run KSP Config Desk Tauri runtime")
.with_context("tauri_error", error.to_string()),
),
};
}
fn configure_state(builder: tauri::Builder<tauri::Wry>, app_state: crate::AppState) -> tauri::Builder<tauri::Wry> {
return builder.manage(app_state);
}
fn configure_plugins(builder: tauri::Builder<tauri::Wry>) -> tauri::Builder<tauri::Wry> {
let tracing_plugin = tauri_plugin_tracing::Builder::new().build::<tauri::Wry>();
return builder.plugin(tracing_plugin);
}
#[allow(clippy::question_mark_used)] // Tauri generates the question-mark operator internally for async command dispatch.
fn configure_commands(builder: tauri::Builder<tauri::Wry>) -> tauri::Builder<tauri::Wry> {
return builder.invoke_handler(tauri::generate_handler![
get_app_snapshot,
get_config_documents,
get_config_document_detail,
save_config_document_source,
get_config_profile_documents,
get_config_profile_detail,
get_environment_report,
set_environment_value,
remove_environment_value,
reveal_environment_value,
get_logging_document,
get_logging_runtime_status,
apply_logging_profile,
save_logging_document,
emit_logging_test,
emit_frontend_log,
splash_frontend_ready
]);
}
fn configure_setup(builder: tauri::Builder<tauri::Wry>) -> tauri::Builder<tauri::Wry> {
return builder.setup(|app| {
let splash = crate::tw_splash::require_window(app);
if let std::result::Result::Err(error) = splash {
return std::result::Result::Err(std::boxed::Box::new(error));
}
let main = crate::tw_main::require_window(app);
if let std::result::Result::Err(error) = main {
return std::result::Result::Err(std::boxed::Box::new(error));
}
return std::result::Result::Ok(());
});
}
#[tauri::command]
fn get_app_snapshot(state: tauri::State<'_, crate::AppState>) -> std::result::Result<crate::AppSnapshotDto, crate::CommandErrorDto> {
let snapshot = state.snapshot();
return match snapshot {
std::result::Result::Ok(value) => std::result::Result::Ok(value),
std::result::Result::Err(error) => std::result::Result::Err(crate::CommandErrorDto::from_error(&error)),
};
}
#[tauri::command]
fn get_config_documents(state: tauri::State<'_, crate::AppState>) -> std::vec::Vec<crate::ConfigDocumentSummaryDto> {
return crate::documents::inventory(&state);
}
#[tauri::command]
fn get_config_document_detail(
file_id: String,
state: tauri::State<'_, crate::AppState>,
) -> std::result::Result<crate::ConfigDocumentDetailDto, crate::ConfigDocumentErrorDto> {
return crate::documents::detail(&state, file_id.as_str());
}
#[tauri::command]
fn save_config_document_source(
file_id: String,
source: String,
state: tauri::State<'_, crate::AppState>,
) -> std::result::Result<crate::ConfigDocumentSaveResultDto, crate::ConfigDocumentErrorDto> {
return crate::documents::save_source(&state, file_id.as_str(), source.as_str());
}
#[tauri::command]
fn get_config_profile_documents(
state: tauri::State<'_, crate::AppState>,
) -> std::result::Result<std::vec::Vec<crate::ConfigProfileDocumentDto>, crate::CommandErrorDto> {
let result = crate::profiles::inventory(&state);
return match result {
std::result::Result::Ok(value) => std::result::Result::Ok(value),
std::result::Result::Err(error) => std::result::Result::Err(crate::CommandErrorDto::from_error(&error)),
};
}
#[tauri::command]
fn get_config_profile_detail(
file_id: String,
profile_id: std::option::Option<String>,
state: tauri::State<'_, crate::AppState>,
) -> std::result::Result<crate::ConfigProfileDetailDto, crate::CommandErrorDto> {
let result = crate::profiles::detail(&state, file_id.as_str(), profile_id.as_deref());
return match result {
std::result::Result::Ok(value) => std::result::Result::Ok(value),
std::result::Result::Err(error) => std::result::Result::Err(crate::CommandErrorDto::from_error(&error)),
};
}
#[tauri::command]
fn get_environment_report(
state: tauri::State<'_, crate::AppState>,
) -> std::result::Result<std::vec::Vec<crate::ConfigEnvironmentReportDto>, crate::CommandErrorDto> {
let result = crate::environment::report(&state);
return match result {
std::result::Result::Ok(value) => std::result::Result::Ok(value),
std::result::Result::Err(error) => std::result::Result::Err(crate::CommandErrorDto::from_error(&error)),
};
}
#[tauri::command]
fn set_environment_value(
variable_name: String,
value: String,
state: tauri::State<'_, crate::AppState>,
) -> std::result::Result<crate::ConfigEnvironmentChangeDto, crate::CommandErrorDto> {
let result = crate::environment::set_value(&state, variable_name.as_str(), value.as_str());
return match result {
std::result::Result::Ok(value) => std::result::Result::Ok(value),
std::result::Result::Err(error) => std::result::Result::Err(crate::CommandErrorDto::from_error(&error)),
};
}
#[tauri::command]
fn remove_environment_value(
variable_name: String,
state: tauri::State<'_, crate::AppState>,
) -> std::result::Result<crate::ConfigEnvironmentChangeDto, crate::CommandErrorDto> {
let result = crate::environment::remove_value(&state, variable_name.as_str());
return match result {
std::result::Result::Ok(value) => std::result::Result::Ok(value),
std::result::Result::Err(error) => std::result::Result::Err(crate::CommandErrorDto::from_error(&error)),
};
}
#[tauri::command]
fn reveal_environment_value(
request: crate::SecretRevealRequestDto,
state: tauri::State<'_, crate::AppState>,
) -> std::result::Result<crate::SecretRevealResponseDto, crate::CommandErrorDto> {
let result = crate::secrets::reveal(&state, request);
return match result {
std::result::Result::Ok(value) => std::result::Result::Ok(value),
std::result::Result::Err(error) => std::result::Result::Err(crate::CommandErrorDto::from_error(&error)),
};
}
#[tauri::command]
fn get_logging_document(state: tauri::State<'_, crate::AppState>) -> std::result::Result<crate::LoggingDocumentDto, crate::CommandErrorDto> {
let result = crate::logging_editor::document(&state);
return match result {
std::result::Result::Ok(value) => std::result::Result::Ok(value),
std::result::Result::Err(error) => std::result::Result::Err(crate::CommandErrorDto::from_error(&error)),
};
}
#[tauri::command]
fn get_logging_runtime_status(state: tauri::State<'_, crate::AppState>) -> std::result::Result<crate::LoggingRuntimeStatusDto, crate::CommandErrorDto> {
let result = crate::logging_runtime::status(&state);
return match result {
std::result::Result::Ok(value) => std::result::Result::Ok(value),
std::result::Result::Err(error) => std::result::Result::Err(crate::CommandErrorDto::from_error(&error)),
};
}
#[tauri::command]
fn apply_logging_profile(
profile_id: String,
state: tauri::State<'_, crate::AppState>,
) -> std::result::Result<crate::LoggingRuntimeStatusDto, crate::CommandErrorDto> {
let result = crate::logging_runtime::apply_profile(&state, profile_id.as_str());
return match result {
std::result::Result::Ok(value) => std::result::Result::Ok(value),
std::result::Result::Err(error) => std::result::Result::Err(crate::CommandErrorDto::from_error(&error)),
};
}
#[tauri::command]
fn save_logging_document(
candidate: crate::LoggingDocumentCandidateDto,
state: tauri::State<'_, crate::AppState>,
) -> std::result::Result<crate::LoggingDocumentSaveResultDto, crate::CommandErrorDto> {
let result = crate::logging_editor::save(&state, candidate);
return match result {
std::result::Result::Ok(value) => std::result::Result::Ok(value),
std::result::Result::Err(error) => std::result::Result::Err(crate::CommandErrorDto::from_error(&error)),
};
}
#[tauri::command]
fn emit_logging_test(
request: crate::LoggingTestRequestDto,
state: tauri::State<'_, crate::AppState>,
) -> std::result::Result<crate::LoggingTestResultDto, crate::CommandErrorDto> {
let result = crate::logging_test::emit(&state, request);
return match result {
std::result::Result::Ok(value) => std::result::Result::Ok(value),
std::result::Result::Err(error) => std::result::Result::Err(crate::CommandErrorDto::from_error(&error)),
};
}
#[tauri::command]
fn emit_frontend_log(payload: crate::FrontendLogPayloadDto) -> std::result::Result<(), crate::CommandErrorDto> {
let result = crate::emit_frontend_log_event(payload);
return match result {
std::result::Result::Ok(()) => std::result::Result::Ok(()),
std::result::Result::Err(error) => std::result::Result::Err(crate::CommandErrorDto::from_error(&error)),
};
}
#[tauri::command]
async fn splash_frontend_ready(
app: tauri::AppHandle,
webview_window: tauri::WebviewWindow,
state: tauri::State<'_, crate::AppState>,
) -> std::result::Result<(), crate::CommandErrorDto> {
let result = crate::splash_frontend_ready_service(app, webview_window, &state).await;
return match result {
std::result::Result::Ok(()) => std::result::Result::Ok(()),
std::result::Result::Err(error) => std::result::Result::Err(crate::CommandErrorDto::from_error(&error)),
};
}

View File

@@ -0,0 +1,50 @@
// file: crates/ksp-app-config-desk/src/tw_main.rs
// version: 1
//! Tauri-window helpers for the Config Desk main window.
use tauri::Manager; // rust-rules: trait-import
pub(crate) const WINDOW_LABEL_MAIN: &str = "main";
pub(crate) fn require_window(manager: &impl Manager<tauri::Wry>) -> ksp_core_lib::Result<tauri::WebviewWindow> {
let window = manager.get_webview_window(WINDOW_LABEL_MAIN);
return match window {
std::option::Option::Some(value) => std::result::Result::Ok(value),
std::option::Option::None => std::result::Result::Err(
ksp_core_lib::Error::new(crate::ERROR_CODE_TAURI_WINDOW_MISSING, "Config Desk main window is missing")
.with_context("window_label", WINDOW_LABEL_MAIN),
),
};
}
pub(crate) fn show_and_focus(app: &tauri::AppHandle) -> ksp_core_lib::Result<()> {
let window = require_window(app);
let window = match window {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let shown = window.show();
if let std::result::Result::Err(error) = shown {
return std::result::Result::Err(
ksp_core_lib::Error::new(crate::ERROR_CODE_TAURI_WINDOW_OPERATION_FAILED, "Cannot show Config Desk main window")
.with_context("window_label", WINDOW_LABEL_MAIN)
.with_source(error),
);
}
let focused = window.set_focus();
if let std::result::Result::Err(error) = focused {
return std::result::Result::Err(
ksp_core_lib::Error::new(crate::ERROR_CODE_TAURI_WINDOW_OPERATION_FAILED, "Cannot focus Config Desk main window")
.with_context("window_label", WINDOW_LABEL_MAIN)
.with_source(error),
);
}
ksp_logging_lib::debug!(
target: crate::TRACING_TARGET,
domain = crate::TRACING_DOMAIN_WINDOWS,
window_label = WINDOW_LABEL_MAIN,
"main window shown and focused"
);
return std::result::Result::Ok(());
}

View File

@@ -0,0 +1,132 @@
// file: crates/ksp-app-config-desk/src/tw_splash.rs
// version: 2
//! Tauri-window lifecycle for the Config Desk splash window.
use tauri::Emitter; // rust-rules: trait-import
use tauri::Manager; // rust-rules: trait-import
pub(crate) const WINDOW_LABEL_SPLASH: &str = "splash";
const SPLASH_EVENT_NAME: &str = "ksp-splash-order";
pub(crate) fn require_window(manager: &impl Manager<tauri::Wry>) -> ksp_core_lib::Result<tauri::WebviewWindow> {
let window = manager.get_webview_window(WINDOW_LABEL_SPLASH);
return match window {
std::option::Option::Some(value) => std::result::Result::Ok(value),
std::option::Option::None => std::result::Result::Err(
ksp_core_lib::Error::new(crate::ERROR_CODE_TAURI_WINDOW_MISSING, "Config Desk splash window is missing")
.with_context("window_label", WINDOW_LABEL_SPLASH),
),
};
}
pub(crate) async fn frontend_ready(app: tauri::AppHandle, invoking_window: tauri::WebviewWindow, state: &crate::AppState) -> ksp_core_lib::Result<()> {
if invoking_window.label() != WINDOW_LABEL_SPLASH {
return std::result::Result::Err(
ksp_core_lib::Error::new(crate::ERROR_CODE_SPLASH_ORIGIN_INVALID, "Splash readiness may only originate from the splash window")
.with_context("window_label", invoking_window.label()),
);
}
if !state.begin_splash_sequence() {
ksp_logging_lib::trace!(
target: crate::TRACING_TARGET,
domain = crate::TRACING_DOMAIN_WINDOWS,
window_label = WINDOW_LABEL_SPLASH,
"duplicate splash frontend readiness ignored"
);
return std::result::Result::Ok(());
}
let settings = state.splash_settings();
let lifecycle_started = std::time::Instant::now();
ksp_logging_lib::debug!(
target: crate::TRACING_TARGET,
domain = crate::TRACING_DOMAIN_WINDOWS,
window_label = WINDOW_LABEL_SPLASH,
minimum_ms = settings.minimum_ms(),
minimum_source = settings.minimum_source(),
fade_ms = settings.fade_ms(),
fade_source = settings.fade_source(),
expected_backend_lifecycle_ms = settings.expected_backend_lifecycle_ms(),
"splash frontend readiness accepted; starting splash to main lifecycle"
);
let fade_in = emit_order(
&invoking_window,
crate::SplashOrderDto::new("fade_in", std::option::Option::Some("Initialisation de KSP Config Desk..."), std::option::Option::Some(settings.fade_ms())),
);
if let std::result::Result::Err(error) = fade_in {
return std::result::Result::Err(error);
}
let minimum_wait_started = std::time::Instant::now();
tokio::time::sleep(std::time::Duration::from_millis(settings.minimum_ms())).await;
ksp_logging_lib::debug!(
target: crate::TRACING_TARGET,
domain = crate::TRACING_DOMAIN_WINDOWS,
window_label = WINDOW_LABEL_SPLASH,
configured_wait_ms = settings.minimum_ms(),
actual_wait_ms = minimum_wait_started.elapsed().as_secs_f64() * 1000.0,
lifecycle_elapsed_ms = lifecycle_started.elapsed().as_secs_f64() * 1000.0,
"splash minimum wait completed"
);
let fade_out = emit_order(
&invoking_window,
crate::SplashOrderDto::new("fade_out", std::option::Option::Some("Initialisation terminée."), std::option::Option::Some(settings.fade_ms())),
);
if let std::result::Result::Err(error) = fade_out {
return std::result::Result::Err(error);
}
let fade_wait_started = std::time::Instant::now();
tokio::time::sleep(std::time::Duration::from_millis(u64::from(settings.fade_ms()))).await;
ksp_logging_lib::debug!(
target: crate::TRACING_TARGET,
domain = crate::TRACING_DOMAIN_WINDOWS,
window_label = WINDOW_LABEL_SPLASH,
configured_wait_ms = settings.fade_ms(),
actual_wait_ms = fade_wait_started.elapsed().as_secs_f64() * 1000.0,
lifecycle_elapsed_ms = lifecycle_started.elapsed().as_secs_f64() * 1000.0,
"splash fade-out wait completed"
);
let show_main = crate::show_main_window(&app);
if let std::result::Result::Err(error) = show_main {
return std::result::Result::Err(error);
}
let destroyed = invoking_window.destroy();
if let std::result::Result::Err(error) = destroyed {
return std::result::Result::Err(
ksp_core_lib::Error::new(crate::ERROR_CODE_TAURI_WINDOW_OPERATION_FAILED, "Cannot destroy Config Desk splash window")
.with_context("window_label", WINDOW_LABEL_SPLASH)
.with_source(error),
);
}
ksp_logging_lib::debug!(
target: crate::TRACING_TARGET,
domain = crate::TRACING_DOMAIN_WINDOWS,
window_label = WINDOW_LABEL_SPLASH,
expected_backend_lifecycle_ms = settings.expected_backend_lifecycle_ms(),
actual_backend_lifecycle_ms = lifecycle_started.elapsed().as_secs_f64() * 1000.0,
"splash window destroyed after main activation"
);
return std::result::Result::Ok(());
}
fn emit_order(window: &tauri::WebviewWindow, order: crate::SplashOrderDto) -> ksp_core_lib::Result<()> {
let action = order.action.clone();
let emitted = window.emit(SPLASH_EVENT_NAME, order);
return match emitted {
std::result::Result::Ok(()) => {
ksp_logging_lib::trace!(
target: crate::TRACING_TARGET,
domain = crate::TRACING_DOMAIN_WINDOWS,
window_label = WINDOW_LABEL_SPLASH,
action = action.as_str(),
"splash order emitted"
);
std::result::Result::Ok(())
},
std::result::Result::Err(error) => std::result::Result::Err(
ksp_core_lib::Error::new(crate::ERROR_CODE_TAURI_WINDOW_OPERATION_FAILED, "Cannot emit Config Desk splash order")
.with_context("window_label", WINDOW_LABEL_SPLASH)
.with_context("action", action)
.with_source(error),
),
};
}

View File

@@ -0,0 +1,50 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "KSP Config Desk",
"version": "0.1.4",
"identifier": "com.sasedev.ksp-app-config-desk",
"build": {
"beforeDevCommand": "npm run dev",
"devUrl": "http://localhost:1430",
"beforeBuildCommand": "npm run build",
"frontendDist": "../../../builds/khadhroony-solana-project/ksp-app-config-desk/dist"
},
"app": {
"windows": [
{
"label": "splash",
"url": "splash.html",
"title": "Chargement — KSP Config Desk",
"width": 960,
"height": 637,
"resizable": false,
"decorations": false,
"transparent": true,
"center": true,
"alwaysOnTop": true
},
{
"label": "main",
"url": "main.html",
"title": "KSP Config Desk",
"width": 1500,
"height": 960,
"minWidth": 1024,
"minHeight": 768,
"center": true,
"visible": false
}
],
"security": {
"csp": null
}
},
"bundle": {
"active": true,
"targets": "all",
"icon": [
"icons/favicon.png",
"icons/favicon.ico"
]
}
}

View File

@@ -0,0 +1,72 @@
// file: crates/ksp-app-config-desk/tests/desktop_contract.rs
// version: 2
//! Desktop build/shell contract audits for Config Desk.
fn app_root() -> std::path::PathBuf {
return std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"));
}
fn read_text(path: &std::path::Path) -> String {
let source = std::fs::read_to_string(path);
assert!(source.is_ok(), "unable to read {}", path.display());
return match source {
std::result::Result::Ok(value) => value,
std::result::Result::Err(_) => String::new(),
};
}
fn read_json(path: &std::path::Path) -> serde_json::Value {
let source = read_text(path);
let parsed = serde_json::from_str::<serde_json::Value>(source.as_str());
assert!(parsed.is_ok(), "unable to parse {}", path.display());
return match parsed {
std::result::Result::Ok(value) => value,
std::result::Result::Err(_) => serde_json::Value::Null,
};
}
#[test]
fn tauri_and_frontend_build_contracts_remain_explicit() {
let root = app_root();
let tauri = read_json(root.join("tauri.conf.json").as_path());
assert_eq!(tauri.pointer("/build/devUrl").and_then(serde_json::Value::as_str), std::option::Option::Some("http://localhost:1430"));
assert_eq!(tauri.pointer("/build/beforeDevCommand").and_then(serde_json::Value::as_str), std::option::Option::Some("npm run dev"));
assert_eq!(tauri.pointer("/build/beforeBuildCommand").and_then(serde_json::Value::as_str), std::option::Option::Some("npm run build"));
let windows = tauri.pointer("/app/windows").and_then(serde_json::Value::as_array);
assert!(windows.is_some());
let windows = match windows {
std::option::Option::Some(value) => value,
std::option::Option::None => return,
};
assert_eq!(windows.len(), 2);
let labels = windows.iter().filter_map(|window| return window.get("label").and_then(serde_json::Value::as_str)).collect::<std::vec::Vec<_>>();
assert_eq!(labels, ["splash", "main"]);
let main = windows.iter().find(|window| return window.get("label").and_then(serde_json::Value::as_str) == std::option::Option::Some("main"));
assert!(main.is_some());
if let std::option::Option::Some(main) = main {
assert_eq!(main.get("visible").and_then(serde_json::Value::as_bool), std::option::Option::Some(false));
}
let package = read_json(root.join("package.json").as_path());
let build = package.pointer("/scripts/build").and_then(serde_json::Value::as_str);
let check = package.pointer("/scripts/check").and_then(serde_json::Value::as_str);
assert!(build.is_some_and(|value| return value.contains("tsc") && value.contains("vite build")));
assert_eq!(check, std::option::Option::Some("tsc --noEmit"));
}
#[test]
fn shell_registry_owns_view_activation_and_specialized_editor_dispatch() {
let root = app_root();
let registry = read_text(root.join("frontend/ts/shell_registry.ts").as_path());
let main = read_text(root.join("frontend/ts/main.ts").as_path());
let documents = read_text(root.join("frontend/ts/documents.ts").as_path());
let html = read_text(root.join("frontend/main.html").as_path());
assert!(registry.contains("export const viewRegistry"));
assert!(registry.contains("fileId: \"cfg.std.logging\""));
assert!(registry.contains("viewId: \"logging\""));
assert!(main.contains("registeredViewIds()"));
assert!(main.contains("ksp:activate-view"));
assert!(documents.contains("specializedEditorForFileId"));
assert!(documents.contains("requestViewActivation"));
assert!(html.contains("id=\"openSpecializedEditor\""));
}

View File

@@ -0,0 +1,80 @@
// file: crates/ksp-app-config-desk/tests/desktop_security.rs
// version: 1
//! Desktop security/ownership regression audits local to Config Desk.
fn app_root() -> std::path::PathBuf {
return std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"));
}
fn collect_files(directory: &std::path::Path, extension: &str, 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_files(path.as_path(), extension, files);
continue;
}
if path.extension().and_then(std::ffi::OsStr::to_str) == std::option::Option::Some(extension) {
files.push(path);
}
}
}
fn read_text(path: &std::path::Path) -> String {
let source = std::fs::read_to_string(path);
assert!(source.is_ok(), "unable to read {}", path.display());
return match source {
std::result::Result::Ok(value) => value,
std::result::Result::Err(_) => String::new(),
};
}
#[test]
fn frontend_avoids_native_dialogs_and_persistent_secret_storage() {
let root = app_root();
let mut typescript = std::vec::Vec::new();
collect_files(root.join("frontend/ts").as_path(), "ts", &mut typescript);
assert!(!typescript.is_empty());
for path in typescript {
let source = read_text(path.as_path());
for forbidden in ["window.alert(", "window.confirm(", "window.prompt("] {
assert!(!source.contains(forbidden), "{} uses forbidden browser-native dialog {forbidden}", path.display());
}
}
let secret_reveal = read_text(root.join("frontend/ts/secret_reveal.ts").as_path());
assert!(!secret_reveal.contains("localStorage"));
assert!(!secret_reveal.contains("sessionStorage"));
assert!(secret_reveal.contains("clearTransientSecretReveal"));
}
#[test]
fn tauri_commands_and_logging_adapters_remain_on_owned_boundaries() {
let root = app_root();
let manifest = read_text(root.join("Cargo.toml").as_path());
assert!(manifest.contains("ksp-logging-lib"));
assert!(manifest.contains("tauri-plugin-tracing"));
assert!(!manifest.contains("tauri-plugin-log"));
let source_root = root.join("src");
let mut rust_files = std::vec::Vec::new();
collect_files(source_root.as_path(), "rs", &mut rust_files);
let mut command_attribute_count = 0usize;
for path in rust_files {
let source = read_text(path.as_path());
let count = source.matches("#[tauri::command]").count();
if path.file_name().and_then(std::ffi::OsStr::to_str) == std::option::Option::Some("tauri.rs") {
command_attribute_count += count;
} else {
assert_eq!(count, 0, "{} declares a Tauri command outside tauri.rs", path.display());
}
}
assert!(command_attribute_count > 0);
}

View File

@@ -0,0 +1,14 @@
// file: crates/ksp-app-config-desk/tests/public_api.rs
// version: 1
//! Public API checks for the KSP configuration desktop application library.
#![forbid(unsafe_code)]
#![deny(unreachable_pub)]
#![warn(missing_docs)]
#[test]
fn run_entry_point_is_available_from_crate_root() {
let run_entry_point: fn(&[std::ffi::OsString]) -> ksp_core_lib::Result<()> = ksp_app_config_desk_lib::run;
assert_eq!(std::mem::size_of_val(&run_entry_point), std::mem::size_of::<fn(&[std::ffi::OsString]) -> ksp_core_lib::Result<()>>());
}

View File

@@ -0,0 +1,31 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": [
"ES2022",
"DOM",
"DOM.Iterable"
],
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"allowSyntheticDefaultImports": true,
"types": [
"vite/client",
"node"
]
},
"include": [
"frontend",
"vite.config.ts"
]
}

View File

@@ -0,0 +1,117 @@
// file: crates/ksp-app-config-desk/unit_tests/bootstrap.rs
// version: 2
#[test]
fn fallback_logging_is_console_only_and_bounded_to_info() {
let settings = super::fallback_logging_settings();
assert_eq!(settings.default_filter(), ksp_logging_lib::LogFilterLevel::Info);
assert_eq!(settings.span_events(), ksp_logging_lib::SpanEvents::Off);
assert!(settings.files().is_empty());
let console = settings.console();
assert!(console.is_some());
if let std::option::Option::Some(console) = console {
assert!(console.enabled());
assert_eq!(console.output(), ksp_logging_lib::ConsoleOutput::Stderr);
assert_eq!(console.format(), ksp_logging_lib::LogFormat::Human);
}
}
#[test]
fn invalid_logging_source_is_planned_as_transient_fallback_before_subscriber_installation() {
let fixture = BootstrapFixture::new("invalid-source");
assert!(fixture.is_ok());
let fixture = match fixture {
std::result::Result::Ok(value) => value,
std::result::Result::Err(_) => return,
};
let malformed = std::fs::write(fixture.config_root.join(ksp_config_lib::DEFAULT_STD_LOGGING_FILENAME), "{ invalid json");
assert!(malformed.is_ok());
let management = fixture.management();
assert!(management.is_ok());
let management = match management {
std::result::Result::Ok(value) => value,
std::result::Result::Err(_) => return,
};
let plan = super::resolve_logging_startup(&management);
let fallback = matches!(&plan, super::LoggingStartupPlan::Fallback { .. });
assert!(fallback, "invalid Logging source must produce a fallback startup plan");
if let super::LoggingStartupPlan::Fallback { diagnostic, settings, .. } = plan {
assert_eq!(settings.default_filter(), ksp_logging_lib::LogFilterLevel::Info);
assert!(settings.files().is_empty());
assert!(!diagnostic.domain.is_empty());
assert!(!diagnostic.code.is_empty());
assert!(!diagnostic.message.is_empty());
}
}
struct BootstrapFixture {
root: std::path::PathBuf,
config_root: std::path::PathBuf,
schema_root: std::path::PathBuf,
}
impl BootstrapFixture {
fn new(label: &str) -> ksp_core_lib::Result<Self> {
let root = std::env::temp_dir().join(std::format!("ksp-config-desk-{label}-{}", std::process::id()));
let cleanup = std::fs::remove_dir_all(root.as_path());
match cleanup {
std::result::Result::Ok(()) | std::result::Result::Err(_) => {},
}
let config_root = root.join("config");
let schema_root = root.join("schemas");
let create_config = std::fs::create_dir_all(config_root.as_path());
if let std::result::Result::Err(error) = create_config {
return std::result::Result::Err(
ksp_core_lib::Error::new(crate::ERROR_CODE_APP_STATE_INVALID, "Cannot create bootstrap test config root").with_source(error),
);
}
let create_schema = std::fs::create_dir_all(schema_root.as_path());
if let std::result::Result::Err(error) = create_schema {
return std::result::Result::Err(
ksp_core_lib::Error::new(crate::ERROR_CODE_APP_STATE_INVALID, "Cannot create bootstrap test schema root").with_source(error),
);
}
let workspace = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"));
let workspace = match workspace.parent().and_then(std::path::Path::parent) {
std::option::Option::Some(value) => value,
std::option::Option::None => {
return std::result::Result::Err(ksp_core_lib::Error::new(
crate::ERROR_CODE_APP_STATE_INVALID,
"Cannot resolve workspace root for bootstrap test",
));
},
};
let source_schema = workspace.join(ksp_config_lib::DEFAULT_SCHEMA_PATH).join(ksp_config_lib::DEFAULT_STD_LOGGING_SCHEMA_FILENAME);
let destination_schema = schema_root.join(ksp_config_lib::DEFAULT_STD_LOGGING_SCHEMA_FILENAME);
let copied = std::fs::copy(source_schema.as_path(), destination_schema.as_path());
if let std::result::Result::Err(error) = copied {
return std::result::Result::Err(
ksp_core_lib::Error::new(crate::ERROR_CODE_APP_STATE_INVALID, "Cannot copy bootstrap test Logging schema").with_source(error),
);
}
return std::result::Result::Ok(Self { root, config_root, schema_root });
}
fn management(&self) -> ksp_core_lib::Result<ksp_config_lib::ConfigManagement> {
let bootstrap = ksp_config_lib::ConfigBootstrapOptions::from_paths(self.config_root.as_path(), self.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 = ksp_config_lib::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(ksp_config_lib::ConfigManagement::new(ksp_config_lib::ConfigDocumentEngine::new(bootstrap, registry)));
}
}
impl std::ops::Drop for BootstrapFixture {
fn drop(&mut self) {
let cleanup = std::fs::remove_dir_all(self.root.as_path());
match cleanup {
std::result::Result::Ok(()) | std::result::Result::Err(_) => {},
}
}
}

View File

@@ -0,0 +1,32 @@
// file: crates/ksp-app-config-desk/unit_tests/documents.rs
// version: 1
use super::*;
#[test]
fn diagnostic_classifier_distinguishes_json_schema_semantic_and_effective_errors() {
let json = ksp_core_lib::Error::new(ksp_config_lib::ERROR_CODE_JSON_SYNTAX_INVALID, "json");
assert_eq!(classify_error(&json), STAGE_JSON);
let schema = ksp_core_lib::Error::new(ksp_config_lib::ERROR_CODE_SCHEMA_VALIDATION_FAILED, "schema");
assert_eq!(classify_error(&schema), STAGE_SCHEMA);
let semantic = ksp_core_lib::Error::new(ksp_config_lib::ERROR_CODE_DOCUMENT_SEMANTIC_INVALID, "semantic");
assert_eq!(classify_error(&semantic), STAGE_SEMANTIC);
let effective = ksp_core_lib::Error::new(ksp_config_lib::ERROR_CODE_EFFECTIVE_CONFIG_INVALID, "effective");
assert_eq!(classify_error(&effective), STAGE_EFFECTIVE);
}
#[test]
fn schema_source_read_errors_are_classified_as_schema_failures() {
let error = ksp_core_lib::Error::new(ksp_config_lib::ERROR_CODE_JSON_FILE_READ_FAILED, "missing schema").with_context("file_id", "schema.std.logging");
assert_eq!(classify_error(&error), STAGE_SCHEMA);
}
#[test]
fn document_error_projection_does_not_expose_arbitrary_error_context() {
let error = ksp_core_lib::Error::new(ksp_config_lib::ERROR_CODE_JSON_SYNTAX_INVALID, "invalid source").with_context("detail", "sensitive-canary");
let projection = ConfigDocumentErrorDto::from_error(&error);
assert_eq!(projection.diagnostic_stage, STAGE_JSON);
assert_eq!(projection.error.domain, "config");
assert_eq!(projection.error.code, "json_syntax_invalid");
assert_eq!(projection.error.message, "invalid source");
}

View File

@@ -0,0 +1,11 @@
// file: crates/ksp-app-config-desk/unit_tests/dto_common.rs
// version: 1
#[test]
fn command_error_projection_excludes_context_values() {
let error = ksp_core_lib::Error::new(ksp_core_lib::ErrorCode::new("test", "failure"), "safe message").with_context("secret_canary", "must-not-be-exported");
let dto = super::CommandErrorDto::from_error(&error);
assert_eq!(dto.domain, "test");
assert_eq!(dto.code, "failure");
assert_eq!(dto.message, "safe message");
}

View File

@@ -0,0 +1,91 @@
// file: crates/ksp-app-config-desk/unit_tests/environment.rs
// version: 2
#[test]
fn namespace_projection_distinguishes_ksp_and_kspb_sensitivity_families() {
assert_eq!(super::namespace_label("KSP_LOGS_DIRECTORY"), "KSP");
assert_eq!(super::namespace_label("KSP_PUBLIC_RPC_URL"), "KSP_PUBLIC");
assert_eq!(super::namespace_label("KSP_SECRET_API_KEY"), "KSP_SECRET");
assert_eq!(super::namespace_label("KSPB_WORKER_MODE"), "KSPB");
assert_eq!(super::namespace_label("KSPB_PUBLIC_ENDPOINT"), "KSPB_PUBLIC");
assert_eq!(super::namespace_label("KSPB_SECRET_TOKEN"), "KSPB_SECRET");
}
#[test]
fn environment_labels_cover_config_sources_and_sensitivities() {
assert_eq!(super::environment_source_label(std::option::Option::Some(ksp_config_lib::ConfigEnvironmentSource::Process)), "process");
assert_eq!(super::environment_source_label(std::option::Option::Some(ksp_config_lib::ConfigEnvironmentSource::DotEnv)), "dotenv");
assert_eq!(super::environment_source_label(std::option::Option::Some(ksp_config_lib::ConfigEnvironmentSource::Fallback)), "fallback");
assert_eq!(super::environment_source_label(std::option::Option::None), "none");
assert_eq!(super::sensitivity_label(ksp_config_lib::ConfigSensitivity::Public), "public");
assert_eq!(super::sensitivity_label(ksp_config_lib::ConfigSensitivity::Internal), "internal");
assert_eq!(super::sensitivity_label(ksp_config_lib::ConfigSensitivity::Secret), "secret");
}
#[test]
fn mutation_sensitivity_is_owned_by_config_namespace_rules() {
let public = super::mutation_sensitivity("KSP_PUBLIC_TEST_VALUE");
assert!(public.is_ok());
if let std::result::Result::Ok(value) = public {
assert_eq!(value, ksp_config_lib::ConfigSensitivity::Public);
}
let secret = super::mutation_sensitivity("KSP_SECRET_TEST_VALUE");
assert!(secret.is_ok());
if let std::result::Result::Ok(value) = secret {
assert_eq!(value, ksp_config_lib::ConfigSensitivity::Secret);
}
let internal = super::mutation_sensitivity("KSPB_TEST_VALUE");
assert!(internal.is_ok());
if let std::result::Result::Ok(value) = internal {
assert_eq!(value, ksp_config_lib::ConfigSensitivity::Internal);
}
assert!(super::mutation_sensitivity("EXTERNAL_TEST_VALUE").is_err());
}
#[test]
fn mutation_operation_labels_are_stable() {
assert_eq!(super::EnvironmentMutationOperation::Set.label(), "set");
assert_eq!(super::EnvironmentMutationOperation::Remove.label(), "remove");
}
#[test]
fn committed_environment_report_is_safe_and_deterministic_when_present() {
let management = committed_management();
assert!(management.is_ok(), "committed management should construct: {management:?}");
if let std::result::Result::Ok(management) = management {
let report = super::report_from_management(&management);
assert!(report.is_ok(), "environment report should project safely: {report:?}");
if let std::result::Result::Ok(report) = report {
let mut previous = std::option::Option::<&str>::None;
for entry in &report {
if let std::option::Option::Some(previous) = previous {
assert!(previous <= entry.variable_name.as_str());
}
if entry.sensitivity == "secret" {
if let std::option::Option::Some(value) = entry.desired_safe_value.as_deref() {
assert_eq!(value, ksp_config_lib::REDACTED_CONFIG_VALUE);
}
if let std::option::Option::Some(value) = entry.effective_safe_value.as_deref() {
assert_eq!(value, ksp_config_lib::REDACTED_CONFIG_VALUE);
}
}
previous = std::option::Option::Some(entry.variable_name.as_str());
}
}
}
}
fn committed_management() -> ksp_core_lib::Result<ksp_config_lib::ConfigManagement> {
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 bootstrap = match bootstrap {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let registry = ksp_config_lib::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(ksp_config_lib::ConfigManagement::new(ksp_config_lib::ConfigDocumentEngine::new(bootstrap, registry)));
}

View File

@@ -0,0 +1,40 @@
// file: crates/ksp-app-config-desk/unit_tests/frontend_logging.rs
// version: 1
#[test]
fn frontend_log_levels_are_explicit_and_case_insensitive() {
assert!(matches!(super::parse_level(" TRACE "), std::result::Result::Ok(super::FrontendLogLevel::Trace)));
assert!(matches!(super::parse_level("debug"), std::result::Result::Ok(super::FrontendLogLevel::Debug)));
assert!(matches!(super::parse_level("Info"), std::result::Result::Ok(super::FrontendLogLevel::Info)));
assert!(matches!(super::parse_level("warn"), std::result::Result::Ok(super::FrontendLogLevel::Warn)));
assert!(matches!(super::parse_level("ERROR"), std::result::Result::Ok(super::FrontendLogLevel::Error)));
}
#[test]
fn unsupported_frontend_log_level_is_rejected() {
let result = super::parse_level("notice");
assert!(result.is_err());
let error = match result {
std::result::Result::Ok(_) => return,
std::result::Result::Err(error) => error,
};
assert_eq!(error.code(), crate::ERROR_CODE_FRONTEND_LOG_LEVEL_INVALID);
}
#[test]
fn frontend_log_targets_are_whitelisted() {
assert!(matches!(super::parse_target("frontend"), std::result::Result::Ok(super::FrontendLogTarget::Frontend)));
assert!(matches!(super::parse_target(" MAIN "), std::result::Result::Ok(super::FrontendLogTarget::Main)));
assert!(matches!(super::parse_target("Splash"), std::result::Result::Ok(super::FrontendLogTarget::Splash)));
}
#[test]
fn arbitrary_frontend_log_target_is_rejected() {
let result = super::parse_target("ksp-app-config-desk.test.logging");
assert!(result.is_err());
let error = match result {
std::result::Result::Ok(_) => return,
std::result::Result::Err(error) => error,
};
assert_eq!(error.code(), crate::ERROR_CODE_FRONTEND_LOG_TARGET_INVALID);
}

View File

@@ -0,0 +1,225 @@
// file: crates/ksp-app-config-desk/unit_tests/logging_editor.rs
// version: 4
#[test]
fn committed_logging_document_maps_complete_read_only_editor_contract() {
let management = committed_management();
assert!(management.is_ok(), "committed management should construct: {management:?}");
if let std::result::Result::Ok(management) = management {
let source = management.load_logging_document();
assert!(source.is_ok(), "typed Logging source should load: {source:?}");
let document = super::document_from_management(&management);
assert!(document.is_ok(), "Logging editor document should load: {document:?}");
if let (std::result::Result::Ok(source), std::result::Result::Ok(document)) = (source, document) {
assert_eq!(document.file_id, ksp_config_lib::FILE_ID_STD_LOGGING);
assert_eq!(document.format_version, source.format_version());
assert_eq!(document.logs_directory, source.logs_directory());
assert_eq!(document.default_profile, source.default_profile());
assert_eq!(document.profiles.len(), source.profiles().len());
for (profile, source_profile) in document.profiles.iter().zip(source.profiles()) {
assert_eq!(profile.profile_id, source_profile.profile_id());
assert_eq!(profile.default_filter, source_profile.default_filter());
assert_eq!(profile.span_events, source_profile.span_events());
assert_eq!(profile.console.enabled, source_profile.console().enabled());
assert_eq!(profile.console.output, source_profile.console().output());
assert_eq!(profile.console.format, source_profile.console().format());
assert_eq!(profile.files.len(), source_profile.files().len());
assert_eq!(profile.target_filters.len(), source_profile.target_filters().len());
}
}
}
}
#[test]
fn logging_editor_mapping_preserves_multiple_targets_and_domains() {
let filter = ksp_config_lib::LoggingOutputFilterConfig::new(
"trace",
std::vec!["ksp-config-lib".to_owned(), "ksp-app-config-desk".to_owned()],
std::vec!["config".to_owned(), "frontend".to_owned()],
);
let mapped = super::project_output_filter(&filter);
assert_eq!(mapped.level, "trace");
assert_eq!(mapped.targets, std::vec!["ksp-config-lib".to_owned(), "ksp-app-config-desk".to_owned()]);
assert_eq!(mapped.domains, std::vec!["config".to_owned(), "frontend".to_owned()]);
}
fn committed_management() -> ksp_core_lib::Result<ksp_config_lib::ConfigManagement> {
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 bootstrap = match bootstrap {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let registry = ksp_config_lib::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(ksp_config_lib::ConfigManagement::new(ksp_config_lib::ConfigDocumentEngine::new(bootstrap, registry)));
}
#[test]
fn editor_candidate_round_trips_through_typed_config_contract() {
let management = committed_management();
assert!(management.is_ok(), "committed management should construct: {management:?}");
if let std::result::Result::Ok(management) = management {
let document = super::document_from_management(&management);
assert!(document.is_ok(), "Logging editor document should load: {document:?}");
if let std::result::Result::Ok(document) = document {
let candidate = super::LoggingDocumentCandidateDto {
logs_directory: document.logs_directory.clone(),
default_profile: document.default_profile.clone(),
profiles: document.profiles.clone(),
};
let config = super::config_candidate(candidate);
assert_eq!(config.logs_directory(), document.logs_directory);
assert_eq!(config.default_profile(), document.default_profile);
assert_eq!(config.profiles().len(), document.profiles.len());
for (config_profile, document_profile) in config.profiles().iter().zip(document.profiles.iter()) {
assert_eq!(config_profile.profile_id(), document_profile.profile_id);
assert_eq!(config_profile.files().len(), document_profile.files.len());
for (config_file, document_file) in config_profile.files().iter().zip(document_profile.files.iter()) {
assert_eq!(config_file.output_id(), document_file.output_id);
assert_eq!(config_file.ansi(), document_file.ansi);
}
}
}
}
}
#[test]
fn profile_candidate_preserves_multi_file_and_target_filter_shape() {
let profile = super::LoggingProfileDto {
profile_id: "clone_test".to_owned(),
default_filter: "info".to_owned(),
span_events: "full".to_owned(),
console: super::LoggingConsoleDto {
enabled: true,
output: "stdout".to_owned(),
ansi: false,
format: "human".to_owned(),
filter: super::LoggingOutputFilterDto { level: "info".to_owned(), targets: std::vec!["*".to_owned()], domains: std::vec!["*".to_owned()] },
},
files: std::vec![
super::LoggingFileDto {
output_id: "file.one".to_owned(),
enabled: true,
path: "one.log".to_owned(),
rotation: "daily".to_owned(),
format: "human".to_owned(),
ansi: false,
filter: super::LoggingOutputFilterDto { level: "debug".to_owned(), targets: std::vec!["*".to_owned()], domains: std::vec!["*".to_owned()] },
},
super::LoggingFileDto {
output_id: "file.two".to_owned(),
enabled: false,
path: "two.jsonl".to_owned(),
rotation: "hourly".to_owned(),
format: "json".to_owned(),
ansi: false,
filter: super::LoggingOutputFilterDto {
level: "error".to_owned(),
targets: std::vec!["ksp-config-lib".to_owned()],
domains: std::vec!["config".to_owned()],
},
},
],
target_filters: std::vec![super::LoggingTargetFilterDto { target_prefix: "ksp-app-config-desk".to_owned(), level: "trace".to_owned() }],
};
let config = super::config_profile(profile);
assert_eq!(config.profile_id(), "clone_test");
assert_eq!(config.files().len(), 2);
assert_eq!(config.files()[1].rotation(), "hourly");
assert_eq!(config.target_filters().len(), 1);
assert_eq!(config.target_filters()[0].level(), "trace");
}
#[test]
fn runtime_failure_rollback_restores_previous_logging_source() {
let fixture = rollback_fixture();
let fixture = match fixture {
std::result::Result::Ok(value) => value,
std::result::Result::Err(_) => return,
};
let (root, management, file_id, previous) = fixture;
let mut candidate = management.load_logging_document();
assert!(candidate.is_ok(), "rollback fixture should load: {candidate:?}");
if let std::result::Result::Ok(candidate) = candidate.as_mut() {
candidate.set_logs_directory(format!("temporary-runtime-failure-{}", std::process::id()));
}
if let std::result::Result::Ok(candidate) = candidate {
let saved = management.save_logging_document(&candidate);
assert!(saved.is_ok(), "rollback fixture mutation should persist: {saved:?}");
if let std::result::Result::Ok(saved) = saved {
let runtime_error = ksp_core_lib::Error::new(ksp_logging_lib::ERROR_CODE_RELOAD_FAILED, "synthetic runtime reload failure");
let rolled_back = super::rollback_after_runtime_failure(&management, &file_id, previous.as_str(), saved.source_changed(), runtime_error);
assert!(rolled_back.is_err(), "runtime failure should remain visible after successful source rollback");
if let std::result::Result::Err(error) = rolled_back {
assert_eq!(error.code(), ksp_logging_lib::ERROR_CODE_RELOAD_FAILED);
}
let restored = std::fs::read_to_string(root.join("config").join(ksp_config_lib::DEFAULT_STD_LOGGING_FILENAME)).ok();
assert_eq!(restored, std::option::Option::Some(previous));
}
}
let cleanup = std::fs::remove_dir_all(root.as_path());
assert!(cleanup.is_ok(), "rollback fixture should cleanup: {cleanup:?}");
}
fn rollback_fixture() -> ksp_core_lib::Result<(std::path::PathBuf, ksp_config_lib::ConfigManagement, ksp_config_lib::ConfigFileId, String)> {
let root = std::env::temp_dir().join(format!("ksp-config-desk-logging-rollback-{}", 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_APP_STATE_INVALID, "cannot cleanup previous rollback 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_APP_STATE_INVALID, "cannot create rollback fixture").with_source(error));
}
let workspace = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..");
let source_config = workspace.join("config").join(ksp_config_lib::DEFAULT_STD_LOGGING_FILENAME);
let source_schema = workspace.join("config/schemas").join(ksp_config_lib::DEFAULT_STD_LOGGING_SCHEMA_FILENAME);
let config_path = config_root.join(ksp_config_lib::DEFAULT_STD_LOGGING_FILENAME);
let schema_path = schema_root.join(ksp_config_lib::DEFAULT_STD_LOGGING_SCHEMA_FILENAME);
let copied = std::fs::copy(source_config.as_path(), config_path.as_path());
if let std::result::Result::Err(error) = copied {
return std::result::Result::Err(
ksp_core_lib::Error::new(crate::ERROR_CODE_APP_STATE_INVALID, "cannot copy rollback Config fixture").with_source(error),
);
}
let copied = std::fs::copy(source_schema.as_path(), schema_path.as_path());
if let std::result::Result::Err(error) = copied {
return std::result::Result::Err(
ksp_core_lib::Error::new(crate::ERROR_CODE_APP_STATE_INVALID, "cannot copy rollback schema fixture").with_source(error),
);
}
let previous = std::fs::read_to_string(config_path.as_path());
let previous = match previous {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => {
return std::result::Result::Err(ksp_core_lib::Error::new(crate::ERROR_CODE_APP_STATE_INVALID, "cannot read rollback fixture").with_source(error));
},
};
let bootstrap = ksp_config_lib::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 = ksp_config_lib::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 = ksp_config_lib::ConfigFileId::new(ksp_config_lib::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 management = ksp_config_lib::ConfigManagement::new(ksp_config_lib::ConfigDocumentEngine::new(bootstrap, registry));
return std::result::Result::Ok((root, management, file_id, previous));
}

View File

@@ -0,0 +1,26 @@
// file: crates/ksp-app-config-desk/unit_tests/logging_runtime.rs
// version: 1
#[test]
fn launch_identity_is_safe_and_contains_application_and_process_identity() {
let identity = super::launch_identity();
assert!(identity.is_ok());
if let std::result::Result::Ok(identity) = identity {
assert_eq!(identity.application_id(), crate::TRACING_TARGET);
assert!(identity.launch_timestamp().contains("Z-p"));
assert!(identity.launch_timestamp().ends_with(std::process::id().to_string().as_str()));
}
}
#[test]
fn runtime_rotation_labels_are_stable() {
assert_eq!(super::rotation_label(ksp_logging_lib::FileRotation::Never), "never");
assert_eq!(super::rotation_label(ksp_logging_lib::FileRotation::Hourly), "hourly");
assert_eq!(super::rotation_label(ksp_logging_lib::FileRotation::Daily), "daily");
}
#[test]
fn runtime_count_projection_is_non_lossy_for_normal_values() {
assert_eq!(super::count_to_u64(0), 0);
assert_eq!(super::count_to_u64(42), 42);
}

View File

@@ -0,0 +1,51 @@
// file: crates/ksp-app-config-desk/unit_tests/logging_test.rs
// version: 1
use super::{LoggingTestLevel, LoggingTestTarget};
#[test]
fn controlled_logging_test_levels_include_all_five_levels_and_batch_mode() {
assert!(matches!(super::parse_level("trace"), std::result::Result::Ok(LoggingTestLevel::Trace)));
assert!(matches!(super::parse_level("debug"), std::result::Result::Ok(LoggingTestLevel::Debug)));
assert!(matches!(super::parse_level("info"), std::result::Result::Ok(LoggingTestLevel::Info)));
assert!(matches!(super::parse_level("warn"), std::result::Result::Ok(LoggingTestLevel::Warn)));
assert!(matches!(super::parse_level("error"), std::result::Result::Ok(LoggingTestLevel::Error)));
assert!(matches!(super::parse_level("all"), std::result::Result::Ok(LoggingTestLevel::All)));
assert!(super::parse_level("fatal").is_err());
}
#[test]
fn controlled_logging_test_targets_are_static_ksp_targets() {
assert!(matches!(super::parse_target("app"), std::result::Result::Ok(LoggingTestTarget::App)));
assert!(matches!(super::parse_target("logging_test"), std::result::Result::Ok(LoggingTestTarget::Dedicated)));
assert_eq!(super::target_value(LoggingTestTarget::App), crate::TRACING_TARGET);
assert_eq!(super::target_value(LoggingTestTarget::Dedicated), crate::TRACING_TARGET_LOGGING_TEST);
assert!(super::parse_target("external").is_err());
}
#[test]
fn logging_test_domain_modes_cover_absent_known_and_bounded_custom_values() {
let absent = super::resolve_domain("absent", "ignored");
assert!(matches!(absent, std::result::Result::Ok(std::option::Option::None)));
let known = super::resolve_domain("known", crate::TRACING_DOMAIN_LOGGING_TEST);
assert!(matches!(
known.as_ref(),
std::result::Result::Ok(std::option::Option::Some(value)) if value == crate::TRACING_DOMAIN_LOGGING_TEST
));
let custom = super::resolve_domain("custom", "custom.route_1-test");
assert!(matches!(
custom.as_ref(),
std::result::Result::Ok(std::option::Option::Some(value)) if value == "custom.route_1-test"
));
assert!(super::resolve_domain("custom", "custom route").is_err());
assert!(super::resolve_domain("custom", "").is_err());
assert!(super::resolve_domain("known", "unknown.domain").is_err());
}
#[test]
fn logging_test_message_is_required_and_bounded() {
let message = super::validate_message(" test ");
assert!(matches!(message, std::result::Result::Ok("test")));
assert!(super::validate_message(" ").is_err());
assert!(super::validate_message("x".repeat(501).as_str()).is_err());
}

View File

@@ -0,0 +1,87 @@
// file: crates/ksp-app-config-desk/unit_tests/profiles.rs
// version: 3
#[test]
fn profile_inventory_exposes_registered_standard_profile_documents() {
let management = fixture_management();
assert!(management.is_ok(), "fixture management should construct: {management:?}");
if let std::result::Result::Ok(management) = management {
let inventory = super::inventory_from_management(&management);
assert!(inventory.is_ok(), "profile inventory should resolve: {inventory:?}");
if let std::result::Result::Ok(inventory) = inventory {
assert!(inventory.iter().any(|document| -> bool {
return document.file_id == ksp_config_lib::FILE_ID_STD_LOGGING;
}));
assert!(inventory.iter().any(|document| -> bool {
return document.file_id == ksp_config_lib::FILE_ID_STD_TRANSPORT;
}));
for document in inventory {
assert!(!document.default_profile.is_empty());
assert!(document.profile_ids.iter().any(|profile_id| -> bool {
return profile_id == &document.default_profile;
}));
}
}
}
}
#[test]
fn default_profile_detail_uses_safe_effective_value_and_dotenv_provenance() {
let management = fixture_management();
assert!(management.is_ok(), "fixture management should construct: {management:?}");
if let std::result::Result::Ok(management) = management {
let source = management.load_logging_document();
assert!(source.is_ok(), "typed Logging source should load: {source:?}");
let detail = super::detail_from_management(&management, ksp_config_lib::FILE_ID_STD_LOGGING, std::option::Option::None);
assert!(detail.is_ok(), "default profile should inspect safely: {detail:?}");
if let (std::result::Result::Ok(source), std::result::Result::Ok(detail)) = (source, detail) {
assert_eq!(detail.selected_profile, source.default_profile());
assert_eq!(detail.selection_source, "default_profile");
assert!(detail.value_origins.iter().any(|entry| {
return entry.key == "logs_directory" && entry.origin == "global";
}));
assert!(detail.value_origins.iter().any(|entry| {
return entry.key == "default_filter" && entry.origin == "profile";
}));
assert!(detail.environment_provenance.iter().any(|entry| {
return entry.json_pointer == "/logs_directory" && entry.variable_name == "KSP_LOGS_DIRECTORY" && entry.sensitivity == "internal";
}));
assert!(!detail.effective_safe_json.contains("KSP_SECRET_"));
}
}
}
#[test]
fn explicit_profile_inspection_reports_explicit_selection_source() {
let management = fixture_management();
assert!(management.is_ok(), "fixture management should construct: {management:?}");
if let std::result::Result::Ok(management) = management {
let source = management.load_logging_document();
assert!(source.is_ok(), "typed Logging source should load: {source:?}");
if let std::result::Result::Ok(source) = source {
let profile_id = source.default_profile().to_owned();
let detail = super::detail_from_management(&management, ksp_config_lib::FILE_ID_STD_LOGGING, std::option::Option::Some(profile_id.as_str()));
assert!(detail.is_ok(), "explicit profile should inspect: {detail:?}");
if let std::result::Result::Ok(detail) = detail {
assert_eq!(detail.selected_profile, profile_id);
assert_eq!(detail.selection_source, "explicit");
}
}
}
}
fn fixture_management() -> ksp_core_lib::Result<ksp_config_lib::ConfigManagement> {
let workspace = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..");
let fixture_root = workspace.join("crates/ksp-config-lib/unit_tests/fixtures");
let bootstrap = ksp_config_lib::ConfigBootstrapOptions::from_paths(fixture_root, 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 = ksp_config_lib::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(ksp_config_lib::ConfigManagement::new(ksp_config_lib::ConfigDocumentEngine::new(bootstrap, registry)));
}

View File

@@ -0,0 +1,32 @@
// file: crates/ksp-app-config-desk/unit_tests/secrets.rs
// version: 1
#[test]
fn reveal_policy_accepts_only_secret_names_and_supported_sources() {
let effective = super::validate_request("KSP_SECRET_TEST_VALUE", "effective");
assert!(effective.is_ok());
if let std::result::Result::Ok(source) = effective {
assert_eq!(source.label(), "effective");
}
let dotenv = super::validate_request("KSPB_SECRET_TEST_VALUE", "dotenv");
assert!(dotenv.is_ok());
if let std::result::Result::Ok(source) = dotenv {
assert_eq!(source.label(), "dotenv");
}
assert!(super::validate_request("KSP_PUBLIC_TEST_VALUE", "effective").is_err());
assert!(super::validate_request("KSP_TEST_VALUE", "effective").is_err());
assert!(super::validate_request("EXTERNAL_SECRET_VALUE", "effective").is_err());
assert!(super::validate_request("KSP_SECRET_TEST_VALUE", "unknown").is_err());
}
#[test]
fn reveal_response_type_does_not_require_debug_or_clone_contracts() {
let response = super::SecretRevealResponseDto {
variable_name: "KSP_SECRET_TEST_VALUE".to_owned(),
source: "dotenv".to_owned(),
value: std::option::Option::Some("secret-canary".to_owned()),
};
assert_eq!(response.variable_name, "KSP_SECRET_TEST_VALUE");
assert_eq!(response.source, "dotenv");
assert_eq!(response.value.as_deref(), std::option::Option::Some("secret-canary"));
}

View File

@@ -0,0 +1,37 @@
// file: crates/ksp-app-config-desk/unit_tests/splash.rs
// version: 3
#[test]
fn fallback_splash_settings_are_short_and_ordered() {
let settings = crate::SplashSettings::fallback();
assert_eq!(settings.minimum_ms(), 1200);
assert_eq!(settings.fade_ms(), 300);
assert_eq!(settings.minimum_source(), "fallback");
assert_eq!(settings.fade_source(), "fallback");
assert_eq!(settings.expected_backend_lifecycle_ms(), 1500);
assert!(settings.minimum_ms() >= u64::from(settings.fade_ms()));
}
#[test]
fn splash_order_contract_keeps_action_message_and_duration() {
let order = crate::SplashOrderDto::new("fade_out", std::option::Option::Some("done"), std::option::Option::Some(300));
assert_eq!(order.action, "fade_out");
assert_eq!(order.message.as_deref(), std::option::Option::Some("done"));
assert_eq!(order.duration_ms, std::option::Option::Some(300));
}
#[test]
fn splash_duration_parser_rejects_invalid_or_unbounded_values() {
let malformed = super::parse_u64_setting("KSP_DESK_SPLASH_MINIMUM_MS", "invalid", 60_000);
let oversized_minimum = super::parse_u64_setting("KSP_DESK_SPLASH_MINIMUM_MS", "60001", 60_000);
let oversized_fade = super::parse_u32_setting("KSP_DESK_SPLASH_FADE_MS", "10001", 10_000);
assert!(malformed.is_err());
assert!(oversized_minimum.is_err());
assert!(oversized_fade.is_err());
if let std::result::Result::Err(error) = oversized_minimum {
assert!(error.context().iter().any(|field| return field.key() == "maximum_ms" && field.value() == "60000"));
}
if let std::result::Result::Err(error) = oversized_fade {
assert!(error.context().iter().any(|field| return field.key() == "maximum_ms" && field.value() == "10000"));
}
}

View File

@@ -0,0 +1,72 @@
// file: crates/ksp-app-config-desk/vite.config.ts
// version: 2
import { NodePackageImporter } from "sass-embedded";
import { fileURLToPath } from "node:url";
import { resolve } from "node:path";
import { defineConfig, normalizePath } from "vite";
const appRoot = fileURLToPath(new URL(".", import.meta.url));
const frontendRoot = normalizePath(resolve(appRoot, "frontend"));
const frontendDist = normalizePath(resolve(appRoot, "../../../builds/khadhroony-solana-project/ksp-app-config-desk/dist"));
const devHost = process.env.TAURI_DEV_HOST;
export default defineConfig({
clearScreen: false,
root: frontendRoot,
publicDir: false,
input: {
main: normalizePath(resolve(frontendRoot, "main.html")),
splash: normalizePath(resolve(frontendRoot, "splash.html")),
},
build: {
outDir: frontendDist,
emptyOutDir: true,
minify: true,
sourcemap: false,
cssCodeSplit: true,
rolldownOptions: {
output: {
entryFileNames: "js/[name]-[hash].js",
chunkFileNames: "js/chunks/[name]-[hash].js",
assetFileNames: assetInfo => {
const originalName = assetInfo.names[0] ?? "";
const extension = originalName.substring(originalName.lastIndexOf(".") + 1).toLowerCase();
if (extension === "css") {
return "css/[name]-[hash][extname]";
}
if (["eot", "otf", "ttf", "woff", "woff2"].includes(extension)) {
return "fonts/[name]-[hash][extname]";
}
if (["png", "jpg", "jpeg", "gif", "svg", "webp", "ico"].includes(extension)) {
return "imgs/[name][extname]";
}
return "otherassets/[name][extname]";
},
},
},
},
css: {
preprocessorOptions: {
scss: {
quietDeps: true,
silenceDeprecations: ["import", "color-functions", "global-builtin"],
verbose: false,
importers: [new NodePackageImporter()],
},
},
},
server: {
port: 1430,
strictPort: true,
host: devHost || false,
ws: {
protocol: "ws",
host: devHost || "localhost",
port: 1431,
},
watch: {
ignored: ["**/src/**"],
},
},
});

View File

@@ -1,5 +1,5 @@
# file: crates/ksp-config-lib/Cargo.toml # file: crates/ksp-config-lib/Cargo.toml
# version: 3 # version: 6
[package] [package]
name = "ksp-config-lib" name = "ksp-config-lib"
@@ -10,9 +10,13 @@ repository.workspace = true
[dependencies] [dependencies]
ksp-core-lib = { path = "../ksp-core-lib" } ksp-core-lib = { path = "../ksp-core-lib" }
ksp-logging-lib = { path = "../ksp-logging-lib" } ksp-logging-lib = { path = "../ksp-logging-lib" }
serde.workspace = true ksp-onchain-transport-lib = { path = "../ksp-onchain-transport-lib" }
serde = { workspace = true, features = ["derive"] }
serde_json.workspace = true serde_json.workspace = true
jsonschema.workspace = true jsonschema.workspace = true
[dev-dependencies]
tokio = { workspace = true, features = ["macros", "rt"] }
[lints] [lints]
workspace = true workspace = true

View File

@@ -1,5 +1,5 @@
<!-- file: crates/ksp-config-lib/README.md --> <!-- file: crates/ksp-config-lib/README.md -->
<!-- version: 1 --> <!-- version: 5 -->
# ksp-config-lib # ksp-config-lib
@@ -12,7 +12,7 @@ La crate centralise les documents JSON, leurs schemas, les profils et compositio
`ksp-config-lib` possède : `ksp-config-lib` possède :
- le bootstrap non récursif `config/` / `config/schemas/` et les overrides `--cfgpath` / `--schemapath` ; - 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>` ; - le registre logique `file_id -> filename`, son inventaire public read-only ordonné par `file_id` et les overrides `--filemap=<file_id>=<filename>` ;
- la lecture JSON et la validation JSON Schema Draft 2020-12 ; - la lecture JSON et la validation JSON Schema Draft 2020-12 ;
- les invariants sémantiques KSP des documents connus ; - les invariants sémantiques KSP des documents connus ;
- les globals, `default_profile`, profils nommés et leur provenance ; - les globals, `default_profile`, profils nommés et leur provenance ;
@@ -23,20 +23,27 @@ La crate centralise les documents JSON, leurs schemas, les profils et compositio
- la classification `Public`, `Internal`, `Secret` ; - la classification `Public`, `Internal`, `Secret` ;
- les représentations réelle et sûre/redacted ainsi que la provenance des valeurs résolues ; - 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` ; - 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` ; - l'adapter du document HTTP Transport effectif vers `ksp_onchain_transport_lib::HttpTransportSettings`, y compris redaction/provenance des URLs `KSP_SECRET_*` ;
- la surface de management pour inspecter et réparer les sources Config enregistrées, modifier `std.logging.json`, consulter les rapports d'environnement, révéler explicitement une valeur réelle et modifier `.env` ;
- les écritures atomiques JSON/`.env` et la protection des permissions `.env` ; - les é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`. - les audits workspace empêchant les bypass d'ownership Config et les oublis dans `.env.example`.
## Ressources gérées dans `0.1.3` ## Ressources gérées
Le registre par défaut connaît : Le registre par défaut connaît :
```text ```text
cfg.std.logging -> config/std.logging.json cfg.std.logging -> config/std.logging.json
schema.std.logging -> config/schemas/std.logging.schema.json cfg.std.transport -> config/std.transport.json
schema.composite -> config/schemas/composite.schema.json schema.std.logging -> config/schemas/std.logging.schema.json
schema.std.transport -> config/schemas/std.transport.schema.json
schema.composite -> config/schemas/composite.schema.json
``` ```
`ConfigFileRegistry::descriptors()` expose ces descripteurs en lecture seule et dans un ordre déterministe par `file_id`. Une application de management peut ainsi découvrir les fichiers connus sans maintenir une liste parallèle ni dépendre de leurs filenames physiques.
`ConfigManagement::read_source()` permet d'inspecter le texte brut d'un document Config enregistré même lorsque ce document est invalide. `save_source_candidate()` complète cette frontière : le candidat brut est parsé, validé contre son schema et les invariants sémantiques KSP, puis persisté atomiquement uniquement après validation complète. Le `file_id` doit appartenir au registre et désigner un document Config ; aucun path arbitraire n'est accepté.
`config/examples/composite.example.json` démontre le format composite sans créer de composite runtime fictif. `config/examples/composite.example.json` démontre le format composite sans créer de composite runtime fictif.
Le fichier local d'environnement est : Le fichier local d'environnement est :
@@ -55,15 +62,15 @@ Les autres crates et applications KSP ne doivent pas :
- parser ou écrire directement `.env` ; - parser ou écrire directement `.env` ;
- ouvrir directement les documents Config connus par leur filename physique ; - ouvrir directement les documents Config connus par leur filename physique ;
- réimplémenter la sélection de profils, les compositions ou les placeholders ; - réimplémenter la sélection de profils, les compositions ou les placeholders ;
- reconstruire elles-mêmes la configuration Logging depuis le JSON. - reconstruire elles-mêmes la configuration Logging ou Transport 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`. `ksp-config-lib` dépend de `ksp-core-lib` pour `Error`/`Result`, de `ksp-logging-lib` pour les événements Config utiles et le contrat `LoggingSettings`, et de `ksp-onchain-transport-lib` pour construire le contrat runtime Transport dans la direction Config -> Transport.
La dépendance inverse est interdite : `ksp-core-lib` et `ksp-logging-lib` ne dépendent pas de Config. La dépendance inverse est interdite : `ksp-core-lib`, `ksp-logging-lib` et `ksp-onchain-transport-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`. 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. Tauri et les DTO TS-RS restent hors de cette crate. `ksp-app-config-desk` reste une frontière applicative mince au-dessus des APIs Config et découvre les documents standards via le registre Config sans déplacer leur logique métier dans l'application.
## Secrets ## Secrets
@@ -71,12 +78,13 @@ Un secret reste accessible au runtime ou au management lorsqu'un consumer autori
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. 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. Le document Logging refuse les valeurs de sensibilité `Secret` dans sa configuration effective. Le document Transport les accepte pour les URLs endpoint : la valeur réelle est transmise au runtime légitime, tandis que la projection sûre et les `Debug` restent redacted.
## Documentation ## Documentation
- [`USAGE.md`](USAGE.md) — construction du moteur, résolution runtime et management ; - [`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` ; - [`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 ; - [`../../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 ; - [`../../config/std.logging.json`](../../config/std.logging.json) — document standard Logging ;
- [`../../config/std.transport.json`](../../config/std.transport.json) — document standard HTTP Transport ;
- [`../../.env.example`](../../.env.example) — inventaire versionné des variables d'environnement runtime. - [`../../.env.example`](../../.env.example) — inventaire versionné des variables d'environnement runtime.

View File

@@ -1,5 +1,5 @@
<!-- file: crates/ksp-config-lib/TODO.md --> <!-- file: crates/ksp-config-lib/TODO.md -->
<!-- version: 1 --> <!-- version: 4 -->
# TODO ksp-config-lib # TODO ksp-config-lib
@@ -9,7 +9,13 @@ 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. 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` ## Extensions bornées révélées par `0.1.4`
`0.1.4-pre.002` ajoute l'inventaire public read-only de `ConfigFileRegistry`, nécessaire au shell Documents de `ksp-app-config-desk`. Le registre reste propriétaire des descripteurs ; l'application n'entretient pas de liste parallèle de `file_id`.
`0.1.4-pre.003` ferme la seconde lacune révélée pendant `pre.001` : `ConfigManagement::save_source_candidate()` permet de soumettre le texte corrigé d'un `file_id` Config connu, de le parser et de le valider entièrement, puis de le persister atomiquement sans accès filesystem direct de l'application. Les deux extensions Config préalables au shell desktop sont donc traitées.
## Validation desktop de `0.1.4`
La validation applicative desktop appartient à `ksp-app-config-desk` : La validation applicative desktop appartient à `ksp-app-config-desk` :
@@ -25,11 +31,15 @@ La validation applicative desktop appartient à `ksp-app-config-desk` :
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. 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.
## Extension `0.2.1` — Transport HTTP
`0.2.1-pre.006` introduit le premier nouveau domaine standard depuis Logging : `std.transport.json`, son schema, son exemple, son enregistrement et l'adapter Config -> `HttpTransportSettings`. Cette extension confirme que les nouveaux domaines restent ajoutés à la demande d'un consumer réel, sans transformer Config en propriétaire du runtime Transport.
## Futur, uniquement au besoin ## Futur, uniquement au besoin
Les capacités suivantes sont différées jusqu'à l'apparition de composants réels : Les capacités suivantes sont différées jusqu'à l'apparition de composants réels :
- nouveaux documents `std.<domain>.json` et schemas associés ; - documents `std.<domain>.json` supplémentaires et schemas associés ;
- descriptors `cfg.composite.<consumer>` pour de vrais consumers ; - descriptors `cfg.composite.<consumer>` pour de vrais consumers ;
- contrats typés de management supplémentaires pour les nouveaux documents ; - 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 ; - watcher filesystem/reload automatique si une application ou un service démontre le besoin ;

View File

@@ -1,5 +1,5 @@
<!-- file: crates/ksp-config-lib/USAGE.md --> <!-- file: crates/ksp-config-lib/USAGE.md -->
<!-- version: 2 --> <!-- version: 5 -->
# Utilisation de ksp-config-lib # Utilisation de ksp-config-lib
@@ -29,10 +29,27 @@ Les arguments compris par Config sont :
--cfgpath=/path/to/config --cfgpath=/path/to/config
--schemapath=/path/to/schemas --schemapath=/path/to/schemas
--filemap=cfg.std.logging=my-logging.json --filemap=cfg.std.logging=my-logging.json
--filemap=cfg.std.transport=my-transport.json
``` ```
`cfgpath` et `schemapath` ne sont jamais lus depuis JSON, `.env` ou une variable KSP : cette règle évite un bootstrap récursif. `cfgpath` et `schemapath` ne sont jamais lus depuis JSON, `.env` ou une variable KSP : cette règle évite un bootstrap récursif.
### 1.1 Inventorier les fichiers enregistrés
Le registre expose une vue read-only déterministe des descripteurs connus :
```rust
for descriptor in registry.descriptors() {
let file_id = descriptor.file_id().as_str();
let kind = descriptor.kind();
let filename = descriptor.filename();
let schema_file_id = descriptor.schema_file_id();
let _ = (file_id, kind, filename, schema_file_id);
}
```
L'ordre est celui des `file_id`. La vue reflète les éventuels overrides `--filemap` déjà appliqués tout en conservant le kind et l'association de schema. Elle permet notamment à une application de management de construire sa liste de documents/schemas sans dupliquer le registre dans sa propre couche.
## 2. Charger et valider un document connu ## 2. Charger et valider un document connu
Les consumers utilisent un `file_id` logique : Les consumers utilisent un `file_id` logique :
@@ -106,6 +123,23 @@ Un `logs_directory` relatif est ancré sur le current working directory du proce
Les `files[].path` restent relatifs sous le root Logging, y compris après interpolation. Les `files[].path` restent relatifs sous le root Logging, y compris après interpolation.
### 4.1 Construire le Transport HTTP depuis Config
Config possède également l'adapter du document `std.transport` vers le contrat runtime de `ksp-onchain-transport-lib` :
```rust
let transport = match engine.load_resolved_transport_config(std::option::Option::None, &environment) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let transport_settings = transport.into_settings();
```
Les scalaires `*_ms` restent des valeurs Config et sont convertis en `std::time::Duration` par l'adapter. Les URLs peuvent provenir de `KSP_PUBLIC_*` ou de `KSP_SECRET_*`; dans ce dernier cas la valeur réelle reste disponible au runtime Transport, mais `ResolvedTransportConfig::effective().safe_value()` et les représentations `Debug` sont redacted.
La dépendance reste unidirectionnelle : Config connaît le contrat Transport pour le construire ; Transport ne connaît ni Config, ni `.env`, ni les variables KSP.
## 5. Profils et composites ## 5. Profils et composites
Pour un document standard profilé : Pour un document standard profilé :
@@ -197,6 +231,7 @@ Ce guide reste volontairement indépendant des numéros de release. Les contrats
| Environnement | `ConfigEnvironment`, `ConfigEnvironmentSource`, `ConfigEnvironmentValue`, `DEFAULT_DOTENV_PATH`, `DEFAULT_DOTENV_EXAMPLE_PATH` | §3, §78 | | Environnement | `ConfigEnvironment`, `ConfigEnvironmentSource`, `ConfigEnvironmentValue`, `DEFAULT_DOTENV_PATH`, `DEFAULT_DOTENV_EXAMPLE_PATH` | §3, §78 |
| Sensibilité/provenance | `ConfigSensitivity`, `ConfigValueProvenance`, `ResolvedConfigText`, `ResolvedConfigJson`, `REDACTED_CONFIG_VALUE` | §3 | | Sensibilité/provenance | `ConfigSensitivity`, `ConfigValueProvenance`, `ResolvedConfigText`, `ResolvedConfigJson`, `REDACTED_CONFIG_VALUE` | §3 |
| Logging effectif | `ResolvedLoggingConfig` | §4 | | Logging effectif | `ResolvedLoggingConfig` | §4 |
| Transport effectif | `ResolvedTransportConfig` | §4.1 |
| Management | `ConfigManagement`, `ConfigManagedSource`, `ConfigDocumentChangeReport`, `ConfigEnvironmentReport`, `ConfigEnvironmentChangeReport` | §67 | | Management | `ConfigManagement`, `ConfigManagedSource`, `ConfigDocumentChangeReport`, `ConfigEnvironmentReport`, `ConfigEnvironmentChangeReport` | §67 |
| Source Logging typée | `LoggingConfigDocument`, `LoggingProfileConfig`, `LoggingConsoleConfig`, `LoggingFileConfig`, `LoggingOutputFilterConfig`, `LoggingTargetFilterConfig` | §6 et exemple ci-dessous | | 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 | | Erreurs Config | constantes `ERROR_CODE_*` réexportées par la crate | exemple ci-dessous |
@@ -232,7 +267,7 @@ if saved.source_changed() && saved.reload_required() {
La construction depuis zéro utilise les constructeurs publics `LoggingConfigDocument::new`, `LoggingProfileConfig::new`, `LoggingConsoleConfig::new`, `LoggingFileConfig::new`, `LoggingOutputFilterConfig::new` et `LoggingTargetFilterConfig::new`. Les mêmes contraintes schema/sémantiques sont appliquées au moment de `save_logging_document()`. La construction depuis zéro utilise les constructeurs publics `LoggingConfigDocument::new`, `LoggingProfileConfig::new`, `LoggingConsoleConfig::new`, `LoggingFileConfig::new`, `LoggingOutputFilterConfig::new` et `LoggingTargetFilterConfig::new`. Les mêmes contraintes schema/sémantiques sont appliquées au moment de `save_logging_document()`.
### 10.2 Inspecter un source enregistré sans contourner Config ### 10.2 Inspecter et réparer un source enregistré sans contourner Config
```rust ```rust
let source = match management.read_source(&file_id) { let source = match management.read_source(&file_id) {
@@ -245,7 +280,31 @@ let managed_path = source.path();
let raw_content = source.content(); let raw_content = source.content();
``` ```
Cette API est notamment destinée à une UI de réparation lorsque le document n'est plus validable. Elle n'autorise pas la lecture d'un chemin arbitraire. Cette lecture est notamment destinée à une UI de réparation lorsque le document n'est plus validable. Elle n'autorise pas la lecture d'un chemin arbitraire.
Après édition du texte brut, le candidat est soumis à Config :
```rust
let saved = match management.save_source_candidate(&file_id, edited_source.as_str()) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
if saved.source_changed() && saved.reload_required() {
// Reload the affected Config consumer through the application lifecycle.
}
```
`save_source_candidate()` :
- accepte uniquement un `file_id` enregistré de kind `Config` ;
- parse le candidat comme JSON ;
- valide le schema enregistré et les invariants sémantiques KSP ;
- ne remplace aucune donnée lorsque l'une de ces validations échoue ;
- persiste atomiquement le texte brut validé sans reformattage implicite ;
- retourne `ConfigDocumentChangeReport` pour distinguer un changement réel d'un candidat identique.
Le path reste résolu exclusivement par `ConfigFileRegistry`; l'appelant ne fournit jamais de path physique.
### 10.3 Exploiter les rapports `.env` ### 10.3 Exploiter les rapports `.env`
@@ -284,4 +343,3 @@ if let std::result::Result::Err(error) = loaded {
``` ```
Le message/context d'erreur reste destiné au diagnostic ; l'identité machine-readable passe par `ErrorCode`. Le message/context d'erreur reste destiné au diagnostic ; l'identité machine-readable passe par `ErrorCode`.

View File

@@ -0,0 +1,7 @@
// file: crates/ksp-config-lib/src/constants.rs
// version: 1
//! Config-owned tracing constants.
/// Owning tracing target for events emitted by the Config crate.
pub(crate) const TRACING_TARGET: &str = "ksp-config-lib";

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-config-lib/src/document.rs // file: crates/ksp-config-lib/src/document.rs
// version: 4 // version: 5
/// A Config-managed JSON document that has passed syntax, schema and current semantic validation. /// A Config-managed JSON document that has passed syntax, schema and current semantic validation.
#[derive(Clone, Debug, PartialEq)] #[derive(Clone, Debug, PartialEq)]
@@ -85,6 +85,20 @@ impl ConfigDocumentEngine {
return self.validate_document(document, &schema_file_id); return self.validate_document(document, &schema_file_id);
} }
pub(crate) fn validate_source_candidate(&self, file_id: &crate::ConfigFileId, source: &str) -> ksp_core_lib::Result<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 value = serde_json::from_str::<serde_json::Value>(source);
let value = match value {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(json_syntax_error(file_id, path.as_path(), error)),
};
return self.validate_candidate(file_id, value);
}
pub(crate) fn validate_candidate(&self, file_id: &crate::ConfigFileId, value: serde_json::Value) -> ksp_core_lib::Result<ConfigJsonDocument> { 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 = self.registry.descriptor(file_id);
let descriptor = match descriptor { let descriptor = match descriptor {

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-config-lib/src/environment.rs // file: crates/ksp-config-lib/src/environment.rs
// version: 4 // version: 5
/// Default local environment file read by Config from the process launch directory. /// Default local environment file read by Config from the process launch directory.
pub const DEFAULT_DOTENV_PATH: &str = ".env"; pub const DEFAULT_DOTENV_PATH: &str = ".env";
@@ -7,7 +7,6 @@ pub const DEFAULT_DOTENV_PATH: &str = ".env";
/// Versioned environment contract template expected at the repository/runtime root. /// Versioned environment contract template expected at the repository/runtime root.
pub const DEFAULT_DOTENV_EXAMPLE_PATH: &str = ".env.example"; pub const DEFAULT_DOTENV_EXAMPLE_PATH: &str = ".env.example";
const LOGGING_TARGET: &str = "ksp-config-lib";
const LOGGING_DOMAIN: &str = "config.environment"; const LOGGING_DOMAIN: &str = "config.environment";
/// Source that supplied one resolved Config environment variable. /// Source that supplied one resolved Config environment variable.
@@ -556,7 +555,7 @@ fn is_generic_dotenv_name(variable_name: &str) -> bool {
} }
fn emit_missing_variable_warning(variable_name: &str) { 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"); ksp_logging_lib::warn!(target: crate::TRACING_TARGET, domain = LOGGING_DOMAIN, variable_name = variable_name, "Config environment variable is missing");
} }
fn missing_variable_error(variable_name: &str) -> ksp_core_lib::Error { fn missing_variable_error(variable_name: &str) -> ksp_core_lib::Error {

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-config-lib/src/lib.rs // file: crates/ksp-config-lib/src/lib.rs
// version: 10 // version: 12
#![warn(missing_docs)] #![warn(missing_docs)]
#![deny(unreachable_pub)] #![deny(unreachable_pub)]
#![forbid(unsafe_code)] #![forbid(unsafe_code)]
@@ -8,11 +8,12 @@
//! //!
//! The `0.1.3` surface owns bootstrap roots, the logical file registry, JSON/JSON Schema validation, standard-document profiles, generic composites and //! 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 //! 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 //! provenance. Standard Logging and HTTP Transport documents map explicitly to their runtime settings contracts, while the management surface provides
//! mutation, safe environment reports, explicit privileged reveal calls and atomic JSON/`.env` persistence. //! typed Logging mutation, safe environment reports, explicit privileged reveal calls and atomic JSON/`.env` persistence.
mod bootstrap; mod bootstrap;
mod composite; mod composite;
mod constants;
mod document; mod document;
mod environment; mod environment;
mod error; mod error;
@@ -22,6 +23,9 @@ mod persistence;
mod profile; mod profile;
mod registry; mod registry;
mod sensitivity; mod sensitivity;
mod transport;
pub(crate) use self::constants::TRACING_TARGET;
/// Bootstrap argument used to replace the configuration document root. /// Bootstrap argument used to replace the configuration document root.
pub use self::bootstrap::ARG_CFG_PATH; pub use self::bootstrap::ARG_CFG_PATH;
@@ -141,12 +145,20 @@ pub use self::registry::DEFAULT_COMPOSITE_SCHEMA_FILENAME;
pub use self::registry::DEFAULT_STD_LOGGING_FILENAME; pub use self::registry::DEFAULT_STD_LOGGING_FILENAME;
/// Default physical filename for the standard Logging JSON Schema document. /// Default physical filename for the standard Logging JSON Schema document.
pub use self::registry::DEFAULT_STD_LOGGING_SCHEMA_FILENAME; pub use self::registry::DEFAULT_STD_LOGGING_SCHEMA_FILENAME;
/// Default physical filename for the standard HTTP Transport configuration document.
pub use self::registry::DEFAULT_STD_TRANSPORT_FILENAME;
/// Default physical filename for the standard HTTP Transport JSON Schema document.
pub use self::registry::DEFAULT_STD_TRANSPORT_SCHEMA_FILENAME;
/// Logical file identifier for the generic composite JSON Schema document. /// Logical file identifier for the generic composite JSON Schema document.
pub use self::registry::FILE_ID_SCHEMA_COMPOSITE; pub use self::registry::FILE_ID_SCHEMA_COMPOSITE;
/// Logical file identifier for the standard Logging JSON Schema document. /// Logical file identifier for the standard Logging JSON Schema document.
pub use self::registry::FILE_ID_SCHEMA_STD_LOGGING; pub use self::registry::FILE_ID_SCHEMA_STD_LOGGING;
/// Logical file identifier for the standard HTTP Transport JSON Schema document.
pub use self::registry::FILE_ID_SCHEMA_STD_TRANSPORT;
/// Logical file identifier for the standard Logging configuration document. /// Logical file identifier for the standard Logging configuration document.
pub use self::registry::FILE_ID_STD_LOGGING; pub use self::registry::FILE_ID_STD_LOGGING;
/// Logical file identifier for the standard HTTP Transport configuration document.
pub use self::registry::FILE_ID_STD_TRANSPORT;
/// Sensitivity assigned to one Config value after environment resolution. /// Sensitivity assigned to one Config value after environment resolution.
pub use self::sensitivity::ConfigSensitivity; pub use self::sensitivity::ConfigSensitivity;
/// Provenance segment participating in one resolved Config value. /// Provenance segment participating in one resolved Config value.
@@ -157,3 +169,5 @@ pub use self::sensitivity::REDACTED_CONFIG_VALUE;
pub use self::sensitivity::ResolvedConfigJson; pub use self::sensitivity::ResolvedConfigJson;
/// One resolved Config string preserving real/safe representations and provenance. /// One resolved Config string preserving real/safe representations and provenance.
pub use self::sensitivity::ResolvedConfigText; pub use self::sensitivity::ResolvedConfigText;
/// Effective standard HTTP Transport configuration mapped to `ksp_onchain_transport_lib::HttpTransportSettings`.
pub use self::transport::ResolvedTransportConfig;

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-config-lib/src/management.rs // file: crates/ksp-config-lib/src/management.rs
// version: 1 // version: 4
/// Raw source of one registered Config document read for explicit management/correction. /// Raw source of one registered Config document read for explicit management/correction.
#[derive(Clone, Eq, PartialEq)] #[derive(Clone, Eq, PartialEq)]
@@ -476,6 +476,17 @@ impl LoggingFileConfig {
self.format = value.into(); self.format = value.into();
} }
/// Returns whether ANSI output is requested.
#[must_use]
pub const fn ansi(&self) -> bool {
return self.ansi;
}
/// Sets whether ANSI output is requested. Persistence validation currently requires persistent file ANSI to remain disabled.
pub fn set_ansi(&mut self, value: bool) {
self.ansi = value;
}
/// Returns the output filter. /// Returns the output filter.
#[must_use] #[must_use]
pub const fn filter(&self) -> &LoggingOutputFilterConfig { pub const fn filter(&self) -> &LoggingOutputFilterConfig {
@@ -625,6 +636,29 @@ impl ConfigManagement {
}; };
} }
/// Validates and atomically persists a raw source candidate for one registered Config document.
///
/// The candidate is parsed, schema-validated and checked against KSP semantic invariants before any destination bytes are replaced. The raw source text is
/// preserved exactly when persistence succeeds, allowing a management editor to repair an invalid document without bypassing Config ownership.
pub fn save_source_candidate(&self, file_id: &crate::ConfigFileId, source: &str) -> ksp_core_lib::Result<ConfigDocumentChangeReport> {
let descriptor = self.engine.registry().descriptor(file_id);
let descriptor = match descriptor {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
if descriptor.kind() != crate::ConfigFileKind::Config {
return std::result::Result::Err(
management_error("management source persistence requires a Config document").with_context("file_id", file_id.as_str()),
);
}
let validated = self.engine.validate_source_candidate(file_id, source);
let validated = match validated {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
return persist_document_source(file_id, validated.path(), source.as_bytes());
}
/// Loads the validated `std.logging.json` source into its typed management contract. /// Loads the validated `std.logging.json` source into its typed management contract.
pub fn load_logging_document(&self) -> ksp_core_lib::Result<LoggingConfigDocument> { pub fn load_logging_document(&self) -> ksp_core_lib::Result<LoggingConfigDocument> {
let file_id = logging_file_id(); let file_id = logging_file_id();
@@ -675,27 +709,7 @@ impl ConfigManagement {
}, },
}; };
serialized.push('\n'); serialized.push('\n');
let existing = std::fs::read(validated.path()); return persist_document_source(&file_id, validated.path(), serialized.as_bytes());
let existing = match existing {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) if error.kind() == std::io::ErrorKind::NotFound => std::vec::Vec::new(),
std::result::Result::Err(error) => {
return std::result::Result::Err(
ksp_core_lib::Error::new(crate::ERROR_CODE_JSON_FILE_READ_FAILED, "existing Config document cannot be read before persistence")
.with_context("file_id", file_id.as_str())
.with_context("path", validated.path().to_string_lossy().into_owned())
.with_source(error),
);
},
};
if existing.as_slice() == serialized.as_bytes() {
return std::result::Result::Ok(ConfigDocumentChangeReport { source_changed: false, reload_required: false });
}
let write = crate::persistence::atomic_write(validated.path(), serialized.as_bytes());
if let std::result::Result::Err(error) = write {
return std::result::Result::Err(error);
}
return std::result::Result::Ok(ConfigDocumentChangeReport { source_changed: true, reload_required: true });
} }
/// Returns safe desired/effective/shadow reports for every KSP/KSPB variable present in process or `.env` sources. /// Returns safe desired/effective/shadow reports for every KSP/KSPB variable present in process or `.env` sources.
@@ -999,6 +1013,30 @@ fn encode_dotenv_value(value: &str) -> String {
return encoded; return encoded;
} }
fn persist_document_source(file_id: &crate::ConfigFileId, path: &std::path::Path, source: &[u8]) -> ksp_core_lib::Result<ConfigDocumentChangeReport> {
let existing = std::fs::read(path);
let existing = match existing {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) if error.kind() == std::io::ErrorKind::NotFound => std::vec::Vec::new(),
std::result::Result::Err(error) => {
return std::result::Result::Err(
ksp_core_lib::Error::new(crate::ERROR_CODE_JSON_FILE_READ_FAILED, "existing Config document cannot be read before persistence")
.with_context("file_id", file_id.as_str())
.with_context("path", path.to_string_lossy().into_owned())
.with_source(error),
);
},
};
if existing.as_slice() == source {
return std::result::Result::Ok(ConfigDocumentChangeReport { source_changed: false, reload_required: false });
}
let write = crate::persistence::atomic_write(path, source);
if let std::result::Result::Err(error) = write {
return std::result::Result::Err(error);
}
return std::result::Result::Ok(ConfigDocumentChangeReport { source_changed: true, reload_required: true });
}
fn management_error(reason: &'static str) -> ksp_core_lib::Error { fn management_error(reason: &'static str) -> ksp_core_lib::Error {
return ksp_core_lib::Error::new(crate::ERROR_CODE_MANAGEMENT_OPERATION_INVALID, "Config management operation is invalid").with_context("reason", reason); return ksp_core_lib::Error::new(crate::ERROR_CODE_MANAGEMENT_OPERATION_INVALID, "Config management operation is invalid").with_context("reason", reason);
} }

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-config-lib/src/persistence.rs // file: crates/ksp-config-lib/src/persistence.rs
// version: 2 // version: 3
static NEXT_TEMPORARY_FILE_ID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); static NEXT_TEMPORARY_FILE_ID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
@@ -99,7 +99,7 @@ fn cleanup_temporary_file(path: &std::path::Path) {
&& error.kind() != std::io::ErrorKind::NotFound && error.kind() != std::io::ErrorKind::NotFound
{ {
ksp_logging_lib::warn!( ksp_logging_lib::warn!(
target: "ksp-config-lib", target: crate::TRACING_TARGET,
domain = "config.persistence", domain = "config.persistence",
path = %path.to_string_lossy(), path = %path.to_string_lossy(),
error = %error, error = %error,

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-config-lib/src/registry.rs // file: crates/ksp-config-lib/src/registry.rs
// version: 3 // version: 5
/// Bootstrap argument used to replace a known Config filename mapping. /// Bootstrap argument used to replace a known Config filename mapping.
pub const ARG_FILE_MAP: &str = "--filemap"; pub const ARG_FILE_MAP: &str = "--filemap";
@@ -7,12 +7,20 @@ pub const ARG_FILE_MAP: &str = "--filemap";
pub const FILE_ID_STD_LOGGING: &str = "cfg.std.logging"; pub const FILE_ID_STD_LOGGING: &str = "cfg.std.logging";
/// Logical file identifier for the standard Logging JSON Schema document. /// Logical file identifier for the standard Logging JSON Schema document.
pub const FILE_ID_SCHEMA_STD_LOGGING: &str = "schema.std.logging"; pub const FILE_ID_SCHEMA_STD_LOGGING: &str = "schema.std.logging";
/// Logical file identifier for the standard HTTP Transport configuration document.
pub const FILE_ID_STD_TRANSPORT: &str = "cfg.std.transport";
/// Logical file identifier for the standard HTTP Transport JSON Schema document.
pub const FILE_ID_SCHEMA_STD_TRANSPORT: &str = "schema.std.transport";
/// Logical file identifier for the generic composite JSON Schema document. /// Logical file identifier for the generic composite JSON Schema document.
pub const FILE_ID_SCHEMA_COMPOSITE: &str = "schema.composite"; pub const FILE_ID_SCHEMA_COMPOSITE: &str = "schema.composite";
/// Default physical filename for the standard Logging configuration document. /// Default physical filename for the standard Logging configuration document.
pub const DEFAULT_STD_LOGGING_FILENAME: &str = "std.logging.json"; pub const DEFAULT_STD_LOGGING_FILENAME: &str = "std.logging.json";
/// Default physical filename for the standard Logging JSON Schema document. /// Default physical filename for the standard Logging JSON Schema document.
pub const DEFAULT_STD_LOGGING_SCHEMA_FILENAME: &str = "std.logging.schema.json"; pub const DEFAULT_STD_LOGGING_SCHEMA_FILENAME: &str = "std.logging.schema.json";
/// Default physical filename for the standard HTTP Transport configuration document.
pub const DEFAULT_STD_TRANSPORT_FILENAME: &str = "std.transport.json";
/// Default physical filename for the standard HTTP Transport JSON Schema document.
pub const DEFAULT_STD_TRANSPORT_SCHEMA_FILENAME: &str = "std.transport.schema.json";
/// Default physical filename for the generic composite JSON Schema document. /// Default physical filename for the generic composite JSON Schema document.
pub const DEFAULT_COMPOSITE_SCHEMA_FILENAME: &str = "composite.schema.json"; pub const DEFAULT_COMPOSITE_SCHEMA_FILENAME: &str = "composite.schema.json";
@@ -135,13 +143,29 @@ impl ConfigFileRegistry {
std::result::Result::Ok(value) => value, std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error), std::result::Result::Err(error) => return std::result::Result::Err(error),
}; };
let transport = ConfigFileDescriptor::new(
FILE_ID_STD_TRANSPORT,
ConfigFileKind::Config,
DEFAULT_STD_TRANSPORT_FILENAME,
std::option::Option::Some(FILE_ID_SCHEMA_STD_TRANSPORT),
);
let transport = match transport {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let transport_schema =
ConfigFileDescriptor::new(FILE_ID_SCHEMA_STD_TRANSPORT, ConfigFileKind::Schema, DEFAULT_STD_TRANSPORT_SCHEMA_FILENAME, std::option::Option::None);
let transport_schema = match transport_schema {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let composite_schema = let composite_schema =
ConfigFileDescriptor::new(FILE_ID_SCHEMA_COMPOSITE, ConfigFileKind::Schema, DEFAULT_COMPOSITE_SCHEMA_FILENAME, std::option::Option::None); ConfigFileDescriptor::new(FILE_ID_SCHEMA_COMPOSITE, ConfigFileKind::Schema, DEFAULT_COMPOSITE_SCHEMA_FILENAME, std::option::Option::None);
let composite_schema = match composite_schema { let composite_schema = match composite_schema {
std::result::Result::Ok(value) => value, std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error), std::result::Result::Err(error) => return std::result::Result::Err(error),
}; };
return build_registry([logging, logging_schema, composite_schema]); return build_registry([logging, logging_schema, transport, transport_schema, composite_schema]);
} }
/// Creates the default registry and applies repeatable `--filemap=<file_id>=<filename>` overrides from raw process arguments. /// Creates the default registry and applies repeatable `--filemap=<file_id>=<filename>` overrides from raw process arguments.
@@ -165,6 +189,13 @@ impl ConfigFileRegistry {
return std::result::Result::Ok(registry); return std::result::Result::Ok(registry);
} }
/// Iterates over all registered descriptors in deterministic logical `file_id` order.
///
/// The returned view is read-only and reflects filename overrides already applied to this registry.
pub fn descriptors(&self) -> impl std::iter::Iterator<Item = &ConfigFileDescriptor> + '_ {
return self.descriptors.values();
}
/// Returns the descriptor associated with a known logical file identifier. /// Returns the descriptor associated with a known logical file identifier.
pub fn descriptor(&self, file_id: &ConfigFileId) -> ksp_core_lib::Result<&ConfigFileDescriptor> { pub fn descriptor(&self, file_id: &ConfigFileId) -> ksp_core_lib::Result<&ConfigFileDescriptor> {
return match self.descriptors.get(file_id) { return match self.descriptors.get(file_id) {

View File

@@ -0,0 +1,316 @@
// file: crates/ksp-config-lib/src/transport.rs
// version: 1
/// Effective standard HTTP Transport configuration resolved from Config and mapped to the Transport runtime contract.
#[derive(Clone, Eq, PartialEq)]
pub struct ResolvedTransportConfig {
file_id: crate::ConfigFileId,
source_path: std::path::PathBuf,
profile_id: String,
selection_source: crate::ConfigProfileSelectionSource,
effective: crate::ResolvedConfigJson,
settings: ksp_onchain_transport_lib::HttpTransportSettings,
}
impl ResolvedTransportConfig {
/// 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 Transport profile identifier.
#[must_use]
pub fn profile_id(&self) -> &str {
return self.profile_id.as_str();
}
/// Returns the source that selected the standard Transport 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. The safe tree redacts values originating from `KSP_SECRET_*` or `KSPB_SECRET_*`
/// placeholders and is the only representation used by this type's [`std::fmt::Debug`] implementation.
#[must_use]
pub const fn effective(&self) -> &crate::ResolvedConfigJson {
return &self.effective;
}
/// Returns the validated runtime HTTP Transport settings.
#[must_use]
pub const fn settings(&self) -> &ksp_onchain_transport_lib::HttpTransportSettings {
return &self.settings;
}
/// Consumes this resolved Config and returns the mapped runtime HTTP Transport settings.
#[must_use]
pub fn into_settings(self) -> ksp_onchain_transport_lib::HttpTransportSettings {
return self.settings;
}
}
impl std::fmt::Debug for ResolvedTransportConfig {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
return formatter
.debug_struct("ResolvedTransportConfig")
.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 HTTP Transport document, selects a profile, resolves environment placeholders and maps it to Transport runtime settings.
///
/// `requested_profile = None` uses the document `default_profile`; `Some(profile_id)` requests an explicit profile. Secret endpoint URLs are allowed
/// because the Transport URL wrapper owns runtime redaction. Invalid environment-resolved values are reported as
/// [`crate::ERROR_CODE_EFFECTIVE_CONFIG_INVALID`] without copying endpoint URL values into ordinary error context.
pub fn load_resolved_transport_config(
&self,
requested_profile: std::option::Option<&str>,
environment: &crate::ConfigEnvironment,
) -> ksp_core_lib::Result<ResolvedTransportConfig> {
let file_id = crate::ConfigFileId::new(crate::FILE_ID_STD_TRANSPORT);
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_transport_profile(&profile, environment);
}
}
#[derive(serde::Deserialize)]
#[serde(deny_unknown_fields)]
struct EffectiveTransportSource {
format_version: u32,
profile_id: String,
retry: EffectiveRetrySource,
endpoints: std::vec::Vec<EffectiveEndpointSource>,
}
#[derive(serde::Deserialize)]
#[serde(deny_unknown_fields)]
struct EffectiveRetrySource {
max_retries: u32,
initial_backoff_ms: u64,
max_backoff_ms: u64,
}
#[derive(serde::Deserialize)]
#[serde(deny_unknown_fields)]
struct EffectiveEndpointSource {
name: String,
enabled: bool,
provider: String,
cluster: String,
url: String,
connect_timeout_ms: u64,
request_timeout_ms: u64,
max_idle_connections_per_host: std::option::Option<usize>,
roles: std::vec::Vec<EffectiveRoleSource>,
}
#[derive(serde::Deserialize)]
#[serde(deny_unknown_fields)]
struct EffectiveRoleSource {
role: String,
enabled: bool,
request_kinds: std::vec::Vec<String>,
priority: u32,
limits: EffectiveLimitsSource,
}
#[derive(serde::Deserialize)]
#[serde(deny_unknown_fields)]
struct EffectiveLimitsSource {
requests_per_second: std::option::Option<u32>,
burst_capacity: std::option::Option<u32>,
max_concurrent_requests: std::option::Option<u32>,
pause_after_rate_limit_ms: std::option::Option<u64>,
}
fn resolve_transport_profile(profile: &crate::ResolvedConfigProfile, environment: &crate::ConfigEnvironment) -> ksp_core_lib::Result<ResolvedTransportConfig> {
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 source = serde_json::from_value::<EffectiveTransportSource>(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 Transport 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 Transport format_version is unsupported"));
}
if source.profile_id != profile.profile_id() {
return std::result::Result::Err(effective_error(profile, "effective Transport profile_id does not match the selected profile"));
}
let retry = ksp_onchain_transport_lib::HttpRetrySettings::new(
source.retry.max_retries,
std::time::Duration::from_millis(source.retry.initial_backoff_ms),
std::time::Duration::from_millis(source.retry.max_backoff_ms),
);
let endpoints = map_endpoints(source.endpoints, profile);
let endpoints = match endpoints {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let settings = ksp_onchain_transport_lib::HttpTransportSettings::new(endpoints, retry);
let validation = settings.validate();
if let std::result::Result::Err(error) = validation {
return std::result::Result::Err(transport_contract_error(profile, "effective Transport settings fail the Transport runtime contract", &error));
}
ksp_logging_lib::debug!(
target: crate::TRACING_TARGET,
profile_id = profile.profile_id(),
endpoint_count = settings.endpoints().len(),
"mapped standard Transport Config to runtime settings"
);
return std::result::Result::Ok(ResolvedTransportConfig {
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,
settings,
});
}
fn map_endpoints(
sources: std::vec::Vec<EffectiveEndpointSource>,
profile: &crate::ResolvedConfigProfile,
) -> ksp_core_lib::Result<std::vec::Vec<ksp_onchain_transport_lib::HttpEndpointSettings>> {
let mut endpoints = std::vec::Vec::<ksp_onchain_transport_lib::HttpEndpointSettings>::with_capacity(sources.len());
for source in sources {
let endpoint_name = source.name.clone();
let url = ksp_onchain_transport_lib::HttpEndpointUrl::parse(source.url);
let url = match url {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => {
return std::result::Result::Err(
transport_contract_error(profile, "effective endpoint URL is invalid", &error).with_context("endpoint_name", endpoint_name),
);
},
};
let roles = map_roles(source.roles, profile, endpoint_name.as_str());
let roles = match roles {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
endpoints.push(ksp_onchain_transport_lib::HttpEndpointSettings::new(
source.name,
source.enabled,
ksp_onchain_transport_lib::HttpProviderName::new(source.provider),
ksp_onchain_transport_lib::HttpClusterName::new(source.cluster),
url,
std::time::Duration::from_millis(source.connect_timeout_ms),
std::time::Duration::from_millis(source.request_timeout_ms),
source.max_idle_connections_per_host,
roles,
));
}
return std::result::Result::Ok(endpoints);
}
fn map_roles(
sources: std::vec::Vec<EffectiveRoleSource>,
profile: &crate::ResolvedConfigProfile,
endpoint_name: &str,
) -> ksp_core_lib::Result<std::vec::Vec<ksp_onchain_transport_lib::HttpEndpointRoleSettings>> {
let mut roles = std::vec::Vec::<ksp_onchain_transport_lib::HttpEndpointRoleSettings>::with_capacity(sources.len());
for source in sources {
let requests_per_second = map_non_zero(source.limits.requests_per_second, profile, "limits.requests_per_second", endpoint_name);
let requests_per_second = match requests_per_second {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let burst_capacity = map_non_zero(source.limits.burst_capacity, profile, "limits.burst_capacity", endpoint_name);
let burst_capacity = match burst_capacity {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let max_concurrent_requests = map_non_zero(source.limits.max_concurrent_requests, profile, "limits.max_concurrent_requests", endpoint_name);
let max_concurrent_requests = match max_concurrent_requests {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let pause_after_rate_limit = match source.limits.pause_after_rate_limit_ms {
std::option::Option::Some(value) => std::option::Option::Some(std::time::Duration::from_millis(value)),
std::option::Option::None => std::option::Option::None,
};
let limits = ksp_onchain_transport_lib::HttpRoleLimits::new(requests_per_second, burst_capacity, max_concurrent_requests, pause_after_rate_limit);
let mut request_kinds = std::vec::Vec::<ksp_onchain_transport_lib::HttpRequestKind>::with_capacity(source.request_kinds.len());
for request_kind in source.request_kinds {
request_kinds.push(ksp_onchain_transport_lib::HttpRequestKind::new(request_kind));
}
roles.push(ksp_onchain_transport_lib::HttpEndpointRoleSettings::new(
ksp_onchain_transport_lib::HttpRoleName::new(source.role),
source.enabled,
request_kinds,
source.priority,
limits,
));
}
return std::result::Result::Ok(roles);
}
fn map_non_zero(
value: std::option::Option<u32>,
profile: &crate::ResolvedConfigProfile,
field: &'static str,
endpoint_name: &str,
) -> ksp_core_lib::Result<std::option::Option<std::num::NonZeroU32>> {
let value = match value {
std::option::Option::Some(value) => value,
std::option::Option::None => return std::result::Result::Ok(std::option::Option::None),
};
let non_zero = std::num::NonZeroU32::new(value);
return match non_zero {
std::option::Option::Some(value) => std::result::Result::Ok(std::option::Option::Some(value)),
std::option::Option::None => std::result::Result::Err(
effective_error(profile, "effective Transport role limit must be greater than zero")
.with_context("field", field)
.with_context("endpoint_name", endpoint_name),
),
};
}
fn transport_contract_error(profile: &crate::ResolvedConfigProfile, reason: &'static str, transport_error: &ksp_core_lib::Error) -> ksp_core_lib::Error {
return effective_error(profile, reason)
.with_context("transport_error_domain", transport_error.code().domain())
.with_context("transport_error_code", transport_error.code().code());
}
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/transport.rs"]
mod tests;

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-config-lib/tests/ownership.rs // file: crates/ksp-config-lib/tests/ownership.rs
// version: 1 // version: 3
//! Workspace ownership audits for KSP application configuration boundaries. //! Workspace ownership audits for KSP application configuration boundaries.
@@ -94,6 +94,7 @@ fn collect_environment_names(source: &str, names: &mut std::collections::BTreeSe
if let std::result::Result::Ok(candidate) = candidate if let std::result::Result::Ok(candidate) = candidate
&& candidate.len() > prefix_length && candidate.len() > prefix_length
&& !candidate.ends_with('_') && !candidate.ends_with('_')
&& !is_environment_namespace_label(candidate)
{ {
names.insert(candidate.to_owned()); names.insert(candidate.to_owned());
} }
@@ -101,6 +102,10 @@ fn collect_environment_names(source: &str, names: &mut std::collections::BTreeSe
} }
} }
fn is_environment_namespace_label(candidate: &str) -> bool {
return matches!(candidate, "KSP_PUBLIC" | "KSP_SECRET" | "KSPB_PUBLIC" | "KSPB_SECRET");
}
fn dotenv_example_assignments(source: &str) -> std::collections::BTreeMap<String, usize> { fn dotenv_example_assignments(source: &str) -> std::collections::BTreeMap<String, usize> {
let mut assignments = std::collections::BTreeMap::<String, usize>::new(); let mut assignments = std::collections::BTreeMap::<String, usize>::new();
for (line_index, line) in source.lines().enumerate() { for (line_index, line) in source.lines().enumerate() {
@@ -234,13 +239,44 @@ fn workspace_crates_do_not_hardcode_config_managed_physical_files() {
std::result::Result::Ok(value) => non_comment_source(value.as_str()), std::result::Result::Ok(value) => non_comment_source(value.as_str()),
std::result::Result::Err(_) => continue, std::result::Result::Err(_) => continue,
}; };
for token in ["\".env\"", "\"std.logging.json\"", "\"std.logging.schema.json\"", "\"composite.schema.json\""] { for token in [
"\".env\"",
"\"std.logging.json\"",
"\"std.logging.schema.json\"",
"\"std.transport.json\"",
"\"std.transport.schema.json\"",
"\"composite.schema.json\"",
] {
assert!(!source.contains(token), "{} hardcodes Config-managed physical resource {token}; use ksp-config-lib contracts", rust_file.display()); assert!(!source.contains(token), "{} hardcodes Config-managed physical resource {token}; use ksp-config-lib contracts", rust_file.display());
} }
} }
} }
} }
#[test]
fn environment_name_scanner_ignores_namespace_labels_but_keeps_concrete_names() {
let source = r#"
const KSP_PUBLIC_LABEL: &str = "KSP_PUBLIC";
const KSP_SECRET_LABEL: &str = "KSP_SECRET";
const KSPB_PUBLIC_LABEL: &str = "KSPB_PUBLIC";
const KSPB_SECRET_LABEL: &str = "KSPB_SECRET";
const KSP_PUBLIC_RPC_URL: &str = "KSP_PUBLIC_RPC_URL";
const KSP_SECRET_API_KEY: &str = "KSP_SECRET_API_KEY";
const KSPB_PUBLIC_ENDPOINT: &str = "KSPB_PUBLIC_ENDPOINT";
const KSPB_SECRET_TOKEN: &str = "KSPB_SECRET_TOKEN";
"#;
let mut names = std::collections::BTreeSet::<String>::new();
collect_environment_names(source, &mut names);
assert!(!names.contains("KSP_PUBLIC"));
assert!(!names.contains("KSP_SECRET"));
assert!(!names.contains("KSPB_PUBLIC"));
assert!(!names.contains("KSPB_SECRET"));
assert!(names.contains("KSP_PUBLIC_RPC_URL"));
assert!(names.contains("KSP_SECRET_API_KEY"));
assert!(names.contains("KSPB_PUBLIC_ENDPOINT"));
assert!(names.contains("KSPB_SECRET_TOKEN"));
}
#[test] #[test]
fn dotenv_example_covers_runtime_environment_names_with_comments() { fn dotenv_example_covers_runtime_environment_names_with_comments() {
let root = workspace_root(); let root = workspace_root();

View File

@@ -1,8 +1,8 @@
// file: crates/ksp-config-lib/tests/public_api.rs // file: crates/ksp-config-lib/tests/public_api.rs
// version: 10 // version: 17
//! Integration tests for the public `ksp-config-lib` bootstrap, registry, JSON/profile/composite, environment-resolution, sensitivity, Logging-adapter and //! Integration tests for the public `ksp-config-lib` bootstrap, registry, JSON/profile/composite, environment-resolution, sensitivity,
//! management contracts. //! Logging/Transport adapters and management contracts.
#[test] #[test]
fn bootstrap_contract_is_available_from_crate_root() { fn bootstrap_contract_is_available_from_crate_root() {
@@ -74,6 +74,28 @@ fn logical_file_registry_is_available_from_crate_root() {
} }
} }
#[test]
fn registry_descriptor_inventory_is_available_from_crate_root() {
let registry = ksp_config_lib::ConfigFileRegistry::defaults();
assert!(registry.is_ok(), "public registry should remain constructible: {registry:?}");
if let std::result::Result::Ok(registry) = registry {
let descriptors: std::vec::Vec<&ksp_config_lib::ConfigFileDescriptor> = registry.descriptors().collect();
assert_eq!(descriptors.len(), 5);
assert_eq!(descriptors[0].file_id().as_str(), ksp_config_lib::FILE_ID_STD_LOGGING);
assert_eq!(descriptors[0].kind(), ksp_config_lib::ConfigFileKind::Config);
assert_eq!(descriptors[1].file_id().as_str(), ksp_config_lib::FILE_ID_STD_TRANSPORT);
assert_eq!(descriptors[1].kind(), ksp_config_lib::ConfigFileKind::Config);
assert_eq!(descriptors[2].file_id().as_str(), ksp_config_lib::FILE_ID_SCHEMA_COMPOSITE);
assert_eq!(descriptors[3].file_id().as_str(), ksp_config_lib::FILE_ID_SCHEMA_STD_LOGGING);
assert_eq!(descriptors[4].file_id().as_str(), ksp_config_lib::FILE_ID_SCHEMA_STD_TRANSPORT);
let schema_file_id = descriptors[1].schema_file_id();
assert!(schema_file_id.is_some(), "public Transport descriptor should preserve schema association");
if let std::option::Option::Some(schema_file_id) = schema_file_id {
assert_eq!(schema_file_id.as_str(), ksp_config_lib::FILE_ID_SCHEMA_STD_TRANSPORT);
}
}
}
#[test] #[test]
fn validated_json_document_engine_is_available_from_crate_root() { fn validated_json_document_engine_is_available_from_crate_root() {
let workspace = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../.."); let workspace = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..");
@@ -105,10 +127,13 @@ fn resolved_profile_contract_is_available_from_crate_root() {
assert!(file_id.is_ok(), "public Logging file_id should remain constructible: {file_id:?}"); 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) { 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 engine = ksp_config_lib::ConfigDocumentEngine::new(bootstrap, registry);
let document = engine.load_validated_document(&file_id);
let resolved = engine.load_resolved_profile(&file_id, std::option::Option::None); let resolved = engine.load_resolved_profile(&file_id, std::option::Option::None);
assert!(document.is_ok(), "public profile resolver should read committed source: {document:?}");
assert!(resolved.is_ok(), "public profile resolver should resolve committed default profile: {resolved:?}"); assert!(resolved.is_ok(), "public profile resolver should resolve committed default profile: {resolved:?}");
if let std::result::Result::Ok(resolved) = resolved { if let (std::result::Result::Ok(document), std::result::Result::Ok(resolved)) = (document, resolved) {
assert_eq!(resolved.profile_id(), "local_dev"); let default_profile = document.value().get("default_profile").and_then(serde_json::Value::as_str);
assert_eq!(default_profile, std::option::Option::Some(resolved.profile_id()));
assert_eq!(resolved.selection_source(), ksp_config_lib::ConfigProfileSelectionSource::DefaultProfile); 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("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_eq!(resolved.origin("default_filter"), std::option::Option::Some(ksp_config_lib::ConfigValueOrigin::Profile));
@@ -193,17 +218,39 @@ fn management_contracts_are_available_from_crate_root() {
assert!(logging.is_ok(), "public typed Logging management contract should load committed source: {logging:?}"); assert!(logging.is_ok(), "public typed Logging management contract should load committed source: {logging:?}");
if let std::result::Result::Ok(mut logging) = logging { if let std::result::Result::Ok(mut logging) = logging {
assert_eq!(logging.format_version(), 1); assert_eq!(logging.format_version(), 1);
assert_eq!(logging.default_profile(), "local_dev"); let default_profile = logging.default_profile().to_owned();
assert!(!default_profile.is_empty());
assert!(!logging.profiles().is_empty());
assert!(logging.profiles().iter().any(|profile| return profile.profile_id() == default_profile.as_str()));
logging.set_logs_directory("public-api-management-test"); logging.set_logs_directory("public-api-management-test");
assert_eq!(logging.logs_directory(), "public-api-management-test"); assert_eq!(logging.logs_directory(), "public-api-management-test");
assert_eq!(logging.profiles().len(), 1); if let std::option::Option::Some(profile) = logging.profiles_mut().first_mut()
assert_eq!(logging.profiles()[0].profile_id(), "local_dev"); && let std::option::Option::Some(file) = profile.files_mut().first_mut()
{
file.set_ansi(false);
assert!(!file.ansi());
}
} }
} }
let save_source_candidate: fn(
&ksp_config_lib::ConfigManagement,
&ksp_config_lib::ConfigFileId,
&str,
) -> ksp_core_lib::Result<ksp_config_lib::ConfigDocumentChangeReport> = ksp_config_lib::ConfigManagement::save_source_candidate;
let reveal_effective: fn(&ksp_config_lib::ConfigManagement, &str) -> ksp_core_lib::Result<std::option::Option<String>> = 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; 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>> = let reveal_dotenv: fn(&ksp_config_lib::ConfigManagement, &str) -> ksp_core_lib::Result<std::option::Option<String>> =
ksp_config_lib::ConfigManagement::reveal_dotenv_value; ksp_config_lib::ConfigManagement::reveal_dotenv_value;
let _ = (reveal_effective, reveal_dotenv); let _ = (save_source_candidate, reveal_effective, reveal_dotenv);
assert_ne!(ksp_config_lib::ERROR_CODE_MANAGEMENT_OPERATION_INVALID, ksp_config_lib::ERROR_CODE_PERSISTENCE_WRITE_FAILED); assert_ne!(ksp_config_lib::ERROR_CODE_MANAGEMENT_OPERATION_INVALID, ksp_config_lib::ERROR_CODE_PERSISTENCE_WRITE_FAILED);
} }
#[test]
fn transport_adapter_contract_is_available_from_crate_root() {
let _loader = ksp_config_lib::ConfigDocumentEngine::load_resolved_transport_config;
assert!(std::mem::size_of::<ksp_config_lib::ResolvedTransportConfig>() > 0);
assert_eq!(ksp_config_lib::FILE_ID_STD_TRANSPORT, "cfg.std.transport");
assert_eq!(ksp_config_lib::FILE_ID_SCHEMA_STD_TRANSPORT, "schema.std.transport");
assert_eq!(ksp_config_lib::DEFAULT_STD_TRANSPORT_FILENAME, "std.transport.json");
assert_eq!(ksp_config_lib::DEFAULT_STD_TRANSPORT_SCHEMA_FILENAME, "std.transport.schema.json");
}

View File

@@ -0,0 +1,32 @@
// file: crates/ksp-config-lib/tests/transport_devnet_smoke.rs
// version: 1
//! Opt-in live Devnet smoke for the Config -> Transport foundation path.
#[tokio::test(flavor = "current_thread")]
#[ignore = "opt-in live Solana Devnet smoke; performs external network requests"]
async fn committed_devnet_transport_profile_reaches_all_foundation_canaries() {
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"))
.expect("committed Config roots must be valid");
let registry = ksp_config_lib::ConfigFileRegistry::defaults().expect("default Config registry must be valid");
let engine = ksp_config_lib::ConfigDocumentEngine::new(bootstrap, registry);
let environment = ksp_config_lib::ConfigEnvironment::load().expect("Config must capture the opt-in smoke environment");
let resolved = engine
.load_resolved_transport_config(std::option::Option::Some("devnet_public"), &environment)
.expect("committed devnet_public Transport profile must resolve");
assert_eq!(resolved.profile_id(), "devnet_public");
let pool = ksp_onchain_transport_lib::HttpTransportPool::new(resolved.into_settings()).expect("resolved Transport settings must construct the HTTP pool");
let role = ksp_onchain_transport_lib::HttpRoleName::new("default");
let health = pool.get_health(&role).await.expect("Devnet getHealth smoke must succeed");
assert_eq!(health, ksp_onchain_transport_lib::SolanaNodeHealth::Healthy);
let genesis_hash = pool.get_genesis_hash(&role).await.expect("Devnet getGenesisHash smoke must succeed");
assert!(!genesis_hash.as_str().is_empty());
let version = pool.get_version(&role).await.expect("Devnet getVersion smoke must succeed");
assert!(!version.solana_core().is_empty());
let balance = pool
.get_balance(&role, &ksp_core_lib::PRGIDPK_SOLANA_SYSTEM, std::option::Option::None)
.await
.expect("Devnet getBalance smoke must succeed");
assert!(balance.context().slot() > 0);
}

View File

@@ -1,18 +1,18 @@
// file: crates/ksp-config-lib/unit_tests/composite.rs // file: crates/ksp-config-lib/unit_tests/composite.rs
// version: 2 // version: 3
const TEST_COMPOSITE_FILE_ID: &str = "cfg.composite.test"; const TEST_COMPOSITE_FILE_ID: &str = "cfg.composite.test";
const TEST_COMPOSITE_FILENAME: &str = "examples/composite.example.json"; const TEST_COMPOSITE_FILENAME: &str = "examples/composite.example.json";
#[test] #[test]
fn committed_composite_example_resolves_default_document_profile() { fn fixture_composite_example_resolves_default_document_profile() {
let engine = committed_engine(); let engine = fixture_example_engine();
let file_id = crate::ConfigFileId::new(TEST_COMPOSITE_FILE_ID); let file_id = crate::ConfigFileId::new(TEST_COMPOSITE_FILE_ID);
assert!(engine.is_ok(), "composite test engine should be constructible: {engine:?}"); 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:?}"); 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) { 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); let resolved = engine.load_resolved_composite(&file_id, std::option::Option::None);
assert!(resolved.is_ok(), "committed composite example should resolve: {resolved:?}"); assert!(resolved.is_ok(), "fixture composite example should resolve: {resolved:?}");
if let std::result::Result::Ok(resolved) = resolved { if let std::result::Result::Ok(resolved) = resolved {
assert_eq!(resolved.profile_id(), "local_default"); assert_eq!(resolved.profile_id(), "local_default");
assert_eq!(resolved.selection_source(), crate::ConfigProfileSelectionSource::DefaultProfile); assert_eq!(resolved.selection_source(), crate::ConfigProfileSelectionSource::DefaultProfile);
@@ -20,7 +20,12 @@ fn committed_composite_example_resolves_default_document_profile() {
assert!(logging.is_some(), "logging component should be resolved"); assert!(logging.is_some(), "logging component should be resolved");
if let std::option::Option::Some(logging) = logging { 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().file_id().as_str(), crate::FILE_ID_STD_LOGGING);
assert_eq!(logging.resolved().profile_id(), "local_dev"); let logging_document = engine.load_validated_document(logging.resolved().file_id());
assert!(logging_document.is_ok(), "referenced Logging document should validate: {logging_document:?}");
if let std::result::Result::Ok(logging_document) = logging_document {
let default_profile = logging_document.value().get("default_profile").and_then(serde_json::Value::as_str);
assert_eq!(default_profile, std::option::Option::Some(logging.resolved().profile_id()));
}
assert_eq!(logging.resolved().selection_source(), crate::ConfigProfileSelectionSource::DefaultProfile); assert_eq!(logging.resolved().selection_source(), crate::ConfigProfileSelectionSource::DefaultProfile);
assert_eq!(logging.resolved().origin("logs_directory"), std::option::Option::Some(crate::ConfigValueOrigin::Global)); assert_eq!(logging.resolved().origin("logs_directory"), std::option::Option::Some(crate::ConfigValueOrigin::Global));
} }
@@ -30,7 +35,7 @@ fn committed_composite_example_resolves_default_document_profile() {
#[test] #[test]
fn composite_profile_override_marks_referenced_profile_selection_as_composite() { fn composite_profile_override_marks_referenced_profile_selection_as_composite() {
let engine = committed_engine(); let engine = fixture_example_engine();
let file_id = crate::ConfigFileId::new(TEST_COMPOSITE_FILE_ID); let file_id = crate::ConfigFileId::new(TEST_COMPOSITE_FILE_ID);
assert!(engine.is_ok(), "composite test engine should be constructible: {engine:?}"); 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:?}"); assert!(file_id.is_ok(), "composite test file_id should be valid: {file_id:?}");
@@ -51,7 +56,7 @@ fn composite_profile_override_marks_referenced_profile_selection_as_composite()
#[test] #[test]
fn unknown_composite_profile_has_profile_not_found_error() { fn unknown_composite_profile_has_profile_not_found_error() {
let engine = committed_engine(); let engine = fixture_example_engine();
let file_id = crate::ConfigFileId::new(TEST_COMPOSITE_FILE_ID); let file_id = crate::ConfigFileId::new(TEST_COMPOSITE_FILE_ID);
assert!(engine.is_ok(), "composite test engine should be constructible: {engine:?}"); 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:?}"); assert!(file_id.is_ok(), "composite test file_id should be valid: {file_id:?}");
@@ -123,9 +128,10 @@ fn duplicate_component_ids_are_rejected_inside_one_composite_profile() {
cleanup_fixture(&fixture); cleanup_fixture(&fixture);
} }
fn committed_engine() -> ksp_core_lib::Result<crate::ConfigDocumentEngine> { fn fixture_example_engine() -> ksp_core_lib::Result<crate::ConfigDocumentEngine> {
let workspace = workspace_root(); let workspace = workspace_root();
let bootstrap = crate::ConfigBootstrapOptions::from_paths(workspace.join("config"), workspace.join("config/schemas")); let fixture_root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("unit_tests/fixtures");
let bootstrap = crate::ConfigBootstrapOptions::from_paths(fixture_root, workspace.join("config/schemas"));
let bootstrap = match bootstrap { let bootstrap = match bootstrap {
std::result::Result::Ok(value) => value, std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error), std::result::Result::Err(error) => return std::result::Result::Err(error),
@@ -205,7 +211,8 @@ fn prepare_fixture(fixture: &FixtureRoots, composite: &str) -> std::io::Result<(
return std::result::Result::Err(error); return std::result::Result::Err(error);
} }
let workspace = workspace_root(); let workspace = workspace_root();
let logging = std::fs::copy(workspace.join("config/std.logging.json"), fixture.config.join(crate::DEFAULT_STD_LOGGING_FILENAME)); let stable_logging = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("unit_tests/fixtures/std.logging.json");
let logging = std::fs::copy(stable_logging, fixture.config.join(crate::DEFAULT_STD_LOGGING_FILENAME));
if let std::result::Result::Err(error) = logging { if let std::result::Result::Err(error) = logging {
return std::result::Result::Err(error); return std::result::Result::Err(error);
} }

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-config-lib/unit_tests/document.rs // file: crates/ksp-config-lib/unit_tests/document.rs
// version: 2 // version: 3
#[test] #[test]
fn committed_logging_document_passes_registered_schema_and_semantic_validation() { fn committed_logging_document_passes_registered_schema_and_semantic_validation() {
@@ -17,10 +17,16 @@ fn committed_logging_document_passes_registered_schema_and_semantic_validation()
if let std::result::Result::Ok(document) = document { if let std::result::Result::Ok(document) = document {
assert_eq!(document.file_id(), &file_id); assert_eq!(document.file_id(), &file_id);
assert_eq!(document.path(), workspace.join("config/std.logging.json").as_path()); assert_eq!(document.path(), workspace.join("config/std.logging.json").as_path());
let default_profile = document.value().get("default_profile"); let default_profile = document.value().get("default_profile").and_then(serde_json::Value::as_str);
assert!(default_profile.is_some(), "validated Logging document should retain default_profile"); assert!(default_profile.is_some(), "validated Logging document should retain default_profile");
if let std::option::Option::Some(default_profile) = default_profile { if let std::option::Option::Some(default_profile) = default_profile {
assert_eq!(default_profile.as_str(), std::option::Option::Some("local_dev")); let profiles = document.value().get("profiles").and_then(serde_json::Value::as_array);
assert!(profiles.is_some(), "validated Logging document should retain profiles");
if let std::option::Option::Some(profiles) = profiles {
assert!(profiles.iter().any(|profile| -> bool {
return profile.get("profile_id").and_then(serde_json::Value::as_str) == std::option::Option::Some(default_profile);
}));
}
} }
} }
} }

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-config-lib/unit_tests/environment.rs // file: crates/ksp-config-lib/unit_tests/environment.rs
// version: 4 // version: 5
#[test] #[test]
fn process_environment_wins_over_dotenv_and_fallback_even_when_empty() { fn process_environment_wins_over_dotenv_and_fallback_even_when_empty() {
@@ -174,9 +174,8 @@ fn fake_process_collection_filters_unrelated_names_without_mutating_real_environ
} }
#[test] #[test]
fn committed_logging_profile_resolves_environment_fallback_without_changing_source_profile() { fn logging_fixture_profile_resolves_environment_fallback_without_changing_source_profile() {
let workspace = workspace_root(); let bootstrap = logging_fixture_bootstrap();
let bootstrap = crate::ConfigBootstrapOptions::from_paths(workspace.join("config"), workspace.join("config/schemas"));
assert!(bootstrap.is_ok(), "bootstrap should resolve committed roots"); assert!(bootstrap.is_ok(), "bootstrap should resolve committed roots");
let bootstrap = match bootstrap { let bootstrap = match bootstrap {
std::result::Result::Ok(value) => value, std::result::Result::Ok(value) => value,
@@ -235,6 +234,12 @@ fn workspace_root() -> std::path::PathBuf {
return std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../.."); return std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..");
} }
fn logging_fixture_bootstrap() -> ksp_core_lib::Result<crate::ConfigBootstrapOptions> {
let fixture_root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("unit_tests/fixtures");
let workspace = workspace_root();
return crate::ConfigBootstrapOptions::from_paths(fixture_root, workspace.join("config/schemas"));
}
#[test] #[test]
fn secret_environment_value_keeps_real_value_but_redacts_safe_and_debug_views() { fn secret_environment_value_keeps_real_value_but_redacts_safe_and_debug_views() {
let canary = "KSP_SECRET_CANARY_91b7c6"; let canary = "KSP_SECRET_CANARY_91b7c6";
@@ -322,9 +327,8 @@ fn detailed_json_preserves_safe_tree_sensitivity_and_pointer_provenance() {
} }
#[test] #[test]
fn detailed_profile_environment_keeps_global_origin_and_adds_environment_provenance() { fn detailed_fixture_profile_environment_keeps_global_origin_and_adds_environment_provenance() {
let workspace = workspace_root(); let bootstrap = logging_fixture_bootstrap();
let bootstrap = crate::ConfigBootstrapOptions::from_paths(workspace.join("config"), workspace.join("config/schemas"));
let bootstrap = match bootstrap { let bootstrap = match bootstrap {
std::result::Result::Ok(value) => value, std::result::Result::Ok(value) => value,
std::result::Result::Err(_) => return, std::result::Result::Err(_) => return,

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,77 @@
{
"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"
},
{
"target_prefix": "ksp-app-config-desk",
"level": "debug"
}
]
}
]
}

View File

@@ -0,0 +1,40 @@
{
"format_version": 1,
"retry": {
"max_retries": 4,
"initial_backoff_ms": 125,
"max_backoff_ms": 2500
},
"default_profile": "secret_test",
"profiles": [
{
"profile_id": "secret_test",
"endpoints": [
{
"name": "fixture_private",
"enabled": true,
"provider": "fixture-provider",
"cluster": "fixture-cluster",
"url": "${KSP_SECRET_TRANSPORT_TEST_URL:-https://fallback.invalid}",
"connect_timeout_ms": 750,
"request_timeout_ms": 2500,
"max_idle_connections_per_host": 3,
"roles": [
{
"role": "default",
"enabled": true,
"request_kinds": ["*"],
"priority": 7,
"limits": {
"requests_per_second": 9,
"burst_capacity": 12,
"max_concurrent_requests": 4,
"pause_after_rate_limit_ms": 650
}
}
]
}
]
}
]
}

View File

@@ -1,74 +1,145 @@
// file: crates/ksp-config-lib/unit_tests/logging.rs // file: crates/ksp-config-lib/unit_tests/logging.rs
// version: 1 // version: 4
#[test] #[test]
fn committed_logging_profile_maps_complete_runtime_contract() { fn fixture_logging_profile_maps_complete_runtime_contract() {
let engine = committed_engine(); let engine = fixture_engine();
let engine = match engine { let engine = match engine {
std::result::Result::Ok(value) => value, std::result::Result::Ok(value) => value,
std::result::Result::Err(_) => return, std::result::Result::Err(_) => return,
}; };
let environment = crate::ConfigEnvironment::from_maps(std::collections::BTreeMap::new(), std::collections::BTreeMap::new()); 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); let resolved = engine.load_resolved_logging_config(std::option::Option::None, &environment);
assert!(resolved.is_ok(), "committed Logging Config should map"); assert!(resolved.is_ok(), "fixture Logging Config should map");
let resolved = match resolved { let resolved = match resolved {
std::result::Result::Ok(value) => value, std::result::Result::Ok(value) => value,
std::result::Result::Err(_) => return, std::result::Result::Err(_) => return,
}; };
assert_eq!(resolved.file_id().as_str(), crate::FILE_ID_STD_LOGGING); 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.selection_source(), crate::ConfigProfileSelectionSource::DefaultProfile);
assert_eq!(resolved.logs_directory(), current_directory().join("logs").as_path()); let effective = resolved.effective().value();
assert_eq!(resolved.effective().value()["logs_directory"], serde_json::Value::String("logs".to_owned())); let effective_logs_directory = effective.get("logs_directory").and_then(serde_json::Value::as_str);
assert!(effective_logs_directory.is_some(), "effective Logging Config should expose logs_directory");
if let std::option::Option::Some(effective_logs_directory) = effective_logs_directory {
let source_path = std::path::Path::new(effective_logs_directory);
let expected = if source_path.is_absolute() { source_path.to_path_buf() } else { current_directory().join(source_path) };
assert_eq!(resolved.logs_directory(), expected.as_path());
}
let settings = resolved.settings(); let settings = resolved.settings();
assert_eq!(settings.default_filter(), ksp_logging_lib::LogFilterLevel::Warn); let default_filter = effective.get("default_filter").and_then(serde_json::Value::as_str);
assert_eq!(settings.span_events(), ksp_logging_lib::SpanEvents::NewAndClose); let span_events = effective.get("span_events").and_then(serde_json::Value::as_str);
assert_eq!(settings.target_filters().len(), 2); assert_eq!(default_filter.and_then(test_level), std::option::Option::Some(settings.default_filter()));
assert_eq!(settings.target_filters()[0].target_prefix(), "ksp-config-lib"); assert_eq!(span_events.and_then(test_span_events), std::option::Option::Some(settings.span_events()));
assert_eq!(settings.target_filters()[0].level(), ksp_logging_lib::LogFilterLevel::Trace); let source_target_filters = effective.get("target_filters").and_then(serde_json::Value::as_array);
assert_eq!(settings.target_filters()[1].target_prefix(), "ksp-logging-lib"); assert!(source_target_filters.is_some(), "effective Logging Config should expose target_filters");
assert_eq!(settings.target_filters()[1].level(), ksp_logging_lib::LogFilterLevel::Debug); if let std::option::Option::Some(source_target_filters) = source_target_filters {
assert_eq!(settings.target_filters().len(), source_target_filters.len());
for (runtime, source) in settings.target_filters().iter().zip(source_target_filters) {
assert_eq!(source.get("target_prefix").and_then(serde_json::Value::as_str), std::option::Option::Some(runtime.target_prefix()));
assert_eq!(source.get("level").and_then(serde_json::Value::as_str).and_then(test_level), std::option::Option::Some(runtime.level()));
}
}
let console = settings.console(); let console = settings.console();
assert!(console.is_some(), "committed Logging Config declares console settings"); let source_console = effective.get("console");
let console = match console { assert!(console.is_some(), "effective Logging Config declares console settings");
std::option::Option::Some(value) => value, assert!(source_console.is_some(), "effective Logging Config should expose console settings");
std::option::Option::None => return, if let (std::option::Option::Some(console), std::option::Option::Some(source_console)) = (console, source_console) {
assert_eq!(source_console.get("enabled").and_then(serde_json::Value::as_bool), std::option::Option::Some(console.enabled()));
assert_eq!(source_console.get("ansi").and_then(serde_json::Value::as_bool), std::option::Option::Some(console.ansi()));
assert_eq!(source_console.get("output").and_then(serde_json::Value::as_str).and_then(test_console_output), std::option::Option::Some(console.output()));
assert_eq!(source_console.get("format").and_then(serde_json::Value::as_str).and_then(test_format), std::option::Option::Some(console.format()));
let filter = source_console.get("filter");
assert!(filter.is_some(), "effective console should expose filter");
if let std::option::Option::Some(filter) = filter {
assert_eq!(filter.get("level").and_then(serde_json::Value::as_str).and_then(test_level), std::option::Option::Some(console.filter().level()));
assert_eq!(json_string_array(filter.get("targets")), console.filter().targets().to_vec());
assert_eq!(json_string_array(filter.get("domains")), console.filter().domains().to_vec());
}
}
let source_files = effective.get("files").and_then(serde_json::Value::as_array);
assert!(source_files.is_some(), "effective Logging Config should expose files");
if let std::option::Option::Some(source_files) = source_files {
assert_eq!(settings.files().len(), source_files.len());
for (runtime, source) in settings.files().iter().zip(source_files) {
assert_eq!(source.get("output_id").and_then(serde_json::Value::as_str), std::option::Option::Some(runtime.output_id()));
assert_eq!(source.get("enabled").and_then(serde_json::Value::as_bool), std::option::Option::Some(runtime.enabled()));
assert_eq!(source.get("rotation").and_then(serde_json::Value::as_str).and_then(test_rotation), std::option::Option::Some(runtime.rotation()));
assert_eq!(source.get("format").and_then(serde_json::Value::as_str).and_then(test_format), std::option::Option::Some(runtime.format()));
assert_eq!(source.get("ansi").and_then(serde_json::Value::as_bool), std::option::Option::Some(runtime.ansi()));
let filter = source.get("filter");
assert!(filter.is_some(), "effective file should expose filter");
if let std::option::Option::Some(filter) = filter {
assert_eq!(filter.get("level").and_then(serde_json::Value::as_str).and_then(test_level), std::option::Option::Some(runtime.filter().level()));
assert_eq!(json_string_array(filter.get("targets")), runtime.filter().targets().to_vec());
assert_eq!(json_string_array(filter.get("domains")), runtime.filter().domains().to_vec());
}
}
}
}
fn test_level(value: &str) -> std::option::Option<ksp_logging_lib::LogFilterLevel> {
return match value {
"off" => std::option::Option::Some(ksp_logging_lib::LogFilterLevel::Off),
"error" => std::option::Option::Some(ksp_logging_lib::LogFilterLevel::Error),
"warn" => std::option::Option::Some(ksp_logging_lib::LogFilterLevel::Warn),
"info" => std::option::Option::Some(ksp_logging_lib::LogFilterLevel::Info),
"debug" => std::option::Option::Some(ksp_logging_lib::LogFilterLevel::Debug),
"trace" => std::option::Option::Some(ksp_logging_lib::LogFilterLevel::Trace),
_ => std::option::Option::None,
}; };
assert!(console.enabled()); }
assert_eq!(console.output(), ksp_logging_lib::ConsoleOutput::Stderr);
assert!(console.ansi()); fn test_span_events(value: &str) -> std::option::Option<ksp_logging_lib::SpanEvents> {
assert_eq!(console.format(), ksp_logging_lib::LogFormat::Compact); return match value {
assert_eq!(console.filter().level(), ksp_logging_lib::LogFilterLevel::Debug); "off" => std::option::Option::Some(ksp_logging_lib::SpanEvents::Off),
assert_eq!(console.filter().targets(), &["*".to_owned()]); "new_and_close" => std::option::Option::Some(ksp_logging_lib::SpanEvents::NewAndClose),
assert_eq!(console.filter().domains(), &["*".to_owned()]); "full" => std::option::Option::Some(ksp_logging_lib::SpanEvents::Full),
assert_eq!(settings.files().len(), 2); _ => std::option::Option::None,
assert_file( };
&settings.files()[0], }
"file.all.debug",
current_directory().join("logs/debug").as_path(), fn test_console_output(value: &str) -> std::option::Option<ksp_logging_lib::ConsoleOutput> {
"ksp-debug.log", return match value {
ksp_logging_lib::FileRotation::Daily, "stdout" => std::option::Option::Some(ksp_logging_lib::ConsoleOutput::Stdout),
ksp_logging_lib::LogFormat::Human, "stderr" => std::option::Option::Some(ksp_logging_lib::ConsoleOutput::Stderr),
ksp_logging_lib::LogFilterLevel::Debug, _ => std::option::Option::None,
&["*"], };
&["*"], }
);
assert_file( fn test_format(value: &str) -> std::option::Option<ksp_logging_lib::LogFormat> {
&settings.files()[1], return match value {
"file.config.error", "human" => std::option::Option::Some(ksp_logging_lib::LogFormat::Human),
current_directory().join("logs/config").as_path(), "compact" => std::option::Option::Some(ksp_logging_lib::LogFormat::Compact),
"ksp-config-errors.jsonl", "pretty" => std::option::Option::Some(ksp_logging_lib::LogFormat::Pretty),
ksp_logging_lib::FileRotation::Daily, "json" => std::option::Option::Some(ksp_logging_lib::LogFormat::Json),
ksp_logging_lib::LogFormat::Json, _ => std::option::Option::None,
ksp_logging_lib::LogFilterLevel::Error, };
&["ksp-config-lib"], }
&["config"],
); fn test_rotation(value: &str) -> std::option::Option<ksp_logging_lib::FileRotation> {
return match value {
"never" => std::option::Option::Some(ksp_logging_lib::FileRotation::Never),
"hourly" => std::option::Option::Some(ksp_logging_lib::FileRotation::Hourly),
"daily" => std::option::Option::Some(ksp_logging_lib::FileRotation::Daily),
_ => std::option::Option::None,
};
}
fn json_string_array(value: std::option::Option<&serde_json::Value>) -> std::vec::Vec<String> {
let mut result = std::vec::Vec::<String>::new();
if let std::option::Option::Some(values) = value.and_then(serde_json::Value::as_array) {
for value in values {
if let std::option::Option::Some(value) = value.as_str() {
result.push(value.to_owned());
}
}
}
return result;
} }
#[test] #[test]
fn relative_logs_directory_is_anchored_to_process_current_directory() { fn relative_logs_directory_is_anchored_to_process_current_directory() {
let engine = committed_engine(); let engine = fixture_engine();
let engine = match engine { let engine = match engine {
std::result::Result::Ok(value) => value, std::result::Result::Ok(value) => value,
std::result::Result::Err(_) => return, std::result::Result::Err(_) => return,
@@ -84,7 +155,7 @@ fn relative_logs_directory_is_anchored_to_process_current_directory() {
#[test] #[test]
fn absolute_logs_directory_is_preserved() { fn absolute_logs_directory_is_preserved() {
let engine = committed_engine(); let engine = fixture_engine();
let engine = match engine { let engine = match engine {
std::result::Result::Ok(value) => value, std::result::Result::Ok(value) => value,
std::result::Result::Err(_) => return, std::result::Result::Err(_) => return,
@@ -109,7 +180,7 @@ fn effective_file_paths_cannot_escape_logging_root() {
#[test] #[test]
fn explicit_empty_logs_directory_is_invalid_instead_of_using_fallback() { fn explicit_empty_logs_directory_is_invalid_instead_of_using_fallback() {
let engine = committed_engine(); let engine = fixture_engine();
let engine = match engine { let engine = match engine {
std::result::Result::Ok(value) => value, std::result::Result::Ok(value) => value,
std::result::Result::Err(_) => return, std::result::Result::Err(_) => return,
@@ -128,7 +199,7 @@ fn explicit_empty_logs_directory_is_invalid_instead_of_using_fallback() {
#[test] #[test]
fn existing_non_directory_logging_root_is_rejected_without_secret_leak() { fn existing_non_directory_logging_root_is_rejected_without_secret_leak() {
let engine = committed_engine(); let engine = fixture_engine();
let engine = match engine { let engine = match engine {
std::result::Result::Ok(value) => value, std::result::Result::Ok(value) => value,
std::result::Result::Err(_) => return, std::result::Result::Err(_) => return,
@@ -137,7 +208,7 @@ fn existing_non_directory_logging_root_is_rejected_without_secret_leak() {
let path = std::env::temp_dir().join(secret.as_str()); let path = std::env::temp_dir().join(secret.as_str());
let write = std::fs::write(path.as_path(), b"not a directory"); let write = std::fs::write(path.as_path(), b"not a directory");
assert!(write.is_ok(), "secret canary file should be created"); assert!(write.is_ok(), "secret canary file should be created");
let profile = load_committed_profile(&engine); let profile = load_fixture_profile(&engine);
let profile = match profile { let profile = match profile {
std::result::Result::Ok(value) => value, std::result::Result::Ok(value) => value,
std::result::Result::Err(_) => { std::result::Result::Err(_) => {
@@ -159,12 +230,12 @@ fn existing_non_directory_logging_root_is_rejected_without_secret_leak() {
#[test] #[test]
fn logging_adapter_rejects_secret_effective_values_without_exposing_canary() { fn logging_adapter_rejects_secret_effective_values_without_exposing_canary() {
let engine = committed_engine(); let engine = fixture_engine();
let engine = match engine { let engine = match engine {
std::result::Result::Ok(value) => value, std::result::Result::Ok(value) => value,
std::result::Result::Err(_) => return, std::result::Result::Err(_) => return,
}; };
let profile = load_committed_profile(&engine); let profile = load_fixture_profile(&engine);
let profile = match profile { let profile = match profile {
std::result::Result::Ok(value) => value, std::result::Result::Ok(value) => value,
std::result::Result::Err(_) => return, std::result::Result::Err(_) => return,
@@ -191,7 +262,7 @@ fn logging_adapter_rejects_secret_effective_values_without_exposing_canary() {
#[test] #[test]
fn mapped_logging_settings_can_initialize_and_reinitialize_runtime() { fn mapped_logging_settings_can_initialize_and_reinitialize_runtime() {
let engine = committed_engine(); let engine = fixture_engine();
let engine = match engine { let engine = match engine {
std::result::Result::Ok(value) => value, std::result::Result::Ok(value) => value,
std::result::Result::Err(_) => return, std::result::Result::Err(_) => return,
@@ -229,7 +300,7 @@ fn mapped_logging_settings_can_initialize_and_reinitialize_runtime() {
#[test] #[test]
fn resolved_logging_debug_uses_safe_effective_view() { fn resolved_logging_debug_uses_safe_effective_view() {
let engine = committed_engine(); let engine = fixture_engine();
let engine = match engine { let engine = match engine {
std::result::Result::Ok(value) => value, std::result::Result::Ok(value) => value,
std::result::Result::Err(_) => return, std::result::Result::Err(_) => return,
@@ -245,32 +316,10 @@ fn resolved_logging_debug_uses_safe_effective_view() {
} }
} }
fn assert_file( fn fixture_engine() -> ksp_core_lib::Result<crate::ConfigDocumentEngine> {
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 workspace = workspace_root();
let bootstrap = crate::ConfigBootstrapOptions::from_paths(workspace.join("config"), workspace.join("config/schemas")); let fixture_root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("unit_tests/fixtures");
let bootstrap = crate::ConfigBootstrapOptions::from_paths(fixture_root, workspace.join("config/schemas"));
let bootstrap = match bootstrap { let bootstrap = match bootstrap {
std::result::Result::Ok(value) => value, std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error), std::result::Result::Err(error) => return std::result::Result::Err(error),
@@ -283,7 +332,7 @@ fn committed_engine() -> ksp_core_lib::Result<crate::ConfigDocumentEngine> {
return std::result::Result::Ok(crate::ConfigDocumentEngine::new(bootstrap, registry)); return std::result::Result::Ok(crate::ConfigDocumentEngine::new(bootstrap, registry));
} }
fn load_committed_profile(engine: &crate::ConfigDocumentEngine) -> ksp_core_lib::Result<crate::ResolvedConfigProfile> { fn load_fixture_profile(engine: &crate::ConfigDocumentEngine) -> ksp_core_lib::Result<crate::ResolvedConfigProfile> {
let file_id = crate::ConfigFileId::new(crate::FILE_ID_STD_LOGGING); let file_id = crate::ConfigFileId::new(crate::FILE_ID_STD_LOGGING);
let file_id = match file_id { let file_id = match file_id {
std::result::Result::Ok(value) => value, std::result::Result::Ok(value) => value,

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