Skip to main content

morph_cli/core/
session.rs

1use std::fs;
2use std::path::{Path, PathBuf};
3use std::time::{SystemTime, UNIX_EPOCH};
4
5use anyhow::{Context, Result};
6use serde::{Deserialize, Serialize};
7
8const SESSION_DIR: &str = ".morph-cli/sessions";
9
10#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct MigrationSession {
12    pub id: String,
13    pub recipe_names: Vec<String>,
14    pub started_at: u64,
15    pub completed_at: u64,
16    pub mode: String,
17    pub target_path: PathBuf,
18    pub modified_files: Vec<PathBuf>,
19    #[serde(default, skip_serializing_if = "Option::is_none")]
20    pub backup_session_id: Option<String>,
21    #[serde(default)]
22    pub options: SessionOptions,
23}
24
25#[derive(Debug, Clone, Default, Serialize, Deserialize)]
26pub struct SessionOptions {
27    pub write: bool,
28    pub review: bool,
29    pub autofix: bool,
30    pub allow_risky: bool,
31    pub strict: bool,
32    pub format: bool,
33    pub prettier: bool,
34    pub no_format: bool,
35    pub jobs: Option<usize>,
36    pub sequential: bool,
37}
38
39impl MigrationSession {
40    pub fn new(recipe_names: Vec<String>, mode: impl Into<String>, target_path: PathBuf, options: SessionOptions) -> Self {
41        let started_at = current_timestamp();
42        Self {
43            id: generate_session_id(),
44            recipe_names,
45            started_at,
46            completed_at: started_at,
47            mode: mode.into(),
48            target_path,
49            modified_files: Vec::new(),
50            backup_session_id: None,
51            options,
52        }
53    }
54
55    pub fn complete(
56        mut self,
57        modified_files: Vec<PathBuf>,
58        backup_session_id: Option<String>,
59    ) -> Self {
60        self.completed_at = current_timestamp();
61        self.modified_files = modified_files;
62        self.backup_session_id = backup_session_id;
63        self
64    }
65}
66
67pub struct SessionStore {
68    root: PathBuf,
69}
70
71impl SessionStore {
72    pub fn new(project_root: &Path) -> Self {
73        Self {
74            root: project_root.join(SESSION_DIR),
75        }
76    }
77
78    pub fn save(&self, session: &MigrationSession) -> Result<()> {
79        fs::create_dir_all(&self.root).with_context(|| {
80            format!(
81                "Failed to create session directory: {}",
82                self.root.display()
83            )
84        })?;
85
86        let path = self.session_path(&session.id);
87        let json =
88            serde_json::to_string_pretty(session).context("Failed to serialize session metadata")?;
89        fs::write(&path, json)
90            .with_context(|| format!("Failed to write session metadata: {}", path.display()))?;
91        Ok(())
92    }
93
94    pub fn load(&self, id: &str) -> Result<Option<MigrationSession>> {
95        let path = self.session_path(id);
96        if !path.exists() {
97            return Ok(None);
98        }
99
100        let content = fs::read_to_string(&path)
101            .with_context(|| format!("Failed to read session metadata: {}", path.display()))?;
102        let session = serde_json::from_str(&content)
103            .with_context(|| format!("Failed to parse session metadata: {}", path.display()))?;
104        Ok(Some(session))
105    }
106
107    pub fn list(&self) -> Result<Vec<MigrationSession>> {
108        let mut sessions = Vec::new();
109
110        if !self.root.exists() {
111            return Ok(sessions);
112        }
113
114        for entry in fs::read_dir(&self.root)? {
115            let entry = entry?;
116            let path = entry.path();
117
118            if path.extension().and_then(|extension| extension.to_str()) != Some("json") {
119                continue;
120            }
121
122            if let Ok(content) = fs::read_to_string(&path)
123                && let Ok(session) = serde_json::from_str::<MigrationSession>(&content)
124            {
125                sessions.push(session);
126            }
127        }
128
129        sessions.sort_by(|left, right| right.started_at.cmp(&left.started_at));
130        Ok(sessions)
131    }
132
133    fn session_path(&self, id: &str) -> PathBuf {
134        self.root.join(format!("{id}.json"))
135    }
136}
137
138#[derive(Debug, Clone, Serialize, Deserialize)]
139pub struct MigrationCheckpoint {
140    pub id: String,
141    pub session_id: String,
142    pub completed_recipes: Vec<String>,
143    pub remaining_recipes: Vec<String>,
144    pub modified_files: Vec<PathBuf>,
145    pub options: SessionOptions,
146    pub target_path: PathBuf,
147    pub timestamp: u64,
148}
149
150const CHECKPOINT_DIR: &str = ".morph-cli/checkpoints";
151
152pub struct CheckpointStore {
153    root: PathBuf,
154}
155
156impl CheckpointStore {
157    pub fn new(project_root: &Path) -> Self {
158        Self {
159            root: project_root.join(CHECKPOINT_DIR),
160        }
161    }
162
163    pub fn save(&self, checkpoint: &MigrationCheckpoint) -> Result<()> {
164        fs::create_dir_all(&self.root).with_context(|| {
165            format!(
166                "Failed to create checkpoints directory: {}",
167                self.root.display()
168            )
169        })?;
170
171        let path = self.checkpoint_path(&checkpoint.id);
172        let json =
173            serde_json::to_string_pretty(checkpoint).context("Failed to serialize checkpoint metadata")?;
174        fs::write(&path, json)
175            .with_context(|| format!("Failed to write checkpoint metadata: {}", path.display()))?;
176        Ok(())
177    }
178
179    pub fn load(&self, id: &str) -> Result<Option<MigrationCheckpoint>> {
180        let path = self.checkpoint_path(id);
181        if !path.exists() {
182            return Ok(None);
183        }
184
185        let content = fs::read_to_string(&path)
186            .with_context(|| format!("Failed to read checkpoint metadata: {}", path.display()))?;
187        let checkpoint = serde_json::from_str(&content)
188            .with_context(|| format!("Failed to parse checkpoint metadata: {}", path.display()))?;
189        Ok(Some(checkpoint))
190    }
191
192    pub fn list(&self) -> Result<Vec<MigrationCheckpoint>> {
193        let mut checkpoints = Vec::new();
194
195        if !self.root.exists() {
196            return Ok(checkpoints);
197        }
198
199        for entry in fs::read_dir(&self.root)? {
200            let entry = entry?;
201            let path = entry.path();
202
203            if path.extension().and_then(|extension| extension.to_str()) != Some("json") {
204                continue;
205            }
206
207            if let Ok(content) = fs::read_to_string(&path) {
208                if let Ok(checkpoint) = serde_json::from_str::<MigrationCheckpoint>(&content) {
209                    checkpoints.push(checkpoint);
210                }
211            }
212        }
213
214        checkpoints.sort_by(|left, right| right.timestamp.cmp(&left.timestamp));
215        Ok(checkpoints)
216    }
217
218    fn checkpoint_path(&self, id: &str) -> PathBuf {
219        self.root.join(format!("{id}.json"))
220    }
221}
222
223fn generate_session_id() -> String {
224    format!("session-{}", current_timestamp_millis())
225}
226
227pub fn current_timestamp() -> u64 {
228    SystemTime::now()
229        .duration_since(UNIX_EPOCH)
230        .unwrap_or_default()
231        .as_secs()
232}
233
234fn current_timestamp_millis() -> u128 {
235    SystemTime::now()
236        .duration_since(UNIX_EPOCH)
237        .unwrap_or_default()
238        .as_millis()
239}