television/television/history.rs
2025-07-15 19:04:40 +02:00

756 lines
24 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

use anyhow::Result;
use serde::{Deserialize, Serialize};
use std::{
path::{Path, PathBuf},
time::{SystemTime, UNIX_EPOCH},
};
use tracing::debug;
const HISTORY_FILE_NAME: &str = "history.json";
pub const DEFAULT_HISTORY_SIZE: usize = 200;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HistoryEntry {
/// The search query/pattern that was typed
pub query: String,
/// The channel that the entry belongs to
pub channel: String,
/// The timestamp of the entry
pub timestamp: u64,
}
impl PartialEq for HistoryEntry {
fn eq(&self, other: &Self) -> bool {
self.query == other.query && self.channel == other.channel
}
}
impl HistoryEntry {
pub fn new(entry: String, channel: String) -> Self {
let timestamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
Self {
query: entry,
channel,
timestamp,
}
}
}
#[derive(Debug, Clone)]
pub struct History {
entries: Vec<HistoryEntry>,
current_index: Option<usize>,
max_size: usize,
file_path: PathBuf,
current_channel: String,
global_mode: bool,
}
impl History {
pub fn new(
max_size: usize,
channel_name: &str,
global_mode: bool,
data_dir: &Path,
) -> Self {
let file_path = data_dir.join(HISTORY_FILE_NAME);
Self {
entries: Vec::with_capacity(max_size),
current_index: None,
max_size,
file_path,
current_channel: channel_name.to_string(),
global_mode,
}
}
/// Initialize the history by loading previously persisted entries from disk.
pub fn init(&mut self) -> Result<()> {
// if max_size is 0, history is disabled
if self.max_size > 0 {
self.load_from_file()?;
}
Ok(())
}
/// Add a new history entry, if it's not a duplicate.
pub fn add_entry(&mut self, query: String, channel: String) -> Result<()> {
if self.max_size == 0 {
return Ok(());
}
// Don't add empty queries
if query.trim().is_empty() {
return Ok(());
}
// Don't add duplicate consecutive queries
if let Some(last_entry) = self.entries.last() {
if last_entry.query == query && last_entry.channel == channel {
return Ok(());
}
}
// Trim history if it's going to exceed `max_size`
if self.entries.len() + 1 > self.max_size {
self.entries.drain(0..=self.entries.len() - self.max_size);
}
let history_entry = HistoryEntry::new(query, channel);
self.entries.push(history_entry);
// Reset current index when adding new entry
self.current_index = None;
Ok(())
}
/// Get the previous history entry based on the configured mode.
pub fn get_previous_entry(&mut self) -> Option<&HistoryEntry> {
let channel_filter =
(!self.global_mode).then_some(self.current_channel.as_str());
let search_end = match self.current_index {
None => self.entries.len(),
Some(0) => {
return self.entries.first().filter(|entry| {
channel_filter.is_none_or(|ch| entry.channel == ch)
});
}
Some(i) => i,
};
self.entries
.get(..search_end)?
.iter()
.enumerate()
.rev()
.find(|(_, entry)| {
channel_filter.is_none_or(|ch| entry.channel == ch)
})
.map(|(idx, entry)| {
self.current_index = Some(idx);
entry
})
}
/// Get the next history entry based on the configured mode.
pub fn get_next_entry(&mut self) -> Option<&HistoryEntry> {
let channel_filter =
(!self.global_mode).then_some(self.current_channel.as_str());
let search_start = self.current_index? + 1;
self.entries
.get(search_start..)?
.iter()
.enumerate()
.find(|(_, entry)| {
channel_filter.is_none_or(|ch| entry.channel == ch)
})
.map(|(offset, entry)| {
self.current_index = Some(search_start + offset);
entry
})
.or_else(|| {
self.current_index = None; // Reset navigation at end
None
})
}
fn load_from_file(&mut self) -> Result<()> {
if !self.file_path.exists() {
debug!("History file not found: {}", self.file_path.display());
return Ok(());
}
let content = std::fs::read_to_string(&self.file_path)?;
if content.trim().is_empty() {
debug!("History file is empty: {}", self.file_path.display());
return Ok(());
}
let mut loaded_entries: Vec<HistoryEntry> =
serde_json::from_str(&content)?;
// Keep only the most recent entries if file is too large
if loaded_entries.len() > self.max_size {
loaded_entries.drain(0..loaded_entries.len() - self.max_size);
}
self.entries = loaded_entries;
Ok(())
}
pub fn save_to_file(&self) -> Result<()> {
if self.max_size == 0 {
debug!("History is disabled, not saving to file.");
return Ok(());
}
if let Some(parent) = self.file_path.parent() {
std::fs::create_dir_all(parent)?;
}
let json_content = serde_json::to_string_pretty(&self.entries)?;
std::fs::write(&self.file_path, json_content)?;
Ok(())
}
pub fn len(&self) -> usize {
self.entries.len()
}
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
/// Get all entries in the history.
pub fn get_entries(&self) -> &[HistoryEntry] {
&self.entries
}
/// Update the current channel context for this history instance.
pub fn update_channel_context(
&mut self,
channel_name: &str,
global_mode: bool,
) {
self.current_channel = channel_name.to_string();
self.global_mode = global_mode;
// Reset navigation state when switching channels
self.current_index = None;
}
}
#[cfg(test)]
mod tests {
//! Tests for History navigation helpers (channel vs. global mode).
use crate::history::{History, HistoryEntry};
use serde_json;
use std::{fs, path::Path};
use tempfile::tempdir;
/// Helper to create a history.json file with the provided entries inside a temp data dir.
fn setup_history_file(entries: &[HistoryEntry]) -> tempfile::TempDir {
let dir = tempdir().expect("failed to create tempdir");
let json = serde_json::to_string_pretty(entries).unwrap();
fs::write(dir.path().join("history.json"), json).unwrap();
dir
}
fn make_entries() -> Vec<HistoryEntry> {
vec![
HistoryEntry::new("file1".into(), "files".into()),
HistoryEntry::new("dir1".into(), "dirs".into()),
HistoryEntry::new("file2".into(), "files".into()),
HistoryEntry::new("dir2".into(), "dirs".into()),
HistoryEntry::new("file3".into(), "files".into()),
]
}
/// Return a Vec of entry strings in newest→oldest order.
#[allow(dead_code)]
fn dump_entries(hist: &History) -> Vec<String> {
let mut h = hist.clone();
let mut acc = Vec::new();
let max = h.len();
for _ in 0..max {
match h.get_previous_entry() {
Some(e) => acc.push(e.query.clone()),
None => break,
}
}
acc
}
#[allow(dead_code)]
fn print_entries(hist: &History) {
println!("{:?}", dump_entries(hist));
}
/// Read all entries currently stored in history.json for a data dir
fn entries_in_file(dir: &Path) -> Vec<String> {
let raw = fs::read_to_string(dir.join("history.json")).unwrap();
let vec: Vec<HistoryEntry> = serde_json::from_str(&raw).unwrap();
vec.into_iter().map(|e| e.query).collect()
}
#[allow(dead_code)]
fn assert_entries_in_file(dir: &Path, expected: &[&str]) {
let mut got = entries_in_file(dir);
got.sort();
let mut exp: Vec<String> =
expected.iter().map(|&s| s.to_string()).collect();
exp.sort();
assert_eq!(got, exp);
}
#[allow(dead_code)]
fn assert_entries(hist: &History, expected: &[&str]) {
let mut got: Vec<String> =
hist.get_entries().iter().map(|e| e.query.clone()).collect();
got.sort();
let mut exp: Vec<String> =
expected.iter().map(|&s| s.to_string()).collect();
exp.sort();
assert_eq!(got, exp);
}
/// Prev/next navigation scoped to current channel.
#[test]
fn prev_next_channel_mode() {
let dir = setup_history_file(&make_entries());
let mut hist = History::new(10, "files", false, dir.path());
hist.init().unwrap();
// channel mode prev navigation (should skip non-matching channels)
assert_eq!(
hist.get_previous_entry().map(|e| &e.query),
Some(&"file3".to_string())
);
assert_eq!(
hist.get_previous_entry().map(|e| &e.query),
Some(&"file2".to_string())
);
assert_eq!(
hist.get_previous_entry().map(|e| &e.query),
Some(&"file1".to_string())
);
// further calls stay at oldest matching
assert_eq!(
hist.get_previous_entry().map(|e| &e.query),
Some(&"file1".to_string())
);
// Next navigation forward in channel scope
assert_eq!(
hist.get_next_entry().map(|e| &e.query),
Some(&"file2".to_string())
);
assert_eq!(
hist.get_next_entry().map(|e| &e.query),
Some(&"file3".to_string())
);
// After reaching the last matching entry, further next should reset and return None
assert_eq!(hist.get_next_entry(), None);
}
/// Prev/next navigation in global mode.
#[test]
fn prev_next_global_mode() {
let dir = setup_history_file(&make_entries());
let mut hist = History::new(10, "files", true, dir.path());
hist.init().unwrap();
assert_eq!(
hist.get_previous_entry().map(|e| &e.query),
Some(&"file3".to_string())
);
assert_eq!(
hist.get_previous_entry().map(|e| &e.query),
Some(&"dir2".to_string())
);
assert_eq!(
hist.get_previous_entry().map(|e| &e.query),
Some(&"file2".to_string())
);
assert_eq!(
hist.get_previous_entry().map(|e| &e.query),
Some(&"dir1".to_string())
);
assert_eq!(
hist.get_previous_entry().map(|e| &e.query),
Some(&"file1".to_string())
);
// further calls stay at oldest matching
assert_eq!(
hist.get_previous_entry().map(|e| &e.query),
Some(&"file1".to_string())
);
// Next navigation forward in channel scope
assert_eq!(
hist.get_next_entry().map(|e| &e.query),
Some(&"dir1".to_string())
);
assert_eq!(
hist.get_next_entry().map(|e| &e.query),
Some(&"file2".to_string())
);
assert_eq!(
hist.get_next_entry().map(|e| &e.query),
Some(&"dir2".to_string())
);
assert_eq!(
hist.get_next_entry().map(|e| &e.query),
Some(&"file3".to_string())
);
// After reaching the last matching entry, further next should reset and return None
assert_eq!(hist.get_next_entry(), None);
}
/// Channel-scoped navigation when the history file contains no entries for the
/// current channel navigation should immediately return `None` both ways.
#[test]
fn channel_mode_no_matching_entries() {
let only_b = vec![HistoryEntry::new("dir1".into(), "dirs".into())];
let dir = setup_history_file(&only_b);
let mut hist = History::new(10, "files", false, dir.path());
hist.init().unwrap();
assert!(hist.get_previous_entry().is_none());
assert!(hist.get_next_entry().is_none());
}
/// Global mode navigation when there is only a single entry that does not
/// belong to the current channel that entry should still be accessible.
#[test]
fn global_mode_single_nonmatching_entry() {
let only_b = vec![HistoryEntry::new("dir1".into(), "dirs".into())];
let dir = setup_history_file(&only_b);
let mut hist = History::new(10, "files", true, dir.path());
hist.init().unwrap();
assert_eq!(
hist.get_previous_entry().map(|e| &e.query),
Some(&"dir1".to_string())
);
}
/// Ensure `add_entry` respects deduplication and size trimming.
/// Adding entries should skip consecutive duplicates and trim to max size.
#[test]
fn add_entry_dedup_and_trim() {
let dir = setup_history_file(&[]);
let mut hist = History::new(3, "files", false, dir.path());
// add first two unique entries
hist.add_entry("file1".into(), "files".into()).unwrap();
assert_entries(&hist, &["file1"]);
hist.add_entry("file2".into(), "files".into()).unwrap();
assert_entries(&hist, &["file1", "file2"]);
// consecutive duplicates should be ignored
hist.add_entry("file2".into(), "files".into()).unwrap();
assert_entries(&hist, &["file1", "file2"]);
assert_eq!(hist.len(), 2);
// let's add a third unique entry
hist.add_entry("file3".into(), "files".into()).unwrap();
assert_entries(&hist, &["file1", "file2", "file3"]);
assert_eq!(hist.len(), 3);
// let's add a fourth unique entry, we should still have 3 entries
hist.add_entry("file4".into(), "files".into()).unwrap();
assert_entries(&hist, &["file2", "file3", "file4"]);
assert_eq!(hist.len(), 3);
// non-consecutive duplicate counts as new
hist.add_entry("dir1".into(), "dirs".into()).unwrap();
assert_entries(&hist, &["file3", "file4", "dir1"]);
assert_eq!(hist.len(), 3);
// In channel mode (files) the newest matching is file4
assert_eq!(
hist.get_previous_entry().map(|e| &e.query),
Some(&"file4".to_string())
);
// Persist so we can load later
hist.save_to_file().unwrap();
// Switch to global view to verify "dir1" exists
// let's init the history with channel only mode for dirs
let mut hist = History::new(3, "dirs", false, dir.path());
hist.init().unwrap();
assert_entries(&hist, &["file3", "file4", "dir1"]);
assert_eq!(hist.len(), 3);
assert_eq!(
hist.get_previous_entry().map(|e| &e.query),
Some(&"dir1".to_string())
);
}
/// Global mode: initializing with smaller `max_size` trims older entries.
#[test]
fn init_trim_global_mode() {
let dir = setup_history_file(&make_entries());
// max size 3, global mode
let mut hist = History::new(3, "files", true, dir.path());
hist.init().unwrap();
// Should keep only the 3 newest entries (file2, dir2, file3)
assert_eq!(hist.len(), 3);
assert_eq!(dump_entries(&hist), vec!["file3", "dir2", "file2"]);
}
/// Test loading from a non-existent history file.
#[test]
fn init_from_nonexistent_file() {
let dir = tempdir().expect("failed to create tempdir");
let mut hist = History::new(10, "files", false, dir.path());
// Should succeed and have empty entries
hist.init().unwrap();
assert_eq!(hist.len(), 0);
assert!(hist.is_empty());
// Navigation should return None
assert!(hist.get_previous_entry().is_none());
assert!(hist.get_next_entry().is_none());
}
/// Test loading from an empty history file.
#[test]
fn init_from_empty_file() {
let dir = tempdir().expect("failed to create tempdir");
fs::write(dir.path().join("history.json"), "").unwrap();
let mut hist = History::new(10, "files", false, dir.path());
hist.init().unwrap();
assert_eq!(hist.len(), 0);
assert!(hist.is_empty());
}
/// Test that empty queries are ignored.
#[test]
fn add_entry_ignores_empty_queries() {
let dir = tempdir().expect("failed to create tempdir");
let mut hist = History::new(10, "files", false, dir.path());
// Try to add empty and whitespace-only queries
hist.add_entry(String::new(), "files".into()).unwrap();
hist.add_entry(" ".into(), "files".into()).unwrap();
hist.add_entry("\t\n".into(), "files".into()).unwrap();
assert_eq!(hist.len(), 0);
assert!(hist.is_empty());
// Add a real entry to confirm it works
hist.add_entry("real_entry".into(), "files".into()).unwrap();
assert_eq!(hist.len(), 1);
assert_entries(&hist, &["real_entry"]);
}
/// Test navigation on completely empty history.
#[test]
fn navigation_empty_history() {
let dir = tempdir().expect("failed to create tempdir");
let mut hist = History::new(10, "files", false, dir.path());
// Both modes should return None on empty history
assert!(hist.get_previous_entry().is_none());
assert!(hist.get_next_entry().is_none());
// Global mode should also return None
let mut hist_global = History::new(10, "files", true, dir.path());
assert!(hist_global.get_previous_entry().is_none());
assert!(hist_global.get_next_entry().is_none());
}
/// Test navigation state after adding entries.
#[test]
fn navigation_after_adding_entries() {
let dir = tempdir().expect("failed to create tempdir");
let mut hist = History::new(10, "files", false, dir.path());
// Add first file
hist.add_entry("file1".into(), "files".into()).unwrap();
// Should be able to navigate to it
assert_eq!(
hist.get_previous_entry().map(|e| &e.query),
Some(&"file1".to_string())
);
// Add another file - should reset navigation
hist.add_entry("file2".into(), "files".into()).unwrap();
// Should now get the newest file first
assert_eq!(
hist.get_previous_entry().map(|e| &e.query),
Some(&"file2".to_string())
);
assert_eq!(
hist.get_previous_entry().map(|e| &e.query),
Some(&"file1".to_string())
);
}
/// Test non-consecutive duplicates are properly handled.
#[test]
fn non_consecutive_duplicates() {
let dir = tempdir().expect("failed to create tempdir");
let mut hist = History::new(10, "files", false, dir.path());
hist.add_entry("file1".into(), "files".into()).unwrap();
hist.add_entry("file2".into(), "files".into()).unwrap();
hist.add_entry("file1".into(), "files".into()).unwrap(); // Non-consecutive duplicate
assert_eq!(hist.len(), 3);
assert_entries(&hist, &["file1", "file2", "file1"]);
// Both instances should be navigable
assert_eq!(
hist.get_previous_entry().map(|e| &e.query),
Some(&"file1".to_string())
);
assert_eq!(
hist.get_previous_entry().map(|e| &e.query),
Some(&"file2".to_string())
);
assert_eq!(
hist.get_previous_entry().map(|e| &e.query),
Some(&"file1".to_string())
);
}
/// Test cross-channel duplicates.
#[test]
fn cross_channel_duplicates() {
let dir = tempdir().expect("failed to create tempdir");
let mut hist = History::new(10, "files", false, dir.path());
hist.add_entry("same_name".into(), "files".into()).unwrap();
hist.add_entry("same_name".into(), "dirs".into()).unwrap();
hist.add_entry("same_name".into(), "files".into()).unwrap();
assert_eq!(hist.len(), 3);
assert_entries(&hist, &["same_name", "same_name", "same_name"]);
// In channel mode, should only see the files entries
assert_eq!(
hist.get_previous_entry().map(|e| &e.query),
Some(&"same_name".to_string())
);
assert_eq!(
hist.get_previous_entry().map(|e| &e.query),
Some(&"same_name".to_string())
);
// Persist so we can load later
hist.save_to_file().unwrap();
// In global mode, should see all three
let mut hist_global = History::new(10, "files", true, dir.path());
hist_global.init().unwrap();
assert_eq!(
hist_global.get_previous_entry().map(|e| &e.query),
Some(&"same_name".to_string())
);
assert_eq!(
hist_global.get_previous_entry().map(|e| &e.query),
Some(&"same_name".to_string())
);
assert_eq!(
hist_global.get_previous_entry().map(|e| &e.query),
Some(&"same_name".to_string())
);
}
/// Test navigation state preservation during mixed operations.
#[test]
fn navigation_state_preservation() {
let dir = setup_history_file(&make_entries());
let mut hist = History::new(10, "files", false, dir.path());
hist.init().unwrap();
// Start navigation
assert_eq!(
hist.get_previous_entry().map(|e| &e.query),
Some(&"file3".to_string())
);
assert_eq!(
hist.get_previous_entry().map(|e| &e.query),
Some(&"file2".to_string())
);
// Add a new entry - should reset navigation state
hist.add_entry("new_file".into(), "files".into()).unwrap();
// Should start from newest again
assert_eq!(
hist.get_previous_entry().map(|e| &e.query),
Some(&"new_file".to_string())
);
assert_eq!(
hist.get_previous_entry().map(|e| &e.query),
Some(&"file3".to_string())
);
}
/// Test mixed navigation patterns.
#[test]
fn mixed_navigation_patterns() {
let dir = setup_history_file(&make_entries());
let mut hist = History::new(10, "files", false, dir.path());
hist.init().unwrap();
// Go back three times
hist.get_previous_entry();
hist.get_previous_entry();
hist.get_previous_entry();
// Go forward once
assert_eq!(
hist.get_next_entry().map(|e| &e.query),
Some(&"file2".to_string())
);
// Go back again
assert_eq!(
hist.get_previous_entry().map(|e| &e.query),
Some(&"file1".to_string())
);
// Go forward to end and beyond
hist.get_next_entry();
hist.get_next_entry();
assert!(hist.get_next_entry().is_none());
// After reset, should be able to start navigation again
assert_eq!(
hist.get_previous_entry().map(|e| &e.query),
Some(&"file3".to_string())
);
}
/// Test `get_next` without previous navigation.
#[test]
fn next_without_previous() {
let dir = setup_history_file(&make_entries());
let mut hist = History::new(10, "files", false, dir.path());
hist.init().unwrap();
// Calling get_next without any previous navigation should return None
assert!(hist.get_next_entry().is_none());
}
#[test]
fn add_entry_with_history_size_zero() {
let dir = tempdir().expect("failed to create tempdir");
let mut hist = History::new(0, "files", false, dir.path());
// Adding an entry should not change the history
hist.add_entry("file1".into(), "files".into()).unwrap();
assert!(hist.is_empty());
assert_eq!(hist.len(), 0);
// Navigation should return None
assert!(hist.get_previous_entry().is_none());
assert!(hist.get_next_entry().is_none());
}
}