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}