1use serde::{Deserialize, Serialize};
5use serde_json::Value;
6use std::collections::HashMap;
7use std::sync::{Mutex, OnceLock};
8
9pub const USER_QUESTION_SCHEMA: &str = "a3s.code.user-question.v1";
10pub const USER_ANSWER_SCHEMA: &str = "a3s.code.user-answer.v1";
11const MAX_QUESTIONS_PER_RUN: u32 = 3;
12const MAX_OPTIONS: usize = 8;
13
14#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
15pub struct UserQuestionV1 {
16 pub schema: String,
17 pub question_id: String,
18 pub question: String,
19 pub options: Vec<String>,
20 pub allow_free_text: bool,
21}
22
23#[derive(Debug, Clone, PartialEq, Eq)]
24pub enum AskUserResume {
25 Answered { text: String },
26 Unanswered,
27}
28
29fn counts() -> &'static Mutex<HashMap<String, u32>> {
30 static COUNTS: OnceLock<Mutex<HashMap<String, u32>>> = OnceLock::new();
31 COUNTS.get_or_init(|| Mutex::new(HashMap::new()))
32}
33
34#[derive(Debug)]
35pub enum AskUserError {
36 CapExceeded,
37 Invalid(&'static str),
38}
39
40pub fn begin(
45 run_id: &str,
46 question_id: &str,
47 question: &str,
48 options: &[String],
49 allow_free_text: bool,
50) -> Result<UserQuestionV1, AskUserError> {
51 if question.trim().is_empty() {
52 return Err(AskUserError::Invalid("question is required"));
53 }
54 if options.len() > MAX_OPTIONS {
55 return Err(AskUserError::Invalid("too many options"));
56 }
57 let mut counts = counts().lock().expect("ask-user counts");
58 let count = counts.entry(run_id.to_string()).or_insert(0);
59 if *count >= MAX_QUESTIONS_PER_RUN {
60 return Err(AskUserError::CapExceeded);
61 }
62 *count += 1;
63 let sequence = *count;
64 drop(counts);
65 Ok(UserQuestionV1 {
66 schema: USER_QUESTION_SCHEMA.to_string(),
67 question_id: format!("{question_id}-{sequence}"),
68 question: question.to_string(),
69 options: options.to_vec(),
70 allow_free_text,
71 })
72}
73
74fn pending_answers() -> &'static Mutex<HashMap<String, String>> {
75 static ANSWERS: OnceLock<Mutex<HashMap<String, String>>> = OnceLock::new();
76 ANSWERS.get_or_init(|| Mutex::new(HashMap::new()))
77}
78
79fn answer_notify() -> &'static tokio::sync::Notify {
80 static NOTIFY: OnceLock<tokio::sync::Notify> = OnceLock::new();
81 NOTIFY.get_or_init(tokio::sync::Notify::new)
82}
83
84pub fn answer(question_id: &str, text: &str) -> bool {
88 if question_id.trim().is_empty() || text.trim().is_empty() {
89 return false;
90 }
91 pending_answers()
92 .lock()
93 .expect("ask-user answers")
94 .insert(question_id.to_string(), text.to_string());
95 answer_notify().notify_waiters();
96 false
97}
98
99pub async fn wait_for_answer(
101 question_id: &str,
102 cancel: &tokio_util::sync::CancellationToken,
103) -> Option<String> {
104 loop {
105 let notified = answer_notify().notified();
106 if let Some(text) = pending_answers()
107 .lock()
108 .expect("ask-user answers")
109 .remove(question_id)
110 {
111 return Some(text);
112 }
113 if cancel.is_cancelled() {
114 return None;
115 }
116 tokio::select! {
117 _ = notified => {}
118 _ = cancel.cancelled() => return None,
119 }
120 }
121}
122
123pub fn cancel(_question_id: &str) -> bool {
125 false
126}
127
128pub fn resume_message(question_id: &str, resume: &AskUserResume) -> String {
129 let value = match resume {
130 AskUserResume::Answered { text } => serde_json::json!({
131 "schema": USER_ANSWER_SCHEMA,
132 "question_id": question_id,
133 "status": "answered",
134 "answer": text,
135 "permission_grant": false,
136 }),
137 AskUserResume::Unanswered => serde_json::json!({
138 "schema": USER_ANSWER_SCHEMA,
139 "question_id": question_id,
140 "status": "unanswered",
141 "permission_grant": false,
142 }),
143 };
144 value.to_string()
145}
146
147pub fn is_permission_grant(metadata: &Value) -> bool {
148 metadata
149 .get("permission_grant")
150 .and_then(Value::as_bool)
151 .unwrap_or(false)
152 || metadata
153 .get("approved")
154 .and_then(Value::as_bool)
155 .unwrap_or(false)
156}
157
158#[cfg(test)]
159mod tests {
160 use super::*;
161
162 #[test]
163 fn begin_keeps_allow_free_text_and_does_not_answer_in_process() {
164 let question = begin("run-1", "q-1", "Which file?", &["a.rs".into()], true).unwrap();
165 assert_eq!(question.schema, USER_QUESTION_SCHEMA);
166 assert!(question.allow_free_text);
167 assert_eq!(question.options, vec!["a.rs".to_string()]);
168 assert!(!answer(&question.question_id, "a.rs"));
169 assert!(!cancel(&question.question_id));
170 let message = resume_message(
171 &question.question_id,
172 &AskUserResume::Answered {
173 text: "a.rs".into(),
174 },
175 );
176 let value: Value = serde_json::from_str(&message).unwrap();
177 assert_eq!(value["status"], "answered");
178 assert!(!is_permission_grant(&value));
179 }
180
181 #[test]
182 fn question_cap_rejects_a_fourth_question() {
183 for index in 0..MAX_QUESTIONS_PER_RUN {
184 begin("run-cap", &format!("q-{index}"), "again?", &[], false).unwrap();
185 }
186 assert!(matches!(
187 begin("run-cap", "q-over", "again?", &[], false),
188 Err(AskUserError::CapExceeded)
189 ));
190 }
191
192 #[test]
193 fn begin_rejects_empty_question_and_option_overflow() {
194 assert!(matches!(
195 begin("run-invalid", "q-empty", " ", &[], false),
196 Err(AskUserError::Invalid(_))
197 ));
198 let options: Vec<String> = (0..=MAX_OPTIONS)
199 .map(|index| format!("opt-{index}"))
200 .collect();
201 assert!(matches!(
202 begin("run-invalid", "q-options", "pick one?", &options, false),
203 Err(AskUserError::Invalid(_))
204 ));
205 }
206
207 #[tokio::test]
208 async fn wait_for_answer_reads_the_stored_host_text() {
209 let cancel = tokio_util::sync::CancellationToken::new();
210 let question_id = format!("wait-{}", std::process::id());
211 let cancel_for_answer = cancel.clone();
212 let id = question_id.clone();
213 tokio::spawn(async move {
214 let _ = cancel_for_answer;
215 assert!(!answer(&id, "host-token"));
216 });
217 let text = tokio::time::timeout(
218 std::time::Duration::from_secs(2),
219 wait_for_answer(&question_id, &cancel),
220 )
221 .await
222 .expect("host answer")
223 .expect("stored answer");
224 assert_eq!(text, "host-token");
225 }
226
227 #[test]
228 fn answer_and_cancel_return_false_for_unknown_question_ids() {
229 assert!(!answer("missing-question", "nope"));
230 assert!(!cancel("missing-question"));
231 }
232
233 #[test]
234 fn is_permission_grant_honors_legacy_approved_field() {
235 let metadata = serde_json::json!({"approved": true});
236 assert!(is_permission_grant(&metadata));
237 assert!(!is_permission_grant(
238 &serde_json::json!({"permission_grant": false})
239 ));
240 }
241}