use crate::config;
use crate::groups::{self, Groups};
use crate::schema::Message;
use anyhow::Result;
use std::collections::BTreeSet;
use std::path::PathBuf;
fn is_ulid(id: &str) -> bool {
id.len() == 26
&& id
.bytes()
.all(|b| b.is_ascii_digit() || (b.is_ascii_uppercase() && b != b'I' && b != b'L' && b != b'O' && b != b'U'))
}
#[derive(Default, Clone, Debug)]
pub struct ReadState {
pub floor: Option<String>,
pub opened: BTreeSet<String>,
}
impl ReadState {
pub fn is_read(&self, id: &str) -> bool {
self.floor.as_deref().is_some_and(|f| id <= f) || self.opened.contains(id)
}
}
fn path(hub_key: &str, role: &str) -> Result<PathBuf> {
let role = if role.is_empty() { "_" } else { role };
Ok(config::home()?.join(".confer").join("inbox").join(hub_key).join(format!("{role}.json")))
}
pub fn load_state(hub_key: &str, role: &str) -> ReadState {
let Ok(p) = path(hub_key, role) else {
return ReadState::default();
};
let Ok(txt) = std::fs::read_to_string(p) else {
return ReadState::default();
};
let Ok(v) = serde_json::from_str::<serde_json::Value>(&txt) else {
return ReadState::default();
};
let floor = v
.get("floor")
.and_then(|x| x.as_str())
.or_else(|| v.get("read").and_then(|x| x.as_str())) .filter(|s| is_ulid(s))
.map(String::from);
let opened = v
.get("opened")
.and_then(|x| x.as_array())
.map(|a| a.iter().filter_map(|x| x.as_str()).filter(|s| is_ulid(s)).map(String::from).collect())
.unwrap_or_default();
ReadState { floor, opened }
}
fn save(hub_key: &str, role: &str, st: &ReadState) -> Result<()> {
let p = path(hub_key, role)?;
if let Some(parent) = p.parent() {
std::fs::create_dir_all(parent)?;
}
let opened: Vec<&String> = st.opened.iter().collect();
let mut obj = serde_json::Map::new();
if let Some(f) = &st.floor {
obj.insert("floor".into(), serde_json::Value::String(f.clone()));
}
obj.insert("opened".into(), serde_json::json!(opened));
let body = serde_json::Value::Object(obj).to_string();
let tmp = p.with_extension(format!("json.tmp.{}", std::process::id()));
std::fs::write(&tmp, body.as_bytes())?;
std::fs::rename(&tmp, &p)?;
Ok(())
}
pub fn mark_read(hub_key: &str, role: &str, id: &str) -> Result<()> {
if role.is_empty() || !is_ulid(id) {
return Ok(());
}
let mut st = load_state(hub_key, role);
if st.is_read(id) {
return Ok(());
}
st.opened.insert(id.to_string());
save(hub_key, role, &st)
}
pub fn mark_all_read(hub_key: &str, role: &str, latest: &str) -> Result<()> {
if role.is_empty() || !is_ulid(latest) {
return Ok(());
}
let mut st = load_state(hub_key, role);
if st.floor.as_deref().is_none_or(|f| f < latest) {
st.floor = Some(latest.to_string());
}
st.opened.retain(|id| id.as_str() > latest);
save(hub_key, role, &st)
}
pub fn compact_and_save(hub_key: &str, role: &str, candidates: &[String]) -> Result<()> {
let st = load_state(hub_key, role);
let live: BTreeSet<&str> = candidates.iter().map(String::as_str).collect();
let pruned: BTreeSet<String> = st
.opened
.iter()
.filter(|id| {
st.floor.as_deref().is_none_or(|f| id.as_str() > f) && live.contains(id.as_str())
})
.cloned()
.collect();
if pruned != st.opened {
save(hub_key, role, &ReadState { floor: st.floor.clone(), opened: pruned })?;
}
Ok(())
}
pub fn unread_for_me<'a>(
msgs: &'a [Message],
me: &str,
groups: &Groups,
st: &ReadState,
) -> Vec<&'a Message> {
let mut out: Vec<&Message> = msgs
.iter()
.filter(|m| {
m.front.from != me
&& groups::directly_addressed(m, me, groups)
&& !st.is_read(&m.front.id)
})
.collect();
out.sort_by(|a, b| a.front.id.cmp(&b.front.id));
out
}
pub fn direct_ids_for_me(msgs: &[Message], me: &str, groups: &Groups) -> Vec<String> {
let mut ids: Vec<String> = msgs
.iter()
.filter(|m| m.front.from != me && groups::directly_addressed(m, me, groups))
.map(|m| m.front.id.clone())
.filter(|id| is_ulid(id))
.collect();
ids.sort();
ids
}
pub fn latest_id(msgs: &[Message]) -> Option<String> {
msgs.iter().map(|m| m.front.id.clone()).filter(|id| is_ulid(id)).max()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::schema::Frontmatter;
fn msg(id: &str, from: &str, to: &[&str]) -> Message {
Message {
front: Frontmatter {
id: id.into(),
from: from.into(),
msg_type: "note".into(),
ts: "2026-07-11T00:00:00Z".into(),
host: None,
to: to.iter().map(|s| s.to_string()).collect(),
cc: vec![],
priority: None,
topic: None,
reply_to: None,
of: None,
supersedes: None,
resolution: None,
defer: false,
via: None,
src: None,
summary: Some("s".into()),
refs: vec![],
},
body: String::new(),
}
}
const A: &str = "01AAAAAAAAAAAAAAAAAAAAAAAA";
const B: &str = "01BBBBBBBBBBBBBBBBBBBBBBBB";
const C: &str = "01CCCCCCCCCCCCCCCCCCCCCCCC";
const D: &str = "01DDDDDDDDDDDDDDDDDDDDDDDD";
#[test]
fn opening_one_does_not_read_the_others_holes_are_preserved() {
let g = Groups::new();
let msgs = vec![
msg(A, "bob", &["alice"]),
msg(B, "bob", &["alice"]),
msg(C, "carol", &["alice"]),
];
let st = ReadState::default();
assert_eq!(unread_for_me(&msgs, "alice", &g, &st).len(), 3);
let mut st = ReadState::default();
st.opened.insert(C.into());
let u: Vec<_> = unread_for_me(&msgs, "alice", &g, &st)
.iter()
.map(|m| m.front.id.as_str())
.collect();
assert_eq!(u, vec![A, B], "opening the newest must NOT read the older deferred mail");
}
#[test]
fn a_non_ulid_id_cannot_poison_the_read_state() {
let g = Groups::new();
let msgs = vec![msg("not-a-ulid", "bob", &["alice"]), msg(A, "bob", &["alice"])];
let mut st = ReadState::default();
st.floor = Some("zzzzzzzzzzzzzzzzzzzzzzzzzz".into()); assert!(!st.is_read(A) || st.floor.as_deref() == Some("zzzzzzzzzzzzzzzzzzzzzzzzzz"));
let real = unread_for_me(&msgs, "alice", &g, &ReadState::default());
assert_eq!(real.len(), 2, "both messages are candidates regardless of id shape");
}
#[test]
fn a_read_message_between_two_others_does_not_sweep_the_unread_one() {
let mut st = ReadState::default();
st.opened.insert(A.into());
st.opened.insert(C.into());
assert!(st.is_read(A) && st.is_read(C));
assert!(
!st.is_read(B),
"a not-yet-read message between two read ones must stay unread — no floor sweep"
);
assert_eq!(st.floor, None);
}
}