1use serde::{Deserialize, Serialize};
12use std::path::PathBuf;
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
16#[serde(rename_all = "snake_case")]
17pub enum Phase {
18 Running,
20 Done,
22 Error,
24 Stopped,
26}
27
28#[derive(Debug, Clone, Serialize, Deserialize)]
30pub struct SessionState {
31 pub pid: u32,
32 pub command: String,
33 pub app: Option<String>,
35 pub step: Option<String>,
37 pub phase: Phase,
38 pub ts_ms: u64,
40}
41
42impl SessionState {
43 pub fn now(
44 pid: u32,
45 command: &str,
46 app: Option<&str>,
47 step: Option<String>,
48 phase: Phase,
49 ) -> Self {
50 Self {
51 pid,
52 command: command.to_string(),
53 app: app.map(str::to_string),
54 step,
55 phase,
56 ts_ms: unix_ms(),
57 }
58 }
59}
60
61pub fn unix_ms() -> u64 {
62 std::time::SystemTime::now()
63 .duration_since(std::time::UNIX_EPOCH)
64 .map(|d| d.as_millis() as u64)
65 .unwrap_or(0)
66}
67
68#[derive(Debug, Clone)]
70pub struct SignalPaths {
71 pub dir: PathBuf,
72}
73
74impl Default for SignalPaths {
75 fn default() -> Self {
76 let dir = std::env::var_os("LOCALAPPDATA")
77 .map(PathBuf::from)
78 .unwrap_or_else(std::env::temp_dir)
79 .join("actl");
80 Self { dir }
81 }
82}
83
84impl SignalPaths {
85 pub fn at(dir: impl Into<PathBuf>) -> Self {
86 Self { dir: dir.into() }
87 }
88
89 pub fn state_file(&self) -> PathBuf {
90 self.dir.join("state.json")
91 }
92
93 pub fn stop_file(&self) -> PathBuf {
94 self.dir.join("stop-requested")
95 }
96
97 pub fn write_state(&self, st: &SessionState) {
99 let _ = std::fs::create_dir_all(&self.dir);
100 let tmp = self.dir.join("state.json.tmp");
101 if serde_json::to_string(st)
102 .map(|s| std::fs::write(&tmp, s))
103 .is_ok()
104 {
105 let _ = std::fs::rename(&tmp, self.state_file());
106 }
107 }
108
109 pub fn read_state(&self) -> Option<SessionState> {
110 let text = std::fs::read_to_string(self.state_file()).ok()?;
111 serde_json::from_str(&text).ok()
112 }
113
114 pub fn request_stop(&self) {
116 let _ = std::fs::create_dir_all(&self.dir);
117 let _ = std::fs::write(self.stop_file(), unix_ms().to_string());
118 }
119
120 pub fn stop_requested(&self) -> bool {
121 self.stop_file().is_file()
122 }
123
124 pub fn clear_stop(&self) {
126 let _ = std::fs::remove_file(self.stop_file());
127 }
128}
129
130#[cfg(test)]
131mod tests {
132 use super::*;
133
134 fn tmp() -> SignalPaths {
135 static N: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
136 let n = N.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
137 let dir = std::env::temp_dir().join(format!("actl-state-test-{}-{n}", std::process::id()));
138 let _ = std::fs::remove_dir_all(&dir);
139 SignalPaths::at(&dir)
140 }
141
142 #[test]
143 fn state_roundtrip_and_fields() {
144 let p = tmp();
145 let st = SessionState::now(
146 42,
147 "press",
148 Some("记事本"),
149 Some("3/8".into()),
150 Phase::Running,
151 );
152 p.write_state(&st);
153 let back = p.read_state().expect("read back");
154 assert_eq!(back.command, "press");
155 assert_eq!(back.app.as_deref(), Some("记事本"));
156 assert_eq!(back.step.as_deref(), Some("3/8"));
157 assert_eq!(back.phase, Phase::Running);
158 assert!(back.ts_ms > 0);
159 let _ = std::fs::remove_dir_all(&p.dir);
160 }
161
162 #[test]
163 fn stop_flag_is_sticky_until_cleared() {
164 let p = tmp();
165 assert!(!p.stop_requested());
166 p.request_stop();
167 assert!(p.stop_requested());
168 p.clear_stop();
169 assert!(!p.stop_requested());
170 let _ = std::fs::remove_dir_all(&p.dir);
171 }
172
173 #[test]
174 fn phase_serializes_snake_case() {
175 assert_eq!(
176 serde_json::to_string(&Phase::Stopped).unwrap(),
177 r#""stopped""#
178 );
179 }
180}