Skip to main content

pmcp_server_toolkit/
config.rs

1// Originated from pmcp-run/built-in/shared/mcp-server-common/src/config.rs
2// (https://github.com/guyernest/pmcp-run). Lifted into rust-mcp-sdk for Phase 83.
3
4//! `ServerConfig` + sub-sections. Strict `#[serde(deny_unknown_fields)]` per D-13.
5//!
6//! # Strict-parse discipline (D-13)
7//!
8//! Every struct in this module carries `#[serde(deny_unknown_fields)]`. A typo
9//! in any key (e.g. `auto_aprove_levels` for `auto_approve_levels`) is a
10//! **parse error**, not a silent default. This is the defence-in-depth path
11//! against the Tampering threat documented in `83-04-PLAN.md` T-83-04-02 —
12//! mis-spelled keys MUST NOT degrade security policy.
13//!
14//! # REF-01 superset invariant
15//!
16//! `ServerConfig` is a strict **superset** of every key emitted by the three
17//! reference config.tomls (`tests/fixtures/{open-images,imdb,msr-vtt}-config.toml`,
18//! lifted in Plan 01 Task 4). When a fixture grows a new key, the toolkit grows
19//! a new field — typed if known, `toml::Value` if heterogeneous. The invariant
20//! is enforced empirically by the [`tests/reference_configs.rs`] integration
21//! test (REF-01 superset, D-13, ROADMAP SC-2).
22//!
23//! **Anti-pattern (RESEARCH §Pitfall 1, PATTERNS §8):** Do NOT loosen
24//! `deny_unknown_fields` to make a fixture parse. Always ADD the missing field.
25//!
26//! # Three entry points
27//!
28//! | Method | Returns | Use case |
29//! |--------|---------|----------|
30//! | [`ServerConfig::from_toml`] | `Result<Self, ToolkitError::Parse>` | Programmatic partial-config merge; no semantic checks |
31//! | [`ServerConfig::validate`] | `Result<(), ConfigValidationError>` | Post-parse semantic check (run after a merge) |
32//! | [`ServerConfig::from_toml_strict_validated`] | `Result<Self, ToolkitError>` | Production entry: parse + validate in one call |
33//!
34//! Per Phase 83 review R8, `validate()` exists because the `Default` impls on
35//! `ServerSection` etc. would otherwise let `[server]` typos land empty
36//! `name`/`version` strings without an error. The strict-validated convenience
37//! is what production callers should reach for.
38//!
39//! REF-01 superset enumeration (from `tests/fixtures/{open-images,imdb,msr-vtt,reference}-config.toml`;
40//! the SQLite Chinook `reference-config.toml` was lifted in Plan 85-01):
41//!
42//! ```text
43//! [server]            : id, name, description, type, version, is_reference
44//! [metadata]          : display_name, short_description, description, tags, author, visibility
45//! [database]          : type, database, output_location, workgroup, query_timeout_ms,
46//!                       url, file_path, [[database.tables]], [database.pool]
47//! [[database.tables]] : name, description
48//! [database.pool]     : max_connections, connection_timeout_seconds
49//! [code_mode]         : enabled, server_id, allow_writes, allow_deletes, allow_ddl,
50//!                       require_limit, max_limit, blocked_tables, sensitive_columns,
51//!                       auto_approve_levels, token_ttl_seconds, token_secret,
52//!                       [code_mode.limits]
53//! [code_mode.limits]  : max_tables_per_query, max_join_depth, max_subquery_depth
54//! [shared_policy_store] : creates_shared_store, export_to_ssm, ssm_path, templates
55//! [[tools]]           : name, description, sql, ui_resource_uri,
56//!                       [[tools.parameters]], [tools.annotations]
57//! [[tools.parameters]] : name, type, description, required, default, max_length,
58//!                       minimum, maximum, enum
59//! [tools.annotations] : read_only_hint, destructive_hint, idempotent_hint,
60//!                       open_world_hint, cost_hint
61//! [[prompts]]         : name, description, include_resources, arguments
62//! [[resources]]       : uri, name, description, mime_type, content
63//! ```
64
65use serde::{Deserialize, Serialize};
66
67use crate::error::{ConfigValidationError, Result, ToolkitError};
68
69// -----------------------------------------------------------------------------
70// Top-level
71// -----------------------------------------------------------------------------
72
73/// Top-level `pmcp-server-toolkit` configuration parsed from a `config.toml`.
74///
75/// One struct parses the entire file in one shot (per D-13). All sub-sections
76/// carry `#[serde(deny_unknown_fields)]` — a typo anywhere in the file is a
77/// hard parse error.
78///
79/// # Entry points
80///
81/// Use [`ServerConfig::from_toml_strict_validated`] for production callers.
82/// [`ServerConfig::from_toml`] is the no-validation variant for programmatic
83/// merges; [`ServerConfig::validate`] runs the semantic checks separately.
84///
85/// # Examples
86///
87/// ```
88/// use pmcp_server_toolkit::config::ServerConfig;
89///
90/// let toml = r#"
91///     [server]
92///     name = "demo"
93///     version = "0.1.0"
94/// "#;
95/// let cfg = ServerConfig::from_toml_strict_validated(toml)
96///     .expect("valid minimum config");
97/// assert_eq!(cfg.server.name, "demo");
98/// assert_eq!(cfg.server.version, "0.1.0");
99/// ```
100#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
101#[serde(deny_unknown_fields)]
102pub struct ServerConfig {
103    /// `[server]` — identity and version metadata.
104    #[serde(default)]
105    pub server: ServerSection,
106
107    /// `[metadata]` — admin-facing display defaults.
108    #[serde(default)]
109    pub metadata: MetadataSection,
110
111    /// `[database]` — backend connection + tables.
112    #[serde(default)]
113    pub database: DatabaseSection,
114
115    /// `[backend]` (optional, `http` feature) — OpenAPI/REST HTTP backend
116    /// declaration (`base_url` + `[backend.auth]` + `[backend.http]`).
117    ///
118    /// Additive per the REF-01 superset invariant (D-06): a pure-SQL config
119    /// omits `[backend]` and this field parses to `None`. The whole section is
120    /// gated behind the `http` feature — a no-http build has no OpenAPI backend,
121    /// so exposing an unusable stub type would be misleading. See
122    /// [`BackendSection`].
123    #[cfg(feature = "http")]
124    #[serde(default)]
125    pub backend: Option<BackendSection>,
126
127    /// `[code_mode]` (optional) — code-mode policy and limits.
128    #[serde(default)]
129    pub code_mode: Option<CodeModeSection>,
130
131    /// `[[tools]]` — declarative tool surface (TOML-defined handlers).
132    #[serde(default)]
133    pub tools: Vec<ToolDecl>,
134
135    /// `[[config_slots]]` — declared config slots the TARGET environment must
136    /// fill (PKG-03). Additive per the REF-01 superset invariant: a config
137    /// omitting the block parses to an empty vec.
138    ///
139    /// Deliberately NOT gated on the `http` feature — a SQL or workbook Shape A
140    /// server declares slots too, and gating it would make the field vanish in
141    /// the toolkit's own default build.
142    #[serde(default)]
143    pub config_slots: Vec<ConfigSlotDecl>,
144
145    /// `[[prompts]]` — declarative prompt surface.
146    #[serde(default)]
147    pub prompts: Vec<PromptDecl>,
148
149    /// `[[resources]]` — declarative resource surface.
150    #[serde(default)]
151    pub resources: Vec<ResourceDecl>,
152
153    /// `[shared_policy_store]` (optional) — AVP/Cedar shared-policy-store
154    /// declaration emitted by the reference SQL server (`is_reference = true`),
155    /// which provisions the policy store all sibling SQL servers attach to.
156    /// Additive per the REF-01 superset invariant (Plan 85-01); parsed
157    /// verbatim — the toolkit does not provision SSM at parse time.
158    #[serde(default)]
159    pub shared_policy_store: Option<SharedPolicyStoreSection>,
160}
161
162impl ServerConfig {
163    /// Parse `ServerConfig` from a TOML config string.
164    ///
165    /// Performs **strict parsing** (`#[serde(deny_unknown_fields)]` on every
166    /// section, per D-13). Does **not** run semantic validation — callers
167    /// wanting required-field guarantees should use
168    /// [`Self::from_toml_strict_validated`] instead.
169    ///
170    /// # Errors
171    ///
172    /// Returns [`ToolkitError::Parse`] on syntax error or unknown field. A
173    /// mis-spelled key (e.g. `auto_aprove_levels` for `auto_approve_levels`)
174    /// produces a parse error here, not a silent default.
175    ///
176    /// # Example
177    ///
178    /// ```
179    /// use pmcp_server_toolkit::config::ServerConfig;
180    ///
181    /// let toml = r#"
182    ///     [server]
183    ///     id = "demo"
184    ///     name = "Demo"
185    ///     version = "0.1.0"
186    /// "#;
187    /// let cfg = ServerConfig::from_toml(toml).expect("parse");
188    /// assert_eq!(cfg.server.name, "Demo");
189    /// ```
190    pub fn from_toml(toml_str: &str) -> Result<Self> {
191        toml::from_str(toml_str).map_err(ToolkitError::Parse)
192    }
193
194    /// Parse + validate. Per Phase 83 review R8 — guards against the
195    /// missing-required-value trap that the `Default` impls on sub-sections
196    /// would otherwise hide behind silent empty strings (e.g. a typo'd
197    /// `[serever]` header makes `server.name` default to `""`).
198    ///
199    /// # Errors
200    ///
201    /// Returns [`ToolkitError::Parse`] on TOML syntax / unknown-field errors,
202    /// or [`ToolkitError::Validation`] (wrapping
203    /// [`ConfigValidationError`]) on missing required values
204    /// (empty `server.name`, empty `server.version`, empty tool name, empty
205    /// table name).
206    ///
207    /// # Example
208    ///
209    /// ```
210    /// use pmcp_server_toolkit::config::ServerConfig;
211    /// let toml = r#"
212    ///     [server]
213    ///     name = "demo"
214    ///     version = "0.1.0"
215    /// "#;
216    /// let cfg = ServerConfig::from_toml_strict_validated(toml).expect("valid");
217    /// # let _ = cfg;
218    /// ```
219    pub fn from_toml_strict_validated(toml_str: &str) -> Result<Self> {
220        let cfg = Self::from_toml(toml_str)?;
221        cfg.validate()?;
222        Ok(cfg)
223    }
224
225    /// Validate required-field semantics that `#[serde(default)]` would
226    /// otherwise mask. Per Phase 83 review R8.
227    ///
228    /// Rules checked, in order:
229    /// 1. `server.name` is non-empty (trimmed).
230    /// 2. `server.version` is non-empty (trimmed).
231    /// 3. Every `[[tools]]` entry has a non-empty `name`.
232    /// 4. No `[[tools]]` entry mixes tool kinds (`sql` / `path`+`method` /
233    ///    `script`) — D-01 / T-90-02-04.
234    /// 5. Every `[[database.tables]]` entry has a non-empty `name`.
235    /// 6. Every `[[config_slots]]` entry has a non-empty `key` AND `name`
236    ///    (PKG-03). The entry's `kind` needs no rule here — it is the closed
237    ///    [`ConfigSlotKind`] enum, so serde rejects an unknown discriminator at
238    ///    parse time, before `validate()` is called.
239    /// 7. When a `[backend]` block is present (`http` feature), its `base_url`
240    ///    is non-empty (trimmed) — GAP 3 / WR-02. Absent on no-http builds.
241    ///
242    /// # Errors
243    ///
244    /// Returns a [`ConfigValidationError`] variant identifying the
245    /// first rule violated. Iteration order matches struct field order.
246    pub fn validate(&self) -> std::result::Result<(), ConfigValidationError> {
247        if self.server.name.trim().is_empty() {
248            return Err(ConfigValidationError::EmptyServerName);
249        }
250        if self.server.version.trim().is_empty() {
251            return Err(ConfigValidationError::EmptyServerVersion);
252        }
253        for (i, tool) in self.tools.iter().enumerate() {
254            if tool.name.trim().is_empty() {
255                return Err(ConfigValidationError::EmptyToolName(i));
256            }
257            // D-01 / T-90-02-04: a tool is EITHER sql, single-call (path/method),
258            // OR script — never a mixture. Reject ambiguity instead of letting a
259            // silent "script wins" precedence hide a config mistake.
260            if tool.declared_kind_count() > 1 {
261                return Err(ConfigValidationError::AmbiguousToolKind(i));
262            }
263        }
264        for (i, table) in self.database.tables.iter().enumerate() {
265            if table.name.trim().is_empty() {
266                return Err(ConfigValidationError::EmptyTableName(i));
267            }
268        }
269        // PKG-03 (Phase 120 Plan 04): a declared slot must actually name a
270        // config path AND a variable. An empty `key`/`name` claims coverage the
271        // declaration cannot deliver. Deliberately NOT a completeness
272        // heuristic — a "this literal looks secret, so a slot is missing" check
273        // would flag the london-tube fixture's guarded dev `token_secret`, and
274        // a check that cries wolf is worse than none.
275        for (i, slot) in self.config_slots.iter().enumerate() {
276            if slot.key.trim().is_empty() || slot.name.trim().is_empty() {
277                return Err(ConfigValidationError::EmptyConfigSlotField(i));
278            }
279            // Identity-bearing slots structurally carry no value (the whole
280            // "secrets never travel" premise) — a `tested_value` on a `secret`
281            // declaration is the one field where a REAL credential could sit in
282            // a config that is served but never packed, so the doc-comment rule
283            // is enforced here rather than trusted.
284            if slot.kind == ConfigSlotKind::Secret && slot.tested_value.is_some() {
285                return Err(ConfigValidationError::SecretSlotCarriesTestedValue(i));
286            }
287        }
288        // Phase 90 gap-closure (GAP 3 / WR-02): when a `[backend]` block is
289        // declared, its `base_url` must be non-empty. Catch a typo'd / omitted
290        // URL here (the field is `#[serde(default)]` -> `""`) rather than
291        // letting it surface late as an opaque DispatchError at request time.
292        // Gated on `http` because the `backend` field itself is http-only; the
293        // block simply vanishes in a no-http build (SQL configs unaffected).
294        #[cfg(feature = "http")]
295        if let Some(backend) = &self.backend {
296            if backend.base_url.trim().is_empty() {
297                return Err(ConfigValidationError::EmptyBackendBaseUrl);
298            }
299            // Phase 120 follow-up: a reference-shaped base_url must name
300            // exactly ONE variable. The grammar maps every malformed brace
301            // form — the empty `${}` and multi-placeholder compositions like
302            // `${SCHEME}://${HOST}` — to the empty name; catching that here
303            // turns a boot-time `UnresolvedBaseUrlRef` with an empty variable
304            // name into a load-time error naming the actual mistake.
305            if crate::env_ref::parse_env_ref(&backend.base_url) == Some("") {
306                return Err(ConfigValidationError::MalformedBackendBaseUrlRef);
307            }
308            // The same rule for `[backend.auth]` credentials. It is NOT the
309            // same consequence: an unresolvable base_url breaks every request
310            // loudly, while an unresolvable CREDENTIAL was silently omitted
311            // (`expand_api_key_map` drops the entry; the scalar variants
312            // collapse to `NoAuth`), so the server booted and sent every
313            // backend request unauthenticated. Catching it here is what makes
314            // that failure visible at all.
315            if let Some(field) = backend.auth.malformed_env_ref_field() {
316                return Err(ConfigValidationError::MalformedBackendAuthRef(field));
317            }
318        }
319        Ok(())
320    }
321}
322
323// -----------------------------------------------------------------------------
324// [server]
325// -----------------------------------------------------------------------------
326
327/// `[server]` section — identity and version metadata.
328#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
329#[serde(deny_unknown_fields)]
330pub struct ServerSection {
331    /// Stable server identifier (e.g. `"open-images"`). Optional in the TOML;
332    /// callers that need it should fall back to deriving from `name`.
333    #[serde(default)]
334    pub id: Option<String>,
335    /// Human-readable server name (required for production via [`ServerConfig::validate`]).
336    #[serde(default)]
337    pub name: String,
338    /// Short server description.
339    #[serde(default)]
340    pub description: Option<String>,
341    /// Server flavour (e.g. `"sql-api"`). Free-form for now; future plans may tighten.
342    #[serde(default, rename = "type")]
343    pub server_type: Option<String>,
344    /// Semver version string (required for production via [`ServerConfig::validate`]).
345    #[serde(default)]
346    pub version: String,
347    /// Whether this server is the **reference** server that provisions shared
348    /// infrastructure (the `[shared_policy_store]` for all sibling SQL servers).
349    /// Additive per the REF-01 superset invariant (Plan 85-01); the SQLite
350    /// Chinook reference config sets `is_reference = true`.
351    #[serde(default)]
352    pub is_reference: bool,
353}
354
355// -----------------------------------------------------------------------------
356// [metadata]
357// -----------------------------------------------------------------------------
358
359/// `[metadata]` section — admin-facing display defaults (visible in the
360/// pmcp.run UI before an operator customises them).
361#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
362#[serde(deny_unknown_fields)]
363pub struct MetadataSection {
364    /// Long-form display name shown in the UI.
365    #[serde(default)]
366    pub display_name: Option<String>,
367    /// One-line summary for list views.
368    #[serde(default)]
369    pub short_description: Option<String>,
370    /// Multi-line description for detail pages.
371    #[serde(default)]
372    pub description: Option<String>,
373    /// Tag list for filtering / discovery.
374    #[serde(default)]
375    pub tags: Vec<String>,
376    /// Server author (organisation or individual).
377    #[serde(default)]
378    pub author: Option<String>,
379    /// Visibility flag (e.g. `"public"`, `"private"`).
380    #[serde(default)]
381    pub visibility: Option<String>,
382}
383
384// -----------------------------------------------------------------------------
385// [database]
386// -----------------------------------------------------------------------------
387
388/// `[database]` section — backend identification and table catalogue.
389///
390/// Includes Athena-specific keys (`output_location`, `workgroup`) as optional
391/// fields per the REF-01 superset invariant — non-Athena backends omit them.
392#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
393#[serde(deny_unknown_fields)]
394pub struct DatabaseSection {
395    /// Backend type (`"athena"`, `"postgres"`, `"mysql"`, `"sqlite"`, …).
396    #[serde(default, rename = "type")]
397    pub backend_type: Option<String>,
398    /// Database / schema name.
399    #[serde(default)]
400    pub database: Option<String>,
401    /// Athena S3 output location for query results.
402    #[serde(default)]
403    pub output_location: Option<String>,
404    /// Athena workgroup name.
405    #[serde(default)]
406    pub workgroup: Option<String>,
407    /// Per-query timeout in milliseconds.
408    #[serde(default)]
409    pub query_timeout_ms: Option<u64>,
410    /// `[[database.tables]]` — declared table catalogue for schema enrichment.
411    #[serde(default)]
412    pub tables: Vec<DatabaseTableDecl>,
413    /// Connection URL for Postgres / MySQL backends. Supports `env:VAR_NAME`
414    /// indirection at the consumer-resolution layer (the toolkit parses the
415    /// string as-is and leaves resolution to the per-backend connector or
416    /// the secret-resolution machinery from P83 R6/R9). Optional/unused for
417    /// Athena (uses `region` + `workgroup` + `output_location`) and SQLite
418    /// (uses `database` for the file path or `:memory:` literal).
419    #[serde(default)]
420    pub url: Option<String>,
421    /// Filesystem path to a SQLite database file (e.g.
422    /// `"/var/task/assets/chinook.db"` for a Lambda-bundled asset). Additive per
423    /// the REF-01 superset invariant (Plan 85-01). Distinct from `database`
424    /// (which carries the `:memory:` literal or a schema name) and `url` (used
425    /// by Postgres / MySQL). Stored verbatim; the SQLite connector resolves it.
426    #[serde(default)]
427    pub file_path: Option<String>,
428    /// `[database.pool]` — connection-pool tuning (optional).
429    #[serde(default)]
430    pub pool: Option<DatabasePoolSection>,
431}
432
433/// Single `[[database.tables]]` entry.
434#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
435#[serde(deny_unknown_fields)]
436pub struct DatabaseTableDecl {
437    /// Table or view name (required for production via [`ServerConfig::validate`]).
438    #[serde(default)]
439    pub name: String,
440    /// Human-readable table description for schema enrichment.
441    #[serde(default)]
442    pub description: Option<String>,
443}
444
445/// `[database.pool]` connection-pool tuning.
446#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
447#[serde(deny_unknown_fields)]
448pub struct DatabasePoolSection {
449    /// Maximum concurrent connections.
450    #[serde(default)]
451    pub max_connections: Option<u32>,
452    /// Connection-acquisition timeout, in seconds.
453    #[serde(default)]
454    pub connection_timeout_seconds: Option<u64>,
455}
456
457// -----------------------------------------------------------------------------
458// [backend] (http feature)
459// -----------------------------------------------------------------------------
460
461/// Re-export of the outgoing-HTTP authentication config (owned by
462/// [`crate::http::auth`], Plan 90-01). Callers may also reach it via the
463/// `crate::http` module path; this re-export keeps `[backend.auth]` named
464/// alongside the `ServerConfig` types it deserializes into.
465#[cfg(feature = "http")]
466pub use crate::http::auth::AuthConfig;
467
468/// Re-export of the HTTP client tuning config (owned by [`crate::http::client`],
469/// Plan 90-01) used by `[backend.http]`.
470#[cfg(feature = "http")]
471pub use crate::http::client::HttpConfig;
472
473/// `[backend]` section — the OpenAPI/REST HTTP backend declaration (D-06).
474///
475/// This is the HTTP analog of [`DatabaseSection`]: it identifies the upstream
476/// REST API the synthesized tools call. `base_url` is the API root; the optional
477/// `[backend.auth]` sub-table selects an [`AuthConfig`] variant (`type = "..."`)
478/// and `[backend.http]` carries [`HttpConfig`] tuning (timeout / retries / …).
479///
480/// Gated behind the `http` feature — the whole section (and the
481/// [`ServerConfig::backend`] field) is absent in a no-http build so there is no
482/// dead stub type. `AuthConfig` and `HttpConfig` are DEFINED in
483/// [`crate::http`] (Plan 90-01) and re-exported here, not redefined (H3).
484///
485/// Strict-parse discipline (D-13) is preserved: `#[serde(deny_unknown_fields)]`
486/// rejects a typo'd key under `[backend]` or `[backend.http]`.
487///
488/// Secrets posture (T-90-02-02): inline token fields under `[backend.auth]`
489/// hold operator references (`${ENV}` / `env:VAR`) resolved upstream by the
490/// Phase 83 secrets machinery — config parsing stores the string verbatim and
491/// never the resolved value.
492#[cfg(feature = "http")]
493#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
494#[serde(deny_unknown_fields)]
495pub struct BackendSection {
496    /// REST API root URL (e.g. `"https://api.tfl.gov.uk"`). Single-call tools
497    /// concatenate their `path` onto this (an empty per-tool `base_url`
498    /// inherits this value).
499    #[serde(default)]
500    pub base_url: String,
501    /// `[backend.auth]` — outgoing authentication ([`AuthConfig`], six modes).
502    /// Defaults to [`AuthConfig::None`] when the sub-table is omitted.
503    #[serde(default)]
504    pub auth: AuthConfig,
505    /// `[backend.http]` — client tuning ([`HttpConfig`]: timeout / retries /
506    /// backoff / user-agent / default headers). Defaults to [`HttpConfig`]'s
507    /// defaults when the sub-table is omitted.
508    #[serde(default)]
509    pub http: HttpConfig,
510}
511
512#[cfg(feature = "http")]
513impl BackendSection {
514    /// Resolve [`Self::base_url`], expanding a `${VAR}` / `env:VAR` reference
515    /// from the process environment. Callers MUST use this rather than reading
516    /// `base_url` directly — the raw field may hold an unresolved placeholder.
517    ///
518    /// A Shape A server's endpoint is frequently a slot the target environment
519    /// fills, so the config records `base_url = "${TFL_BASE_URL}"` and the
520    /// package digest stays environment-independent. Without expansion that
521    /// literal `${...}` parses, VALIDATES (it is non-empty, so the emptiness
522    /// rule passes) and is then sent as the request URL.
523    ///
524    /// Resolution rules — the grammar is [`crate::env_ref::parse_env_ref`], the
525    /// single toolkit-wide chokepoint:
526    /// - a plain literal (no `${...}` / `env:` prefix) is returned VERBATIM;
527    /// - `${VAR}` / `env:VAR` reads `VAR` from the process environment;
528    /// - a MALFORMED reference — the empty `${}`, or a multi-placeholder
529    ///   composition like `${A}://${B}` (a brace reference names exactly ONE
530    ///   variable) — is an error;
531    /// - an UNSET variable, or one set to an empty / whitespace-only value, is
532    ///   an error.
533    ///
534    /// # Deliberate divergence from credential resolution
535    ///
536    /// A credential resolves an unset reference to the empty string so an
537    /// optional credential is OMITTED (see `crate::http::auth`). An endpoint
538    /// does NOT get that treatment: an empty credential yields a degraded
539    /// request, but an empty endpoint yields a broken one, and
540    /// [`ServerConfig::validate`] only checks emptiness at parse time — an
541    /// empty resolution would sail through and then break every request. This
542    /// uses the error-on-unset semantics of `code_mode`'s `token_secret`
543    /// resolution instead.
544    ///
545    /// # Errors
546    ///
547    /// Returns [`ToolkitError::UnresolvedBaseUrlRef`] when the reference cannot
548    /// be resolved. Per T-120-17 the error names the FIELD and the
549    /// environment-variable NAME only — never a resolved URL or credential.
550    ///
551    /// # Examples
552    ///
553    /// ```
554    /// use pmcp_server_toolkit::config::ServerConfig;
555    ///
556    /// let cfg = ServerConfig::from_toml_strict_validated(
557    ///     "[server]\nname = \"demo\"\nversion = \"0.1.0\"\n\
558    ///      [backend]\nbase_url = \"https://api.example.com\"\n",
559    /// )
560    /// .expect("valid config");
561    /// let backend = cfg.backend.as_ref().expect("[backend] present");
562    /// // A plain literal is used verbatim.
563    /// assert_eq!(backend.resolved_base_url().unwrap(), "https://api.example.com");
564    /// ```
565    pub fn resolved_base_url(&self) -> std::result::Result<String, ToolkitError> {
566        match crate::env_ref::parse_env_ref(&self.base_url) {
567            // Plain literal — used verbatim (every existing [backend] config
568            // and the four SQL reference configs land here, unchanged).
569            None => Ok(self.base_url.clone()),
570            // Malformed `${}` — a reference to an empty name. A credential
571            // treats this as "omit"; an endpoint cannot be omitted.
572            Some("") => Err(ToolkitError::UnresolvedBaseUrlRef { var: String::new() }),
573            Some(name) => match std::env::var(name) {
574                Ok(value) if !value.trim().is_empty() => Ok(value),
575                // Unset, or set-but-empty/whitespace — the same error either
576                // way. The VALUE is never carried into the error.
577                _ => Err(ToolkitError::UnresolvedBaseUrlRef {
578                    var: name.to_string(),
579                }),
580            },
581        }
582    }
583}
584
585// -----------------------------------------------------------------------------
586// [code_mode]
587// -----------------------------------------------------------------------------
588
589/// `[code_mode]` section — code-mode policy + complexity limits.
590///
591/// The toolkit uses **unprefixed** field names (REF-01 invariant); the mapping
592/// to `pmcp_code_mode::CodeModeConfig`'s prefixed names (`sql_allow_writes`,
593/// etc.) is handled by Plan 06's executor wiring.
594#[allow(clippy::struct_excessive_bools)]
595// Why: REF-01 superset — these bools mirror the reference servers' [code_mode] block 1:1 (CONTEXT.md D-13). Grouping into a sub-struct would break REF-01.
596#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
597#[serde(deny_unknown_fields)]
598pub struct CodeModeSection {
599    /// Master enable flag for code-mode.
600    #[serde(default)]
601    pub enabled: bool,
602    /// Server identifier used by AVP / Cedar policy resolution.
603    #[serde(default)]
604    pub server_id: Option<String>,
605    /// Whether INSERT / UPDATE / MERGE statements are allowed.
606    #[serde(default)]
607    pub allow_writes: bool,
608    /// Whether DELETE statements are allowed.
609    #[serde(default)]
610    pub allow_deletes: bool,
611    /// Whether DDL (CREATE / ALTER / DROP) is allowed.
612    #[serde(default)]
613    pub allow_ddl: bool,
614    /// Whether `SELECT` queries must declare a `LIMIT`.
615    #[serde(default)]
616    pub require_limit: bool,
617    /// Maximum allowed `LIMIT` value.
618    #[serde(default)]
619    pub max_limit: Option<u64>,
620    /// Table names blocked from any query (denylist).
621    #[serde(default)]
622    pub blocked_tables: Vec<String>,
623    /// `table.column` strings stripped from query output.
624    #[serde(default)]
625    pub sensitive_columns: Vec<String>,
626    /// Risk levels eligible for auto-approval (e.g. `["low"]`).
627    #[serde(default)]
628    pub auto_approve_levels: Vec<String>,
629    /// Token TTL, in seconds, for HMAC-signed approval tokens.
630    #[serde(default)]
631    pub token_ttl_seconds: Option<u64>,
632    /// Secret reference (e.g. `"${CODE_MODE_SECRET}"`) for HMAC signing — resolved
633    /// at runtime by `SecretsProvider`. NEVER a raw secret value (review R6 +
634    /// T-83-04-04 in the plan threat model).
635    #[serde(default)]
636    pub token_secret: Option<String>,
637    /// Per Phase 83 review R9: inline `token_secret = "raw-string"` is REJECTED
638    /// by default to prevent secrets from being committed to source-controlled
639    /// configs. Set this flag to `true` ONLY in dev/test configs where the
640    /// operator explicitly accepts the risk. NEVER set this in a committed
641    /// production config — production must use the `env:VAR_NAME` syntax that
642    /// resolves at runtime through `SecretsProvider`.
643    #[serde(default)]
644    pub allow_inline_token_secret_for_dev: bool,
645    /// `[code_mode.limits]` — query-complexity caps.
646    #[serde(default)]
647    pub limits: Option<CodeModeLimits>,
648}
649
650/// `[code_mode.limits]` — query-complexity caps.
651#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
652#[serde(deny_unknown_fields)]
653pub struct CodeModeLimits {
654    /// Maximum number of distinct tables referenced in a single query.
655    #[serde(default)]
656    pub max_tables_per_query: Option<u32>,
657    /// Maximum JOIN nesting depth.
658    #[serde(default)]
659    pub max_join_depth: Option<u32>,
660    /// Maximum subquery nesting depth.
661    #[serde(default)]
662    pub max_subquery_depth: Option<u32>,
663}
664
665// -----------------------------------------------------------------------------
666// [shared_policy_store]
667// -----------------------------------------------------------------------------
668
669/// `[shared_policy_store]` section — AVP/Cedar shared-policy-store declaration.
670///
671/// Emitted only by the **reference** SQL server (`[server] is_reference = true`),
672/// which provisions a single shared policy store + a set of Cedar templates that
673/// all sibling SQL servers attach to (rather than each minting its own store).
674///
675/// Additive per the REF-01 superset invariant (Plan 85-01). The toolkit parses
676/// this verbatim — SSM export and store provisioning are deployment-time
677/// concerns handled outside config parsing (D-02 parse-only + lazy startup).
678#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
679#[serde(deny_unknown_fields)]
680pub struct SharedPolicyStoreSection {
681    /// Whether this server creates the shared policy store for all SQL servers.
682    #[serde(default)]
683    pub creates_shared_store: bool,
684    /// Whether the created store's identifier is exported to SSM Parameter Store.
685    #[serde(default)]
686    pub export_to_ssm: bool,
687    /// SSM Parameter Store path the store identifier is exported to (when
688    /// `export_to_ssm = true`).
689    #[serde(default)]
690    pub ssm_path: Option<String>,
691    /// Cedar policy-template names included in the shared store (e.g.
692    /// `"PermitAllSelects"`, `"ForbidAllDeletes"`).
693    #[serde(default)]
694    pub templates: Vec<String>,
695}
696
697// -----------------------------------------------------------------------------
698// [[config_slots]]
699// -----------------------------------------------------------------------------
700
701/// The kind of a declared `[[config_slots]]` entry — a CLOSED vocabulary.
702///
703/// Deliberately an enum rather than a free `String`. A free string lets a typo
704/// (`kind = "endpont"`) parse cleanly, survive
705/// [`ServerConfig::validate`], and fail only at package time when it maps to no
706/// slot type — the failure surfacing two crates away from its cause. As a closed
707/// enum, an unrecognized discriminator is a serde parse error naming the
708/// accepted set, and a fourth kind becomes a deliberate addition here rather
709/// than a silent pass-through.
710///
711/// # Why this type is toolkit-LOCAL
712///
713/// The three `snake_case` discriminators (`endpoint`, `secret`, `auth_mode`)
714/// are deliberately the same strings the `pmcp-package` slot-type discriminator
715/// uses for the corresponding variants, so a packaging tool can compare a
716/// declaration against a package slot **without either crate depending on the
717/// other**. The toolkit must NOT depend on `pmcp-package`: that crate is the
718/// workspace-excluded leaf, and a toolkit dependency on it inverts the layering.
719/// The agreement is enforced by the package side re-parsing the SAME config
720/// bytes, not by a shared type.
721#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
722#[serde(rename_all = "snake_case")]
723pub enum ConfigSlotKind {
724    /// A network endpoint the target environment must supply (e.g. the backend
725    /// API root). Behaviour-relevant: its `tested_value` records the endpoint
726    /// the package was tested against.
727    #[default]
728    Endpoint,
729    /// A named secret the target environment must supply (e.g. an API key).
730    /// Identity-bearing: it structurally carries no `tested_value`.
731    Secret,
732    /// The backend authentication MODE. Structural rather than value-bearing:
733    /// the auth-mode key is a serde tag, so no `${VAR}` placeholder form of it
734    /// can deserialize — the baked literal IS the default and deviation
735    /// surfaces through slot classification, not through a placeholder.
736    AuthMode,
737}
738
739/// Single `[[config_slots]]` entry — a config value the TARGET environment must
740/// fill for this server to run.
741///
742/// A Shape A server's whole identity is its config, so "what must the operator
743/// supply?" has to be declarable IN that config rather than discovered by
744/// grepping for `${...}`. This block is that declaration: it names the config
745/// path, the kind of thing it is, and the value exercised when the server was
746/// tested.
747///
748/// Additive per the REF-01 superset invariant — a config omitting the block
749/// parses to an empty [`ServerConfig::config_slots`]. Strict-parse discipline
750/// (D-13) applies: `#[serde(deny_unknown_fields)]` rejects a typo'd inner key.
751///
752/// # Example
753///
754/// ```toml
755/// [[config_slots]]
756/// key = "backend.base_url"
757/// kind = "endpoint"
758/// name = "TFL_BASE_URL"
759/// tested_value = "https://api.tfl.gov.uk"
760/// ```
761#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
762#[serde(deny_unknown_fields)]
763pub struct ConfigSlotDecl {
764    /// The dotted TOML path this slot fills, e.g. `backend.base_url`,
765    /// `backend.auth.query_params.app_key`, `backend.auth.type`.
766    #[serde(default)]
767    pub key: String,
768    /// The slot kind ([`ConfigSlotKind`] — a closed vocabulary). REQUIRED: an
769    /// entry omitting `kind` is a parse error, because a defaulted kind would
770    /// silently mis-classify the slot.
771    pub kind: ConfigSlotKind,
772    /// The slot's declared name — for a `secret`, the environment-variable
773    /// name; for an `endpoint`, the variable the `${VAR}` placeholder reads.
774    #[serde(default)]
775    pub name: String,
776    /// The value exercised when the server was tested. `None` for
777    /// identity-bearing slots (a secret), which structurally carry no value —
778    /// ENFORCED by [`ServerConfig::validate`], not just stated: a `secret`
779    /// entry carrying a `tested_value` is refused, because that field is the
780    /// one place a real credential could sit in a config that is served but
781    /// never packed.
782    #[serde(default)]
783    pub tested_value: Option<String>,
784}
785
786// -----------------------------------------------------------------------------
787// [[tools]]
788// -----------------------------------------------------------------------------
789
790/// Single `[[tools]]` entry — a declaratively-defined tool surface.
791#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
792#[serde(deny_unknown_fields)]
793pub struct ToolDecl {
794    /// Tool name (required for production via [`ServerConfig::validate`]).
795    #[serde(default)]
796    pub name: String,
797    /// Human-readable tool description.
798    #[serde(default)]
799    pub description: Option<String>,
800    /// SQL template (uses `:param` placeholders bound by [`ParamDecl`]).
801    #[serde(default)]
802    pub sql: Option<String>,
803    /// HTTP request path for a **single-call** OpenAPI/REST tool (D-01), e.g.
804    /// `"/Line/Mode/tube/Status"`. Concatenated onto the backend `base_url`
805    /// (or this tool's [`Self::base_url`] override). Additive per REF-01 — `None`
806    /// for SQL / script tools.
807    #[serde(default)]
808    pub path: Option<String>,
809    /// HTTP method for a single-call tool (`"GET"`, `"POST"`, …). Pairs with
810    /// [`Self::path`] (D-01). Additive; `None` for SQL / script tools.
811    #[serde(default)]
812    pub method: Option<String>,
813    /// Per-tool backend base-URL override. When absent a single-call tool
814    /// inherits `[backend].base_url`. Additive; `None` for SQL / script tools.
815    #[serde(default)]
816    pub base_url: Option<String>,
817    /// JavaScript body for a **script** tool (D-01) — a code-mode snippet that
818    /// orchestrates multiple backend calls and binds `[[tools.parameters]]` to
819    /// `args`. When set, this entry is a script tool ([`Self::is_script_tool`]).
820    /// Additive; `None` for SQL / single-call tools.
821    #[serde(default)]
822    pub script: Option<String>,
823    /// Optional UI-resource URI for `structuredContent` widgets.
824    #[serde(default)]
825    pub ui_resource_uri: Option<String>,
826    /// `[[tools.parameters]]` — declared input parameters.
827    #[serde(default)]
828    pub parameters: Vec<ParamDecl>,
829    /// `[tools.annotations]` — MCP `toolAnnotations`.
830    #[serde(default)]
831    pub annotations: Option<AnnotationsDecl>,
832}
833
834impl ToolDecl {
835    /// Whether this `[[tools]]` entry is a **script** tool (D-01 detection rule).
836    ///
837    /// The detection rule is: `script.is_some()` ⇒ script tool; otherwise a
838    /// `path` + `method` pair ⇒ single-call HTTP tool; otherwise (a `sql`
839    /// field) ⇒ SQL tool. Plan 03/05 synthesizers branch on this method so the
840    /// rule lives in exactly one place. Mutual-exclusivity is enforced at
841    /// [`ServerConfig::validate`] (an entry mixing kinds is rejected, not
842    /// silently resolved by precedence).
843    ///
844    /// # Examples
845    ///
846    /// ```
847    /// use pmcp_server_toolkit::config::ToolDecl;
848    ///
849    /// let script = ToolDecl { script: Some("await api.get('/x')".into()), ..Default::default() };
850    /// assert!(script.is_script_tool());
851    ///
852    /// let single = ToolDecl {
853    ///     path: Some("/Line/Mode/tube/Status".into()),
854    ///     method: Some("GET".into()),
855    ///     ..Default::default()
856    /// };
857    /// assert!(!single.is_script_tool());
858    /// ```
859    #[must_use]
860    pub fn is_script_tool(&self) -> bool {
861        self.script.is_some()
862    }
863
864    /// Number of distinct mutually-exclusive tool kinds declared on this entry.
865    ///
866    /// Used by [`ServerConfig::validate`] to reject an ambiguous `[[tools]]`
867    /// entry (D-01 / T-90-02-04). A well-formed entry declares exactly one kind
868    /// (count `1`); count `> 1` is ambiguous; count `0` is a kind-less stub
869    /// (left to other validation rules).
870    fn declared_kind_count(&self) -> usize {
871        let is_sql = self.sql.is_some();
872        let is_single_call = self.path.is_some() || self.method.is_some();
873        let is_script = self.script.is_some();
874        usize::from(is_sql) + usize::from(is_single_call) + usize::from(is_script)
875    }
876}
877
878/// Single `[[tools.parameters]]` entry.
879///
880/// The `default` and `enum` fields use [`toml::Value`] because they are
881/// heterogeneous in the reference configs (a `default` may be an integer,
882/// a string, or a boolean depending on the parameter type).
883#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
884#[serde(deny_unknown_fields)]
885pub struct ParamDecl {
886    /// Parameter name (the `:param` token used in the tool's `sql`).
887    #[serde(default)]
888    pub name: String,
889    /// JSON-schema type (`"string"`, `"integer"`, `"number"`, `"boolean"`).
890    #[serde(default, rename = "type")]
891    pub param_type: Option<String>,
892    /// Human-readable parameter description.
893    #[serde(default)]
894    pub description: Option<String>,
895    /// Whether the parameter is required.
896    #[serde(default)]
897    pub required: bool,
898    /// Optional default value (any TOML type).
899    #[serde(default)]
900    pub default: Option<toml::Value>,
901    /// Maximum string length (string parameters only).
902    #[serde(default)]
903    pub max_length: Option<u64>,
904    /// Inclusive minimum (integer / number parameters only).
905    #[serde(default)]
906    pub minimum: Option<f64>,
907    /// Inclusive maximum (integer / number parameters only).
908    #[serde(default)]
909    pub maximum: Option<f64>,
910    /// Closed set of allowed values (any TOML scalar).
911    #[serde(default, rename = "enum")]
912    pub enum_values: Option<Vec<toml::Value>>,
913}
914
915/// `[tools.annotations]` — MCP `toolAnnotations` hints.
916#[allow(clippy::struct_excessive_bools)] // Why: REF-01 superset — mirrors the MCP `toolAnnotations` flag set 1:1.
917#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
918#[serde(deny_unknown_fields)]
919pub struct AnnotationsDecl {
920    /// Whether the tool only reads (never mutates) state.
921    #[serde(default)]
922    pub read_only_hint: bool,
923    /// Whether the tool may destroy data.
924    #[serde(default)]
925    pub destructive_hint: bool,
926    /// Whether repeated calls with the same args produce the same result.
927    #[serde(default)]
928    pub idempotent_hint: bool,
929    /// Whether the tool interacts with an open-world (external) service.
930    #[serde(default)]
931    pub open_world_hint: bool,
932    /// Cost hint (`"low"`, `"medium"`, `"high"`).
933    #[serde(default)]
934    pub cost_hint: Option<String>,
935}
936
937// -----------------------------------------------------------------------------
938// [[prompts]]
939// -----------------------------------------------------------------------------
940
941/// Single `[[prompts]]` entry.
942#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
943#[serde(deny_unknown_fields)]
944pub struct PromptDecl {
945    /// Prompt name (the identifier MCP clients call by).
946    #[serde(default)]
947    pub name: String,
948    /// Human-readable prompt description.
949    #[serde(default)]
950    pub description: Option<String>,
951    /// Resource URIs to include in the prompt's assembled body.
952    #[serde(default)]
953    pub include_resources: Vec<String>,
954    /// Declared prompt arguments (MCP `PromptArgument`).
955    #[serde(default)]
956    pub arguments: Vec<PromptArgumentDecl>,
957}
958
959/// Single argument under `[[prompts.arguments]]`.
960#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
961#[serde(deny_unknown_fields)]
962pub struct PromptArgumentDecl {
963    /// Argument name.
964    #[serde(default)]
965    pub name: String,
966    /// Human-readable description.
967    #[serde(default)]
968    pub description: Option<String>,
969    /// Whether the argument is required.
970    #[serde(default)]
971    pub required: bool,
972}
973
974// -----------------------------------------------------------------------------
975// [[resources]]
976// -----------------------------------------------------------------------------
977
978/// Single `[[resources]]` entry — a statically-shipped resource.
979#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
980#[serde(deny_unknown_fields)]
981pub struct ResourceDecl {
982    /// Resource URI (e.g. `"docs://open-images/schema"`).
983    #[serde(default)]
984    pub uri: String,
985    /// Human-readable resource name.
986    #[serde(default)]
987    pub name: Option<String>,
988    /// Resource description.
989    #[serde(default)]
990    pub description: Option<String>,
991    /// MIME type (e.g. `"text/markdown"`).
992    #[serde(default)]
993    pub mime_type: Option<String>,
994    /// Inline resource content (or `"loaded from path.md"` placeholder string —
995    /// the toolkit treats the value verbatim; resolution to filesystem reads
996    /// is the caller's responsibility).
997    #[serde(default)]
998    pub content: Option<String>,
999}
1000
1001// -----------------------------------------------------------------------------
1002// Tests
1003// -----------------------------------------------------------------------------
1004
1005#[cfg(test)]
1006mod tests {
1007    use super::*;
1008    use proptest::prelude::*;
1009
1010    const MINIMAL: &str = r#"
1011        [server]
1012        name = "demo"
1013        version = "0.1.0"
1014    "#;
1015
1016    #[test]
1017    fn parse_minimal_config_succeeds() {
1018        let cfg = ServerConfig::from_toml(MINIMAL).expect("minimal must parse");
1019        assert_eq!(cfg.server.name, "demo");
1020        assert_eq!(cfg.server.version, "0.1.0");
1021        assert!(cfg.tools.is_empty());
1022        assert!(cfg.code_mode.is_none());
1023    }
1024
1025    #[test]
1026    fn parse_unknown_field_fails() {
1027        let toml = r#"
1028            [server]
1029            name = "demo"
1030            version = "0.1.0"
1031            unknown_field = "x"
1032        "#;
1033        let err = ServerConfig::from_toml(toml).expect_err("unknown field must fail");
1034        assert!(matches!(err, ToolkitError::Parse(_)), "got: {err:?}");
1035    }
1036
1037    #[test]
1038    fn parse_typo_in_code_mode_key_fails() {
1039        // T-83-04-02: defence-in-depth against silent policy widening.
1040        let toml = r#"
1041            [server]
1042            name = "demo"
1043            version = "0.1.0"
1044            [code_mode]
1045            enabled = true
1046            auto_aprove_levels = ["low"]
1047        "#;
1048        let err = ServerConfig::from_toml(toml).expect_err("typo'd code_mode key must be rejected");
1049        assert!(matches!(err, ToolkitError::Parse(_)));
1050    }
1051
1052    #[test]
1053    fn code_mode_section_optional() {
1054        let cfg = ServerConfig::from_toml(MINIMAL).expect("parse");
1055        assert!(cfg.code_mode.is_none());
1056    }
1057
1058    #[test]
1059    fn validate_accepts_valid_config() {
1060        let cfg = ServerConfig::from_toml(MINIMAL).expect("parse");
1061        cfg.validate().expect("minimal config must validate");
1062    }
1063
1064    #[test]
1065    fn validate_rejects_empty_server_name() {
1066        let toml = r#"
1067            [server]
1068            name = ""
1069            version = "0.1.0"
1070        "#;
1071        let cfg = ServerConfig::from_toml(toml).expect("parse");
1072        match cfg.validate() {
1073            Err(ConfigValidationError::EmptyServerName) => {},
1074            other => panic!("expected EmptyServerName, got {other:?}"),
1075        }
1076    }
1077
1078    #[test]
1079    fn validate_rejects_empty_server_version() {
1080        let toml = r#"
1081            [server]
1082            name = "demo"
1083            version = ""
1084        "#;
1085        let cfg = ServerConfig::from_toml(toml).expect("parse");
1086        match cfg.validate() {
1087            Err(ConfigValidationError::EmptyServerVersion) => {},
1088            other => panic!("expected EmptyServerVersion, got {other:?}"),
1089        }
1090    }
1091
1092    #[test]
1093    fn validate_rejects_empty_tool_name() {
1094        let toml = r#"
1095            [server]
1096            name = "demo"
1097            version = "0.1.0"
1098
1099            [[tools]]
1100            name = "ok"
1101            description = "first"
1102
1103            [[tools]]
1104            name = ""
1105            description = "second-is-empty"
1106        "#;
1107        let cfg = ServerConfig::from_toml(toml).expect("parse");
1108        match cfg.validate() {
1109            Err(ConfigValidationError::EmptyToolName(1)) => {},
1110            other => panic!("expected EmptyToolName(1), got {other:?}"),
1111        }
1112    }
1113
1114    #[test]
1115    fn validate_rejects_empty_table_name() {
1116        let toml = r#"
1117            [server]
1118            name = "demo"
1119            version = "0.1.0"
1120
1121            [[database.tables]]
1122            name = ""
1123            description = "missing-name"
1124        "#;
1125        let cfg = ServerConfig::from_toml(toml).expect("parse");
1126        match cfg.validate() {
1127            Err(ConfigValidationError::EmptyTableName(0)) => {},
1128            other => panic!("expected EmptyTableName(0), got {other:?}"),
1129        }
1130    }
1131
1132    /// Phase 90 gap-closure (GAP 3 / WR-02): a `[backend]` block with an
1133    /// empty / missing `base_url` is rejected at validate() time with
1134    /// [`ConfigValidationError::EmptyBackendBaseUrl`] — not a late opaque
1135    /// `DispatchError::Connector("invalid base URL")` at request time.
1136    #[cfg(feature = "http")]
1137    #[test]
1138    fn validate_rejects_empty_backend_base_url() {
1139        // base_url key present but empty.
1140        let toml = r#"
1141            [server]
1142            name = "demo"
1143            version = "0.1.0"
1144
1145            [backend]
1146            base_url = ""
1147        "#;
1148        let cfg = ServerConfig::from_toml(toml).expect("parse");
1149        match cfg.validate() {
1150            Err(ConfigValidationError::EmptyBackendBaseUrl) => {},
1151            other => panic!("expected EmptyBackendBaseUrl, got {other:?}"),
1152        }
1153    }
1154
1155    /// A `[backend]` block whose `base_url` key is omitted entirely (defaults
1156    /// to `""` via `#[serde(default)]`) is rejected the same way.
1157    #[cfg(feature = "http")]
1158    #[test]
1159    fn validate_rejects_omitted_backend_base_url() {
1160        let toml = r#"
1161            [server]
1162            name = "demo"
1163            version = "0.1.0"
1164
1165            [backend]
1166        "#;
1167        let cfg = ServerConfig::from_toml(toml).expect("parse");
1168        match cfg.validate() {
1169            Err(ConfigValidationError::EmptyBackendBaseUrl) => {},
1170            other => panic!("expected EmptyBackendBaseUrl, got {other:?}"),
1171        }
1172    }
1173
1174    /// A multi-placeholder composition (`${SCHEME}://${HOST}`) is a MALFORMED
1175    /// reference — the grammar resolves one whole-value `${VAR}`, it does not
1176    /// interpolate — so validate() refuses it at load time instead of letting
1177    /// every boot fail with an `UnresolvedBaseUrlRef` naming an empty variable.
1178    #[cfg(feature = "http")]
1179    #[test]
1180    fn validate_rejects_multi_placeholder_backend_base_url() {
1181        let toml = r#"
1182            [server]
1183            name = "demo"
1184            version = "0.1.0"
1185
1186            [backend]
1187            base_url = "${TFL_SCHEME}://${TFL_HOST}"
1188        "#;
1189        let cfg = ServerConfig::from_toml(toml).expect("parse");
1190        match cfg.validate() {
1191            Err(ConfigValidationError::MalformedBackendBaseUrlRef) => {},
1192            other => panic!("expected MalformedBackendBaseUrlRef, got {other:?}"),
1193        }
1194    }
1195
1196    /// The empty `${}` form is the same class of defect and gets the same
1197    /// load-time refusal.
1198    #[cfg(feature = "http")]
1199    #[test]
1200    fn validate_rejects_empty_name_backend_base_url_ref() {
1201        let toml = r#"
1202            [server]
1203            name = "demo"
1204            version = "0.1.0"
1205
1206            [backend]
1207            base_url = "${}"
1208        "#;
1209        let cfg = ServerConfig::from_toml(toml).expect("parse");
1210        match cfg.validate() {
1211            Err(ConfigValidationError::MalformedBackendBaseUrlRef) => {},
1212            other => panic!("expected MalformedBackendBaseUrlRef, got {other:?}"),
1213        }
1214    }
1215
1216    /// A well-formed single reference stays valid — the check refuses only
1217    /// malformed shapes, never the deferred-to-environment pattern itself.
1218    #[cfg(feature = "http")]
1219    #[test]
1220    fn validate_accepts_single_reference_backend_base_url() {
1221        let toml = r#"
1222            [server]
1223            name = "demo"
1224            version = "0.1.0"
1225
1226            [backend]
1227            base_url = "${TFL_BASE_URL}"
1228        "#;
1229        let cfg = ServerConfig::from_toml(toml).expect("parse");
1230        cfg.validate()
1231            .expect("a single ${VAR} backend.base_url reference must validate");
1232    }
1233
1234    /// The SAME malformed-reference rule applies to `[backend.auth]`
1235    /// credentials, and it applies at LOAD time. Without it the credential path
1236    /// resolved a malformed reference to the empty string and then OMITTED it:
1237    /// the server booted, every backend call went out unauthenticated, and
1238    /// nothing was logged. `${TFL-APP-KEY}` is the realistic shape — a dash is
1239    /// not a portably settable variable name, so the reference names nothing.
1240    #[cfg(feature = "http")]
1241    #[test]
1242    fn validate_rejects_malformed_backend_auth_credential_ref() {
1243        let toml = r#"
1244            [server]
1245            name = "demo"
1246            version = "0.1.0"
1247
1248            [backend]
1249            base_url = "https://api.example.com"
1250
1251            [backend.auth]
1252            type = "bearer"
1253            token = "${TFL-APP-KEY}"
1254        "#;
1255        let cfg = ServerConfig::from_toml(toml).expect("parse");
1256        match cfg.validate() {
1257            Err(ConfigValidationError::MalformedBackendAuthRef(field)) => {
1258                assert_eq!(field, "token");
1259            },
1260            other => panic!("expected MalformedBackendAuthRef, got {other:?}"),
1261        }
1262    }
1263
1264    /// The api_key map path gets the same refusal, and the error names the
1265    /// offending entry so the operator knows WHICH parameter to fix.
1266    #[cfg(feature = "http")]
1267    #[test]
1268    fn validate_rejects_malformed_backend_auth_api_key_entry() {
1269        let toml = r#"
1270            [server]
1271            name = "demo"
1272            version = "0.1.0"
1273
1274            [backend]
1275            base_url = "https://api.example.com"
1276
1277            [backend.auth]
1278            type = "api_key"
1279            query_params = { app_key = "${TFL_SCHEME}://${TFL_HOST}" }
1280        "#;
1281        let cfg = ServerConfig::from_toml(toml).expect("parse");
1282        match cfg.validate() {
1283            Err(ConfigValidationError::MalformedBackendAuthRef(field)) => {
1284                assert_eq!(field, "query_params.app_key");
1285            },
1286            other => panic!("expected MalformedBackendAuthRef, got {other:?}"),
1287        }
1288    }
1289
1290    /// The refusal is scoped to MALFORMED shapes only: a well-formed reference
1291    /// and a plain literal both still validate, so the deferred-to-environment
1292    /// pattern and committed dev configs are untouched.
1293    #[cfg(feature = "http")]
1294    #[test]
1295    fn validate_accepts_wellformed_and_literal_backend_auth_credentials() {
1296        let toml = r#"
1297            [server]
1298            name = "demo"
1299            version = "0.1.0"
1300
1301            [backend]
1302            base_url = "https://api.example.com"
1303
1304            [backend.auth]
1305            type = "basic"
1306            username = "svc-account"
1307            password = "${TFL_APP_KEY}"
1308        "#;
1309        let cfg = ServerConfig::from_toml(toml).expect("parse");
1310        cfg.validate()
1311            .expect("a literal username and a single ${VAR} password must validate");
1312    }
1313
1314    /// A `[backend]` block with a non-empty `base_url` validates OK.
1315    #[cfg(feature = "http")]
1316    #[test]
1317    fn validate_accepts_non_empty_backend_base_url() {
1318        let toml = r#"
1319            [server]
1320            name = "demo"
1321            version = "0.1.0"
1322
1323            [backend]
1324            base_url = "https://api.example.com"
1325        "#;
1326        let cfg = ServerConfig::from_toml(toml).expect("parse");
1327        cfg.validate()
1328            .expect("config with a non-empty backend.base_url must validate");
1329    }
1330
1331    /// A config with NO `[backend]` block (a pure-SQL config) is unaffected by
1332    /// the new check — `backend` is `None`, so the check never fires.
1333    #[cfg(feature = "http")]
1334    #[test]
1335    fn validate_accepts_absent_backend() {
1336        let cfg = ServerConfig::from_toml(MINIMAL).expect("parse");
1337        assert!(cfg.backend.is_none());
1338        cfg.validate()
1339            .expect("a config without [backend] must validate (SQL configs unaffected)");
1340    }
1341
1342    /// The error Display names the offending field and is actionable.
1343    #[cfg(feature = "http")]
1344    #[test]
1345    fn empty_backend_base_url_error_names_the_field() {
1346        let msg = ConfigValidationError::EmptyBackendBaseUrl.to_string();
1347        assert!(
1348            msg.contains("[backend].base_url"),
1349            "error must name the field, got: {msg}"
1350        );
1351    }
1352
1353    #[test]
1354    fn database_url_optional_field_parses() {
1355        // Phase 84 CONN-04 / D-08: the additive `[database].url` field parses
1356        // under `#[serde(deny_unknown_fields)]` and carries the `env:VAR_NAME`
1357        // indirection string verbatim (resolution happens at the consumer layer).
1358        let toml = r#"
1359            [server]
1360            name = "x"
1361            version = "0.0.1"
1362
1363            [database]
1364            url = "env:DATABASE_URL"
1365        "#;
1366        let cfg = ServerConfig::from_toml(toml).expect("config with [database].url must parse");
1367        assert_eq!(cfg.database.url, Some("env:DATABASE_URL".to_string()));
1368    }
1369
1370    #[test]
1371    fn from_toml_strict_validated_rolls_both_errors() {
1372        // 1. Parse error path (unknown field).
1373        let bad_toml = r#"
1374            [server]
1375            name = "demo"
1376            version = "0.1.0"
1377            nonsense = "x"
1378        "#;
1379        let err = ServerConfig::from_toml_strict_validated(bad_toml)
1380            .expect_err("unknown field must surface");
1381        assert!(matches!(err, ToolkitError::Parse(_)), "got: {err:?}");
1382
1383        // 2. Validation error path (empty required value).
1384        let invalid_toml = r#"
1385            [server]
1386            name = ""
1387            version = "0.1.0"
1388        "#;
1389        let err = ServerConfig::from_toml_strict_validated(invalid_toml)
1390            .expect_err("empty name must surface");
1391        assert!(
1392            matches!(
1393                err,
1394                ToolkitError::Validation(ConfigValidationError::EmptyServerName)
1395            ),
1396            "got: {err:?}"
1397        );
1398    }
1399
1400    // -------------------------------------------------------------------------
1401    // ToolDecl two-kind detection — D-01 (shared, not http-gated)
1402    // -------------------------------------------------------------------------
1403
1404    #[test]
1405    fn test_tooldecl_single_call_parses() {
1406        let toml = r#"
1407            [server]
1408            name = "tube"
1409            version = "0.1.0"
1410
1411            [[tools]]
1412            name = "tube_status"
1413            path = "/Line/Mode/tube/Status"
1414            method = "GET"
1415        "#;
1416        let cfg = ServerConfig::from_toml(toml).expect("single-call tool must parse");
1417        let tool = &cfg.tools[0];
1418        assert_eq!(tool.path.as_deref(), Some("/Line/Mode/tube/Status"));
1419        assert_eq!(tool.method.as_deref(), Some("GET"));
1420        assert!(!tool.is_script_tool());
1421        cfg.validate()
1422            .expect("single-call tool is a valid single kind");
1423    }
1424
1425    #[test]
1426    fn test_tooldecl_script_parses() {
1427        let toml = r#"
1428            [server]
1429            name = "tube"
1430            version = "0.1.0"
1431
1432            [[tools]]
1433            name = "plan_journey"
1434            script = """
1435            const a = await api.get('/Journey/JourneyResults/' + args.from + '/to/' + args.to);
1436            return a;
1437            """
1438
1439            [[tools.parameters]]
1440            name = "from"
1441            type = "string"
1442            required = true
1443
1444            [[tools.parameters]]
1445            name = "to"
1446            type = "string"
1447            required = true
1448        "#;
1449        let cfg = ServerConfig::from_toml(toml).expect("script tool must parse");
1450        let tool = &cfg.tools[0];
1451        assert!(tool.script.is_some());
1452        assert!(tool.is_script_tool());
1453        assert_eq!(tool.parameters.len(), 2);
1454        cfg.validate().expect("script tool is a valid single kind");
1455    }
1456
1457    #[test]
1458    fn test_tooldecl_detection() {
1459        let script = ToolDecl {
1460            script: Some("return 1;".to_string()),
1461            ..Default::default()
1462        };
1463        assert!(script.is_script_tool());
1464
1465        let single = ToolDecl {
1466            path: Some("/x".to_string()),
1467            method: Some("GET".to_string()),
1468            ..Default::default()
1469        };
1470        assert!(!single.is_script_tool());
1471
1472        let sql = ToolDecl {
1473            sql: Some("SELECT 1".to_string()),
1474            ..Default::default()
1475        };
1476        assert!(!sql.is_script_tool());
1477    }
1478
1479    #[test]
1480    fn test_tooldecl_ambiguous_rejected() {
1481        // script + path/method is ambiguous (Codex MEDIUM): rejected, not
1482        // resolved by a silent "script wins".
1483        let toml = r#"
1484            [server]
1485            name = "tube"
1486            version = "0.1.0"
1487
1488            [[tools]]
1489            name = "confused"
1490            path = "/x"
1491            method = "GET"
1492            script = "return 1;"
1493        "#;
1494        let cfg = ServerConfig::from_toml(toml).expect("parse (ambiguity is a validate-time rule)");
1495        match cfg.validate() {
1496            Err(ConfigValidationError::AmbiguousToolKind(0)) => {},
1497            other => panic!("expected AmbiguousToolKind(0), got {other:?}"),
1498        }
1499    }
1500
1501    #[test]
1502    fn test_tooldecl_ambiguous_sql_plus_script_rejected() {
1503        let toml = r#"
1504            [server]
1505            name = "tube"
1506            version = "0.1.0"
1507
1508            [[tools]]
1509            name = "confused"
1510            sql = "SELECT 1"
1511            script = "return 1;"
1512        "#;
1513        let cfg = ServerConfig::from_toml(toml).expect("parse");
1514        match cfg.validate() {
1515            Err(ConfigValidationError::AmbiguousToolKind(0)) => {},
1516            other => panic!("expected AmbiguousToolKind(0), got {other:?}"),
1517        }
1518    }
1519
1520    #[test]
1521    fn test_tooldecl_sql_still_parses() {
1522        // REF-01 superset regression: an existing sql= tool is unaffected by the
1523        // additive path/method/base_url/script fields.
1524        let toml = r#"
1525            [server]
1526            name = "demo"
1527            version = "0.1.0"
1528
1529            [[tools]]
1530            name = "list_tables"
1531            sql = "SELECT name FROM sqlite_master"
1532        "#;
1533        let cfg = ServerConfig::from_toml(toml).expect("sql tool must still parse");
1534        let tool = &cfg.tools[0];
1535        assert_eq!(tool.sql.as_deref(), Some("SELECT name FROM sqlite_master"));
1536        assert!(tool.path.is_none());
1537        assert!(tool.method.is_none());
1538        assert!(tool.base_url.is_none());
1539        assert!(tool.script.is_none());
1540        assert!(!tool.is_script_tool());
1541        cfg.validate().expect("sql tool validates as a single kind");
1542    }
1543
1544    // -------------------------------------------------------------------------
1545    // [backend] / [backend.auth] / [backend.http] — D-06 (http feature)
1546    // -------------------------------------------------------------------------
1547
1548    #[cfg(feature = "http")]
1549    #[test]
1550    fn test_backend_section_parses() {
1551        // A full [backend] + [backend.auth] (api_key) + [backend.http] block
1552        // round-trips into ServerConfig with backend.is_some().
1553        let toml = r#"
1554            [server]
1555            name = "tube"
1556            version = "0.1.0"
1557
1558            [backend]
1559            base_url = "https://api.tfl.gov.uk"
1560
1561            [backend.auth]
1562            type = "api_key"
1563
1564            [backend.auth.query_params]
1565            app_key = "${TFL_APP_KEY}"
1566
1567            [backend.http]
1568            timeout_seconds = 10
1569            retries = 2
1570        "#;
1571        let cfg = ServerConfig::from_toml(toml).expect("[backend] config must parse");
1572        let backend = cfg.backend.expect("backend must be Some");
1573        assert_eq!(backend.base_url, "https://api.tfl.gov.uk");
1574        assert_eq!(backend.http.timeout_seconds, 10);
1575        assert_eq!(backend.http.retries, 2);
1576        assert!(
1577            matches!(backend.auth, AuthConfig::ApiKey { .. }),
1578            "auth must be api_key, got {:?}",
1579            backend.auth
1580        );
1581    }
1582
1583    #[cfg(feature = "http")]
1584    #[test]
1585    fn test_backend_auth_defaults_to_none() {
1586        // [backend] without a [backend.auth] sub-table defaults auth to None
1587        // and http to HttpConfig defaults (additive sub-tables).
1588        let toml = r#"
1589            [server]
1590            name = "tube"
1591            version = "0.1.0"
1592
1593            [backend]
1594            base_url = "https://api.example.com"
1595        "#;
1596        let cfg = ServerConfig::from_toml(toml).expect("backend w/o auth must parse");
1597        let backend = cfg.backend.expect("backend must be Some");
1598        assert!(matches!(backend.auth, AuthConfig::None));
1599        assert_eq!(backend.http, HttpConfig::default());
1600    }
1601
1602    #[cfg(feature = "http")]
1603    #[test]
1604    fn test_sql_config_unaffected() {
1605        // REF-01 superset / D-06 additive proof: a pure-SQL config with NO
1606        // [backend] still parses, and backend == None.
1607        let toml = r#"
1608            [server]
1609            name = "demo"
1610            version = "0.1.0"
1611
1612            [database]
1613            type = "sqlite"
1614            file_path = "/tmp/demo.db"
1615
1616            [[tools]]
1617            name = "list_tables"
1618            sql = "SELECT name FROM sqlite_master"
1619        "#;
1620        let cfg = ServerConfig::from_toml(toml).expect("SQL config must still parse");
1621        assert!(
1622            cfg.backend.is_none(),
1623            "SQL config must have backend == None"
1624        );
1625        assert_eq!(cfg.tools.len(), 1);
1626    }
1627
1628    #[cfg(feature = "http")]
1629    #[test]
1630    fn test_backend_unknown_field_rejected() {
1631        // T-90-02-01: deny_unknown_fields preserved — an unknown key under
1632        // [backend.http] is a hard parse error, never a silent default.
1633        let toml = r#"
1634            [server]
1635            name = "tube"
1636            version = "0.1.0"
1637
1638            [backend]
1639            base_url = "https://api.example.com"
1640
1641            [backend.http]
1642            foo = 1
1643        "#;
1644        let err =
1645            ServerConfig::from_toml(toml).expect_err("unknown [backend.http] key must be rejected");
1646        assert!(matches!(err, ToolkitError::Parse(_)), "got: {err:?}");
1647    }
1648
1649    // -------------------------------------------------------------------------
1650    // `[[config_slots]]` — PKG-03 slot declarations (Phase 120 Plan 04 Task 1)
1651    // -------------------------------------------------------------------------
1652
1653    /// The three-slot declaration block the london-tube proving fixture carries.
1654    const CONFIG_SLOTS_TOML: &str = r#"
1655        [server]
1656        name = "london-tube"
1657        version = "1.1.0"
1658
1659        [[config_slots]]
1660        key = "backend.base_url"
1661        kind = "endpoint"
1662        name = "TFL_BASE_URL"
1663        tested_value = "https://api.tfl.gov.uk"
1664
1665        [[config_slots]]
1666        key = "backend.auth.query_params.app_key"
1667        kind = "secret"
1668        name = "TFL_APP_KEY"
1669
1670        [[config_slots]]
1671        key = "backend.auth.type"
1672        kind = "auth_mode"
1673        name = "backend-auth-mode"
1674        tested_value = "api_key"
1675    "#;
1676
1677    /// Test 1: a `[[config_slots]]` block parses through the STRICT + validated
1678    /// entry point and exposes all three entries with their fields intact.
1679    #[test]
1680    fn config_slots_block_parses_through_strict_entry_point() {
1681        let cfg = ServerConfig::from_toml_strict_validated(CONFIG_SLOTS_TOML)
1682            .expect("[[config_slots]] must parse through the strict entry point");
1683        assert_eq!(cfg.config_slots.len(), 3, "three declared slots");
1684
1685        assert_eq!(cfg.config_slots[0].key, "backend.base_url");
1686        assert_eq!(cfg.config_slots[0].kind, ConfigSlotKind::Endpoint);
1687        assert_eq!(cfg.config_slots[0].name, "TFL_BASE_URL");
1688        assert_eq!(
1689            cfg.config_slots[0].tested_value.as_deref(),
1690            Some("https://api.tfl.gov.uk")
1691        );
1692
1693        assert_eq!(cfg.config_slots[1].kind, ConfigSlotKind::Secret);
1694        assert_eq!(cfg.config_slots[1].name, "TFL_APP_KEY");
1695        assert_eq!(cfg.config_slots[2].kind, ConfigSlotKind::AuthMode);
1696    }
1697
1698    /// Test 2: the field is ADDITIVE — a config with no `[[config_slots]]` block
1699    /// parses unchanged and yields an empty vec (`#[serde(default)]`).
1700    #[test]
1701    fn config_without_config_slots_parses_with_empty_vec() {
1702        let cfg = ServerConfig::from_toml_strict_validated(MINIMAL)
1703            .expect("a config omitting [[config_slots]] still parses");
1704        assert!(
1705            cfg.config_slots.is_empty(),
1706            "absent block yields an empty vec, not a default entry"
1707        );
1708    }
1709
1710    /// Test 3: `deny_unknown_fields` still bites at the TOP level — a typo'd
1711    /// `[[config_slotz]]` is a hard parse error, never a silently-ignored block.
1712    #[test]
1713    fn top_level_config_slots_typo_is_still_rejected() {
1714        let toml = r#"
1715            [server]
1716            name = "demo"
1717            version = "0.1.0"
1718
1719            [[config_slotz]]
1720            key = "backend.base_url"
1721            kind = "endpoint"
1722            name = "TFL_BASE_URL"
1723        "#;
1724        let err = ServerConfig::from_toml(toml)
1725            .expect_err("a typo'd top-level array-of-tables must be rejected");
1726        assert!(matches!(err, ToolkitError::Parse(_)), "got: {err:?}");
1727    }
1728
1729    /// Test 4: the decl struct is itself `deny_unknown_fields` — a typo INSIDE
1730    /// the block (`nmae`) is rejected rather than silently dropped.
1731    #[test]
1732    fn config_slot_unknown_inner_key_is_rejected() {
1733        let toml = r#"
1734            [server]
1735            name = "demo"
1736            version = "0.1.0"
1737
1738            [[config_slots]]
1739            key = "backend.base_url"
1740            kind = "endpoint"
1741            nmae = "TFL_BASE_URL"
1742        "#;
1743        let err = ServerConfig::from_toml(toml)
1744            .expect_err("an unknown key inside [[config_slots]] must be rejected");
1745        assert!(matches!(err, ToolkitError::Parse(_)), "got: {err:?}");
1746    }
1747
1748    /// Test 5: `tested_value` is OPTIONAL — an identity-bearing slot structurally
1749    /// carries no value, so omitting it parses to `None`.
1750    #[test]
1751    fn config_slot_tested_value_is_optional() {
1752        let toml = r#"
1753            [server]
1754            name = "demo"
1755            version = "0.1.0"
1756
1757            [[config_slots]]
1758            key = "backend.auth.query_params.app_key"
1759            kind = "secret"
1760            name = "TFL_APP_KEY"
1761        "#;
1762        let cfg = ServerConfig::from_toml_strict_validated(toml)
1763            .expect("an entry without tested_value parses");
1764        assert_eq!(cfg.config_slots.len(), 1);
1765        assert!(
1766            cfg.config_slots[0].tested_value.is_none(),
1767            "omitted tested_value parses to None"
1768        );
1769    }
1770
1771    /// Test 6 (Codex MEDIUM — the invalid-kind hole): `kind` is a CLOSED
1772    /// vocabulary. A typo such as `endpont` — or an empty string — is a PARSE
1773    /// error naming the accepted set, not a declaration that parses cleanly and
1774    /// then fails to map to any package slot type two crates away.
1775    #[test]
1776    fn config_slot_invalid_kind_is_rejected_naming_the_accepted_set() {
1777        for bad in ["endpont", ""] {
1778            let toml = format!(
1779                r#"
1780                [server]
1781                name = "demo"
1782                version = "0.1.0"
1783
1784                [[config_slots]]
1785                key = "backend.base_url"
1786                kind = "{bad}"
1787                name = "TFL_BASE_URL"
1788                "#
1789            );
1790            let err = ServerConfig::from_toml(&toml)
1791                .expect_err("an unrecognized config-slot kind must be rejected at parse time");
1792            let rendered = err.to_string();
1793            for accepted in ["endpoint", "secret", "auth_mode"] {
1794                assert!(
1795                    rendered.contains(accepted),
1796                    "the error for kind = \"{bad}\" must name the accepted kind \
1797                     `{accepted}`: {rendered}"
1798                );
1799            }
1800        }
1801    }
1802
1803    /// Test 7: all three valid kinds parse, and the parsed value is a CLOSED
1804    /// enum — comparable as `ConfigSlotKind`, not as a free string. A fourth
1805    /// kind is therefore a deliberate addition here, never a silent
1806    /// pass-through to the package side.
1807    #[test]
1808    fn config_slot_all_three_kinds_parse_as_a_closed_enum() {
1809        let cfg = ServerConfig::from_toml_strict_validated(CONFIG_SLOTS_TOML)
1810            .expect("all three kinds parse");
1811        let kinds: Vec<ConfigSlotKind> = cfg.config_slots.iter().map(|s| s.kind).collect();
1812        assert_eq!(
1813            kinds,
1814            vec![
1815                ConfigSlotKind::Endpoint,
1816                ConfigSlotKind::Secret,
1817                ConfigSlotKind::AuthMode
1818            ],
1819            "kind is a closed enum, not a free string"
1820        );
1821    }
1822
1823    /// `validate()` rejects an entry whose `key` or `name` is empty/whitespace,
1824    /// carrying the offending entry INDEX (the `EmptyTableName(i)` error shape).
1825    #[test]
1826    fn config_slot_empty_key_or_name_fails_validation() {
1827        for field in ["key", "name"] {
1828            let (key, name) = if field == "key" {
1829                ("   ", "TFL_BASE_URL")
1830            } else {
1831                ("backend.base_url", "  ")
1832            };
1833            let toml = format!(
1834                r#"
1835                [server]
1836                name = "demo"
1837                version = "0.1.0"
1838
1839                [[config_slots]]
1840                key = "{key}"
1841                kind = "endpoint"
1842                name = "{name}"
1843                "#
1844            );
1845            let cfg = ServerConfig::from_toml(&toml).expect("parses; emptiness is semantic");
1846            let err = cfg
1847                .validate()
1848                .expect_err("an empty config-slot key/name must fail validation");
1849            assert!(
1850                matches!(err, ConfigValidationError::EmptyConfigSlotField(0)),
1851                "empty {field} must yield EmptyConfigSlotField(0), got: {err:?}"
1852            );
1853        }
1854    }
1855
1856    /// `validate()` refuses a `secret` declaration carrying a `tested_value` —
1857    /// identity-bearing slots structurally record no value, and this field is
1858    /// the one place a REAL credential could sit in a config that is served
1859    /// but never packed (pack-time gates only run on packaging).
1860    #[test]
1861    fn config_slot_secret_with_tested_value_fails_validation_without_echoing_it() {
1862        let toml = r#"
1863            [server]
1864            name = "demo"
1865            version = "0.1.0"
1866
1867            [[config_slots]]
1868            key = "backend.auth.query_params.app_key"
1869            kind = "secret"
1870            name = "TFL_APP_KEY"
1871            tested_value = "sentinel-real-credential"
1872        "#;
1873        let cfg = ServerConfig::from_toml(toml).expect("parses; the rule is semantic");
1874        let err = cfg
1875            .validate()
1876            .expect_err("a secret slot carrying a tested_value must fail validation");
1877        assert!(
1878            matches!(err, ConfigValidationError::SecretSlotCarriesTestedValue(0)),
1879            "got: {err:?}"
1880        );
1881        assert!(
1882            !err.to_string().contains("sentinel-real-credential"),
1883            "the error must not echo the value: {err}"
1884        );
1885    }
1886
1887    proptest! {
1888        /// TEST-02: any valid `ServerConfig` round-trips through TOML.
1889        ///
1890        /// Builds a `ServerConfig` from an arbitrary (but valid) `(name, version)`
1891        /// pair, serializes it, parses it back, and asserts equality on the
1892        /// load-bearing scalars.
1893        #[test]
1894        fn server_config_minimal_round_trips(
1895            name in "[a-zA-Z0-9_-]{1,32}",
1896            version in "[0-9]+\\.[0-9]+\\.[0-9]+",
1897        ) {
1898            let cfg = ServerConfig {
1899                server: ServerSection {
1900                    name: name.clone(),
1901                    version: version.clone(),
1902                    ..Default::default()
1903                },
1904                ..Default::default()
1905            };
1906            let s = toml::to_string(&cfg).unwrap();
1907            let parsed = ServerConfig::from_toml(&s).unwrap();
1908            prop_assert_eq!(parsed.server.name, name);
1909            prop_assert_eq!(parsed.server.version, version);
1910        }
1911    }
1912}