use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use super::{wake_prompt, ProviderSession, WakeError, WakeLetter};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct InboxLetter {
pub from_nick: String,
pub to: String,
pub event_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub room: Option<String>,
pub prompt: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct InboxEntry {
pub name: String,
pub letter: InboxLetter,
}
pub fn entry_stem(event_id: &str) -> String {
let stem: String = event_id
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
c
} else {
'_'
}
})
.collect();
if stem.trim_matches('_').is_empty() {
"letter".to_string()
} else {
stem
}
}
pub fn write_letter(
dir: &Path,
session: &ProviderSession,
letter: &WakeLetter<'_>,
) -> Result<PathBuf, WakeError> {
create_private_dir(dir)?;
let entry = InboxLetter {
from_nick: letter.from_nick.to_string(),
to: session.nick.clone(),
event_id: letter.event_id.to_string(),
room: letter.room.map(str::to_string),
prompt: wake_prompt(session, letter),
};
let bytes = serde_json::to_vec(&entry).map_err(|err| WakeError::Transport(err.to_string()))?;
let stem = entry_stem(letter.event_id);
let tmp = dir.join(format!(".{stem}.tmp"));
let path = dir.join(format!("{stem}.json"));
let mut file = private_file(&tmp)?;
file.write_all(&bytes)
.and_then(|()| file.sync_all())
.map_err(|err| WakeError::Transport(format!("inbox write: {}", err.kind())))?;
drop(file);
fs::rename(&tmp, &path)
.map_err(|err| WakeError::Transport(format!("inbox rename: {}", err.kind())))?;
Ok(path)
}
pub fn drain(dir: &Path) -> Vec<InboxEntry> {
let Ok(read) = fs::read_dir(dir) else {
return Vec::new();
};
let mut paths: Vec<(std::time::SystemTime, String, PathBuf)> = read
.filter_map(Result::ok)
.filter_map(|item| {
let name = item.file_name().to_string_lossy().into_owned();
if name.starts_with('.') || !name.ends_with(".json") {
return None;
}
let modified = item
.metadata()
.and_then(|meta| meta.modified())
.unwrap_or(std::time::UNIX_EPOCH);
Some((modified, name, item.path()))
})
.collect();
paths.sort();
let mut out = Vec::new();
for (_, name, path) in paths {
let claimed = dir.join(format!(".claim-{}-{name}", std::process::id()));
if fs::rename(&path, &claimed).is_err() {
continue;
}
let parsed = fs::read(&claimed)
.ok()
.and_then(|bytes| serde_json::from_slice::<InboxLetter>(&bytes).ok());
let _ = fs::remove_file(&claimed);
if let Some(letter) = parsed {
out.push(InboxEntry { name, letter });
}
}
out
}
pub fn pending(dir: &Path) -> usize {
fs::read_dir(dir)
.map(|read| {
read.filter_map(Result::ok)
.filter(|item| {
let name = item.file_name().to_string_lossy().into_owned();
!name.starts_with('.') && name.ends_with(".json")
})
.count()
})
.unwrap_or(0)
}
pub const LIVE_DIR: &str = ".live";
pub const HEARTBEAT: std::time::Duration = std::time::Duration::from_secs(2);
pub const HEARTBEAT_STALE: std::time::Duration = std::time::Duration::from_secs(15);
pub mod consumer {
pub const CLAUDE_CHANNEL: &str = "claude-channel";
pub const CLAUDE_REWAKE: &str = "claude-rewake";
pub fn stop(format: &str) -> String {
format!("stop-{format}")
}
}
fn live_path(dir: &Path, name: &str) -> PathBuf {
dir.join(LIVE_DIR).join(entry_stem(name))
}
pub struct Presence {
path: PathBuf,
}
impl Presence {
pub fn announce(dir: &Path, name: &str) -> Result<Self, WakeError> {
let path = live_path(dir, name);
if let Some(parent) = path.parent() {
create_private_dir(dir)?;
create_private_dir(parent)?;
}
let presence = Self { path };
presence.beat();
Ok(presence)
}
pub fn beat(&self) {
if let Ok(mut file) = private_file(&self.path) {
let _ = write!(file, "{}", std::process::id());
}
}
}
impl Drop for Presence {
fn drop(&mut self) {
let _ = fs::remove_file(&self.path);
}
}
pub fn is_live(dir: &Path, name: &str) -> bool {
fs::metadata(live_path(dir, name))
.and_then(|meta| meta.modified())
.ok()
.and_then(|modified| modified.elapsed().ok())
.is_some_and(|age| age <= HEARTBEAT_STALE)
}
pub fn arm(dir: &Path, name: &str) -> Result<(), WakeError> {
create_private_dir(dir)?;
create_private_dir(&dir.join(LIVE_DIR))?;
private_file(&live_path(dir, name)).map(|_| ())
}
pub fn disarm(dir: &Path, name: &str) {
let _ = fs::remove_file(live_path(dir, name));
}
pub fn is_armed(dir: &Path, name: &str) -> bool {
live_path(dir, name).exists()
}
fn create_private_dir(dir: &Path) -> Result<(), WakeError> {
fs::create_dir_all(dir)
.map_err(|err| WakeError::Unavailable(format!("inbox dir: {}", err.kind())))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = fs::set_permissions(dir, fs::Permissions::from_mode(0o700));
}
Ok(())
}
fn private_file(path: &Path) -> Result<fs::File, WakeError> {
let mut options = fs::OpenOptions::new();
options.write(true).create(true).truncate(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.mode(0o600);
}
options
.open(path)
.map_err(|err| WakeError::Transport(format!("inbox open: {}", err.kind())))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::provider::tests::{letter, session};
use crate::provider::{ProviderKind, SessionKind};
fn temp_dir(tag: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!(
"m4a-inbox-{tag}-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
let _ = fs::remove_dir_all(&dir);
dir
}
#[test]
fn write_then_drain_round_trips_and_empties() {
let dir = temp_dir("rt");
let s = session(SessionKind::local(ProviderKind::ClaudeCode));
let path = write_letter(&dir, &s, &letter("hello")).unwrap();
assert!(path.ends_with("_ev1.json"));
write_letter(&dir, &s, &letter("hello")).unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = fs::metadata(&path).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o600);
}
let drained = drain(&dir);
assert_eq!(drained.len(), 1);
assert_eq!(drained[0].letter.from_nick, "carol");
assert!(drained[0].letter.prompt.ends_with("hello"));
assert!(drain(&dir).is_empty());
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn presence_beats_and_clears_and_arm_sticks() {
let dir = temp_dir("live");
assert!(!is_live(&dir, consumer::CLAUDE_CHANNEL));
{
let _p = Presence::announce(&dir, consumer::CLAUDE_CHANNEL).unwrap();
assert!(is_live(&dir, consumer::CLAUDE_CHANNEL));
assert!(!is_live(&dir, consumer::CLAUDE_REWAKE));
}
assert!(!is_live(&dir, consumer::CLAUDE_CHANNEL));
let stop = consumer::stop("codex-stop");
assert!(!is_armed(&dir, &stop));
arm(&dir, &stop).unwrap();
assert!(is_armed(&dir, &stop));
assert_eq!(pending(&dir), 0);
assert!(drain(&dir).is_empty());
disarm(&dir, &stop);
assert!(!is_armed(&dir, &stop));
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn stems_are_file_safe() {
assert_eq!(entry_stem("$abc:def/../x"), "_abc_def____x");
assert_eq!(entry_stem("$"), "letter");
}
}