use serde_json::Value;
use std::collections::HashMap;
use std::sync::Arc;
use std::time::Duration;
pub const QUESTION_WAIT: Duration = Duration::from_secs(1800);
pub const REJECTED_MESSAGE: &str = "The user dismissed this question";
#[derive(Debug, Clone)]
pub enum Outcome {
Answers(Vec<Vec<String>>),
Rejected,
}
struct Pending {
request: Value,
tx: tokio::sync::oneshot::Sender<Outcome>,
}
#[derive(Default)]
pub struct QuestionGate {
pending: parking_lot::Mutex<HashMap<String, Pending>>,
}
pub struct PendingGuard {
gate: Arc<QuestionGate>,
id: String,
}
impl Drop for PendingGuard {
fn drop(&mut self) {
self.gate.pending.lock().remove(&self.id);
}
}
impl QuestionGate {
pub fn new() -> Arc<Self> {
Arc::new(Self::default())
}
pub fn register(
self: &Arc<Self>,
id: &str,
request: Value,
) -> (tokio::sync::oneshot::Receiver<Outcome>, PendingGuard) {
let (tx, rx) = tokio::sync::oneshot::channel();
self.pending
.lock()
.insert(id.to_string(), Pending { request, tx });
let guard = PendingGuard {
gate: Arc::clone(self),
id: id.to_string(),
};
(rx, guard)
}
pub fn reply(&self, id: &str, answers: Vec<Vec<String>>) -> bool {
match self.pending.lock().remove(id) {
Some(p) => p.tx.send(Outcome::Answers(answers)).is_ok(),
None => false,
}
}
pub fn reject(&self, id: &str) -> bool {
match self.pending.lock().remove(id) {
Some(p) => p.tx.send(Outcome::Rejected).is_ok(),
None => false,
}
}
pub fn remove(&self, id: &str) {
self.pending.lock().remove(id);
}
pub fn list(&self) -> Vec<Value> {
self.pending
.lock()
.values()
.map(|p| p.request.clone())
.collect()
}
pub async fn wait(&self, id: &str, rx: tokio::sync::oneshot::Receiver<Outcome>) -> Outcome {
match tokio::time::timeout(QUESTION_WAIT, rx).await {
Ok(Ok(outcome)) => outcome,
Ok(Err(_)) => Outcome::Rejected, Err(_) => {
self.remove(id);
Outcome::Rejected
}
}
}
}
pub fn format_output(questions: &Value, answers: &[Vec<String>]) -> String {
let items: Vec<String> = questions
.as_array()
.map(|qs| {
qs.iter()
.enumerate()
.map(|(i, q)| {
let answer = answers
.get(i)
.map(|a| a.join(", "))
.filter(|s| !s.is_empty())
.unwrap_or_else(|| "Unanswered".to_string());
format!(
"\"{}\"=\"{}\"",
q["question"].as_str().unwrap_or_default(),
answer
)
})
.collect()
})
.unwrap_or_default();
format!(
"User has answered your questions: {}. You can now continue with the user's answers in mind.",
items.join(", ")
)
}
pub fn format_title(questions: &Value) -> String {
let n = questions.as_array().map(|a| a.len()).unwrap_or(0);
format!("Asked {} question{}", n, if n > 1 { "s" } else { "" })
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn output_matches_v1_template() {
let qs = json!([
{"question": "Pick a color", "header": "Color",
"options": [{"label": "Red", "description": "r"}]},
{"question": "Pick speed", "header": "Speed",
"options": [{"label": "Fast", "description": "f"}]}
]);
let answers = vec![vec!["Red".to_string()], vec![]];
assert_eq!(
format_output(&qs, &answers),
"User has answered your questions: \"Pick a color\"=\"Red\", \
\"Pick speed\"=\"Unanswered\". You can now continue with the user's answers in mind."
);
assert_eq!(format_title(&qs), "Asked 2 questions");
assert_eq!(format_title(&json!([])), "Asked 0 question"); }
#[tokio::test]
async fn register_reply_list_roundtrip() {
let gate = QuestionGate::new();
let req = json!({"id": "que_1", "sessionID": "ses_1", "questions": []});
let (rx, _guard) = gate.register("que_1", req.clone());
assert_eq!(gate.list(), vec![req.clone()]);
assert!(gate.reply("que_1", vec![vec!["a".into()]]));
assert!(gate.list().is_empty(), "entry removed on reply");
match rx.await.unwrap() {
Outcome::Answers(a) => assert_eq!(a, vec![vec!["a".to_string()]]),
_ => panic!("expected answers"),
}
assert!(!gate.reply("que_1", vec![]));
}
#[tokio::test]
async fn reject_resolves_and_unknown_is_false() {
let gate = QuestionGate::new();
let (rx, _guard) = gate.register("que_2", json!({"id": "que_2"}));
assert!(gate.reject("que_2"));
assert!(matches!(rx.await.unwrap(), Outcome::Rejected));
assert!(!gate.reject("que_missing"));
assert!(!gate.reply("que_missing", vec![]));
}
#[tokio::test(start_paused = true)]
async fn wait_timeout_rejects_and_cleans_pending() {
let gate = QuestionGate::new();
let (rx, _guard) = gate.register("que_3", json!({"id": "que_3"}));
assert_eq!(gate.list().len(), 1);
tokio::time::advance(QUESTION_WAIT + Duration::from_secs(1)).await;
let outcome = gate.wait("que_3", rx).await;
assert!(matches!(outcome, Outcome::Rejected));
assert!(gate.list().is_empty(), "timeout cleans the pending map");
}
#[tokio::test]
async fn guard_drop_cleans_pending_on_abort() {
let gate = QuestionGate::new();
{
let (_rx, _guard) = gate.register("que_4", json!({"id": "que_4"}));
assert_eq!(gate.list().len(), 1);
} assert!(
gate.list().is_empty(),
"drop removes pending (upstream ensuring)"
);
}
}