Skip to main content

skiff_cli/
model.rs

1//! Shared command/parameter model (OpenAPI + MCP + GraphQL).
2
3use 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/// CLI parameter type: `None` means boolean `store_true` flag (Python `python_type is None`).
33#[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    /// kebab-case CLI flag name
55    pub name: String,
56    /// original name for API/tool call
57    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    // OpenAPI
73    pub method: Option<String>,
74    pub path: Option<String>,
75    pub content_type: Option<String>,
76    // MCP
77    pub tool_name: Option<String>,
78    // GraphQL
79    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
100/// Map JSON Schema type to CLI param type + help suffix (Python `schema_type_to_python`).
101pub 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
112/// Serialize a parameter for `--list --json`.
113pub 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/// List / help JSON detail level.
128#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
129pub enum ListDetail {
130    /// Tool names only.
131    Names,
132    /// Name + short description (default for `--agent` lists).
133    #[default]
134    Brief,
135    /// Full parameter schemas ([`command_to_json`]).
136    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
150/// Truncate `s` to at most `max_chars` Unicode scalars, preferring a word boundary.
151pub 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
162/// Name + truncated description for progressive discovery.
163pub 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
178/// Serialize a command for `--list --json --detail full` / `TOOL --help --json`.
179pub 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
200/// Serialize commands at the requested detail level.
201pub 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
209/// Flat name array, or prefix-compressed object when that saves space.
210///
211/// Compressed shape (agent-reconstructable):
212/// `{"groups":{"workers-scripts":["list","get"]},"names":["echo"]}`
213/// → tool id = `"{prefix}-{suffix}"` for groups, or bare `names` entries.
214pub 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        // Multi-byte chars must not panic on a mid-codepoint cut.
285        let s = "éééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééé";
286        let out = truncate_at_word(s, 10);
287        assert!(out.ends_with("..."));
288        assert!(out.chars().count() <= 13); // 10 + "..."
289    }
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}