use serde::Serialize;
use std::collections::BTreeSet;
use std::path::Path;
use std::time::Duration;
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
#[serde(tag = "state", rename_all = "snake_case")]
pub enum DaemonState {
Running { pid: u32 },
StalePid { pid: u32 },
None,
}
impl DaemonState {
pub fn is_running(&self) -> bool {
matches!(self, DaemonState::Running { .. })
}
}
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct PeerRow {
pub handle: String,
pub did: String,
pub tier: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct SessionSnapshot {
pub key: String,
pub handle: Option<String>,
pub did: Option<String>,
pub fingerprint: Option<String>,
pub nickname: Option<String>,
pub emoji: Option<String>,
pub primary_hex: Option<String>,
pub ansi256_primary: Option<u8>,
pub daemon: DaemonState,
pub daemon_version: Option<String>,
pub relay_url: Option<String>,
pub slot_id: Option<String>,
pub last_sync_age_s: Option<u64>,
pub peers: Vec<PeerRow>,
pub cwd: Option<String>,
pub likely_idle: bool,
pub retired: bool,
}
#[derive(Debug, Clone, Serialize)]
pub struct RelayHealth {
pub url: String,
pub ok: bool,
pub status: Option<u16>,
pub unprobed: bool,
}
#[derive(Debug, Clone, Serialize)]
pub struct DashReport {
pub schema: &'static str,
pub sessions: Vec<SessionSnapshot>,
pub relays: Vec<RelayHealth>,
}
#[derive(Debug, Clone, Default)]
pub struct CollectOpts {
pub probe_relays: bool,
}
pub const SCHEMA: &str = "wire-dash-v1";
const MAX_STATE_FILE: u64 = 256 * 1024;
fn read_capped(path: &Path) -> Option<Vec<u8>> {
use std::io::Read;
let f = std::fs::File::open(path).ok()?;
let mut buf = Vec::new();
f.take(MAX_STATE_FILE).read_to_end(&mut buf).ok()?;
Some(buf)
}
pub fn fingerprint_from_did(did: &str) -> Option<String> {
let tail = did.rsplit(':').next()?; let fp = tail.rsplit('-').next().unwrap_or("");
if fp.is_empty() {
return None;
}
Some(fp.chars().take(16).collect())
}
pub fn read_peers(home: &Path, own_did: Option<&str>, own_handle: Option<&str>) -> Vec<PeerRow> {
let path = home.join("config").join("wire").join("trust.json");
let Some(bytes) = read_capped(&path) else {
return Vec::new();
};
let Ok(v) = serde_json::from_slice::<serde_json::Value>(&bytes) else {
return Vec::new();
};
let Some(agents) = v.get("agents").and_then(|a| a.as_object()) else {
return Vec::new();
};
let mut out = Vec::new();
for (handle, rec) in agents {
let did = rec.get("did").and_then(|d| d.as_str()).unwrap_or("");
if own_did == Some(did) || own_handle == Some(handle.as_str()) {
continue;
}
out.push(PeerRow {
handle: handle.clone(),
did: did.to_string(),
tier: rec
.get("tier")
.and_then(|t| t.as_str())
.unwrap_or("UNTRUSTED")
.to_string(),
});
}
out.sort_by(|a, b| a.handle.cmp(&b.handle));
out
}
pub fn read_relay_binding(home: &Path) -> (Option<String>, Option<String>) {
let path = home.join("config").join("wire").join("relay.json");
let Some(bytes) = read_capped(&path) else {
return (None, None);
};
let Ok(v) = serde_json::from_slice::<serde_json::Value>(&bytes) else {
return (None, None);
};
let sf = v.get("self");
let url = sf
.and_then(|s| s.get("relay_url"))
.and_then(|u| u.as_str())
.map(|s| s.to_string());
let slot = sf
.and_then(|s| s.get("slot_id"))
.and_then(|u| u.as_str())
.map(|s| s.to_string());
(url, slot)
}
pub fn last_sync_age_s(home: &Path) -> Option<u64> {
let path = home.join("state").join("wire").join("last_sync.json");
let mtime = std::fs::metadata(&path).ok()?.modified().ok()?;
mtime.elapsed().ok().map(|d| d.as_secs())
}
fn read_daemon_version(home: &Path) -> Option<String> {
let path = home.join("state").join("wire").join("daemon.pid");
let bytes = read_capped(&path)?;
let v = serde_json::from_slice::<serde_json::Value>(&bytes).ok()?;
v.get("version")
.and_then(|s| s.as_str())
.map(|s| s.to_string())
}
fn snapshot_one(si: &crate::session::SessionInfo) -> SessionSnapshot {
let home = &si.home_dir;
let pid = crate::session::session_daemon_pid(home);
let daemon = match (pid, si.daemon_running) {
(Some(pid), true) => DaemonState::Running { pid },
(Some(pid), false) => DaemonState::StalePid { pid },
(None, _) => DaemonState::None,
};
let peers = read_peers(home, si.did.as_deref(), si.handle.as_deref());
let (relay_url, slot_id) = read_relay_binding(home);
let (nickname, emoji, primary_hex, ansi256_primary) = match &si.character {
Some(c) => (
Some(c.nickname.clone()),
Some(c.emoji.clone()),
Some(c.palette.primary_hex.clone()),
Some(c.palette.ansi256_primary),
),
None => (None, None, None, None),
};
let retired = crate::retire::is_retired(home);
let likely_idle = daemon.is_running() && peers.is_empty() && !retired;
SessionSnapshot {
key: si.name.clone(),
handle: si.handle.clone(),
fingerprint: si.did.as_deref().and_then(fingerprint_from_did),
did: si.did.clone(),
nickname,
emoji,
primary_hex,
ansi256_primary,
daemon,
daemon_version: read_daemon_version(home),
relay_url,
slot_id,
last_sync_age_s: last_sync_age_s(home),
peers,
cwd: si.cwd.clone(),
likely_idle,
retired,
}
}
fn probe_relay(url: &str) -> RelayHealth {
let base = url.trim_end_matches('/');
if !(base.starts_with("http://") || base.starts_with("https://")) {
return RelayHealth {
url: url.to_string(),
ok: false,
status: None,
unprobed: false,
};
}
let build = reqwest::blocking::Client::builder()
.timeout(Duration::from_secs(2))
.redirect(reqwest::redirect::Policy::none())
.build();
let Ok(client) = build else {
return RelayHealth {
url: url.to_string(),
ok: false,
status: None,
unprobed: false,
};
};
match client.get(format!("{base}/healthz")).send() {
Ok(r) => RelayHealth {
url: url.to_string(),
ok: r.status().is_success(),
status: Some(r.status().as_u16()),
unprobed: false,
},
Err(_) => RelayHealth {
url: url.to_string(),
ok: false,
status: None,
unprobed: false,
},
}
}
pub fn collect(opts: &CollectOpts) -> anyhow::Result<DashReport> {
let sessions = crate::session::list_sessions()?;
let mut snaps = Vec::with_capacity(sessions.len());
let mut relay_urls: BTreeSet<String> = BTreeSet::new();
for si in &sessions {
let snap = snapshot_one(si);
if let Some(u) = &snap.relay_url {
relay_urls.insert(u.clone());
}
snaps.push(snap);
}
snaps.sort_by(|a, b| {
b.peers
.len()
.cmp(&a.peers.len())
.then(b.daemon.is_running().cmp(&a.daemon.is_running()))
.then(a.key.cmp(&b.key))
});
let relays = relay_urls
.into_iter()
.map(|u| {
if opts.probe_relays {
probe_relay(&u)
} else {
RelayHealth {
url: u,
ok: false,
status: None,
unprobed: true,
}
}
})
.collect();
Ok(DashReport {
schema: SCHEMA,
sessions: snaps,
relays,
})
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
#[test]
fn fingerprint_extracts_trailing_hex() {
assert_eq!(
fingerprint_from_did("did:wire:terra-plain-e6511a52").as_deref(),
Some("e6511a52")
);
assert_eq!(
fingerprint_from_did("did:wire:a-b-c-deadbeef").as_deref(),
Some("deadbeef")
);
assert_eq!(fingerprint_from_did("garbage").as_deref(), Some("garbage"));
}
#[test]
fn read_peers_excludes_self_and_reads_tier() {
let dir = tempfile::tempdir().unwrap();
let cw = dir.path().join("config").join("wire");
fs::create_dir_all(&cw).unwrap();
fs::write(
cw.join("trust.json"),
r#"{"agents":{
"terra-plain":{"did":"did:wire:terra-plain-e6511a52","tier":"ATTESTED"},
"raven-kettle":{"did":"did:wire:raven-kettle-11112222","tier":"VERIFIED"}
},"version":1}"#,
)
.unwrap();
let peers = read_peers(
dir.path(),
Some("did:wire:terra-plain-e6511a52"),
Some("terra-plain"),
);
assert_eq!(peers.len(), 1, "self must be excluded");
assert_eq!(peers[0].handle, "raven-kettle");
assert_eq!(peers[0].tier, "VERIFIED");
}
#[test]
fn read_peers_excludes_self_by_handle_when_did_missing() {
let dir = tempfile::tempdir().unwrap();
let cw = dir.path().join("config").join("wire");
fs::create_dir_all(&cw).unwrap();
fs::write(
cw.join("trust.json"),
r#"{"agents":{
"terra-plain":{"tier":"ATTESTED"},
"raven-kettle":{"did":"did:wire:raven-kettle-11112222","tier":"VERIFIED"}
},"version":1}"#,
)
.unwrap();
let peers = read_peers(
dir.path(),
Some("did:wire:terra-plain-e6511a52"),
Some("terra-plain"),
);
assert_eq!(peers.len(), 1, "did-less self entry excluded by handle");
assert_eq!(peers[0].handle, "raven-kettle");
}
#[test]
fn read_peers_missing_file_is_empty() {
let dir = tempfile::tempdir().unwrap();
assert!(read_peers(dir.path(), None, None).is_empty());
}
#[test]
fn fingerprint_none_for_empty_tail() {
assert_eq!(fingerprint_from_did("did:wire:"), None);
assert_eq!(fingerprint_from_did(""), None);
}
#[test]
fn dash_report_json_shape_is_stable() {
let report = DashReport {
schema: SCHEMA,
sessions: vec![SessionSnapshot {
key: "k".into(),
handle: Some("h".into()),
did: Some("did:wire:h-deadbeef".into()),
fingerprint: Some("deadbeef".into()),
nickname: Some("h".into()),
emoji: Some("🦊".into()),
primary_hex: Some("#da60a3".into()),
ansi256_primary: Some(175),
daemon: DaemonState::StalePid { pid: 9 },
daemon_version: Some("0.16.0".into()),
relay_url: Some("https://wireup.net".into()),
slot_id: Some("s".into()),
last_sync_age_s: Some(5),
peers: vec![],
cwd: Some("/tmp/x".into()),
likely_idle: false,
retired: false,
}],
relays: vec![],
};
let v = serde_json::to_value(&report).unwrap();
assert_eq!(v["schema"], "wire-dash-v1");
let s = &v["sessions"][0];
for key in [
"key",
"handle",
"did",
"fingerprint",
"nickname",
"emoji",
"primary_hex",
"ansi256_primary",
"daemon",
"daemon_version",
"relay_url",
"slot_id",
"last_sync_age_s",
"peers",
"cwd",
"likely_idle",
"retired",
] {
assert!(s.get(key).is_some(), "missing golden field: {key}");
}
assert_eq!(s["daemon"]["state"], "stale_pid");
assert_eq!(s["daemon"]["pid"], 9);
}
#[test]
fn read_relay_binding_parses_self() {
let dir = tempfile::tempdir().unwrap();
let cw = dir.path().join("config").join("wire");
fs::create_dir_all(&cw).unwrap();
fs::write(
cw.join("relay.json"),
r#"{"self":{"relay_url":"https://wireup.net","slot_id":"abc123"},"peers":{}}"#,
)
.unwrap();
let (url, slot) = read_relay_binding(dir.path());
assert_eq!(url.as_deref(), Some("https://wireup.net"));
assert_eq!(slot.as_deref(), Some("abc123"));
}
#[test]
fn daemon_state_serializes_with_tag() {
let j = serde_json::to_value(DaemonState::Running { pid: 42 }).unwrap();
assert_eq!(j["state"], "running");
assert_eq!(j["pid"], 42);
let n = serde_json::to_value(DaemonState::None).unwrap();
assert_eq!(n["state"], "none");
}
}