use super::{
env::AnalysisEnv,
guards::is_valid_checkpoint,
state::{AnalysisConfig, AnalysisPhase, AnalysisState},
};
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::path::Path;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Checkpoint {
pub version: u32,
pub phase: AnalysisPhase,
pub config: AnalysisConfig,
pub created_at: chrono::DateTime<chrono::Utc>,
pub metrics_count: Option<usize>,
}
impl Checkpoint {
pub const CURRENT_VERSION: u32 = 1;
pub fn from_state(state: &AnalysisState) -> Self {
Self {
version: Self::CURRENT_VERSION,
phase: state.phase,
config: state.config.clone(),
created_at: chrono::Utc::now(),
metrics_count: state.results.metrics.as_ref().map(|m| m.len()),
}
}
pub fn into_state(self) -> AnalysisState {
let mut state = AnalysisState::new(self.config);
state.phase = match self.phase {
AnalysisPhase::CallGraphBuilding => AnalysisPhase::Initialized,
AnalysisPhase::CoverageLoading => AnalysisPhase::CallGraphComplete,
AnalysisPhase::PurityAnalyzing => AnalysisPhase::CoverageComplete,
AnalysisPhase::ContextLoading => AnalysisPhase::PurityComplete,
AnalysisPhase::ScoringInProgress => AnalysisPhase::ContextComplete,
AnalysisPhase::FilteringInProgress => AnalysisPhase::ScoringComplete,
other => other, };
state
}
}
pub fn save_checkpoint(state: &AnalysisState, path: &Path) -> Result<()> {
let checkpoint = Checkpoint::from_state(state);
let json = serde_json::to_string_pretty(&checkpoint)
.context("Failed to serialize checkpoint to JSON")?;
std::fs::write(path, json).context("Failed to write checkpoint file")?;
Ok(())
}
pub fn load_checkpoint(path: &Path) -> Result<AnalysisState> {
let json = std::fs::read_to_string(path).context("Failed to read checkpoint file")?;
let checkpoint: Checkpoint =
serde_json::from_str(&json).context("Failed to parse checkpoint JSON")?;
if checkpoint.version > Checkpoint::CURRENT_VERSION {
anyhow::bail!(
"Checkpoint version {} is newer than supported version {}",
checkpoint.version,
Checkpoint::CURRENT_VERSION
);
}
Ok(checkpoint.into_state())
}
pub fn resume_analysis<Env: AnalysisEnv>(
checkpoint_path: &Path,
metrics: Vec<crate::core::FunctionMetrics>,
mut env: Env,
) -> Result<AnalysisState> {
use super::actions::WorkflowRunner;
let mut state = load_checkpoint(checkpoint_path)?;
state.results.metrics = Some(metrics);
env.info(&format!("Resuming from phase: {:?}", state.phase));
if !is_valid_checkpoint(&state) {
env.warn("Checkpoint state invalid, restarting from beginning");
state.phase = AnalysisPhase::Initialized;
}
WorkflowRunner::new(state, env).run()
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
use tempfile::NamedTempFile;
fn create_test_config() -> AnalysisConfig {
AnalysisConfig {
project_path: PathBuf::from("src"),
enable_context: false,
..Default::default()
}
}
#[test]
fn test_checkpoint_creation() {
let config = create_test_config();
let state = AnalysisState::new(config);
let checkpoint = Checkpoint::from_state(&state);
assert_eq!(checkpoint.version, Checkpoint::CURRENT_VERSION);
assert_eq!(checkpoint.phase, AnalysisPhase::Initialized);
}
#[test]
fn test_checkpoint_roundtrip() {
let config = create_test_config();
let state = AnalysisState::new(config.clone());
let file = NamedTempFile::new().unwrap();
save_checkpoint(&state, file.path()).unwrap();
let loaded = load_checkpoint(file.path()).unwrap();
assert_eq!(loaded.phase, state.phase);
assert_eq!(loaded.config.project_path, config.project_path);
}
#[test]
fn test_checkpoint_phase_recovery() {
let cases = vec![
(AnalysisPhase::CallGraphBuilding, AnalysisPhase::Initialized),
(
AnalysisPhase::CoverageLoading,
AnalysisPhase::CallGraphComplete,
),
(
AnalysisPhase::PurityAnalyzing,
AnalysisPhase::CoverageComplete,
),
(AnalysisPhase::ContextLoading, AnalysisPhase::PurityComplete),
(
AnalysisPhase::ScoringInProgress,
AnalysisPhase::ContextComplete,
),
(
AnalysisPhase::FilteringInProgress,
AnalysisPhase::ScoringComplete,
),
(
AnalysisPhase::CallGraphComplete,
AnalysisPhase::CallGraphComplete,
),
(AnalysisPhase::Complete, AnalysisPhase::Complete),
];
for (input_phase, expected_phase) in cases {
let checkpoint = Checkpoint {
version: Checkpoint::CURRENT_VERSION,
phase: input_phase,
config: create_test_config(),
created_at: chrono::Utc::now(),
metrics_count: None,
};
let state = checkpoint.into_state();
assert_eq!(
state.phase, expected_phase,
"Phase {:?} should recover to {:?}",
input_phase, expected_phase
);
}
}
#[test]
fn test_checkpoint_version_validation() {
let config = create_test_config();
let state = AnalysisState::new(config);
let file = NamedTempFile::new().unwrap();
save_checkpoint(&state, file.path()).unwrap();
let future_checkpoint = Checkpoint {
version: Checkpoint::CURRENT_VERSION + 10,
phase: AnalysisPhase::Initialized,
config: create_test_config(),
created_at: chrono::Utc::now(),
metrics_count: None,
};
let json = serde_json::to_string(&future_checkpoint).unwrap();
let future_file = NamedTempFile::new().unwrap();
std::fs::write(future_file.path(), json).unwrap();
let result = load_checkpoint(future_file.path());
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("newer than supported"));
}
#[test]
fn test_checkpoint_file_not_found() {
let result = load_checkpoint(Path::new("/nonexistent/checkpoint.json"));
assert!(result.is_err());
}
#[test]
fn test_checkpoint_invalid_json() {
let file = NamedTempFile::new().unwrap();
std::fs::write(file.path(), "not valid json").unwrap();
let result = load_checkpoint(file.path());
assert!(result.is_err());
}
#[test]
fn test_checkpoint_with_coverage_config() {
let mut config = create_test_config();
config.coverage_file = Some(PathBuf::from("coverage.lcov"));
config.enable_context = true;
let state = AnalysisState::new(config);
let file = NamedTempFile::new().unwrap();
save_checkpoint(&state, file.path()).unwrap();
let loaded = load_checkpoint(file.path()).unwrap();
assert_eq!(
loaded.config.coverage_file,
Some(PathBuf::from("coverage.lcov"))
);
assert!(loaded.config.enable_context);
}
}