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)]
pub members: Vec<String>,
#[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 {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
Self {
id: format!("sec-{:08x}", nanos as u32),
name: name.into(),
members: Vec::new(),
collapsed: false,
banner_font: None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
pub struct SidebarModel {
#[serde(default)]
pub ungrouped: Vec<String>,
#[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> {
UngroupedSession(&'a str),
SectionHeader(&'a Section),
SectionMember {
section: &'a Section,
internal: &'a str,
},
}
impl<'a> VisibleEntry<'a> {
pub fn kind(&self) -> VisibleKind {
match self {
Self::UngroupedSession(_) => VisibleKind::Ungrouped,
Self::SectionHeader(_) => VisibleKind::Header,
Self::SectionMember { .. } => VisibleKind::Member,
}
}
pub fn session_name(&self) -> Option<&'a str> {
match self {
Self::UngroupedSession(n) => Some(n),
Self::SectionMember { internal, .. } => Some(internal),
Self::SectionHeader(_) => None,
}
}
pub fn identity(&self) -> &'a str {
match self {
Self::UngroupedSession(n) => n,
Self::SectionHeader(s) => &s.id,
Self::SectionMember { internal, .. } => internal,
}
}
}
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 n in &self.ungrouped {
out.push(VisibleEntry::UngroupedSession(n.as_str()));
}
for s in &self.sections {
out.push(VisibleEntry::SectionHeader(s));
if !s.collapsed {
for m in &s.members {
out.push(VisibleEntry::SectionMember {
section: s,
internal: m.as_str(),
});
}
}
}
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, n) in self.ungrouped.iter().enumerate() {
if n == ident {
return Some(self.flat_index(Location::Ungrouped(i)));
}
}
for (si, s) in self.sections.iter().enumerate() {
for (mi, n) in s.members.iter().enumerate() {
if n == ident {
return Some(self.flat_index(Location::Member(si, mi)));
}
}
}
None
}
pub fn reconcile(&mut self, live: &[String]) {
let mut seen = std::collections::HashSet::new();
self.ungrouped.retain(|n| seen.insert(n.clone()));
for s in &mut self.sections {
s.members.retain(|n| seen.insert(n.clone()));
}
for n in live {
if !seen.contains(n) {
self.ungrouped.push(n.clone());
seen.insert(n.clone());
}
}
}
pub fn remove_session(&mut self, internal: &str) {
self.ungrouped.retain(|n| n != internal);
for s in &mut self.sections {
s.members.retain(|n| n != internal);
}
}
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 sec(id: &str, name: &str, members: &[&str]) -> Section {
Section {
id: id.into(),
name: name.into(),
members: members.iter().map(|s| s.to_string()).collect(),
collapsed: false,
banner_font: None,
}
}
fn model(ungrouped: &[&str], sections: Vec<Section>) -> SidebarModel {
SidebarModel {
ungrouped: ungrouped.iter().map(|s| s.to_string()).collect(),
sections,
}
}
#[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(&["a".into(), "b".into(), "newbie".into()]);
assert_eq!(m.ungrouped, vec!["a".to_string(), "newbie".to_string()]);
assert_eq!(
m.sections[0].members,
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!(m.ungrouped, vec!["alpha".to_string(), "beta".to_string()]);
assert_eq!(
m.sections[0].members,
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(&["a".into(), "b".into(), "c".into()]);
assert_eq!(m.ungrouped, vec!["a".to_string(), "b".to_string()]);
assert_eq!(m.sections[0].members, vec!["c".to_string()]);
}
#[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!(m.ungrouped, vec!["beta".to_string()]);
assert_eq!(m.sections[0].members, 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!(
m.ungrouped,
vec!["a".to_string(), "b".to_string(), "c".to_string()]
);
assert!(m.sections.is_empty());
}
#[test]
fn find_identity_returns_flat_index() {
let m = model(&["a"], vec![sec("g1", "W", &["b"])]);
assert_eq!(m.find_identity("a"), Some(0));
assert_eq!(m.find_identity("g1"), Some(1));
assert_eq!(m.find_identity("b"), Some(2));
assert!(m.find_identity("nope").is_none());
}
#[test]
fn roundtrip_toml() {
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);
}
}