1use serde::{Deserialize, Serialize};
4use serde_json::Value;
5
6#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
7#[serde(rename_all = "snake_case")]
8pub enum ParamLocation {
9 Path,
10 Query,
11 Header,
12 Body,
13 File,
14 ToolInput,
15 GraphqlArg,
16}
17
18impl ParamLocation {
19 pub fn as_str(self) -> &'static str {
20 match self {
21 Self::Path => "path",
22 Self::Query => "query",
23 Self::Header => "header",
24 Self::Body => "body",
25 Self::File => "file",
26 Self::ToolInput => "tool_input",
27 Self::GraphqlArg => "graphql_arg",
28 }
29 }
30}
31
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34pub enum ParamType {
35 Boolean,
36 Integer,
37 Float,
38 String,
39}
40
41impl ParamType {
42 pub fn type_name(self) -> &'static str {
43 match self {
44 Self::Boolean => "boolean",
45 Self::Integer => "int",
46 Self::Float => "float",
47 Self::String => "str",
48 }
49 }
50}
51
52#[derive(Debug, Clone)]
53pub struct ParamDef {
54 pub name: String,
56 pub original_name: String,
58 pub python_type: ParamType,
59 pub required: bool,
60 pub description: String,
61 pub choices: Option<Vec<String>>,
62 pub location: ParamLocation,
63 pub schema: Value,
64}
65
66#[derive(Debug, Clone, Default)]
67pub struct CommandDef {
68 pub name: String,
69 pub description: String,
70 pub params: Vec<ParamDef>,
71 pub has_body: bool,
72 pub method: Option<String>,
74 pub path: Option<String>,
75 pub content_type: Option<String>,
76 pub tool_name: Option<String>,
78 pub graphql_operation_type: Option<String>,
80 pub graphql_field_name: Option<String>,
81 pub graphql_return_type: Option<Value>,
82}
83
84impl CommandDef {
85 pub fn new(name: impl Into<String>) -> Self {
86 Self {
87 name: name.into(),
88 ..Default::default()
89 }
90 }
91}
92
93#[derive(Debug, Clone, Default)]
94pub struct BakeConfig {
95 pub include: Vec<String>,
96 pub exclude: Vec<String>,
97 pub methods: Vec<String>,
98}
99
100pub fn schema_type_to_python(schema: &Value) -> (ParamType, &'static str) {
102 match schema.get("type").and_then(|t| t.as_str()) {
103 Some("integer") => (ParamType::Integer, ""),
104 Some("number") => (ParamType::Float, ""),
105 Some("boolean") => (ParamType::Boolean, ""),
106 Some("array") => (ParamType::String, " (JSON array)"),
107 Some("object") => (ParamType::String, " (JSON object)"),
108 _ => (ParamType::String, ""),
109 }
110}
111
112pub fn param_to_json(p: &ParamDef) -> Value {
114 let mut d = serde_json::json!({
115 "name": p.name,
116 "type": p.python_type.type_name(),
117 "required": p.required,
118 "description": p.description,
119 "location": p.location.as_str(),
120 });
121 if let Some(choices) = &p.choices {
122 d["choices"] = Value::Array(choices.iter().cloned().map(Value::String).collect());
123 }
124 d
125}
126
127#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
129pub enum ListDetail {
130 Names,
132 #[default]
134 Brief,
135 Full,
137}
138
139impl ListDetail {
140 pub fn parse(s: &str) -> Option<Self> {
141 match s {
142 "names" => Some(Self::Names),
143 "brief" => Some(Self::Brief),
144 "full" => Some(Self::Full),
145 _ => None,
146 }
147 }
148}
149
150pub fn truncate_at_word(s: &str, max_chars: usize) -> String {
152 if s.chars().count() <= max_chars {
153 return s.to_string();
154 }
155 let truncated: String = s.chars().take(max_chars).collect();
156 match truncated.rsplit_once(' ') {
157 Some((head, _)) if !head.is_empty() => format!("{head}..."),
158 _ => format!("{truncated}..."),
159 }
160}
161
162pub fn command_to_brief_json(cmd: &CommandDef) -> Value {
164 let desc = truncate_at_word(&cmd.description, 120);
165 let mut d = serde_json::json!({
166 "name": cmd.name,
167 "description": desc,
168 });
169 if let Some(m) = &cmd.method {
170 d["method"] = Value::String(m.to_uppercase());
171 }
172 if let Some(op) = &cmd.graphql_operation_type {
173 d["operationType"] = Value::String(op.clone());
174 }
175 d
176}
177
178pub fn command_to_json(cmd: &CommandDef) -> Value {
180 let mut d = serde_json::json!({
181 "name": cmd.name,
182 "description": cmd.description,
183 "parameters": cmd.params.iter().map(param_to_json).collect::<Vec<_>>(),
184 });
185 if let Some(m) = &cmd.method {
186 d["method"] = Value::String(m.to_uppercase());
187 }
188 if let Some(p) = &cmd.path {
189 d["path"] = Value::String(p.clone());
190 }
191 if let Some(t) = &cmd.tool_name {
192 d["toolName"] = Value::String(t.clone());
193 }
194 if let Some(op) = &cmd.graphql_operation_type {
195 d["operationType"] = Value::String(op.clone());
196 }
197 d
198}
199
200pub fn commands_to_json(commands: &[CommandDef], detail: ListDetail) -> Value {
202 match detail {
203 ListDetail::Names => names_list_json(commands),
204 ListDetail::Brief => Value::Array(commands.iter().map(command_to_brief_json).collect()),
205 ListDetail::Full => Value::Array(commands.iter().map(command_to_json).collect()),
206 }
207}
208
209pub fn names_list_json(commands: &[CommandDef]) -> Value {
215 let names: Vec<String> = commands.iter().map(|c| c.name.clone()).collect();
216 compress_names(&names)
217 .unwrap_or_else(|| Value::Array(names.into_iter().map(Value::String).collect()))
218}
219
220fn compress_names(names: &[String]) -> Option<Value> {
221 if names.len() < 8 {
222 return None;
223 }
224 let mut groups: std::collections::BTreeMap<String, Vec<String>> =
225 std::collections::BTreeMap::new();
226 let mut ungrouped: Vec<String> = Vec::new();
227
228 for name in names {
229 match name.rfind('-') {
230 Some(i) if i > 0 && i + 1 < name.len() => {
231 let prefix = name[..i].to_string();
232 let suffix = name[i + 1..].to_string();
233 groups.entry(prefix).or_default().push(suffix);
234 }
235 _ => ungrouped.push(name.clone()),
236 }
237 }
238
239 let mut kept = serde_json::Map::new();
240 for (prefix, suffixes) in groups {
241 if suffixes.len() >= 2 {
242 kept.insert(
243 prefix,
244 Value::Array(suffixes.into_iter().map(Value::String).collect()),
245 );
246 } else if let Some(suf) = suffixes.into_iter().next() {
247 ungrouped.push(format!("{prefix}-{suf}"));
248 }
249 }
250
251 if kept.is_empty() {
252 return None;
253 }
254
255 let compressed = {
256 let mut m = serde_json::Map::new();
257 m.insert("groups".into(), Value::Object(kept));
258 if !ungrouped.is_empty() {
259 m.insert(
260 "names".into(),
261 Value::Array(ungrouped.iter().cloned().map(Value::String).collect()),
262 );
263 }
264 Value::Object(m)
265 };
266 let flat = Value::Array(names.iter().cloned().map(Value::String).collect());
267 let c_len = serde_json::to_vec(&compressed)
268 .map(|v| v.len())
269 .unwrap_or(usize::MAX);
270 let f_len = serde_json::to_vec(&flat).map(|v| v.len()).unwrap_or(0);
271 if c_len < f_len {
272 Some(compressed)
273 } else {
274 None
275 }
276}
277
278#[cfg(test)]
279mod tests {
280 use super::*;
281
282 #[test]
283 fn truncate_at_word_utf8_safe() {
284 let s = "éééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééé";
286 let out = truncate_at_word(s, 10);
287 assert!(out.ends_with("..."));
288 assert!(out.chars().count() <= 13); }
290
291 #[test]
292 fn truncate_prefers_word_boundary() {
293 let s = "hello world this is a long description here";
294 let out = truncate_at_word(s, 20);
295 assert_eq!(out, "hello world this is...");
296 }
297
298 #[test]
299 fn brief_json_truncates_long_desc() {
300 let cmd = CommandDef {
301 name: "t".into(),
302 description: "a".repeat(200),
303 ..Default::default()
304 };
305 let v = command_to_brief_json(&cmd);
306 let d = v["description"].as_str().unwrap();
307 assert!(d.len() < 200);
308 assert!(d.ends_with("..."));
309 }
310
311 #[test]
312 fn compresses_shared_prefixes() {
313 let names: Vec<String> = (0..10)
314 .map(|i| format!("workers-scripts-op{i}"))
315 .chain(std::iter::once("echo".into()))
316 .collect();
317 let v = compress_names(&names).expect("should compress");
318 assert!(v.get("groups").is_some());
319 let flat_len = serde_json::to_vec(&Value::Array(
320 names.iter().cloned().map(Value::String).collect(),
321 ))
322 .unwrap()
323 .len();
324 let c_len = serde_json::to_vec(&v).unwrap().len();
325 assert!(c_len < flat_len);
326 }
327}