use chrono::{DateTime, Local};
use std::collections::{HashMap, VecDeque};
use std::sync::Arc;
use std::time::Instant;
use tokio::sync::Mutex;
pub const MAX_MESSAGES: usize = 1000;
pub const MAX_ERRORS: usize = 100;
#[derive(Debug, Clone, Default)]
pub struct SubStats {
pub message_count: u64,
}
#[derive(Debug, Clone)]
pub struct DisplayMessage {
pub timestamp: DateTime<Local>,
pub destination: String,
pub body: String,
pub headers: Vec<(String, String)>,
}
pub struct AppState {
pub start_time: DateTime<Local>,
pub host: String,
pub user: String,
pub heartbeat_interval_ms: u32,
pub subscriptions: HashMap<String, SubStats>,
pub heartbeat_count: u64,
pub last_heartbeat: Option<Instant>,
pub sent_count: u64,
pub error_count: u64,
pub warning_count: u64,
pub info_count: u64,
pub messages: VecDeque<DisplayMessage>,
pub errors: VecDeque<DisplayMessage>,
pub show_headers: bool,
pub scroll_offset: usize,
pub error_scroll_offset: usize,
pub input: String,
pub cursor_pos: usize,
pub command_history: Vec<String>,
pub history_index: Option<usize>,
pub saved_input: String,
}
impl AppState {
pub fn new(host: String, user: String, heartbeat_interval_ms: u32) -> Self {
Self {
start_time: Local::now(),
host,
user,
heartbeat_interval_ms,
subscriptions: HashMap::new(),
heartbeat_count: 0,
last_heartbeat: None,
sent_count: 0,
error_count: 0,
warning_count: 0,
info_count: 0,
messages: VecDeque::with_capacity(MAX_MESSAGES),
errors: VecDeque::with_capacity(MAX_ERRORS),
show_headers: false,
scroll_offset: 0,
error_scroll_offset: 0,
input: String::new(),
cursor_pos: 0,
command_history: Vec::new(),
history_index: None,
saved_input: String::new(),
}
}
pub fn record_heartbeat(&mut self) {
self.heartbeat_count += 1;
self.last_heartbeat = Some(Instant::now());
}
pub fn heartbeat_indicator(&self) -> (&'static str, bool) {
match self.last_heartbeat {
Some(last) => {
let elapsed = last.elapsed().as_millis() as u32;
if elapsed < 1000 {
("✦", true) } else if elapsed < self.heartbeat_interval_ms * 2 {
("◇", false) } else {
("!", false) }
}
None => ("○", false), }
}
pub fn record_message(
&mut self,
destination: &str,
body: String,
headers: Vec<(String, String)>,
) {
match destination {
"SENT" => self.sent_count += 1,
"ERROR" => self.error_count += 1,
"BROKER ERROR" => {
self.record_error(body, headers);
return;
}
"WARN" => self.warning_count += 1,
"INFO" => self.info_count += 1,
_ => {
let stats = self
.subscriptions
.entry(destination.to_string())
.or_default();
stats.message_count += 1;
}
}
let msg = DisplayMessage {
timestamp: Local::now(),
destination: destination.to_string(),
body,
headers,
};
self.messages.push_back(msg);
while self.messages.len() > MAX_MESSAGES {
self.messages.pop_front();
}
}
pub fn record_error(&mut self, body: String, headers: Vec<(String, String)>) {
self.error_count += 1;
let msg = DisplayMessage {
timestamp: Local::now(),
destination: "BROKER ERROR".to_string(),
body,
headers,
};
self.errors.push_back(msg);
while self.errors.len() > MAX_ERRORS {
self.errors.pop_front();
}
}
pub fn register_subscription(&mut self, destination: &str) {
self.subscriptions
.entry(destination.to_string())
.or_default();
}
pub fn total_message_count(&self) -> u64 {
self.subscriptions.values().map(|s| s.message_count).sum()
}
pub fn session_duration(&self) -> String {
let duration = Local::now().signed_duration_since(self.start_time);
let total_secs = duration.num_seconds();
let hours = total_secs / 3600;
let mins = (total_secs % 3600) / 60;
let secs = total_secs % 60;
format!("{:02}:{:02}:{:02}", hours, mins, secs)
}
pub fn toggle_headers(&mut self) {
self.show_headers = !self.show_headers;
}
pub fn clear_messages(&mut self) {
self.messages.clear();
self.scroll_offset = 0;
}
pub fn add_to_history(&mut self, cmd: &str) {
let cmd = cmd.trim();
if !cmd.is_empty() {
if self.command_history.last().map(|s| s.as_str()) != Some(cmd) {
self.command_history.push(cmd.to_string());
}
}
self.history_index = None;
self.saved_input.clear();
}
pub fn history_prev(&mut self) {
if self.command_history.is_empty() {
return;
}
match self.history_index {
None => {
self.saved_input = self.input.clone();
self.history_index = Some(self.command_history.len() - 1);
}
Some(idx) if idx > 0 => {
self.history_index = Some(idx - 1);
}
_ => return, }
if let Some(idx) = self.history_index {
self.input = self.command_history[idx].clone();
self.cursor_pos = self.input.len();
}
}
pub fn history_next(&mut self) {
match self.history_index {
None => {} Some(idx) => {
if idx + 1 < self.command_history.len() {
self.history_index = Some(idx + 1);
self.input = self.command_history[idx + 1].clone();
self.cursor_pos = self.input.len();
} else {
self.history_index = None;
self.input = self.saved_input.clone();
self.cursor_pos = self.input.len();
}
}
}
}
pub fn generate_summary(&self) -> String {
self.generate_summary_with_options(false, 80)
}
pub fn generate_summary_with_options(
&self,
include_messages: bool,
max_width: usize,
) -> String {
let end_time = Local::now();
let duration = end_time.signed_duration_since(self.start_time);
let total_secs = duration.num_seconds();
let mins = total_secs / 60;
let secs = total_secs % 60;
let mut lines = Vec::new();
lines.push(
"═══════════════════════════════════════════════════════════════════════════════"
.to_string(),
);
lines.push(" iridium-stomp Session Report".to_string());
lines.push(
"═══════════════════════════════════════════════════════════════════════════════"
.to_string(),
);
lines.push(format!(" Host: {}", self.host));
lines.push(format!(" User: {}", self.user));
lines.push(format!(
" Started: {}",
self.start_time.format("%Y-%m-%d %H:%M:%S")
));
lines.push(format!(
" Ended: {}",
end_time.format("%Y-%m-%d %H:%M:%S")
));
lines.push(format!(" Duration: {}m {}s", mins, secs));
lines.push(String::new());
lines.push(" Subscriptions:".to_string());
let mut subs: Vec<_> = self.subscriptions.iter().collect();
subs.sort_by_key(|b| std::cmp::Reverse(b.1.message_count));
let max_dest_len = subs
.iter()
.map(|(d, _)| d.len())
.max()
.unwrap_or(20)
.min(40);
for (dest, stats) in &subs {
let dest_display = truncate_str(dest, max_dest_len);
lines.push(format!(
" {:width$} {:>6}",
dest_display,
stats.message_count,
width = max_dest_len
));
}
lines.push(format!(" {:─>width$}", "", width = max_dest_len + 7));
lines.push(format!(
" {:width$} {:>6}",
"Total",
self.total_message_count(),
width = max_dest_len
));
lines.push(String::new());
lines.push(format!(" Heartbeats received: {}", self.heartbeat_count));
if include_messages && !self.messages.is_empty() {
lines.push(String::new());
lines.push(
"───────────────────────────────────────────────────────────────────────────────"
.to_string(),
);
lines.push(" Message History".to_string());
lines.push(
"───────────────────────────────────────────────────────────────────────────────"
.to_string(),
);
for msg in &self.messages {
let time = msg.timestamp.format("%H:%M:%S").to_string();
let prefix = format!(" {} [{}] ", time, msg.destination);
let body_width = max_width.saturating_sub(prefix.len());
let body = truncate_str(&msg.body, body_width);
lines.push(format!("{}{}", prefix, body));
}
}
lines.push(
"═══════════════════════════════════════════════════════════════════════════════"
.to_string(),
);
lines.join("\n")
}
}
pub(crate) fn truncate_str(s: &str, max_len: usize) -> String {
if s.chars().count() <= max_len {
s.to_string()
} else if max_len <= 3 {
".".repeat(max_len)
} else {
let kept: String = s.chars().take(max_len - 3).collect();
format!("{}...", kept)
}
}
pub(crate) fn char_wrap(s: &str, width: usize) -> Vec<String> {
if width == 0 {
return Vec::new();
}
let chars: Vec<char> = s.chars().collect();
chars
.chunks(width)
.map(|chunk| chunk.iter().collect())
.collect()
}
pub type SharedState = Arc<Mutex<AppState>>;
pub fn new_shared_state(host: String, user: String, heartbeat_interval_ms: u32) -> SharedState {
Arc::new(Mutex::new(AppState::new(host, user, heartbeat_interval_ms)))
}
#[cfg(test)]
mod tests {
use super::{char_wrap, truncate_str};
#[test]
fn truncate_str_never_panics_on_multibyte() {
assert_eq!(truncate_str("aaaaéaaaaaaaaaa", 8), "aaaaé...");
let out = truncate_str("hello 🌦 world weather report", 11);
assert!(out.ends_with("..."));
assert_eq!(out.chars().count(), 11);
}
#[test]
fn truncate_str_passes_short_and_handles_tiny_max() {
assert_eq!(truncate_str("short", 10), "short");
assert_eq!(truncate_str("", 5), "");
assert_eq!(truncate_str("hello", 3), "...");
assert_eq!(truncate_str("hello", 2), "..");
}
#[test]
fn char_wrap_splits_on_char_boundaries_and_reassembles() {
let s = "aaébbédd"; let chunks = char_wrap(s, 3);
assert_eq!(chunks, vec!["aaé", "bbé", "dd"]);
assert_eq!(chunks.concat(), s);
assert!(char_wrap("abc", 0).is_empty());
}
}