use crate::{config, gitcmd, roster};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
#[derive(Serialize, Deserialize, Clone, PartialEq, Eq)]
pub struct Membership {
pub dir: String,
pub role: String,
}
#[derive(Serialize, Deserialize, Default)]
struct Registry {
#[serde(default)]
hubs: Vec<Membership>,
}
fn registry_path() -> Option<PathBuf> {
config::home().ok().map(|h| h.join(".confer").join("hubs.json"))
}
fn load() -> Vec<Membership> {
registry_path()
.and_then(|p| std::fs::read_to_string(p).ok())
.and_then(|s| serde_json::from_str::<Registry>(&s).ok())
.map(|r| r.hubs)
.unwrap_or_default()
}
fn write_registry(hubs: &[Membership]) {
if let Some(p) = registry_path() {
if let Some(d) = p.parent() {
let _ = std::fs::create_dir_all(d);
}
if let Ok(s) = serde_json::to_string_pretty(&Registry { hubs: hubs.to_vec() }) {
let _ = std::fs::write(p, s);
}
}
}
fn is_ephemeral(dir: &str) -> bool {
let temp = dir.contains("/tmp/") || dir.contains("/T/") || dir.contains("/var/folders/");
temp && dir.contains("/scratchpad/")
}
pub fn prune() -> Vec<Membership> {
let hubs = load();
let kept: Vec<Membership> = hubs
.iter()
.filter(|m| Path::new(&m.dir).is_dir() && !is_ephemeral(&m.dir))
.cloned()
.collect();
if kept.len() != hubs.len() {
write_registry(&kept);
}
kept
}
pub fn hub_dirs() -> Vec<PathBuf> {
let mut seen_dir = std::collections::HashSet::new();
let mut seen_hub = std::collections::HashSet::new();
let mut out = Vec::new();
for m in prune() {
let dir = PathBuf::from(&m.dir);
if !seen_dir.insert(dir.clone()) {
continue;
}
let hub_key = root_sha(&dir).unwrap_or_else(|| m.dir.clone());
if !seen_hub.insert(hub_key) {
continue;
}
out.push(dir);
}
out
}
pub fn record(dir: &Path, role: &str) {
if role.is_empty() {
return;
}
let dir = dir.canonicalize().unwrap_or_else(|_| dir.to_path_buf()).to_string_lossy().to_string();
let mut hubs = prune();
let m = Membership { dir, role: role.to_string() };
if !hubs.contains(&m) {
hubs.push(m);
write_registry(&hubs);
}
}
pub fn hub_label(dir: &Path) -> String {
if let Ok(o) = gitcmd::output(dir, &["config", "--get", "remote.origin.url"]) {
if o.status.success() {
let url = String::from_utf8_lossy(&o.stdout).trim().trim_end_matches(".git").to_string();
let normalized = url.replace(':', "/");
let segs: Vec<&str> = normalized.split('/').filter(|s| !s.is_empty()).collect();
if segs.len() >= 2 {
return format!("{}/{}", segs[segs.len() - 2], segs[segs.len() - 1]);
}
}
}
let anchor = root_sha(dir).map(|s| format!(" {}", &s[..s.len().min(8)])).unwrap_or_default();
format!("{} (local{anchor})", dir.file_name().and_then(|s| s.to_str()).unwrap_or("hub"))
}
pub fn root_sha(dir: &Path) -> Option<String> {
let o = gitcmd::output(dir, &["rev-list", "--max-parents=0", "HEAD"]).ok()?;
if !o.status.success() {
return None;
}
let s = String::from_utf8_lossy(&o.stdout);
s.split_whitespace().next().map(String::from)
}
pub fn fingerprint(pubkey: &str) -> String {
if let Ok(home) = config::home() {
use std::sync::atomic::{AtomicU64, Ordering};
static N: AtomicU64 = AtomicU64::new(0);
let n = N.fetch_add(1, Ordering::Relaxed);
let tmp = home
.join(".confer")
.join(format!("fp.{}.{n}.tmp", std::process::id()));
if std::fs::write(&tmp, pubkey).is_ok() {
let out = std::process::Command::new("ssh-keygen").arg("-lf").arg(&tmp).output();
let _ = std::fs::remove_file(&tmp);
if let Ok(o) = out {
if o.status.success() {
if let Some(fp) = String::from_utf8_lossy(&o.stdout)
.split_whitespace()
.find(|t| t.starts_with("SHA256:"))
{
return fp.to_string();
}
}
}
}
}
let body = pubkey.split_whitespace().nth(1).unwrap_or(pubkey);
let tail: String = {
let chars: Vec<char> = body.chars().collect();
chars[chars.len().saturating_sub(8)..].iter().collect()
};
format!("key:…{tail}")
}
pub fn appearances(exclude: &Path) -> HashMap<String, Vec<(String, String)>> {
let exclude = exclude.canonicalize().unwrap_or_else(|_| exclude.to_path_buf());
let mut idx: HashMap<String, Vec<(String, String)>> = HashMap::new();
for m in prune() {
let dir = PathBuf::from(&m.dir);
if dir.canonicalize().map(|d| d == exclude).unwrap_or(false) || !dir.is_dir() {
continue;
}
let label = hub_label(&dir);
for (rid, role) in roster::load(&dir) {
if let Some(pk) = role.pubkey {
idx.entry(pk).or_default().push((label.clone(), rid));
}
}
}
idx
}
#[cfg(test)]
mod tests {
use super::is_ephemeral;
#[test]
fn ephemeral_matches_scratchpad_test_hubs_only() {
assert!(is_ephemeral("/private/tmp/claude-501/x/scratchpad/tasklayer/wd"));
assert!(is_ephemeral("/var/folders/tw/x/T/sess/scratchpad/hub"));
assert!(!is_ephemeral("/private/var/folders/tw/x/T/confer-cli-5118-clone-carol-41"));
assert!(!is_ephemeral("/Users/me/git/team-hub"));
assert!(!is_ephemeral("/Users/me/git/proj/team-hub"));
assert!(!is_ephemeral("/home/me/scratchpad-notes")); }
}