morph_cli/core/
session.rs1use 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}