lit/commands/
transaction.rs1use crate::core::find_repo_root;
2use crate::response::TransactionResponse;
3use serde::{Deserialize, Serialize};
4use std::fs;
5
6#[derive(Debug, Clone, Serialize, Deserialize)]
7struct TransactionState {
8 tx_id: String,
9 started_at: i64,
10 wal: Vec<WalEntry>,
12}
13
14#[derive(Debug, Clone, Serialize, Deserialize)]
15struct WalEntry {
16 op: String,
17 path: String,
18 original: Option<String>,
20}
21
22fn tx_state_path(repo_root: &std::path::Path) -> std::path::PathBuf {
23 repo_root.join(".lit").join("transaction.json")
24}
25
26fn lock_path(repo_root: &std::path::Path) -> std::path::PathBuf {
27 repo_root.join(".lit").join("transaction.lock")
28}
29
30pub fn execute_begin() -> Result<TransactionResponse, crate::errors::LitError> {
31 let repo_root = find_repo_root()?;
32 let lock = lock_path(&repo_root);
33
34 if lock.exists() {
35 return Err(
36 "Another transaction is in progress. Use 'lit tx rollback' to abort it.".into(),
37 );
38 }
39
40 let tx_id = uuid::Uuid::new_v4().to_string();
41
42 let state = TransactionState {
43 tx_id: tx_id.clone(),
44 started_at: chrono::Utc::now().timestamp(),
45 wal: Vec::new(),
46 };
47
48 let data = serde_json::to_string_pretty(&state)
49 .map_err(|e| format!("Failed to serialize transaction state: {}", e))?;
50 fs::write(tx_state_path(&repo_root), &data)
51 .map_err(|e| format!("Failed to write transaction state: {}", e))?;
52 fs::write(&lock, &tx_id).map_err(|e| format!("Failed to create transaction lock: {}", e))?;
53
54 Ok(TransactionResponse {
55 action: "begin".to_string(),
56 tx_id: Some(tx_id),
57 message: "Transaction started".to_string(),
58 })
59}
60
61pub fn execute_commit_tx() -> Result<TransactionResponse, crate::errors::LitError> {
62 let repo_root = find_repo_root()?;
63 let lock = lock_path(&repo_root);
64 let state_path = tx_state_path(&repo_root);
65
66 if !lock.exists() {
67 return Err("No transaction in progress".into());
68 }
69
70 let data = fs::read_to_string(&state_path)
71 .map_err(|e| format!("Failed to read transaction state: {}", e))?;
72 let state: TransactionState =
73 serde_json::from_str(&data).map_err(|e| format!("Corrupt transaction state: {}", e))?;
74
75 let _ = fs::remove_file(&state_path);
77 let _ = fs::remove_file(&lock);
78
79 Ok(TransactionResponse {
80 action: "commit".to_string(),
81 tx_id: Some(state.tx_id),
82 message: "Transaction committed".to_string(),
83 })
84}
85
86pub fn execute_rollback() -> Result<TransactionResponse, crate::errors::LitError> {
87 let repo_root = find_repo_root()?;
88 let lock = lock_path(&repo_root);
89 let state_path = tx_state_path(&repo_root);
90
91 if !lock.exists() {
92 return Err("No transaction in progress".into());
93 }
94
95 let data = fs::read_to_string(&state_path)
96 .map_err(|e| format!("Failed to read transaction state: {}", e))?;
97 let state: TransactionState =
98 serde_json::from_str(&data).map_err(|e| format!("Corrupt transaction state: {}", e))?;
99
100 let mut rollback_errors = Vec::new();
102 for entry in state.wal.iter().rev() {
103 match entry.op.as_str() {
104 "write" => {
105 if let Some(original_b64) = &entry.original {
107 match base64_decode(original_b64) {
108 Ok(bytes) => {
109 if let Err(e) = fs::write(&entry.path, &bytes) {
110 rollback_errors
111 .push(format!("Failed to restore {}: {}", entry.path, e));
112 }
113 }
114 Err(e) => {
115 rollback_errors
116 .push(format!("Failed to decode WAL for {}: {}", entry.path, e));
117 }
118 }
119 }
120 }
121 "create" => {
122 let _ = fs::remove_file(&entry.path);
124 }
125 "delete" => {
126 if let Some(original_b64) = &entry.original {
128 if let Ok(bytes) = base64_decode(original_b64) {
129 let _ = fs::write(&entry.path, &bytes);
130 }
131 }
132 }
133 _ => {}
134 }
135 }
136
137 let _ = fs::remove_file(&state_path);
138 let _ = fs::remove_file(&lock);
139
140 let message = if rollback_errors.is_empty() {
141 "Transaction rolled back".to_string()
142 } else {
143 format!(
144 "Transaction rolled back with {} error(s)",
145 rollback_errors.len()
146 )
147 };
148
149 Ok(TransactionResponse {
150 action: "rollback".to_string(),
151 tx_id: Some(state.tx_id),
152 message,
153 })
154}
155
156pub fn record_wal(
158 repo_root: &std::path::Path,
159 op: &str,
160 path: &str,
161) -> Result<(), crate::errors::LitError> {
162 let state_path = tx_state_path(repo_root);
163 if !state_path.exists() {
164 return Ok(()); }
166
167 let data = fs::read_to_string(&state_path)
168 .map_err(|e| format!("Failed to read transaction state: {}", e))?;
169 let mut state: TransactionState =
170 serde_json::from_str(&data).map_err(|e| format!("Corrupt transaction state: {}", e))?;
171
172 let original = if op == "write" || op == "delete" {
173 fs::read(path).ok().map(|bytes| base64_encode(&bytes))
174 } else {
175 None
176 };
177
178 state.wal.push(WalEntry {
179 op: op.to_string(),
180 path: path.to_string(),
181 original,
182 });
183
184 let data = serde_json::to_string_pretty(&state)
185 .map_err(|e| format!("Failed to serialize transaction state: {}", e))?;
186 fs::write(&state_path, &data)
187 .map_err(|e| format!("Failed to write transaction state: {}", e))?;
188
189 Ok(())
190}
191
192fn base64_encode(data: &[u8]) -> String {
193 const CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
195 let mut result = String::new();
196 for chunk in data.chunks(3) {
197 let b0 = chunk[0] as u32;
198 let b1 = if chunk.len() > 1 { chunk[1] as u32 } else { 0 };
199 let b2 = if chunk.len() > 2 { chunk[2] as u32 } else { 0 };
200 let n = (b0 << 16) | (b1 << 8) | b2;
201 result.push(CHARS[((n >> 18) & 0x3F) as usize] as char);
202 result.push(CHARS[((n >> 12) & 0x3F) as usize] as char);
203 if chunk.len() > 1 {
204 result.push(CHARS[((n >> 6) & 0x3F) as usize] as char);
205 } else {
206 result.push('=');
207 }
208 if chunk.len() > 2 {
209 result.push(CHARS[(n & 0x3F) as usize] as char);
210 } else {
211 result.push('=');
212 }
213 }
214 result
215}
216
217fn base64_decode(s: &str) -> Result<Vec<u8>, crate::errors::LitError> {
218 let mut result = Vec::new();
219 let chars: Vec<u8> = s.bytes().filter(|b| *b != b'\n' && *b != b'\r').collect();
220 for chunk in chars.chunks(4) {
221 if chunk.len() < 4 {
222 break;
223 }
224 let vals: Vec<u32> = chunk
225 .iter()
226 .map(|&c| {
227 match c {
228 b'A'..=b'Z' => (c - b'A') as u32,
229 b'a'..=b'z' => (c - b'a' + 26) as u32,
230 b'0'..=b'9' => (c - b'0' + 52) as u32,
231 b'+' => 62,
232 b'/' => 63,
233 _ => 0, }
235 })
236 .collect();
237 let n = (vals[0] << 18) | (vals[1] << 12) | (vals[2] << 6) | vals[3];
238 result.push(((n >> 16) & 0xFF) as u8);
239 if chunk[2] != b'=' {
240 result.push(((n >> 8) & 0xFF) as u8);
241 }
242 if chunk[3] != b'=' {
243 result.push((n & 0xFF) as u8);
244 }
245 }
246 Ok(result)
247}