use crate::config;
use anyhow::Result;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
#[derive(Serialize, Deserialize, Default)]
pub struct Registry {
pub enabled: bool,
#[serde(default)]
pub targets: Vec<Target>,
}
#[derive(Serialize, Deserialize, Clone)]
pub struct Target {
pub hub: String,
pub role: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub session: Option<String>,
}
pub fn current_session() -> Option<String> {
std::env::var("CLAUDE_CODE_SESSION_ID").ok().filter(|s| !s.is_empty())
}
fn path() -> Result<PathBuf> {
Ok(config::home()?.join(".confer").join("autoheal.json"))
}
pub fn load() -> Registry {
path()
.ok()
.and_then(|p| std::fs::read_to_string(p).ok())
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_default()
}
pub fn save(r: &Registry) -> Result<()> {
let p = path()?;
if let Some(d) = p.parent() {
std::fs::create_dir_all(d)?;
}
std::fs::write(p, serde_json::to_string_pretty(r)?)?;
Ok(())
}
pub fn set_enabled(on: bool) -> Result<()> {
let mut r = load();
r.enabled = on;
save(&r)
}
pub fn add_target(hub: &str, role: &str) {
if role.is_empty() {
return;
}
let mut r = load();
let session = current_session();
if let Some(t) = r.targets.iter_mut().find(|t| t.hub == hub && t.role == role) {
if t.session != session {
t.session = session;
let _ = save(&r);
}
} else {
r.targets.push(Target { hub: hub.to_string(), role: role.to_string(), session });
let _ = save(&r);
}
}
pub fn retarget(old: &str, new: &str) {
let mut r = load();
let mut changed = false;
for t in &mut r.targets {
if t.hub == old {
t.hub = new.to_string();
changed = true;
}
}
if changed {
let _ = save(&r);
}
}
pub fn stale_targets() -> Vec<Target> {
load()
.targets
.into_iter()
.filter(|t| !std::path::Path::new(&t.hub).exists())
.collect()
}
pub fn prune() -> Vec<Target> {
let mut r = load();
let (live, dead): (Vec<Target>, Vec<Target>) = r
.targets
.drain(..)
.partition(|t| std::path::Path::new(&t.hub).exists());
if !dead.is_empty() {
r.targets = live;
let _ = save(&r);
}
dead
}
pub fn owned_by_session(
t: &Target,
me_session: &Option<String>,
me_role: &Option<String>,
) -> bool {
if let Some(s) = me_session {
if t.session.as_deref() == Some(s.as_str()) {
return true;
}
}
if let Some(r) = me_role {
if &t.role == r {
return true;
}
}
false
}
#[cfg(test)]
mod tests {
use super::*;
fn t(session: Option<&str>, role: &str) -> Target {
Target { hub: "/x".into(), role: role.into(), session: session.map(String::from) }
}
#[test]
fn scoping_owns_my_session_and_my_role_but_never_a_peer() {
let me_s = Some("sess-A".to_string());
let me_r = Some("alice".to_string());
assert!(owned_by_session(&t(Some("sess-A"), "alice"), &me_s, &me_r));
assert!(owned_by_session(&t(Some("sess-OLD"), "alice"), &me_s, &me_r));
assert!(owned_by_session(&t(None, "alice"), &me_s, &me_r));
assert!(!owned_by_session(&t(Some("sess-B"), "carol"), &me_s, &me_r));
assert!(!owned_by_session(&t(None, "carol"), &me_s, &me_r));
}
#[test]
fn no_identity_owns_nothing_rather_than_over_listing() {
let none: Option<String> = None;
assert!(!owned_by_session(&t(Some("sess-A"), "alice"), &none, &none));
assert!(!owned_by_session(&t(None, "alice"), &none, &none));
let me_r = Some("alice".to_string());
assert!(owned_by_session(&t(None, "alice"), &none, &me_r));
assert!(!owned_by_session(&t(None, "carol"), &none, &me_r));
}
}