xtask/codex_schema/
naming.rs1use std::collections::BTreeSet;
9use std::sync::OnceLock;
10
11use anyhow::{bail, Result};
12use fancy_regex::Regex;
13use serde_json::{Map, Value};
14
15pub 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
34pub 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 ];
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
90const 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
105pub 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
121pub 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
131pub 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
176pub 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;