Skip to main content

stakpak_shared/models/tools/
ask_user.rs

1//! Ask User tool types — single source of truth for MCP schema, CLI, and TUI.
2//!
3//! These types carry both `serde` and `schemars` annotations so they can be
4//! used directly in MCP tool definitions (schema generation) **and** for
5//! runtime (de)serialization in the TUI / CLI.
6
7use rmcp::schemars;
8use serde::{Deserialize, Serialize};
9
10// ---------------------------------------------------------------------------
11// Request (LLM → tool)
12// ---------------------------------------------------------------------------
13
14/// Request payload for the `ask_user` tool.
15#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, schemars::JsonSchema)]
16pub struct AskUserRequest {
17    #[schemars(
18        description = "List of questions to ask the user. Each question has a label, question text, and options."
19    )]
20    pub questions: Vec<AskUserQuestion>,
21}
22
23/// A single question presented to the user.
24#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, schemars::JsonSchema)]
25pub struct AskUserQuestion {
26    #[schemars(description = "Short unique label for tab display (max ~15 chars recommended)")]
27    pub label: String,
28    #[schemars(description = "Full question text to display")]
29    pub question: String,
30    #[schemars(description = "Predefined answer options")]
31    pub options: Vec<AskUserOption>,
32    /// Whether to allow custom text input (default: true)
33    #[serde(default = "default_true")]
34    #[schemars(description = "Whether to allow custom text input (default: true)")]
35    pub allow_custom: bool,
36    /// When true, user can select multiple options (checkbox list). Default: false (single-select).
37    #[serde(default)]
38    #[schemars(
39        description = "When true, user can select/deselect multiple options (checkbox list). Default: false (single-select radio behavior)."
40    )]
41    pub multi_select: bool,
42}
43
44/// A predefined answer option for a question.
45#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, schemars::JsonSchema)]
46pub struct AskUserOption {
47    #[schemars(description = "Value to return to LLM when selected")]
48    pub value: String,
49    #[schemars(description = "Display label for the option")]
50    pub label: String,
51    /// Optional description shown below the label.
52    #[serde(skip_serializing_if = "Option::is_none")]
53    #[schemars(description = "Optional description shown below the label")]
54    pub description: Option<String>,
55    /// Default selection state for multi_select questions. Ignored for single-select.
56    #[serde(default)]
57    #[schemars(
58        description = "Default selection state when multi_select is true. Pre-marks this option as selected. Ignored for single-select questions."
59    )]
60    pub selected: bool,
61}
62
63// ---------------------------------------------------------------------------
64// Response (tool → LLM)
65// ---------------------------------------------------------------------------
66
67/// User's answer to a single question.
68#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, schemars::JsonSchema)]
69pub struct AskUserAnswer {
70    /// Question label this answers.
71    pub question_label: String,
72    /// Selected option value OR custom text (for single-select questions).
73    /// For multi-select questions this is a JSON array string of selected values.
74    pub answer: String,
75    /// Whether this was a custom answer (typed by user).
76    pub is_custom: bool,
77    /// Selected values for multi-select questions. Empty/absent for single-select.
78    #[serde(default, skip_serializing_if = "Vec::is_empty")]
79    pub selected_values: Vec<String>,
80}
81
82/// Aggregated result of the `ask_user` tool.
83#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, schemars::JsonSchema)]
84pub struct AskUserResult {
85    /// All answers provided by the user.
86    pub answers: Vec<AskUserAnswer>,
87    /// Whether the user completed all questions (false if cancelled).
88    pub completed: bool,
89    /// Reason for incompletion (if cancelled).
90    #[serde(skip_serializing_if = "Option::is_none")]
91    pub reason: Option<String>,
92}
93
94// ---------------------------------------------------------------------------
95// Helpers
96// ---------------------------------------------------------------------------
97
98fn default_true() -> bool {
99    true
100}
101
102// ---------------------------------------------------------------------------
103// Tests
104// ---------------------------------------------------------------------------
105
106#[cfg(test)]
107mod tests {
108    use super::*;
109
110    #[test]
111    fn test_question_serialization() {
112        let question = AskUserQuestion {
113            label: "Environment".to_string(),
114            question: "Which environment should I deploy to?".to_string(),
115            options: vec![
116                AskUserOption {
117                    value: "dev".to_string(),
118                    label: "Development".to_string(),
119                    description: Some("For testing changes".to_string()),
120                    selected: false,
121                },
122                AskUserOption {
123                    value: "prod".to_string(),
124                    label: "Production".to_string(),
125                    description: None,
126                    selected: false,
127                },
128            ],
129            allow_custom: true,
130            multi_select: false,
131        };
132
133        let json = serde_json::to_string(&question).unwrap();
134        assert!(json.contains("\"label\":\"Environment\""));
135        assert!(json.contains("\"value\":\"dev\""));
136        assert!(json.contains("\"description\":\"For testing changes\""));
137        // description: None should be skipped
138        assert!(!json.contains("\"description\":null"));
139    }
140
141    #[test]
142    fn test_question_deserialization_with_defaults() {
143        let json = r#"{
144            "label": "Test",
145            "question": "Is this a test?",
146            "options": []
147        }"#;
148
149        let question: AskUserQuestion = serde_json::from_str(json).unwrap();
150        assert_eq!(question.label, "Test");
151        assert!(question.allow_custom, "allow_custom should default to true");
152    }
153
154    #[test]
155    fn test_question_deserialization_explicit_false() {
156        let json = r#"{
157            "label": "Test",
158            "question": "Is this a test?",
159            "options": [],
160            "allow_custom": false
161        }"#;
162
163        let question: AskUserQuestion = serde_json::from_str(json).unwrap();
164        assert!(!question.allow_custom);
165    }
166
167    #[test]
168    fn test_answer_serialization() {
169        let answer = AskUserAnswer {
170            question_label: "Environment".to_string(),
171            answer: "production".to_string(),
172            is_custom: false,
173            selected_values: vec![],
174        };
175
176        let json = serde_json::to_string(&answer).unwrap();
177        assert!(json.contains("\"question_label\":\"Environment\""));
178        assert!(json.contains("\"answer\":\"production\""));
179        assert!(json.contains("\"is_custom\":false"));
180    }
181
182    #[test]
183    fn test_answer_custom_input() {
184        let answer = AskUserAnswer {
185            question_label: "Feedback".to_string(),
186            answer: "User typed this custom response".to_string(),
187            is_custom: true,
188            selected_values: vec![],
189        };
190
191        let json = serde_json::to_string(&answer).unwrap();
192        assert!(json.contains("\"is_custom\":true"));
193        assert!(json.contains("User typed this custom response"));
194    }
195
196    #[test]
197    fn test_result_completed() {
198        let result = AskUserResult {
199            answers: vec![
200                AskUserAnswer {
201                    question_label: "q1".to_string(),
202                    answer: "a1".to_string(),
203                    is_custom: false,
204                    selected_values: vec![],
205                },
206                AskUserAnswer {
207                    question_label: "q2".to_string(),
208                    answer: "custom answer".to_string(),
209                    is_custom: true,
210                    selected_values: vec![],
211                },
212            ],
213            completed: true,
214            reason: None,
215        };
216
217        let json = serde_json::to_string(&result).unwrap();
218        assert!(json.contains("\"completed\":true"));
219        // reason: None should be skipped
220        assert!(!json.contains("\"reason\""));
221        assert!(json.contains("\"question_label\":\"q1\""));
222        assert!(json.contains("\"question_label\":\"q2\""));
223    }
224
225    #[test]
226    fn test_result_cancelled() {
227        let result = AskUserResult {
228            answers: vec![],
229            completed: false,
230            reason: Some("User cancelled the question prompt.".to_string()),
231        };
232
233        let json = serde_json::to_string(&result).unwrap();
234        assert!(json.contains("\"completed\":false"));
235        assert!(json.contains("\"reason\":\"User cancelled the question prompt.\""));
236        assert!(json.contains("\"answers\":[]"));
237    }
238
239    #[test]
240    fn test_result_deserialization() {
241        let json = r#"{
242            "answers": [
243                {"question_label": "env", "answer": "dev", "is_custom": false}
244            ],
245            "completed": true
246        }"#;
247
248        let result: AskUserResult = serde_json::from_str(json).unwrap();
249        assert!(result.completed);
250        assert!(result.reason.is_none());
251        assert_eq!(result.answers.len(), 1);
252        assert_eq!(result.answers[0].question_label, "env");
253        assert_eq!(result.answers[0].answer, "dev");
254        assert!(!result.answers[0].is_custom);
255    }
256
257    #[test]
258    fn test_option_without_description() {
259        let option = AskUserOption {
260            value: "yes".to_string(),
261            label: "Yes".to_string(),
262            description: None,
263            selected: false,
264        };
265
266        let json = serde_json::to_string(&option).unwrap();
267        // description should be omitted entirely when None
268        assert!(!json.contains("description"));
269        assert!(json.contains("\"value\":\"yes\""));
270        assert!(json.contains("\"label\":\"Yes\""));
271    }
272
273    #[test]
274    fn test_unicode_handling() {
275        let question = AskUserQuestion {
276            label: "言語".to_string(),
277            question: "どの言語を使用しますか?".to_string(),
278            options: vec![
279                AskUserOption {
280                    value: "ja".to_string(),
281                    label: "日本語".to_string(),
282                    description: Some("Japanese language".to_string()),
283                    selected: false,
284                },
285                AskUserOption {
286                    value: "emoji".to_string(),
287                    label: "🚀 Rocket".to_string(),
288                    description: Some("With emoji 🎉".to_string()),
289                    selected: false,
290                },
291            ],
292            allow_custom: true,
293            multi_select: false,
294        };
295
296        let json = serde_json::to_string(&question).unwrap();
297        let parsed: AskUserQuestion = serde_json::from_str(&json).unwrap();
298
299        assert_eq!(parsed.label, "言語");
300        assert_eq!(parsed.question, "どの言語を使用しますか?");
301        assert_eq!(parsed.options[0].label, "日本語");
302        assert_eq!(parsed.options[1].label, "🚀 Rocket");
303    }
304
305    #[test]
306    fn test_types_equality() {
307        let q1 = AskUserQuestion {
308            label: "Test".to_string(),
309            question: "Question?".to_string(),
310            options: vec![],
311            allow_custom: true,
312            multi_select: false,
313        };
314
315        let q2 = q1.clone();
316        assert_eq!(q1, q2);
317
318        let a1 = AskUserAnswer {
319            question_label: "Test".to_string(),
320            answer: "answer".to_string(),
321            is_custom: false,
322            selected_values: vec![],
323        };
324
325        let a2 = a1.clone();
326        assert_eq!(a1, a2);
327
328        let r1 = AskUserResult {
329            answers: vec![a1],
330            completed: true,
331            reason: None,
332        };
333
334        let r2 = r1.clone();
335        assert_eq!(r1, r2);
336    }
337
338    #[test]
339    fn test_request_round_trip() {
340        let request = AskUserRequest {
341            questions: vec![AskUserQuestion {
342                label: "Env".to_string(),
343                question: "Which env?".to_string(),
344                options: vec![AskUserOption {
345                    value: "dev".to_string(),
346                    label: "Dev".to_string(),
347                    description: None,
348                    selected: false,
349                }],
350                allow_custom: false,
351                multi_select: false,
352            }],
353        };
354
355        let json = serde_json::to_string(&request).unwrap();
356        let parsed: AskUserRequest = serde_json::from_str(&json).unwrap();
357        assert_eq!(request, parsed);
358    }
359
360    #[test]
361    fn test_multi_select_defaults() {
362        let json = r#"{
363            "label": "Scope",
364            "question": "Which repos?",
365            "options": [
366                {"value": "a", "label": "Repo A"},
367                {"value": "b", "label": "Repo B", "selected": true}
368            ]
369        }"#;
370
371        let question: AskUserQuestion = serde_json::from_str(json).unwrap();
372        assert!(
373            !question.multi_select,
374            "multi_select should default to false"
375        );
376        assert!(
377            !question.options[0].selected,
378            "selected should default to false"
379        );
380        assert!(
381            question.options[1].selected,
382            "selected should be true when set"
383        );
384    }
385
386    #[test]
387    fn test_multi_select_question_round_trip() {
388        let question = AskUserQuestion {
389            label: "Scope".to_string(),
390            question: "Which repos should I include?".to_string(),
391            options: vec![
392                AskUserOption {
393                    value: "repo:api".to_string(),
394                    label: "~/projects/api".to_string(),
395                    description: None,
396                    selected: true,
397                },
398                AskUserOption {
399                    value: "repo:web".to_string(),
400                    label: "~/projects/web".to_string(),
401                    description: None,
402                    selected: false,
403                },
404            ],
405            allow_custom: false,
406            multi_select: true,
407        };
408
409        let json = serde_json::to_string(&question).unwrap();
410        assert!(json.contains("\"multi_select\":true"));
411        assert!(json.contains("\"selected\":true"));
412
413        let parsed: AskUserQuestion = serde_json::from_str(&json).unwrap();
414        assert_eq!(question, parsed);
415    }
416
417    #[test]
418    fn test_multi_select_answer_with_selected_values() {
419        let answer = AskUserAnswer {
420            question_label: "Scope".to_string(),
421            answer: "[\"repo:api\",\"repo:web\"]".to_string(),
422            is_custom: false,
423            selected_values: vec!["repo:api".to_string(), "repo:web".to_string()],
424        };
425
426        let json = serde_json::to_string(&answer).unwrap();
427        assert!(json.contains("\"selected_values\""));
428        assert!(json.contains("repo:api"));
429        assert!(json.contains("repo:web"));
430
431        let parsed: AskUserAnswer = serde_json::from_str(&json).unwrap();
432        assert_eq!(parsed.selected_values.len(), 2);
433    }
434
435    #[test]
436    fn test_selected_values_omitted_when_empty() {
437        let answer = AskUserAnswer {
438            question_label: "Env".to_string(),
439            answer: "dev".to_string(),
440            is_custom: false,
441            selected_values: vec![],
442        };
443
444        let json = serde_json::to_string(&answer).unwrap();
445        assert!(
446            !json.contains("selected_values"),
447            "selected_values should be omitted when empty"
448        );
449    }
450
451    #[test]
452    fn test_answer_deserialization_without_selected_values() {
453        // Backward compatibility: old answers without selected_values should still parse
454        let json = r#"{"question_label": "env", "answer": "dev", "is_custom": false}"#;
455        let answer: AskUserAnswer = serde_json::from_str(json).unwrap();
456        assert!(answer.selected_values.is_empty());
457    }
458}