use std::fs;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
const SESSION_DIR: &str = ".morph-cli/sessions";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MigrationSession {
pub id: String,
pub recipe_names: Vec<String>,
pub started_at: u64,
pub completed_at: u64,
pub mode: String,
pub target_path: PathBuf,
pub modified_files: Vec<PathBuf>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub backup_session_id: Option<String>,
#[serde(default)]
pub options: SessionOptions,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct SessionOptions {
pub write: bool,
pub review: bool,
pub autofix: bool,
pub allow_risky: bool,
pub strict: bool,
pub format: bool,
pub prettier: bool,
pub no_format: bool,
pub jobs: Option<usize>,
pub sequential: bool,
}
impl MigrationSession {
pub fn new(recipe_names: Vec<String>, mode: impl Into<String>, target_path: PathBuf, options: SessionOptions) -> Self {
let started_at = current_timestamp();
Self {
id: generate_session_id(),
recipe_names,
started_at,
completed_at: started_at,
mode: mode.into(),
target_path,
modified_files: Vec::new(),
backup_session_id: None,
options,
}
}
pub fn complete(
mut self,
modified_files: Vec<PathBuf>,
backup_session_id: Option<String>,
) -> Self {
self.completed_at = current_timestamp();
self.modified_files = modified_files;
self.backup_session_id = backup_session_id;
self
}
}
pub struct SessionStore {
root: PathBuf,
}
impl SessionStore {
pub fn new(project_root: &Path) -> Self {
Self {
root: project_root.join(SESSION_DIR),
}
}
pub fn save(&self, session: &MigrationSession) -> Result<()> {
fs::create_dir_all(&self.root).with_context(|| {
format!(
"Failed to create session directory: {}",
self.root.display()
)
})?;
let path = self.session_path(&session.id);
let json =
serde_json::to_string_pretty(session).context("Failed to serialize session metadata")?;
fs::write(&path, json)
.with_context(|| format!("Failed to write session metadata: {}", path.display()))?;
Ok(())
}
pub fn load(&self, id: &str) -> Result<Option<MigrationSession>> {
let path = self.session_path(id);
if !path.exists() {
return Ok(None);
}
let content = fs::read_to_string(&path)
.with_context(|| format!("Failed to read session metadata: {}", path.display()))?;
let session = serde_json::from_str(&content)
.with_context(|| format!("Failed to parse session metadata: {}", path.display()))?;
Ok(Some(session))
}
pub fn list(&self) -> Result<Vec<MigrationSession>> {
let mut sessions = Vec::new();
if !self.root.exists() {
return Ok(sessions);
}
for entry in fs::read_dir(&self.root)? {
let entry = entry?;
let path = entry.path();
if path.extension().and_then(|extension| extension.to_str()) != Some("json") {
continue;
}
if let Ok(content) = fs::read_to_string(&path)
&& let Ok(session) = serde_json::from_str::<MigrationSession>(&content)
{
sessions.push(session);
}
}
sessions.sort_by(|left, right| right.started_at.cmp(&left.started_at));
Ok(sessions)
}
fn session_path(&self, id: &str) -> PathBuf {
self.root.join(format!("{id}.json"))
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MigrationCheckpoint {
pub id: String,
pub session_id: String,
pub completed_recipes: Vec<String>,
pub remaining_recipes: Vec<String>,
pub modified_files: Vec<PathBuf>,
pub options: SessionOptions,
pub target_path: PathBuf,
pub timestamp: u64,
}
const CHECKPOINT_DIR: &str = ".morph-cli/checkpoints";
pub struct CheckpointStore {
root: PathBuf,
}
impl CheckpointStore {
pub fn new(project_root: &Path) -> Self {
Self {
root: project_root.join(CHECKPOINT_DIR),
}
}
pub fn save(&self, checkpoint: &MigrationCheckpoint) -> Result<()> {
fs::create_dir_all(&self.root).with_context(|| {
format!(
"Failed to create checkpoints directory: {}",
self.root.display()
)
})?;
let path = self.checkpoint_path(&checkpoint.id);
let json =
serde_json::to_string_pretty(checkpoint).context("Failed to serialize checkpoint metadata")?;
fs::write(&path, json)
.with_context(|| format!("Failed to write checkpoint metadata: {}", path.display()))?;
Ok(())
}
pub fn load(&self, id: &str) -> Result<Option<MigrationCheckpoint>> {
let path = self.checkpoint_path(id);
if !path.exists() {
return Ok(None);
}
let content = fs::read_to_string(&path)
.with_context(|| format!("Failed to read checkpoint metadata: {}", path.display()))?;
let checkpoint = serde_json::from_str(&content)
.with_context(|| format!("Failed to parse checkpoint metadata: {}", path.display()))?;
Ok(Some(checkpoint))
}
pub fn list(&self) -> Result<Vec<MigrationCheckpoint>> {
let mut checkpoints = Vec::new();
if !self.root.exists() {
return Ok(checkpoints);
}
for entry in fs::read_dir(&self.root)? {
let entry = entry?;
let path = entry.path();
if path.extension().and_then(|extension| extension.to_str()) != Some("json") {
continue;
}
if let Ok(content) = fs::read_to_string(&path) {
if let Ok(checkpoint) = serde_json::from_str::<MigrationCheckpoint>(&content) {
checkpoints.push(checkpoint);
}
}
}
checkpoints.sort_by(|left, right| right.timestamp.cmp(&left.timestamp));
Ok(checkpoints)
}
fn checkpoint_path(&self, id: &str) -> PathBuf {
self.root.join(format!("{id}.json"))
}
}
fn generate_session_id() -> String {
format!("session-{}", current_timestamp_millis())
}
pub fn current_timestamp() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
}
fn current_timestamp_millis() -> u128 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis()
}