use serde::{Deserialize, Serialize};
use std::path::PathBuf;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Phase {
Running,
Done,
Error,
Stopped,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SessionState {
pub pid: u32,
pub command: String,
pub app: Option<String>,
pub step: Option<String>,
pub phase: Phase,
pub ts_ms: u64,
}
impl SessionState {
pub fn now(
pid: u32,
command: &str,
app: Option<&str>,
step: Option<String>,
phase: Phase,
) -> Self {
Self {
pid,
command: command.to_string(),
app: app.map(str::to_string),
step,
phase,
ts_ms: unix_ms(),
}
}
}
pub fn unix_ms() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0)
}
#[derive(Debug, Clone)]
pub struct SignalPaths {
pub dir: PathBuf,
}
impl Default for SignalPaths {
fn default() -> Self {
let dir = std::env::var_os("LOCALAPPDATA")
.map(PathBuf::from)
.unwrap_or_else(std::env::temp_dir)
.join("actl");
Self { dir }
}
}
impl SignalPaths {
pub fn at(dir: impl Into<PathBuf>) -> Self {
Self { dir: dir.into() }
}
pub fn state_file(&self) -> PathBuf {
self.dir.join("state.json")
}
pub fn stop_file(&self) -> PathBuf {
self.dir.join("stop-requested")
}
pub fn write_state(&self, st: &SessionState) {
let _ = std::fs::create_dir_all(&self.dir);
let tmp = self.dir.join("state.json.tmp");
if serde_json::to_string(st)
.map(|s| std::fs::write(&tmp, s))
.is_ok()
{
let _ = std::fs::rename(&tmp, self.state_file());
}
}
pub fn read_state(&self) -> Option<SessionState> {
let text = std::fs::read_to_string(self.state_file()).ok()?;
serde_json::from_str(&text).ok()
}
pub fn request_stop(&self) {
let _ = std::fs::create_dir_all(&self.dir);
let _ = std::fs::write(self.stop_file(), unix_ms().to_string());
}
pub fn stop_requested(&self) -> bool {
self.stop_file().is_file()
}
pub fn clear_stop(&self) {
let _ = std::fs::remove_file(self.stop_file());
}
}
#[cfg(test)]
mod tests {
use super::*;
fn tmp() -> SignalPaths {
static N: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
let n = N.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
let dir = std::env::temp_dir().join(format!("actl-state-test-{}-{n}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
SignalPaths::at(&dir)
}
#[test]
fn state_roundtrip_and_fields() {
let p = tmp();
let st = SessionState::now(
42,
"press",
Some("记事本"),
Some("3/8".into()),
Phase::Running,
);
p.write_state(&st);
let back = p.read_state().expect("read back");
assert_eq!(back.command, "press");
assert_eq!(back.app.as_deref(), Some("记事本"));
assert_eq!(back.step.as_deref(), Some("3/8"));
assert_eq!(back.phase, Phase::Running);
assert!(back.ts_ms > 0);
let _ = std::fs::remove_dir_all(&p.dir);
}
#[test]
fn stop_flag_is_sticky_until_cleared() {
let p = tmp();
assert!(!p.stop_requested());
p.request_stop();
assert!(p.stop_requested());
p.clear_stop();
assert!(!p.stop_requested());
let _ = std::fs::remove_dir_all(&p.dir);
}
#[test]
fn phase_serializes_snake_case() {
assert_eq!(
serde_json::to_string(&Phase::Stopped).unwrap(),
r#""stopped""#
);
}
}