use crate::config::Config;
use std::io::Write;
use std::path::PathBuf;
use std::process::{Command, Stdio};
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
const CHAIN_CACHE_FILE: &str = "chain_output";
const PULSE_STATE_FILE: &str = "pulse_state";
const PULSE_STATE_TTL_SECONDS: u64 = 10;
const POLL_INTERVAL_MS: u64 = 5;
pub fn run_chain(chain_command: &str, raw_stdin: &str, cfg: &Config) -> String {
let now_ms = now_millis();
let cache_path = cache_file(CHAIN_CACHE_FILE);
if let Some(path) = cache_path.as_ref() {
if let Some((written_ms, output)) = read_cache_entry(path) {
if is_cache_fresh(written_ms, now_ms, cfg.chain.chain_cache_ttl_seconds) {
return output;
}
}
}
match spawn_with_timeout(chain_command, raw_stdin, cfg.chain.chain_timeout_ms) {
Some(output) => {
let trimmed = trim_trailing_newline(&output);
if let Some(path) = cache_path.as_ref() {
write_cache_entry(path, now_ms, &trimmed);
}
trimmed
}
None => cache_path
.as_ref()
.and_then(read_cache_entry)
.map(|(_, output)| output)
.unwrap_or_default(),
}
}
pub fn read_prev_pulse_state() -> bool {
let path = match cache_file(PULSE_STATE_FILE) {
Some(path) => path,
None => return false,
};
match read_cache_entry(&path) {
Some((written_ms, value))
if is_cache_fresh(written_ms, now_millis(), PULSE_STATE_TTL_SECONDS) =>
{
value.trim() == "1"
}
_ => false,
}
}
pub fn write_pulse_state(on: bool) {
if let Some(path) = cache_file(PULSE_STATE_FILE) {
write_cache_entry(&path, now_millis(), if on { "1" } else { "0" });
}
}
pub fn read_named_cache(name: &str) -> Option<(u128, String)> {
let path = cache_file(name)?;
read_cache_entry(&path)
}
pub fn write_named_cache(name: &str, now_ms: u128, payload: &str) {
if let Some(path) = cache_file(name) {
write_cache_entry(&path, now_ms, payload);
}
}
pub fn cache_now_millis() -> u128 {
now_millis()
}
pub fn is_named_cache_fresh(written_ms: u128, now_ms: u128, ttl_seconds: u64) -> bool {
is_cache_fresh(written_ms, now_ms, ttl_seconds)
}
fn is_cache_fresh(written_ms: u128, now_ms: u128, ttl_seconds: u64) -> bool {
if ttl_seconds == 0 || now_ms < written_ms {
return false;
}
let elapsed_ms = now_ms - written_ms;
elapsed_ms <= (ttl_seconds as u128) * 1000
}
fn cache_dir() -> Option<PathBuf> {
let home = std::env::var_os("HOME").map(PathBuf::from)?;
Some(home.join("Library").join("Caches").join("understatus"))
}
fn cache_file(name: &str) -> Option<PathBuf> {
cache_dir().map(|dir| dir.join(name))
}
fn read_cache_entry(path: &PathBuf) -> Option<(u128, String)> {
let contents = std::fs::read_to_string(path).ok()?;
let (timestamp_line, payload) = match contents.split_once('\n') {
Some((first, rest)) => (first, rest.to_string()),
None => (contents.as_str(), String::new()),
};
let written_ms: u128 = timestamp_line.trim().parse().ok()?;
Some((written_ms, payload))
}
fn write_cache_entry(path: &PathBuf, now_ms: u128, payload: &str) {
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let _ = std::fs::write(path, format!("{now_ms}\n{payload}"));
}
fn spawn_with_timeout(command: &str, raw_stdin: &str, timeout_ms: u64) -> Option<String> {
let mut child = Command::new("sh")
.arg("-c")
.arg(command)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.ok()?;
if let Some(mut stdin) = child.stdin.take() {
let _ = stdin.write_all(raw_stdin.as_bytes());
}
let deadline = Instant::now() + Duration::from_millis(timeout_ms);
loop {
match child.try_wait() {
Ok(Some(_status)) => {
return collect_stdout(child);
}
Ok(None) => {
if Instant::now() >= deadline {
let _ = child.kill();
let _ = child.wait();
return None;
}
std::thread::sleep(Duration::from_millis(POLL_INTERVAL_MS));
}
Err(_) => {
let _ = child.kill();
let _ = child.wait();
return None;
}
}
}
}
fn collect_stdout(mut child: std::process::Child) -> Option<String> {
use std::io::Read;
let mut stdout = child.stdout.take()?;
let mut buffer = Vec::new();
stdout.read_to_end(&mut buffer).ok()?;
Some(String::from_utf8_lossy(&buffer).into_owned())
}
fn trim_trailing_newline(value: &str) -> String {
value
.strip_suffix('\n')
.map(|stripped| stripped.strip_suffix('\r').unwrap_or(stripped))
.unwrap_or(value)
.to_string()
}
fn now_millis() -> u128 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|elapsed| elapsed.as_millis())
.unwrap_or(0)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn cache_freshness_respects_ttl() {
assert!(is_cache_fresh(0, 2_000, 3));
assert!(is_cache_fresh(0, 3_000, 3));
assert!(!is_cache_fresh(0, 3_001, 3));
assert!(!is_cache_fresh(0, 0, 0));
assert!(!is_cache_fresh(5_000, 1_000, 3));
}
#[test]
fn trims_only_trailing_newline() {
assert_eq!(trim_trailing_newline("hello\n"), "hello");
assert_eq!(trim_trailing_newline("hello\r\n"), "hello");
assert_eq!(trim_trailing_newline("a\nb\n"), "a\nb");
assert_eq!(trim_trailing_newline("no-newline"), "no-newline");
assert_eq!(trim_trailing_newline(""), "");
}
#[test]
fn cache_entry_roundtrip() {
let path =
std::env::temp_dir().join(format!("understatus-cache-rt-{}", std::process::id()));
write_cache_entry(&path, 12_345, "line1\nline2");
let (written_ms, payload) = read_cache_entry(&path).expect("캐시 읽기 실패");
assert_eq!(written_ms, 12_345);
assert_eq!(payload, "line1\nline2");
let _ = std::fs::remove_file(&path);
}
#[test]
fn cache_entry_empty_payload() {
let path =
std::env::temp_dir().join(format!("understatus-cache-empty-{}", std::process::id()));
write_cache_entry(&path, 999, "");
let (written_ms, payload) = read_cache_entry(&path).expect("캐시 읽기 실패");
assert_eq!(written_ms, 999);
assert_eq!(payload, "");
let _ = std::fs::remove_file(&path);
}
#[test]
fn missing_cache_returns_none() {
let path = std::env::temp_dir().join("understatus-cache-does-not-exist-xyz");
let _ = std::fs::remove_file(&path);
assert!(read_cache_entry(&path).is_none());
}
#[test]
fn spawn_returns_stdout() {
let output = spawn_with_timeout("printf 'hi-there'", "", 2_000);
assert_eq!(output.as_deref(), Some("hi-there"));
}
#[test]
fn spawn_passes_stdin() {
let output = spawn_with_timeout("cat", "payload-123", 2_000);
assert_eq!(output.as_deref(), Some("payload-123"));
}
#[test]
fn spawn_times_out_quickly() {
let started = Instant::now();
let output = spawn_with_timeout("sleep 5", "", 200);
let elapsed = started.elapsed();
assert_eq!(output, None, "타임아웃 시 None이어야 함");
assert!(
elapsed < Duration::from_secs(2),
"타임아웃이 렌더를 블록함: {elapsed:?}"
);
}
#[test]
fn spawn_nonzero_exit_still_returns_stdout() {
let output = spawn_with_timeout("printf 'partial'; exit 1", "", 2_000);
assert_eq!(output.as_deref(), Some("partial"));
}
#[test]
fn fresh_cache_decision_skips_spawn() {
let path =
std::env::temp_dir().join(format!("understatus-skip-spawn-{}", std::process::id()));
let now = now_millis();
write_cache_entry(&path, now, "CACHED-OUTPUT");
let ttl = Config::default().chain.chain_cache_ttl_seconds; let entry = read_cache_entry(&path).expect("캐시 읽기 실패");
let (written_ms, output) = entry;
let would_skip_spawn = is_cache_fresh(written_ms, now_millis(), ttl);
assert!(would_skip_spawn, "신선 캐시는 자식 스폰을 건너뛰어야 함");
assert_eq!(output, "CACHED-OUTPUT");
let _ = std::fs::remove_file(&path);
}
#[test]
fn stale_cache_decision_triggers_spawn() {
let path =
std::env::temp_dir().join(format!("understatus-stale-spawn-{}", std::process::id()));
let now = now_millis();
write_cache_entry(&path, now.saturating_sub(15_000), "OLD");
let ttl = Config::default().chain.chain_cache_ttl_seconds;
let (written_ms, _output) = read_cache_entry(&path).expect("캐시 읽기 실패");
assert!(
!is_cache_fresh(written_ms, now, ttl),
"만료 캐시는 자식 스폰을 트리거해야 함"
);
let _ = std::fs::remove_file(&path);
}
}