use std::collections::{HashMap, VecDeque};
use std::fmt::Write;
use std::sync::{Mutex, OnceLock};
use tracing::warn;
use crate::board::TicketPhase;
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: TicketPhase, new_status: TicketPhase) {
let mutex = TICKET_BUFFER
.get()
.expect("ticket_buffer not initialized — call init_global() first");
let mut map = mutex.lock().unwrap_poison();
let deque = 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.as_ref().to_string(),
new_status: new_status.as_ref().to_string(),
});
}
pub fn drain(workspace_name: &str) -> String {
let mutex = TICKET_BUFFER
.get()
.expect("ticket_buffer not initialized — call init_global() first");
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 crate::board::TicketPhase;
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",
TicketPhase::Backlog,
TicketPhase::Analysis,
);
push(
"ws-a",
"mahbot-2",
TicketPhase::Analysis,
TicketPhase::Planning,
);
push(
"ws-a",
"mahbot-3",
TicketPhase::InDevelopment,
TicketPhase::InDiagnostics,
);
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}"),
TicketPhase::Backlog,
TicketPhase::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",
TicketPhase::Backlog,
TicketPhase::Analysis,
);
push(
"ws-b",
"mahbot-2",
TicketPhase::ReadyForDevelopment,
TicketPhase::InDevelopment,
);
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",
TicketPhase::Backlog,
TicketPhase::Analysis,
);
let first = drain("ws-a");
assert!(!first.is_empty());
let second = drain("ws-a");
assert!(second.is_empty());
}
}