use super::stack::Stack;
#[derive(Debug, Clone)]
pub struct HistoryEntry {
pub description: String,
pub snapshot: Stack,
pub head_hash: String,
pub timestamp: chrono::DateTime<chrono::Utc>,
}
pub struct History {
entries: Vec<HistoryEntry>,
cursor: usize,
max_entries: usize,
}
impl History {
pub fn new(max_entries: usize) -> Self {
Self {
entries: Vec::new(),
cursor: 0,
max_entries,
}
}
pub fn push(
&mut self,
description: impl Into<String>,
stack: &Stack,
head_hash: impl Into<String>,
) {
if !self.entries.is_empty() {
self.entries.truncate(self.cursor + 1);
}
self.entries.push(HistoryEntry {
description: description.into(),
snapshot: stack.clone(),
head_hash: head_hash.into(),
timestamp: chrono::Utc::now(),
});
self.cursor = self.entries.len() - 1;
if self.entries.len() > self.max_entries {
self.entries.remove(0);
self.cursor = self.entries.len() - 1;
}
}
pub fn undo(&mut self) -> Option<(&Stack, &str)> {
if self.cursor == 0 || self.entries.is_empty() {
return None;
}
self.cursor -= 1;
let entry = &self.entries[self.cursor];
Some((&entry.snapshot, &entry.head_hash))
}
pub fn redo(&mut self) -> Option<(&Stack, &str)> {
if self.entries.is_empty() || self.cursor >= self.entries.len() - 1 {
return None;
}
self.cursor += 1;
let entry = &self.entries[self.cursor];
Some((&entry.snapshot, &entry.head_hash))
}
pub fn list(&self) -> &[HistoryEntry] {
&self.entries
}
pub fn position(&self) -> usize {
self.cursor
}
pub fn total(&self) -> usize {
self.entries.len()
}
pub fn can_undo(&self) -> bool {
!self.entries.is_empty() && self.cursor > 0
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::stack::{PatchEntry, PatchStatus, Stack};
fn dummy_stack(n: usize) -> Stack {
let patches: Vec<PatchEntry> = (0..n).map(|i| PatchEntry {
hash: format!("abc{}", i),
subject: format!("commit {}", i),
body: String::new(),
author: "test".into(),
timestamp: "2026-01-01".into(),
pr_branch: None,
pr_number: None,
pr_url: None,
status: PatchStatus::Clean,
}).collect();
Stack::new("main".into(), patches)
}
#[test]
fn push_and_undo() {
let mut h = History::new(100);
h.push("initial", &dummy_stack(1), "aaa");
h.push("second", &dummy_stack(2), "bbb");
h.push("third", &dummy_stack(3), "ccc");
assert_eq!(h.total(), 3);
assert_eq!(h.position(), 2);
let (stack, hash) = h.undo().unwrap();
assert_eq!(stack.len(), 2);
assert_eq!(hash, "bbb");
let (stack, hash) = h.undo().unwrap();
assert_eq!(stack.len(), 1);
assert_eq!(hash, "aaa");
assert!(h.undo().is_none());
}
#[test]
fn redo_after_undo() {
let mut h = History::new(100);
h.push("a", &dummy_stack(1), "h1");
h.push("b", &dummy_stack(2), "h2");
h.push("c", &dummy_stack(3), "h3");
h.undo(); h.undo();
let (stack, hash) = h.redo().unwrap();
assert_eq!(stack.len(), 2);
assert_eq!(hash, "h2");
let (stack, _) = h.redo().unwrap();
assert_eq!(stack.len(), 3);
assert!(h.redo().is_none());
}
#[test]
fn push_after_undo_clears_redo() {
let mut h = History::new(100);
h.push("a", &dummy_stack(1), "h1");
h.push("b", &dummy_stack(2), "h2");
h.push("c", &dummy_stack(3), "h3");
h.undo(); h.push("d", &dummy_stack(4), "h4");
assert_eq!(h.total(), 3); assert!(h.redo().is_none()); }
#[test]
fn max_entries_eviction() {
let mut h = History::new(3);
h.push("a", &dummy_stack(1), "h1");
h.push("b", &dummy_stack(2), "h2");
h.push("c", &dummy_stack(3), "h3");
h.push("d", &dummy_stack(4), "h4");
assert_eq!(h.total(), 3); let descriptions: Vec<&str> = h.list().iter().map(|e| e.description.as_str()).collect();
assert_eq!(descriptions, vec!["b", "c", "d"]);
}
#[test]
fn empty_history() {
let mut h = History::new(10);
assert!(h.undo().is_none());
assert!(h.redo().is_none());
assert!(!h.can_undo());
assert_eq!(h.total(), 0);
}
}