sdd-layer 0.19.0

Spec-Driven Development CLI and agent harness
//! Loader/spinner com threshold de 200ms (T-12, RF-05, CA-RF05).
//! Operações que terminam antes de 200ms não renderizam spinner (evita flash).

use std::time::{Duration, Instant};

const FRAMES: [&str; 10] = ["", "", "", "", "", "", "", "", "", ""];
const THRESHOLD: Duration = Duration::from_millis(200);

#[derive(Debug, Default)]
pub struct Loader {
    armed_at: Option<Instant>,
    frame: usize,
    pub label: String,
}

impl Loader {
    pub fn new() -> Self {
        Loader::default()
    }

    /// Arma o loader para uma operação; o spinner só aparecerá após o threshold.
    pub fn arm(&mut self, label: impl Into<String>) {
        self.armed_at = Some(Instant::now());
        self.frame = 0;
        self.label = label.into();
    }

    pub fn disarm(&mut self) {
        self.armed_at = None;
        self.label.clear();
    }

    #[allow(dead_code)]
    pub fn is_armed(&self) -> bool {
        self.armed_at.is_some()
    }

    /// Avança a animação (chamada a cada tick do event loop).
    pub fn tick(&mut self) {
        if self.armed_at.is_some() {
            self.frame = (self.frame + 1) % FRAMES.len();
        }
    }

    /// Só é visível depois de decorrido o threshold de 200ms.
    pub fn is_visible(&self) -> bool {
        match self.armed_at {
            Some(t) => t.elapsed() >= THRESHOLD,
            None => false,
        }
    }

    pub fn elapsed(&self) -> Option<Duration> {
        self.armed_at.map(|armed_at| armed_at.elapsed())
    }

    pub fn elapsed_label(&self) -> String {
        format_elapsed(self.elapsed().unwrap_or_default())
    }

    pub fn current_frame(&self) -> &'static str {
        FRAMES[self.frame % FRAMES.len()]
    }

    #[cfg(test)]
    fn force_armed_at(&mut self, instant: Instant) {
        self.armed_at = Some(instant);
    }
}

fn format_elapsed(duration: Duration) -> String {
    let total_millis = duration.as_millis();
    let minutes = total_millis / 60_000;
    let seconds = (total_millis / 1_000) % 60;
    let tenths = (total_millis / 100) % 10;
    format!("{minutes:02}:{seconds:02}.{tenths}")
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn not_visible_right_after_arm() {
        let mut l = Loader::new();
        l.arm("rodando idea");
        assert!(l.is_armed());
        assert!(!l.is_visible(), "loader não deve aparecer antes de 200ms");
    }

    #[test]
    fn visible_after_threshold() {
        let mut l = Loader::new();
        l.arm("rodando prd");
        l.force_armed_at(Instant::now() - Duration::from_millis(250));
        assert!(l.is_visible(), "loader deve aparecer após 200ms");
    }

    #[test]
    fn disarm_hides() {
        let mut l = Loader::new();
        l.arm("x");
        l.disarm();
        assert!(!l.is_armed());
        assert!(!l.is_visible());
    }

    #[test]
    fn tick_advances_frame() {
        let mut l = Loader::new();
        l.arm("x");
        let f0 = l.current_frame();
        l.tick();
        let f1 = l.current_frame();
        assert_ne!(f0, f1);
    }

    #[test]
    fn elapsed_label_is_human_readable() {
        let mut l = Loader::new();
        l.arm("x");
        l.force_armed_at(Instant::now() - Duration::from_millis(65_432));
        assert!(l.elapsed_label().starts_with("01:05."));
    }
}