use std::sync::atomic::{AtomicU32, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Section {
pub id: String,
pub name: String,
#[serde(default, deserialize_with = "deserialize_containers")]
pub members: Vec<Container>,
#[serde(default, skip_serializing_if = "is_false")]
pub collapsed: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub banner_font: Option<String>,
}
fn is_false(b: &bool) -> bool {
!*b
}
impl Section {
pub fn new(name: impl Into<String>) -> Self {
Self {
id: next_id("sec"),
name: name.into(),
members: Vec::new(),
collapsed: false,
banner_font: None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Container {
pub id: String,
pub name: String,
pub members: Vec<String>,
pub active: String,
}
impl Container {
pub fn single(internal: String, name: String) -> Self {
Self {
id: next_id("con"),
name,
active: internal.clone(),
members: vec![internal],
}
}
pub fn with_id(id: String, internal: String, name: String) -> Self {
Self {
id,
name,
active: internal.clone(),
members: vec![internal],
}
}
pub fn add_tab(&mut self, internal: String) {
if self.members.iter().any(|m| m == &internal) {
self.active = internal;
return;
}
self.active = internal.clone();
self.members.push(internal);
}
pub fn contains_internal(&self, internal: &str) -> bool {
self.members.iter().any(|m| m == internal)
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(untagged)]
enum StoredContainer {
Legacy(String),
Container(Container),
}
impl From<StoredContainer> for Container {
fn from(s: StoredContainer) -> Self {
match s {
StoredContainer::Legacy(internal) => Container::single(internal.clone(), internal),
StoredContainer::Container(c) => c,
}
}
}
fn deserialize_containers<'de, D>(de: D) -> Result<Vec<Container>, D::Error>
where
D: serde::Deserializer<'de>,
{
let stored: Vec<StoredContainer> = Vec::deserialize(de)?;
Ok(stored.into_iter().map(Container::from).collect())
}
fn next_id(prefix: &str) -> String {
static SEQ: AtomicU32 = AtomicU32::new(0);
let seq = SEQ.fetch_add(1, Ordering::Relaxed);
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos() as u32)
.unwrap_or(0);
format!("{}-{:08x}", prefix, nanos.wrapping_add(seq))
}
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
pub struct SidebarModel {
#[serde(default, deserialize_with = "deserialize_containers")]
pub ungrouped: Vec<Container>,
#[serde(default)]
pub sections: Vec<Section>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum VisibleKind {
Ungrouped,
Header,
Member,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Location {
Ungrouped(usize),
Header(usize),
Member(usize, usize),
}
#[derive(Debug, Clone, Copy)]
pub enum VisibleEntry<'a> {
Ungrouped(&'a Container),
SectionHeader(&'a Section),
Member {
section: &'a Section,
container: &'a Container,
},
}
impl<'a> VisibleEntry<'a> {
pub fn kind(&self) -> VisibleKind {
match self {
Self::Ungrouped(_) => VisibleKind::Ungrouped,
Self::SectionHeader(_) => VisibleKind::Header,
Self::Member { .. } => VisibleKind::Member,
}
}
pub fn session_name(&self) -> Option<&'a str> {
match self {
Self::Ungrouped(c) => Some(c.active.as_str()),
Self::Member { container, .. } => Some(container.active.as_str()),
Self::SectionHeader(_) => None,
}
}
pub fn container(&self) -> Option<&'a Container> {
match self {
Self::Ungrouped(c) => Some(c),
Self::Member { container, .. } => Some(container),
Self::SectionHeader(_) => None,
}
}
pub fn identity(&self) -> &'a str {
match self {
Self::Ungrouped(c) => &c.id,
Self::SectionHeader(s) => &s.id,
Self::Member { container, .. } => &container.id,
}
}
}
impl SidebarModel {
fn visible_member_count(s: &Section) -> usize {
if s.collapsed {
0
} else {
s.members.len()
}
}
pub fn len(&self) -> usize {
self.ungrouped.len()
+ self
.sections
.iter()
.map(|s| 1 + Self::visible_member_count(s))
.sum::<usize>()
}
pub fn is_empty(&self) -> bool {
self.len() == 0
}
pub fn visible(&self) -> Vec<VisibleEntry<'_>> {
let mut out = Vec::with_capacity(self.len());
for c in &self.ungrouped {
out.push(VisibleEntry::Ungrouped(c));
}
for s in &self.sections {
out.push(VisibleEntry::SectionHeader(s));
if !s.collapsed {
for c in &s.members {
out.push(VisibleEntry::Member {
section: s,
container: c,
});
}
}
}
out
}
pub fn locate(&self, idx: usize) -> Option<Location> {
if idx < self.ungrouped.len() {
return Some(Location::Ungrouped(idx));
}
let mut cursor = self.ungrouped.len();
for (si, sec) in self.sections.iter().enumerate() {
if idx == cursor {
return Some(Location::Header(si));
}
let next = cursor + 1 + Self::visible_member_count(sec);
if idx < next {
return Some(Location::Member(si, idx - cursor - 1));
}
cursor = next;
}
None
}
pub fn flat_index(&self, loc: Location) -> usize {
match loc {
Location::Ungrouped(i) => i.min(self.ungrouped.len()),
Location::Header(si) => {
let mut idx = self.ungrouped.len();
let bound = si.min(self.sections.len());
for s in &self.sections[..bound] {
idx += 1 + Self::visible_member_count(s);
}
idx
}
Location::Member(si, mi) => {
if si >= self.sections.len() {
return self.len();
}
let mut idx = self.ungrouped.len();
for s in &self.sections[..si] {
idx += 1 + Self::visible_member_count(s);
}
let header = idx;
if self.sections[si].collapsed {
return header;
}
header + 1 + mi.min(self.sections[si].members.len())
}
}
}
pub fn find_identity(&self, ident: &str) -> Option<usize> {
for (si, s) in self.sections.iter().enumerate() {
if s.id == ident {
return Some(self.flat_index(Location::Header(si)));
}
}
for (i, c) in self.ungrouped.iter().enumerate() {
if c.id == ident {
return Some(self.flat_index(Location::Ungrouped(i)));
}
}
for (si, s) in self.sections.iter().enumerate() {
for (mi, c) in s.members.iter().enumerate() {
if c.id == ident {
return Some(self.flat_index(Location::Member(si, mi)));
}
}
}
for (i, c) in self.ungrouped.iter().enumerate() {
if c.contains_internal(ident) {
return Some(self.flat_index(Location::Ungrouped(i)));
}
}
for (si, s) in self.sections.iter().enumerate() {
for (mi, c) in s.members.iter().enumerate() {
if c.contains_internal(ident) {
return Some(self.flat_index(Location::Member(si, mi)));
}
}
}
None
}
pub fn reconcile(&mut self, live: &[(String, Option<String>)]) -> bool {
let mut changed = false;
let mut seen = std::collections::HashSet::new();
for c in &mut self.ungrouped {
let before = c.members.len();
c.members.retain(|m| seen.insert(m.clone()));
if c.members.len() != before {
changed = true;
}
if !c.members.iter().any(|m| m == &c.active) {
if let Some(first) = c.members.first() {
c.active = first.clone();
changed = true;
}
}
}
let before = self.ungrouped.len();
self.ungrouped.retain(|c| !c.members.is_empty());
if self.ungrouped.len() != before {
changed = true;
}
for s in &mut self.sections {
for c in &mut s.members {
let before = c.members.len();
c.members.retain(|m| seen.insert(m.clone()));
if c.members.len() != before {
changed = true;
}
if !c.members.iter().any(|m| m == &c.active) {
if let Some(first) = c.members.first() {
c.active = first.clone();
changed = true;
}
}
}
let before = s.members.len();
s.members.retain(|c| !c.members.is_empty());
if s.members.len() != before {
changed = true;
}
}
for (n, cid) in live {
if seen.contains(n) {
continue;
}
let joined = match cid {
Some(id) => self.add_tab_to_container(id, n.clone()),
None => false,
};
if !joined {
let container = match cid {
Some(id) => Container::with_id(id.clone(), n.clone(), n.clone()),
None => Container::single(n.clone(), n.clone()),
};
self.ungrouped.push(container);
}
seen.insert(n.clone());
changed = true;
}
changed
}
pub fn add_tab_to_container(&mut self, container_id: &str, internal: String) -> bool {
for c in &mut self.ungrouped {
if c.id == container_id {
c.add_tab(internal);
return true;
}
}
for s in &mut self.sections {
for c in &mut s.members {
if c.id == container_id {
c.add_tab(internal);
return true;
}
}
}
false
}
pub fn set_active_tab(&mut self, container_id: &str, internal: &str) -> bool {
for c in &mut self.ungrouped {
if c.id == container_id {
if c.contains_internal(internal) {
c.active = internal.to_string();
return true;
}
return false;
}
}
for s in &mut self.sections {
for c in &mut s.members {
if c.id == container_id {
if c.contains_internal(internal) {
c.active = internal.to_string();
return true;
}
return false;
}
}
}
false
}
pub fn find_container(&self, container_id: &str) -> Option<&Container> {
for c in &self.ungrouped {
if c.id == container_id {
return Some(c);
}
}
for s in &self.sections {
for c in &s.members {
if c.id == container_id {
return Some(c);
}
}
}
None
}
pub fn replace_session(&mut self, old: &str, new: &str) -> bool {
if old == new {
return false;
}
if self.contains(new) {
return false;
}
for c in &mut self.ungrouped {
if let Some(slot) = c.members.iter_mut().find(|m| *m == old) {
*slot = new.to_string();
if c.active == old {
c.active = new.to_string();
}
return true;
}
}
for s in &mut self.sections {
for c in &mut s.members {
if let Some(slot) = c.members.iter_mut().find(|m| *m == old) {
*slot = new.to_string();
if c.active == old {
c.active = new.to_string();
}
return true;
}
}
}
false
}
fn contains(&self, internal: &str) -> bool {
self.ungrouped.iter().any(|c| c.contains_internal(internal))
|| self
.sections
.iter()
.any(|s| s.members.iter().any(|c| c.contains_internal(internal)))
}
pub fn remove_session(&mut self, internal: &str) {
for c in &mut self.ungrouped {
c.members.retain(|m| m != internal);
if c.active == internal {
c.active = c.members.first().cloned().unwrap_or_default();
}
}
self.ungrouped.retain(|c| !c.members.is_empty());
for s in &mut self.sections {
for c in &mut s.members {
c.members.retain(|m| m != internal);
if c.active == internal {
c.active = c.members.first().cloned().unwrap_or_default();
}
}
s.members.retain(|c| !c.members.is_empty());
}
}
pub fn insert_section_at_end(&mut self, name: String) -> String {
let s = Section::new(name);
let id = s.id.clone();
self.sections.push(s);
id
}
pub fn rename_section(&mut self, id: &str, new_name: String) -> bool {
for s in &mut self.sections {
if s.id == id {
s.name = new_name;
return true;
}
}
false
}
pub fn delete_section_at(&mut self, si: usize) {
if si >= self.sections.len() {
return;
}
let mut sec = self.sections.remove(si);
self.ungrouped.append(&mut sec.members);
}
}
#[cfg(test)]
mod tests {
use super::*;
fn con(internal: &str) -> Container {
Container::single(internal.to_string(), internal.to_string())
}
fn sec(id: &str, name: &str, members: &[&str]) -> Section {
Section {
id: id.into(),
name: name.into(),
members: members.iter().map(|s| con(s)).collect(),
collapsed: false,
banner_font: None,
}
}
fn model(ungrouped: &[&str], sections: Vec<Section>) -> SidebarModel {
SidebarModel {
ungrouped: ungrouped.iter().map(|s| con(s)).collect(),
sections,
}
}
fn ungrouped_names(m: &SidebarModel) -> Vec<String> {
m.ungrouped.iter().map(|c| c.active.clone()).collect()
}
fn section_names(s: &Section) -> Vec<String> {
s.members.iter().map(|c| c.active.clone()).collect()
}
fn live(names: &[&str]) -> Vec<(String, Option<String>)> {
names.iter().map(|n| (n.to_string(), None)).collect()
}
#[test]
fn flat_index_matches_visible_iteration() {
let m = model(
&["a", "b"],
vec![sec("g1", "Work", &["c"]), sec("g2", "Play", &["d", "e"])],
);
let visible = m.visible();
assert_eq!(visible.len(), m.len());
for i in 0..m.len() {
let loc = m.locate(i).expect("locate");
assert_eq!(m.flat_index(loc), i, "round-trip failed at {}", i);
}
}
#[test]
fn locate_covers_all_zones() {
let m = model(&["a", "b"], vec![sec("g1", "W", &["c"])]);
assert!(matches!(m.locate(0), Some(Location::Ungrouped(0))));
assert!(matches!(m.locate(1), Some(Location::Ungrouped(1))));
assert!(matches!(m.locate(2), Some(Location::Header(0))));
assert!(matches!(m.locate(3), Some(Location::Member(0, 0))));
assert!(m.locate(4).is_none());
}
#[test]
fn reconcile_keeps_dead_sessions_appends_new() {
let mut m = model(&["a"], vec![sec("g1", "W", &["b", "gone"])]);
m.reconcile(&live(&["a", "b", "newbie"]));
assert_eq!(
ungrouped_names(&m),
vec!["a".to_string(), "newbie".to_string()]
);
assert_eq!(
section_names(&m.sections[0]),
vec!["b".to_string(), "gone".to_string()]
);
}
#[test]
fn reconcile_empty_live_preserves_everything() {
let mut m = model(
&["alpha", "beta"],
vec![sec("g1", "Work", &["gamma", "delta"])],
);
m.reconcile(&[]);
assert_eq!(
ungrouped_names(&m),
vec!["alpha".to_string(), "beta".to_string()]
);
assert_eq!(
section_names(&m.sections[0]),
vec!["gamma".to_string(), "delta".to_string()]
);
}
#[test]
fn reconcile_dedupes_across_buckets() {
let mut m = model(&["a", "b"], vec![sec("g1", "W", &["b", "c"])]);
m.reconcile(&live(&["a", "b", "c"]));
assert_eq!(ungrouped_names(&m), vec!["a".to_string(), "b".to_string()]);
assert_eq!(section_names(&m.sections[0]), vec!["c".to_string()]);
}
#[test]
fn reconcile_groups_new_session_by_container_id() {
let mut m = model(&["a", "b"], vec![]);
let target_id = m.ungrouped[1].id.clone();
m.reconcile(&[
("a".into(), None),
("b".into(), None),
("b2".into(), Some(target_id.clone())),
]);
assert_eq!(m.ungrouped.len(), 2);
assert_eq!(m.ungrouped[1].id, target_id);
assert_eq!(
m.ungrouped[1].members,
vec!["b".to_string(), "b2".to_string()]
);
assert_eq!(m.ungrouped[1].active, "b2");
}
#[test]
fn reconcile_unknown_container_id_creates_keyed_container() {
let mut m = model(&[], vec![]);
m.reconcile(&[("new".into(), Some("con-external".into()))]);
assert_eq!(m.ungrouped.len(), 1);
assert_eq!(m.ungrouped[0].id, "con-external");
assert_eq!(m.ungrouped[0].active, "new");
}
#[test]
fn remove_session_drops_from_both_buckets() {
let mut m = model(
&["alpha", "beta"],
vec![sec("g1", "W", &["gamma", "delta"])],
);
m.remove_session("alpha");
m.remove_session("gamma");
assert_eq!(ungrouped_names(&m), vec!["beta".to_string()]);
assert_eq!(section_names(&m.sections[0]), vec!["delta".to_string()]);
}
#[test]
fn delete_section_moves_members_to_ungrouped() {
let mut m = model(&["a"], vec![sec("g1", "W", &["b", "c"])]);
m.delete_section_at(0);
assert_eq!(
ungrouped_names(&m),
vec!["a".to_string(), "b".to_string(), "c".to_string()]
);
assert!(m.sections.is_empty());
}
#[test]
fn find_identity_matches_section_container_and_internal() {
let m = model(&["a"], vec![sec("g1", "W", &["b"])]);
assert_eq!(m.find_identity("g1"), Some(1));
let con_a = &m.ungrouped[0];
assert_eq!(m.find_identity(&con_a.id), Some(0));
assert_eq!(m.find_identity("a"), Some(0));
assert_eq!(m.find_identity("b"), Some(2));
assert!(m.find_identity("nope").is_none());
}
#[test]
fn replace_session_updates_active_too() {
let mut m = model(&["old"], vec![]);
assert!(m.replace_session("old", "new"));
assert_eq!(m.ungrouped[0].members, vec!["new".to_string()]);
assert_eq!(m.ungrouped[0].active, "new");
}
#[test]
fn roundtrip_toml_new_shape() {
let m = model(
&["bosun-alpha"],
vec![
sec("g1", "Premium", &["bosun-beta", "bosun-gamma"]),
sec("g2", "YetiDev", &[]),
],
);
let toml = toml::to_string(&m).expect("serialize");
let parsed: SidebarModel = toml::from_str(&toml).expect("parse");
assert_eq!(parsed, m);
}
#[test]
fn legacy_string_shape_deserializes_into_containers() {
let legacy = r#"
ungrouped = ["bosun-alpha", "bosun-beta"]
[[sections]]
id = "g1"
name = "Work"
members = ["bosun-gamma"]
"#;
let parsed: SidebarModel = toml::from_str(legacy).expect("legacy parse");
assert_eq!(parsed.ungrouped.len(), 2);
assert_eq!(parsed.ungrouped[0].members, vec!["bosun-alpha".to_string()]);
assert_eq!(parsed.ungrouped[0].active, "bosun-alpha");
assert!(parsed.ungrouped[0].id.starts_with("con-"));
assert_eq!(parsed.sections[0].members.len(), 1);
assert_eq!(
parsed.sections[0].members[0].members,
vec!["bosun-gamma".to_string()]
);
let re_emitted = toml::to_string(&parsed).expect("serialize");
assert!(
re_emitted.contains("[[ungrouped]]"),
"expected table form, got:\n{}",
re_emitted
);
}
}