unifier-cli 0.2.0

Filesystem postbox for inter-process communication via a Unix tree
Documentation
//! Postbox operations: keys, mailbox messages, and cron drops.

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};

/// A message picked up from the filesystem postbox.
#[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(())
}