use std::fs;
use std::path::{Path, PathBuf};
use uuid::Uuid;
use crate::cron::CronSchedule;
use crate::envelope::{Envelope, DEFAULT_SENDER};
use crate::error::{Error, Result};
use crate::fs_text::{read_text, write_text, write_text_atomic};
use crate::home::UnifierHome;
use crate::paths::{cron_dir, key_path, mailbox_dir, message_path, parse_message_id};
use crate::scope::{path_within_root, resolve_under_root};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Message {
pub id: Uuid,
pub path: PathBuf,
pub body: String,
}
pub fn put_key(home: &UnifierHome, key: &str, value: &str) -> Result<()> {
validate_key(key)?;
write_text_atomic(&key_path(home, key), value)
}
pub fn get_key(home: &UnifierHome, key: &str) -> Result<Option<String>> {
validate_key(key)?;
let path = key_path(home, key);
if path.is_file() {
Ok(Some(read_text(&path)?))
} else {
Ok(None)
}
}
pub fn delete_key(home: &UnifierHome, key: &str) -> Result<bool> {
validate_key(key)?;
let path = key_path(home, key);
if path.is_file() {
fs::remove_file(path)?;
Ok(true)
} else {
Ok(false)
}
}
pub fn send(home: &UnifierHome, recipient: &str, body: &str) -> Result<Uuid> {
send_from(home, DEFAULT_SENDER, recipient, body)
}
pub fn send_from(home: &UnifierHome, from: &str, recipient: &str, body: &str) -> Result<Uuid> {
validate_segment(from, "from")?;
validate_segment(recipient, "recipient")?;
let env = Envelope::new(from, recipient, Envelope::parse_payload(body));
let dir = mailbox_dir(home, recipient);
write_text(&message_path(&dir, &env.id), &env.to_json()?)?;
Ok(env.id)
}
pub fn post_cron(home: &UnifierHome, schedule: &str, body: &str) -> Result<Uuid> {
CronSchedule::parse(schedule)?;
let dir = cron_dir(home, schedule);
drop_message(&dir, body)
}
fn drop_message(dir: &Path, body: &str) -> Result<Uuid> {
let id = Uuid::new_v4();
write_text(&message_path(dir, &id), body)?;
Ok(id)
}
pub fn poll_mailbox(home: &UnifierHome, recipient: &str) -> Result<Vec<Message>> {
validate_segment(recipient, "recipient")?;
collect_messages(&mailbox_dir(home, recipient))
}
pub fn poll_cron(home: &UnifierHome) -> Result<Vec<Message>> {
let cron_root = home.path().join(crate::constants::CRON);
if !cron_root.is_dir() {
return Ok(Vec::new());
}
let mut out = Vec::new();
for entry in fs::read_dir(&cron_root)? {
let entry = entry?;
if !entry.file_type()?.is_dir() {
continue;
}
let name = entry.file_name();
let name = name.to_string_lossy();
let schedule = match CronSchedule::parse(&name) {
Ok(s) => s,
Err(_) => continue,
};
if !schedule.matches_now() {
continue;
}
out.extend(collect_messages(&entry.path())?);
}
out.sort_by(|a, b| a.path.cmp(&b.path));
Ok(out)
}
pub fn list_dir(home: &UnifierHome, subpath: &str) -> Result<Vec<Message>> {
let dir = resolve_under_root(home.path(), subpath)?;
if !dir.is_dir() {
return Ok(Vec::new());
}
collect_messages(&dir)
}
pub fn ack(home: &UnifierHome, id_or_path: &str) -> Result<bool> {
let path = resolve_message_path(home, id_or_path)?;
if path.is_file() {
fs::remove_file(&path)?;
Ok(true)
} else {
Ok(false)
}
}
fn collect_messages(dir: &Path) -> Result<Vec<Message>> {
if !dir.is_dir() {
return Ok(Vec::new());
}
let mut messages = Vec::new();
for entry in fs::read_dir(dir)? {
let entry = entry?;
if !entry.file_type()?.is_file() {
continue;
}
let name = entry.file_name();
let name = name.to_string_lossy();
let Some(id) = parse_message_id(&name) else {
continue;
};
let path = entry.path();
messages.push(Message {
id,
path: path.clone(),
body: read_text(&path)?,
});
}
messages.sort_by_key(|m| m.id);
Ok(messages)
}
fn resolve_message_path(home: &UnifierHome, id_or_path: &str) -> Result<PathBuf> {
if id_or_path.contains('/') {
let candidate = resolve_under_root(home.path(), id_or_path)?;
if !path_within_root(home.path(), &candidate)? {
return Err(Error::msg("path escapes store root"));
}
return Ok(candidate);
}
if PathBuf::from(id_or_path).is_absolute() {
let candidate = PathBuf::from(id_or_path);
if !path_within_root(home.path(), &candidate)? {
return Err(Error::msg("path escapes store root"));
}
return Ok(candidate);
}
let id = Uuid::parse_str(id_or_path)
.map_err(|_| Error::msg(format!("invalid message id or path: {id_or_path}")))?;
find_message_by_id(home, &id)
}
fn find_message_by_id(home: &UnifierHome, id: &Uuid) -> Result<PathBuf> {
let filename = format!("{}.txt", id.hyphenated());
for sub in [crate::constants::CRON, crate::constants::MAILBOX] {
let base = home.path().join(sub);
if !base.is_dir() {
continue;
}
for entry in fs::read_dir(&base)? {
let entry = entry?;
if entry.file_type()?.is_dir() {
let candidate = entry.path().join(&filename);
if candidate.is_file() {
return Ok(candidate);
}
}
}
}
Err(Error::msg(format!("message not found: {id}")))
}
fn validate_key(key: &str) -> Result<()> {
if key.is_empty() {
return Err(Error::msg("key must not be empty"));
}
if key.contains("..") {
return Err(Error::msg("key must not contain '..'"));
}
Ok(())
}
fn validate_segment(segment: &str, label: &str) -> Result<()> {
if segment.is_empty() || segment.contains('/') || segment.contains("..") {
return Err(Error::msg(format!("invalid {label}: {segment}")));
}
Ok(())
}