use std::collections::VecDeque;
use std::time::{SystemTime, UNIX_EPOCH};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DaemonStatus {
Connecting,
Online {
version: String,
uptime_secs: u64,
},
Offline {
last_error: String,
},
}
impl DaemonStatus {
pub fn is_online(&self) -> bool {
matches!(self, DaemonStatus::Online { .. })
}
pub fn badge(&self) -> (char, &'static str) {
match self {
DaemonStatus::Online { .. } => ('●', "online"),
DaemonStatus::Connecting => ('◌', "connecting"),
DaemonStatus::Offline { .. } => ('○', "offline"),
}
}
}
pub fn fmt_uptime(secs: u64) -> String {
let hours = secs / 3600;
let minutes = (secs % 3600) / 60;
format!("{hours}h {minutes}m")
}
pub fn timestamped(msg: &str) -> String {
let secs = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let day_secs = secs % 86_400;
let hh = day_secs / 3600;
let mm = (day_secs % 3600) / 60;
let ss = day_secs % 60;
format!("[{hh:02}:{mm:02}:{ss:02}] {msg}")
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LogEntry {
pub text: String,
pub scope: Option<String>,
}
#[derive(Debug, Clone, Default)]
pub struct ActivityLog {
entries: VecDeque<LogEntry>,
}
impl ActivityLog {
pub const MAX_ENTRIES: usize = 500;
pub fn new() -> Self {
Self {
entries: VecDeque::new(),
}
}
pub fn push(&mut self, msg: impl AsRef<str>) {
self.push_entry(LogEntry {
text: timestamped(msg.as_ref()),
scope: None,
});
}
pub fn push_scoped(&mut self, scope: impl Into<String>, msg: impl AsRef<str>) {
self.push_entry(LogEntry {
text: timestamped(msg.as_ref()),
scope: Some(scope.into()),
});
}
pub fn push_raw(&mut self, line: impl Into<String>) {
self.push_entry(LogEntry {
text: line.into(),
scope: None,
});
}
pub fn push_raw_scoped(&mut self, scope: impl Into<String>, line: impl Into<String>) {
self.push_entry(LogEntry {
text: line.into(),
scope: Some(scope.into()),
});
}
pub fn push_entry(&mut self, entry: LogEntry) {
self.entries.push_back(entry);
while self.entries.len() > Self::MAX_ENTRIES {
self.entries.pop_front();
}
}
pub fn len(&self) -> usize {
self.entries.len()
}
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
pub fn tail(&self, n: usize) -> impl Iterator<Item = &String> {
let skip = self.entries.len().saturating_sub(n);
self.entries.iter().skip(skip).map(|e| &e.text)
}
pub fn tail_scoped<'a>(
&'a self,
filter: Option<&'a str>,
n: usize,
) -> impl Iterator<Item = &'a String> {
let matched: Vec<&String> = self
.entries
.iter()
.filter(move |e| match (filter, e.scope.as_deref()) {
(None, _) => true,
(Some(_), None) => true,
(Some(want), Some(got)) => want == got,
})
.map(|e| &e.text)
.collect();
let skip = matched.len().saturating_sub(n);
matched.into_iter().skip(skip)
}
pub fn has_scoped(&self, filter: Option<&str>) -> bool {
self.entries
.iter()
.any(|e| match (filter, e.scope.as_deref()) {
(None, _) => true,
(Some(_), None) => true,
(Some(want), Some(got)) => want == got,
})
}
pub fn iter(&self) -> impl Iterator<Item = &String> {
self.entries.iter().map(|e| &e.text)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_daemon_status_is_online() {
assert!(
DaemonStatus::Online {
version: "1.0".into(),
uptime_secs: 10,
}
.is_online()
);
assert!(!DaemonStatus::Connecting.is_online());
assert!(
!DaemonStatus::Offline {
last_error: "x".into(),
}
.is_online()
);
}
#[test]
fn test_daemon_status_badge() {
let online = DaemonStatus::Online {
version: "1.0".into(),
uptime_secs: 0,
};
assert_eq!(online.badge(), ('●', "online"));
assert_eq!(DaemonStatus::Connecting.badge(), ('◌', "connecting"));
assert_eq!(
DaemonStatus::Offline {
last_error: "x".into()
}
.badge(),
('○', "offline")
);
}
#[test]
fn test_fmt_uptime() {
assert_eq!(fmt_uptime(7440), "2h 4m");
assert_eq!(fmt_uptime(0), "0h 0m");
assert_eq!(fmt_uptime(59), "0h 0m");
assert_eq!(fmt_uptime(3600), "1h 0m");
assert_eq!(fmt_uptime(3661), "1h 1m");
}
#[test]
fn test_timestamped_format() {
let line = timestamped("hello world");
assert!(line.ends_with(" hello world"), "payload preserved: {line}");
assert!(line.starts_with('['), "starts with bracket: {line}");
let bytes = line.as_bytes();
assert_eq!(bytes[0], b'[');
assert_eq!(bytes[9], b']');
assert_eq!(bytes[10], b' ');
for i in [1, 2, 4, 5, 7, 8] {
assert!(bytes[i].is_ascii_digit(), "digit at {i}: {line}");
}
assert_eq!(bytes[3], b':');
assert_eq!(bytes[6], b':');
}
#[test]
fn test_log_starts_empty() {
let log = ActivityLog::new();
assert!(log.is_empty());
assert_eq!(log.len(), 0);
}
#[test]
fn test_log_max_capacity() {
let mut log = ActivityLog::new();
for i in 0..(ActivityLog::MAX_ENTRIES + 250) {
log.push_raw(format!("line {i}"));
}
assert_eq!(log.len(), ActivityLog::MAX_ENTRIES);
let first = log.iter().next().expect("non-empty log");
assert_eq!(first, "line 250");
let last = log.iter().last().expect("non-empty log");
assert_eq!(last, "line 749");
}
#[test]
fn test_log_push_timestamps() {
let mut log = ActivityLog::new();
log.push("event happened");
let line = log.iter().next().expect("one entry");
assert!(line.starts_with('['), "timestamped: {line}");
assert!(line.ends_with(" event happened"));
}
#[test]
fn test_log_tail() {
let mut log = ActivityLog::new();
for i in 0..10 {
log.push_raw(format!("l{i}"));
}
let tail: Vec<&String> = log.tail(3).collect();
assert_eq!(tail.len(), 3);
assert_eq!(tail[0], "l7");
assert_eq!(tail[2], "l9");
assert_eq!(log.tail(100).count(), 10);
}
#[test]
fn test_log_scoped_filtering() {
let mut log = ActivityLog::new();
log.push("daemon started"); log.push_scoped("cto", "reindex cto");
log.push_raw_scoped("cto", " 100/200 files");
log.push_scoped("trusty", "search trusty");
let all: Vec<&String> = log.tail_scoped(None, 100).collect();
assert_eq!(all.len(), 4);
let cto: Vec<&String> = log.tail_scoped(Some("cto"), 100).collect();
assert_eq!(cto.len(), 3);
assert!(cto.iter().any(|l| l.contains("reindex cto")));
assert!(cto.iter().any(|l| l.contains("100/200 files")));
assert!(cto.iter().any(|l| l.contains("daemon started")));
assert!(!cto.iter().any(|l| l.contains("search trusty")));
let other: Vec<&String> = log.tail_scoped(Some("absent"), 100).collect();
assert_eq!(other.len(), 1);
assert!(other[0].contains("daemon started"));
assert!(log.has_scoped(None));
assert!(log.has_scoped(Some("cto")));
assert!(log.has_scoped(Some("absent")));
assert_eq!(log.tail_scoped(Some("cto"), 1).count(), 1);
}
}