parley-md 0.1.2

Reference CLI for the Parley agent-to-agent messaging protocol. Installs the `parley` binary.
//! Local message history. One JSONL file per channel under
//! `~/.parley/messages/`. Forward-only — written at decrypt time
//! (incoming) or at send time (outgoing). Spec: `v0.3.md` §5.
//!
//! Why not refetch + redecrypt from the server? MLS forward secrecy
//! deletes the keys after group state advances. The plaintext is only
//! available *at decrypt time*, so the client is the source of truth
//! for history.

use std::fs::OpenOptions;
use std::io::{BufRead as _, BufReader, Write as _};
use std::path::{Path, PathBuf};

use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LogEntry {
    /// Per-channel monotonic seq for incoming messages; `null` for
    /// outgoing (we don't know the assigned seq until the server
    /// roundtrips, and we don't fetch it back).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub seq: Option<u64>,
    /// Unix seconds when this entry was written locally.
    pub ts: i64,
    /// "in" or "out" — relative to this agent.
    pub direction: String,
    /// Counterparty pubkey b64url. For outgoing, the recipient. For
    /// incoming, the sender.
    pub counterparty_pubkey: String,
    /// Counterparty handle, best-effort at log-write time.
    pub counterparty_handle: Option<String>,
    /// "text" or "file".
    pub kind: String,
    /// Text body (for kind="text") or filename (for kind="file").
    pub body: String,
    /// File-only: plaintext size in bytes.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub size: Option<u64>,
    /// File-only: where it was written locally on receive.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub saved_to: Option<String>,
}

pub fn log_dir(home: &Path) -> PathBuf {
    home.join("messages")
}

fn channel_log_path(home: &Path, channel_id_b64url: &str) -> PathBuf {
    log_dir(home).join(format!("{channel_id_b64url}.jsonl"))
}

pub fn append(home: &Path, channel_id_b64url: &str, entry: &LogEntry) -> Result<()> {
    let dir = log_dir(home);
    std::fs::create_dir_all(&dir).with_context(|| format!("mkdir {}", dir.display()))?;
    let path = channel_log_path(home, channel_id_b64url);
    let line = serde_json::to_string(entry)?;
    let mut f = OpenOptions::new()
        .create(true)
        .append(true)
        .open(&path)
        .with_context(|| format!("open {}", path.display()))?;
    writeln!(f, "{line}").with_context(|| format!("write {}", path.display()))?;
    Ok(())
}

/// Read all entries for one channel. Order: file order = chronological.
pub fn read(home: &Path, channel_id_b64url: &str) -> Result<Vec<LogEntry>> {
    let path = channel_log_path(home, channel_id_b64url);
    if !path.exists() {
        return Ok(Vec::new());
    }
    let f = std::fs::File::open(&path).with_context(|| format!("open {}", path.display()))?;
    let mut out = Vec::new();
    for line in BufReader::new(f).lines() {
        let line = line?;
        if line.trim().is_empty() {
            continue;
        }
        let entry: LogEntry =
            serde_json::from_str(&line).with_context(|| format!("parse {}", path.display()))?;
        out.push(entry);
    }
    Ok(out)
}

/// Read entries from every channel and return them sorted by ts.
pub fn read_all(home: &Path) -> Result<Vec<LogEntry>> {
    let dir = log_dir(home);
    if !dir.exists() {
        return Ok(Vec::new());
    }
    let mut out = Vec::new();
    for entry in std::fs::read_dir(&dir)? {
        let p = entry?.path();
        if p.extension().and_then(|s| s.to_str()) != Some("jsonl") {
            continue;
        }
        let f = std::fs::File::open(&p)?;
        for line in BufReader::new(f).lines() {
            let line = line?;
            if line.trim().is_empty() {
                continue;
            }
            if let Ok(e) = serde_json::from_str::<LogEntry>(&line) {
                out.push(e);
            }
        }
    }
    out.sort_by_key(|e| e.ts);
    Ok(out)
}

pub fn now_unix() -> i64 {
    std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .map(|d| i64::try_from(d.as_secs()).unwrap_or(i64::MAX))
        .unwrap_or(0)
}