use std::collections::HashSet;
use std::fs;
use std::path::PathBuf;
use std::time::Duration;
use super::qa_protocol::{QOutFile, format_seq, parse_seq_from_filename};
pub struct QAWatcher {
qa_dir: PathBuf,
answered_seqs: HashSet<u32>,
notified_seqs: HashSet<u32>,
poll_interval: Duration,
}
impl QAWatcher {
pub fn new(run_state_dir: PathBuf) -> Self {
QAWatcher {
qa_dir: run_state_dir.join("qa"),
answered_seqs: HashSet::new(),
notified_seqs: HashSet::new(),
poll_interval: Duration::from_millis(500),
}
}
pub fn ensure_qa_dir(&self) -> Result<(), String> {
fs::create_dir_all(&self.qa_dir).map_err(|e| format!("Failed to create qa dir: {}", e))
}
pub fn poll_for_questions(&mut self) -> Option<(u32, QOutFile)> {
if !self.qa_dir.exists() {
return None;
}
let entries = fs::read_dir(&self.qa_dir).ok()?;
let mut qout_seqs: Vec<u32> = Vec::new();
for entry in entries.flatten() {
let filename = entry.file_name().to_string_lossy().to_string();
if filename.starts_with("q-answers-") {
if let Some(seq) = parse_seq_from_filename(&filename) {
self.answered_seqs.insert(seq);
}
} else if filename.starts_with("q-out-") {
if let Some(seq) = parse_seq_from_filename(&filename) {
qout_seqs.push(seq);
}
}
}
qout_seqs.sort();
for seq in qout_seqs {
if self.answered_seqs.contains(&seq) {
continue;
}
if self.notified_seqs.contains(&seq) {
break; }
let filename = format!("q-out-{}.json", format_seq(seq));
let path = self.qa_dir.join(&filename);
if let Ok(raw) = fs::read_to_string(&path) {
if let Ok(qout) = serde_json::from_str::<QOutFile>(&raw) {
self.notified_seqs.insert(seq);
return Some((seq, qout));
}
}
break;
}
None
}
pub fn mark_answered(&mut self, seq: u32) {
self.answered_seqs.insert(seq);
}
pub fn qa_dir(&self) -> &PathBuf {
&self.qa_dir
}
pub fn poll_interval(&self) -> Duration {
self.poll_interval
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tui::qa_protocol::{QOutFile, Question, QuestionType};
use tempfile::TempDir;
fn make_qout(seq: u32) -> QOutFile {
QOutFile {
seq,
questions: vec![Question {
id: "q1".to_string(),
question_type: QuestionType::ChooseOne,
prompt: "Pick".to_string(),
options: Some(vec!["a".to_string(), "b".to_string()]),
preview: None,
}],
}
}
#[test]
fn test_poll_detects_new_qout() {
let dir = TempDir::new().unwrap();
let run_dir = dir.path().join("run-test");
let qa_dir = run_dir.join("qa");
fs::create_dir_all(&qa_dir).unwrap();
let mut watcher = QAWatcher::new(run_dir);
let qout = make_qout(1);
let json = serde_json::to_string(&qout).unwrap();
fs::write(qa_dir.join("q-out-001.json"), &json).unwrap();
let result = watcher.poll_for_questions();
assert!(result.is_some());
let (seq, detected) = result.unwrap();
assert_eq!(seq, 1);
assert_eq!(detected.questions.len(), 1);
}
#[test]
fn test_poll_ignores_answered() {
let dir = TempDir::new().unwrap();
let run_dir = dir.path().join("run-test");
let qa_dir = run_dir.join("qa");
fs::create_dir_all(&qa_dir).unwrap();
let mut watcher = QAWatcher::new(run_dir);
let qout = make_qout(1);
fs::write(
qa_dir.join("q-out-001.json"),
serde_json::to_string(&qout).unwrap(),
)
.unwrap();
fs::write(
qa_dir.join("q-answers-001.json"),
r#"{"seq":1,"answers":[]}"#,
)
.unwrap();
let result = watcher.poll_for_questions();
assert!(result.is_none());
}
#[test]
fn test_poll_processes_in_order() {
let dir = TempDir::new().unwrap();
let run_dir = dir.path().join("run-test");
let qa_dir = run_dir.join("qa");
fs::create_dir_all(&qa_dir).unwrap();
let mut watcher = QAWatcher::new(run_dir);
fs::write(
qa_dir.join("q-out-001.json"),
serde_json::to_string(&make_qout(1)).unwrap(),
)
.unwrap();
fs::write(
qa_dir.join("q-out-002.json"),
serde_json::to_string(&make_qout(2)).unwrap(),
)
.unwrap();
let result = watcher.poll_for_questions();
assert_eq!(result.unwrap().0, 1);
let result = watcher.poll_for_questions();
assert!(result.is_none());
watcher.mark_answered(1);
let result = watcher.poll_for_questions();
assert_eq!(result.unwrap().0, 2);
}
#[test]
fn test_poll_when_qa_dir_does_not_exist() {
let dir = TempDir::new().unwrap();
let run_dir = dir.path().join("run-no-qa");
let mut watcher = QAWatcher::new(run_dir);
let result = watcher.poll_for_questions();
assert!(result.is_none());
}
#[test]
fn test_poll_skips_invalid_json_qout() {
let dir = TempDir::new().unwrap();
let run_dir = dir.path().join("run-test");
let qa_dir = run_dir.join("qa");
fs::create_dir_all(&qa_dir).unwrap();
let mut watcher = QAWatcher::new(run_dir);
fs::write(qa_dir.join("q-out-001.json"), b"NOT VALID JSON").unwrap();
let result = watcher.poll_for_questions();
assert!(result.is_none());
}
#[test]
fn test_poll_002_before_001_still_processes_in_order() {
let dir = TempDir::new().unwrap();
let run_dir = dir.path().join("run-test");
let qa_dir = run_dir.join("qa");
fs::create_dir_all(&qa_dir).unwrap();
let mut watcher = QAWatcher::new(run_dir);
fs::write(
qa_dir.join("q-out-002.json"),
serde_json::to_string(&make_qout(2)).unwrap(),
)
.unwrap();
fs::write(
qa_dir.join("q-out-001.json"),
serde_json::to_string(&make_qout(1)).unwrap(),
)
.unwrap();
let result = watcher.poll_for_questions();
assert!(result.is_some());
assert_eq!(result.unwrap().0, 1);
let result = watcher.poll_for_questions();
assert!(result.is_none());
watcher.mark_answered(1);
let result = watcher.poll_for_questions();
assert!(result.is_some());
assert_eq!(result.unwrap().0, 2);
}
#[test]
fn test_mark_answered_prevents_re_notification() {
let dir = TempDir::new().unwrap();
let run_dir = dir.path().join("run-test");
let qa_dir = run_dir.join("qa");
fs::create_dir_all(&qa_dir).unwrap();
let mut watcher = QAWatcher::new(run_dir);
fs::write(
qa_dir.join("q-out-001.json"),
serde_json::to_string(&make_qout(1)).unwrap(),
)
.unwrap();
let r1 = watcher.poll_for_questions();
assert!(r1.is_some());
watcher.mark_answered(1);
let r2 = watcher.poll_for_questions();
assert!(r2.is_none());
}
#[test]
fn test_poll_notifies_only_once_before_answer_written() {
let dir = TempDir::new().unwrap();
let run_dir = dir.path().join("run-test");
let qa_dir = run_dir.join("qa");
fs::create_dir_all(&qa_dir).unwrap();
let mut watcher = QAWatcher::new(run_dir);
fs::write(
qa_dir.join("q-out-001.json"),
serde_json::to_string(&make_qout(1)).unwrap(),
)
.unwrap();
let r1 = watcher.poll_for_questions();
assert!(r1.is_some());
let r2 = watcher.poll_for_questions();
assert!(r2.is_none());
let r3 = watcher.poll_for_questions();
assert!(r3.is_none());
}
#[test]
fn test_qa_dir_accessor() {
let dir = TempDir::new().unwrap();
let run_dir = dir.path().join("my-run");
let watcher = QAWatcher::new(run_dir.clone());
assert_eq!(*watcher.qa_dir(), run_dir.join("qa"));
}
#[test]
fn test_ensure_qa_dir_creates_directory() {
let dir = TempDir::new().unwrap();
let run_dir = dir.path().join("run-ensure");
let watcher = QAWatcher::new(run_dir);
assert!(!watcher.qa_dir().exists());
watcher.ensure_qa_dir().expect("Should create qa dir");
assert!(watcher.qa_dir().exists());
}
#[test]
fn test_poll_ignores_non_qout_files() {
let dir = TempDir::new().unwrap();
let run_dir = dir.path().join("run-test");
let qa_dir = run_dir.join("qa");
fs::create_dir_all(&qa_dir).unwrap();
let mut watcher = QAWatcher::new(run_dir);
fs::write(qa_dir.join("something.json"), b"{}").unwrap();
fs::write(qa_dir.join("q-out-.json"), b"{}").unwrap();
fs::write(qa_dir.join("q-out-001.txt"), b"{}").unwrap();
let result = watcher.poll_for_questions();
assert!(result.is_none());
}
}