use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, BTreeSet};
use std::fs;
use std::io;
use std::path::Path;
use std::time::{Duration, Instant};
#[derive(Debug, Default, Serialize, Deserialize)]
pub struct LocalState {
#[serde(default)]
pub read: BTreeSet<String>,
#[serde(default)]
pub closed: BTreeMap<String, String>,
#[serde(default)]
pub seq: u64,
#[serde(default)]
pub witness: BTreeMap<String, u64>,
}
pub struct Lock(fs::File);
#[cfg(unix)]
mod flock {
unsafe extern "C" {
fn flock(fd: i32, operation: i32) -> i32;
}
pub const EX: i32 = 2;
pub const NB: i32 = 4;
pub const UN: i32 = 8;
pub fn try_lock(fd: i32) -> bool {
unsafe { flock(fd, EX | NB) == 0 }
}
pub fn unlock(fd: i32) {
unsafe { flock(fd, UN) };
}
}
#[cfg(unix)]
impl Drop for Lock {
fn drop(&mut self) {
use std::os::unix::io::AsRawFd;
flock::unlock(self.0.as_raw_fd());
}
}
#[cfg(unix)]
pub fn take_lock(target: &Path) -> io::Result<Lock> {
use std::os::unix::io::AsRawFd;
let lp = target.with_extension("json.lock");
if let Some(d) = lp.parent() {
fs::create_dir_all(d)?;
}
let f = fs::OpenOptions::new()
.create(true)
.write(true)
.truncate(false)
.open(&lp)?;
let deadline = Instant::now() + Duration::from_secs(30);
loop {
if flock::try_lock(f.as_raw_fd()) {
return Ok(Lock(f));
}
if Instant::now() > deadline {
return Err(io::Error::new(
io::ErrorKind::TimedOut,
format!(
"{} stayed locked; another gage process is stuck",
lp.display()
),
));
}
std::thread::sleep(Duration::from_millis(20));
}
}
#[cfg(not(unix))]
pub fn take_lock(_target: &Path) -> io::Result<Lock> {
Err(io::Error::new(
io::ErrorKind::Unsupported,
"state locking needs a unix host",
))
}
impl LocalState {
fn path() -> std::path::PathBuf {
crate::identity::home().join("state.json")
}
pub fn load() -> Self {
std::fs::read_to_string(Self::path())
.ok()
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_default()
}
pub fn mutate<F>(f: F) -> std::io::Result<Self>
where
F: FnOnce(&mut Self) -> bool,
{
let p = Self::path();
let _guard = take_lock(&p)?;
let mut cur = Self::load();
if f(&mut cur) {
let tmp = p.with_extension(format!("json.tmp.{}", std::process::id()));
std::fs::write(&tmp, serde_json::to_vec_pretty(&cur)?)?;
std::fs::rename(&tmp, &p)?;
}
Ok(cur)
}
pub fn is_read(&self, id: &str) -> bool {
self.read.contains(id)
}
pub fn mark_read(&mut self, id: &str) -> std::io::Result<()> {
let owned = id.to_string();
Self::mutate(move |s| s.read.insert(owned))?;
self.read.insert(id.to_string());
Ok(())
}
pub fn witnessed(&self, witness: &str) -> Option<u64> {
self.witness.get(witness).copied()
}
pub fn observe(witnesses: &[String]) -> std::io::Result<Self> {
Self::mutate(|s| {
let next = s.seq + 1;
let mut added = false;
for w in witnesses {
if !s.witness.contains_key(w) {
s.witness.insert(w.clone(), next);
added = true;
}
}
if added {
s.seq = next;
}
added
})
}
pub fn observe_and_cut<F>(scan: F) -> std::io::Result<u64>
where
F: FnOnce(&dyn Fn(&str) -> bool) -> Vec<String>,
{
let mut cut = 0;
Self::mutate(|s| {
let batch = s.seq + 1;
cut = s.seq + 2;
let already: BTreeSet<String> = s.witness.keys().cloned().collect();
for w in scan(&|x| already.contains(x)) {
s.witness.entry(w).or_insert(batch);
}
s.seq = cut;
true
})?;
Ok(cut)
}
pub fn close(&mut self, thread: &str) -> std::io::Result<()> {
let owned = thread.to_string();
Self::mutate(move |s| s.closed.insert(owned, "closed".into()).is_none())?;
self.closed.insert(thread.to_string(), "closed".into());
Ok(())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ThreadState {
AwaitingYou,
AwaitingThem,
Settled,
Closed,
}
impl ThreadState {
pub fn label(&self) -> &'static str {
match self {
ThreadState::AwaitingYou => "awaiting YOU",
ThreadState::AwaitingThem => "awaiting them",
ThreadState::Settled => "settled",
ThreadState::Closed => "closed",
}
}
}
pub struct Thread {
pub id: String,
pub subject: String,
pub messages: Vec<crate::manifest::Manifest>,
pub unread: usize,
pub state: ThreadState,
pub others: Vec<String>,
pub unverified: Vec<String>,
}
fn thread_claim(m: &crate::manifest::Manifest, held: &BTreeMap<String, String>) -> (String, bool) {
let Some(claim) = m.thread.as_deref() else {
return (m.id.clone(), true);
};
if claim == m.id {
return (m.id.clone(), true);
}
let Some(parent) = m.in_reply_to.as_deref() else {
return (m.id.clone(), false);
};
match held.get(parent) {
Some(pt) if pt == claim => (claim.to_string(), true),
Some(_) => (m.id.clone(), false),
None => (claim.to_string(), false),
}
}
pub fn threads(
manifests: Vec<crate::manifest::Manifest>,
st: &LocalState,
mine: &str,
) -> Vec<Thread> {
let held: BTreeMap<String, String> = manifests
.iter()
.map(|m| (m.id.clone(), m.thread_id().to_string()))
.collect();
let mut by: BTreeMap<String, Vec<crate::manifest::Manifest>> = BTreeMap::new();
let mut unverified: BTreeMap<String, Vec<String>> = BTreeMap::new();
for m in manifests {
let (tid, ok) = thread_claim(&m, &held);
if !ok {
unverified
.entry(tid.clone())
.or_default()
.push(m.id.clone());
}
by.entry(tid).or_default().push(m);
}
let mut out: Vec<Thread> = by
.into_iter()
.map(|(id, mut msgs)| {
msgs.sort_by(|a, b| {
(a.id != id, &a.created, &a.id).cmp(&(b.id != id, &b.created, &b.id))
});
let unread = msgs
.iter()
.filter(|m| m.from.key != mine && !st.is_read(&m.id))
.count();
let subject = msgs
.iter()
.find(|m| m.id == id)
.or_else(|| msgs.first())
.map(|m| m.subject.clone())
.unwrap_or_else(|| "(no subject)".into());
let state = if st.closed.contains_key(&id) {
ThreadState::Closed
} else {
match msgs.last() {
Some(last) if last.msg_type.expects_reply() => {
if last.from.key == mine {
ThreadState::AwaitingThem
} else {
ThreadState::AwaitingYou
}
}
_ => ThreadState::Settled,
}
};
let mut others: Vec<String> = Vec::new();
for m in &msgs {
if m.from.key != mine && !others.contains(&m.from.key) {
others.push(m.from.key.clone());
}
}
let unverified = unverified.remove(&id).unwrap_or_default();
Thread {
id,
subject,
messages: msgs,
unread,
state,
others,
unverified,
}
})
.collect();
out.sort_by(|a, b| {
let an = a
.messages
.last()
.map(|m| m.created.clone())
.unwrap_or_default();
let bn = b
.messages
.last()
.map(|m| m.created.clone())
.unwrap_or_default();
bn.cmp(&an)
});
out
}