use super::{TxAction, TxEntry, TxLog};
use crate::storage::{InMemoryStateStore, StateRef, StateStore};
use ryo_mutations::SerializableMutation;
use ryo_source::pure::PureFile;
use std::path::Path;
use thiserror::Error;
#[derive(Debug, Error)]
pub enum ReplayError {
#[error("Mutation data missing for entry #{0}")]
MissingMutationData(u64),
#[error("Failed to parse mutation data: {0}")]
ParseError(String),
#[error("Mutation type '{0}' cannot be replayed (Generic mutation)")]
UnreplayableMutation(String),
#[error("Pre-state mismatch at entry #{id}: expected {expected}, got {actual}")]
PreStateMismatch {
id: u64,
expected: String,
actual: String,
},
#[error("Post-state mismatch at entry #{id}: expected {expected}, got {actual}")]
PostStateMismatch {
id: u64,
expected: String,
actual: String,
},
#[error("Mutation failed: {0}")]
MutationFailed(String),
}
#[derive(Debug, Clone)]
pub struct ReplayResult {
pub mutations_applied: usize,
pub total_changes: usize,
pub skipped: usize,
pub warnings: Vec<String>,
}
impl ReplayResult {
fn new() -> Self {
Self {
mutations_applied: 0,
total_changes: 0,
skipped: 0,
warnings: Vec::new(),
}
}
}
#[derive(Debug, Clone, Copy, Default)]
pub enum ReplayMode {
#[default]
Fast,
VerifyPre,
VerifyPost,
VerifyBoth,
}
impl ReplayMode {
pub fn verify_pre(&self) -> bool {
matches!(self, ReplayMode::VerifyPre | ReplayMode::VerifyBoth)
}
pub fn verify_post(&self) -> bool {
matches!(self, ReplayMode::VerifyPost | ReplayMode::VerifyBoth)
}
}
pub struct ReplayEngine<'a> {
log: &'a TxLog,
mode: ReplayMode,
state_store: InMemoryStateStore,
}
impl<'a> ReplayEngine<'a> {
pub fn new(log: &'a TxLog) -> Self {
Self {
log,
mode: ReplayMode::default(),
state_store: InMemoryStateStore::new(),
}
}
pub fn with_mode(mut self, mode: ReplayMode) -> Self {
self.mode = mode;
self
}
pub fn replay_all(&mut self, file: &mut PureFile) -> Result<ReplayResult, ReplayError> {
let mut result = ReplayResult::new();
for entry in self.log.entries() {
match &entry.action {
TxAction::MutationApplied {
mutation_type,
mutation_data,
pre_state,
post_state,
..
} => {
match self.replay_mutation_entry(
entry,
mutation_type,
mutation_data,
pre_state.as_ref(),
post_state.as_ref(),
file,
) {
Ok(changes) => {
result.mutations_applied += 1;
result.total_changes += changes;
}
Err(ReplayError::UnreplayableMutation(t)) => {
result.skipped += 1;
result.warnings.push(format!(
"Skipped unreplayable mutation: {} (entry #{})",
t, entry.id
));
}
Err(ReplayError::MissingMutationData(_)) => {
result.skipped += 1;
result.warnings.push(format!(
"Skipped mutation without data (entry #{})",
entry.id
));
}
Err(e) => return Err(e),
}
}
_ => {
}
}
}
Ok(result)
}
pub fn replay_file(
&mut self,
target_path: &Path,
file: &mut PureFile,
) -> Result<ReplayResult, ReplayError> {
let mut result = ReplayResult::new();
for entry in self.log.entries() {
if let TxAction::MutationApplied {
mutation_type,
mutation_data,
file_path,
pre_state,
post_state,
..
} = &entry.action
{
let matches = file_path.as_ref().map(|p| p == target_path).unwrap_or(true);
if !matches {
continue;
}
match self.replay_mutation_entry(
entry,
mutation_type,
mutation_data,
pre_state.as_ref(),
post_state.as_ref(),
file,
) {
Ok(changes) => {
result.mutations_applied += 1;
result.total_changes += changes;
}
Err(ReplayError::UnreplayableMutation(t)) => {
result.skipped += 1;
result.warnings.push(format!(
"Skipped unreplayable mutation: {} (entry #{})",
t, entry.id
));
}
Err(ReplayError::MissingMutationData(_)) => {
result.skipped += 1;
result.warnings.push(format!(
"Skipped mutation without data (entry #{})",
entry.id
));
}
Err(e) => return Err(e),
}
}
}
Ok(result)
}
fn replay_mutation_entry(
&mut self,
entry: &TxEntry,
mutation_type: &str,
mutation_data: &Option<serde_json::Value>,
pre_state: Option<&StateRef>,
post_state: Option<&StateRef>,
file: &mut PureFile,
) -> Result<usize, ReplayError> {
if self.mode.verify_pre() {
if let Some(expected_pre) = pre_state {
let current = self.state_store.store(
&file
.to_source()
.map_err(|e| ReplayError::MutationFailed(e.to_string()))?,
);
if ¤t != expected_pre {
return Err(ReplayError::PreStateMismatch {
id: entry.id,
expected: expected_pre.short().to_string(),
actual: current.short().to_string(),
});
}
}
}
let data = mutation_data
.as_ref()
.ok_or(ReplayError::MissingMutationData(entry.id))?;
let serializable: SerializableMutation = serde_json::from_value(data.clone())
.map_err(|e| ReplayError::ParseError(e.to_string()))?;
let mutation = serializable
.to_mutation()
.ok_or_else(|| ReplayError::UnreplayableMutation(mutation_type.to_string()))?;
let _mutation = mutation; let changes = 0usize;
if self.mode.verify_post() {
if let Some(expected_post) = post_state {
let current = self.state_store.store(
&file
.to_source()
.map_err(|e| ReplayError::MutationFailed(e.to_string()))?,
);
if ¤t != expected_post {
return Err(ReplayError::PostStateMismatch {
id: entry.id,
expected: expected_post.short().to_string(),
actual: current.short().to_string(),
});
}
}
}
Ok(changes)
}
pub fn analyze(&self) -> ReplayAnalysis {
let mut analysis = ReplayAnalysis::default();
for entry in self.log.entries() {
if let TxAction::MutationApplied {
mutation_type,
mutation_data,
pre_state,
post_state,
..
} = &entry.action
{
analysis.total_mutations += 1;
if mutation_data.is_none() {
analysis.missing_data += 1;
analysis
.unreplayable_types
.push(format!("{} (entry #{}, no data)", mutation_type, entry.id));
continue;
}
if pre_state.is_some() {
analysis.with_pre_state += 1;
}
if post_state.is_some() {
analysis.with_post_state += 1;
}
if let Some(data) = mutation_data {
match serde_json::from_value::<SerializableMutation>(data.clone()) {
Ok(sm) => {
if sm.to_mutation().is_some() {
analysis.replayable += 1;
} else {
analysis.unreplayable_types.push(format!(
"{} (entry #{}, Generic)",
mutation_type, entry.id
));
}
}
Err(_) => {
analysis.parse_errors += 1;
analysis.unreplayable_types.push(format!(
"{} (entry #{}, parse error)",
mutation_type, entry.id
));
}
}
}
}
}
analysis
}
}
#[derive(Debug, Default)]
pub struct ReplayAnalysis {
pub total_mutations: usize,
pub replayable: usize,
pub missing_data: usize,
pub parse_errors: usize,
pub with_pre_state: usize,
pub with_post_state: usize,
pub unreplayable_types: Vec<String>,
}
impl ReplayAnalysis {
pub fn replayable_percentage(&self) -> f64 {
if self.total_mutations == 0 {
100.0
} else {
(self.replayable as f64 / self.total_mutations as f64) * 100.0
}
}
pub fn is_fully_replayable(&self) -> bool {
self.replayable == self.total_mutations
}
pub fn can_verify(&self) -> bool {
self.with_pre_state == self.total_mutations && self.with_post_state == self.total_mutations
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::txlog::TxAction;
use ryo_analysis::{SymbolId, SymbolKind, SymbolPath, SymbolRegistry};
use ryo_mutations::RenameMutation;
fn dummy_symbol_id() -> SymbolId {
let mut registry = SymbolRegistry::new();
let path = SymbolPath::parse("test_crate::test_dummy").unwrap();
registry.register(path, SymbolKind::Function).unwrap()
}
fn create_test_log_with_mutation() -> TxLog {
let mut log = TxLog::new();
let mutation = RenameMutation {
symbol_id: dummy_symbol_id(),
to: "bar".to_string(),
};
use ryo_mutations::ToSerializable;
let serializable = mutation.to_serializable();
log.log(TxAction::MutationApplied {
mutation_type: "Rename".to_string(),
target: "foo -> bar".to_string(),
changes: 1,
mutation_data: Some(serializable.to_json()),
file_path: None,
pre_state: None,
post_state: None,
affected_symbols: vec![],
});
log
}
#[test]
fn test_replay_engine_analyze() {
let log = create_test_log_with_mutation();
let engine = ReplayEngine::new(&log);
let analysis = engine.analyze();
assert_eq!(analysis.total_mutations, 1);
assert_eq!(analysis.replayable, 1);
assert!(analysis.is_fully_replayable());
}
#[test]
#[ignore = "V1 path disabled - Mutation::apply removed, needs V2 migration"]
fn test_replay_simple_mutation() {
let log = create_test_log_with_mutation();
let mut engine = ReplayEngine::new(&log);
let mut file = PureFile::from_source("fn foo() {}").unwrap();
let result = engine.replay_all(&mut file).unwrap();
assert_eq!(result.mutations_applied, 1);
assert!(result.total_changes > 0);
let source = file.to_source().unwrap();
assert!(source.contains("bar"));
assert!(!source.contains("foo"));
}
#[test]
fn test_analyze_missing_data() {
let mut log = TxLog::new();
log.log(TxAction::MutationApplied {
mutation_type: "Rename".to_string(),
target: "foo -> bar".to_string(),
changes: 1,
mutation_data: None, file_path: None,
pre_state: None,
post_state: None,
affected_symbols: vec![],
});
let engine = ReplayEngine::new(&log);
let analysis = engine.analyze();
assert_eq!(analysis.total_mutations, 1);
assert_eq!(analysis.missing_data, 1);
assert_eq!(analysis.replayable, 0);
assert!(!analysis.is_fully_replayable());
}
#[test]
fn test_verify_multiple_mutation_types() {
use ryo_mutations::{AddDeriveMutation, AddFunctionMutation, ToSerializable};
let mut log = TxLog::new();
let rename = RenameMutation {
symbol_id: dummy_symbol_id(),
to: "new_name".to_string(),
};
log.log(TxAction::MutationApplied {
mutation_type: "Rename".to_string(),
target: "old_name -> new_name".to_string(),
changes: 1,
mutation_data: Some(rename.to_serializable().to_json()),
file_path: None,
pre_state: None,
post_state: None,
affected_symbols: vec![],
});
let add_fn = AddFunctionMutation {
parent: dummy_symbol_id(),
name: "new_fn".to_string(),
params: vec![("x".to_string(), "i32".to_string())],
return_type: Some("i32".to_string()),
body: "x + 1".to_string(),
is_pub: true,
};
log.log(TxAction::MutationApplied {
mutation_type: "AddFunction".to_string(),
target: "new_fn".to_string(),
changes: 1,
mutation_data: Some(add_fn.to_serializable().to_json()),
file_path: None,
pre_state: None,
post_state: None,
affected_symbols: vec![],
});
let add_derive = AddDeriveMutation {
symbol_id: dummy_symbol_id(),
derives: vec!["Debug".to_string(), "Clone".to_string()],
};
log.log(TxAction::MutationApplied {
mutation_type: "AddDerive".to_string(),
target: "MyStruct".to_string(),
changes: 1,
mutation_data: Some(add_derive.to_serializable().to_json()),
file_path: None,
pre_state: None,
post_state: None,
affected_symbols: vec![],
});
log.log(TxAction::MutationApplied {
mutation_type: "Rename".to_string(),
target: "legacy call".to_string(),
changes: 1,
mutation_data: None, file_path: None,
pre_state: None,
post_state: None,
affected_symbols: vec![],
});
let engine = ReplayEngine::new(&log);
let analysis = engine.analyze();
assert_eq!(analysis.total_mutations, 4);
assert_eq!(analysis.replayable, 1); assert_eq!(analysis.missing_data, 1); assert!(!analysis.is_fully_replayable());
assert!((analysis.replayable_percentage() - 25.0).abs() < 0.1);
}
#[test]
fn test_verify_generic_mutation_not_replayable() {
use ryo_mutations::SerializableMutation;
let mut log = TxLog::new();
let generic = SerializableMutation::Generic {
mutation_type: "CustomMutation".to_string(),
description: "Some custom operation".to_string(),
};
log.log(TxAction::MutationApplied {
mutation_type: "CustomMutation".to_string(),
target: "custom".to_string(),
changes: 1,
mutation_data: Some(generic.to_json()),
file_path: None,
pre_state: None,
post_state: None,
affected_symbols: vec![],
});
let engine = ReplayEngine::new(&log);
let analysis = engine.analyze();
assert_eq!(analysis.total_mutations, 1);
assert_eq!(analysis.replayable, 0); assert!(!analysis.unreplayable_types.is_empty());
}
#[test]
fn test_verify_replay_with_state_verification() {
use crate::storage::InMemoryStateStore;
use ryo_mutations::ToSerializable;
let mut state_store = InMemoryStateStore::new();
let initial_source = "fn foo() { println!(\"hello\"); }";
let pre_state = state_store.store(initial_source);
let expected_source = "fn bar() { println!(\"hello\"); }";
let post_state = state_store.store(expected_source);
let mut log = TxLog::new();
let rename = RenameMutation {
symbol_id: dummy_symbol_id(),
to: "bar".to_string(),
};
log.log(TxAction::MutationApplied {
mutation_type: "Rename".to_string(),
target: "foo -> bar".to_string(),
changes: 1,
mutation_data: Some(rename.to_serializable().to_json()),
file_path: Some(std::path::PathBuf::from("test.rs")),
pre_state: Some(pre_state.clone()),
post_state: Some(post_state.clone()),
affected_symbols: vec![],
});
let engine = ReplayEngine::new(&log);
let analysis = engine.analyze();
assert_eq!(analysis.with_pre_state, 1);
assert_eq!(analysis.with_post_state, 1);
assert!(analysis.can_verify());
}
#[test]
#[ignore = "V1 path disabled - Mutation::apply removed, needs V2 migration"]
fn test_verify_sequential_replay() {
use ryo_mutations::ToSerializable;
let mut log = TxLog::new();
let rename1 = RenameMutation {
symbol_id: dummy_symbol_id(),
to: "bar".to_string(),
};
log.log(TxAction::MutationApplied {
mutation_type: "Rename".to_string(),
target: "foo -> bar".to_string(),
changes: 1,
mutation_data: Some(rename1.to_serializable().to_json()),
file_path: None,
pre_state: None,
post_state: None,
affected_symbols: vec![],
});
let rename2 = RenameMutation {
symbol_id: dummy_symbol_id(),
to: "baz".to_string(),
};
log.log(TxAction::MutationApplied {
mutation_type: "Rename".to_string(),
target: "bar -> baz".to_string(),
changes: 1,
mutation_data: Some(rename2.to_serializable().to_json()),
file_path: None,
pre_state: None,
post_state: None,
affected_symbols: vec![],
});
let mut engine = ReplayEngine::new(&log);
let mut file = PureFile::from_source("fn foo() { foo(); }").unwrap();
let result = engine.replay_all(&mut file).unwrap();
assert_eq!(result.mutations_applied, 2);
let source = file.to_source().unwrap();
assert!(source.contains("baz"));
assert!(!source.contains("foo"));
assert!(!source.contains("bar"));
}
#[test]
fn test_verify_current_logger_issue() {
use crate::txlog::TxLogger;
use std::path::PathBuf;
use std::thread;
use std::time::Duration;
let logger = TxLogger::start(PathBuf::from("/test"), 10);
logger.log_mutation("Rename", "foo -> bar", 5);
thread::sleep(Duration::from_millis(10));
let log = logger.finish();
let engine = ReplayEngine::new(&log);
let analysis = engine.analyze();
assert_eq!(analysis.total_mutations, 1);
assert_eq!(analysis.missing_data, 1);
assert_eq!(analysis.replayable, 0);
}
#[test]
fn test_verify_new_record_mutation_api() {
use crate::txlog::TxLogger;
use std::path::PathBuf;
use std::thread;
use std::time::Duration;
let logger = TxLogger::start(PathBuf::from("/test"), 10);
let mutation = RenameMutation {
symbol_id: dummy_symbol_id(),
to: "bar".to_string(),
};
logger.record_mutation(&mutation, 5);
thread::sleep(Duration::from_millis(10));
let log = logger.finish();
let engine = ReplayEngine::new(&log);
let analysis = engine.analyze();
assert_eq!(analysis.total_mutations, 1);
assert_eq!(analysis.missing_data, 0);
assert_eq!(analysis.replayable, 1);
assert!(analysis.is_fully_replayable());
}
#[test]
fn test_verify_mixed_api_usage() {
use crate::txlog::TxLogger;
use std::path::PathBuf;
use std::thread;
use std::time::Duration;
let logger = TxLogger::start(PathBuf::from("/test"), 10);
logger.log_mutation("Rename", "legacy", 1);
let mutation1 = RenameMutation {
symbol_id: dummy_symbol_id(),
to: "bar".to_string(),
};
logger.record_mutation(&mutation1, 2);
let mutation2 = RenameMutation {
symbol_id: dummy_symbol_id(),
to: "qux".to_string(),
};
logger.record_mutation_for_file(&mutation2, 3, "src/lib.rs");
thread::sleep(Duration::from_millis(10));
let log = logger.finish();
let engine = ReplayEngine::new(&log);
let analysis = engine.analyze();
assert_eq!(analysis.total_mutations, 3);
assert_eq!(analysis.replayable, 2);
assert_eq!(analysis.missing_data, 1);
assert!(!analysis.is_fully_replayable());
assert!((analysis.replayable_percentage() - 66.67).abs() < 1.0);
}
}