use crate::groups::{self, Groups};
use crate::projection::id_ref_matches;
use crate::schema::Message;
use crate::{
config, format_line, framed_body, gitcmd, id_matches, projection, render_targets,
resolve_unique, roster, schema, short_id, store, superseded_set, tiers, to_json, truncate,
verify, warn_safety,
};
use anyhow::{anyhow, Result};
use std::collections::{BTreeSet, HashMap, HashSet};
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";
#[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);
}
}
pub(crate) fn cmd_show(id: String) -> Result<()> {
let root = config::repo_root()?;
let msgs = store::all_messages(&root)?;
let hits: Vec<&Message> = msgs
.iter()
.filter(|m| id_matches(&m.front.id, &id))
.collect();
match hits.as_slice() {
[] => Err(anyhow!("no message with id (or prefix) '{id}'")),
[m] => {
let roster = roster::load(&root);
let hub_key = config::hub_key(&root);
let t = verify::status(&root, &hub_key, &roster, &mut verify::Cache::default(), m);
let who = roster::display(&roster, &m.front.from);
let body = schema::sanitize_term(&m.to_markdown()?, true);
println!("{}", framed_body(&body, m, who, &t, tiers::get(&hub_key)));
if let Some(newer) = msgs.iter().find(|x| {
x.front
.supersedes
.as_deref()
.is_some_and(|s| id_matches(&m.front.id, s))
}) {
println!(
"> ⚠ superseded by {} — see the newer message",
short_id(&newer.front.id)
);
}
if let Some(old) = &m.front.supersedes {
println!("> (this supersedes {})", short_id(old));
}
let topic = m.front.topic.as_deref().unwrap_or("general");
let path = store::message_path(&root, topic, &m.front.id, &m.front.from, &m.front.ts);
let rel = path
.strip_prefix(&root)
.unwrap_or(&path)
.to_string_lossy()
.to_string();
if let Ok(o) = gitcmd::output(&root, &["log", "--format=%cI", "--", &rel]) {
let stdout = String::from_utf8_lossy(&o.stdout);
let times: Vec<&str> = stdout.lines().collect();
if times.len() > 1 {
println!(
"> ✎ edited in place: created {}, last edited {} ({} commits)",
times.last().copied().unwrap_or("?"),
times.first().copied().unwrap_or("?"),
times.len()
);
}
}
if let Ok(me) = config::resolve_role(None, &root) {
let _ = mark_read(&config::hub_key(&root), &me, &m.front.id);
}
Ok(())
}
many => {
eprintln!("ambiguous prefix '{id}' matches {} messages:", many.len());
for m in many {
eprintln!(" {} — {}", m.front.id, m.summary_line());
}
Err(anyhow!("specify a longer id"))
}
}
}
pub(crate) fn cmd_inbox(role: Option<String>, peek: bool) -> Result<()> {
let root = config::repo_root()?;
let me = config::resolve_role(role, &root).unwrap_or_default();
if me.is_empty() {
return Err(anyhow!(
"no role — set one to have an inbox (join, or --role <you>)"
));
}
if let Err(e) = gitcmd::integrate(&root) {
warn_safety(format!("hub sync failed ({e}); showing local state"));
}
let hub = config::hub_key(&root);
let roster = roster::load(&root);
let grps = groups::load(&root);
let msgs = store::all_messages(&root)?;
let _ = compact_and_save(&hub, &me, &direct_ids_for_me(&msgs, &me, &grps));
let st = load_state(&hub, &me);
let unread = unread_for_me(&msgs, &me, &grps, &st);
if unread.is_empty() {
println!("inbox clear — no unread mail addressed to {me}.");
return Ok(());
}
println!("── {} unread for {me} ──\n", unread.len());
let mut vc = verify::Cache::default();
for m in &unread {
let t = verify::status(&root, &hub, &roster, &mut vc, m);
if peek {
println!(
" {} · from {} — {}",
short_id(&m.front.id),
schema::sanitize_term(roster::display(&roster, &m.front.from), false),
truncate(&m.summary_line(), 72)
);
let _ = t;
} else {
let who = roster::display(&roster, &m.front.from);
let body = schema::sanitize_term(&m.to_markdown()?, true);
println!("{}", framed_body(&body, m, who, &t, tiers::get(&hub)));
println!();
}
}
println!(
"(nothing marked read — `confer show <id>` opens one, `confer ack <id>` dismisses one, `confer ack` clears all)"
);
Ok(())
}
pub(crate) fn cmd_ack(id: Option<String>, role: Option<String>) -> Result<()> {
let root = config::repo_root()?;
let me = config::resolve_role(role, &root).unwrap_or_default();
if me.is_empty() {
return Err(anyhow!(
"no role — set one to ack mail (join, or --role <you>)"
));
}
let hub = config::hub_key(&root);
let msgs = store::all_messages(&root)?;
match id {
Some(raw) => {
let target = resolve_unique(&msgs, &raw)?.to_string();
mark_read(&hub, &me, &target)?;
println!("acked {} — marked read (others left unread).", short_id(&target));
}
None => match latest_id(&msgs) {
Some(latest) => {
mark_all_read(&hub, &me, &latest)?;
println!("acked all — inbox clear for {me}.");
}
None => println!("no messages to ack."),
},
}
Ok(())
}
pub(crate) fn cmd_requests(
open_only: bool,
mine: bool,
role: Option<String>,
json: bool,
backlog: bool,
blocked_only: bool,
) -> Result<()> {
let root = config::repo_root()?;
let me = config::resolve_role(role, &root).unwrap_or_default();
match gitcmd::integrate(&root) {
Ok(r) if !r.fetched => {
eprintln!("confer: couldn't refresh from the hub — the board below may be stale")
}
Err(e) => warn_safety(format!("hub sync failed ({e}); showing local state")),
_ => {}
}
let roster = roster::load(&root);
let msgs = store::all_messages(&root)?;
let board = projection::Board::fold(&msgs, chrono::Utc::now());
let by_id: HashMap<&str, &Message> = msgs.iter().map(|m| (m.front.id.as_str(), m)).collect();
for row in &board.rows {
if backlog {
if !row.is_backlog() {
continue;
}
} else if blocked_only {
if row.status != "BLOCKED" {
continue;
}
} else if open_only && !row.is_active() {
continue;
}
if mine && row.from != me && !row.to.iter().any(|t| t == me.as_str()) {
continue;
}
if json {
let m = by_id[row.id.as_str()];
let mut v = serde_json::to_value(&m.front)?;
if let serde_json::Value::Object(map) = &mut v {
map.insert(
"status".into(),
serde_json::Value::String(row.status.into()),
);
map.insert("claimants".into(), serde_json::json!(row.claimants));
map.insert("age_secs".into(), serde_json::json!(row.age_secs));
if let Some(res) = &row.resolution {
map.insert("resolution".into(), serde_json::json!(res));
}
}
println!("{}", serde_json::to_string(&v)?);
} else {
let owner = match row.claimants.as_slice() {
[] => String::new(),
[one] => format!(" [by {one}]"),
[first, rest @ ..] => format!(" [by {first}; ⚠ contested: {}]", rest.join(",")),
};
let status_disp = match &row.resolution {
Some(x) => format!("DONE·{x}"),
None => row.status.to_string(),
};
let tag = if row.deferred { " ⏳" } else { "" };
println!(
"{}{status_disp:<11} {:>4} {} | {}{}{tag} — {}{owner}",
if row.stale { "⚠ " } else { " " },
fmt_age(row.age_secs),
short_id(&row.id),
schema::sanitize_term(roster::display(&roster, &row.from), false),
render_targets(&roster, &row.to),
truncate(&row.summary, 66),
);
}
}
if !json {
let wip_s = if board.wip.is_empty() {
"none".to_string()
} else {
board
.wip
.iter()
.map(|(a, n)| format!("{a}×{n}"))
.collect::<Vec<_>>()
.join(", ")
};
println!(
"── flow: {} open · {} claimed · {} blocked · {} backlog · {} closed ── WIP: {wip_s}",
board.open, board.claimed, board.blocked, board.backlog, board.closed
);
}
Ok(())
}
pub(crate) fn fmt_age(secs: i64) -> String {
if secs < 3600 {
format!("{}m", secs / 60)
} else if secs < 86400 {
format!("{}h", secs / 3600)
} else {
format!("{}d", secs / 86400)
}
}
pub(crate) fn cmd_thread(id: String, full: bool) -> Result<()> {
let root = config::repo_root()?;
let roster = roster::load(&root);
let msgs = store::all_messages(&root)?;
let seed = resolve_unique(&msgs, &id)?.to_string();
let mut set: HashSet<String> = HashSet::from([seed]);
loop {
let before = set.len();
for m in &msgs {
let links: Vec<&String> = [&m.front.of, &m.front.reply_to, &m.front.supersedes]
.into_iter()
.flatten()
.collect();
let touches = set.contains(&m.front.id)
|| links
.iter()
.any(|l| set.iter().any(|s| id_ref_matches(s, l)));
if touches {
set.insert(m.front.id.clone());
for l in &links {
if let Some(t) = msgs.iter().find(|x| id_ref_matches(&x.front.id, l)) {
set.insert(t.front.id.clone());
}
}
}
}
if set.len() == before {
break;
}
}
let mut thread: Vec<&Message> = msgs.iter().filter(|m| set.contains(&m.front.id)).collect();
thread.sort_by(|a, b| a.front.id.cmp(&b.front.id));
let hub_key = config::hub_key(&root);
let mut vc = verify::Cache::default();
for m in &thread {
let t = verify::status(&root, &hub_key, &roster, &mut vc, m);
if full {
let who = roster::display(&roster, &m.front.from);
let body = schema::sanitize_term(&m.to_markdown()?, true);
println!("{}\n", framed_body(&body, m, who, &t, tiers::get(&hub_key)));
} else {
println!("{}", format_line(&roster, m, false, Some(&t)));
}
}
Ok(())
}
pub(crate) fn cmd_read(last: Option<usize>, topic: Option<String>, full: bool, json: bool) -> Result<()> {
let root = config::repo_root()?;
let roster = roster::load(&root);
let mut msgs = store::all_messages(&root)?;
if let Some(t) = &topic {
msgs.retain(|m| m.front.topic.as_deref() == Some(t.as_str()));
}
let superseded = superseded_set(&msgs);
msgs.sort_by(|a, b| a.front.id.cmp(&b.front.id));
if let Some(n) = last {
let len = msgs.len();
if len > n {
msgs = msgs.split_off(len - n);
}
}
let hub_key = config::hub_key(&root);
let mut vc = verify::Cache::default();
for m in &msgs {
let sup = if superseded.contains(&m.front.id) {
" [superseded]"
} else {
""
};
if json {
println!("{}", to_json(m)?);
} else if full {
let who = roster::display(&roster, &m.front.from);
let t = verify::status(&root, &hub_key, &roster, &mut vc, m);
let hdr = format!(
"### {} {}{}{sup}",
m.front.msg_type.to_uppercase(),
short_id(&m.front.id),
render_targets(&roster, &m.front.to),
);
let body = schema::sanitize_term(&m.body, true);
println!(
"\n{hdr}\n{}",
framed_body(&body, m, who, &t, tiers::get(&hub_key))
);
} else {
let t = verify::status(&root, &hub_key, &roster, &mut vc, m);
println!("{}{sup}", format_line(&roster, m, false, Some(&t)));
}
}
Ok(())
}