o7 0.1.1

O7 workflow DSL runner
Documentation
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),
        }
    }

    /// Ensure the qa/ directory exists.
    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))
    }

    /// Scan for the next unanswered q-out file.
    /// Returns Some((seq, qout)) if found, None otherwise.
    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; // Block on unanswered notified question
            }
            // Try to read and parse
            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
    }

    /// Mark a sequence as answered.
    pub fn mark_answered(&mut self, seq: u32) {
        self.answered_seqs.insert(seq);
    }

    /// Get the qa directory path.
    pub fn qa_dir(&self) -> &PathBuf {
        &self.qa_dir
    }

    /// Get the poll interval.
    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);

        // Write a q-out file
        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);

        // Write both q-out and q-answers
        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);

        // Write two q-out files
        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();

        // Should get seq 1 first
        let result = watcher.poll_for_questions();
        assert_eq!(result.unwrap().0, 1);

        // Should block on seq 1 (not yet answered)
        let result = watcher.poll_for_questions();
        assert!(result.is_none());

        // Mark 1 as answered
        watcher.mark_answered(1);

        // Now should get seq 2
        let result = watcher.poll_for_questions();
        assert_eq!(result.unwrap().0, 2);
    }

    // --- New coverage tests ---

    #[test]
    fn test_poll_when_qa_dir_does_not_exist() {
        let dir = TempDir::new().unwrap();
        let run_dir = dir.path().join("run-no-qa");
        // Intentionally do NOT create run_dir/qa
        let mut watcher = QAWatcher::new(run_dir);
        // Should return None without panicking
        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);

        // Write invalid JSON as seq 1
        fs::write(qa_dir.join("q-out-001.json"), b"NOT VALID JSON").unwrap();

        // Should return None — invalid JSON is silently skipped and poll blocks
        let result = watcher.poll_for_questions();
        assert!(result.is_none());
    }

    #[test]
    fn test_poll_002_before_001_still_processes_in_order() {
        // Files written to the filesystem out of seq order must still be served in seq 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);

        // Write seq 2 before seq 1
        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();

        // Must serve seq 1 first
        let result = watcher.poll_for_questions();
        assert!(result.is_some());
        assert_eq!(result.unwrap().0, 1);

        // Seq 2 must still be blocked until seq 1 is answered
        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();

        // First poll notifies
        let r1 = watcher.poll_for_questions();
        assert!(r1.is_some());

        // mark_answered via the watcher method (simulating the event loop path)
        watcher.mark_answered(1);

        // Should not notify again even though the file is still there
        let r2 = watcher.poll_for_questions();
        assert!(r2.is_none());
    }

    #[test]
    fn test_poll_notifies_only_once_before_answer_written() {
        // Even without mark_answered, a notified seq must not be re-notified.
        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());

        // Without answering, second poll must return None (blocking behaviour)
        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);

        // Write files with similar but non-matching names
        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());
    }
}