use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::path::PathBuf;
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
struct Registry {
channels: BTreeMap<String, Vec<String>>,
}
pub(crate) fn safe_component(value: &str) -> bool {
!value.is_empty()
&& value
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b':'))
}
fn registry_path() -> PathBuf {
crate::paths::channels_dir().join("links.json")
}
fn load() -> Registry {
match crate::statefile::read_if_present(®istry_path()) {
Ok(Some(s)) => serde_json::from_str(&s).unwrap_or_default(),
_ => Registry::default(),
}
}
fn save(reg: &Registry) -> Result<(), String> {
let path = registry_path();
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir).map_err(|e| e.to_string())?;
}
let body = format!(
"{}\n",
serde_json::to_string_pretty(reg).map_err(|e| e.to_string())?
);
crate::statefile::write_atomic(&path, body.as_bytes()).map_err(|e| e.to_string())
}
pub fn logical_of(surface_id: &str) -> String {
let reg = load();
for (name, members) in ®.channels {
if members.iter().any(|m| m == surface_id) {
return name.clone();
}
}
surface_id.to_string()
}
pub fn surfaces_of(logical: &str) -> Vec<String> {
let reg = load();
reg.channels
.get(logical)
.cloned()
.unwrap_or_else(|| vec![logical.to_string()])
}
fn one_to_one(surface_id: &str) -> bool {
if surface_id.starts_with("term-") {
return true;
}
let Some(meta) = crate::channel::discord::channel_meta(surface_id) else {
return false;
};
match meta.get("class").and_then(|v| v.as_str()) {
Some(class) => class == "dm",
None => meta.get("server").is_none(),
}
}
pub fn in_registry(id: &str) -> Option<String> {
let reg = load();
if reg.channels.contains_key(id) {
return Some(id.to_string());
}
reg.channels
.iter()
.find(|(_, members)| members.iter().any(|m| m == id))
.map(|(name, _)| name.clone())
}
pub fn link(name: &str, surface_ids: &[String]) -> Result<(), String> {
if !safe_component(name) {
return Err("channel name must be a safe path component".into());
}
if surface_ids.is_empty() {
return Err("link needs at least one surface id".into());
}
let mut reg = load();
if !reg.channels.contains_key(name)
&& crate::paths::channel_meta_file(name).exists()
&& !surface_ids.iter().any(|s| s == name)
{
return Err(format!(
"\"{name}\" is an existing surface id — pick a fresh name for the linked channel"
));
}
for sid in surface_ids {
if !safe_component(sid) {
return Err(format!("\"{sid}\" is not a safe surface id"));
}
if !one_to_one(sid) {
return Err(format!(
"\"{sid}\" is not a 1:1 surface — v1 links DM-class and terminal surfaces only"
));
}
for (other, members) in ®.channels {
if other != name && members.iter().any(|m| m == sid) {
return Err(format!(
"\"{sid}\" is already linked into channel \"{other}\" — unlink it first"
));
}
}
}
let members = reg.channels.entry(name.to_string()).or_default();
let fresh: Vec<String> = surface_ids
.iter()
.filter(|sid| !members.iter().any(|m| m == *sid))
.cloned()
.collect();
members.extend(fresh.iter().cloned());
save(®)?;
for sid in &fresh {
merge_surface_material(name, sid);
}
crate::channel::discord::absorb_settings(name, &fresh)
}
fn merge_surface_material(name: &str, sid: &str) {
if sid == name {
return;
}
let src_dir = crate::paths::channel_dir(sid);
let dst_dir = crate::paths::channel_dir(name);
let _ = std::fs::create_dir_all(&dst_dir);
let src_msgs = src_dir.join("messages.jsonl");
let dst_msgs = dst_dir.join("messages.jsonl");
if src_msgs.is_file() {
let mut records: Vec<(String, String)> = Vec::new();
for path in [&dst_msgs, &src_msgs] {
for line in std::fs::read_to_string(path).unwrap_or_default().lines() {
let ts = serde_json::from_str::<serde_json::Value>(line)
.ok()
.and_then(|v| v.get("ts").and_then(|t| t.as_str()).map(String::from))
.unwrap_or_default();
records.push((ts, line.to_string()));
}
}
records.sort_by(|a, b| a.0.cmp(&b.0));
let body: String = records.into_iter().map(|(_, l)| l + "\n").collect();
if crate::statefile::write_atomic(&dst_msgs, body.as_bytes()).is_ok() {
let _ = std::fs::rename(&src_msgs, src_dir.join("messages.jsonl.linked"));
}
}
let src_files = src_dir.join("files");
if src_files.is_dir() {
let dst_files = dst_dir.join("files");
let _ = std::fs::create_dir_all(&dst_files);
for entry in std::fs::read_dir(&src_files)
.into_iter()
.flatten()
.flatten()
{
let to = dst_files.join(entry.file_name());
if !to.exists() {
let _ = std::fs::rename(entry.path(), to);
}
}
let _ = std::fs::remove_dir(&src_files);
}
let src_purpose = src_dir.join("purpose.md");
let dst_purpose = dst_dir.join("purpose.md");
if src_purpose.is_file() && !dst_purpose.exists() {
let _ = std::fs::rename(&src_purpose, &dst_purpose);
}
}
pub fn unlink(surface_id: &str) -> Result<String, String> {
let mut reg = load();
let Some((name, members)) = reg
.channels
.iter_mut()
.find(|(_, m)| m.iter().any(|s| s == surface_id))
.map(|(n, m)| (n.clone(), m))
else {
return Err(format!("\"{surface_id}\" is not linked into any channel"));
};
members.retain(|m| m != surface_id);
if members.len() <= 1 {
reg.channels.remove(&name);
}
save(®)?;
Ok(name)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn link_resolve_unlink_lifecycle() {
let _guard = crate::statefile::TEST_ENV_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let dir = tempfile::tempdir().unwrap();
std::env::set_var("ROSTER_ROOT", dir.path());
assert_eq!(logical_of("term-manas-dobby"), "term-manas-dobby");
assert_eq!(surfaces_of("term-manas-dobby"), vec!["term-manas-dobby"]);
crate::channel::discord::write_channel_meta(
"900",
&serde_json::json!({ "platform": "discord", "class": "dm" }),
);
crate::channel::discord::persist_message(
"term-manas-dobby",
&serde_json::json!({ "ts": "2026-07-01T10:00:00Z", "content": "first" }),
);
crate::channel::discord::persist_message(
"900",
&serde_json::json!({ "ts": "2026-07-01T09:00:00Z", "content": "earlier" }),
);
link("manas", &["term-manas-dobby".into(), "900".into()]).unwrap();
assert_eq!(logical_of("900"), "manas");
assert_eq!(logical_of("term-manas-dobby"), "manas");
assert_eq!(surfaces_of("manas"), vec!["term-manas-dobby", "900"]);
let merged =
std::fs::read_to_string(crate::paths::channel_dir("manas").join("messages.jsonl"))
.unwrap();
let posts: Vec<&str> = merged.lines().collect();
assert!(
posts[0].contains("earlier") && posts[1].contains("first"),
"{merged}"
);
assert!(crate::paths::channel_dir("900")
.join("messages.jsonl.linked")
.is_file());
crate::channel::discord::persist_message(
"900",
&serde_json::json!({ "ts": "2026-07-01T11:00:00Z", "content": "after" }),
);
let merged =
std::fs::read_to_string(crate::paths::channel_dir("manas").join("messages.jsonl"))
.unwrap();
assert_eq!(merged.lines().count(), 3);
assert!(crate::channel::discord::channel_trusted("900"));
assert!(crate::channel::discord::channel_trusted("manas"));
crate::channel::discord::write_channel_meta(
"800",
&serde_json::json!({ "platform": "discord", "class": "public" }),
);
assert!(link("manas", &["800".into()]).unwrap_err().contains("1:1"));
crate::channel::discord::write_channel_meta(
"700",
&serde_json::json!({ "platform": "discord", "name": "DM with jane" }),
);
crate::channel::discord::write_channel_meta(
"600",
&serde_json::json!({ "platform": "discord", "server": "acme", "name": "general" }),
);
link("manas", &["700".into()]).unwrap();
assert_eq!(logical_of("700"), "manas");
assert!(link("manas", &["600".into()]).unwrap_err().contains("1:1"));
assert_eq!(unlink("700").unwrap(), "manas");
assert!(link("other", &["900".into()])
.unwrap_err()
.contains("already linked"));
assert_eq!(unlink("900").unwrap(), "manas");
assert_eq!(logical_of("900"), "900");
assert_eq!(logical_of("term-manas-dobby"), "term-manas-dobby");
assert!(unlink("900").is_err());
std::env::remove_var("ROSTER_ROOT");
}
}