use clap::{Parser, ValueEnum};
use ruma_lean::{lean_kahn_sort, LeanEvent, StateResVersion};
use std::collections::{HashMap, HashSet};
use std::fs::File;
use std::io::{self, BufRead, BufReader, BufWriter, Read, Write};
use std::path::PathBuf;
use std::time::Instant;
#[derive(Parser, Debug)]
#[command(author, version, about, long_about = None)]
struct Args {
#[arg(short, long, num_args(1..))]
input: Vec<PathBuf>,
#[arg(short, long)]
room: Option<String>,
#[arg(long, env = "MATRIX_HOMESERVER")]
homeserver: Option<String>,
#[arg(long, env = "MATRIX_TOKEN")]
token: Option<String>,
#[arg(short, long)]
output: Option<PathBuf>,
#[arg(short, long, value_enum)]
state_res: Option<StateResVersion>,
#[arg(short, long, value_enum, default_value = "default")]
format: OutputFormat,
#[arg(long)]
debug: bool,
#[arg(short, long)]
quiet: bool,
#[arg(long, default_value = "matrix.org")]
origin: String,
}
#[derive(ValueEnum, Clone, Debug, PartialEq, Eq, Default)]
enum OutputFormat {
#[default]
Events,
Default,
Federation,
Summary,
Timeline,
}
fn parse_room_version(ver: &str) -> anyhow::Result<StateResVersion> {
match ver {
"1" => Ok(StateResVersion::V1),
"2" | "3" | "4" | "5" | "6" | "7" | "8" | "9" | "10" | "11" => Ok(StateResVersion::V2),
"12" => Ok(StateResVersion::V2_1),
_ => anyhow::bail!("Unsupported room version: {ver}"),
}
}
fn detect_version(events: &[serde_json::Value], debug: bool) -> anyhow::Result<StateResVersion> {
for ev in events {
if ev.get("type").and_then(|t| t.as_str()) == Some("m.room.create") {
if let Some(ver) = ev
.get("content")
.and_then(|c| c.get("room_version"))
.and_then(|v| v.as_str())
{
if debug {
eprintln!("[DEBUG] Found m.room.create with version: {}", ver);
}
return parse_room_version(ver);
}
}
}
anyhow::bail!(
"No m.room.create event found — cannot detect room version. \
Use --state-res to specify the algorithm manually."
)
}
fn fetch_room_state(
homeserver: &str,
room_id: &str,
token: Option<&str>,
) -> anyhow::Result<serde_json::Value> {
let base = if homeserver.starts_with("http://") || homeserver.starts_with("https://") {
homeserver.to_string()
} else {
format!("https://{}", homeserver)
};
let url = format!("{}/_matrix/client/v3/rooms/{}/state", base, room_id);
eprintln!("Fetching {}", url);
let mut request = ureq::get(&url);
if let Some(t) = token {
request = request.set("Authorization", &format!("Bearer {}", t));
}
let response = match request.call() {
Ok(resp) => resp,
Err(ureq::Error::Status(code, resp)) => {
let body = resp.into_string().unwrap_or_default();
anyhow::bail!("HTTP {}: {}", code, body);
}
Err(e) => anyhow::bail!("Request failed: {}", e),
};
let body = response.into_string()?;
let val: serde_json::Value = serde_json::from_str(&body).map_err(|e| {
anyhow::anyhow!(
"Failed to parse JSON: {}. Response: {}",
e,
&body[..body.len().min(500)]
)
})?;
Ok(val)
}
fn load_file(input_path: &PathBuf) -> anyhow::Result<Vec<serde_json::Value>> {
let input_reader: Box<dyn Read> = if input_path.to_str() == Some("-") {
Box::new(io::stdin())
} else {
Box::new(File::open(input_path)?)
};
let is_jsonl = input_path
.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("jsonl"));
let mut reader = BufReader::new(input_reader);
if is_jsonl {
let mut values = Vec::new();
for line in reader.lines() {
let line = line?;
if line.trim().is_empty() {
continue;
}
let val: serde_json::Value = serde_json::from_str(&line)?;
values.push(val);
}
if values.is_empty() {
anyhow::bail!("No input data provided in JSONL file.");
}
Ok(values)
} else {
let mut input_data = Vec::new();
loop {
let mut line = String::new();
let bytes_read = reader.read_line(&mut line)?;
if bytes_read == 0 {
break;
}
if line.trim().is_empty() {
continue;
}
input_data.extend_from_slice(line.as_bytes());
}
if input_data.is_empty() {
anyhow::bail!("No input data provided before empty line or EOF.");
}
let val: serde_json::Value = serde_json::from_slice(&input_data)?;
match val {
serde_json::Value::Array(arr) => Ok(arr),
other => Ok(vec![other]),
}
}
}
fn merge_event_sets(
file_sets: Vec<(String, Vec<serde_json::Value>)>,
debug: bool,
quiet: bool,
) -> anyhow::Result<Vec<serde_json::Value>> {
let num_files = file_sets.len();
let mut seen_ids: HashSet<String> = HashSet::new();
let mut merged: Vec<serde_json::Value> = Vec::new();
let mut per_file_ids: Vec<HashSet<String>> = Vec::with_capacity(num_files);
for (label, events) in &file_sets {
let mut file_ids = HashSet::with_capacity(events.len());
let mut added = 0usize;
let mut dupes = 0usize;
for val in events {
let event_id = val
.get("event_id")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
file_ids.insert(event_id.clone());
if seen_ids.insert(event_id) {
merged.push(val.clone());
added += 1;
} else {
dupes += 1;
}
}
if !quiet {
eprintln!(
"[merge] {}: {} events ({} new, {} shared)",
label,
events.len(),
added,
dupes
);
}
per_file_ids.push(file_ids);
}
if num_files >= 2 {
let mut any_shared = false;
for i in 0..num_files {
for j in (i + 1)..num_files {
let pair_shared = per_file_ids[i].intersection(&per_file_ids[j]).count();
if pair_shared > 0 {
any_shared = true;
break;
}
}
if any_shared {
break;
}
}
if !any_shared {
anyhow::bail!(
"Disjoint DAGs: no shared events found across inputs. \
Cannot compute meaningful merge — the DAGs share no history."
);
}
let total_shared: usize = {
let mut shared_ids: HashSet<&String> = HashSet::new();
for i in 0..num_files {
for j in (i + 1)..num_files {
shared_ids.extend(per_file_ids[i].intersection(&per_file_ids[j]));
}
}
shared_ids.len()
};
if !quiet {
eprintln!(
"[merge] merge-base: {} shared events across {} inputs",
total_shared, num_files
);
}
if debug {
let shared_all: HashSet<&String> = {
let mut s: HashSet<&String> = HashSet::new();
for i in 0..num_files {
for j in (i + 1)..num_files {
s.extend(per_file_ids[i].intersection(&per_file_ids[j]));
}
}
s
};
let mut shared_depths: Vec<(&String, u64)> = shared_all
.iter()
.filter_map(|id| {
merged.iter().find_map(|v| {
let eid = v.get("event_id")?.as_str()?;
if eid == id.as_str() {
Some((*id, v.get("depth")?.as_u64().unwrap_or(0)))
} else {
None
}
})
})
.collect();
shared_depths.sort_by_key(|b| std::cmp::Reverse(b.1));
eprintln!(
"[merge] highest shared depths: {:?}",
&shared_depths[..shared_depths.len().min(5)]
);
}
}
if !quiet {
eprintln!("[merge] total: {} unique events", merged.len());
}
Ok(merged)
}
fn run_cli(args: &Args) -> anyhow::Result<serde_json::Value> {
let input_val: serde_json::Value = if let Some(room_id) = &args.room {
let homeserver = args
.homeserver
.as_deref()
.ok_or_else(|| anyhow::anyhow!("--homeserver is required when using --room"))?;
let token = args.token.clone().or_else(|| {
let env_key = format!(
"MTOKEN_{}",
homeserver
.trim_start_matches("https://")
.trim_start_matches("http://")
.to_uppercase()
.replace(['.', '-'], "_")
);
std::env::var(&env_key).ok()
});
fetch_room_state(homeserver, room_id, token.as_deref())?
} else if !args.input.is_empty() {
if args.input.len() == 1 {
let events = load_file(&args.input[0])?;
serde_json::Value::Array(events)
} else {
let mut file_sets = Vec::with_capacity(args.input.len());
for path in &args.input {
let label = path
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| path.display().to_string());
let events = load_file(path)?;
file_sets.push((label, events));
}
let merged = merge_event_sets(file_sets, args.debug, args.quiet)?;
serde_json::Value::Array(merged)
}
} else {
anyhow::bail!("Either --input or --room must be provided.");
};
let (raw_events, heads) = if let Some(obj) = input_val.as_object() {
if obj.contains_key("events") && obj.contains_key("heads") {
let evs = obj
.get("events")
.unwrap()
.as_array()
.ok_or_else(|| anyhow::anyhow!("'events' field must be a JSON array"))?
.clone();
let hds_arr = obj
.get("heads")
.unwrap()
.as_array()
.ok_or_else(|| anyhow::anyhow!("'heads' field must be a JSON array"))?;
let mut hds = Vec::with_capacity(hds_arr.len());
for v in hds_arr {
hds.push(
v.as_str()
.ok_or_else(|| anyhow::anyhow!("each 'head' must be a string"))?
.to_string(),
);
}
(evs, hds)
} else {
(vec![input_val], Vec::new())
}
} else if let Some(arr) = input_val.as_array() {
(arr.clone(), Vec::new())
} else {
anyhow::bail!("Unexpected JSON format");
};
let event_count = raw_events.len();
let version = match args.state_res {
Some(v) => v,
None => detect_version(&raw_events, args.debug)?,
};
let mut raw_map = HashMap::with_capacity(event_count);
let mut events_map = HashMap::with_capacity(event_count);
let mut creator_user_id = String::new();
for val in raw_events {
match serde_json::from_value::<LeanEvent>(val.clone()) {
Ok(ev) => {
if ev.event_type == "m.room.create" {
creator_user_id = ev.sender.clone();
}
raw_map.insert(ev.event_id.clone(), val);
events_map.insert(ev.event_id.clone(), ev);
}
Err(e) => {
if args.debug {
eprintln!("[DEBUG] Failed to parse event: {:?}. Error: {}", val, e);
}
let _ = serde_json::from_value::<LeanEvent>(val)?;
}
}
}
let heads = if heads.is_empty() {
let all_ids: std::collections::HashSet<String> = events_map.keys().cloned().collect();
let mut referenced: std::collections::HashSet<String> = std::collections::HashSet::new();
for ev in events_map.values() {
for pe in &ev.prev_events {
referenced.insert(pe.clone());
}
}
let mut auto_heads: Vec<String> = all_ids.difference(&referenced).cloned().collect();
auto_heads.sort();
if args.debug {
eprintln!(
"[DEBUG] Auto-computed {} heads: {:?}",
auto_heads.len(),
auto_heads
);
}
auto_heads
} else {
heads
};
let state_maps = if heads.len() <= 1 {
let reachable: std::collections::HashSet<String> = if heads.len() == 1 {
let mut visited = std::collections::HashSet::new();
let mut stack = vec![heads[0].clone()];
while let Some(ev_id) = stack.pop() {
if visited.insert(ev_id.clone()) {
if let Some(ev) = events_map.get(&ev_id) {
for pe in &ev.prev_events {
stack.push(pe.clone());
}
}
}
}
visited
} else {
events_map.keys().cloned().collect()
};
let mut sorted_events: Vec<&LeanEvent> = events_map
.values()
.filter(|ev| reachable.contains(&ev.event_id))
.collect();
sorted_events.sort_by(|a, b| a.cmp_by_depth(b));
let mut state_map = std::collections::HashMap::new();
for ev in sorted_events {
let key = (ev.event_type.clone(), ev.state_key.clone());
state_map.insert(key, ev.event_id.clone());
}
vec![state_map]
} else {
let mut maps = Vec::new();
for head_id in &heads {
let mut reachable: Vec<&LeanEvent> = Vec::new();
let mut visited = std::collections::HashSet::new();
let mut stack = vec![head_id.clone()];
while let Some(ev_id) = stack.pop() {
if visited.insert(ev_id.clone()) {
if let Some(ev) = events_map.get(&ev_id) {
reachable.push(ev);
for prev_ev_id in &ev.prev_events {
stack.push(prev_ev_id.clone());
}
}
}
}
reachable.sort_by(|a, b| a.cmp_by_depth(b));
let mut state_map = std::collections::HashMap::new();
for ev in reachable {
let key = (ev.event_type.clone(), ev.state_key.clone());
state_map.insert(key, ev.event_id.clone());
}
maps.push(state_map);
}
maps
};
let mut power_events = HashMap::new();
let power_event_types = [
"m.room.create",
"m.room.power_levels",
"m.room.join_rules",
"m.room.member",
];
for ev in events_map.values() {
if power_event_types.contains(&ev.event_type.as_str()) {
let mut power_ev = ev.clone();
if (!creator_user_id.is_empty() && ev.sender == creator_user_id)
|| ev.event_type == "m.room.create"
{
power_ev.power_level = 100;
} else {
power_ev.power_level = 0;
}
power_events.insert(ev.event_id.clone(), power_ev);
}
}
let sorted_power_ids = lean_kahn_sort(&power_events, version);
let mut resolved_power_state = std::collections::BTreeMap::new();
for id in sorted_power_ids {
let ev = power_events.get(&id).unwrap();
resolved_power_state.insert((ev.event_type.clone(), ev.state_key.clone()), id);
}
let mut user_power_levels = HashMap::new();
let mut default_power_level = 0;
if let Some(id) = resolved_power_state.get(&("m.room.power_levels".to_string(), "".to_string()))
{
if let Some(ev) = events_map.get(id) {
if let Some(users) = ev.content.get("users").and_then(|u| u.as_object()) {
for (user_id, pl) in users {
if let Some(pl_val) = pl.as_i64() {
user_power_levels.insert(user_id.clone(), pl_val);
}
}
}
if let Some(pl_val) = ev.content.get("users_default").and_then(|v| v.as_i64()) {
default_power_level = pl_val;
}
}
}
for ev in events_map.values_mut() {
ev.power_level = *user_power_levels
.get(&ev.sender)
.unwrap_or(&default_power_level);
}
let start = Instant::now();
let mut occurrences: HashMap<(String, String), HashMap<String, usize>> = HashMap::new();
let num_sets = state_maps.len();
for map in &state_maps {
for (key, id) in map {
*occurrences
.entry(key.clone())
.or_default()
.entry(id.clone())
.or_insert(0) += 1;
}
}
let mut unconflicted_state = std::collections::BTreeMap::new();
let mut conflicted_events = HashMap::new();
for (key, ids) in occurrences {
if ids.len() == 1 && ids.values().next().unwrap() == &num_sets {
let id = ids.keys().next().unwrap();
unconflicted_state.insert(key, id.clone());
if version == StateResVersion::V2_1 {
if let Some(ev) = events_map.get(id) {
conflicted_events.insert(id.clone(), ev.clone());
}
}
} else {
for id in ids.keys() {
if let Some(ev) = events_map.get(id) {
conflicted_events.insert(id.clone(), ev.clone());
}
}
}
}
let final_state_map =
ruma_lean::resolve_lean(unconflicted_state, conflicted_events.clone(), version);
let duration = start.elapsed();
let resolved_state_list: Vec<String> = final_state_map.values().cloned().collect();
let auth_chain_ids = compute_auth_chain(&resolved_state_list, &events_map);
match args.format {
OutputFormat::Events => {
let mut state_events: Vec<&serde_json::Value> = resolved_state_list
.iter()
.filter_map(|id| raw_map.get(id))
.collect();
state_events.sort_by(|a, b| {
let a_ev = a
.get("event_id")
.and_then(|id| id.as_str())
.and_then(|id| events_map.get(id));
let b_ev = b
.get("event_id")
.and_then(|id| id.as_str())
.and_then(|id| events_map.get(id));
let a_depth = a_ev.map(|e| e.depth).unwrap_or(0);
let b_depth = b_ev.map(|e| e.depth).unwrap_or(0);
a_depth.cmp(&b_depth).then_with(|| {
let a_id = a_ev.map(|e| e.event_id.as_str()).unwrap_or("");
let b_id = b_ev.map(|e| e.event_id.as_str()).unwrap_or("");
a_id.cmp(b_id)
})
});
Ok(serde_json::json!(state_events))
}
OutputFormat::Federation => {
let state_events: Vec<&serde_json::Value> = resolved_state_list
.iter()
.filter_map(|id| raw_map.get(id))
.collect();
let auth_chain_events: Vec<&serde_json::Value> = auth_chain_ids
.iter()
.filter_map(|id| raw_map.get(id))
.collect();
Ok(serde_json::json!({
"origin": args.origin,
"state": state_events,
"auth_chain": auth_chain_events
}))
}
OutputFormat::Default => Ok(serde_json::json!({
"status": "success",
"version": version,
"duration_ms": duration.as_millis(),
"resolved_state_size": resolved_state_list.len(),
"auth_chain_size": auth_chain_ids.len(),
"state_event_ids": resolved_state_list
})),
OutputFormat::Summary => {
let mut state_entries: Vec<serde_json::Value> = Vec::new();
let mut members: HashMap<String, Vec<serde_json::Value>> = HashMap::new();
for ((typ, sk), eid) in &final_state_map {
let ev = events_map.get(eid);
if typ == "m.room.member" {
let membership = ev
.and_then(|e| e.content.get("membership"))
.and_then(|m| m.as_str())
.unwrap_or("unknown");
let displayname = ev
.and_then(|e| e.content.get("displayname"))
.and_then(|d| d.as_str())
.unwrap_or("");
members
.entry(membership.to_string())
.or_default()
.push(serde_json::json!({
"user_id": sk,
"displayname": displayname,
"event_id": eid,
"depth": ev.map(|e| e.depth).unwrap_or(0),
}));
} else {
state_entries.push(serde_json::json!({
"type": typ,
"state_key": sk,
"event_id": eid,
"sender": ev.map(|e| e.sender.as_str()).unwrap_or("?"),
"depth": ev.map(|e| e.depth).unwrap_or(0),
}));
}
}
state_entries.sort_by(|a, b| {
let ta = a["type"].as_str().unwrap_or("");
let tb = b["type"].as_str().unwrap_or("");
ta.cmp(tb).then_with(|| {
let sa = a["state_key"].as_str().unwrap_or("");
let sb = b["state_key"].as_str().unwrap_or("");
sa.cmp(sb)
})
});
for list in members.values_mut() {
list.sort_by(|a, b| {
let ua = a["user_id"].as_str().unwrap_or("");
let ub = b["user_id"].as_str().unwrap_or("");
ua.cmp(ub)
});
}
let membership_order = ["join", "invite", "knock", "leave", "ban"];
let mut membership_obj = serde_json::Map::new();
for status in &membership_order {
if let Some(list) = members.get(*status) {
membership_obj.insert(
status.to_string(),
serde_json::json!({
"count": list.len(),
"users": list
}),
);
}
}
for (status, list) in &members {
if !membership_order.contains(&status.as_str()) {
membership_obj.insert(
status.clone(),
serde_json::json!({
"count": list.len(),
"users": list
}),
);
}
}
let min_depth = events_map.values().map(|e| e.depth).min().unwrap_or(0);
let max_depth = events_map.values().map(|e| e.depth).max().unwrap_or(0);
let root_event_id = events_map
.values()
.min_by_key(|e| e.depth)
.map(|e| e.event_id.as_str())
.unwrap_or("");
Ok(serde_json::json!({
"status": "success",
"version": version,
"duration_ms": duration.as_millis(),
"total_events": event_count,
"resolved_state_size": state_entries.len() + members.values().map(|v| v.len()).sum::<usize>(),
"auth_chain_size": auth_chain_ids.len(),
"min_depth": min_depth,
"max_depth": max_depth,
"root_event_id": root_event_id,
"heads": heads,
"membership": membership_obj,
"state": state_entries
}))
}
OutputFormat::Timeline => {
let mut displaynames: HashMap<String, String> = HashMap::new();
let mut sorted_events: Vec<&LeanEvent> = events_map.values().collect();
sorted_events.sort_by(|a, b| a.depth.cmp(&b.depth).then(a.event_id.cmp(&b.event_id)));
for ev in &sorted_events {
if ev.event_type == "m.room.member" {
if let Some(dn) = ev.content.get("displayname").and_then(|v| v.as_str()) {
if !dn.is_empty() {
displaynames.insert(ev.state_key.clone(), dn.to_string());
}
}
}
}
let get_name = |user_id: &str| -> String {
displaynames.get(user_id).cloned().unwrap_or_else(|| {
user_id
.split(':')
.next()
.unwrap_or(user_id)
.trim_start_matches('@')
.to_string()
})
};
let mut output = String::new();
let mut last_date = String::new();
for ev in &sorted_events {
let ts_ms = ev.origin_server_ts;
let ts_secs = (ts_ms / 1000) as i64;
let time_of_day = ((ts_secs % 86400 + 86400) % 86400) as u64;
let hours = time_of_day / 3600;
let minutes = (time_of_day % 3600) / 60;
let days = ts_secs.div_euclid(86400);
let (y, m, d) = epoch_days_to_ymd(days);
let month_names = [
"Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov",
"Dec",
];
let month_str = month_names.get((m - 1) as usize).unwrap_or(&"???");
let ampm = if hours < 12 { "AM" } else { "PM" };
let h12 = if hours == 0 {
12
} else if hours > 12 {
hours - 12
} else {
hours
};
let date = format!(
"{} {} {} {:02}:{:02} {}",
d, month_str, y, h12, minutes, ampm
);
let sender = get_name(&ev.sender);
let desc = match ev.event_type.as_str() {
"m.room.create" => format!("{} sent m.room.create state event", sender),
"m.room.member" => {
let membership = ev
.content
.get("membership")
.and_then(|v| v.as_str())
.unwrap_or("?");
let target = get_name(&ev.state_key);
let reason = ev
.content
.get("reason")
.and_then(|v| v.as_str())
.unwrap_or("");
match membership {
"join" => format!("{} joined the room", target),
"leave" if ev.sender == ev.state_key => {
format!("{} left the room", target)
}
"leave" => format!(
"{} kicked {}{}",
sender,
target,
if reason.is_empty() {
String::new()
} else {
format!(" {}", reason)
}
),
"ban" => format!(
"{} banned {}{}",
sender,
target,
if reason.is_empty() {
String::new()
} else {
format!(" {}", reason)
}
),
"invite" => format!("{} invited {}", sender, target),
"knock" => format!("{} knocked", target),
_ => {
format!("{} set {}'s membership to {}", sender, target, membership)
}
}
}
"m.room.message" => {
let body = ev
.content
.get("body")
.and_then(|v| v.as_str())
.unwrap_or("");
let msgtype = ev
.content
.get("msgtype")
.and_then(|v| v.as_str())
.unwrap_or("m.text");
match msgtype {
"m.text" | "m.notice" => format!("{}: {}", sender, body),
"m.image" => format!("{} sent an image", sender),
"m.video" => format!("{} sent a video", sender),
"m.audio" => format!("{} sent an audio file", sender),
"m.file" => format!("{} sent a file", sender),
"m.emote" => format!("* {} {}", sender, body),
_ => format!("{} sent {}", sender, msgtype),
}
}
"m.room.name" => {
let name = ev
.content
.get("name")
.and_then(|v| v.as_str())
.unwrap_or("?");
format!("{} changed room name to \"{}\"", sender, name)
}
"m.room.topic" => {
let topic = ev
.content
.get("topic")
.and_then(|v| v.as_str())
.unwrap_or("?");
format!("{} changed room topic to \"{}\"", sender, topic)
}
"m.room.avatar" => format!("{} changed room avatar", sender),
"m.room.redaction" => format!("{} redacted an event", sender),
"m.reaction" => continue,
"m.sticker" => format!("{} sent a sticker", sender),
typ => format!("{} sent {} state event", sender, typ),
};
if date != last_date {
if !last_date.is_empty() {
output.push('\n');
}
last_date = date.clone();
}
output.push_str(&desc);
output.push('\n');
output.push_str(&date);
output.push('\n');
}
eprint!("{}", output);
Ok(serde_json::json!({
"status": "success",
"format": "timeline",
"events": event_count
}))
}
}
}
fn epoch_days_to_ymd(days: i64) -> (i64, u32, u32) {
let z = days + 719468;
let era = if z >= 0 { z } else { z - 146096 } / 146097;
let doe = (z - era * 146097) as u64;
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
let y = yoe as i64 + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
let y = if m <= 2 { y + 1 } else { y };
(y, m, d)
}
fn compute_auth_chain(
resolved_ids: &[String],
events_map: &HashMap<String, LeanEvent>,
) -> Vec<String> {
let mut auth_chain = std::collections::BTreeSet::new();
let mut stack = Vec::new();
for id in resolved_ids {
stack.push(id.clone());
}
while let Some(event_id) = stack.pop() {
if let Some(event) = events_map.get(&event_id) {
for auth_id in &event.auth_events {
if !auth_chain.contains(auth_id) {
auth_chain.insert(auth_id.clone());
stack.push(auth_id.clone());
}
}
}
}
auth_chain.into_iter().collect()
}
fn main() {
let args = Args::parse();
match run_cli(&args) {
Ok(output) => {
let output_writer: Box<dyn Write> = match args.output {
Some(path) => Box::new(BufWriter::new(
File::create(path).expect("Failed to create output file"),
)),
None => Box::new(BufWriter::new(io::stdout())),
};
let mut buffered_out = output_writer;
serde_json::to_writer_pretty(&mut buffered_out, &output)
.expect("Failed to write output");
if let Err(e) = writeln!(buffered_out) {
if e.kind() != std::io::ErrorKind::BrokenPipe {
panic!("Failed to write trailing newline: {}", e);
}
}
buffered_out.flush().expect("Failed to flush output buffer");
}
Err(e) => {
eprintln!("Error: {}", e);
let err_json = serde_json::json!({
"status": "error",
"error": e.to_string()
});
serde_json::to_writer_pretty(io::stderr(), &err_json).ok();
eprintln!();
std::process::exit(1);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn ev(id: &str, depth: u64) -> serde_json::Value {
json!({
"event_id": id,
"type": "m.room.member",
"state_key": format!("@user:{}", id),
"origin_server_ts": 1000 + depth,
"depth": depth,
"prev_events": [],
"auth_events": []
})
}
#[test]
fn test_merge_dedup_by_event_id() {
let a = vec![ev("$1", 1), ev("$2", 2), ev("$3", 3)];
let b = vec![ev("$2", 2), ev("$3", 3), ev("$4", 4)];
let result = merge_event_sets(
vec![("a.jsonl".into(), a), ("b.jsonl".into(), b)],
false,
true,
)
.unwrap();
let ids: Vec<&str> = result
.iter()
.map(|v| v["event_id"].as_str().unwrap())
.collect();
assert_eq!(ids, vec!["$1", "$2", "$3", "$4"]);
}
#[test]
fn test_merge_disjoint_fails() {
let a = vec![ev("$1", 1), ev("$2", 2)];
let b = vec![ev("$3", 3), ev("$4", 4)];
let result = merge_event_sets(
vec![("a.jsonl".into(), a), ("b.jsonl".into(), b)],
false,
true,
);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("Disjoint DAGs"));
}
#[test]
fn test_merge_pairwise_shared() {
let a = vec![ev("$1", 1), ev("$2", 2)];
let b = vec![ev("$2", 2), ev("$3", 3)];
let c = vec![ev("$3", 3), ev("$4", 4)];
let result = merge_event_sets(
vec![
("a.jsonl".into(), a),
("b.jsonl".into(), b),
("c.jsonl".into(), c),
],
false,
true,
)
.unwrap();
let ids: Vec<&str> = result
.iter()
.map(|v| v["event_id"].as_str().unwrap())
.collect();
assert_eq!(ids, vec!["$1", "$2", "$3", "$4"]);
}
#[test]
fn test_merge_single_file() {
let a = vec![ev("$1", 1), ev("$2", 2)];
let result = merge_event_sets(vec![("a.jsonl".into(), a)], false, true).unwrap();
assert_eq!(result.len(), 2);
}
#[test]
fn test_merge_complete_overlap() {
let a = vec![ev("$1", 1), ev("$2", 2), ev("$3", 3)];
let b = vec![ev("$1", 1), ev("$2", 2), ev("$3", 3)];
let result = merge_event_sets(
vec![("a.jsonl".into(), a), ("b.jsonl".into(), b)],
false,
true,
)
.unwrap();
assert_eq!(result.len(), 3);
}
#[test]
fn test_merge_subset() {
let large = vec![ev("$1", 1), ev("$2", 2), ev("$3", 3), ev("$4", 4)];
let small = vec![ev("$1", 1), ev("$2", 2)];
let result = merge_event_sets(
vec![("large.jsonl".into(), large), ("small.jsonl".into(), small)],
false,
true,
)
.unwrap();
assert_eq!(result.len(), 4);
}
#[test]
fn test_merge_debug_depths() {
let a = vec![ev("$1", 10), ev("$2", 20)];
let b = vec![ev("$2", 20), ev("$3", 30)];
let result = merge_event_sets(
vec![("a.jsonl".into(), a), ("b.jsonl".into(), b)],
true,
true,
)
.unwrap();
assert_eq!(result.len(), 3);
}
#[test]
fn test_merge_single_event_per_file() {
let a = vec![ev("$1", 1)];
let b = vec![ev("$1", 1)];
let result = merge_event_sets(
vec![("a.jsonl".into(), a), ("b.jsonl".into(), b)],
false,
true,
)
.unwrap();
assert_eq!(result.len(), 1);
}
#[test]
fn test_merge_two_single_events_disjoint() {
let a = vec![ev("$1", 1)];
let b = vec![ev("$2", 2)];
let result = merge_event_sets(
vec![("a.jsonl".into(), a), ("b.jsonl".into(), b)],
false,
true,
);
assert!(result.is_err());
}
#[test]
fn test_merge_two_events_one_shared() {
let a = vec![ev("$1", 1), ev("$2", 2)];
let b = vec![ev("$2", 2), ev("$3", 3)];
let result = merge_event_sets(
vec![("a.jsonl".into(), a), ("b.jsonl".into(), b)],
false,
true,
)
.unwrap();
assert_eq!(result.len(), 3);
}
#[test]
fn test_merge_one_file_only() {
let a = vec![ev("$1", 1)];
let result = merge_event_sets(vec![("a.jsonl".into(), a)], false, true).unwrap();
assert_eq!(result.len(), 1);
}
}