use std::collections::{HashMap, VecDeque};
use std::fmt::Write;
use std::sync::{Mutex, OnceLock};
use tracing::warn;
use crate::util::UnwrapPoison;
const PER_WORKSPACE_CAPACITY: usize = 100;
#[derive(Clone)]
struct Entry {
id: String,
old_status: String,
new_status: String,
}
static TICKET_BUFFER: OnceLock<Mutex<HashMap<String, VecDeque<Entry>>>> = OnceLock::new();
pub fn init_global() {
TICKET_BUFFER
.set(Mutex::new(HashMap::new()))
.map_err(|_| "TICKET_BUFFER already initialized")
.expect("TICKET_BUFFER already initialized");
}
pub fn push(workspace_name: &str, id: &str, old_status: &str, new_status: &str) {
let Some(mutex) = TICKET_BUFFER.get() else {
warn!("ticket_buffer not initialized — call init_global() first");
return;
};
let mut map = mutex.lock().unwrap_poison();
let deque = match map.get_mut(workspace_name) {
Some(d) => d,
None => map.entry(workspace_name.to_string()).or_default(),
};
if deque.len() >= PER_WORKSPACE_CAPACITY {
warn!(
workspace = %workspace_name,
capacity = PER_WORKSPACE_CAPACITY,
"Ticket buffer overflow — dropping oldest entry"
);
deque.pop_front();
}
deque.push_back(Entry {
id: id.to_string(),
old_status: old_status.to_string(),
new_status: new_status.to_string(),
});
}
pub fn drain(workspace_name: &str) -> String {
let Some(mutex) = TICKET_BUFFER.get() else {
return String::new();
};
let mut map = mutex.lock().unwrap_poison();
let Some(entries) = map.remove(workspace_name) else {
return String::new();
};
if entries.is_empty() {
return String::new();
}
let mut out = String::from("Ticket updates:\n");
for entry in entries {
let _ = writeln!(
out,
"• {}: {} → {}",
entry.id, entry.old_status, entry.new_status
);
}
out
}
#[cfg(test)]
pub fn reset() {
match TICKET_BUFFER.get() {
Some(mutex) => {
mutex.lock().unwrap_poison().clear();
}
None => {
let _ = TICKET_BUFFER.set(Mutex::new(HashMap::new()));
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Mutex;
static TEST_LOCK: Mutex<()> = Mutex::new(());
#[test]
fn push_and_drain_ordered() {
let _guard = TEST_LOCK.lock().unwrap();
reset();
push("ws-a", "mahbot-1", "backlog", "analysis");
push("ws-a", "mahbot-2", "analysis", "planning");
push("ws-a", "mahbot-3", "in_development", "in_diagnostics");
let result = drain("ws-a");
assert!(result.contains("mahbot-1: backlog → analysis"));
assert!(result.contains("mahbot-2: analysis → planning"));
assert!(result.contains("mahbot-3: in_development → in_diagnostics"));
let pos1 = result.find("mahbot-1").unwrap();
let pos2 = result.find("mahbot-2").unwrap();
let pos3 = result.find("mahbot-3").unwrap();
assert!(pos1 < pos2 && pos2 < pos3);
}
#[test]
fn drain_nonexistent_returns_empty() {
let _guard = TEST_LOCK.lock().unwrap();
reset();
assert_eq!(drain("nonexistent"), "");
}
#[test]
fn overflow_drops_oldest() {
let _guard = TEST_LOCK.lock().unwrap();
reset();
for i in 0..101 {
push("ws-b", &format!("mahbot-{i}"), "backlog", "analysis");
}
let result = drain("ws-b");
assert!(!result.contains("mahbot-0"));
assert!(result.contains("mahbot-1"));
assert!(result.contains("mahbot-100"));
assert_eq!(result.lines().count(), 101);
}
#[test]
fn workspace_isolation() {
let _guard = TEST_LOCK.lock().unwrap();
reset();
push("ws-a", "mahbot-1", "backlog", "analysis");
push(
"ws-b",
"mahbot-2",
"ready_for_development",
"in_development",
);
let result_a = drain("ws-a");
assert!(result_a.contains("mahbot-1"));
assert!(!result_a.contains("mahbot-2"));
let result_b = drain("ws-b");
assert!(result_b.contains("mahbot-2"));
assert!(!result_b.contains("mahbot-1"));
}
#[test]
fn drain_consumes_entries() {
let _guard = TEST_LOCK.lock().unwrap();
reset();
push("ws-a", "mahbot-1", "backlog", "analysis");
let first = drain("ws-a");
assert!(!first.is_empty());
let second = drain("ws-a");
assert!(second.is_empty());
}
}