use crate::schema::Message;
use crate::{config, crosshub, gitcmd, presence, roster, store, watchlock};
use chrono::{DateTime, Utc};
use std::collections::{BTreeMap, HashSet};
use std::path::{Path, PathBuf};
pub fn id_ref_matches(full: &str, reference: &str) -> bool {
!reference.is_empty()
&& (full == reference || (reference.len() >= 8 && full.ends_with(reference)))
}
pub fn request_status(msgs: &[Message], req_id: &str) -> &'static str {
let mut st = "OPEN";
for m in msgs {
if m.front.supersedes.as_deref().is_some_and(|s| id_ref_matches(req_id, s)) {
return "SUPERSEDED";
}
if m.front.of.as_deref().is_some_and(|of| id_ref_matches(req_id, of)) {
match m.front.msg_type.as_str() {
"done" => return "DONE",
"error" => return "ERROR",
"blocked" => st = "BLOCKED",
"claim" => st = "CLAIMED",
_ => {}
}
}
}
st
}
pub fn request_deferred(msgs: &[Message], req: &Message) -> bool {
let mut deferred = req.front.defer;
for m in msgs {
if m.front.of.as_deref().is_some_and(|of| id_ref_matches(&req.front.id, of)) {
match m.front.msg_type.as_str() {
"defer" => deferred = true,
"claim" => deferred = false,
_ => {}
}
}
}
deferred
}
pub fn done_resolution(msgs: &[Message], req_id: &str) -> Option<String> {
msgs.iter()
.find(|m| {
m.front.msg_type == "done"
&& m.front.of.as_deref().is_some_and(|of| id_ref_matches(req_id, of))
})
.and_then(|m| m.front.resolution.clone())
}
pub fn claimants(msgs: &[Message], req_id: &str) -> Vec<String> {
let mut seen: Vec<String> = Vec::new();
for m in msgs {
if m.front.msg_type == "claim"
&& m.front.of.as_deref().is_some_and(|of| id_ref_matches(req_id, of))
&& !seen.contains(&m.front.from)
{
seen.push(m.front.from.clone());
}
}
seen
}
pub fn request_author<'a>(msgs: &'a [Message], req_id: &str) -> Option<&'a str> {
msgs.iter()
.find(|m| id_ref_matches(&m.front.id, req_id))
.map(|m| m.front.from.as_str())
}
pub const STALE_SECS: i64 = 3 * 86400;
#[derive(Debug, Clone)]
pub struct RequestRow {
pub id: String,
pub from: String,
pub to: Vec<String>,
pub summary: String,
pub status: &'static str,
pub resolution: Option<String>,
pub deferred: bool,
pub claimants: Vec<String>,
pub age_secs: i64,
pub stale: bool,
}
impl RequestRow {
pub fn is_open(&self) -> bool {
matches!(self.status, "OPEN" | "CLAIMED")
}
pub fn is_active(&self) -> bool {
self.is_open() && !self.deferred
}
pub fn is_backlog(&self) -> bool {
self.deferred && matches!(self.status, "OPEN" | "CLAIMED" | "BLOCKED")
}
}
#[derive(Debug, Clone, Default)]
pub struct Board {
pub rows: Vec<RequestRow>,
pub open: usize,
pub claimed: usize,
pub blocked: usize,
pub backlog: usize,
pub closed: usize,
pub wip: BTreeMap<String, usize>,
}
impl Board {
pub fn fold(msgs: &[Message], now: DateTime<Utc>) -> Board {
let mut reqs: Vec<&Message> = msgs.iter().filter(|m| m.front.msg_type == "request").collect();
reqs.sort_by(|a, b| a.front.id.cmp(&b.front.id));
let mut board = Board::default();
for r in reqs {
let status = request_status(msgs, &r.front.id);
let deferred = request_deferred(msgs, r);
let cs = if status == "CLAIMED" { claimants(msgs, &r.front.id) } else { Vec::new() };
let resolution = done_resolution(msgs, &r.front.id);
let age_secs = DateTime::parse_from_rfc3339(&r.front.ts)
.ok()
.map(|t| (now - t.with_timezone(&Utc)).num_seconds().max(0))
.unwrap_or(0);
let row = RequestRow {
id: r.front.id.clone(),
from: r.front.from.clone(),
to: r.front.to.clone(),
summary: r.summary_line(),
status,
resolution,
deferred,
claimants: cs,
age_secs,
stale: matches!(status, "OPEN" | "CLAIMED") && age_secs >= STALE_SECS,
};
if row.is_backlog() {
board.backlog += 1;
} else {
match status {
"OPEN" => board.open += 1,
"CLAIMED" => {
board.claimed += 1;
for c in &row.claimants {
*board.wip.entry(c.clone()).or_default() += 1;
}
}
"BLOCKED" => board.blocked += 1,
_ => board.closed += 1,
}
}
board.rows.push(row);
}
board
}
}
pub fn thread_root<'a>(msgs: &'a [Message], m: &'a Message) -> &'a Message {
let mut cur = m;
for _ in 0..64 {
let Some(pref) = cur
.front
.of
.as_deref()
.or(cur.front.reply_to.as_deref())
.or(cur.front.supersedes.as_deref())
else {
break;
};
match msgs.iter().find(|x| id_ref_matches(&x.front.id, pref)) {
Some(p) if !std::ptr::eq(p, cur) => cur = p,
_ => break,
}
}
cur
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RefKind {
Ref,
Patch,
}
#[derive(Debug, Clone)]
pub struct RefHit {
pub repo: String,
pub path: String,
pub sha: String,
pub range: Option<[u64; 2]>,
pub content_hash: Option<String>,
pub kind: RefKind,
pub result_hash: Option<String>,
pub ref_name: Option<String>,
pub ref_type: Option<String>,
pub commit_date: Option<String>,
pub dirty: bool,
pub untracked: bool,
pub base_ref: Option<String>,
pub fork_point: Option<String>,
pub msg_id: String,
pub from: String,
pub msg_type: String,
pub ts: String,
pub topic: Option<String>,
pub summary: String,
pub thread_root: String,
pub request_status: Option<&'static str>,
}
#[derive(Debug, Clone, Default)]
pub struct RefIndex {
pub by_file: BTreeMap<(String, String), Vec<RefHit>>,
}
#[derive(Debug, Clone)]
pub struct FileSummary {
pub repo: String,
pub path: String,
pub ref_count: usize,
pub last_ts: String,
}
impl RefIndex {
pub fn fold(msgs: &[Message]) -> RefIndex {
let mut idx = RefIndex::default();
for m in msgs {
if m.front.refs.is_empty() {
continue;
}
let root = thread_root(msgs, m);
let rstatus =
(root.front.msg_type == "request").then(|| request_status(msgs, &root.front.id));
for r in &m.front.refs {
idx.by_file.entry((r.repo.clone(), r.path.clone())).or_default().push(RefHit {
repo: r.repo.clone(),
path: r.path.clone(),
sha: r.sha.clone(),
range: r.range,
content_hash: r.content_hash.clone(),
kind: if r.patch { RefKind::Patch } else { RefKind::Ref },
result_hash: r.result_hash.clone(),
ref_name: r.ref_name.clone(),
ref_type: r.ref_type.clone(),
commit_date: r.commit_date.clone(),
dirty: r.dirty,
untracked: r.untracked,
base_ref: r.base_ref.clone(),
fork_point: r.fork_point.clone(),
msg_id: m.front.id.clone(),
from: m.front.from.clone(),
msg_type: m.front.msg_type.clone(),
ts: m.front.ts.clone(),
topic: m.front.topic.clone(),
summary: m.summary_line(),
thread_root: root.front.id.clone(),
request_status: rstatus,
});
}
}
idx
}
pub fn files(&self) -> Vec<FileSummary> {
self.by_file
.iter()
.map(|((repo, path), hits)| {
let last_ts = hits.iter().map(|h| h.ts.as_str()).max().unwrap_or("").to_string();
FileSummary { repo: repo.clone(), path: path.clone(), ref_count: hits.len(), last_ts }
})
.collect()
}
pub fn query(&self, repo: &str, path: Option<&str>, range: Option<[u64; 2]>) -> Vec<&RefHit> {
let overlaps = |hr: Option<[u64; 2]>| match (hr, range) {
(_, None) => true,
(None, Some(_)) => true,
(Some(h), Some(q)) => h[0] <= q[1] && q[0] <= h[1],
};
let mut out: Vec<&RefHit> = self
.by_file
.iter()
.filter(|((rp, pt), _)| rp == repo && path.is_none_or(|p| pt == p))
.flat_map(|(_, hits)| hits.iter())
.filter(|h| overlaps(h.range))
.collect();
out.sort_by(|a, b| b.msg_id.cmp(&a.msg_id));
out
}
}
#[derive(Debug, Clone)]
pub struct AgentRow {
pub id: String,
pub display: String,
pub desc: Option<String>,
pub profile: Option<String>,
pub expected_host: Option<String>,
pub last_ts: Option<String>,
pub last_host: Option<String>,
pub presence: Option<presence::Presence>,
pub xhub: Vec<(String, String)>,
}
pub fn agents(
msgs: &[Message],
roster: &roster::Roster,
pres: &std::collections::HashMap<String, presence::Presence>,
xhub: &std::collections::HashMap<String, Vec<(String, String)>>,
) -> Vec<AgentRow> {
use std::collections::HashMap;
let mut last: HashMap<String, (String, Option<String>)> = HashMap::new();
for m in msgs {
let e = last.entry(m.front.from.clone()).or_insert_with(|| (String::new(), None));
if m.front.ts > e.0 {
*e = (m.front.ts.clone(), m.front.host.clone());
}
}
let mut ids: Vec<String> = roster.keys().cloned().collect();
for k in last.keys().chain(pres.keys()) {
if !roster.contains_key(k) {
ids.push(k.clone());
}
}
ids.sort();
ids.dedup();
ids.into_iter()
.map(|id| {
let xh = roster::pubkey(roster, &id)
.and_then(|pk| xhub.get(pk))
.cloned()
.unwrap_or_default();
let (last_ts, last_host) = match last.get(&id) {
Some((t, h)) if !t.is_empty() => (Some(t.clone()), h.clone()),
_ => (None, None),
};
AgentRow {
display: roster::display(roster, &id).to_string(),
desc: roster.get(&id).and_then(|r| r.desc.clone()),
profile: roster.get(&id).and_then(|r| r.profile.clone()),
expected_host: roster::host(roster, &id).map(str::to_string),
last_ts,
last_host,
presence: pres.get(&id).cloned(),
xhub: xh,
id,
}
})
.collect()
}
pub fn disk_free_gb(root: &std::path::Path) -> Option<f64> {
let o = std::process::Command::new("df").args(["-Pk", &root.to_string_lossy()]).output().ok()?;
if !o.status.success() {
return None;
}
let s = String::from_utf8_lossy(&o.stdout);
let avail_kb: f64 = s.lines().nth(1)?.split_whitespace().nth(3)?.parse().ok()?;
Some(avail_kb / 1024.0 / 1024.0)
}
pub const TAIL: usize = 40;
#[derive(Clone)]
pub struct Health {
pub role: String,
pub reachable: Option<bool>,
pub pending: Option<u64>,
pub behind: Option<u64>,
pub watch: Option<watchlock::WatchState>,
pub disk_gb: Option<f64>,
pub last_fetch_secs: Option<u64>,
}
#[derive(Clone)]
pub struct TailItem {
pub time: String,
pub from: String,
pub to: Vec<String>,
pub kind: String,
pub summary: String,
}
pub struct Snapshot {
pub dir: PathBuf,
pub label: String,
pub roster: roster::Roster,
pub board: Board,
pub agents: Vec<AgentRow>,
pub tail: Vec<TailItem>,
pub health: Health,
pub error: Option<String>,
pub messages: Vec<Message>,
}
impl Snapshot {
pub fn load(dir: PathBuf, fetch: bool) -> Snapshot {
let label = crosshub::hub_label(&dir);
let now = Utc::now();
if !dir.join("threads").is_dir() && !dir.join("roles").is_dir() {
return Snapshot::errored(dir, label, "not a confer hub (no threads/ or roles/)");
}
let roster = roster::load(&dir);
let msgs = match store::all_messages(&dir) {
Ok(m) => m,
Err(e) => return Snapshot::errored(dir, label, &format!("cannot read messages: {e}")),
};
let pres: std::collections::HashMap<String, presence::Presence> = presence::load_all(&dir, fetch)
.into_iter()
.map(|p| (p.role.clone(), p))
.collect();
let xhub = crosshub::appearances(&dir);
let board = Board::fold(&msgs, now);
let agents = agents(&msgs, &roster, &pres, &xhub);
let mut sorted: Vec<&Message> = msgs.iter().collect();
sorted.sort_by(|a, b| a.front.id.cmp(&b.front.id));
let tail: Vec<TailItem> = sorted
.iter()
.rev()
.take(TAIL)
.rev()
.map(|m| TailItem {
time: m.front.ts.get(11..16).unwrap_or("").to_string(),
from: m.front.from.clone(),
to: m.front.to.clone(),
kind: m.front.msg_type.clone(),
summary: m.summary_line(),
})
.collect();
let health = probe_health(&dir);
Snapshot { dir, label, roster, board, agents, tail, health, error: None, messages: msgs }
}
pub fn errored(dir: PathBuf, label: String, msg: &str) -> Snapshot {
Snapshot {
dir,
label,
roster: roster::Roster::new(),
board: Board::default(),
agents: Vec::new(),
tail: Vec::new(),
health: Health { role: String::new(), reachable: Some(false), pending: None, behind: None, watch: None, disk_gb: None, last_fetch_secs: None },
error: Some(msg.to_string()),
messages: Vec::new(),
}
}
}
fn last_fetch_secs(dir: &Path) -> Option<u64> {
let mtime = std::fs::metadata(dir.join(".git").join("FETCH_HEAD")).ok()?.modified().ok()?;
std::time::SystemTime::now().duration_since(mtime).ok().map(|d| d.as_secs())
}
pub fn probe_health(dir: &Path) -> Health {
let role = config::resolve_role(None, dir).unwrap_or_default();
let count = |range: &str| {
gitcmd::output(dir, &["rev-list", "--count", range])
.ok()
.filter(|o| o.status.success())
.and_then(|o| String::from_utf8_lossy(&o.stdout).trim().parse::<u64>().ok())
};
let watch = if role.is_empty() {
None
} else {
Some(watchlock::classify(&watchlock::inspect(&config::hub_key(dir), &role, 90), env!("CARGO_PKG_VERSION")))
};
Health {
reachable: None,
pending: count("@{u}..HEAD"),
behind: count("HEAD..@{u}"),
watch,
disk_gb: disk_free_gb(dir),
last_fetch_secs: last_fetch_secs(dir),
role,
}
}
pub fn render_targets(roster: &roster::Roster, targets: &[String]) -> String {
if targets.is_empty() {
return "—".to_string();
}
targets
.iter()
.map(|t| if t == "all" { "all".to_string() } else { roster::display(roster, t).to_string() })
.collect::<Vec<_>>()
.join(", ")
}
pub(crate) fn superseded_set(msgs: &[Message]) -> HashSet<String> {
let mut s = HashSet::new();
for m in msgs {
if let Some(sup) = &m.front.supersedes {
if let Some(t) = msgs.iter().find(|x| id_ref_matches(&x.front.id, sup)) {
s.insert(t.front.id.clone());
}
}
}
s
}
#[cfg(test)]
mod tests {
use super::*;
use crate::schema::Frontmatter;
fn m(id: &str, from: &str, ty: &str, of: Option<&str>, sup: Option<&str>, res: Option<&str>) -> Message {
Message {
front: Frontmatter {
id: id.into(),
from: from.into(),
msg_type: ty.into(),
ts: "2026-07-16T00:00:00Z".into(),
host: None,
to: vec![],
cc: vec![],
priority: None,
topic: None,
reply_to: None,
of: of.map(String::from),
supersedes: sup.map(String::from),
resolution: res.map(String::from),
defer: false,
via: None,
src: None,
summary: Some("s".into()),
refs: vec![],
},
body: String::new(),
}
}
#[test]
fn a_supersede_after_done_does_not_erase_the_completion() {
let msgs = vec![
m("01A", "alice", "request", None, None, None),
m("01B", "bob", "done", Some("01A"), None, None),
m("01C", "carol", "supersede", None, Some("01A"), None),
];
assert_eq!(request_status(&msgs, "01A"), "DONE");
let msgs = vec![
m("01A", "alice", "request", None, None, None),
m("01B", "carol", "supersede", None, Some("01A"), None),
m("01C", "bob", "done", Some("01A"), None, None),
];
assert_eq!(request_status(&msgs, "01A"), "SUPERSEDED");
}
#[test]
fn done_resolution_is_the_closing_dones_not_a_later_wont_do() {
let msgs = vec![
m("01A", "alice", "request", None, None, None),
m("01B", "bob", "done", Some("01A"), None, None),
m("01C", "alice", "done", Some("01A"), None, Some("wont-do")),
];
assert_eq!(done_resolution(&msgs, "01A"), None);
let msgs = vec![
m("01A", "alice", "request", None, None, None),
m("01B", "bob", "done", Some("01A"), None, Some("duplicate")),
];
assert_eq!(done_resolution(&msgs, "01A").as_deref(), Some("duplicate"));
}
#[test]
fn id_ref_matches_edge_cases() {
assert!(id_ref_matches("01ABCDEFGH", "01ABCDEFGH"));
assert!(id_ref_matches("01J8Z9K3QH7XABCDEF", "K3QH7XABCDEF"));
assert!(!id_ref_matches("01J8Z9K3QH7XABCDEF", "ABCDEF"));
assert!(!id_ref_matches("01J8Z9K3QH7XABCDEF", "01J8Z9K3"));
assert!(!id_ref_matches("01J8Z9K3QH7XABCDEF", ""));
assert!(!id_ref_matches("", ""));
}
#[test]
fn claimants_are_distinct_and_first_claim_ordered() {
let msgs = vec![
m("01A", "alice", "request", None, None, None),
m("01B", "bob", "claim", Some("01A"), None, None),
m("01C", "carol", "claim", Some("01A"), None, None),
m("01D", "bob", "claim", Some("01A"), None, None),
];
assert_eq!(claimants(&msgs, "01A"), vec!["bob".to_string(), "carol".to_string()]);
}
#[test]
fn request_deferred_fold_order_claim_clears_a_prior_defer() {
let mut req = m("01A", "alice", "request", None, None, None);
req.front.defer = false;
let msgs = vec![
req.clone(),
m("01B", "bob", "defer", Some("01A"), None, None),
];
assert!(request_deferred(&msgs, &req), "an explicit defer event moves it to the backlog");
let msgs2 = vec![
req.clone(),
m("01B", "bob", "defer", Some("01A"), None, None),
m("01C", "carol", "claim", Some("01A"), None, None),
];
assert!(!request_deferred(&msgs2, &req), "a claim after defer clears it (Phase 2 semantics)");
let mut sender_deferred = req.clone();
sender_deferred.front.defer = true;
assert!(request_deferred(&[sender_deferred.clone()], &sender_deferred));
}
#[test]
fn thread_root_walks_of_reply_to_supersedes_and_guards_cycles() {
let msgs = vec![
m("01A", "alice", "request", None, None, None),
m("01B", "bob", "note", Some("01A"), None, None),
m("01C", "carol", "note", Some("01B"), None, None),
];
let leaf = msgs.iter().find(|x| x.front.id == "01C").unwrap();
assert_eq!(thread_root(&msgs, leaf).front.id, "01A");
let dangling = vec![m("01Z", "dave", "note", Some("nonexistent"), None, None)];
assert_eq!(thread_root(&dangling, &dangling[0]).front.id, "01Z");
let mut a = m("01A", "alice", "note", Some("01B"), None, None);
let mut b = m("01B", "bob", "note", Some("01A"), None, None);
a.front.reply_to = None;
b.front.reply_to = None;
let cyc = vec![a, b];
let root = thread_root(&cyc, &cyc[0]);
assert!(root.front.id == "01A" || root.front.id == "01B", "must terminate on some node in the cycle: {}", root.front.id);
}
fn code_ref(repo: &str, path: &str, range: Option<[u64; 2]>) -> crate::schema::CodeRef {
crate::schema::CodeRef {
repo: repo.into(),
path: path.into(),
sha: "a".repeat(40),
range,
content_hash: None,
ref_name: None,
ref_type: None,
commit_date: None,
dirty: false,
untracked: false,
rev: None,
base_ref: None,
fork_point: None,
patch: false,
result_hash: None,
}
}
#[test]
fn ref_index_query_matches_whole_file_and_overlapping_ranges() {
let mut whole = m("01A", "alice", "note", None, None, None);
whole.front.refs = vec![code_ref("lib", "f.rs", None)];
let mut ranged = m("01B", "bob", "note", None, None, None);
ranged.front.refs = vec![code_ref("lib", "f.rs", Some([10, 20]))];
let msgs = vec![whole, ranged];
let idx = RefIndex::fold(&msgs);
let hits_any_line = idx.query("lib", Some("f.rs"), Some([500, 500]));
assert!(hits_any_line.iter().any(|h| h.msg_id == "01A"), "whole-file ref should match any line");
let all = idx.query("lib", Some("f.rs"), None);
assert_eq!(all.len(), 2);
let overlap = idx.query("lib", Some("f.rs"), Some([15, 16]));
assert!(overlap.iter().any(|h| h.msg_id == "01B"));
let miss = idx.query("lib", Some("f.rs"), Some([30, 40]));
assert!(miss.iter().any(|h| h.msg_id == "01A"));
assert!(!miss.iter().any(|h| h.msg_id == "01B"), "a disjoint range must not match: {miss:?}");
assert!(idx.query("other", Some("f.rs"), None).is_empty());
assert!(idx.query("lib", Some("nope.rs"), None).is_empty());
let all_sorted = idx.query("lib", Some("f.rs"), None);
assert_eq!(all_sorted[0].msg_id, "01B");
}
#[test]
fn ref_index_files_summarizes_distinct_targets() {
let mut a1 = m("01A", "alice", "note", None, None, None);
a1.front.ts = "2026-07-16T10:00:00Z".into();
a1.front.refs = vec![code_ref("lib", "f.rs", None)];
let mut a2 = m("01B", "bob", "note", None, None, None);
a2.front.ts = "2026-07-16T11:00:00Z".into();
a2.front.refs = vec![code_ref("lib", "f.rs", Some([10, 20]))];
let mut b1 = m("01C", "carol", "note", None, None, None);
b1.front.ts = "2026-07-16T09:00:00Z".into();
b1.front.refs = vec![code_ref("lib", "g.rs", None)];
let msgs = vec![a1, a2, b1];
let idx = RefIndex::fold(&msgs);
let mut files = idx.files();
files.sort_by(|x, y| y.ref_count.cmp(&x.ref_count).then(x.path.cmp(&y.path)));
assert_eq!(files.len(), 2);
assert_eq!(files[0].repo, "lib");
assert_eq!(files[0].path, "f.rs");
assert_eq!(files[0].ref_count, 2);
assert_eq!(files[0].last_ts, "2026-07-16T11:00:00Z");
assert_eq!(files[1].path, "g.rs");
assert_eq!(files[1].ref_count, 1);
assert_eq!(files[1].last_ts, "2026-07-16T09:00:00Z");
}
#[test]
fn ref_index_fold_carries_thread_root_and_request_status() {
let req = m("01A", "alice", "request", None, None, None);
let mut reply = m("01B", "bob", "note", Some("01A"), None, None);
reply.front.refs = vec![code_ref("lib", "f.rs", Some([1, 2]))];
let claim = m("01C", "bob", "claim", Some("01A"), None, None);
let msgs = vec![req, reply, claim];
let idx = RefIndex::fold(&msgs);
let hits = idx.query("lib", Some("f.rs"), None);
assert_eq!(hits.len(), 1);
assert_eq!(hits[0].thread_root, "01A");
assert_eq!(hits[0].request_status, Some("CLAIMED"));
}
}