128 Commits

Author SHA1 Message Date
5b3967327a v0.2.4-rel.001 2026-08-18 23:51:56 +02:00
804b92b957 v0.2.4-pre.009-fix.001 2026-08-18 23:26:17 +02:00
e6772c109d v0.2.4-pre.009 2026-08-18 22:43:09 +02:00
7e84522787 v0.2.4-pre.008-fix.001 2026-08-18 21:53:33 +02:00
37461ec248 v0.2.4-pre.008 2026-08-18 21:52:00 +02:00
4252a21140 v0.2.4-pre.007 2026-08-18 21:33:21 +02:00
7a889dd466 v0.2.4-pre.006 2026-08-18 21:21:02 +02:00
d3cc7c93f0 v0.2.4-pre.005 2026-08-18 21:03:00 +02:00
561b6678ed v0.2.4-pre.004 2026-08-18 20:54:16 +02:00
e500024c3a v0.2.4-pre.003 2026-08-18 20:51:52 +02:00
033800f897 v0.2.4-pre.002-fix.001 2026-08-18 20:21:01 +02:00
cf8d79567d v0.2.4-pre.002 2026-08-18 20:12:46 +02:00
cb19742cf7 v0.2.4-pre.001-fix.001 2026-08-18 19:51:00 +02:00
c03a3eaf5d v0.2.4-pre.001 2026-08-18 19:36:57 +02:00
f7acac3fc8 v0.2.3-rel.001 2026-08-18 17:42:12 +02:00
039b93cfb3 v0.2.3-pre.009 2026-08-18 17:33:46 +02:00
c9a0f7fd37 v0.2.3-pre.008 2026-08-18 16:47:54 +02:00
704110d509 v0.2.3-pre.007 2026-08-18 16:12:07 +02:00
fb32ed03f8 v0.2.3-pre.006-fix.001 2026-08-18 12:40:24 +02:00
4439589f0d v0.2.3-pre.006 2026-08-18 12:35:29 +02:00
1e1bc0d421 v0.2.3-pre.005 2026-08-18 12:33:16 +02:00
bd26d4f636 v0.2.3-pre.004 2026-08-18 12:14:59 +02:00
2a7f3e8f40 v0.2.3-pre.003 2026-08-18 11:35:51 +02:00
1d37f04d63 v0.2.3-pre.002-fix.001 2026-08-18 11:27:24 +02:00
1ccf11bcc1 v0.2.3-pre.002 2026-08-18 11:13:04 +02:00
d06284fca2 v0.2.3-pre.001 2026-08-18 10:47:19 +02:00
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
513f57dd21 v0.1.3-rel.001 2026-08-16 08:06:38 +02:00
8279241144 v0.1.3-pre.015-fix.002 2026-08-16 07:56:38 +02:00
ee8fdedf86 v0.1.3-pre.015-fix.001 2026-08-16 07:54:49 +02:00
64136c99ad v0.1.3-pre.015 2026-08-16 07:52:59 +02:00
92982667ac v0.1.3-pre.014 2026-08-16 06:18:16 +02:00
299a0cb2fc v0.1.3-pre.013-fix.001 2026-08-16 06:06:51 +02:00
9549303ed5 v0.1.3-pre.013 2026-08-16 06:04:06 +02:00
a9405ec7ff v0.1.3-pre.012 2026-08-16 05:01:33 +02:00
6a13cea614 v0.1.3-pre.011-fix.001 2026-08-16 04:44:25 +02:00
274428bd2f v0.1.3-pre.011 2026-08-16 04:35:23 +02:00
695068acea v0.1.3-pre.010-fix.001 2026-08-15 22:02:29 +02:00
1fc002c978 v0.1.3-pre.010 2026-08-15 21:48:54 +02:00
97a07ce683 v0.1.3-pre.009-fix.001 2026-08-15 21:32:56 +02:00
a5b4c748ea v0.1.3-pre.009 2026-08-15 21:25:08 +02:00
d1196c03e5 v0.1.3-pre.008 2026-08-15 21:20:56 +02:00
96753e4ba1 v0.1.3-pre.007 2026-08-15 20:57:13 +02:00
b7323fe961 v0.1.3-pre.006 2026-08-15 20:40:38 +02:00
3a479e4f43 v0.1.3-pre.005-fix-001 2026-08-15 20:26:24 +02:00
063b24ee1c v0.1.3-pre.005 2026-08-15 20:22:02 +02:00
29660fd9f0 v0.1.3-pre.004 2026-08-15 19:58:18 +02:00
0629a48e97 v0.1.3-pre.003 2026-08-15 19:17:09 +02:00
9207919d47 v0.1.3-pre.002-fix.001 2026-08-15 19:11:30 +02:00
8360f25f59 v0.1.3-pre.002 2026-08-15 08:40:11 +02:00
ff94762b10 v0.1.3-pre.001-fix.003 2026-08-15 08:36:51 +02:00
532f56eaad v0.1.3-pre.001-fix.002 2026-08-15 08:35:36 +02:00
5cba2beb64 v0.1.3-pre.001-fix.001 2026-08-15 01:47:06 +02:00
ba384ec3d7 v0.1.3-pre.001 2026-08-15 00:12:35 +02:00
ba158ad375 v0.1.3-pre.001 2026-08-15 00:12:27 +02:00
477 changed files with 77978 additions and 3071 deletions

24
.env.example Normal file
View File

@@ -0,0 +1,24 @@
# file: .env.example
# version: 3
# KSP Logging root directory. Used by config/std.logging.json for relative log output paths.
# The current Config document fallback is "logs" when neither the process environment nor .env defines this variable.
KSP_LOGS_DIRECTORY=logs
# 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
# version: 2
# version: 3
# Rust build artifacts
# /target/ # target is outside.
@@ -16,6 +16,8 @@ pnpm-debug.log*
# Node / frontend dependencies and generated artifacts
**/node_modules/
**/dist/
bindings/
gen/
package-lock.json
pnpm-lock.yaml
yarn.lock

46
CHANGELOG.md Normal file
View File

@@ -0,0 +1,46 @@
<!-- file: CHANGELOG.md -->
<!-- version: 8 -->
# Changelog KSP
## 0.2.4 — HTTP Blocks + Economics + compliance HTTP finale — 2026-08-18
`0.2.4` achève la surface HTTP Solana standard de `ksp-onchain-transport-lib` : les 10 wrappers Blocks et 5 wrappers Economics portent la couverture typée à **52/52 méthodes HTTP courantes**, tandis que les **14/14 méthodes historiques Deprecated/runtime Removed** restent conservées pour compliance. La release stabilise notamment `getBlock` moderne avec sa forme bare encoding legacy dépréciée, les quatre variantes `transactionDetails`, les versions transaction numériques génériques, `numRewardPartitions` (SIMD-0118), `commissionBps` (SIMD-0291), les overloads de ranges/performance, `getBlockProduction`, les valeurs Economics fournies par le runtime et `getInflationReward` positionnel sans plafond d'adresses inventé. Le réaudit final confirme l'égalité exacte entre l'inventaire HTTP officiel courant et le registre KSP, applique `KSP-TRANSPORT-007` aux **52/52 wrappers**, et ne matérialise aucune extension spéculative issue de la watchlist SIMD. Les canaries de frontières, le workspace complet et les deux smokes Devnet — Transport pur couvrant Accounts/Tokens/Cluster/Transactions/Blocks/Economics et composition Config -> Transport transitoire — sont validés avant publication. Le prompt `prompts/010-V0_2_5_START_PROMPT.md`, enrichi par `pre.009-fix.001`, ouvre `0.2.5 — Wallet foundation` avec threat model, interopérabilité `.kspwallet` et capacités indépendantes VIEW/OWNER.
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.3 — HTTP Transactions — 2026-08-18
`0.2.3` complète la famille HTTP Transactions de `ksp-onchain-transport-lib` avec 11 wrappers typés, portant la surface HTTP typée courante à 37 méthodes : 4 foundation, 22 Accounts/Tokens/Cluster et 11 Transactions. La release stabilise la classification exacte `8 Read / RetrySafe`, `2 WriteSubmission / NeverAfterDispatch` (`requestAirdrop`, `sendTransaction`) et `1 Simulation / RetrySafe` (`simulateTransaction`), avec preuves end-to-end qu'une write submission n'est jamais resoumise après dispatch ambigu mais peut retenter lorsqu'un échec est prouvé `NotDispatched`. `getTransaction` couvre la config moderne complète et la forme bare encoding legacy dépréciée ; les variantes de transaction, metadata, version et `transactionIndex` restent préservées losslessly. `simulateTransaction` couvre sa config complète, ses invariants déterministes et son résultat riche sans introduire de client RPC Solana haut niveau ni de décodage transactionnel dans Transport. La release formalise `KSP-TRANSPORT-007` et réaudite rétroactivement les 37 wrappers courants sans remédiation fonctionnelle. Les canaries conservent 52 méthodes HTTP courantes, 14 historiques Deprecated/Removed et 15 Blocks/Economics réservées à `0.2.4`. Les graphes Cargo et les deux smokes Devnet — Transport pur read-only et composition Config -> Transport transitoire — ont été validés avant publication. Le prompt `prompts/009-V0_2_4_START_PROMPT.md` ouvre `0.2.4 — HTTP Blocks + Economics + compliance HTTP finale`.
## 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` stabilise `ksp-config-lib` comme propriétaire KSP unique des documents Config, schemas, profils, compositions, variables `KSP_*`/`KSPB_*`, `.env`, placeholders et persistence autorisée. La release introduit le bootstrap non récursif `cfgpath`/`schemapath`, le registre logique `file_id -> filename`, JSON Schema, globals/profils/`default_profile`, compositions par `file_id`, priorité process env > `.env` > fallback, sensibilité `Public`/`Internal`/`Secret`, représentations real/safe avec provenance, management/persistence atomique JSON et `.env`, ainsi que l'adapter vers `ksp_logging_lib::LoggingSettings` et les audits d'ownership. Elle complète également `ksp-logging-lib` avec les contrats/runtime multi-sink, routing structuré `domain` et hot reload nécessaires au premier document `std.logging.json`, puis prépare `0.1.4 — ksp-app-config-desk` comme validation desktop/Tauri extensible de cette fondation.
## 0.1.2 — Logging foundation — 2026-08-14
`0.1.2` stabilise `ksp-logging-lib` comme façade KSP unique de logging/tracing runtime. La release introduit les événements et spans KSP, le takeover des targets, le subscriber global unique, `LoggingSettings`, les sorties console/fichier non bloquantes, rotation, stripping ANSI, compteurs de lignes abandonnées, hot reload transactionnel et instrumentation async indépendante de l'executor. La stack `tracing`, `tracing-subscriber` et `tracing-appender` reste possédée exclusivement par Logging ; Tokio est limité aux tests réels d'instrumentation async.
## 0.1.1 — Core foundation — 2026-08-14
`0.1.1` stabilise `ksp-core-lib` avec le contrat commun `ErrorCode` / `ErrorContext` / `Error` / `Result<T>`, la primitive `Pubkey`, les Program IDs Solana fondamentaux possédés par KSP et leur registre canonique recherché/filtrable. La release fixe également la taxonomie de classification et la politique Cargo workspace utilisée par les crates suivantes.
## 0.0.3 — Fondation architecture et règles — 2026-08-14
`0.0.3` clôt la phase fondatrice : nomenclature KSP, règles Rust/Cargo/documentation, architecture en couches, contrats des composants, workers/jobs/pipelines/scénarios/apps, politique de versions/deltas et séquence des premières releases fonctionnelles. Elle prépare explicitement l'ouverture de `0.1.1` sans ajouter de fonctionnalité métier Solana.

View File

@@ -1,12 +1,12 @@
# file: Cargo.toml
# version: 39
# version: 142
[workspace]
resolver = "3"
members = ["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]
version = "0.1.2"
version = "0.2.4"
edition = "2024"
license = "MIT"
repository = "https://git.sasedev.com/Sasedev/khadhroony-solana-project"
@@ -14,11 +14,21 @@ authors = ["SinuS von SifriduS <sinus@sasedev.net>"]
publish = false
[workspace.dependencies]
fs2 = { version = "^0.4" }
serde = { version = "^1.0" }
serde_json = { version = "^1.0" }
jsonschema = { version = "^0.49", default-features = false }
reqwest = { version = "^0.13", default-features = false }
solana-pubkey = { version = "^4.3", default-features = false }
tracing = { version = "^0.1", default-features = false, features = ["std"] }
tracing-subscriber = { version = "^0.3", default-features = false, features = ["fmt"] }
tracing = { version = "^0.1", default-features = false }
tracing-subscriber = { version = "^0.3", 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]
missing_docs = "warn"

View File

@@ -1,5 +1,5 @@
<!-- file: README.md -->
<!-- version: 5 -->
<!-- version: 7 -->
# Khadhroony Solana Project
@@ -35,7 +35,7 @@ Les besoins du trading constituent une priorité produit à court terme mais ne
- Les jobs utilisent le préfixe `ksp-job-`.
- Une application ou un outil de démonstration se termine par `-demo`.
- Les crates Rust sont placées directement sous `crates/`, sans sous-répertoires de catégories.
- Les applications sont placées sous `apps/` lorsqu'elles sont introduites.
- Les applications Tauri, workers, jobs et autres packages Rust KSP sont des crates workspace placées directement sous `crates/`; leur nom encode leur rôle (`ksp-app-*`, `ksp-worker-*`, `ksp-job-*`).
- Les composants réutilisables restent séparés de leurs applications de manipulation ou de démonstration.
- Les applications et demos restent des interfaces/compositions ; les opérations réutilisables appartiennent aux composants KSP de niveau approprié.
- Les exécutables KSP ne dépendent pas directement de crates externes relatives à Solana ou à un protocole Solana.
@@ -51,6 +51,7 @@ Les besoins du trading constituent une priorité produit à court terme mais ne
- [`RULES.md`](RULES.md) — index des règles normatives ;
- [`ROADMAP.md`](ROADMAP.md) — trajectoire globale du projet ;
- [`CHANGELOG.md`](CHANGELOG.md) — synthèse des releases stables ;
- [`docs/000-README.md`](docs/000-README.md) — point d'entrée de la documentation ;
- [`docs/IDEAS.md`](docs/IDEAS.md) — idées et sujets à explorer ;
- [`prompts/000-README.md`](prompts/000-README.md) — prompts de reprise ;

View File

@@ -1,11 +1,9 @@
<!-- file: ROADMAP.md -->
<!-- version: 14 -->
<!-- version: 43 -->
# 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.
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.
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.
## Légende
@@ -15,6 +13,14 @@ Les décisions architecturales négatives ou de prudence n'apparaissent pas comm
- `[C]` — annulé ;
- `[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
- [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
### 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.1.2`Introduire `ksp-logging-lib` comme façade KSP de `tracing`, `tracing-appender` et `tracing-subscriber`.
- [ ] `0.1.3` — Introduire `ksp-config-lib` : documents, profils, résolution, validation et modifications autorisées.
- [ ] `0.1.4` — Introduire `ksp-app-config-desk` pour valider réellement Config et la frontière Tauri.
- [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.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`.
`0.1.1` et `0.1.2` sont fixées. `0.1.3` / `0.1.4` constituent la séquence par défaut : si le `pre.001` de Config démontre que son périmètre doit être scin, une release supplémentaire est insérée et les numéros suivants sont décalés plutôt que de surcharger une release.
### 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.
- [X] `0.2.3` — HTTP Transactions stable : 11/11 wrappers typés publiés, classification `8 Read / 2 WriteSubmission / 1 Simulation`, no-resend ambigu prouvé pour les write submissions, `KSP-TRANSPORT-007` réaudité conforme sur les 37 wrappers HTTP courants, graphes Cargo et deux smokes Devnet validés ; `0.2.4` reprend les 15 Blocks/Economics restants.
- [X] `0.2.4` — HTTP Blocks + Economics stable : 15/15 wrappers `V0_2_4` publiés, surface typed complète à 52/52 méthodes courantes, 14/14 historiques conservées, réaudit SIMD/inventaire final et `KSP-TRANSPORT-007` global validés ; deux smokes Devnet passés avant publication.
- [ ] `0.2.5` — Introduire `ksp-wallet-lib` et le format interopérable `.kspwallet` avec threat model offline, capacités indépendantes VIEW/OWNER, metadata protégées, signature, rotations de key slots et 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.
- [ ] Introduire `ksp-wallet-lib` et `ksp-app-wallet-desk`.
- [ ] Développer la première surface utile de `ksp-interface-lib`.
- [ ] Introduire `ksp-program-api` puis `ksp-program-lib`.
- [ ] Définir `ksp-execution-policy-api` comme contrat de policy commun à plusieurs contextes.
- [ ] 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.
- [ ] Couvrir toutes les méthodes/opérations documentées pour la surface normative ciblée par chaque release.
- [ ] Conserver les méthodes deprecated/obsolete encore fonctionnelles et émettre un `warn` KSP lors de leur utilisation.
- [ ] Implémenter les méthodes unstable/experimental ciblées et émettre un `warn` KSP lors de leur utilisation.
- [ ] Centraliser la metadata de statut des méthodes plutôt que disperser des warnings ad hoc.
- [ ] Garder `ksp-onchain-transport-lib` indépendant de `ksp-config-lib`, du Store et des modèles Program/métier.
## 0.3.x — Données, stockage et acquisition raw
## Architecture de données — progression canonique
- [ ] Introduire `ksp-materializer-api` / `ksp-materializer-lib`.
- [ ] 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.
La chaîne durable cible est :
## 0.4.x — Baseline Solana, SPL et metadata
```text
RAW -> CORE -> DECODE -> SPECIALIZED
```
- [ ] Ajouter progressivement les decoders et `ProgramExecutionPreparer` Core/SPL nécessaires.
- [ ] Ajouter Token, Token-2022, ATA et metadata utiles.
- [ ] Introduire `ksp-offchain-transport-lib` au plus tard au premier besoin externe.
- [ ] 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é.
- **RAW** et **CORE** ne nécessitent aucun décodage Program.
- À 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.
- À 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.
## 0.5.x — Anchor et protocoles trading
## 0.3.x — RAW / acquisition persistée
- [ ] Introduire Anchor.
- [ ] Étendre Meteora par surfaces bornées.
- [ ] Étendre Raydium par surfaces bornées.
- [ ] Ajouter progressivement Pump, Orca, Jupiter, OKX et autres intégrations utiles.
- [ ] Ajouter interfaces, program implementations, materializers, jobs/scénarios/demos nécessaires pour chaque surface.
- [ ] `0.3.1` — Introduire `ksp-store-api` + `ksp-store-lib` avec PostgreSQL de référence et **modèles/persistence RAW uniquement**.
- [ ] `0.3.2` — Étendre `ksp-interface-lib` avec les wires génériques cessaires aux acquisitions et à la future normalisation CORE.
- [ ] `0.3.3` — Introduire `ksp-job-api` et un job de backfill historique concret.
- [ ] `0.3.4` — Introduire une application spécialisée de backfill/inspection RAW.
- [ ] 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.
- [ ] Introduire `ksp-worker-generic-materializer` pour D2 Core -> D3 journal de matérialisation générique.
- [ ] Introduire `ksp-worker-domain-projector` pour D3 -> D4 projections spécialisées ; nom révisable.
- [ ] 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.
- [ ] Étendre `ksp-worker-control-lib` à la gouvernance de plusieurs workers autonomes.
- [ ] 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.
- [ ] Définir la persistence CORE canonique Solana générique.
- [ ] Implémenter `RAW -> CORE` sans decoder Program : blocs, slots, signatures, transactions/messages, comptes, instructions/CPI brutes, logs/meta et relations structurelles.
- [ ] Ajouter replay/backfill RAW -> CORE.
- [ ] Ajouter worker/service CORE.
- [ ] Ajouter l'application de contrôle/inspection CORE utile.
## 0.7.x — Trading Intelligence
## Séries DECODE/SPECIALIZED/EXECUTION — progression verticale
- [ ] Statistiques et métriques.
- [ ] Features et datasets historiques.
- [ ] Signaux et risque.
### Priorité 1 — Solana Core Programs
- [ ] 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.
- [ ] Patterns/anomalies.
- [ ] XGBoost puis autres modèles lorsque les contrats sont stables.
## 0.8.x et suivantes — Trading opérationnel et expansion produits
- [ ] 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.
- [ ] XGBoost puis autres modèles lorsque les données et 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.
- [ ] Étudier plus tard les autres applications Wallet : mobile, extensions navigateur et web.

View File

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

View File

@@ -0,0 +1,64 @@
{
"format_version": 1,
"logs_directory": "${KSP_LOGS_DIRECTORY:-logs}",
"default_profile": "example",
"profiles": [
{
"profile_id": "example",
"default_filter": "info",
"span_events": "new_and_close",
"console": {
"enabled": true,
"output": "stdout",
"ansi": true,
"format": "pretty",
"filter": {
"level": "debug",
"targets": [
"*"
],
"domains": [
"*"
]
}
},
"files": [
{
"output_id": "file.all.info",
"enabled": true,
"path": "ksp-info.log",
"rotation": "daily",
"format": "compact",
"ansi": false,
"filter": {
"level": "info",
"targets": [
"*"
],
"domains": [
"*"
]
}
},
{
"output_id": "file.store.trace",
"enabled": false,
"path": "store/store-trace.jsonl",
"rotation": "hourly",
"format": "json",
"ansi": false,
"filter": {
"level": "trace",
"targets": [
"*"
],
"domains": [
"store"
]
}
}
],
"target_filters": []
}
]
}

View File

@@ -0,0 +1,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,62 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "KSP composite configuration",
"type": "object",
"additionalProperties": false,
"required": ["format_version", "default_profile", "profiles"],
"properties": {
"format_version": {
"const": 1
},
"default_profile": {
"type": "string",
"minLength": 1
},
"profiles": {
"type": "array",
"minItems": 1,
"items": {
"$ref": "#/$defs/composite_profile"
}
}
},
"$defs": {
"composite_profile": {
"type": "object",
"additionalProperties": false,
"required": ["profile_id", "documents"],
"properties": {
"profile_id": {
"type": "string",
"minLength": 1
},
"documents": {
"type": "array",
"minItems": 1,
"items": {
"$ref": "#/$defs/document_reference"
}
}
}
},
"document_reference": {
"type": "object",
"additionalProperties": false,
"required": ["component_id", "file_id"],
"properties": {
"component_id": {
"type": "string",
"minLength": 1
},
"file_id": {
"type": "string",
"pattern": "^cfg\\.std\\.[a-z0-9][a-z0-9._-]*$"
},
"profile_id": {
"type": "string",
"minLength": 1
}
}
}
}
}

View File

@@ -0,0 +1,266 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "urn:ksp:schema:std.logging:v1",
"title": "KSP standard Logging configuration",
"type": "object",
"additionalProperties": false,
"required": [
"format_version",
"logs_directory",
"default_profile",
"profiles"
],
"properties": {
"format_version": {
"const": 1
},
"logs_directory": {
"type": "string",
"minLength": 1
},
"default_profile": {
"$ref": "#/$defs/profileId"
},
"profiles": {
"type": "array",
"minItems": 1,
"items": {
"$ref": "#/$defs/profile"
}
}
},
"$defs": {
"profileId": {
"type": "string",
"pattern": "^[a-z0-9][a-z0-9._-]*$"
},
"level": {
"enum": [
"off",
"error",
"warn",
"info",
"debug",
"trace"
]
},
"format": {
"enum": [
"human",
"compact",
"pretty",
"json"
]
},
"selectorList": {
"type": "array",
"minItems": 1,
"uniqueItems": true,
"items": {
"type": "string",
"minLength": 1
},
"allOf": [
{
"if": {
"contains": {
"const": "*"
}
},
"then": {
"maxItems": 1
}
}
]
},
"targetSelectorList": {
"allOf": [
{
"$ref": "#/$defs/selectorList"
},
{
"items": {
"anyOf": [
{
"const": "*"
},
{
"type": "string",
"pattern": "^ksp-"
}
]
}
}
]
},
"outputFilter": {
"type": "object",
"additionalProperties": false,
"required": [
"level",
"targets",
"domains"
],
"properties": {
"level": {
"$ref": "#/$defs/level"
},
"targets": {
"$ref": "#/$defs/targetSelectorList"
},
"domains": {
"$ref": "#/$defs/selectorList"
}
}
},
"console": {
"type": "object",
"additionalProperties": false,
"required": [
"enabled",
"output",
"ansi",
"format",
"filter"
],
"properties": {
"enabled": {
"type": "boolean"
},
"output": {
"enum": [
"stdout",
"stderr"
]
},
"ansi": {
"type": "boolean"
},
"format": {
"$ref": "#/$defs/format"
},
"filter": {
"$ref": "#/$defs/outputFilter"
}
},
"not": {
"properties": {
"ansi": {
"const": true
},
"format": {
"const": "json"
}
},
"required": [
"ansi",
"format"
]
}
},
"outputId": {
"type": "string",
"pattern": "^[a-z0-9][a-z0-9_-]*(\\.[a-z0-9][a-z0-9_-]*)*$"
},
"file": {
"type": "object",
"additionalProperties": false,
"required": [
"output_id",
"enabled",
"path",
"rotation",
"format",
"ansi",
"filter"
],
"properties": {
"output_id": {
"$ref": "#/$defs/outputId"
},
"enabled": {
"type": "boolean"
},
"path": {
"type": "string",
"minLength": 1
},
"rotation": {
"enum": [
"never",
"hourly",
"daily"
]
},
"format": {
"$ref": "#/$defs/format"
},
"ansi": {
"const": false
},
"filter": {
"$ref": "#/$defs/outputFilter"
}
}
},
"targetFilter": {
"type": "object",
"additionalProperties": false,
"required": [
"target_prefix",
"level"
],
"properties": {
"target_prefix": {
"type": "string",
"pattern": "^ksp-"
},
"level": {
"$ref": "#/$defs/level"
}
}
},
"profile": {
"type": "object",
"additionalProperties": false,
"required": [
"profile_id",
"default_filter",
"span_events",
"console",
"files",
"target_filters"
],
"properties": {
"profile_id": {
"$ref": "#/$defs/profileId"
},
"default_filter": {
"$ref": "#/$defs/level"
},
"span_events": {
"enum": [
"off",
"new_and_close",
"full"
]
},
"console": {
"$ref": "#/$defs/console"
},
"files": {
"type": "array",
"items": {
"$ref": "#/$defs/file"
}
},
"target_filters": {
"type": "array",
"items": {
"$ref": "#/$defs/targetFilter"
}
}
}
}
}
}

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

98
config/std.logging.json Normal file
View File

@@ -0,0 +1,98 @@
{
"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": "info",
"targets": [
"*"
],
"domains": [
"*"
]
}
},
"files": [
{
"output_id": "file.all.info",
"enabled": true,
"path": "info/ksp-info.log",
"rotation": "daily",
"format": "human",
"ansi": false,
"filter": {
"level": "info",
"targets": [
"*"
],
"domains": [
"*"
]
}
},
{
"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",
"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": "info"
},
{
"target_prefix": "ksp-logging-lib",
"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

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

View File

@@ -0,0 +1,90 @@
<!-- file: crates/ksp-config-lib/README.md -->
<!-- version: 5 -->
# ksp-config-lib
`ksp-config-lib` est le propriétaire unique de la configuration applicative KSP.
La crate centralise les documents JSON, leurs schemas, les profils et compositions, les variables d'environnement `KSP_*` / `KSPB_*`, le fichier `.env`, la résolution effective et les mutations persistantes explicitement autorisées.
## Responsabilités
`ksp-config-lib` possède :
- le bootstrap non récursif `config/` / `config/schemas/` et les overrides `--cfgpath` / `--schemapath` ;
- le registre logique `file_id -> filename`, 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 ;
- les invariants sémantiques KSP des documents connus ;
- les globals, `default_profile`, profils nommés et leur provenance ;
- les compositions génériques par `file_id`, sans dépendance à un filename physique ;
- le snapshot des variables process KSP/KSPB et la lecture de `./.env` ;
- la priorité `process > .env > fallback > missing` ;
- les placeholders `${NAME}` et `${NAME:-fallback}` ;
- la classification `Public`, `Internal`, `Secret` ;
- les représentations réelle et sûre/redacted ainsi que la provenance des valeurs résolues ;
- l'adapter du document Logging effectif vers `ksp_logging_lib::LoggingSettings` ;
- 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 audits workspace empêchant les bypass d'ownership Config et les oublis dans `.env.example`.
## Ressources gérées
Le registre par défaut connaît :
```text
cfg.std.logging -> config/std.logging.json
cfg.std.transport -> config/std.transport.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.
Le fichier local d'environnement est :
```text
./.env
```
Il n'est ni versionné ni livré. Le dépôt maintient `/.env.example` comme inventaire versionné des variables runtime utilisées. Toute nouvelle variable KSP/KSPB concrète doit y être ajoutée avec un commentaire d'usage dans le même delta que sa première utilisation.
## Frontières
Les autres crates et applications KSP ne doivent pas :
- lire directement les variables applicatives `KSP_*` / `KSPB_*` ;
- parser ou écrire directement `.env` ;
- ouvrir directement les documents Config connus par leur filename physique ;
- réimplémenter la sélection de profils, les compositions ou les placeholders ;
- reconstruire elles-mêmes la configuration Logging ou Transport depuis le JSON.
`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`, `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`.
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
Un secret reste accessible au runtime ou au management lorsqu'un consumer autorisé en a réellement besoin, mais les vues ordinaires utilisent la représentation sûre.
Les méthodes `reveal_*` constituent un opt-in explicite au réel. L'authentification/autorisation de l'utilisateur humain appartient à l'application appelante et les valeurs retournées par ces méthodes ne doivent jamais être journalisées.
Le document Logging refuse les valeurs de sensibilité `Secret` dans sa configuration effective. 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
- [`USAGE.md`](USAGE.md) — construction du moteur, résolution runtime et management ;
- [`TODO.md`](TODO.md) — points explicitement différés après `0.1.3` ;
- [`../../docs/plans/005-V0_1_3_CONFIG_FOUNDATION_PLAN.md`](../../docs/plans/005-V0_1_3_CONFIG_FOUNDATION_PLAN.md) — plan historique détaillé de la fondation Config ;
- [`../../config/std.logging.json`](../../config/std.logging.json) — 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.

View File

@@ -0,0 +1,48 @@
<!-- file: crates/ksp-config-lib/TODO.md -->
<!-- version: 4 -->
# TODO ksp-config-lib
## État de clôture `0.1.3`
Aucun TODO fonctionnel bloquant n'est ouvert pour la fondation Config `0.1.3`.
Les responsabilités prévues pour cette release sont implémentées et couvertes par les tests : bootstrap, registre `file_id`, JSON/JSON Schema, profils, composites, environnement process/`.env`, placeholders, sensibilité/provenance, adapter Logging, management/persistence et audits d'ownership.
## 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` :
- frontière Tauri et DTO TS-RS applicatifs ;
- affichage des sources et diagnostics Config ;
- sélection/inspection des profils ;
- affichage desired/effective/shadow des variables ;
- actions explicites de reveal de secrets avec contrôle d'autorisation côté application ;
- édition/sauvegarde de `std.logging.json` via `ConfigManagement` ;
- édition de `.env` via `ConfigManagement` ;
- orchestration réelle `Config -> LoggingSettings -> initialize/reinitialize` avec `LoggingGuard` possédé par l'application ;
- validation UX des erreurs de source invalide, des modifications non effectives car masquées par le process et des besoins de reload.
Ces points ne nécessitent pas de duplication de logique dans `ksp-config-lib`; toute lacune réelle révélée par l'application ouvrira un delta Config explicite.
## 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
Les capacités suivantes sont différées jusqu'à l'apparition de composants réels :
- documents `std.<domain>.json` supplémentaires et schemas associés ;
- descriptors `cfg.composite.<consumer>` pour de vrais consumers ;
- contrats typés de management supplémentaires pour les nouveaux documents ;
- watcher filesystem/reload automatique si une application ou un service démontre le besoin ;
- intégration éventuelle d'un secrets manager externe.
Ne pas introduire par anticipation un JSON patch arbitraire, un watcher générique, un service distribué de configuration ou un chiffrement maison de `.env`.

View File

@@ -0,0 +1,345 @@
<!-- file: crates/ksp-config-lib/USAGE.md -->
<!-- version: 5 -->
# Utilisation de ksp-config-lib
## 1. Bootstrap et moteur documentaire
Config doit interpréter ses propres arguments de bootstrap avant toute lecture de document :
```rust
let args: std::vec::Vec<std::ffi::OsString> = std::env::args_os().collect();
let bootstrap = match ksp_config_lib::ConfigBootstrapOptions::from_args(args.as_slice()) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let registry = match ksp_config_lib::ConfigFileRegistry::from_args(args.as_slice()) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let engine = ksp_config_lib::ConfigDocumentEngine::new(bootstrap, registry);
```
Les arguments compris par Config sont :
```text
--cfgpath=/path/to/config
--schemapath=/path/to/schemas
--filemap=cfg.std.logging=my-logging.json
--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.
### 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
Les consumers utilisent un `file_id` logique :
```rust
let file_id = match ksp_config_lib::ConfigFileId::new(ksp_config_lib::FILE_ID_STD_LOGGING) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let document = engine.load_validated_document(&file_id);
```
Le moteur résout le path physique via le registre, charge le schema associé, valide le schema lui-même, valide l'instance puis applique les invariants sémantiques KSP.
## 3. Environnement effectif
`ConfigEnvironment::load()` capture les variables process KSP/KSPB et lit `./.env` :
```rust
let environment = match ksp_config_lib::ConfigEnvironment::load() {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
```
La priorité est :
```text
process environment > .env > fallback > missing
```
Une chaîne vide explicitement présente est une valeur définie ; elle ne provoque pas l'utilisation du fallback.
Exemples de placeholders :
```text
${KSP_LOGS_DIRECTORY}
${KSP_LOGS_DIRECTORY:-logs}
```
Pour conserver la sensibilité et la provenance, préférer les variantes détaillées :
```rust
let resolved = environment.resolve_text_detailed("${KSP_SECRET_EXAMPLE}");
```
`ResolvedConfigText` / `ResolvedConfigJson` séparent valeur réelle et valeur sûre. Une représentation `Debug` ne doit pas révéler le réel d'un secret.
## 4. Construire Logging depuis Config
Le chemin normal consiste à charger le profil Logging, résoudre l'environnement puis construire directement le contrat Logging public :
```rust
let resolved = match engine.load_resolved_logging_config(std::option::Option::None, &environment) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let settings = resolved.into_settings();
let initialized = ksp_logging_lib::initialize(&settings);
let mut logging_guard = match initialized {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
```
L'application/service possède `logging_guard`. Config ne conserve pas de singleton Logging.
Un `logs_directory` relatif est ancré sur le current working directory du processus. Un path absolu est conservé. Une valeur explicite invalide produit une erreur effective : elle ne retombe pas silencieusement sur le fallback du placeholder.
Les `files[].path` restent relatifs sous le root Logging, y compris après interpolation.
### 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
Pour un document standard profilé :
```rust
let profile = engine.load_resolved_profile(&file_id, std::option::Option::None);
```
`None` utilise le `default_profile`; `Some("profile_id")` impose un profil explicite.
Un composite référence les documents par `file_id`, jamais par filename. `load_resolved_composite(...)` conserve chaque `ResolvedConfigProfile` composant et sa provenance plutôt que d'aplatir plusieurs domaines dans une map ambiguë.
## 6. Management de `std.logging.json`
Une application de management construit la façade à partir d'un moteur :
```rust
let management = ksp_config_lib::ConfigManagement::new(engine);
let document = match management.load_logging_document() {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
```
Le type `LoggingConfigDocument` et ses sous-structures exposent des setters/mutators typés. Après modification, la sauvegarde :
```rust
let saved = management.save_logging_document(&document);
```
valide le candidat complet avant toute substitution du fichier. Un candidat invalide ne remplace pas la source existante.
`read_source(file_id)` reste disponible pour une UI de réparation : il peut lire le texte brut d'un document enregistré même lorsque son JSON ou son schema est invalide. Il n'ouvre pas un path arbitraire.
## 7. Management de `.env`
Les rapports ordinaires sont sûrs :
```rust
let report = management.environment_report();
```
Ils distinguent notamment valeur souhaitée `.env`, valeur effective, source et shadowing process sans exposer un secret réel.
L'accès au réel est volontairement explicite :
```rust
let effective = management.reveal_effective_environment_value("KSP_SECRET_EXAMPLE");
let persisted = management.reveal_dotenv_value("KSP_SECRET_EXAMPLE");
```
Une application doit contrôler l'autorisation de l'utilisateur avant ces appels et ne jamais journaliser les valeurs retournées.
Les mutations persistantes utilisent :
```rust
let changed = management.set_dotenv_value("KSP_LOGS_DIRECTORY", "logs");
let removed = management.remove_dotenv_value("KSP_LOGS_DIRECTORY");
```
Elles n'altèrent jamais l'environnement hérité du processus. Une valeur process peut donc masquer une modification `.env`; `ConfigEnvironmentChangeReport` distingue `source_changed`, `effective_changed`, `shadowed_by_process_environment` et `reload_required`.
Sur Unix, un nouveau `.env` est créé avec des permissions privées `0600`; les permissions existantes sont préservées lors des remplacements atomiques.
## 8. `.env.example`
`/.env.example` est l'inventaire versionné. `/.env` reste local et ignoré.
Toute nouvelle variable runtime concrète `KSP_*` / `KSPB_*` introduite dans le code ou les documents Config doit être ajoutée à `.env.example` avec un commentaire expliquant son usage. Les audits `ksp-config-lib/tests/ownership.rs` font échouer `cargo test` lorsqu'une clé concrète est oubliée.
## 9. Frontière Tauri
Une application Tauri doit appeler les APIs ci-dessus via ses commandes/DTO applicatifs. Elle ne lit ni JSON ni `.env` directement et ne résout jamais elle-même les placeholders.
Les valeurs `Secret` ne doivent pas être incluses par défaut dans les DTO publics. Une action UI explicitement autorisée peut appeler une méthode `reveal_*` et transporter le résultat par un DTO spécifique, sans log ni diagnostic contenant la valeur réelle.
## 10. Index de la surface publique
Ce guide reste volontairement indépendant des numéros de release. Les contrats publics sont regroupés ci-dessous par usage ; les constantes de noms/erreurs accompagnent les mêmes familles et ne constituent pas des workflows séparés.
| Famille publique | Contrats principaux | Exemple |
|------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------|--------------------------|
| Bootstrap | `ConfigBootstrapOptions`, `ARG_CFG_PATH`, `ARG_SCHEMA_PATH`, `DEFAULT_CFG_PATH`, `DEFAULT_SCHEMA_PATH` | §1 |
| Registre logique | `ConfigFileRegistry`, `ConfigFileId`, `ConfigFileDescriptor`, `ConfigFileKind`, `ARG_FILE_MAP`, constantes `FILE_ID_*` / `DEFAULT_*_FILENAME` | §12 |
| Documents | `ConfigDocumentEngine`, `ConfigJsonDocument` | §2 |
| Profils | `ResolvedConfigProfile`, `ConfigProfileSelectionSource`, `ConfigValueOrigin` | §5 |
| Composites | `ResolvedConfigComposite`, `ResolvedCompositeComponent` | §5 |
| Environnement | `ConfigEnvironment`, `ConfigEnvironmentSource`, `ConfigEnvironmentValue`, `DEFAULT_DOTENV_PATH`, `DEFAULT_DOTENV_EXAMPLE_PATH` | §3, §78 |
| Sensibilité/provenance | `ConfigSensitivity`, `ConfigValueProvenance`, `ResolvedConfigText`, `ResolvedConfigJson`, `REDACTED_CONFIG_VALUE` | §3 |
| Logging effectif | `ResolvedLoggingConfig` | §4 |
| Transport effectif | `ResolvedTransportConfig` | §4.1 |
| Management | `ConfigManagement`, `ConfigManagedSource`, `ConfigDocumentChangeReport`, `ConfigEnvironmentReport`, `ConfigEnvironmentChangeReport` | §67 |
| Source Logging typée | `LoggingConfigDocument`, `LoggingProfileConfig`, `LoggingConsoleConfig`, `LoggingFileConfig`, `LoggingOutputFilterConfig`, `LoggingTargetFilterConfig` | §6 et exemple ci-dessous |
| Erreurs Config | constantes `ERROR_CODE_*` réexportées par la crate | exemple ci-dessous |
### 10.1 Modifier une configuration Logging typée
Les getters permettent d'inspecter la source ; les setters et vues `*_mut()` permettent de construire un candidat avant validation/persistence :
```rust
let mut document = match management.load_logging_document() {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
document.set_logs_directory("${KSP_LOGS_DIRECTORY:-logs}");
if let std::option::Option::Some(profile) = document.profiles_mut().first_mut() {
profile.set_default_filter("debug");
profile.console_mut().set_enabled(true);
profile.console_mut().filter_mut().set_level("info");
profile.console_mut().filter_mut().domains_mut().push("config".to_owned());
}
let saved = match management.save_logging_document(&document) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
if saved.source_changed() && saved.reload_required() {
// The application decides when/how to reload the affected runtime consumer.
}
```
La construction depuis zéro utilise les constructeurs publics `LoggingConfigDocument::new`, `LoggingProfileConfig::new`, `LoggingConsoleConfig::new`, `LoggingFileConfig::new`, `LoggingOutputFilterConfig::new` et `LoggingTargetFilterConfig::new`. Les mêmes contraintes schema/sémantiques sont appliquées au moment de `save_logging_document()`.
### 10.2 Inspecter et réparer un source enregistré sans contourner Config
```rust
let source = match management.read_source(&file_id) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let logical_id = source.file_id();
let managed_path = source.path();
let raw_content = source.content();
```
Cette 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`
```rust
let reports = match management.environment_report() {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
for report in reports {
let name = report.variable_name();
let sensitivity = report.sensitivity();
let desired = report.desired_safe_value();
let effective = report.effective_safe_value();
let source = report.effective_source();
let shadowed = report.shadowed_by_process_environment();
let _ = (name, sensitivity, desired, effective, source, shadowed);
}
```
Après une mutation, `ConfigEnvironmentChangeReport` expose `source_changed()`, `effective_changed()`, `shadowed_by_process_environment()` et `reload_required()`.
### 10.4 Distinguer un code d'erreur Config
Les codes publics permettent à une UI/service de brancher sa logique sans parser le texte du message :
```rust
let loaded = engine.load_validated_document(&file_id);
if let std::result::Result::Err(error) = loaded {
if error.code() == ksp_config_lib::ERROR_CODE_SCHEMA_VALIDATION_FAILED {
// Present a schema-specific diagnostic path to the caller.
}
return std::result::Result::Err(error);
}
```
Le message/context d'erreur reste destiné au diagnostic ; l'identité machine-readable passe par `ErrorCode`.

View File

@@ -0,0 +1,205 @@
// file: crates/ksp-config-lib/src/bootstrap.rs
// version: 1
/// Default root containing KSP runtime configuration documents.
pub const DEFAULT_CFG_PATH: &str = "config";
/// Default root containing KSP JSON schemas.
pub const DEFAULT_SCHEMA_PATH: &str = "config/schemas";
/// Bootstrap argument used to replace the configuration document root.
pub const ARG_CFG_PATH: &str = "--cfgpath";
/// Bootstrap argument used to replace the schema root.
pub const ARG_SCHEMA_PATH: &str = "--schemapath";
/// Non-recursive bootstrap options required before Config can resolve any managed document.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ConfigBootstrapOptions {
cfg_path: std::path::PathBuf,
schema_path: std::path::PathBuf,
}
impl ConfigBootstrapOptions {
/// Creates bootstrap options using the KSP hardcoded configuration and schema roots.
pub fn defaults() -> ksp_core_lib::Result<Self> {
return Self::from_paths(crate::DEFAULT_CFG_PATH, crate::DEFAULT_SCHEMA_PATH);
}
/// Creates bootstrap options from explicit programmatic configuration and schema roots.
pub fn from_paths(
cfg_path: impl std::convert::Into<std::path::PathBuf>,
schema_path: impl std::convert::Into<std::path::PathBuf>,
) -> ksp_core_lib::Result<Self> {
let cfg_path = validate_bootstrap_path(crate::ARG_CFG_PATH, cfg_path.into());
let cfg_path = match cfg_path {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let schema_path = validate_bootstrap_path(crate::ARG_SCHEMA_PATH, schema_path.into());
return match schema_path {
std::result::Result::Ok(value) => std::result::Result::Ok(Self { cfg_path, schema_path: value }),
std::result::Result::Err(error) => std::result::Result::Err(error),
};
}
/// Parses the KSP-owned bootstrap path arguments from a raw process argument slice.
///
/// Both `--cfgpath=value` / `--cfgpath value` and `--schemapath=value` / `--schemapath value` are accepted. Unrelated arguments are ignored so an
/// application can pass its complete argument vector. When the same bootstrap path is specified more than once, the last explicit value wins.
pub fn from_args(args: &[std::ffi::OsString]) -> ksp_core_lib::Result<Self> {
let mut options = Self::defaults_unchecked();
let mut index: usize = 0;
while index < args.len() {
let argument = &args[index];
if argument.as_os_str() == std::ffi::OsStr::new(crate::ARG_CFG_PATH) {
let parsed = parse_separate_path_argument(args, index, crate::ARG_CFG_PATH);
match parsed {
std::result::Result::Ok((path, next_index)) => {
options.cfg_path = path;
index = next_index;
},
std::result::Result::Err(error) => return std::result::Result::Err(error),
}
} else if argument.as_os_str() == std::ffi::OsStr::new(crate::ARG_SCHEMA_PATH) {
let parsed = parse_separate_path_argument(args, index, crate::ARG_SCHEMA_PATH);
match parsed {
std::result::Result::Ok((path, next_index)) => {
options.schema_path = path;
index = next_index;
},
std::result::Result::Err(error) => return std::result::Result::Err(error),
}
} else {
let inline = parse_inline_path_argument(argument);
match inline {
std::option::Option::Some((kind, path)) => match kind {
BootstrapPathKind::Config => options.cfg_path = path,
BootstrapPathKind::Schema => options.schema_path = path,
},
std::option::Option::None => {},
}
}
index += 1;
}
return Self::from_paths(options.cfg_path, options.schema_path);
}
/// Returns the root used for managed runtime configuration documents.
#[must_use]
pub fn cfg_path(&self) -> &std::path::Path {
return self.cfg_path.as_path();
}
/// Returns the root used for managed JSON schemas.
#[must_use]
pub fn schema_path(&self) -> &std::path::Path {
return self.schema_path.as_path();
}
/// Replaces the configuration document root after applying bootstrap path validation.
pub fn with_cfg_path(self, path: impl std::convert::Into<std::path::PathBuf>) -> ksp_core_lib::Result<Self> {
let validated = validate_bootstrap_path(crate::ARG_CFG_PATH, path.into());
return match validated {
std::result::Result::Ok(cfg_path) => std::result::Result::Ok(Self { cfg_path, schema_path: self.schema_path }),
std::result::Result::Err(error) => std::result::Result::Err(error),
};
}
/// Replaces the schema root after applying bootstrap path validation.
pub fn with_schema_path(self, path: impl std::convert::Into<std::path::PathBuf>) -> ksp_core_lib::Result<Self> {
let validated = validate_bootstrap_path(crate::ARG_SCHEMA_PATH, path.into());
return match validated {
std::result::Result::Ok(schema_path) => std::result::Result::Ok(Self { cfg_path: self.cfg_path, schema_path }),
std::result::Result::Err(error) => std::result::Result::Err(error),
};
}
fn defaults_unchecked() -> Self {
return Self {
cfg_path: std::path::PathBuf::from(crate::DEFAULT_CFG_PATH),
schema_path: std::path::PathBuf::from(crate::DEFAULT_SCHEMA_PATH),
};
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum BootstrapPathKind {
Config,
Schema,
}
fn parse_inline_path_argument(argument: &std::ffi::OsStr) -> std::option::Option<(BootstrapPathKind, std::path::PathBuf)> {
let text = match argument.to_str() {
std::option::Option::Some(value) => value,
std::option::Option::None => return std::option::Option::None,
};
let cfg_prefix = "--cfgpath=";
let schema_prefix = "--schemapath=";
if let std::option::Option::Some(value) = text.strip_prefix(cfg_prefix) {
return std::option::Option::Some((BootstrapPathKind::Config, std::path::PathBuf::from(value)));
}
if let std::option::Option::Some(value) = text.strip_prefix(schema_prefix) {
return std::option::Option::Some((BootstrapPathKind::Schema, std::path::PathBuf::from(value)));
}
return std::option::Option::None;
}
fn parse_separate_path_argument(args: &[std::ffi::OsString], index: usize, argument_name: &'static str) -> ksp_core_lib::Result<(std::path::PathBuf, usize)> {
let value_index = index + 1;
if value_index >= args.len() {
return std::result::Result::Err(missing_argument_value_error(argument_name));
}
let value = &args[value_index];
let option_like = match value.to_str() {
std::option::Option::Some(text) => text.starts_with("--"),
std::option::Option::None => false,
};
if option_like {
return std::result::Result::Err(missing_argument_value_error(argument_name));
}
return std::result::Result::Ok((std::path::PathBuf::from(value.as_os_str()), value_index));
}
fn validate_bootstrap_path(argument_name: &'static str, path: std::path::PathBuf) -> ksp_core_lib::Result<std::path::PathBuf> {
if path.as_os_str().is_empty() {
return std::result::Result::Err(invalid_path_error(argument_name, &path, "path is empty"));
}
let metadata = std::fs::metadata(path.as_path());
return match metadata {
std::result::Result::Ok(value) => {
if value.is_dir() {
std::result::Result::Ok(path)
} else {
std::result::Result::Err(invalid_path_error(argument_name, &path, "existing path is not a directory"))
}
},
std::result::Result::Err(error) => {
if error.kind() == std::io::ErrorKind::NotFound {
std::result::Result::Ok(path)
} else {
std::result::Result::Err(invalid_path_source_error(argument_name, &path, error))
}
},
};
}
fn missing_argument_value_error(argument_name: &'static str) -> ksp_core_lib::Error {
return ksp_core_lib::Error::new(crate::ERROR_CODE_BOOTSTRAP_ARGUMENT_MISSING_VALUE, "Config bootstrap argument requires a path value")
.with_context("argument", argument_name);
}
fn invalid_path_error(argument_name: &'static str, path: &std::path::Path, reason: &'static str) -> ksp_core_lib::Error {
return ksp_core_lib::Error::new(crate::ERROR_CODE_BOOTSTRAP_INVALID_PATH, "Config bootstrap path is invalid")
.with_context("argument", argument_name)
.with_context("path", path.to_string_lossy().into_owned())
.with_context("reason", reason);
}
fn invalid_path_source_error(argument_name: &'static str, path: &std::path::Path, source: std::io::Error) -> ksp_core_lib::Error {
return ksp_core_lib::Error::new(crate::ERROR_CODE_BOOTSTRAP_INVALID_PATH, "Config bootstrap path cannot be inspected")
.with_context("argument", argument_name)
.with_context("path", path.to_string_lossy().into_owned())
.with_source(source);
}
#[cfg(test)]
#[path = "../unit_tests/bootstrap.rs"]
mod tests;

View File

@@ -0,0 +1,309 @@
// file: crates/ksp-config-lib/src/composite.rs
// version: 1
/// One resolved document component selected by a composite profile.
#[derive(Clone, Debug, PartialEq)]
pub struct ResolvedCompositeComponent {
component_id: String,
resolved: crate::ResolvedConfigProfile,
}
impl ResolvedCompositeComponent {
/// Returns the component identifier unique inside the selected composite profile.
#[must_use]
pub fn component_id(&self) -> &str {
return self.component_id.as_str();
}
/// Returns the referenced standard document after global/profile resolution.
#[must_use]
pub const fn resolved(&self) -> &crate::ResolvedConfigProfile {
return &self.resolved;
}
}
/// Validated composite document resolved to one composite profile and all of its referenced standard documents.
#[derive(Clone, Debug, PartialEq)]
pub struct ResolvedConfigComposite {
file_id: crate::ConfigFileId,
path: std::path::PathBuf,
profile_id: String,
selection_source: crate::ConfigProfileSelectionSource,
components: std::collections::BTreeMap<String, ResolvedCompositeComponent>,
}
impl ResolvedConfigComposite {
/// Returns the logical composite file identifier.
#[must_use]
pub fn file_id(&self) -> &crate::ConfigFileId {
return &self.file_id;
}
/// Returns the physical path of the validated composite source.
#[must_use]
pub fn path(&self) -> &std::path::Path {
return self.path.as_path();
}
/// Returns the selected composite profile identifier.
#[must_use]
pub fn profile_id(&self) -> &str {
return self.profile_id.as_str();
}
/// Returns whether the composite profile came from `default_profile` or an explicit caller selection.
#[must_use]
pub const fn selection_source(&self) -> crate::ConfigProfileSelectionSource {
return self.selection_source;
}
/// Returns all resolved components keyed by their composite-local `component_id`.
#[must_use]
pub const fn components(&self) -> &std::collections::BTreeMap<String, ResolvedCompositeComponent> {
return &self.components;
}
/// Returns one resolved component by its composite-local identifier.
#[must_use]
pub fn component(&self, component_id: &str) -> std::option::Option<&ResolvedCompositeComponent> {
return self.components.get(component_id);
}
}
impl crate::ConfigDocumentEngine {
/// Loads, validates and resolves one registered composite Config document.
///
/// Composite descriptors are introduced only when a concrete consumer exists. The generic contract and schema are available from `0.1.3-pre.009` onward.
/// Passing `None` selects the composite's `default_profile`; passing `Some(profile_id)` selects a composite profile explicitly.
pub fn load_resolved_composite(
&self,
file_id: &crate::ConfigFileId,
requested_profile: std::option::Option<&str>,
) -> ksp_core_lib::Result<ResolvedConfigComposite> {
let document = self.load_validated_document(file_id);
let document = match document {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
if !document.file_id().as_str().starts_with("cfg.composite.") {
return std::result::Result::Err(
ksp_core_lib::Error::new(crate::ERROR_CODE_COMPOSITE_REFERENCE_INVALID, "requested Config document is not a composite")
.with_context("file_id", document.file_id().as_str()),
);
}
return resolve_composite_document(self, &document, requested_profile);
}
}
#[derive(serde::Deserialize)]
#[serde(deny_unknown_fields)]
struct CompositeDocumentSource {
format_version: u32,
default_profile: String,
profiles: std::vec::Vec<CompositeProfileSource>,
}
#[derive(serde::Deserialize)]
#[serde(deny_unknown_fields)]
struct CompositeProfileSource {
profile_id: String,
documents: std::vec::Vec<CompositeDocumentReferenceSource>,
}
#[derive(serde::Deserialize)]
#[serde(deny_unknown_fields)]
struct CompositeDocumentReferenceSource {
component_id: String,
file_id: String,
profile_id: std::option::Option<String>,
}
pub(crate) fn validate_composite_document_contract(engine: &crate::ConfigDocumentEngine, document: &crate::ConfigJsonDocument) -> ksp_core_lib::Result<()> {
if !document.file_id().as_str().starts_with("cfg.composite.") {
return std::result::Result::Ok(());
}
let source = parse_composite_source(document);
let source = match source {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
if source.format_version != 1 {
return std::result::Result::Err(composite_semantic_error(document, "unsupported composite format_version"));
}
if source.default_profile.trim().is_empty() {
return std::result::Result::Err(composite_semantic_error(document, "composite default_profile must not be empty"));
}
for (profile_index, profile) in source.profiles.iter().enumerate() {
if profile.profile_id.trim().is_empty() {
return std::result::Result::Err(
composite_semantic_error(document, "composite profile_id must not be empty").with_context("profile_index", profile_index.to_string()),
);
}
let mut component_ids = std::collections::BTreeSet::<String>::new();
for (component_index, reference) in profile.documents.iter().enumerate() {
if component_ids.contains(reference.component_id.as_str()) {
return std::result::Result::Err(
composite_semantic_error(document, "component_id values must be unique inside one composite profile")
.with_context("profile_id", profile.profile_id.as_str())
.with_context("component_index", component_index.to_string())
.with_context("component_id", reference.component_id.as_str()),
);
}
component_ids.insert(reference.component_id.clone());
let validation = validate_reference(engine, document, profile, reference, component_index);
if let std::result::Result::Err(error) = validation {
return std::result::Result::Err(error);
}
}
}
return std::result::Result::Ok(());
}
fn resolve_composite_document(
engine: &crate::ConfigDocumentEngine,
document: &crate::ConfigJsonDocument,
requested_profile: std::option::Option<&str>,
) -> ksp_core_lib::Result<ResolvedConfigComposite> {
let source = parse_composite_source(document);
let source = match source {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let (selected_profile_id, selection_source) = match requested_profile {
std::option::Option::Some(value) => (value, crate::ConfigProfileSelectionSource::Explicit),
std::option::Option::None => (source.default_profile.as_str(), crate::ConfigProfileSelectionSource::DefaultProfile),
};
let mut selected: std::option::Option<&CompositeProfileSource> = std::option::Option::None;
for profile in &source.profiles {
if profile.profile_id == selected_profile_id {
selected = std::option::Option::Some(profile);
break;
}
}
let selected = match selected {
std::option::Option::Some(value) => value,
std::option::Option::None => {
return std::result::Result::Err(
ksp_core_lib::Error::new(crate::ERROR_CODE_PROFILE_NOT_FOUND, "requested composite Config profile does not exist")
.with_context("file_id", document.file_id().as_str())
.with_context("path", document.path().to_string_lossy().into_owned())
.with_context("profile_id", selected_profile_id),
);
},
};
let mut components = std::collections::BTreeMap::<String, ResolvedCompositeComponent>::new();
for reference in &selected.documents {
let file_id = crate::ConfigFileId::new(reference.file_id.as_str());
let file_id = match file_id {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let resolved = crate::profile::load_resolved_profile_with_source(
engine,
&file_id,
reference.profile_id.as_deref(),
crate::ConfigProfileSelectionSource::Composite,
);
let resolved = match resolved {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let component = ResolvedCompositeComponent { component_id: reference.component_id.clone(), resolved };
components.insert(reference.component_id.clone(), component);
}
return std::result::Result::Ok(ResolvedConfigComposite {
file_id: document.file_id().clone(),
path: document.path().to_path_buf(),
profile_id: selected_profile_id.to_owned(),
selection_source,
components,
});
}
fn validate_reference(
engine: &crate::ConfigDocumentEngine,
document: &crate::ConfigJsonDocument,
profile: &CompositeProfileSource,
reference: &CompositeDocumentReferenceSource,
component_index: usize,
) -> ksp_core_lib::Result<()> {
if reference.component_id.trim().is_empty() {
return std::result::Result::Err(composite_reference_error(document, profile, reference, component_index, "component_id must not be empty"));
}
let file_id = crate::ConfigFileId::new(reference.file_id.as_str());
let file_id = match file_id {
std::result::Result::Ok(value) => value,
std::result::Result::Err(_) => {
return std::result::Result::Err(composite_reference_error(document, profile, reference, component_index, "referenced file_id is invalid"));
},
};
if !file_id.as_str().starts_with("cfg.std.") {
return std::result::Result::Err(composite_reference_error(
document,
profile,
reference,
component_index,
"composite references must target standard Config document file_ids",
));
}
let descriptor = engine.registry().descriptor(&file_id);
let descriptor = match descriptor {
std::result::Result::Ok(value) => value,
std::result::Result::Err(_) => {
return std::result::Result::Err(composite_reference_error(document, profile, reference, component_index, "referenced file_id is not registered"));
},
};
if descriptor.kind() != crate::ConfigFileKind::Config {
return std::result::Result::Err(composite_reference_error(
document,
profile,
reference,
component_index,
"referenced file_id is not a Config document",
));
}
let resolved =
crate::profile::load_resolved_profile_with_source(engine, &file_id, reference.profile_id.as_deref(), crate::ConfigProfileSelectionSource::Composite);
if let std::result::Result::Err(error) = resolved {
return std::result::Result::Err(error);
}
return std::result::Result::Ok(());
}
fn parse_composite_source(document: &crate::ConfigJsonDocument) -> ksp_core_lib::Result<CompositeDocumentSource> {
let source = serde_json::from_value::<CompositeDocumentSource>(document.value().clone());
return match source {
std::result::Result::Ok(value) => std::result::Result::Ok(value),
std::result::Result::Err(error) => std::result::Result::Err(
composite_semantic_error(document, "schema-valid composite document cannot be decoded into the KSP source contract").with_source(error),
),
};
}
fn composite_semantic_error(document: &crate::ConfigJsonDocument, reason: &'static str) -> ksp_core_lib::Error {
return ksp_core_lib::Error::new(crate::ERROR_CODE_DOCUMENT_SEMANTIC_INVALID, "Config composite violates KSP semantic invariants")
.with_context("file_id", document.file_id().as_str())
.with_context("path", document.path().to_string_lossy().into_owned())
.with_context("reason", reason);
}
fn composite_reference_error(
document: &crate::ConfigJsonDocument,
profile: &CompositeProfileSource,
reference: &CompositeDocumentReferenceSource,
component_index: usize,
reason: &'static str,
) -> ksp_core_lib::Error {
return ksp_core_lib::Error::new(crate::ERROR_CODE_COMPOSITE_REFERENCE_INVALID, "Config composite contains an invalid document reference")
.with_context("file_id", document.file_id().as_str())
.with_context("path", document.path().to_string_lossy().into_owned())
.with_context("profile_id", profile.profile_id.as_str())
.with_context("component_index", component_index.to_string())
.with_context("component_id", reference.component_id.as_str())
.with_context("referenced_file_id", reference.file_id.as_str())
.with_context("reason", reason);
}
#[cfg(test)]
#[path = "../unit_tests/composite.rs"]
mod tests;

View File

@@ -0,0 +1,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

@@ -0,0 +1,507 @@
// file: crates/ksp-config-lib/src/document.rs
// version: 5
/// A Config-managed JSON document that has passed syntax, schema and current semantic validation.
#[derive(Clone, Debug, PartialEq)]
pub struct ConfigJsonDocument {
file_id: crate::ConfigFileId,
path: std::path::PathBuf,
value: serde_json::Value,
}
impl ConfigJsonDocument {
/// Returns the logical Config file identifier used to load this document.
#[must_use]
pub fn file_id(&self) -> &crate::ConfigFileId {
return &self.file_id;
}
/// Returns the resolved physical path from which this document was loaded.
#[must_use]
pub fn path(&self) -> &std::path::Path {
return self.path.as_path();
}
/// Returns the validated JSON value without transferring Config ownership of file I/O or validation.
#[must_use]
pub fn value(&self) -> &serde_json::Value {
return &self.value;
}
}
/// Generic JSON/JSON Schema engine owned by `ksp-config-lib`.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ConfigDocumentEngine {
bootstrap: crate::ConfigBootstrapOptions,
registry: crate::ConfigFileRegistry,
}
impl ConfigDocumentEngine {
/// Creates a document engine from already validated bootstrap options and a logical file registry.
#[must_use]
pub fn new(bootstrap: crate::ConfigBootstrapOptions, registry: crate::ConfigFileRegistry) -> Self {
return Self { bootstrap, registry };
}
/// Returns the bootstrap roots used by this engine.
#[must_use]
pub const fn bootstrap(&self) -> &crate::ConfigBootstrapOptions {
return &self.bootstrap;
}
/// Returns the logical file registry used by this engine.
#[must_use]
pub const fn registry(&self) -> &crate::ConfigFileRegistry {
return &self.registry;
}
/// Loads one registered Config document and validates it against its registered JSON Schema and current KSP semantic invariants.
pub fn load_validated_document(&self, file_id: &crate::ConfigFileId) -> ksp_core_lib::Result<ConfigJsonDocument> {
let descriptor = self.registry.descriptor(file_id);
let descriptor = match descriptor {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
if descriptor.kind() != crate::ConfigFileKind::Config {
return std::result::Result::Err(
ksp_core_lib::Error::new(crate::ERROR_CODE_FILE_MAPPING_INVALID, "requested file_id does not identify a Config document")
.with_context("file_id", file_id.as_str()),
);
}
let schema_file_id = match descriptor.schema_file_id() {
std::option::Option::Some(value) => value.clone(),
std::option::Option::None => {
return std::result::Result::Err(
ksp_core_lib::Error::new(crate::ERROR_CODE_FILE_MAPPING_INVALID, "Config document has no registered validation schema")
.with_context("file_id", file_id.as_str()),
);
},
};
let document = self.load_json(file_id);
let document = match document {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
return self.validate_document(document, &schema_file_id);
}
pub(crate) fn validate_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> {
let descriptor = self.registry.descriptor(file_id);
let descriptor = match descriptor {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
if descriptor.kind() != crate::ConfigFileKind::Config {
return std::result::Result::Err(
ksp_core_lib::Error::new(crate::ERROR_CODE_FILE_MAPPING_INVALID, "requested file_id does not identify a Config document")
.with_context("file_id", file_id.as_str()),
);
}
let schema_file_id = match descriptor.schema_file_id() {
std::option::Option::Some(value) => value.clone(),
std::option::Option::None => {
return std::result::Result::Err(
ksp_core_lib::Error::new(crate::ERROR_CODE_FILE_MAPPING_INVALID, "Config document has no registered validation schema")
.with_context("file_id", file_id.as_str()),
);
},
};
let path = self.registry.resolve_path(&self.bootstrap, file_id);
let path = match path {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let document = ConfigJsonDocument { file_id: file_id.clone(), path, value };
return self.validate_document(document, &schema_file_id);
}
fn validate_document(&self, document: ConfigJsonDocument, schema_file_id: &crate::ConfigFileId) -> ksp_core_lib::Result<ConfigJsonDocument> {
let schema = self.load_json(schema_file_id);
let schema = match schema {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let schema_validation = validate_schema_document(&schema);
match schema_validation {
std::result::Result::Ok(()) => {},
std::result::Result::Err(error) => return std::result::Result::Err(error),
}
let instance_validation = validate_instance(&document, &schema);
match instance_validation {
std::result::Result::Ok(()) => {},
std::result::Result::Err(error) => return std::result::Result::Err(error),
}
let semantic_validation = validate_document_semantics(self, &document);
return match semantic_validation {
std::result::Result::Ok(()) => std::result::Result::Ok(document),
std::result::Result::Err(error) => std::result::Result::Err(error),
};
}
fn load_json(&self, file_id: &crate::ConfigFileId) -> ksp_core_lib::Result<ConfigJsonDocument> {
let path = self.registry.resolve_path(&self.bootstrap, file_id);
let path = match path {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let content = std::fs::read_to_string(path.as_path());
let content = match content {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(json_read_error(file_id, &path, error)),
};
let value = serde_json::from_str::<serde_json::Value>(content.as_str());
return match value {
std::result::Result::Ok(value) => std::result::Result::Ok(ConfigJsonDocument { file_id: file_id.clone(), path, value }),
std::result::Result::Err(error) => std::result::Result::Err(json_syntax_error(file_id, &path, error)),
};
}
}
#[derive(serde::Deserialize)]
#[serde(deny_unknown_fields)]
struct LoggingDocumentSource {
format_version: u32,
logs_directory: String,
default_profile: String,
profiles: std::vec::Vec<LoggingProfileSource>,
}
#[derive(serde::Deserialize)]
#[serde(deny_unknown_fields)]
struct LoggingProfileSource {
profile_id: String,
default_filter: String,
span_events: String,
console: LoggingConsoleSource,
files: std::vec::Vec<LoggingFileSource>,
target_filters: std::vec::Vec<LoggingTargetFilterSource>,
}
#[derive(serde::Deserialize)]
#[serde(deny_unknown_fields)]
struct LoggingConsoleSource {
enabled: bool,
output: String,
ansi: bool,
format: String,
filter: LoggingOutputFilterSource,
}
#[derive(serde::Deserialize)]
#[serde(deny_unknown_fields)]
struct LoggingFileSource {
output_id: String,
enabled: bool,
path: String,
rotation: String,
format: String,
ansi: bool,
filter: LoggingOutputFilterSource,
}
#[derive(serde::Deserialize)]
#[serde(deny_unknown_fields)]
struct LoggingOutputFilterSource {
level: String,
targets: std::vec::Vec<String>,
domains: std::vec::Vec<String>,
}
#[derive(serde::Deserialize)]
#[serde(deny_unknown_fields)]
struct LoggingTargetFilterSource {
target_prefix: String,
level: String,
}
fn validate_schema_document(schema: &ConfigJsonDocument) -> ksp_core_lib::Result<()> {
let validation = jsonschema::meta::validate(schema.value());
return match validation {
std::result::Result::Ok(()) => std::result::Result::Ok(()),
std::result::Result::Err(error) => std::result::Result::Err(
ksp_core_lib::Error::new(crate::ERROR_CODE_SCHEMA_INVALID, "Config JSON Schema document is invalid")
.with_context("file_id", schema.file_id().as_str())
.with_context("path", schema.path().to_string_lossy().into_owned())
.with_context("detail", error.to_string()),
),
};
}
fn validate_instance(document: &ConfigJsonDocument, schema: &ConfigJsonDocument) -> ksp_core_lib::Result<()> {
let validation = jsonschema::draft202012::validate(schema.value(), document.value());
return match validation {
std::result::Result::Ok(()) => std::result::Result::Ok(()),
std::result::Result::Err(error) => std::result::Result::Err(
ksp_core_lib::Error::new(crate::ERROR_CODE_SCHEMA_VALIDATION_FAILED, "Config document does not satisfy its registered JSON Schema")
.with_context("file_id", document.file_id().as_str())
.with_context("schema_file_id", schema.file_id().as_str())
.with_context("path", document.path().to_string_lossy().into_owned())
.with_context("detail", error.to_string()),
),
};
}
fn validate_document_semantics(engine: &ConfigDocumentEngine, document: &ConfigJsonDocument) -> ksp_core_lib::Result<()> {
let profile_validation = crate::profile::validate_document_profile_contract(document);
if let std::result::Result::Err(error) = profile_validation {
return std::result::Result::Err(error);
}
let composite_validation = crate::composite::validate_composite_document_contract(engine, document);
if let std::result::Result::Err(error) = composite_validation {
return std::result::Result::Err(error);
}
if document.file_id().as_str() != crate::FILE_ID_STD_LOGGING {
return std::result::Result::Ok(());
}
let parsed = serde_json::from_value::<LoggingDocumentSource>(document.value().clone());
let parsed = match parsed {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => {
return std::result::Result::Err(
semantic_error(document, "schema-valid Logging document cannot be decoded into the KSP source contract").with_source(error),
);
},
};
return validate_logging_document(document, &parsed);
}
fn validate_logging_document(document: &ConfigJsonDocument, source: &LoggingDocumentSource) -> ksp_core_lib::Result<()> {
if source.format_version != 1 {
return std::result::Result::Err(semantic_error(document, "unsupported Logging document format_version"));
}
if source.logs_directory.trim().is_empty() {
return std::result::Result::Err(semantic_error(document, "logs_directory must not be empty"));
}
if source.default_profile.trim().is_empty() {
return std::result::Result::Err(semantic_error(document, "default_profile must not be empty"));
}
for (profile_index, profile) in source.profiles.iter().enumerate() {
let validation = validate_logging_profile(document, profile, profile_index);
if let std::result::Result::Err(error) = validation {
return std::result::Result::Err(error);
}
}
return std::result::Result::Ok(());
}
fn validate_logging_profile(document: &ConfigJsonDocument, profile: &LoggingProfileSource, profile_index: usize) -> ksp_core_lib::Result<()> {
if profile.profile_id.trim().is_empty() || profile.default_filter.trim().is_empty() || profile.span_events.trim().is_empty() {
return std::result::Result::Err(
semantic_error(document, "Logging profile identity and base settings must not be empty").with_context("profile_index", profile_index.to_string()),
);
}
let console_validation = validate_logging_console(document, &profile.console, profile_index);
if let std::result::Result::Err(error) = console_validation {
return std::result::Result::Err(error);
}
for (file_index, file) in profile.files.iter().enumerate() {
let file_validation = validate_logging_file(document, file, profile_index, file_index);
if let std::result::Result::Err(error) = file_validation {
return std::result::Result::Err(error);
}
for previous in &profile.files[..file_index] {
if previous.output_id == file.output_id {
return std::result::Result::Err(
semantic_error(document, "Logging file output identifiers must be unique within a profile")
.with_context("profile_index", profile_index.to_string())
.with_context("output_id", file.output_id.as_str()),
);
}
}
}
for (target_index, target_filter) in profile.target_filters.iter().enumerate() {
if target_filter.target_prefix.trim().is_empty() || !target_filter.target_prefix.starts_with("ksp-") || target_filter.level.trim().is_empty() {
return std::result::Result::Err(
semantic_error(document, "Logging global target filter must identify a KSP-owned target")
.with_context("profile_index", profile_index.to_string())
.with_context("target_filter_index", target_index.to_string()),
);
}
}
return std::result::Result::Ok(());
}
fn validate_logging_console(document: &ConfigJsonDocument, console: &LoggingConsoleSource, profile_index: usize) -> ksp_core_lib::Result<()> {
let _enabled = console.enabled;
if console.output.trim().is_empty() || console.format.trim().is_empty() {
return std::result::Result::Err(
semantic_error(document, "Logging console output and format must not be empty").with_context("profile_index", profile_index.to_string()),
);
}
if console.ansi && console.format == "json" {
return std::result::Result::Err(
semantic_error(document, "ANSI formatting is not compatible with JSON console output").with_context("profile_index", profile_index.to_string()),
);
}
return validate_logging_output_filter(document, &console.filter, profile_index, "console");
}
fn validate_logging_file(document: &ConfigJsonDocument, file: &LoggingFileSource, profile_index: usize, file_index: usize) -> ksp_core_lib::Result<()> {
let _enabled = file.enabled;
if !valid_output_id(file.output_id.as_str()) {
return std::result::Result::Err(
semantic_error(document, "Logging file output_id is invalid")
.with_context("profile_index", profile_index.to_string())
.with_context("file_index", file_index.to_string())
.with_context("output_id", file.output_id.as_str()),
);
}
if file.path.trim().is_empty() || !relative_log_path_is_valid(file.path.as_str()) {
return std::result::Result::Err(
semantic_error(document, "Logging file path must stay relative to logs_directory without traversal")
.with_context("profile_index", profile_index.to_string())
.with_context("file_index", file_index.to_string()),
);
}
if file.rotation.trim().is_empty() || file.format.trim().is_empty() {
return std::result::Result::Err(
semantic_error(document, "Logging file rotation and format must not be empty").with_context("profile_index", profile_index.to_string()),
);
}
if file.ansi {
return std::result::Result::Err(
semantic_error(document, "ANSI sequences are not allowed in persistent Logging outputs").with_context("profile_index", profile_index.to_string()),
);
}
return validate_logging_output_filter(document, &file.filter, profile_index, file.output_id.as_str());
}
fn validate_logging_output_filter(
document: &ConfigJsonDocument,
filter: &LoggingOutputFilterSource,
profile_index: usize,
output: &str,
) -> ksp_core_lib::Result<()> {
if filter.level.trim().is_empty() {
return std::result::Result::Err(
semantic_error(document, "Logging output filter level must not be empty").with_context("profile_index", profile_index.to_string()),
);
}
let targets = validate_selectors(document, &filter.targets, true, profile_index, output, "targets");
if let std::result::Result::Err(error) = targets {
return std::result::Result::Err(error);
}
return validate_selectors(document, &filter.domains, false, profile_index, output, "domains");
}
fn validate_selectors(
document: &ConfigJsonDocument,
selectors: &[String],
target_dimension: bool,
profile_index: usize,
output: &str,
dimension: &'static str,
) -> ksp_core_lib::Result<()> {
if selectors.is_empty() {
return std::result::Result::Err(selector_error(document, profile_index, output, dimension, "selector list must not be empty"));
}
if selectors.len() > 1
&& selectors.iter().any(|selector| -> bool {
return selector == "*";
})
{
return std::result::Result::Err(selector_error(document, profile_index, output, dimension, "wildcard selector must be used alone"));
}
for (index, selector) in selectors.iter().enumerate() {
if selector.trim().is_empty() {
return std::result::Result::Err(
selector_error(document, profile_index, output, dimension, "selector must not be empty").with_context("selector_index", index.to_string()),
);
}
if target_dimension && selector != "*" && !selector.starts_with("ksp-") {
return std::result::Result::Err(
selector_error(document, profile_index, output, dimension, "target selector must identify a KSP-owned target")
.with_context("selector_index", index.to_string()),
);
}
for previous in &selectors[..index] {
if previous == selector {
return std::result::Result::Err(
selector_error(document, profile_index, output, dimension, "selectors must be unique").with_context("selector_index", index.to_string()),
);
}
}
}
return std::result::Result::Ok(());
}
fn valid_output_id(output_id: &str) -> bool {
let mut previous_was_separator = true;
if output_id.is_empty() {
return false;
}
for byte in output_id.bytes() {
if byte == b'.' {
if previous_was_separator {
return false;
}
previous_was_separator = true;
} else if byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_' || byte == b'-' {
previous_was_separator = false;
} else {
return false;
}
}
return !previous_was_separator;
}
fn relative_log_path_is_valid(value: &str) -> bool {
let path = std::path::Path::new(value);
if path.is_absolute() {
return false;
}
let mut has_normal_component = false;
for component in path.components() {
match component {
std::path::Component::Normal(_) => has_normal_component = true,
std::path::Component::CurDir | std::path::Component::ParentDir | std::path::Component::RootDir | std::path::Component::Prefix(_) => return false,
}
}
return has_normal_component;
}
fn selector_error(document: &ConfigJsonDocument, profile_index: usize, output: &str, dimension: &'static str, reason: &'static str) -> ksp_core_lib::Error {
return semantic_error(document, reason)
.with_context("profile_index", profile_index.to_string())
.with_context("output", output)
.with_context("dimension", dimension);
}
fn semantic_error(document: &ConfigJsonDocument, reason: &'static str) -> ksp_core_lib::Error {
return ksp_core_lib::Error::new(crate::ERROR_CODE_DOCUMENT_SEMANTIC_INVALID, "Config document violates KSP semantic invariants")
.with_context("file_id", document.file_id().as_str())
.with_context("path", document.path().to_string_lossy().into_owned())
.with_context("reason", reason);
}
fn json_read_error(file_id: &crate::ConfigFileId, path: &std::path::Path, source: std::io::Error) -> ksp_core_lib::Error {
return ksp_core_lib::Error::new(crate::ERROR_CODE_JSON_FILE_READ_FAILED, "Config-managed JSON file cannot be read")
.with_context("file_id", file_id.as_str())
.with_context("path", path.to_string_lossy().into_owned())
.with_source(source);
}
fn json_syntax_error(file_id: &crate::ConfigFileId, path: &std::path::Path, source: serde_json::Error) -> ksp_core_lib::Error {
return ksp_core_lib::Error::new(crate::ERROR_CODE_JSON_SYNTAX_INVALID, "Config-managed file contains invalid JSON syntax")
.with_context("file_id", file_id.as_str())
.with_context("path", path.to_string_lossy().into_owned())
.with_source(source);
}
#[cfg(test)]
#[path = "../unit_tests/document.rs"]
mod tests;

View File

@@ -0,0 +1,604 @@
// file: crates/ksp-config-lib/src/environment.rs
// version: 5
/// Default local environment file read by Config from the process launch directory.
pub const DEFAULT_DOTENV_PATH: &str = ".env";
/// Versioned environment contract template expected at the repository/runtime root.
pub const DEFAULT_DOTENV_EXAMPLE_PATH: &str = ".env.example";
const LOGGING_DOMAIN: &str = "config.environment";
/// Source that supplied one resolved Config environment variable.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ConfigEnvironmentSource {
/// Value was present in the environment inherited by the current process.
Process,
/// Value was absent from the process environment and came from the local `.env` file.
DotEnv,
/// Value was absent from both external sources and came from the placeholder/API fallback.
Fallback,
}
/// One resolved Config environment variable with real/safe values, sensitivity and its winning source.
#[derive(Clone, Eq, PartialEq)]
pub struct ConfigEnvironmentValue {
variable_name: String,
value: String,
safe_value: String,
sensitivity: crate::ConfigSensitivity,
source: ConfigEnvironmentSource,
}
impl ConfigEnvironmentValue {
/// Returns the resolved variable name.
#[must_use]
pub fn variable_name(&self) -> &str {
return self.variable_name.as_str();
}
/// Returns the real resolved value.
#[must_use]
pub fn value(&self) -> &str {
return self.value.as_str();
}
/// Returns the representation safe for ordinary diagnostics.
#[must_use]
pub fn safe_value(&self) -> &str {
return self.safe_value.as_str();
}
/// Returns the sensitivity derived from the variable namespace.
#[must_use]
pub const fn sensitivity(&self) -> crate::ConfigSensitivity {
return self.sensitivity;
}
/// Returns the source that won process > `.env` > fallback resolution.
#[must_use]
pub const fn source(&self) -> ConfigEnvironmentSource {
return self.source;
}
/// Returns provenance without embedding the resolved value.
#[must_use]
pub fn provenance(&self) -> crate::ConfigValueProvenance {
return environment_provenance(self.variable_name.as_str(), self.source);
}
}
impl std::fmt::Debug for ConfigEnvironmentValue {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
return formatter
.debug_struct("ConfigEnvironmentValue")
.field("variable_name", &self.variable_name)
.field("safe_value", &self.safe_value)
.field("sensitivity", &self.sensitivity)
.field("source", &self.source)
.finish();
}
}
/// Config-owned snapshot of KSP/KSPB process environment values and the local `.env` file.
///
/// The process environment is captured first and always has priority over `.env`. An absent `.env` file is equivalent to an empty local environment source.
/// Config never mutates the parent/process environment through this type.
#[derive(Clone, Eq, PartialEq)]
pub struct ConfigEnvironment {
process: std::collections::BTreeMap<String, String>,
dotenv: std::collections::BTreeMap<String, String>,
dotenv_path: std::path::PathBuf,
}
impl ConfigEnvironment {
/// Captures supported variables from the current process and reads `./.env` when it exists.
pub fn load() -> ksp_core_lib::Result<Self> {
return Self::load_from_dotenv_path(std::path::Path::new(DEFAULT_DOTENV_PATH));
}
/// Returns the local `.env` path used by this environment snapshot.
#[must_use]
pub fn dotenv_path(&self) -> &std::path::Path {
return self.dotenv_path.as_path();
}
/// Resolves one KSP/KSPB variable using process > `.env` > fallback priority.
///
/// The fallback is used only when the variable is absent. An explicitly defined empty string is a real value and therefore wins over the fallback.
pub fn resolve_variable(&self, variable_name: &str, fallback: std::option::Option<&str>) -> ksp_core_lib::Result<ConfigEnvironmentValue> {
let validation = validate_supported_variable_name(variable_name);
if let std::result::Result::Err(error) = validation {
return std::result::Result::Err(error);
}
if let std::option::Option::Some(value) = self.process.get(variable_name) {
return resolved_environment_value(variable_name, value.as_str(), ConfigEnvironmentSource::Process);
}
if let std::option::Option::Some(value) = self.dotenv.get(variable_name) {
return resolved_environment_value(variable_name, value.as_str(), ConfigEnvironmentSource::DotEnv);
}
if let std::option::Option::Some(value) = fallback {
return resolved_environment_value(variable_name, value, ConfigEnvironmentSource::Fallback);
}
emit_missing_variable_warning(variable_name);
return std::result::Result::Err(missing_variable_error(variable_name));
}
/// Resolves `${NAME}` and `${NAME:-fallback}` placeholders embedded in one UTF-8 string.
///
/// This compatibility helper returns only the real runtime string. Use [`Self::resolve_text_detailed`] when safe value, sensitivity or provenance are
/// needed.
pub fn resolve_text(&self, source: &str) -> ksp_core_lib::Result<String> {
let resolved = self.resolve_text_detailed(source);
return match resolved {
std::result::Result::Ok(value) => std::result::Result::Ok(value.value().to_owned()),
std::result::Result::Err(error) => std::result::Result::Err(error),
};
}
/// Resolves one UTF-8 string while preserving real/safe representations, strongest sensitivity and ordered provenance.
///
/// Multiple placeholders are supported. Fallback text is literal and inherits the sensitivity of the referenced variable. Literal-only strings are
/// classified as `Internal`; when placeholders are present, the result sensitivity is the strongest placeholder sensitivity.
pub fn resolve_text_detailed(&self, source: &str) -> ksp_core_lib::Result<crate::ResolvedConfigText> {
let mut value = String::new();
let mut safe_value = String::new();
let mut provenance = std::vec::Vec::<crate::ConfigValueProvenance>::new();
let mut sensitivity = crate::ConfigSensitivity::Public;
let mut saw_placeholder = false;
let mut remaining = source;
loop {
let start = remaining.find("${");
let start = match start {
std::option::Option::Some(value) => value,
std::option::Option::None => {
if !remaining.is_empty() {
value.push_str(remaining);
safe_value.push_str(remaining);
provenance.push(crate::ConfigValueProvenance::DocumentLiteral);
}
if !saw_placeholder {
sensitivity = crate::ConfigSensitivity::Internal;
}
return std::result::Result::Ok(crate::ResolvedConfigText::new(value, safe_value, sensitivity, provenance));
},
};
let literal = &remaining[..start];
if !literal.is_empty() {
value.push_str(literal);
safe_value.push_str(literal);
provenance.push(crate::ConfigValueProvenance::DocumentLiteral);
}
let expression_and_tail = &remaining[start + 2..];
let end = expression_and_tail.find('}');
let end = match end {
std::option::Option::Some(value) => value,
std::option::Option::None => return std::result::Result::Err(invalid_placeholder_error("placeholder is missing its closing '}'")),
};
let expression = &expression_and_tail[..end];
let parsed = parse_placeholder_expression(expression);
let (variable_name, fallback) = match parsed {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let resolved = self.resolve_variable(variable_name, fallback);
let resolved = match resolved {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
saw_placeholder = true;
sensitivity = sensitivity.strongest(resolved.sensitivity());
value.push_str(resolved.value());
safe_value.push_str(resolved.safe_value());
provenance.push(resolved.provenance());
remaining = &expression_and_tail[end + 1..];
}
}
/// Recursively resolves environment placeholders in JSON values and returns only the real runtime tree.
///
/// Use [`Self::resolve_json_detailed`] when safe value, sensitivity or per-location provenance are needed.
pub fn resolve_json(&self, source: &serde_json::Value) -> ksp_core_lib::Result<serde_json::Value> {
let resolved = self.resolve_json_detailed(source);
return match resolved {
std::result::Result::Ok(value) => std::result::Result::Ok(value.value().clone()),
std::result::Result::Err(error) => std::result::Result::Err(error),
};
}
/// Recursively resolves environment placeholders in JSON values while preserving real/safe trees, strongest sensitivity and JSON-Pointer provenance.
pub fn resolve_json_detailed(&self, source: &serde_json::Value) -> ksp_core_lib::Result<crate::ResolvedConfigJson> {
let mut provenance = std::collections::BTreeMap::<String, std::vec::Vec<crate::ConfigValueProvenance>>::new();
let resolved = resolve_json_node(self, source, "", &mut provenance);
let (value, safe_value, sensitivity) = match resolved {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
return std::result::Result::Ok(crate::ResolvedConfigJson::new(value, safe_value, sensitivity, provenance));
}
/// Recursively resolves environment placeholders in one JSON object map while leaving map keys unchanged.
pub fn resolve_map(&self, source: &serde_json::Map<String, serde_json::Value>) -> ksp_core_lib::Result<serde_json::Map<String, serde_json::Value>> {
let resolved = self.resolve_json(&serde_json::Value::Object(source.clone()));
let resolved = match resolved {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
return match resolved {
serde_json::Value::Object(value) => std::result::Result::Ok(value),
_ => std::result::Result::Err(invalid_placeholder_error("resolved JSON object changed shape")),
};
}
pub(crate) fn load_from_dotenv_path(dotenv_path: &std::path::Path) -> ksp_core_lib::Result<Self> {
let process = collect_process_environment(std::env::vars_os());
let process = match process {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let dotenv = load_dotenv_file(dotenv_path);
let dotenv = match dotenv {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
return std::result::Result::Ok(Self { process, dotenv, dotenv_path: dotenv_path.to_path_buf() });
}
pub(crate) const fn process_values(&self) -> &std::collections::BTreeMap<String, String> {
return &self.process;
}
pub(crate) const fn dotenv_values(&self) -> &std::collections::BTreeMap<String, String> {
return &self.dotenv;
}
#[cfg(test)]
pub(crate) fn from_maps(process: std::collections::BTreeMap<String, String>, dotenv: std::collections::BTreeMap<String, String>) -> Self {
return Self { process, dotenv, dotenv_path: std::path::PathBuf::from(DEFAULT_DOTENV_PATH) };
}
}
fn collect_process_environment<I>(values: I) -> ksp_core_lib::Result<std::collections::BTreeMap<String, String>>
where
I: std::iter::IntoIterator<Item = (std::ffi::OsString, std::ffi::OsString)>,
{
let mut output = std::collections::BTreeMap::<String, String>::new();
for (name, value) in values {
let name = match name.to_str() {
std::option::Option::Some(value) => value,
std::option::Option::None => continue,
};
if !has_supported_namespace(name) {
continue;
}
let validation = validate_supported_variable_name(name);
if let std::result::Result::Err(error) = validation {
return std::result::Result::Err(error);
}
let value = value.into_string();
let value = match value {
std::result::Result::Ok(value) => value,
std::result::Result::Err(_) => return std::result::Result::Err(invalid_environment_value_error(name)),
};
output.insert(name.to_owned(), value);
}
return std::result::Result::Ok(output);
}
fn load_dotenv_file(path: &std::path::Path) -> ksp_core_lib::Result<std::collections::BTreeMap<String, String>> {
let content = std::fs::read_to_string(path);
let content = match content {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) if error.kind() == std::io::ErrorKind::NotFound => return std::result::Result::Ok(std::collections::BTreeMap::new()),
std::result::Result::Err(error) => return std::result::Result::Err(dotenv_read_error(path, error)),
};
return parse_dotenv_content(path, content.as_str());
}
pub(crate) fn parse_dotenv_content(path: &std::path::Path, content: &str) -> ksp_core_lib::Result<std::collections::BTreeMap<String, String>> {
let mut output = std::collections::BTreeMap::<String, String>::new();
for (line_index, raw_line) in content.lines().enumerate() {
let raw_line = if line_index == 0 { raw_line.trim_start_matches('\u{feff}') } else { raw_line };
let line = raw_line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let assignment = match line.strip_prefix("export ") {
std::option::Option::Some(value) => value.trim_start(),
std::option::Option::None => line,
};
let separator = assignment.find('=');
let separator = match separator {
std::option::Option::Some(value) => value,
std::option::Option::None => return std::result::Result::Err(dotenv_syntax_error(path, line_index + 1, "assignment is missing '='")),
};
let variable_name = assignment[..separator].trim();
if !is_generic_dotenv_name(variable_name) {
return std::result::Result::Err(dotenv_syntax_error(path, line_index + 1, "variable name is invalid"));
}
let raw_value = assignment[separator + 1..].trim();
let value = parse_dotenv_value(path, line_index + 1, raw_value);
let value = match value {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
if !has_supported_namespace(variable_name) {
continue;
}
let validation = validate_supported_variable_name(variable_name);
if let std::result::Result::Err(error) = validation {
return std::result::Result::Err(error);
}
let entry = output.entry(variable_name.to_owned());
match entry {
std::collections::btree_map::Entry::Occupied(_) => {
return std::result::Result::Err(dotenv_duplicate_error(path, line_index + 1, variable_name));
},
std::collections::btree_map::Entry::Vacant(entry) => {
entry.insert(value);
},
}
}
return std::result::Result::Ok(output);
}
fn parse_dotenv_value(path: &std::path::Path, line_number: usize, raw_value: &str) -> ksp_core_lib::Result<String> {
if raw_value.starts_with('\'') && (raw_value.len() < 2 || !raw_value.ends_with('\'')) {
return std::result::Result::Err(dotenv_syntax_error(path, line_number, "single-quoted value is not terminated"));
}
if raw_value.starts_with('\'') {
return std::result::Result::Ok(raw_value[1..raw_value.len() - 1].to_owned());
}
if raw_value.starts_with('"') && (raw_value.len() < 2 || !raw_value.ends_with('"')) {
return std::result::Result::Err(dotenv_syntax_error(path, line_number, "double-quoted value is not terminated"));
}
if raw_value.starts_with('"') {
return parse_double_quoted_value(path, line_number, &raw_value[1..raw_value.len() - 1]);
}
let inline_comment = raw_value.find(" #");
let value = match inline_comment {
std::option::Option::Some(index) => raw_value[..index].trim_end(),
std::option::Option::None => raw_value,
};
return std::result::Result::Ok(value.to_owned());
}
fn parse_double_quoted_value(path: &std::path::Path, line_number: usize, source: &str) -> ksp_core_lib::Result<String> {
let mut output = String::new();
let mut escaped = false;
for character in source.chars() {
if escaped {
let mapped = match character {
'n' => '\n',
'r' => '\r',
't' => '\t',
'\\' => '\\',
'"' => '"',
_ => return std::result::Result::Err(dotenv_syntax_error(path, line_number, "double-quoted value contains an unsupported escape")),
};
output.push(mapped);
escaped = false;
} else if character == '\\' {
escaped = true;
} else {
output.push(character);
}
}
if escaped {
return std::result::Result::Err(dotenv_syntax_error(path, line_number, "double-quoted value ends with an incomplete escape"));
}
return std::result::Result::Ok(output);
}
fn parse_placeholder_expression(expression: &str) -> ksp_core_lib::Result<(&str, std::option::Option<&str>)> {
if expression.is_empty() || expression.contains("${") {
return std::result::Result::Err(invalid_placeholder_error("placeholder expression is empty or nested"));
}
let fallback_separator = expression.find(":-");
let (variable_name, fallback) = match fallback_separator {
std::option::Option::Some(index) => (&expression[..index], std::option::Option::Some(&expression[index + 2..])),
std::option::Option::None => (expression, std::option::Option::None),
};
let validation = validate_supported_variable_name(variable_name);
if let std::result::Result::Err(error) = validation {
return std::result::Result::Err(error);
}
return std::result::Result::Ok((variable_name, fallback));
}
fn resolved_environment_value(variable_name: &str, value: &str, source: ConfigEnvironmentSource) -> ksp_core_lib::Result<ConfigEnvironmentValue> {
let sensitivity = crate::ConfigSensitivity::from_variable_name(variable_name);
let sensitivity = match sensitivity {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let safe_value = if sensitivity.is_secret() { crate::REDACTED_CONFIG_VALUE.to_owned() } else { value.to_owned() };
return std::result::Result::Ok(ConfigEnvironmentValue {
variable_name: variable_name.to_owned(),
value: value.to_owned(),
safe_value,
sensitivity,
source,
});
}
fn environment_provenance(variable_name: &str, source: ConfigEnvironmentSource) -> crate::ConfigValueProvenance {
return match source {
ConfigEnvironmentSource::Process => crate::ConfigValueProvenance::EnvironmentProcess { variable_name: variable_name.to_owned() },
ConfigEnvironmentSource::DotEnv => crate::ConfigValueProvenance::EnvironmentDotEnv { variable_name: variable_name.to_owned() },
ConfigEnvironmentSource::Fallback => crate::ConfigValueProvenance::EnvironmentFallback { variable_name: variable_name.to_owned() },
};
}
fn resolve_json_node(
environment: &ConfigEnvironment,
source: &serde_json::Value,
pointer: &str,
provenance: &mut std::collections::BTreeMap<String, std::vec::Vec<crate::ConfigValueProvenance>>,
) -> ksp_core_lib::Result<(serde_json::Value, serde_json::Value, crate::ConfigSensitivity)> {
return match source {
serde_json::Value::Null | serde_json::Value::Bool(_) | serde_json::Value::Number(_) => {
provenance.insert(pointer.to_owned(), vec![crate::ConfigValueProvenance::DocumentLiteral]);
std::result::Result::Ok((source.clone(), source.clone(), crate::ConfigSensitivity::Internal))
},
serde_json::Value::String(value) => {
let resolved = environment.resolve_text_detailed(value.as_str());
let resolved = match resolved {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
provenance.insert(pointer.to_owned(), resolved.provenance().to_vec());
std::result::Result::Ok((
serde_json::Value::String(resolved.value().to_owned()),
serde_json::Value::String(resolved.safe_value().to_owned()),
resolved.sensitivity(),
))
},
serde_json::Value::Array(values) => resolve_json_array(environment, values, pointer, provenance),
serde_json::Value::Object(values) => resolve_json_object(environment, values, pointer, provenance),
};
}
fn resolve_json_array(
environment: &ConfigEnvironment,
source: &[serde_json::Value],
pointer: &str,
provenance: &mut std::collections::BTreeMap<String, std::vec::Vec<crate::ConfigValueProvenance>>,
) -> ksp_core_lib::Result<(serde_json::Value, serde_json::Value, crate::ConfigSensitivity)> {
let mut value = std::vec::Vec::<serde_json::Value>::with_capacity(source.len());
let mut safe_value = std::vec::Vec::<serde_json::Value>::with_capacity(source.len());
let mut sensitivity = crate::ConfigSensitivity::Public;
if source.is_empty() {
sensitivity = crate::ConfigSensitivity::Internal;
}
for (index, item) in source.iter().enumerate() {
let child_pointer = format!("{pointer}/{index}");
let resolved = resolve_json_node(environment, item, child_pointer.as_str(), provenance);
let (resolved_value, resolved_safe_value, resolved_sensitivity) = match resolved {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
value.push(resolved_value);
safe_value.push(resolved_safe_value);
sensitivity = sensitivity.strongest(resolved_sensitivity);
}
return std::result::Result::Ok((serde_json::Value::Array(value), serde_json::Value::Array(safe_value), sensitivity));
}
fn resolve_json_object(
environment: &ConfigEnvironment,
source: &serde_json::Map<String, serde_json::Value>,
pointer: &str,
provenance: &mut std::collections::BTreeMap<String, std::vec::Vec<crate::ConfigValueProvenance>>,
) -> ksp_core_lib::Result<(serde_json::Value, serde_json::Value, crate::ConfigSensitivity)> {
let mut value = serde_json::Map::<String, serde_json::Value>::new();
let mut safe_value = serde_json::Map::<String, serde_json::Value>::new();
let mut sensitivity = crate::ConfigSensitivity::Public;
if source.is_empty() {
sensitivity = crate::ConfigSensitivity::Internal;
}
for (key, item) in source {
let escaped_key = escape_json_pointer_token(key.as_str());
let child_pointer = format!("{pointer}/{escaped_key}");
let resolved = resolve_json_node(environment, item, child_pointer.as_str(), provenance);
let (resolved_value, resolved_safe_value, resolved_sensitivity) = match resolved {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
value.insert(key.clone(), resolved_value);
safe_value.insert(key.clone(), resolved_safe_value);
sensitivity = sensitivity.strongest(resolved_sensitivity);
}
return std::result::Result::Ok((serde_json::Value::Object(value), serde_json::Value::Object(safe_value), sensitivity));
}
fn escape_json_pointer_token(value: &str) -> String {
return value.replace('~', "~0").replace('/', "~1");
}
pub(crate) fn validate_supported_variable_name(variable_name: &str) -> ksp_core_lib::Result<()> {
if !has_supported_namespace(variable_name) {
return std::result::Result::Err(invalid_variable_error(variable_name, "variable must use the KSP_ or KSPB_ namespace"));
}
let prefix_length = if variable_name.starts_with("KSPB_") { 5 } else { 4 };
if variable_name.len() <= prefix_length {
return std::result::Result::Err(invalid_variable_error(variable_name, "variable namespace must be followed by a name"));
}
for byte in variable_name.bytes() {
let valid = byte.is_ascii_uppercase() || byte.is_ascii_digit() || byte == b'_';
if !valid {
return std::result::Result::Err(invalid_variable_error(variable_name, "variable names use uppercase ASCII letters, digits and underscores"));
}
}
return std::result::Result::Ok(());
}
fn has_supported_namespace(variable_name: &str) -> bool {
return variable_name.starts_with("KSP_") || variable_name.starts_with("KSPB_");
}
fn is_generic_dotenv_name(variable_name: &str) -> bool {
let mut bytes = variable_name.bytes();
let first = match bytes.next() {
std::option::Option::Some(value) => value,
std::option::Option::None => return false,
};
if !(first.is_ascii_alphabetic() || first == b'_') {
return false;
}
for byte in bytes {
if !(byte.is_ascii_alphanumeric() || byte == b'_') {
return false;
}
}
return true;
}
fn emit_missing_variable_warning(variable_name: &str) {
ksp_logging_lib::warn!(target: 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 {
return ksp_core_lib::Error::new(crate::ERROR_CODE_ENVIRONMENT_VARIABLE_MISSING, "required Config environment variable is missing")
.with_context("variable_name", variable_name);
}
fn invalid_variable_error(variable_name: &str, reason: &'static str) -> ksp_core_lib::Error {
return ksp_core_lib::Error::new(crate::ERROR_CODE_ENVIRONMENT_VARIABLE_INVALID, "Config environment variable name is invalid")
.with_context("variable_name", variable_name)
.with_context("reason", reason);
}
fn invalid_environment_value_error(variable_name: &str) -> ksp_core_lib::Error {
return ksp_core_lib::Error::new(crate::ERROR_CODE_ENVIRONMENT_VALUE_INVALID, "Config environment variable value is not valid UTF-8")
.with_context("variable_name", variable_name);
}
fn invalid_placeholder_error(reason: &'static str) -> ksp_core_lib::Error {
return ksp_core_lib::Error::new(crate::ERROR_CODE_ENVIRONMENT_PLACEHOLDER_INVALID, "Config environment placeholder is invalid")
.with_context("reason", reason);
}
fn dotenv_read_error(path: &std::path::Path, source: std::io::Error) -> ksp_core_lib::Error {
return ksp_core_lib::Error::new(crate::ERROR_CODE_DOTENV_FILE_READ_FAILED, "Config cannot read the local .env file")
.with_context("path", path.to_string_lossy().into_owned())
.with_source(source);
}
fn dotenv_syntax_error(path: &std::path::Path, line_number: usize, reason: &'static str) -> ksp_core_lib::Error {
return ksp_core_lib::Error::new(crate::ERROR_CODE_DOTENV_SYNTAX_INVALID, "Config local .env syntax is invalid")
.with_context("path", path.to_string_lossy().into_owned())
.with_context("line", line_number.to_string())
.with_context("reason", reason);
}
fn dotenv_duplicate_error(path: &std::path::Path, line_number: usize, variable_name: &str) -> ksp_core_lib::Error {
return ksp_core_lib::Error::new(crate::ERROR_CODE_DOTENV_SYNTAX_INVALID, "Config local .env contains a duplicate KSP variable")
.with_context("path", path.to_string_lossy().into_owned())
.with_context("line", line_number.to_string())
.with_context("variable_name", variable_name);
}
#[cfg(test)]
#[path = "../unit_tests/environment.rs"]
mod tests;

View File

@@ -0,0 +1,68 @@
// file: crates/ksp-config-lib/src/error.rs
// version: 8
/// Error code used when a Config bootstrap argument is missing its value.
pub const ERROR_CODE_BOOTSTRAP_ARGUMENT_MISSING_VALUE: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config", "bootstrap_argument_missing_value");
/// Error code used when a Config bootstrap path is empty, inaccessible, or resolves to an existing non-directory path.
pub const ERROR_CODE_BOOTSTRAP_INVALID_PATH: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config", "bootstrap_invalid_path");
/// Error code used when a logical Config file identifier is malformed.
pub const ERROR_CODE_FILE_ID_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config", "file_id_invalid");
/// Error code used when a requested logical Config file identifier is not registered.
pub const ERROR_CODE_FILE_ID_UNKNOWN: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config", "file_id_unknown");
/// Error code used when the same logical Config file identifier is registered more than once.
pub const ERROR_CODE_FILE_ID_DUPLICATE: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config", "file_id_duplicate");
/// Error code used when a Config filename mapping or descriptor relation is invalid.
pub const ERROR_CODE_FILE_MAPPING_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config", "file_mapping_invalid");
/// Error code used when a Config-managed JSON document or schema cannot be read from its resolved path.
pub const ERROR_CODE_JSON_FILE_READ_FAILED: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config", "json_file_read_failed");
/// Error code used when a Config-managed file contains invalid JSON syntax.
pub const ERROR_CODE_JSON_SYNTAX_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config", "json_syntax_invalid");
/// Error code used when a JSON Schema document is itself invalid for the selected JSON Schema draft.
pub const ERROR_CODE_SCHEMA_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config", "schema_invalid");
/// Error code used when a Config document does not satisfy its registered JSON Schema.
pub const ERROR_CODE_SCHEMA_VALIDATION_FAILED: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config", "schema_validation_failed");
/// Error code used when a schema-valid Config document violates KSP semantic invariants for its document type.
pub const ERROR_CODE_DOCUMENT_SEMANTIC_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config", "document_semantic_invalid");
/// Error code used when an explicitly requested Config profile does not exist in a validated document.
pub const ERROR_CODE_PROFILE_NOT_FOUND: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config", "profile_not_found");
/// Error code used when a composite document references an invalid, unknown, or unsupported Config document.
pub const ERROR_CODE_COMPOSITE_REFERENCE_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config", "composite_reference_invalid");
/// Error code used when the local `.env` file cannot be read for a reason other than absence.
pub const ERROR_CODE_DOTENV_FILE_READ_FAILED: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config", "dotenv_file_read_failed");
/// Error code used when the local `.env` file contains syntax Config cannot interpret safely.
pub const ERROR_CODE_DOTENV_SYNTAX_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config", "dotenv_syntax_invalid");
/// Error code used when a Config environment variable name is malformed or outside the KSP/KSPB namespaces.
pub const ERROR_CODE_ENVIRONMENT_VARIABLE_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config", "environment_variable_invalid");
/// Error code used when a referenced Config environment variable is absent and has no fallback.
pub const ERROR_CODE_ENVIRONMENT_VARIABLE_MISSING: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config", "environment_variable_missing");
/// Error code used when a supported process environment variable has a value that cannot become a JSON UTF-8 string.
pub const ERROR_CODE_ENVIRONMENT_VALUE_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config", "environment_value_invalid");
/// Error code used when a `${NAME}` / `${NAME:-fallback}` expression is malformed.
pub const ERROR_CODE_ENVIRONMENT_PLACEHOLDER_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config", "environment_placeholder_invalid");
/// Error code used when an environment-resolved Config cannot be mapped safely to a runtime consumer contract.
pub const ERROR_CODE_EFFECTIVE_CONFIG_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config", "effective_config_invalid");
/// Error code used when an explicit Config management operation is unsupported or targets the wrong managed resource kind.
pub const ERROR_CODE_MANAGEMENT_OPERATION_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config", "management_operation_invalid");
/// Error code used when an atomic managed Config or `.env` persistence operation fails before commit.
pub const ERROR_CODE_PERSISTENCE_WRITE_FAILED: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config", "persistence_write_failed");

View File

@@ -0,0 +1,173 @@
// file: crates/ksp-config-lib/src/lib.rs
// version: 12
#![warn(missing_docs)]
#![deny(unreachable_pub)]
#![forbid(unsafe_code)]
//! KSP-owned application configuration facade.
//!
//! The `0.1.3` surface owns bootstrap roots, the logical file registry, JSON/JSON Schema validation, standard-document profiles, generic composites and
//! KSP/KSPB environment resolution through process + `.env` + fallback precedence. Resolved values preserve real/safe representations, sensitivity and
//! provenance. Standard Logging and HTTP Transport documents map explicitly to their runtime settings contracts, while the management surface provides
//! typed Logging mutation, safe environment reports, explicit privileged reveal calls and atomic JSON/`.env` persistence.
mod bootstrap;
mod composite;
mod constants;
mod document;
mod environment;
mod error;
mod logging;
mod management;
mod persistence;
mod profile;
mod registry;
mod sensitivity;
mod transport;
pub(crate) use self::constants::TRACING_TARGET;
/// Bootstrap argument used to replace the configuration document root.
pub use self::bootstrap::ARG_CFG_PATH;
/// Bootstrap argument used to replace the schema root.
pub use self::bootstrap::ARG_SCHEMA_PATH;
/// Non-recursive bootstrap options required before Config can resolve any managed document.
pub use self::bootstrap::ConfigBootstrapOptions;
/// Default root containing KSP runtime configuration documents.
pub use self::bootstrap::DEFAULT_CFG_PATH;
/// Default root containing KSP JSON schemas.
pub use self::bootstrap::DEFAULT_SCHEMA_PATH;
/// One resolved document component selected by a composite profile.
pub use self::composite::ResolvedCompositeComponent;
/// Validated composite document resolved to one profile and its referenced standard documents.
pub use self::composite::ResolvedConfigComposite;
/// Generic JSON/JSON Schema engine owned by Config.
pub use self::document::ConfigDocumentEngine;
/// A Config-managed JSON document after syntax, schema and current semantic validation.
pub use self::document::ConfigJsonDocument;
/// Config-owned snapshot of KSP/KSPB process environment values and the local `.env` file.
pub use self::environment::ConfigEnvironment;
/// Source that supplied one resolved Config environment variable.
pub use self::environment::ConfigEnvironmentSource;
/// One resolved Config environment variable with real/safe values, sensitivity and its winning source.
pub use self::environment::ConfigEnvironmentValue;
/// Versioned environment contract template expected at the repository/runtime root.
pub use self::environment::DEFAULT_DOTENV_EXAMPLE_PATH;
/// Default local environment file read by Config from the process launch directory.
pub use self::environment::DEFAULT_DOTENV_PATH;
/// Error code used when a Config bootstrap argument is missing its value.
pub use self::error::ERROR_CODE_BOOTSTRAP_ARGUMENT_MISSING_VALUE;
/// Error code used when a Config bootstrap path is empty, inaccessible, or resolves to an existing non-directory path.
pub use self::error::ERROR_CODE_BOOTSTRAP_INVALID_PATH;
/// Error code used when a composite document contains an invalid or unsupported document reference.
pub use self::error::ERROR_CODE_COMPOSITE_REFERENCE_INVALID;
/// Error code used when a schema-valid Config document violates KSP semantic invariants.
pub use self::error::ERROR_CODE_DOCUMENT_SEMANTIC_INVALID;
/// Error code used when the local `.env` file cannot be read for a reason other than absence.
pub use self::error::ERROR_CODE_DOTENV_FILE_READ_FAILED;
/// Error code used when the local `.env` file contains invalid syntax.
pub use self::error::ERROR_CODE_DOTENV_SYNTAX_INVALID;
/// Error code used when an environment-resolved Config cannot map safely to a runtime consumer contract.
pub use self::error::ERROR_CODE_EFFECTIVE_CONFIG_INVALID;
/// Error code used when a Config environment placeholder is malformed.
pub use self::error::ERROR_CODE_ENVIRONMENT_PLACEHOLDER_INVALID;
/// Error code used when a supported Config environment variable has a non-UTF-8 process value.
pub use self::error::ERROR_CODE_ENVIRONMENT_VALUE_INVALID;
/// Error code used when a Config environment variable name is invalid or outside KSP/KSPB namespaces.
pub use self::error::ERROR_CODE_ENVIRONMENT_VARIABLE_INVALID;
/// Error code used when a referenced Config environment variable is absent and has no fallback.
pub use self::error::ERROR_CODE_ENVIRONMENT_VARIABLE_MISSING;
/// Error code used when the same logical Config file identifier is registered more than once.
pub use self::error::ERROR_CODE_FILE_ID_DUPLICATE;
/// Error code used when a logical Config file identifier is malformed.
pub use self::error::ERROR_CODE_FILE_ID_INVALID;
/// Error code used when a requested logical Config file identifier is not registered.
pub use self::error::ERROR_CODE_FILE_ID_UNKNOWN;
/// Error code used when a Config filename mapping or descriptor relation is invalid.
pub use self::error::ERROR_CODE_FILE_MAPPING_INVALID;
/// Error code used when a Config-managed JSON document or schema cannot be read.
pub use self::error::ERROR_CODE_JSON_FILE_READ_FAILED;
/// Error code used when a Config-managed file contains invalid JSON syntax.
pub use self::error::ERROR_CODE_JSON_SYNTAX_INVALID;
/// Error code used when an explicit management operation is unsupported or targets the wrong managed resource kind.
pub use self::error::ERROR_CODE_MANAGEMENT_OPERATION_INVALID;
/// Error code used when atomic managed Config or `.env` persistence fails before commit.
pub use self::error::ERROR_CODE_PERSISTENCE_WRITE_FAILED;
/// Error code used when an explicitly requested Config profile does not exist.
pub use self::error::ERROR_CODE_PROFILE_NOT_FOUND;
/// Error code used when a JSON Schema document is itself invalid.
pub use self::error::ERROR_CODE_SCHEMA_INVALID;
/// Error code used when a Config document fails its registered JSON Schema validation.
pub use self::error::ERROR_CODE_SCHEMA_VALIDATION_FAILED;
/// Effective standard Logging configuration mapped to `ksp_logging_lib::LoggingSettings`.
pub use self::logging::ResolvedLoggingConfig;
/// Result of one validated Config document persistence operation.
pub use self::management::ConfigDocumentChangeReport;
/// Result of one persistent `.env` mutation.
pub use self::management::ConfigEnvironmentChangeReport;
/// Safe desired/effective/shadow view of one KSP/KSPB environment variable.
pub use self::management::ConfigEnvironmentReport;
/// Raw source of one registered Config document read through the explicit management surface.
pub use self::management::ConfigManagedSource;
/// Explicit Config management facade for source inspection and validated persistent mutations.
pub use self::management::ConfigManagement;
/// Typed source contract for `config/std.logging.json`.
pub use self::management::LoggingConfigDocument;
/// Typed source contract for the standard Logging console output.
pub use self::management::LoggingConsoleConfig;
/// Typed source contract for one persistent Logging file output.
pub use self::management::LoggingFileConfig;
/// Typed source contract for one Logging sink selector/filter.
pub use self::management::LoggingOutputFilterConfig;
/// Typed source contract for one profile in `std.logging.json`.
pub use self::management::LoggingProfileConfig;
/// Typed source contract for one global Logging target override.
pub use self::management::LoggingTargetFilterConfig;
/// Source that selected an effective standard Config profile.
pub use self::profile::ConfigProfileSelectionSource;
/// Origin of one top-level value in a resolved standard Config profile.
pub use self::profile::ConfigValueOrigin;
/// Validated standard Config document resolved to one profile with global/profile provenance.
pub use self::profile::ResolvedConfigProfile;
/// Bootstrap argument used to replace a known Config filename mapping.
pub use self::registry::ARG_FILE_MAP;
/// Logical descriptor associating a stable file identifier with its physical filename and validation schema.
pub use self::registry::ConfigFileDescriptor;
/// Stable logical identifier for a Config-managed file.
pub use self::registry::ConfigFileId;
/// Physical root category used to resolve a Config-managed file.
pub use self::registry::ConfigFileKind;
/// Registry of KSP-known logical Config files and their replaceable physical filenames.
pub use self::registry::ConfigFileRegistry;
/// Default physical filename for the generic composite JSON Schema document.
pub use self::registry::DEFAULT_COMPOSITE_SCHEMA_FILENAME;
/// Default physical filename for the standard Logging configuration document.
pub use self::registry::DEFAULT_STD_LOGGING_FILENAME;
/// Default physical filename for the standard Logging JSON Schema document.
pub use self::registry::DEFAULT_STD_LOGGING_SCHEMA_FILENAME;
/// 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.
pub use self::registry::FILE_ID_SCHEMA_COMPOSITE;
/// Logical file identifier for the standard Logging JSON Schema document.
pub use self::registry::FILE_ID_SCHEMA_STD_LOGGING;
/// Logical file identifier for the standard HTTP Transport JSON Schema document.
pub use self::registry::FILE_ID_SCHEMA_STD_TRANSPORT;
/// Logical file identifier for the standard Logging configuration document.
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.
pub use self::sensitivity::ConfigSensitivity;
/// Provenance segment participating in one resolved Config value.
pub use self::sensitivity::ConfigValueProvenance;
/// Replacement used for secret environment fragments in safe diagnostic representations.
pub use self::sensitivity::REDACTED_CONFIG_VALUE;
/// Recursively resolved JSON value preserving real/safe trees and provenance.
pub use self::sensitivity::ResolvedConfigJson;
/// One resolved Config string preserving real/safe representations and provenance.
pub use self::sensitivity::ResolvedConfigText;
/// Effective standard HTTP Transport configuration mapped to `ksp_onchain_transport_lib::HttpTransportSettings`.
pub use self::transport::ResolvedTransportConfig;

View File

@@ -0,0 +1,437 @@
// file: crates/ksp-config-lib/src/logging.rs
// version: 1
/// Effective standard Logging configuration resolved from Config and mapped to the Logging runtime contract.
#[derive(Clone, Eq, PartialEq)]
pub struct ResolvedLoggingConfig {
file_id: crate::ConfigFileId,
source_path: std::path::PathBuf,
profile_id: String,
selection_source: crate::ConfigProfileSelectionSource,
effective: crate::ResolvedConfigJson,
logs_directory: std::path::PathBuf,
settings: ksp_logging_lib::LoggingSettings,
}
impl ResolvedLoggingConfig {
/// Returns the logical Config document identifier used by this runtime configuration.
#[must_use]
pub const fn file_id(&self) -> &crate::ConfigFileId {
return &self.file_id;
}
/// Returns the physical source Config document path.
#[must_use]
pub fn source_path(&self) -> &std::path::Path {
return self.source_path.as_path();
}
/// Returns the selected standard Logging profile identifier.
#[must_use]
pub fn profile_id(&self) -> &str {
return self.profile_id.as_str();
}
/// Returns the source that selected the standard Logging profile.
#[must_use]
pub const fn selection_source(&self) -> crate::ConfigProfileSelectionSource {
return self.selection_source;
}
/// Returns the detailed environment-resolved effective Config view.
///
/// The real tree is available to legitimate runtime consumers and the safe tree is suitable for ordinary diagnostics.
#[must_use]
pub const fn effective(&self) -> &crate::ResolvedConfigJson {
return &self.effective;
}
/// Returns the validated real Logging root directory.
///
/// Relative Config values are anchored to the process current working directory when this adapter runs. Absolute Config values are preserved.
#[must_use]
pub fn logs_directory(&self) -> &std::path::Path {
return self.logs_directory.as_path();
}
/// Returns the mapped runtime Logging settings.
#[must_use]
pub const fn settings(&self) -> &ksp_logging_lib::LoggingSettings {
return &self.settings;
}
/// Consumes this resolved Config and returns the mapped runtime Logging settings.
#[must_use]
pub fn into_settings(self) -> ksp_logging_lib::LoggingSettings {
return self.settings;
}
}
impl std::fmt::Debug for ResolvedLoggingConfig {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
return formatter
.debug_struct("ResolvedLoggingConfig")
.field("file_id", &self.file_id)
.field("source_path", &self.source_path)
.field("profile_id", &self.profile_id)
.field("selection_source", &self.selection_source)
.field("effective", &self.effective)
.finish_non_exhaustive();
}
}
impl crate::ConfigDocumentEngine {
/// Loads the standard Logging document, selects a profile, resolves environment placeholders and maps the effective result to `LoggingSettings`.
///
/// `requested_profile = None` uses the document `default_profile`; `Some(profile_id)` requests an explicit profile. Source JSON validation remains distinct
/// from effective runtime validation: an environment value that resolves to an invalid Logging setting returns
/// [`crate::ERROR_CODE_EFFECTIVE_CONFIG_INVALID`] and does not silently fall back to the placeholder fallback.
pub fn load_resolved_logging_config(
&self,
requested_profile: std::option::Option<&str>,
environment: &crate::ConfigEnvironment,
) -> ksp_core_lib::Result<ResolvedLoggingConfig> {
let file_id = crate::ConfigFileId::new(crate::FILE_ID_STD_LOGGING);
let file_id = match file_id {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let profile = self.load_resolved_profile(&file_id, requested_profile);
let profile = match profile {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
return resolve_logging_profile(&profile, environment);
}
}
#[derive(serde::Deserialize)]
#[serde(deny_unknown_fields)]
struct EffectiveLoggingSource {
format_version: u32,
logs_directory: String,
profile_id: String,
default_filter: String,
span_events: String,
console: EffectiveConsoleSource,
files: std::vec::Vec<EffectiveFileSource>,
target_filters: std::vec::Vec<EffectiveTargetFilterSource>,
}
#[derive(serde::Deserialize)]
#[serde(deny_unknown_fields)]
struct EffectiveConsoleSource {
enabled: bool,
output: String,
ansi: bool,
format: String,
filter: EffectiveOutputFilterSource,
}
#[derive(serde::Deserialize)]
#[serde(deny_unknown_fields)]
struct EffectiveFileSource {
output_id: String,
enabled: bool,
path: String,
rotation: String,
format: String,
ansi: bool,
filter: EffectiveOutputFilterSource,
}
#[derive(serde::Deserialize)]
#[serde(deny_unknown_fields)]
struct EffectiveOutputFilterSource {
level: String,
targets: std::vec::Vec<String>,
domains: std::vec::Vec<String>,
}
#[derive(serde::Deserialize)]
#[serde(deny_unknown_fields)]
struct EffectiveTargetFilterSource {
target_prefix: String,
level: String,
}
fn resolve_logging_profile(profile: &crate::ResolvedConfigProfile, environment: &crate::ConfigEnvironment) -> ksp_core_lib::Result<ResolvedLoggingConfig> {
let effective = profile.resolve_effective_environment_detailed(environment);
let effective = match effective {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let sensitivity_validation = validate_logging_sensitivity(profile, &effective);
if let std::result::Result::Err(error) = sensitivity_validation {
return std::result::Result::Err(error);
}
let source = serde_json::from_value::<EffectiveLoggingSource>(effective.value().clone());
let source = match source {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => {
return std::result::Result::Err(
effective_error(profile, "effective Logging Config cannot be decoded into the runtime adapter contract").with_source(error),
);
},
};
if source.format_version != 1 {
return std::result::Result::Err(effective_error(profile, "effective Logging format_version is unsupported"));
}
if source.profile_id != profile.profile_id() {
return std::result::Result::Err(effective_error(profile, "effective Logging profile_id differs from the selected source profile"));
}
let safe_logs_directory = safe_string_at(effective.safe_value(), "/logs_directory");
let logs_directory = resolve_logs_directory(source.logs_directory.as_str(), safe_logs_directory.as_str(), profile);
let logs_directory = match logs_directory {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let default_filter = map_level(source.default_filter.as_str(), "default_filter", profile);
let default_filter = match default_filter {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let span_events = map_span_events(source.span_events.as_str(), profile);
let span_events = match span_events {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let console = map_console(source.console, profile);
let console = match console {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let files = map_files(source.files, logs_directory.as_path(), profile);
let files = match files {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let mut settings = ksp_logging_lib::LoggingSettings::new(default_filter, span_events, std::option::Option::Some(console), files);
for target_filter in source.target_filters {
let level = map_level(target_filter.level.as_str(), "target_filters.level", profile);
let level = match level {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
settings = settings.with_target_filter(ksp_logging_lib::TargetFilter::new(target_filter.target_prefix, level));
}
let validation = settings.validate();
if let std::result::Result::Err(error) = validation {
return std::result::Result::Err(
effective_error(profile, "effective Logging settings fail the Logging runtime contract")
.with_context("logging_error_domain", error.code().domain())
.with_context("logging_error_code", error.code().code()),
);
}
return std::result::Result::Ok(ResolvedLoggingConfig {
file_id: profile.file_id().clone(),
source_path: profile.path().to_path_buf(),
profile_id: profile.profile_id().to_owned(),
selection_source: profile.selection_source(),
effective,
logs_directory,
settings,
});
}
fn validate_logging_sensitivity(profile: &crate::ResolvedConfigProfile, effective: &crate::ResolvedConfigJson) -> ksp_core_lib::Result<()> {
if effective.sensitivity().is_secret() {
return std::result::Result::Err(effective_error(profile, "standard Logging configuration must not consume Secret environment values"));
}
return std::result::Result::Ok(());
}
fn map_console(source: EffectiveConsoleSource, profile: &crate::ResolvedConfigProfile) -> ksp_core_lib::Result<ksp_logging_lib::ConsoleSettings> {
let output = match source.output.as_str() {
"stdout" => ksp_logging_lib::ConsoleOutput::Stdout,
"stderr" => ksp_logging_lib::ConsoleOutput::Stderr,
_ => return std::result::Result::Err(effective_field_error(profile, "console.output", "effective Logging console output is unsupported")),
};
let format = map_format(source.format.as_str(), "console.format", profile);
let format = match format {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let filter = map_output_filter(source.filter, "console.filter", profile);
let filter = match filter {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
return std::result::Result::Ok(ksp_logging_lib::ConsoleSettings::new(source.enabled, output, source.ansi, format, filter));
}
fn map_files(
sources: std::vec::Vec<EffectiveFileSource>,
logs_directory: &std::path::Path,
profile: &crate::ResolvedConfigProfile,
) -> ksp_core_lib::Result<std::vec::Vec<ksp_logging_lib::FileSettings>> {
let mut files = std::vec::Vec::<ksp_logging_lib::FileSettings>::with_capacity(sources.len());
for source in sources {
if !relative_file_path_is_valid(source.path.as_str()) {
return std::result::Result::Err(
effective_field_error(profile, "files.path", "effective Logging file path must stay relative to logs_directory without traversal")
.with_context("output_id", source.output_id.as_str()),
);
}
let file_path = std::path::Path::new(source.path.as_str());
let file_name = match file_path.file_name().and_then(std::ffi::OsStr::to_str) {
std::option::Option::Some(value) if !value.is_empty() => value.to_owned(),
_ => return std::result::Result::Err(effective_field_error(profile, "files.path", "effective Logging file path has no UTF-8 file name")),
};
let relative_directory = match file_path.parent() {
std::option::Option::Some(value) => value,
std::option::Option::None => std::path::Path::new(""),
};
let directory = logs_directory.join(relative_directory);
let rotation = map_rotation(source.rotation.as_str(), profile);
let rotation = match rotation {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let format = map_format(source.format.as_str(), "files.format", profile);
let format = match format {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let filter = map_output_filter(source.filter, "files.filter", profile);
let filter = match filter {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let file = ksp_logging_lib::FileSettings::new(source.output_id, source.enabled, directory, file_name, rotation, format, filter).with_ansi(source.ansi);
files.push(file);
}
return std::result::Result::Ok(files);
}
fn map_output_filter(
source: EffectiveOutputFilterSource,
field: &'static str,
profile: &crate::ResolvedConfigProfile,
) -> ksp_core_lib::Result<ksp_logging_lib::OutputFilter> {
let level = map_level(source.level.as_str(), field, profile);
let level = match level {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
return std::result::Result::Ok(ksp_logging_lib::OutputFilter::new(level, source.targets, source.domains));
}
fn map_level(value: &str, field: &'static str, profile: &crate::ResolvedConfigProfile) -> ksp_core_lib::Result<ksp_logging_lib::LogFilterLevel> {
return match value {
"off" => std::result::Result::Ok(ksp_logging_lib::LogFilterLevel::Off),
"error" => std::result::Result::Ok(ksp_logging_lib::LogFilterLevel::Error),
"warn" => std::result::Result::Ok(ksp_logging_lib::LogFilterLevel::Warn),
"info" => std::result::Result::Ok(ksp_logging_lib::LogFilterLevel::Info),
"debug" => std::result::Result::Ok(ksp_logging_lib::LogFilterLevel::Debug),
"trace" => std::result::Result::Ok(ksp_logging_lib::LogFilterLevel::Trace),
_ => std::result::Result::Err(effective_field_error(profile, field, "effective Logging level is unsupported")),
};
}
fn map_span_events(value: &str, profile: &crate::ResolvedConfigProfile) -> ksp_core_lib::Result<ksp_logging_lib::SpanEvents> {
return match value {
"off" => std::result::Result::Ok(ksp_logging_lib::SpanEvents::Off),
"new_and_close" => std::result::Result::Ok(ksp_logging_lib::SpanEvents::NewAndClose),
"full" => std::result::Result::Ok(ksp_logging_lib::SpanEvents::Full),
_ => std::result::Result::Err(effective_field_error(profile, "span_events", "effective Logging span_events value is unsupported")),
};
}
fn map_format(value: &str, field: &'static str, profile: &crate::ResolvedConfigProfile) -> ksp_core_lib::Result<ksp_logging_lib::LogFormat> {
return match value {
"human" => std::result::Result::Ok(ksp_logging_lib::LogFormat::Human),
"compact" => std::result::Result::Ok(ksp_logging_lib::LogFormat::Compact),
"pretty" => std::result::Result::Ok(ksp_logging_lib::LogFormat::Pretty),
"json" => std::result::Result::Ok(ksp_logging_lib::LogFormat::Json),
_ => std::result::Result::Err(effective_field_error(profile, field, "effective Logging format is unsupported")),
};
}
fn map_rotation(value: &str, profile: &crate::ResolvedConfigProfile) -> ksp_core_lib::Result<ksp_logging_lib::FileRotation> {
return match value {
"never" => std::result::Result::Ok(ksp_logging_lib::FileRotation::Never),
"hourly" => std::result::Result::Ok(ksp_logging_lib::FileRotation::Hourly),
"daily" => std::result::Result::Ok(ksp_logging_lib::FileRotation::Daily),
_ => std::result::Result::Err(effective_field_error(profile, "files.rotation", "effective Logging rotation is unsupported")),
};
}
fn resolve_logs_directory(value: &str, safe_value: &str, profile: &crate::ResolvedConfigProfile) -> ksp_core_lib::Result<std::path::PathBuf> {
if value.trim().is_empty() {
return std::result::Result::Err(
effective_field_error(profile, "logs_directory", "effective Logging logs_directory must not be empty").with_context("safe_value", safe_value),
);
}
let configured = std::path::PathBuf::from(value);
let resolved = if configured.is_absolute() {
configured
} else {
let current_directory = std::env::current_dir();
let current_directory = match current_directory {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => {
return std::result::Result::Err(
effective_field_error(profile, "logs_directory", "process current working directory cannot be resolved").with_source(error),
);
},
};
current_directory.join(configured)
};
let metadata = std::fs::metadata(resolved.as_path());
match metadata {
std::result::Result::Ok(value) if !value.is_dir() => {
return std::result::Result::Err(
effective_field_error(profile, "logs_directory", "effective Logging logs_directory resolves to an existing non-directory path")
.with_context("safe_value", safe_value),
);
},
std::result::Result::Ok(_) => {},
std::result::Result::Err(error) if error.kind() == std::io::ErrorKind::NotFound => {},
std::result::Result::Err(error) => {
return std::result::Result::Err(
effective_field_error(profile, "logs_directory", "effective Logging logs_directory cannot be inspected")
.with_context("safe_value", safe_value)
.with_source(error),
);
},
}
return std::result::Result::Ok(resolved);
}
fn relative_file_path_is_valid(value: &str) -> bool {
let path = std::path::Path::new(value);
if path.is_absolute() {
return false;
}
let mut has_normal_component = false;
for component in path.components() {
match component {
std::path::Component::Normal(_) => has_normal_component = true,
std::path::Component::CurDir | std::path::Component::ParentDir | std::path::Component::RootDir | std::path::Component::Prefix(_) => return false,
}
}
return has_normal_component;
}
fn safe_string_at(value: &serde_json::Value, pointer: &str) -> String {
return match value.pointer(pointer).and_then(serde_json::Value::as_str) {
std::option::Option::Some(value) => value.to_owned(),
std::option::Option::None => "<unavailable>".to_owned(),
};
}
fn effective_field_error(profile: &crate::ResolvedConfigProfile, field: &'static str, reason: &'static str) -> ksp_core_lib::Error {
return effective_error(profile, reason).with_context("field", field);
}
fn effective_error(profile: &crate::ResolvedConfigProfile, reason: &'static str) -> ksp_core_lib::Error {
return ksp_core_lib::Error::new(crate::ERROR_CODE_EFFECTIVE_CONFIG_INVALID, "effective Config cannot be mapped to the requested runtime contract")
.with_context("file_id", profile.file_id().as_str())
.with_context("profile_id", profile.profile_id())
.with_context("reason", reason);
}
#[cfg(test)]
#[path = "../unit_tests/logging.rs"]
mod tests;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,119 @@
// file: crates/ksp-config-lib/src/persistence.rs
// version: 3
static NEXT_TEMPORARY_FILE_ID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
pub(crate) fn atomic_write(path: &std::path::Path, content: &[u8]) -> ksp_core_lib::Result<()> {
return atomic_write_with_policy(path, content, false);
}
pub(crate) fn atomic_write_private(path: &std::path::Path, content: &[u8]) -> ksp_core_lib::Result<()> {
return atomic_write_with_policy(path, content, true);
}
fn atomic_write_with_policy(path: &std::path::Path, content: &[u8], private_when_new: bool) -> ksp_core_lib::Result<()> {
let parent = match path.parent() {
std::option::Option::Some(value) if !value.as_os_str().is_empty() => value,
_ => std::path::Path::new("."),
};
let filename = match path.file_name().and_then(std::ffi::OsStr::to_str) {
std::option::Option::Some(value) if !value.is_empty() => value,
_ => return std::result::Result::Err(persistence_error(path, "managed Config path has no UTF-8 file name")),
};
let existing_permissions = destination_permissions(path);
let existing_permissions = match existing_permissions {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let temporary_id = NEXT_TEMPORARY_FILE_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let temporary_name = format!(".{filename}.ksp-tmp-{}-{temporary_id}", std::process::id());
let temporary_path = parent.join(temporary_name);
let opened = std::fs::OpenOptions::new().write(true).create_new(true).open(temporary_path.as_path());
let mut file = match opened {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(persistence_io_error(path, "temporary Config file cannot be created", error)),
};
let permissions = apply_temporary_permissions(&file, existing_permissions, private_when_new);
if let std::result::Result::Err(error) = permissions {
cleanup_temporary_file(temporary_path.as_path());
return std::result::Result::Err(persistence_io_error(path, "temporary Config file permissions cannot be applied", error));
}
let write = std::io::Write::write_all(&mut file, content);
if let std::result::Result::Err(error) = write {
cleanup_temporary_file(temporary_path.as_path());
return std::result::Result::Err(persistence_io_error(path, "temporary Config file cannot be written", error));
}
let sync = file.sync_all();
if let std::result::Result::Err(error) = sync {
cleanup_temporary_file(temporary_path.as_path());
return std::result::Result::Err(persistence_io_error(path, "temporary Config file cannot be synchronized", error));
}
drop(file);
let rename = std::fs::rename(temporary_path.as_path(), path);
if let std::result::Result::Err(error) = rename {
cleanup_temporary_file(temporary_path.as_path());
return std::result::Result::Err(persistence_io_error(path, "atomic Config file replacement failed", error));
}
return std::result::Result::Ok(());
}
fn destination_permissions(path: &std::path::Path) -> ksp_core_lib::Result<std::option::Option<std::fs::Permissions>> {
let metadata = std::fs::metadata(path);
return match metadata {
std::result::Result::Ok(value) => std::result::Result::Ok(std::option::Option::Some(value.permissions())),
std::result::Result::Err(error) if error.kind() == std::io::ErrorKind::NotFound => std::result::Result::Ok(std::option::Option::None),
std::result::Result::Err(error) => {
std::result::Result::Err(persistence_io_error(path, "managed Config file metadata cannot be read before replacement", error))
},
};
}
fn apply_temporary_permissions(
file: &std::fs::File,
existing_permissions: std::option::Option<std::fs::Permissions>,
private_when_new: bool,
) -> std::io::Result<()> {
if let std::option::Option::Some(permissions) = existing_permissions {
return file.set_permissions(permissions);
}
return apply_new_file_permissions(file, private_when_new);
}
#[cfg(unix)]
fn apply_new_file_permissions(file: &std::fs::File, private_when_new: bool) -> std::io::Result<()> {
if private_when_new {
let permissions = <std::fs::Permissions as std::os::unix::fs::PermissionsExt>::from_mode(0o600);
return file.set_permissions(permissions);
}
return std::result::Result::Ok(());
}
#[cfg(not(unix))]
fn apply_new_file_permissions(_file: &std::fs::File, _private_when_new: bool) -> std::io::Result<()> {
return std::result::Result::Ok(());
}
fn cleanup_temporary_file(path: &std::path::Path) {
let removal = std::fs::remove_file(path);
if let std::result::Result::Err(error) = removal
&& error.kind() != std::io::ErrorKind::NotFound
{
ksp_logging_lib::warn!(
target: crate::TRACING_TARGET,
domain = "config.persistence",
path = %path.to_string_lossy(),
error = %error,
"unable to cleanup temporary Config file"
);
}
}
fn persistence_error(path: &std::path::Path, reason: &'static str) -> ksp_core_lib::Error {
return ksp_core_lib::Error::new(crate::ERROR_CODE_PERSISTENCE_WRITE_FAILED, "Config persistence failed")
.with_context("path", path.to_string_lossy().into_owned())
.with_context("reason", reason);
}
fn persistence_io_error(path: &std::path::Path, reason: &'static str, source: std::io::Error) -> ksp_core_lib::Error {
return persistence_error(path, reason).with_source(source);
}

View File

@@ -0,0 +1,292 @@
// file: crates/ksp-config-lib/src/profile.rs
// version: 4
/// Origin of one top-level value in a resolved standard Config profile.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ConfigValueOrigin {
/// Value comes from the global section of the specialized document.
Global,
/// Value comes from the selected profile object.
Profile,
}
/// Source that selected the effective profile.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ConfigProfileSelectionSource {
/// The document's autonomous `default_profile` selected the profile.
DefaultProfile,
/// A caller explicitly requested the profile by `profile_id`.
Explicit,
/// A composite document selected the referenced standard document profile.
Composite,
}
/// Validated standard document resolved to one profile while retaining global/profile provenance.
#[derive(Clone, Debug, PartialEq)]
pub struct ResolvedConfigProfile {
file_id: crate::ConfigFileId,
path: std::path::PathBuf,
profile_id: String,
selection_source: ConfigProfileSelectionSource,
globals: serde_json::Map<String, serde_json::Value>,
profile: serde_json::Map<String, serde_json::Value>,
effective: serde_json::Map<String, serde_json::Value>,
origins: std::collections::BTreeMap<String, ConfigValueOrigin>,
}
impl ResolvedConfigProfile {
/// Returns the logical document identifier from which this profile was resolved.
#[must_use]
pub fn file_id(&self) -> &crate::ConfigFileId {
return &self.file_id;
}
/// Returns the physical path of the validated source document.
#[must_use]
pub fn path(&self) -> &std::path::Path {
return self.path.as_path();
}
/// Returns the selected unique profile identifier.
#[must_use]
pub fn profile_id(&self) -> &str {
return self.profile_id.as_str();
}
/// Returns whether selection came from `default_profile` or an explicit caller request.
#[must_use]
pub const fn selection_source(&self) -> ConfigProfileSelectionSource {
return self.selection_source;
}
/// Returns document-global values, excluding the reserved `default_profile` and `profiles` keys.
#[must_use]
pub fn globals(&self) -> &serde_json::Map<String, serde_json::Value> {
return &self.globals;
}
/// Returns the selected profile object including its `profile_id`.
#[must_use]
pub fn profile(&self) -> &serde_json::Map<String, serde_json::Value> {
return &self.profile;
}
/// Returns a deterministic top-level effective view in which selected profile keys override same-named global keys.
#[must_use]
pub fn effective(&self) -> &serde_json::Map<String, serde_json::Value> {
return &self.effective;
}
/// Returns the top-level provenance for an effective key.
#[must_use]
pub fn origin(&self, key: &str) -> std::option::Option<ConfigValueOrigin> {
return self.origins.get(key).copied();
}
/// Resolves environment placeholders in the effective view and returns only the real runtime map.
///
/// Use [`Self::resolve_effective_environment_detailed`] when safe value, sensitivity and environment provenance are required. Global/Profile provenance on
/// this source profile remains unchanged in both cases.
pub fn resolve_effective_environment(&self, environment: &crate::ConfigEnvironment) -> ksp_core_lib::Result<serde_json::Map<String, serde_json::Value>> {
let resolved = self.resolve_effective_environment_detailed(environment);
let resolved = match resolved {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
return match resolved.value() {
serde_json::Value::Object(value) => std::result::Result::Ok(value.clone()),
_ => std::result::Result::Err(ksp_core_lib::Error::new(
crate::ERROR_CODE_DOCUMENT_SEMANTIC_INVALID,
"resolved Config profile effective view changed JSON shape",
)),
};
}
/// Resolves environment placeholders while preserving real/safe JSON trees, strongest sensitivity and JSON-Pointer environment provenance.
///
/// Top-level Global/Profile provenance remains available through [`Self::origin`]; the returned value adds literal/process/`.env`/fallback provenance for
/// the environment-resolution stage.
pub fn resolve_effective_environment_detailed(&self, environment: &crate::ConfigEnvironment) -> ksp_core_lib::Result<crate::ResolvedConfigJson> {
return environment.resolve_json_detailed(&serde_json::Value::Object(self.effective.clone()));
}
}
impl crate::ConfigDocumentEngine {
/// Loads, validates and resolves one standard Config document to its default or explicitly requested profile.
///
/// Passing `None` selects the autonomous `default_profile` declared by the document. Passing `Some(profile_id)` selects that profile explicitly.
/// Environment interpolation is intentionally not applied implicitly by profile selection. Call `ResolvedConfigProfile::resolve_effective_environment` for
/// a real runtime map or `ResolvedConfigProfile::resolve_effective_environment_detailed` when safe value, sensitivity and provenance are also required.
pub fn load_resolved_profile(
&self,
file_id: &crate::ConfigFileId,
requested_profile: std::option::Option<&str>,
) -> ksp_core_lib::Result<ResolvedConfigProfile> {
let document = self.load_validated_document(file_id);
let document = match document {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let source = match requested_profile {
std::option::Option::Some(_) => ConfigProfileSelectionSource::Explicit,
std::option::Option::None => ConfigProfileSelectionSource::DefaultProfile,
};
return resolve_document_profile(&document, requested_profile, source);
}
}
pub(crate) fn load_resolved_profile_with_source(
engine: &crate::ConfigDocumentEngine,
file_id: &crate::ConfigFileId,
requested_profile: std::option::Option<&str>,
explicit_source: ConfigProfileSelectionSource,
) -> ksp_core_lib::Result<ResolvedConfigProfile> {
let document = engine.load_validated_document(file_id);
let document = match document {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let source = match requested_profile {
std::option::Option::Some(_) => explicit_source,
std::option::Option::None => ConfigProfileSelectionSource::DefaultProfile,
};
return resolve_document_profile(&document, requested_profile, source);
}
pub(crate) fn validate_document_profile_contract(document: &crate::ConfigJsonDocument) -> ksp_core_lib::Result<()> {
let root = match document.value().as_object() {
std::option::Option::Some(value) => value,
std::option::Option::None => return std::result::Result::Ok(()),
};
let default_profile = root.get("default_profile");
let profiles = root.get("profiles");
if default_profile.is_none() && profiles.is_none() {
return std::result::Result::Ok(());
}
let default_profile = match default_profile.and_then(serde_json::Value::as_str) {
std::option::Option::Some(value) if !value.trim().is_empty() => value,
_ => return std::result::Result::Err(profile_semantic_error(document, "default_profile must identify a non-empty profile_id")),
};
let profiles = match profiles.and_then(serde_json::Value::as_array) {
std::option::Option::Some(value) if !value.is_empty() => value,
_ => return std::result::Result::Err(profile_semantic_error(document, "profiles must contain at least one profile object")),
};
let mut ids = std::collections::BTreeSet::<String>::new();
let mut default_found = false;
for (index, profile) in profiles.iter().enumerate() {
let object = match profile.as_object() {
std::option::Option::Some(value) => value,
std::option::Option::None => {
return std::result::Result::Err(
profile_semantic_error(document, "profile entry must be an object").with_context("profile_index", index.to_string()),
);
},
};
let profile_id = match object.get("profile_id").and_then(serde_json::Value::as_str) {
std::option::Option::Some(value) if !value.trim().is_empty() => value,
_ => {
return std::result::Result::Err(
profile_semantic_error(document, "profile entry must declare a non-empty profile_id").with_context("profile_index", index.to_string()),
);
},
};
if ids.contains(profile_id) {
return std::result::Result::Err(
profile_semantic_error(document, "profile_id values must be unique")
.with_context("profile_index", index.to_string())
.with_context("profile_id", profile_id),
);
}
ids.insert(profile_id.to_owned());
if profile_id == default_profile {
default_found = true;
}
}
if !default_found {
return std::result::Result::Err(
profile_semantic_error(document, "default_profile must reference an existing profile_id").with_context("default_profile", default_profile),
);
}
return std::result::Result::Ok(());
}
fn resolve_document_profile(
document: &crate::ConfigJsonDocument,
requested_profile: std::option::Option<&str>,
explicit_source: ConfigProfileSelectionSource,
) -> ksp_core_lib::Result<ResolvedConfigProfile> {
let root = match document.value().as_object() {
std::option::Option::Some(value) => value,
std::option::Option::None => return std::result::Result::Err(profile_semantic_error(document, "profile resolution requires an object document")),
};
let default_profile = match root.get("default_profile").and_then(serde_json::Value::as_str) {
std::option::Option::Some(value) => value,
std::option::Option::None => return std::result::Result::Err(profile_semantic_error(document, "profile resolution requires default_profile")),
};
let (selected_profile, selection_source) = match requested_profile {
std::option::Option::Some(value) => (value, explicit_source),
std::option::Option::None => (default_profile, ConfigProfileSelectionSource::DefaultProfile),
};
let profiles = match root.get("profiles").and_then(serde_json::Value::as_array) {
std::option::Option::Some(value) => value,
std::option::Option::None => return std::result::Result::Err(profile_semantic_error(document, "profile resolution requires profiles")),
};
let mut selected: std::option::Option<&serde_json::Map<String, serde_json::Value>> = std::option::Option::None;
for profile in profiles {
let object = match profile.as_object() {
std::option::Option::Some(value) => value,
std::option::Option::None => continue,
};
if object.get("profile_id").and_then(serde_json::Value::as_str) == std::option::Option::Some(selected_profile) {
selected = std::option::Option::Some(object);
break;
}
}
let selected = match selected {
std::option::Option::Some(value) => value,
std::option::Option::None => {
return std::result::Result::Err(
ksp_core_lib::Error::new(crate::ERROR_CODE_PROFILE_NOT_FOUND, "requested Config profile does not exist")
.with_context("file_id", document.file_id().as_str())
.with_context("path", document.path().to_string_lossy().into_owned())
.with_context("profile_id", selected_profile),
);
},
};
let mut globals = serde_json::Map::<String, serde_json::Value>::new();
let mut effective = serde_json::Map::<String, serde_json::Value>::new();
let mut origins = std::collections::BTreeMap::<String, ConfigValueOrigin>::new();
for (key, value) in root {
if key != "default_profile" && key != "profiles" {
globals.insert(key.clone(), value.clone());
effective.insert(key.clone(), value.clone());
origins.insert(key.clone(), ConfigValueOrigin::Global);
}
}
let profile = (*selected).clone();
for (key, value) in &profile {
effective.insert(key.clone(), value.clone());
origins.insert(key.clone(), ConfigValueOrigin::Profile);
}
return std::result::Result::Ok(ResolvedConfigProfile {
file_id: document.file_id().clone(),
path: document.path().to_path_buf(),
profile_id: selected_profile.to_owned(),
selection_source,
globals,
profile,
effective,
origins,
});
}
fn profile_semantic_error(document: &crate::ConfigJsonDocument, reason: &'static str) -> ksp_core_lib::Error {
return ksp_core_lib::Error::new(crate::ERROR_CODE_DOCUMENT_SEMANTIC_INVALID, "Config document violates KSP profile invariants")
.with_context("file_id", document.file_id().as_str())
.with_context("path", document.path().to_string_lossy().into_owned())
.with_context("reason", reason);
}
#[cfg(test)]
#[path = "../unit_tests/profile.rs"]
mod tests;

View File

@@ -0,0 +1,439 @@
// file: crates/ksp-config-lib/src/registry.rs
// version: 5
/// Bootstrap argument used to replace a known Config filename mapping.
pub const ARG_FILE_MAP: &str = "--filemap";
/// Logical file identifier for the standard Logging configuration document.
pub const FILE_ID_STD_LOGGING: &str = "cfg.std.logging";
/// Logical file identifier for the standard Logging JSON Schema document.
pub const FILE_ID_SCHEMA_STD_LOGGING: &str = "schema.std.logging";
/// Logical file identifier for the 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.
pub const FILE_ID_SCHEMA_COMPOSITE: &str = "schema.composite";
/// Default physical filename for the standard Logging configuration document.
pub const DEFAULT_STD_LOGGING_FILENAME: &str = "std.logging.json";
/// Default physical filename for the standard Logging JSON Schema document.
pub const DEFAULT_STD_LOGGING_SCHEMA_FILENAME: &str = "std.logging.schema.json";
/// Default physical filename for the 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.
pub const DEFAULT_COMPOSITE_SCHEMA_FILENAME: &str = "composite.schema.json";
/// Stable logical identifier for a Config-managed file.
#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
pub struct ConfigFileId(String);
impl ConfigFileId {
/// Creates and validates a logical Config file identifier.
pub fn new(value: impl std::convert::Into<String>) -> ksp_core_lib::Result<Self> {
let value = value.into();
let validated = validate_file_id(value.as_str());
return match validated {
std::result::Result::Ok(()) => std::result::Result::Ok(Self(value)),
std::result::Result::Err(error) => std::result::Result::Err(error),
};
}
/// Returns the logical identifier as text.
#[must_use]
pub fn as_str(&self) -> &str {
return self.0.as_str();
}
}
/// Physical root category used to resolve a Config-managed file.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ConfigFileKind {
/// Runtime configuration document resolved below `cfgpath`.
Config,
/// JSON Schema document resolved below `schemapath`.
Schema,
}
/// Logical descriptor associating a stable file identifier with its physical filename, root category and optional validation schema.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ConfigFileDescriptor {
file_id: ConfigFileId,
kind: ConfigFileKind,
filename: std::path::PathBuf,
schema_file_id: std::option::Option<ConfigFileId>,
}
impl ConfigFileDescriptor {
/// Returns the stable logical identifier.
#[must_use]
pub fn file_id(&self) -> &ConfigFileId {
return &self.file_id;
}
/// Returns the root category used when resolving the file.
#[must_use]
pub fn kind(&self) -> ConfigFileKind {
return self.kind;
}
/// Returns the relative physical filename currently mapped to the identifier.
#[must_use]
pub fn filename(&self) -> &std::path::Path {
return self.filename.as_path();
}
/// Returns the logical schema identifier associated with this Config document when one is declared.
#[must_use]
pub fn schema_file_id(&self) -> std::option::Option<&ConfigFileId> {
return self.schema_file_id.as_ref();
}
pub(crate) fn new(
file_id: &'static str,
kind: ConfigFileKind,
filename: &'static str,
schema_file_id: std::option::Option<&'static str>,
) -> ksp_core_lib::Result<Self> {
let file_id = ConfigFileId::new(file_id);
let file_id = match file_id {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let kind_validation = validate_kind_prefix(&file_id, kind);
match kind_validation {
std::result::Result::Ok(()) => {},
std::result::Result::Err(error) => return std::result::Result::Err(error),
}
let filename = validate_relative_filename(&file_id, std::path::PathBuf::from(filename));
let filename = match filename {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let schema_file_id = parse_schema_file_id(&file_id, kind, schema_file_id);
return match schema_file_id {
std::result::Result::Ok(value) => std::result::Result::Ok(Self { file_id, kind, filename, schema_file_id: value }),
std::result::Result::Err(error) => std::result::Result::Err(error),
};
}
}
/// Registry of KSP-known logical Config files and their replaceable physical filenames.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ConfigFileRegistry {
descriptors: std::collections::BTreeMap<ConfigFileId, ConfigFileDescriptor>,
}
impl ConfigFileRegistry {
/// Creates the registry containing the KSP default file mappings known in the current release.
pub fn defaults() -> ksp_core_lib::Result<Self> {
let logging = ConfigFileDescriptor::new(
FILE_ID_STD_LOGGING,
ConfigFileKind::Config,
DEFAULT_STD_LOGGING_FILENAME,
std::option::Option::Some(FILE_ID_SCHEMA_STD_LOGGING),
);
let logging = match logging {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let logging_schema =
ConfigFileDescriptor::new(FILE_ID_SCHEMA_STD_LOGGING, ConfigFileKind::Schema, DEFAULT_STD_LOGGING_SCHEMA_FILENAME, std::option::Option::None);
let logging_schema = match logging_schema {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let 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 =
ConfigFileDescriptor::new(FILE_ID_SCHEMA_COMPOSITE, ConfigFileKind::Schema, DEFAULT_COMPOSITE_SCHEMA_FILENAME, std::option::Option::None);
let composite_schema = match composite_schema {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
return build_registry([logging, logging_schema, transport, transport_schema, composite_schema]);
}
/// Creates the default registry and applies repeatable `--filemap=<file_id>=<filename>` overrides from raw process arguments.
///
/// Unrelated arguments are ignored. A repeated mapping for the same known `file_id` is accepted and the last mapping wins.
pub fn from_args(args: &[std::ffi::OsString]) -> ksp_core_lib::Result<Self> {
let registry = Self::defaults();
let mut registry = match registry {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let mut index: usize = 0;
while index < args.len() {
let application = apply_file_map_argument(&mut registry, &args[index]);
match application {
std::result::Result::Ok(()) => {},
std::result::Result::Err(error) => return std::result::Result::Err(error),
}
index += 1;
}
return std::result::Result::Ok(registry);
}
/// 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.
pub fn descriptor(&self, file_id: &ConfigFileId) -> ksp_core_lib::Result<&ConfigFileDescriptor> {
return match self.descriptors.get(file_id) {
std::option::Option::Some(descriptor) => std::result::Result::Ok(descriptor),
std::option::Option::None => std::result::Result::Err(unknown_file_id_error(file_id.as_str())),
};
}
/// Resolves a known logical file identifier below the bootstrap root selected by its descriptor kind.
pub fn resolve_path(&self, bootstrap: &crate::ConfigBootstrapOptions, file_id: &ConfigFileId) -> ksp_core_lib::Result<std::path::PathBuf> {
let descriptor = self.descriptor(file_id);
let descriptor = match descriptor {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let root = match descriptor.kind() {
ConfigFileKind::Config => bootstrap.cfg_path(),
ConfigFileKind::Schema => bootstrap.schema_path(),
};
return std::result::Result::Ok(root.join(descriptor.filename()));
}
/// Replaces the physical filename of one known logical identifier while preserving its kind, schema association and logical identity.
pub fn with_filename_override(mut self, file_id: &ConfigFileId, filename: impl std::convert::Into<std::path::PathBuf>) -> ksp_core_lib::Result<Self> {
let update = self.set_filename_override(file_id, filename.into());
return match update {
std::result::Result::Ok(()) => std::result::Result::Ok(self),
std::result::Result::Err(error) => std::result::Result::Err(error),
};
}
fn set_filename_override(&mut self, file_id: &ConfigFileId, filename: std::path::PathBuf) -> ksp_core_lib::Result<()> {
let descriptor = self.descriptors.get(file_id);
let descriptor = match descriptor {
std::option::Option::Some(value) => value.clone(),
std::option::Option::None => return std::result::Result::Err(unknown_file_id_error(file_id.as_str())),
};
let filename = validate_relative_filename(file_id, filename);
let filename = match filename {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let updated = ConfigFileDescriptor {
file_id: descriptor.file_id.clone(),
kind: descriptor.kind,
filename,
schema_file_id: descriptor.schema_file_id.clone(),
};
self.descriptors.insert(file_id.clone(), updated);
return std::result::Result::Ok(());
}
}
pub(crate) fn build_registry<const N: usize>(descriptors: [ConfigFileDescriptor; N]) -> ksp_core_lib::Result<ConfigFileRegistry> {
let mut registry = ConfigFileRegistry { descriptors: std::collections::BTreeMap::new() };
for descriptor in descriptors {
let file_id = descriptor.file_id.clone();
let duplicate_id = file_id.clone();
let previous = registry.descriptors.insert(file_id, descriptor);
if previous.is_some() {
return std::result::Result::Err(duplicate_file_id_error(duplicate_id.as_str()));
}
}
let associations = validate_schema_associations(&registry);
return match associations {
std::result::Result::Ok(()) => std::result::Result::Ok(registry),
std::result::Result::Err(error) => std::result::Result::Err(error),
};
}
fn parse_schema_file_id(
file_id: &ConfigFileId,
kind: ConfigFileKind,
schema_file_id: std::option::Option<&'static str>,
) -> ksp_core_lib::Result<std::option::Option<ConfigFileId>> {
return match (kind, schema_file_id) {
(ConfigFileKind::Schema, std::option::Option::Some(_)) => {
std::result::Result::Err(invalid_file_mapping_with_id_error(file_id.as_str(), "schema descriptors cannot declare another validation schema"))
},
(ConfigFileKind::Schema, std::option::Option::None) | (ConfigFileKind::Config, std::option::Option::None) => {
std::result::Result::Ok(std::option::Option::None)
},
(ConfigFileKind::Config, std::option::Option::Some(value)) => {
let schema_id = ConfigFileId::new(value);
match schema_id {
std::result::Result::Ok(schema_id) => {
if schema_id.as_str().starts_with("schema.") {
std::result::Result::Ok(std::option::Option::Some(schema_id))
} else {
std::result::Result::Err(invalid_file_mapping_with_id_error(
file_id.as_str(),
"validation schema file_id must use the schema namespace",
))
}
},
std::result::Result::Err(error) => std::result::Result::Err(error),
}
},
};
}
fn validate_schema_associations(registry: &ConfigFileRegistry) -> ksp_core_lib::Result<()> {
for descriptor in registry.descriptors.values() {
let schema_file_id = match descriptor.schema_file_id() {
std::option::Option::Some(value) => value,
std::option::Option::None => continue,
};
let schema = registry.descriptors.get(schema_file_id);
let schema = match schema {
std::option::Option::Some(value) => value,
std::option::Option::None => {
return std::result::Result::Err(invalid_file_mapping_with_id_error(
descriptor.file_id().as_str(),
"validation schema file_id is not registered",
));
},
};
if schema.kind() != ConfigFileKind::Schema {
return std::result::Result::Err(invalid_file_mapping_with_id_error(
descriptor.file_id().as_str(),
"validation schema descriptor must have schema kind",
));
}
}
return std::result::Result::Ok(());
}
fn apply_file_map_argument(registry: &mut ConfigFileRegistry, argument: &std::ffi::OsStr) -> ksp_core_lib::Result<()> {
let text = match argument.to_str() {
std::option::Option::Some(value) => value,
std::option::Option::None => return std::result::Result::Ok(()),
};
if text == ARG_FILE_MAP {
return std::result::Result::Err(invalid_file_mapping_error("--filemap requires the inline form --filemap=<file_id>=<filename>"));
}
let prefix = "--filemap=";
let mapping = match text.strip_prefix(prefix) {
std::option::Option::Some(value) => value,
std::option::Option::None => return std::result::Result::Ok(()),
};
let separator = mapping.find('=');
let separator = match separator {
std::option::Option::Some(value) => value,
std::option::Option::None => {
return std::result::Result::Err(invalid_file_mapping_error("file mapping must contain a file_id and filename separated by '='"));
},
};
let file_id_text = &mapping[..separator];
let filename_text = &mapping[separator + 1..];
if file_id_text.is_empty() || filename_text.is_empty() {
return std::result::Result::Err(invalid_file_mapping_error("file mapping requires non-empty file_id and filename values"));
}
let file_id = ConfigFileId::new(file_id_text);
let file_id = match file_id {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
return registry.set_filename_override(&file_id, std::path::PathBuf::from(filename_text));
}
fn validate_file_id(value: &str) -> ksp_core_lib::Result<()> {
if value.is_empty() || value.starts_with('.') || value.ends_with('.') || value.contains("..") {
return std::result::Result::Err(invalid_file_id_error(value));
}
let mut valid = true;
for byte in value.bytes() {
let allowed = byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'.' || byte == b'_' || byte == b'-';
if !allowed {
valid = false;
}
}
if !valid {
return std::result::Result::Err(invalid_file_id_error(value));
}
return std::result::Result::Ok(());
}
fn validate_kind_prefix(file_id: &ConfigFileId, kind: ConfigFileKind) -> ksp_core_lib::Result<()> {
let valid = match kind {
ConfigFileKind::Config => file_id.as_str().starts_with("cfg."),
ConfigFileKind::Schema => file_id.as_str().starts_with("schema."),
};
if !valid {
return std::result::Result::Err(invalid_file_mapping_with_id_error(file_id.as_str(), "file_id prefix does not match descriptor kind"));
}
return std::result::Result::Ok(());
}
fn validate_relative_filename(file_id: &ConfigFileId, filename: std::path::PathBuf) -> ksp_core_lib::Result<std::path::PathBuf> {
if filename.as_os_str().is_empty() || filename.is_absolute() {
return std::result::Result::Err(invalid_filename_error(file_id.as_str(), &filename));
}
let mut has_normal_component = false;
for component in filename.components() {
match component {
std::path::Component::Normal(_) => has_normal_component = true,
std::path::Component::CurDir | std::path::Component::ParentDir | std::path::Component::RootDir | std::path::Component::Prefix(_) => {
return std::result::Result::Err(invalid_filename_error(file_id.as_str(), &filename));
},
}
}
if !has_normal_component {
return std::result::Result::Err(invalid_filename_error(file_id.as_str(), &filename));
}
return std::result::Result::Ok(filename);
}
fn invalid_file_id_error(file_id: &str) -> ksp_core_lib::Error {
return ksp_core_lib::Error::new(crate::ERROR_CODE_FILE_ID_INVALID, "Config file_id is invalid")
.with_context("file_id", file_id)
.with_context("reason", "expected lowercase ASCII segments separated by single dots");
}
fn unknown_file_id_error(file_id: &str) -> ksp_core_lib::Error {
return ksp_core_lib::Error::new(crate::ERROR_CODE_FILE_ID_UNKNOWN, "Config file_id is not registered").with_context("file_id", file_id);
}
fn duplicate_file_id_error(file_id: &str) -> ksp_core_lib::Error {
return ksp_core_lib::Error::new(crate::ERROR_CODE_FILE_ID_DUPLICATE, "Config file_id is registered more than once").with_context("file_id", file_id);
}
fn invalid_file_mapping_error(reason: &'static str) -> ksp_core_lib::Error {
return ksp_core_lib::Error::new(crate::ERROR_CODE_FILE_MAPPING_INVALID, "Config file mapping is invalid").with_context("reason", reason);
}
fn invalid_file_mapping_with_id_error(file_id: &str, reason: &'static str) -> ksp_core_lib::Error {
return ksp_core_lib::Error::new(crate::ERROR_CODE_FILE_MAPPING_INVALID, "Config file mapping is invalid")
.with_context("file_id", file_id)
.with_context("reason", reason);
}
fn invalid_filename_error(file_id: &str, filename: &std::path::Path) -> ksp_core_lib::Error {
return ksp_core_lib::Error::new(crate::ERROR_CODE_FILE_MAPPING_INVALID, "Config mapped filename is invalid")
.with_context("file_id", file_id)
.with_context("filename", filename.to_string_lossy().into_owned())
.with_context("reason", "filename must stay relative to its Config-owned root without traversal components");
}
#[cfg(test)]
#[path = "../unit_tests/registry.rs"]
mod tests;

View File

@@ -0,0 +1,209 @@
// file: crates/ksp-config-lib/src/sensitivity.rs
// version: 1
/// Replacement used for secret environment fragments in safe diagnostic representations.
pub const REDACTED_CONFIG_VALUE: &str = "********";
/// Sensitivity assigned to one Config value after environment resolution.
#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
pub enum ConfigSensitivity {
/// Value may be exposed by a public projection when the consumer contract allows it.
Public,
/// Value is available to the runtime but is not generically public.
Internal,
/// Value must remain available to legitimate runtime/management consumers while being redacted from ordinary diagnostics.
Secret,
}
impl ConfigSensitivity {
/// Classifies one supported KSP/KSPB environment variable by its namespace.
pub fn from_variable_name(variable_name: &str) -> ksp_core_lib::Result<Self> {
let validation = crate::environment::validate_supported_variable_name(variable_name);
if let std::result::Result::Err(error) = validation {
return std::result::Result::Err(error);
}
if variable_name.starts_with("KSP_SECRET_") || variable_name.starts_with("KSPB_SECRET_") {
return std::result::Result::Ok(Self::Secret);
}
if variable_name.starts_with("KSP_PUBLIC_") || variable_name.starts_with("KSPB_PUBLIC_") {
return std::result::Result::Ok(Self::Public);
}
return std::result::Result::Ok(Self::Internal);
}
/// Returns the strongest of two sensitivities using `Secret > Internal > Public`.
#[must_use]
pub const fn strongest(self, other: Self) -> Self {
if self as u8 >= other as u8 {
return self;
}
return other;
}
/// Returns whether this sensitivity requires ordinary diagnostic redaction.
#[must_use]
pub const fn is_secret(self) -> bool {
return matches!(self, Self::Secret);
}
}
/// Provenance segment participating in one resolved Config value.
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum ConfigValueProvenance {
/// Literal text/value came directly from the Config document.
DocumentLiteral,
/// Environment substitution came from the inherited process environment.
EnvironmentProcess {
/// Referenced variable name; never its value.
variable_name: String,
},
/// Environment substitution came from the local `.env` file.
EnvironmentDotEnv {
/// Referenced variable name; never its value.
variable_name: String,
},
/// Environment substitution used the placeholder/API fallback.
EnvironmentFallback {
/// Referenced variable name; never its value.
variable_name: String,
},
}
impl ConfigValueProvenance {
/// Returns the referenced variable name for environment provenance.
#[must_use]
pub fn variable_name(&self) -> std::option::Option<&str> {
return match self {
Self::DocumentLiteral => std::option::Option::None,
Self::EnvironmentProcess { variable_name } | Self::EnvironmentDotEnv { variable_name } | Self::EnvironmentFallback { variable_name } => {
std::option::Option::Some(variable_name.as_str())
},
};
}
/// Returns the environment source represented by this provenance segment when applicable.
#[must_use]
pub const fn environment_source(&self) -> std::option::Option<crate::ConfigEnvironmentSource> {
return match self {
Self::DocumentLiteral => std::option::Option::None,
Self::EnvironmentProcess { .. } => std::option::Option::Some(crate::ConfigEnvironmentSource::Process),
Self::EnvironmentDotEnv { .. } => std::option::Option::Some(crate::ConfigEnvironmentSource::DotEnv),
Self::EnvironmentFallback { .. } => std::option::Option::Some(crate::ConfigEnvironmentSource::Fallback),
};
}
}
/// One resolved Config string with real/safe representations, sensitivity and ordered provenance.
#[derive(Clone, Eq, PartialEq)]
pub struct ResolvedConfigText {
value: String,
safe_value: String,
sensitivity: ConfigSensitivity,
provenance: std::vec::Vec<ConfigValueProvenance>,
}
impl ResolvedConfigText {
pub(crate) fn new(value: String, safe_value: String, sensitivity: ConfigSensitivity, provenance: std::vec::Vec<ConfigValueProvenance>) -> Self {
return Self { value, safe_value, sensitivity, provenance };
}
/// Returns the real runtime value.
#[must_use]
pub fn value(&self) -> &str {
return self.value.as_str();
}
/// Returns the representation safe for ordinary diagnostics.
#[must_use]
pub fn safe_value(&self) -> &str {
return self.safe_value.as_str();
}
/// Returns the strongest sensitivity contributed by referenced environment placeholders.
#[must_use]
pub const fn sensitivity(&self) -> ConfigSensitivity {
return self.sensitivity;
}
/// Returns ordered provenance segments participating in the resolved string.
#[must_use]
pub fn provenance(&self) -> &[ConfigValueProvenance] {
return self.provenance.as_slice();
}
}
impl std::fmt::Debug for ResolvedConfigText {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
return formatter
.debug_struct("ResolvedConfigText")
.field("safe_value", &self.safe_value)
.field("sensitivity", &self.sensitivity)
.field("provenance", &self.provenance)
.finish();
}
}
/// Recursively resolved JSON value preserving a real tree, a safe tree and provenance indexed by JSON Pointer.
#[derive(Clone, Eq, PartialEq)]
pub struct ResolvedConfigJson {
value: serde_json::Value,
safe_value: serde_json::Value,
sensitivity: ConfigSensitivity,
provenance: std::collections::BTreeMap<String, std::vec::Vec<ConfigValueProvenance>>,
}
impl ResolvedConfigJson {
pub(crate) fn new(
value: serde_json::Value,
safe_value: serde_json::Value,
sensitivity: ConfigSensitivity,
provenance: std::collections::BTreeMap<String, std::vec::Vec<ConfigValueProvenance>>,
) -> Self {
return Self { value, safe_value, sensitivity, provenance };
}
/// Returns the real JSON tree intended for legitimate runtime consumers.
#[must_use]
pub const fn value(&self) -> &serde_json::Value {
return &self.value;
}
/// Returns the JSON tree safe for ordinary diagnostics.
#[must_use]
pub const fn safe_value(&self) -> &serde_json::Value {
return &self.safe_value;
}
/// Returns the strongest sensitivity found anywhere in the resolved JSON tree.
#[must_use]
pub const fn sensitivity(&self) -> ConfigSensitivity {
return self.sensitivity;
}
/// Returns provenance indexed by RFC 6901 JSON Pointer strings.
#[must_use]
pub const fn provenance(&self) -> &std::collections::BTreeMap<String, std::vec::Vec<ConfigValueProvenance>> {
return &self.provenance;
}
/// Returns provenance for one JSON Pointer when the resolved value recorded that location.
#[must_use]
pub fn provenance_at(&self, json_pointer: &str) -> std::option::Option<&[ConfigValueProvenance]> {
return self.provenance.get(json_pointer).map(std::vec::Vec::as_slice);
}
}
impl std::fmt::Debug for ResolvedConfigJson {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
return formatter
.debug_struct("ResolvedConfigJson")
.field("safe_value", &self.safe_value)
.field("sensitivity", &self.sensitivity)
.field("provenance", &self.provenance)
.finish();
}
}
#[cfg(test)]
#[path = "../unit_tests/sensitivity.rs"]
mod tests;

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