mcp_execution_codegen/progressive/types.rs
1//! Types for progressive loading code generation.
2//!
3//! Defines data structures used during progressive code generation,
4//! where each tool is generated as a separate file.
5
6use serde::{Deserialize, Serialize};
7
8/// Context for rendering a single tool template.
9///
10/// Contains all data needed to generate one tool file in the
11/// progressive loading pattern.
12///
13/// # Examples
14///
15/// ```
16/// use mcp_execution_codegen::progressive::ToolContext;
17/// use serde_json::json;
18///
19/// let context = ToolContext {
20/// server_id: "github".to_string(),
21/// name: "create_issue".to_string(),
22/// name_literal: "create_issue".to_string(),
23/// server_id_literal: "github".to_string(),
24/// typescript_name: "createIssue".to_string(),
25/// description: "Creates a new issue".to_string(),
26/// input_schema: json!({"type": "object"}),
27/// properties: vec![],
28/// category: Some("issues".to_string()),
29/// keywords: Some("create,issue,new,bug".to_string()),
30/// short_description: "Create a new issue".to_string(),
31/// };
32///
33/// assert_eq!(context.server_id, "github");
34/// ```
35#[derive(Debug, Clone, Serialize, Deserialize)]
36pub struct ToolContext {
37 /// MCP server identifier, sanitized for safe embedding in a `JSDoc` comment
38 pub server_id: String,
39 /// Original tool name (`snake_case`), sanitized for safe embedding in a `JSDoc` comment
40 pub name: String,
41 /// Original tool name escaped for safe embedding in a single-quoted TS string literal
42 pub name_literal: String,
43 /// Server identifier escaped for safe embedding in a single-quoted TS string literal
44 pub server_id_literal: String,
45 /// TypeScript-friendly name (camelCase), sanitized to a safe identifier
46 pub typescript_name: String,
47 /// Human-readable description
48 pub description: String,
49 /// JSON Schema for input parameters, with `description` fields sanitized
50 /// for safe interpolation into `JSDoc` block comments (see issue #102).
51 pub input_schema: serde_json::Value,
52 /// Extracted properties for template rendering
53 pub properties: Vec<PropertyInfo>,
54 /// Optional category for tool grouping
55 pub category: Option<String>,
56 /// Optional keywords for discovery via grep/search
57 pub keywords: Option<String>,
58 /// Short description for header comment. Always populated: `ProgressiveGenerator`'s only
59 /// constructor falls back to `description` when no categorization short description is
60 /// available, so `None` is not a state this type can represent.
61 pub short_description: String,
62}
63
64/// Information about a single parameter property.
65///
66/// Used in Handlebars templates to render parameter type definitions.
67///
68/// # Examples
69///
70/// ```
71/// use mcp_execution_codegen::progressive::PropertyInfo;
72///
73/// let prop = PropertyInfo {
74/// name: "title".to_string(),
75/// typescript_type: "string".to_string(),
76/// description: Some("Issue title".to_string()),
77/// required: true,
78/// };
79///
80/// assert_eq!(prop.name, "title");
81/// assert!(prop.required);
82/// ```
83#[derive(Debug, Clone, Serialize, Deserialize)]
84pub struct PropertyInfo {
85 /// Property name
86 pub name: String,
87 /// TypeScript type (e.g., "string", "number", "boolean")
88 pub typescript_type: String,
89 /// Optional description from schema
90 pub description: Option<String>,
91 /// Whether the property is required
92 pub required: bool,
93}
94
95/// Context for rendering the index.ts template.
96///
97/// Contains server-level metadata and list of all tools.
98///
99/// # Examples
100///
101/// ```
102/// use mcp_execution_codegen::progressive::IndexContext;
103///
104/// let context = IndexContext {
105/// server_name: "GitHub".to_string(),
106/// server_version: "1.0.0".to_string(),
107/// tool_count: 30,
108/// tools: vec![],
109/// categories: None,
110/// };
111///
112/// assert_eq!(context.tool_count, 30);
113/// ```
114#[derive(Debug, Clone, Serialize, Deserialize)]
115pub struct IndexContext {
116 /// Server name for documentation
117 pub server_name: String,
118 /// Server version
119 pub server_version: String,
120 /// Total number of tools
121 pub tool_count: usize,
122 /// List of tool summaries
123 pub tools: Vec<ToolSummary>,
124 /// Tools grouped by category (optional, for categorized generation)
125 #[serde(skip_serializing_if = "Option::is_none")]
126 pub categories: Option<Vec<CategoryInfo>>,
127}
128
129/// Summary of a tool for index file generation.
130///
131/// Lighter-weight than full `ToolContext`, used only for
132/// re-exports and documentation in index.ts.
133///
134/// # Examples
135///
136/// ```
137/// use mcp_execution_codegen::progressive::ToolSummary;
138///
139/// let summary = ToolSummary {
140/// typescript_name: "createIssue".to_string(),
141/// description: "Creates a new issue".to_string(),
142/// category: Some("issues".to_string()),
143/// keywords: Some("create,issue,new".to_string()),
144/// short_description: Some("Create a new issue".to_string()),
145/// };
146///
147/// assert_eq!(summary.typescript_name, "createIssue");
148/// ```
149#[derive(Debug, Clone, Serialize, Deserialize)]
150pub struct ToolSummary {
151 /// TypeScript-friendly name (camelCase)
152 pub typescript_name: String,
153 /// Human-readable description
154 pub description: String,
155 /// Optional category for tool grouping
156 pub category: Option<String>,
157 /// Optional keywords for discovery via grep/search
158 pub keywords: Option<String>,
159 /// Optional short description for header comment
160 pub short_description: Option<String>,
161}
162
163/// Categorization metadata for a single tool.
164///
165/// Contains all categorization data from Claude's analysis.
166///
167/// # Examples
168///
169/// ```
170/// use mcp_execution_codegen::progressive::ToolCategorization;
171///
172/// let cat = ToolCategorization {
173/// category: "issues".to_string(),
174/// keywords: vec!["create".to_string(), "issue".to_string(), "new".to_string(), "bug".to_string()],
175/// short_description: "Create a new issue in a repository".to_string(),
176/// };
177///
178/// assert_eq!(cat.category, "issues");
179/// ```
180#[derive(Debug, Clone, Serialize, Deserialize)]
181pub struct ToolCategorization {
182 /// Category for tool grouping
183 pub category: String,
184 /// Keywords for discovery via grep/search
185 pub keywords: Vec<String>,
186 /// Concise description for header comment
187 pub short_description: String,
188}
189
190/// Category information for grouped tool display in index.
191///
192/// Groups tools by category for organized documentation.
193///
194/// # Examples
195///
196/// ```
197/// use mcp_execution_codegen::progressive::{CategoryInfo, ToolSummary};
198///
199/// let category = CategoryInfo {
200/// name: "issues".to_string(),
201/// tools: vec![
202/// ToolSummary {
203/// typescript_name: "createIssue".to_string(),
204/// description: "Creates a new issue".to_string(),
205/// category: Some("issues".to_string()),
206/// keywords: Some("create,issue".to_string()),
207/// short_description: Some("Create issue".to_string()),
208/// },
209/// ],
210/// };
211///
212/// assert_eq!(category.name, "issues");
213/// ```
214#[derive(Debug, Clone, Serialize, Deserialize)]
215pub struct CategoryInfo {
216 /// Category name
217 pub name: String,
218 /// Tools in this category
219 pub tools: Vec<ToolSummary>,
220}
221
222/// Context for rendering the runtime bridge template.
223///
224/// The forbidden-char/forbidden-env-name fields are rendered directly from
225/// `mcp_execution_core`'s canonical lists so the generated bridge's copies structurally
226/// cannot drift from the Rust source of truth — see [`BridgeContext::default`], the only way
227/// to construct one, which populates them from `mcp_execution_core::forbidden_chars`/
228/// `forbidden_env_names`/`forbidden_env_prefix` rather than leaving them empty. This
229/// deliberately does *not* derive `Default`: an empty `forbidden_chars` would render a bridge
230/// whose `validateCommandString` accepts every shell metacharacter (fail-open on exactly the
231/// check this exists to enforce), so `Default` is hand-written to make "always populated" a
232/// property of the type rather than a convention callers must remember to uphold.
233///
234/// The three fields are private with read-only accessors for the same reason: `pub` fields
235/// would let `BridgeContext { forbidden_chars: vec![], .. }` bypass the invariant entirely and
236/// still compile, silently reintroducing the fail-open state `Default` exists to prevent.
237/// `Deserialize` is intentionally not derived — nothing in this codebase deserializes a
238/// `BridgeContext` from external input, and doing so would need to re-validate non-emptiness
239/// rather than trust the wire data.
240///
241/// # Examples
242///
243/// ```
244/// use mcp_execution_codegen::progressive::BridgeContext;
245///
246/// let context = BridgeContext::default();
247/// assert!(!context.forbidden_chars().is_empty());
248/// assert!(context.forbidden_chars().contains(&";".to_string()));
249/// assert!(!context.forbidden_env_prefix().is_empty());
250/// ```
251// The shared `forbidden_` prefix mirrors the three distinct `mcp_execution_core` accessors
252// (`forbidden_chars`/`forbidden_env_names`/`forbidden_env_prefix`) these fields are populated
253// from; dropping it would obscure that correspondence for no clarity gain.
254#[allow(clippy::struct_field_names)]
255#[derive(Debug, Clone, Serialize)]
256pub struct BridgeContext {
257 /// Shell metacharacters forbidden in a command or argument string, each pre-escaped for
258 /// safe embedding inside a single-quoted TypeScript string literal.
259 forbidden_chars: Vec<String>,
260 /// Forbidden environment variable names (exact match).
261 forbidden_env_names: Vec<String>,
262 /// Environment-variable-name prefix rejected regardless of exact match (e.g. `DYLD_`).
263 forbidden_env_prefix: String,
264}
265
266impl BridgeContext {
267 /// Shell metacharacters forbidden in a command or argument string, each pre-escaped for
268 /// safe embedding inside a single-quoted TypeScript string literal. Never empty.
269 ///
270 /// # Examples
271 ///
272 /// ```
273 /// use mcp_execution_codegen::progressive::BridgeContext;
274 ///
275 /// assert!(!BridgeContext::default().forbidden_chars().is_empty());
276 /// ```
277 #[must_use]
278 pub fn forbidden_chars(&self) -> &[String] {
279 &self.forbidden_chars
280 }
281
282 /// Forbidden environment variable names (exact match). Never empty.
283 ///
284 /// # Examples
285 ///
286 /// ```
287 /// use mcp_execution_codegen::progressive::BridgeContext;
288 ///
289 /// assert!(!BridgeContext::default().forbidden_env_names().is_empty());
290 /// ```
291 #[must_use]
292 pub fn forbidden_env_names(&self) -> &[String] {
293 &self.forbidden_env_names
294 }
295
296 /// Environment-variable-name prefix rejected regardless of exact match (e.g. `DYLD_`).
297 /// Never empty.
298 ///
299 /// # Examples
300 ///
301 /// ```
302 /// use mcp_execution_codegen::progressive::BridgeContext;
303 ///
304 /// assert!(!BridgeContext::default().forbidden_env_prefix().is_empty());
305 /// ```
306 #[must_use]
307 pub fn forbidden_env_prefix(&self) -> &str {
308 &self.forbidden_env_prefix
309 }
310}
311
312impl Default for BridgeContext {
313 /// Populates the forbidden-char/forbidden-env-name fields directly from
314 /// `mcp_execution_core`'s canonical lists, so `BridgeContext::default()` can never render
315 /// a bridge with an empty (fail-open) `FORBIDDEN_CHARS`. Each character is passed through
316 /// `sanitize_ts_string_literal` (this crate's TS-string-literal escaper) so it renders as
317 /// a syntactically valid single-quoted TypeScript string literal regardless of what the
318 /// Rust list contains.
319 fn default() -> Self {
320 Self {
321 forbidden_chars: mcp_execution_core::forbidden_chars()
322 .iter()
323 .map(|c| crate::progressive::generator::sanitize_ts_string_literal(&c.to_string()))
324 .collect(),
325 forbidden_env_names: mcp_execution_core::forbidden_env_names()
326 .iter()
327 .map(|&s| s.to_string())
328 .collect(),
329 forbidden_env_prefix: mcp_execution_core::forbidden_env_prefix().to_string(),
330 }
331 }
332}
333
334#[cfg(test)]
335mod tests {
336 use super::*;
337 use serde_json::json;
338
339 #[test]
340 fn test_tool_context() {
341 let context = ToolContext {
342 server_id: "github".to_string(),
343 name: "create_issue".to_string(),
344 name_literal: "create_issue".to_string(),
345 server_id_literal: "github".to_string(),
346 typescript_name: "createIssue".to_string(),
347 description: "Creates an issue".to_string(),
348 input_schema: json!({"type": "object"}),
349 properties: vec![],
350 category: Some("issues".to_string()),
351 keywords: Some("create,issue,new".to_string()),
352 short_description: "Create a new issue".to_string(),
353 };
354
355 assert_eq!(context.server_id, "github");
356 assert_eq!(context.name, "create_issue");
357 assert_eq!(context.typescript_name, "createIssue");
358 assert_eq!(context.category, Some("issues".to_string()));
359 assert_eq!(context.keywords, Some("create,issue,new".to_string()));
360 }
361
362 #[test]
363 fn test_property_info() {
364 let prop = PropertyInfo {
365 name: "title".to_string(),
366 typescript_type: "string".to_string(),
367 description: Some("Issue title".to_string()),
368 required: true,
369 };
370
371 assert_eq!(prop.name, "title");
372 assert_eq!(prop.typescript_type, "string");
373 assert!(prop.required);
374 }
375
376 #[test]
377 fn test_index_context() {
378 let context = IndexContext {
379 server_name: "GitHub".to_string(),
380 server_version: "1.0.0".to_string(),
381 tool_count: 5,
382 tools: vec![],
383 categories: None,
384 };
385
386 assert_eq!(context.server_name, "GitHub");
387 assert_eq!(context.tool_count, 5);
388 assert!(context.categories.is_none());
389 }
390
391 #[test]
392 fn test_tool_summary() {
393 let summary = ToolSummary {
394 typescript_name: "createIssue".to_string(),
395 description: "Creates an issue".to_string(),
396 category: Some("issues".to_string()),
397 keywords: Some("create,issue".to_string()),
398 short_description: Some("Create issue".to_string()),
399 };
400
401 assert_eq!(summary.typescript_name, "createIssue");
402 assert_eq!(summary.category, Some("issues".to_string()));
403 assert_eq!(summary.keywords, Some("create,issue".to_string()));
404 }
405
406 #[test]
407 fn test_bridge_context_default() {
408 let context = BridgeContext::default();
409 let _serialized = serde_json::to_string(&context).unwrap();
410
411 // #221 critique S2: `Default` must never render a fail-open (empty) forbidden-char
412 // list, since an empty `FORBIDDEN_CHARS` in the rendered bridge would make
413 // `validateCommandString` accept every shell metacharacter.
414 assert!(!context.forbidden_chars().is_empty());
415 assert!(!context.forbidden_env_names().is_empty());
416 assert!(!context.forbidden_env_prefix().is_empty());
417 }
418}