Skip to main content

lit/commands/
transaction.rs

1use 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    /// Write-ahead log entries: (operation_type, path, original_content_base64)
11    wal: Vec<WalEntry>,
12}
13
14#[derive(Debug, Clone, Serialize, Deserialize)]
15struct WalEntry {
16    op: String,
17    path: String,
18    /// Base64-encoded original content for rollback (None for creates)
19    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    // Commit = just remove the WAL and lock (changes are already applied)
76    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    // Replay WAL in reverse to undo changes
101    let mut rollback_errors = Vec::new();
102    for entry in state.wal.iter().rev() {
103        match entry.op.as_str() {
104            "write" => {
105                // Restore original content
106                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                // Remove created file
123                let _ = fs::remove_file(&entry.path);
124            }
125            "delete" => {
126                // Restore deleted file
127                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
156/// Record a WAL entry for the current transaction (called from other commands)
157pub 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(()); // No active transaction
165    }
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    // Simple base64 without pulling in another crate
194    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, // padding
234                }
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}