1use std::fs;
10use std::io::Write;
11use std::path::{Path, PathBuf};
12
13use serde::{Deserialize, Serialize};
14
15use super::{wake_prompt, ProviderSession, WakeError, WakeLetter};
16
17#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
19pub struct InboxLetter {
20 pub from_nick: String,
22 pub to: String,
24 pub event_id: String,
26 #[serde(default, skip_serializing_if = "Option::is_none")]
28 pub room: Option<String>,
29 pub prompt: String,
31}
32
33#[derive(Debug, Clone, PartialEq, Eq)]
35pub struct InboxEntry {
36 pub name: String,
38 pub letter: InboxLetter,
40}
41
42pub fn entry_stem(event_id: &str) -> String {
44 let stem: String = event_id
45 .chars()
46 .map(|c| {
47 if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
48 c
49 } else {
50 '_'
51 }
52 })
53 .collect();
54 if stem.trim_matches('_').is_empty() {
55 "letter".to_string()
56 } else {
57 stem
58 }
59}
60
61pub fn write_letter(
65 dir: &Path,
66 session: &ProviderSession,
67 letter: &WakeLetter<'_>,
68) -> Result<PathBuf, WakeError> {
69 create_private_dir(dir)?;
70 let entry = InboxLetter {
71 from_nick: letter.from_nick.to_string(),
72 to: session.nick.clone(),
73 event_id: letter.event_id.to_string(),
74 room: letter.room.map(str::to_string),
75 prompt: wake_prompt(session, letter),
76 };
77 let bytes = serde_json::to_vec(&entry).map_err(|err| WakeError::Transport(err.to_string()))?;
78 let stem = entry_stem(letter.event_id);
79 let tmp = dir.join(format!(".{stem}.tmp"));
80 let path = dir.join(format!("{stem}.json"));
81 let mut file = private_file(&tmp)?;
82 file.write_all(&bytes)
83 .and_then(|()| file.sync_all())
84 .map_err(|err| WakeError::Transport(format!("inbox write: {}", err.kind())))?;
85 drop(file);
86 fs::rename(&tmp, &path)
87 .map_err(|err| WakeError::Transport(format!("inbox rename: {}", err.kind())))?;
88 Ok(path)
89}
90
91pub fn drain(dir: &Path) -> Vec<InboxEntry> {
94 let Ok(read) = fs::read_dir(dir) else {
95 return Vec::new();
96 };
97 let mut paths: Vec<(std::time::SystemTime, String, PathBuf)> = read
98 .filter_map(Result::ok)
99 .filter_map(|item| {
100 let name = item.file_name().to_string_lossy().into_owned();
101 if name.starts_with('.') || !name.ends_with(".json") {
102 return None;
103 }
104 let modified = item
105 .metadata()
106 .and_then(|meta| meta.modified())
107 .unwrap_or(std::time::UNIX_EPOCH);
108 Some((modified, name, item.path()))
109 })
110 .collect();
111 paths.sort();
112 let mut out = Vec::new();
113 for (_, name, path) in paths {
114 let claimed = dir.join(format!(".claim-{}-{name}", std::process::id()));
117 if fs::rename(&path, &claimed).is_err() {
118 continue;
119 }
120 let parsed = fs::read(&claimed)
121 .ok()
122 .and_then(|bytes| serde_json::from_slice::<InboxLetter>(&bytes).ok());
123 let _ = fs::remove_file(&claimed);
124 if let Some(letter) = parsed {
125 out.push(InboxEntry { name, letter });
126 }
127 }
128 out
129}
130
131pub fn pending(dir: &Path) -> usize {
133 fs::read_dir(dir)
134 .map(|read| {
135 read.filter_map(Result::ok)
136 .filter(|item| {
137 let name = item.file_name().to_string_lossy().into_owned();
138 !name.starts_with('.') && name.ends_with(".json")
139 })
140 .count()
141 })
142 .unwrap_or(0)
143}
144
145pub const LIVE_DIR: &str = ".live";
147
148pub const HEARTBEAT: std::time::Duration = std::time::Duration::from_secs(2);
151
152pub const HEARTBEAT_STALE: std::time::Duration = std::time::Duration::from_secs(15);
154
155pub mod consumer {
157 pub const CLAUDE_CHANNEL: &str = "claude-channel";
159 pub const CLAUDE_REWAKE: &str = "claude-rewake";
161 pub fn stop(format: &str) -> String {
163 format!("stop-{format}")
164 }
165}
166
167fn live_path(dir: &Path, name: &str) -> PathBuf {
168 dir.join(LIVE_DIR).join(entry_stem(name))
169}
170
171pub struct Presence {
173 path: PathBuf,
174}
175
176impl Presence {
177 pub fn announce(dir: &Path, name: &str) -> Result<Self, WakeError> {
179 let path = live_path(dir, name);
180 if let Some(parent) = path.parent() {
181 create_private_dir(dir)?;
182 create_private_dir(parent)?;
183 }
184 let presence = Self { path };
185 presence.beat();
186 Ok(presence)
187 }
188
189 pub fn beat(&self) {
191 if let Ok(mut file) = private_file(&self.path) {
192 let _ = write!(file, "{}", std::process::id());
193 }
194 }
195}
196
197impl Drop for Presence {
198 fn drop(&mut self) {
199 let _ = fs::remove_file(&self.path);
200 }
201}
202
203pub fn is_live(dir: &Path, name: &str) -> bool {
205 fs::metadata(live_path(dir, name))
206 .and_then(|meta| meta.modified())
207 .ok()
208 .and_then(|modified| modified.elapsed().ok())
209 .is_some_and(|age| age <= HEARTBEAT_STALE)
210}
211
212pub fn arm(dir: &Path, name: &str) -> Result<(), WakeError> {
215 create_private_dir(dir)?;
216 create_private_dir(&dir.join(LIVE_DIR))?;
217 private_file(&live_path(dir, name)).map(|_| ())
218}
219
220pub fn disarm(dir: &Path, name: &str) {
222 let _ = fs::remove_file(live_path(dir, name));
223}
224
225pub fn is_armed(dir: &Path, name: &str) -> bool {
227 live_path(dir, name).exists()
228}
229
230fn create_private_dir(dir: &Path) -> Result<(), WakeError> {
231 fs::create_dir_all(dir)
232 .map_err(|err| WakeError::Unavailable(format!("inbox dir: {}", err.kind())))?;
233 #[cfg(unix)]
234 {
235 use std::os::unix::fs::PermissionsExt;
236 let _ = fs::set_permissions(dir, fs::Permissions::from_mode(0o700));
237 }
238 Ok(())
239}
240
241fn private_file(path: &Path) -> Result<fs::File, WakeError> {
242 let mut options = fs::OpenOptions::new();
243 options.write(true).create(true).truncate(true);
244 #[cfg(unix)]
245 {
246 use std::os::unix::fs::OpenOptionsExt;
247 options.mode(0o600);
248 }
249 options
250 .open(path)
251 .map_err(|err| WakeError::Transport(format!("inbox open: {}", err.kind())))
252}
253
254#[cfg(test)]
255mod tests {
256 use super::*;
257 use crate::tests::{letter, session};
258 use crate::{ProviderKind, SessionKind};
259
260 fn temp_dir(tag: &str) -> PathBuf {
261 let dir = std::env::temp_dir().join(format!(
262 "m4a-inbox-{tag}-{}-{}",
263 std::process::id(),
264 std::time::SystemTime::now()
265 .duration_since(std::time::UNIX_EPOCH)
266 .unwrap()
267 .as_nanos()
268 ));
269 let _ = fs::remove_dir_all(&dir);
270 dir
271 }
272
273 #[test]
274 fn write_then_drain_round_trips_and_empties() {
275 let dir = temp_dir("rt");
276 let s = session(SessionKind::local(ProviderKind::ClaudeCode));
277 let path = write_letter(&dir, &s, &letter("hello")).unwrap();
278 assert!(path.ends_with("_ev1.json"));
279 write_letter(&dir, &s, &letter("hello")).unwrap();
281 #[cfg(unix)]
282 {
283 use std::os::unix::fs::PermissionsExt;
284 let mode = fs::metadata(&path).unwrap().permissions().mode() & 0o777;
285 assert_eq!(mode, 0o600);
286 }
287 let drained = drain(&dir);
288 assert_eq!(drained.len(), 1);
289 assert_eq!(drained[0].letter.from_nick, "carol");
290 assert!(drained[0].letter.prompt.ends_with("hello"));
291 assert!(drain(&dir).is_empty());
292 let _ = fs::remove_dir_all(&dir);
293 }
294
295 #[test]
296 fn presence_beats_and_clears_and_arm_sticks() {
297 let dir = temp_dir("live");
298 assert!(!is_live(&dir, consumer::CLAUDE_CHANNEL));
299 {
300 let _p = Presence::announce(&dir, consumer::CLAUDE_CHANNEL).unwrap();
301 assert!(is_live(&dir, consumer::CLAUDE_CHANNEL));
302 assert!(!is_live(&dir, consumer::CLAUDE_REWAKE));
303 }
304 assert!(!is_live(&dir, consumer::CLAUDE_CHANNEL));
305 let stop = consumer::stop("codex-stop");
306 assert!(!is_armed(&dir, &stop));
307 arm(&dir, &stop).unwrap();
308 assert!(is_armed(&dir, &stop));
309 assert_eq!(pending(&dir), 0);
311 assert!(drain(&dir).is_empty());
312 disarm(&dir, &stop);
313 assert!(!is_armed(&dir, &stop));
314 let _ = fs::remove_dir_all(&dir);
315 }
316
317 #[test]
318 fn stems_are_file_safe() {
319 assert_eq!(entry_stem("$abc:def/../x"), "_abc_def____x");
320 assert_eq!(entry_stem("$"), "letter");
321 }
322}