qq-cli 0.1.0

A modular Rust QQ task interface for coding agents
use std::io::{self, Write};
use std::sync::{Arc, atomic::AtomicBool, mpsc};
use std::time::{Duration, Instant};

use qq_cli::cli::stream_output::Output;
use qq_cli::messaging::watch::Sink;

#[test]
fn bounded_writer_returns_on_deadline_and_never_reuses_a_blocked_worker() {
    struct Blocked {
        release: mpsc::Receiver<()>,
        finished: mpsc::SyncSender<()>,
    }
    impl Write for Blocked {
        fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
            self.release.recv().unwrap();
            self.finished.send(()).unwrap();
            Ok(bytes.len())
        }
        fn flush(&mut self) -> io::Result<()> {
            Ok(())
        }
    }
    let (release, wait) = mpsc::sync_channel(1);
    let (done, finished) = mpsc::sync_channel(1);
    let mut output = Output::new(
        Blocked {
            release: wait,
            finished: done,
        },
        Arc::new(AtomicBool::new(false)),
    )
    .unwrap();
    let start = Instant::now();
    assert_eq!(
        output
            .record(&sample(""), start + Duration::from_millis(150))
            .unwrap_err()
            .code,
        "OUTPUT_DEADLINE"
    );
    assert!(start.elapsed() < Duration::from_secs(2));
    assert!(output.started());
    assert_eq!(
        output
            .record(&summary(), Instant::now() + Duration::from_secs(1))
            .unwrap_err()
            .code,
        "OUTPUT_IO"
    );
    release.send(()).unwrap();
    finished.recv_timeout(Duration::from_secs(2)).unwrap();
}

#[test]
fn worker_failure_and_cancellation_do_not_produce_extra_records() {
    struct Broken;
    impl Write for Broken {
        fn write(&mut self, _: &[u8]) -> io::Result<usize> {
            Err(io::ErrorKind::BrokenPipe.into())
        }
        fn flush(&mut self) -> io::Result<()> {
            Ok(())
        }
    }
    let mut output = Output::new(Broken, Arc::new(AtomicBool::new(false))).unwrap();
    assert_eq!(
        output
            .record(&sample(""), Instant::now() + Duration::from_secs(1))
            .unwrap_err()
            .code,
        "OUTPUT_IO"
    );
    let mut output = Output::new(Vec::<u8>::new(), Arc::new(AtomicBool::new(true))).unwrap();
    assert_eq!(
        output
            .record(&sample(""), Instant::now() + Duration::from_secs(1))
            .unwrap_err()
            .code,
        "CANCELLED"
    );
    assert!(!output.started());
}

#[test]
fn output_worker_process_exits_when_os_pipe_is_not_drained() {
    if std::env::var_os("QQ_CLI_TEST_BLOCKED_OUTPUT").is_some() {
        let mut output = Output::new(io::stdout(), Arc::new(AtomicBool::new(false))).unwrap();
        let error = output
            .record(
                &sample(&"x".repeat(1024 * 1024)),
                Instant::now() + Duration::from_millis(300),
            )
            .unwrap_err();
        std::process::exit(if error.code == "OUTPUT_DEADLINE" {
            0
        } else {
            1
        });
    }
    let mut child = std::process::Command::new(std::env::current_exe().unwrap())
        .args([
            "--exact",
            "output_worker_process_exits_when_os_pipe_is_not_drained",
            "--nocapture",
        ])
        .env("QQ_CLI_TEST_BLOCKED_OUTPUT", "1")
        .stdout(std::process::Stdio::piped())
        .stderr(std::process::Stdio::null())
        .spawn()
        .unwrap();
    let until = Instant::now() + Duration::from_secs(5);
    loop {
        if let Some(status) = child.try_wait().unwrap() {
            assert!(status.success());
            break;
        }
        if Instant::now() >= until {
            child.kill().unwrap();
            child.wait().unwrap();
            panic!("Blocked stdout prevented process exit");
        }
        std::thread::sleep(Duration::from_millis(10));
    }
}

fn sample(text: &str) -> qq_cli::core::contracts::event::Event {
    use qq_cli::core::contracts::{
        event::{Event, GapCoverage},
        scalar::No,
    };
    Event::Gap {
        reason: text.into(),
        recovered: No,
        coverage: GapCoverage::Unknown,
    }
}
fn summary() -> qq_cli::core::contracts::event::Event {
    use qq_cli::core::contracts::event::{Coverage, Event};
    Event::Summary {
        reason: "test".into(),
        emitted: 0,
        duplicates: 0,
        filtered: 0,
        heartbeats: 0,
        frames: 0,
        gaps: 0,
        bytes: 0,
        coverage: Coverage::BestEffort,
        error_code: None,
        checkpoint: None,
        recovery: None,
    }
}