Skip to main content

xtask/codex_schema/
naming.rs

1//! Method-name -> Rust-identifier derivation, ported from
2//! `build_combined_schema.py`. Naming here must exactly match what `typify`
3//! names the corresponding generated Rust types/enum variants - this was
4//! empirically cross-validated against real typify output when this crate
5//! was first built, so keep it in lock-step with the Python original rather
6//! than "simplifying".
7
8use std::collections::BTreeSet;
9use std::sync::OnceLock;
10
11use anyhow::{bail, Result};
12use fancy_regex::Regex;
13use serde_json::{Map, Value};
14
15/// `PascalCase` derived from a `/`- and `_`-delimited method name, e.g.
16/// `"thread/start"` -> `"ThreadStart"`. Only the first character of each
17/// segment is uppercased; the rest of the segment is left untouched (so
18/// already-camelCase segments like `"mcpServer"` become `"McpServer"`, not
19/// `"MCPSERVER"` or `"Mcpserver"`).
20pub fn method_to_pascal(method: &str) -> String {
21    method
22        .split(['/', '_'])
23        .filter(|t| !t.is_empty())
24        .map(|t| {
25            let mut chars = t.chars();
26            match chars.next() {
27                Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
28                None => String::new(),
29            }
30        })
31        .collect()
32}
33
34/// Known irregular method -> response-type-name mappings the naming
35/// convention can't derive (see `crates/shared/codex-app-server-client/README.md`
36/// for how these were discovered).
37pub const RESPONSE_OVERRIDES: &[(&str, &str)] = &[
38    ("account/read", "GetAccountResponse"),
39    ("account/rateLimits/read", "GetAccountRateLimitsResponse"),
40    ("account/usage/read", "GetAccountTokenUsageResponse"),
41    (
42        "account/workspaceMessages/read",
43        "GetWorkspaceMessagesResponse",
44    ),
45    ("account/login/start", "LoginAccountResponse"),
46    (
47        "account/sendAddCreditsNudgeEmail",
48        "SendAddCreditsNudgeEmailResponse",
49    ),
50    (
51        "account/chatgptAuthTokens/refresh",
52        "ChatgptAuthTokensRefreshResponse",
53    ),
54    ("app/list", "AppsListResponse"),
55    ("config/batchWrite", "ConfigWriteResponse"),
56    ("config/value/write", "ConfigWriteResponse"),
57    (
58        "item/commandExecution/requestApproval",
59        "CommandExecutionRequestApprovalResponse",
60    ),
61    (
62        "item/fileChange/requestApproval",
63        "FileChangeRequestApprovalResponse",
64    ),
65    (
66        "item/permissions/requestApproval",
67        "PermissionsRequestApprovalResponse",
68    ),
69    ("item/tool/call", "DynamicToolCallResponse"),
70    ("item/tool/requestUserInput", "ToolRequestUserInputResponse"),
71    ("mcpServer/resource/read", "McpResourceReadResponse"),
72    (
73        "remoteControl/client/list",
74        "RemoteControlClientsListResponse",
75    ),
76    (
77        "remoteControl/client/revoke",
78        "RemoteControlClientsRevokeResponse",
79    ),
80    // config/mcpServer/reload has no response payload (params/result are both `undefined`)
81];
82
83fn response_override(method: &str) -> Option<&'static str> {
84    RESPONSE_OVERRIDES
85        .iter()
86        .find(|(m, _)| *m == method)
87        .map(|(_, r)| *r)
88}
89
90/// Methods confirmed (by hand, against the schema) to genuinely have no
91/// response payload / no params - NOT "our heuristic failed to find one."
92/// Anything else the heuristics can't resolve is a hard build-time error, so
93/// a future codex schema change that introduces a new shape breaks the build
94/// loudly instead of silently generating wrong code (dropped params, dropped
95/// response data). See README.md "Regenerating the schema".
96const KNOWN_VOID_RESPONSE_METHODS: &[&str] = &["config/mcpServer/reload"];
97
98fn camel_token_regex() -> &'static Regex {
99    static RE: OnceLock<Regex> = OnceLock::new();
100    RE.get_or_init(|| {
101        Regex::new(r"[A-Z]?[a-z0-9]+|[A-Z]+(?=[A-Z]|$)").expect("camel-token regex is valid")
102    })
103}
104
105/// Splits a single camelCase/PascalCase word into lowercase tokens, treating
106/// runs of consecutive capitals as an acronym token of their own (e.g.
107/// `"HTTPServer"` -> `["http", "server"]`). Exact regex ported from
108/// `build_combined_schema.py`: `[A-Z]?[a-z0-9]+|[A-Z]+(?=[A-Z]|$)`.
109pub fn camel_tokens(word: &str) -> Vec<String> {
110    camel_token_regex()
111        .find_iter(word)
112        .map(|m| {
113            m.unwrap_or_else(|e| panic!("camel-token regex failed to match {word:?}: {e}"))
114                .as_str()
115                .to_lowercase()
116        })
117        .filter(|t| !t.is_empty())
118        .collect()
119}
120
121/// `snake_case` function name derived from a method name, e.g.
122/// `"item/tool/requestUserInput"` -> `"item_tool_request_user_input"`.
123pub fn method_to_snake_fn(method: &str) -> String {
124    let mut tokens = Vec::new();
125    for segment in method.split('/') {
126        tokens.extend(camel_tokens(segment));
127    }
128    tokens.join("_")
129}
130
131/// Finds a `*Response` definition whose name's tokens are a superset of the
132/// method's tokens, preferring the shortest match. Bails if two or more
133/// candidates tie for shortest - an unresolvable ambiguity, not something to
134/// silently guess at (a future codex schema change that introduces a second
135/// plausible `*Response` name for some method should break the build, not
136/// silently wire that method to whichever candidate happened to sort first).
137/// A single strictly-shortest candidate is treated as an unambiguous match
138/// even when longer candidates also exist.
139pub fn fuzzy_response_match<'a>(
140    method: &str,
141    all_def_names: impl Iterator<Item = &'a str>,
142) -> Result<Option<String>> {
143    let method_tokens: BTreeSet<String> = method.split(['/', '_']).flat_map(camel_tokens).collect();
144
145    let mut candidates: Vec<&str> = Vec::new();
146    for name in all_def_names {
147        let Some(base) = name.strip_suffix("Response") else {
148            continue;
149        };
150        let base_tokens: BTreeSet<String> = camel_tokens(base).into_iter().collect();
151        if method_tokens.is_subset(&base_tokens) {
152            candidates.push(name);
153        }
154    }
155    candidates.sort_by_key(|c| c.len());
156
157    match candidates.as_slice() {
158        [] => Ok(None),
159        [only] => Ok(Some(only.to_string())),
160        [shortest, rest @ ..] => {
161            let tied: Vec<&&str> = rest.iter().filter(|c| c.len() == shortest.len()).collect();
162            if !tied.is_empty() {
163                bail!(
164                    "{method}: ambiguous fuzzy response-type match - multiple *Response types tie \
165                     for shortest token-superset match: {:?} (all candidates: {:?}). Add an explicit \
166                     RESPONSE_OVERRIDES entry for this method instead of guessing.",
167                    std::iter::once(*shortest).chain(tied.into_iter().copied()).collect::<Vec<_>>(),
168                    candidates
169                );
170            }
171            Ok(Some(shortest.to_string()))
172        }
173    }
174}
175
176/// Resolves a method's response type name: `RESPONSE_OVERRIDES`, then the
177/// `{PascalMethod}Response` naming convention, then a fuzzy token-subset
178/// match, then `KNOWN_VOID_RESPONSE_METHODS`. Hard-fails (matching the
179/// Python original's `resolve_response`) if none of those resolve it, so a
180/// future codex schema change with a genuinely new naming shape breaks the
181/// build loudly instead of silently generating wrong code.
182pub fn resolve_response(
183    method: &str,
184    combined_defs: &Map<String, Value>,
185) -> Result<Option<String>> {
186    let candidate = response_override(method)
187        .map(str::to_string)
188        .unwrap_or_else(|| format!("{}Response", method_to_pascal(method)));
189    if combined_defs.contains_key(&candidate) {
190        return Ok(Some(candidate));
191    }
192    if let Some(fuzzy) = fuzzy_response_match(method, combined_defs.keys().map(String::as_str))? {
193        return Ok(Some(fuzzy));
194    }
195    if KNOWN_VOID_RESPONSE_METHODS.contains(&method) {
196        return Ok(None);
197    }
198    bail!(
199        "{method}: could not resolve a response type (checked RESPONSE_OVERRIDES, the {}Response \
200         naming convention, and a fuzzy token-subset match) and it is not in \
201         KNOWN_VOID_RESPONSE_METHODS. Either add an override, add a fuzzy-matchable response \
202         type, or - only if you've confirmed by hand that this method truly returns no payload - \
203         add it to KNOWN_VOID_RESPONSE_METHODS.",
204        method_to_pascal(method)
205    )
206}
207
208#[cfg(test)]
209#[path = "naming_tests.rs"]
210mod tests;