use super::entry::{TxAction, TxEntry};
use ryo_analysis::SymbolPath;
use serde::{Deserialize, Serialize};
use std::path::Path;
use std::time::Instant;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TxSummary {
pub total_entries: usize,
pub total_mutations: usize,
pub total_changes: usize,
pub files_modified: usize,
pub duration_ms: u64,
pub checkpoints: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TxLog {
entries: Vec<TxEntry>,
pub session_id: String,
pub project_path: String,
pub started_at: String, pub ended_at: Option<String>,
#[serde(skip)]
session_start: Option<Instant>,
}
impl Default for TxLog {
fn default() -> Self {
Self::new()
}
}
impl TxLog {
pub fn new() -> Self {
Self {
entries: Vec::new(),
session_id: uuid_v4(),
project_path: String::new(),
started_at: chrono_now(),
ended_at: None,
session_start: Some(Instant::now()),
}
}
pub fn with_project(project_path: impl Into<String>) -> Self {
let mut log = Self::new();
log.project_path = project_path.into();
log
}
pub fn push(&mut self, entry: TxEntry) {
self.entries.push(entry);
}
pub fn log(&mut self, action: TxAction) -> u64 {
let id = self.entries.len() as u64;
let timestamp_ms = self
.session_start
.map(|s| s.elapsed().as_millis() as u64)
.unwrap_or(0);
self.entries.push(TxEntry::new(id, timestamp_ms, action));
id
}
pub fn entries(&self) -> &[TxEntry] {
&self.entries
}
pub fn get(&self, id: u64) -> Option<&TxEntry> {
self.entries.get(id as usize)
}
pub fn len(&self) -> usize {
self.entries.len()
}
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
pub fn iter(&self) -> impl Iterator<Item = &TxEntry> {
self.entries.iter()
}
pub fn iter_replayable(&self) -> impl Iterator<Item = &TxEntry> {
self.entries.iter().filter(|e| e.action.is_replayable())
}
pub fn iter_mutations(&self) -> impl Iterator<Item = &TxEntry> {
self.entries.iter().filter(|e| e.action.is_mutation())
}
pub fn mutations_affecting(&self, symbol: &SymbolPath) -> Vec<&TxEntry> {
self.entries
.iter()
.filter(|e| match &e.action {
TxAction::MutationApplied {
affected_symbols, ..
} => affected_symbols
.iter()
.any(|s| s == symbol || s.is_ancestor_of(symbol)),
_ => false,
})
.collect()
}
pub fn mutations_affecting_subtree(&self, symbol: &SymbolPath) -> Vec<&TxEntry> {
self.entries
.iter()
.filter(|e| match &e.action {
TxAction::MutationApplied {
affected_symbols, ..
} => affected_symbols
.iter()
.any(|s| s == symbol || s.is_ancestor_of(symbol) || s.is_descendant_of(symbol)),
_ => false,
})
.collect()
}
pub fn entries_since_checkpoint(&self, checkpoint_name: &str) -> Vec<&TxEntry> {
let checkpoint_idx = self.entries.iter().rposition(
|e| matches!(&e.action, TxAction::Checkpoint { name } if name == checkpoint_name),
);
match checkpoint_idx {
Some(idx) => self.entries[idx + 1..].iter().collect(),
None => Vec::new(),
}
}
pub fn last_n(&self, n: usize) -> &[TxEntry] {
let start = self.entries.len().saturating_sub(n);
&self.entries[start..]
}
pub fn end_session(&mut self) {
self.ended_at = Some(chrono_now());
}
pub fn summary(&self) -> TxSummary {
let total_mutations = self
.entries
.iter()
.filter(|e| e.action.is_mutation())
.count();
let total_changes: usize = self
.entries
.iter()
.map(|e| match &e.action {
TxAction::MutationApplied { changes, .. } => *changes,
TxAction::MutationBatch { total_changes, .. } => *total_changes,
TxAction::FileModified { changes, .. } => *changes,
_ => 0,
})
.sum();
let files_modified: usize = self
.entries
.iter()
.filter(|e| {
matches!(
&e.action,
TxAction::FileModified { .. } | TxAction::FileWritten { .. }
)
})
.count();
let checkpoints: Vec<String> = self
.entries
.iter()
.filter_map(|e| match &e.action {
TxAction::Checkpoint { name } => Some(name.clone()),
_ => None,
})
.collect();
let duration_ms = self.entries.last().map(|e| e.timestamp_ms).unwrap_or(0);
TxSummary {
total_entries: self.entries.len(),
total_mutations,
total_changes,
files_modified,
duration_ms,
checkpoints,
}
}
pub fn dump_json(&self, path: &Path) -> std::io::Result<()> {
let json = serde_json::to_string_pretty(self)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
std::fs::write(path, json)
}
pub fn dump_json_compact(&self, path: &Path) -> std::io::Result<()> {
let json = serde_json::to_string(self)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
std::fs::write(path, json)
}
pub fn load_json(path: &Path) -> std::io::Result<Self> {
let json = std::fs::read_to_string(path)?;
serde_json::from_str(&json)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))
}
pub fn to_json(&self) -> Result<String, serde_json::Error> {
serde_json::to_string_pretty(self)
}
pub fn from_json(json: &str) -> Result<Self, serde_json::Error> {
serde_json::from_str(json)
}
}
#[cfg(test)]
pub struct TxReplay<'a> {
log: &'a TxLog,
position: usize,
}
#[cfg(test)]
impl<'a> TxReplay<'a> {
pub fn new(log: &'a TxLog) -> Self {
Self { log, position: 0 }
}
pub fn position(&self) -> usize {
self.position
}
pub fn step(&mut self) -> Option<&'a TxEntry> {
if self.position < self.log.len() {
let entry = &self.log.entries[self.position];
self.position += 1;
Some(entry)
} else {
None
}
}
pub fn seek_checkpoint(&mut self, name: &str) -> bool {
for (i, entry) in self.log.entries.iter().enumerate() {
if matches!(&entry.action, TxAction::Checkpoint { name: n } if n == name) {
self.position = i;
return true;
}
}
false
}
}
fn uuid_v4() -> String {
use std::time::{SystemTime, UNIX_EPOCH};
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default();
format!(
"{:08x}-{:04x}-4{:03x}-{:04x}-{:012x}",
now.as_secs() as u32,
((now.as_nanos() >> 16) as u16),
(now.as_nanos() >> 32) as u16 & 0x0FFF,
((now.as_nanos() >> 48) as u16 & 0x3FFF) | 0x8000,
now.as_nanos() as u64 & 0xFFFFFFFFFFFF,
)
}
fn chrono_now() -> String {
use std::time::{SystemTime, UNIX_EPOCH};
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default();
format!("{}Z", now.as_secs())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_log_basic() {
let mut log = TxLog::new();
log.log(TxAction::SessionStart {
project_path: "/test".into(),
file_count: 10,
});
log.log(TxAction::MutationApplied {
mutation_type: "Rename".to_string(),
target: "foo -> bar".to_string(),
changes: 5,
mutation_data: None,
file_path: None,
pre_state: None,
post_state: None,
affected_symbols: vec![],
});
assert_eq!(log.len(), 2);
assert_eq!(log.iter_mutations().count(), 1);
}
#[test]
fn test_log_serialization() {
let mut log = TxLog::with_project("/test/project");
log.log(TxAction::GoalSet {
query: "rename test".to_string(),
intent_type: "RenameIdent".to_string(),
confidence: 0.9,
});
let json = log.to_json().unwrap();
let loaded = TxLog::from_json(&json).unwrap();
assert_eq!(loaded.len(), 1);
assert_eq!(loaded.project_path, "/test/project");
}
#[test]
fn test_replay() {
let mut log = TxLog::new();
log.log(TxAction::Checkpoint {
name: "start".to_string(),
});
log.log(TxAction::MutationApplied {
mutation_type: "Rename".to_string(),
target: "a".to_string(),
changes: 1,
mutation_data: None,
file_path: None,
pre_state: None,
post_state: None,
affected_symbols: vec![],
});
log.log(TxAction::MutationApplied {
mutation_type: "Rename".to_string(),
target: "b".to_string(),
changes: 2,
mutation_data: None,
file_path: None,
pre_state: None,
post_state: None,
affected_symbols: vec![],
});
let mut replay = TxReplay::new(&log);
assert_eq!(replay.position(), 0);
replay.step();
assert_eq!(replay.position(), 1);
replay.seek_checkpoint("start");
assert_eq!(replay.position(), 0);
}
#[test]
fn test_summary() {
let mut log = TxLog::new();
log.log(TxAction::MutationApplied {
mutation_type: "Rename".to_string(),
target: "a".to_string(),
changes: 5,
mutation_data: None,
file_path: None,
pre_state: None,
post_state: None,
affected_symbols: vec![],
});
log.log(TxAction::FileModified {
path: "/test.rs".into(),
changes: 3,
});
log.log(TxAction::Checkpoint {
name: "mid".to_string(),
});
let summary = log.summary();
assert_eq!(summary.total_entries, 3);
assert_eq!(summary.total_mutations, 1);
assert_eq!(summary.total_changes, 8); assert_eq!(summary.checkpoints, vec!["mid"]);
}
}