rpi_plugin_sdk/
dialogs.rs1use serde_json::Value;
3
4#[derive(Debug, Clone, PartialEq, Eq)]
5pub struct PermissionOption {
6 pub title: String,
7 pub permission: String,
8}
9
10pub fn permission_options(ui: &Value) -> Result<Vec<PermissionOption>, String> {
12 if ui.get("purpose").and_then(Value::as_str) != Some("permission") {
13 return Err("dialog is not a permission request".to_owned());
14 }
15 if !matches!(
16 ui.get("kind").and_then(Value::as_str),
17 Some("confirm" | "selector")
18 ) || [
19 "allowMultiple",
20 "allow_multiple",
21 "multiple",
22 "allowFreeform",
23 "allow_freeform",
24 "allow_custom",
25 ]
26 .iter()
27 .any(|key| ui.get(*key).and_then(Value::as_bool) == Some(true))
28 || ui.get("questions").is_some()
29 {
30 return Err("permission dialogs require a single choice without freeform input".to_owned());
31 }
32 let options = ui
33 .get("options")
34 .and_then(Value::as_array)
35 .ok_or("permission dialog requires options")?;
36 let mut result: Vec<PermissionOption> = Vec::new();
37 for option in options {
38 let title = option
39 .get("title")
40 .and_then(Value::as_str)
41 .filter(|s| !s.is_empty() && s.trim() == *s)
42 .ok_or("permission option requires a nonempty title")?;
43 let permission = option
44 .get("permission")
45 .and_then(Value::as_str)
46 .filter(|s| {
47 matches!(
48 *s,
49 "allow_once" | "allow_always" | "reject_once" | "reject_always"
50 )
51 })
52 .ok_or("permission option requires an explicit allow/reject decision")?;
53 if result.iter().any(|choice| choice.title == title) {
54 return Err("permission option titles must be unique".to_owned());
55 }
56 result.push(PermissionOption {
57 title: title.to_owned(),
58 permission: permission.to_owned(),
59 });
60 }
61 if result.is_empty() {
62 return Err("permission dialog requires at least one option".to_owned());
63 }
64 Ok(result)
65}
66
67pub fn validate(ui: &Value, tool_call_id: Option<&str>) -> Result<(), String> {
68 match ui.get("purpose") {
69 None => Ok(()),
70 Some(Value::String(purpose)) if purpose == "question" => Ok(()),
71 Some(Value::String(purpose)) if purpose == "permission" => {
72 if tool_call_id.is_none_or(|id| id.trim().is_empty()) {
73 return Err("permission dialog requires a nonempty toolCallId".to_owned());
74 }
75 permission_options(ui).map(|_| ())
76 }
77 _ => Err("dialog purpose must be question or permission".to_owned()),
78 }
79}
80
81#[cfg(test)]
82mod tests {
83 use super::*;
84 use serde_json::json;
85
86 #[test]
87 fn selectors_are_questions_until_explicitly_declared_permissions() {
88 let ui = json!({"kind":"selector","options":[{"title":"Rust"},{"title":"Python"}]});
89 assert!(validate(&ui, None).is_ok());
90 assert!(permission_options(&ui).is_err());
91 }
92
93 #[test]
94 fn permission_options_preserve_labels_and_require_semantics() {
95 let mut ui = json!({"purpose":"permission","kind":"confirm","options":[
96 {"title":"允许一次","permission":"allow_once"},
97 {"title":"拒绝","permission":"reject_once"}
98 ]});
99 assert!(validate(&ui, None).is_err());
100 assert!(validate(&ui, Some("tool-1")).is_ok());
101 assert_eq!(permission_options(&ui).unwrap()[0].title, "允许一次");
102 ui["options"][1]["permission"] = json!("arbitrary");
103 assert!(validate(&ui, Some("tool-1")).is_err());
104 }
105}