use std::io::{self, Write};
use std::path::Path;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;
use anyhow::{Context, Result};
use chrono::Local;
use crate::SortBy;
use crate::export::OutputFormat;
use crate::filter::ProjectFilter;
fn clear_screen() -> Result<()> {
print!("\x1B[2J\x1B[H");
io::stdout().flush().context("Failed to flush stdout")?;
Ok(())
}
pub fn format_watch_header(path: &Path, interval: u64) -> String {
let now = Local::now().format("%Y-%m-%d %H:%M:%S");
format!(
"devpulse — watching {} (every {}s) | Last update: {} | Press Ctrl+C to exit\n",
path.display(),
interval,
now,
)
}
pub fn interruptible_sleep(duration: Duration, stop: &AtomicBool) -> bool {
let step = Duration::from_millis(500);
let mut remaining = duration;
while remaining > Duration::ZERO {
if stop.load(Ordering::Relaxed) {
return true;
}
let sleep_time = remaining.min(step);
std::thread::sleep(sleep_time);
remaining = remaining.saturating_sub(sleep_time);
}
stop.load(Ordering::Relaxed)
}
#[allow(clippy::too_many_arguments)]
pub fn run_watch_loop(
scan_path: &Path,
sort: &SortBy,
format: &OutputFormat,
interval_secs: u64,
filters: &[ProjectFilter],
depth: u32,
use_color: bool,
theme: &crate::theme::Theme,
) -> Result<()> {
let stop = Arc::new(AtomicBool::new(false));
let stop_clone = Arc::clone(&stop);
ctrlc::set_handler(move || {
stop_clone.store(true, Ordering::Relaxed);
})
.context("Failed to set Ctrl+C handler")?;
loop {
clear_screen()?;
print!("{}", format_watch_header(scan_path, interval_secs));
io::stdout().flush().context("Failed to flush stdout")?;
crate::scan_and_display(
scan_path,
sort,
format,
filters,
&[],
depth,
None,
false,
None,
false,
use_color,
false, theme,
)?;
if interruptible_sleep(Duration::from_secs(interval_secs), &stop) {
println!("\nExiting watch mode.");
break;
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
use std::time::{Duration, Instant};
#[test]
fn test_format_watch_header_contains_path() {
let path = PathBuf::from("/home/user/projects");
let header = format_watch_header(&path, 60);
assert!(
header.contains("/home/user/projects"),
"Header should contain the scan path"
);
}
#[test]
fn test_format_watch_header_contains_interval() {
let path = PathBuf::from("/tmp");
let header = format_watch_header(&path, 30);
assert!(header.contains("30s"), "Header should contain the interval");
}
#[test]
fn test_format_watch_header_contains_timestamp() {
let path = PathBuf::from("/tmp");
let header = format_watch_header(&path, 60);
assert!(
header.contains("Last update:"),
"Header should contain 'Last update:'"
);
let now = Local::now().format("%Y-%m-%d").to_string();
assert!(header.contains(&now), "Header should contain today's date");
}
#[test]
fn test_format_watch_header_contains_exit_hint() {
let path = PathBuf::from("/tmp");
let header = format_watch_header(&path, 60);
assert!(
header.contains("Ctrl+C"),
"Header should mention Ctrl+C to exit"
);
}
#[test]
fn test_interruptible_sleep_completes_normally() {
let stop = AtomicBool::new(false);
let start = Instant::now();
let was_stopped = interruptible_sleep(Duration::from_millis(100), &stop);
let elapsed = start.elapsed();
assert!(!was_stopped, "Should not report stopped");
assert!(
elapsed >= Duration::from_millis(100),
"Should sleep for at least the requested duration"
);
}
#[test]
fn test_interruptible_sleep_stops_early_when_flagged() {
let stop = AtomicBool::new(false);
let stop_ref = &stop as *const AtomicBool as usize;
std::thread::spawn(move || {
std::thread::sleep(Duration::from_millis(200));
unsafe {
let stop_ptr = stop_ref as *const AtomicBool;
(*stop_ptr).store(true, Ordering::Relaxed);
}
});
let start = Instant::now();
let was_stopped = interruptible_sleep(Duration::from_secs(10), &stop);
let elapsed = start.elapsed();
assert!(was_stopped, "Should report stopped");
assert!(
elapsed < Duration::from_secs(2),
"Should exit well before 10s timeout (exited in {:?})",
elapsed
);
}
#[test]
fn test_interruptible_sleep_immediate_stop() {
let stop = AtomicBool::new(true); let start = Instant::now();
let was_stopped = interruptible_sleep(Duration::from_secs(60), &stop);
let elapsed = start.elapsed();
assert!(was_stopped, "Should report stopped immediately");
assert!(
elapsed < Duration::from_secs(2),
"Should exit almost immediately (exited in {:?})",
elapsed
);
}
#[test]
fn test_interruptible_sleep_zero_duration() {
let stop = AtomicBool::new(false);
let was_stopped = interruptible_sleep(Duration::ZERO, &stop);
assert!(!was_stopped, "Zero duration should complete without stop");
}
#[test]
fn test_clear_screen_does_not_panic() {
let result = clear_screen();
assert!(result.is_ok(), "clear_screen should not fail");
}
}