// channel_log.rs — Per-channel message history // // Shared by all channel daemon implementations. Tracks messages // with consumed/unread semantics for the recv protocol. use std::collections::VecDeque; const DEFAULT_CAPACITY: usize = 200; /// Per-channel message history with consumed/unread tracking. pub struct ChannelLog { messages: VecDeque, consumed: usize, total: usize, /// Messages we sent (don't count as unread) own: usize, } impl ChannelLog { pub fn new() -> Self { Self { messages: VecDeque::with_capacity(DEFAULT_CAPACITY), consumed: 0, total: 0, own: 0, } } pub fn push(&mut self, line: String) { if self.messages.len() >= DEFAULT_CAPACITY { self.messages.pop_front(); if self.consumed > 0 { self.consumed -= 1; } } self.messages.push_back(line); self.total += 1; } pub fn unread(&self) -> u32 { (self.total - self.consumed - self.own) as u32 } /// Push a message that we sent (doesn't count as unread). pub fn push_own(&mut self, line: String) { self.push(line); self.own += 1; } /// Return all unconsumed messages (marks consumed), plus scrollback /// to reach at least min_count total. pub fn recv_new(&mut self, min_count: usize) -> String { let buf_len = self.messages.len(); let unconsumed_start = buf_len.saturating_sub(self.total - self.consumed); let new_msgs: Vec<&str> = self.messages.iter() .skip(unconsumed_start) .map(|s| s.as_str()) .collect(); let need_extra = min_count.saturating_sub(new_msgs.len()); let scroll_start = unconsumed_start.saturating_sub(need_extra); let scrollback: Vec<&str> = self.messages.iter() .skip(scroll_start) .take(unconsumed_start - scroll_start) .map(|s| s.as_str()) .collect(); self.consumed = self.total; self.own = 0; let mut result = scrollback; result.extend(new_msgs); result.join("\n") } /// Return last N lines without consuming. pub fn recv_history(&self, count: usize) -> String { self.messages.iter() .rev().take(count) .collect::>().into_iter().rev() .map(|s| s.as_str()) .collect::>() .join("\n") } } // ── Disk logging ─────────────────────────────────────────────── use std::path::PathBuf; /// Append a timestamped message to a per-channel log file. /// /// Log path: `{log_dir}/{channel}.log` /// Format: `{unix_secs} <{nick}> {text}` pub fn append_disk_log(log_dir: &std::path::Path, channel: &str, nick: &str, text: &str) { use std::io::Write; let filename = format!("{}.log", channel.trim_start_matches('#').to_lowercase()); let _ = std::fs::create_dir_all(log_dir); if let Ok(mut f) = std::fs::OpenOptions::new() .create(true) .append(true) .open(log_dir.join(&filename)) { let secs = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap_or_default() .as_secs(); let _ = writeln!(f, "{secs} <{nick}> {text}"); } } /// Load a ChannelLog from a disk log file, populating with the last N lines. pub fn load_disk_log(log_dir: &std::path::Path, channel: &str) -> ChannelLog { use std::io::{BufRead, BufReader}; let filename = format!("{}.log", channel.trim_start_matches('#').to_lowercase()); let path = log_dir.join(&filename); let mut log = ChannelLog::new(); let Ok(file) = std::fs::File::open(&path) else { return log }; let reader = BufReader::new(file); // Read all lines, keep only the last DEFAULT_CAPACITY let lines: Vec = reader.lines().flatten().collect(); for line in lines.into_iter().rev().take(DEFAULT_CAPACITY).collect::>().into_iter().rev() { log.push(line); } // Mark all loaded lines as consumed (they're history, not new) log.consumed = log.total; log } /// Standard log directory for a channel daemon. pub fn log_dir(daemon_name: &str) -> PathBuf { dirs::home_dir() .unwrap_or_default() .join(format!(".consciousness/channels/{}.logs", daemon_name)) }