1use anyhow::{Context, Result};
7use serde::{Deserialize, Serialize};
8use std::collections::HashMap;
9use std::fs;
10use std::path::{Path, PathBuf};
11use tracing::{debug, error, info, warn};
12
13use crate::types::{
14 AllowedCommands, AllowedGlobs, BashCommandMode, BashMode, FileEditMode, Modes, WriteIfEmptyMode,
15};
16
17use super::bash_state::FileWhitelistData;
18
19#[derive(Debug, Clone, Serialize, Deserialize)]
21pub struct BashStateSnapshot {
22 pub bash_command_mode: BashCommandModeSnapshot,
23 pub file_edit_mode: FileEditModeSnapshot,
24 pub write_if_empty_mode: WriteIfEmptyModeSnapshot,
25 pub whitelist_for_overwrite: HashMap<String, FileWhitelistDataSnapshot>,
26 pub mode: String,
27 pub workspace_root: String,
28 pub chat_id: String,
29 #[serde(default, skip_serializing_if = "String::is_empty")]
30 pub cwd: String,
31}
32
33#[derive(Debug, Clone, Serialize, Deserialize)]
34pub struct BashCommandModeSnapshot {
35 pub bash_mode: String,
36 pub allowed_commands: AllowedCommandsSnapshot,
37}
38
39#[derive(Debug, Clone, Serialize, Deserialize)]
40#[serde(untagged)]
41pub enum AllowedCommandsSnapshot {
42 All(String),
43 List(Vec<String>),
44}
45
46#[derive(Debug, Clone, Serialize, Deserialize)]
47pub struct FileEditModeSnapshot {
48 pub allowed_globs: AllowedGlobsSnapshot,
49}
50
51#[derive(Debug, Clone, Serialize, Deserialize)]
52pub struct WriteIfEmptyModeSnapshot {
53 pub allowed_globs: AllowedGlobsSnapshot,
54}
55
56#[derive(Debug, Clone, Serialize, Deserialize)]
57#[serde(untagged)]
58pub enum AllowedGlobsSnapshot {
59 All(String),
60 List(Vec<String>),
61}
62
63#[derive(Debug, Clone, Serialize, Deserialize)]
64pub struct FileWhitelistDataSnapshot {
65 pub file_hash: String,
66 pub line_ranges_read: Vec<(usize, usize)>,
67 pub total_lines: usize,
68}
69
70pub fn get_state_dir() -> Result<PathBuf> {
71 let home = home::home_dir().unwrap_or_else(|| PathBuf::from("/tmp"));
72 let bash_state_dir = home.join(".local/share/wcgw/bash_state");
73 if !bash_state_dir.exists() {
74 fs::create_dir_all(&bash_state_dir)?;
75 }
76 Ok(bash_state_dir)
77}
78
79fn get_state_file_path(thread_id: &str) -> Result<PathBuf> {
80 Ok(get_state_dir()?.join(format!("{thread_id}_bash_state.json")))
81}
82
83fn atomic_write(path: &Path, content: &[u8]) -> Result<()> {
93 use std::io::Write;
94 let parent =
95 path.parent().filter(|p| !p.as_os_str().is_empty()).unwrap_or_else(|| Path::new("."));
96 let mut tmp = tempfile::Builder::new()
97 .prefix(".winx-state-")
98 .tempfile_in(parent)
99 .with_context(|| format!("create temp state file in {}", parent.display()))?;
100 tmp.write_all(content)?;
101 tmp.persist(path).map_err(|e| e.error)?;
102 Ok(())
103}
104
105fn parse_state_or_quarantine(path: &Path, json: &str) -> Result<BashStateSnapshot> {
112 match serde_json::from_str(json) {
113 Ok(snapshot) => Ok(snapshot),
114 Err(parse_err) => {
115 let quarantine = path.with_extension("json.corrupt");
116 match fs::rename(path, &quarantine) {
117 Ok(()) => error!(
118 path = %path.display(),
119 quarantine = %quarantine.display(),
120 "corrupt bash state quarantined; saved session (incl. its mode) lost: {parse_err}"
121 ),
122 Err(rename_err) => warn!(
123 path = %path.display(),
124 "failed to quarantine corrupt bash state: {rename_err}"
125 ),
126 }
127 Err(parse_err).context("parsing persisted bash state")
128 }
129 }
130}
131
132pub fn save_bash_state(thread_id: &str, state: &BashStateSnapshot) -> Result<()> {
133 if thread_id.is_empty() {
134 return Ok(());
135 }
136 let json = serde_json::to_string_pretty(state)?;
137 atomic_write(&get_state_file_path(thread_id)?, json.as_bytes())?;
138 Ok(())
139}
140
141pub fn save_bash_state_to_path(path: &Path, state: &BashStateSnapshot) -> Result<()> {
142 let json = serde_json::to_string_pretty(state)?;
143 atomic_write(path, json.as_bytes())?;
144 Ok(())
145}
146
147pub fn load_bash_state(thread_id: &str) -> Result<Option<BashStateSnapshot>> {
148 if thread_id.is_empty() {
149 return Ok(None);
150 }
151 let path = get_state_file_path(thread_id)?;
152 if !path.exists() {
153 return Ok(None);
154 }
155 let json = fs::read_to_string(&path)?;
156 Ok(Some(parse_state_or_quarantine(&path, &json)?))
157}
158
159pub fn load_bash_state_from_path(path: &Path) -> Result<Option<BashStateSnapshot>> {
160 if !path.exists() {
161 return Ok(None);
162 }
163 let json = fs::read_to_string(path)?;
164 Ok(Some(parse_state_or_quarantine(path, &json)?))
165}
166
167pub fn delete_bash_state(thread_id: &str) -> Result<()> {
168 if thread_id.is_empty() {
169 return Ok(());
170 }
171 let path = get_state_file_path(thread_id)?;
172 if path.exists() {
173 fs::remove_file(path)?;
174 }
175 Ok(())
176}
177
178impl BashStateSnapshot {
179 pub fn from_state(
180 cwd: &str,
181 workspace_root: &str,
182 mode: &Modes,
183 bash_command_mode: &BashCommandMode,
184 file_edit_mode: &FileEditMode,
185 write_if_empty_mode: &WriteIfEmptyMode,
186 whitelist: &HashMap<String, FileWhitelistData>,
187 thread_id: &str,
188 ) -> Self {
189 Self {
190 bash_command_mode: BashCommandModeSnapshot::from(bash_command_mode),
191 file_edit_mode: FileEditModeSnapshot::from(file_edit_mode),
192 write_if_empty_mode: WriteIfEmptyModeSnapshot::from(write_if_empty_mode),
193 whitelist_for_overwrite: whitelist
194 .iter()
195 .map(|(k, v)| (k.clone(), FileWhitelistDataSnapshot::from(v)))
196 .collect(),
197 mode: mode.to_string(),
198 workspace_root: workspace_root.to_string(),
199 chat_id: thread_id.to_string(),
200 cwd: if cwd == workspace_root { String::new() } else { cwd.to_string() },
201 }
202 }
203
204 pub fn to_state_components(
205 &self,
206 ) -> (
207 String,
208 String,
209 Modes,
210 BashCommandMode,
211 FileEditMode,
212 WriteIfEmptyMode,
213 HashMap<String, FileWhitelistData>,
214 String,
215 ) {
216 let mode = match self.mode.as_str() {
217 "architect" => Modes::Architect,
218 "code_writer" => Modes::CodeWriter,
219 _ => Modes::Wcgw,
220 };
221 let whitelist = self
222 .whitelist_for_overwrite
223 .iter()
224 .map(|(k, v)| (k.clone(), v.to_whitelist_data()))
225 .collect();
226 let cwd = if self.cwd.is_empty() { self.workspace_root.clone() } else { self.cwd.clone() };
227 (
228 cwd,
229 self.workspace_root.clone(),
230 mode,
231 self.bash_command_mode.to_bash_command_mode(),
232 self.file_edit_mode.to_file_edit_mode(),
233 self.write_if_empty_mode.to_write_if_empty_mode(),
234 whitelist,
235 self.chat_id.clone(),
236 )
237 }
238}
239
240impl From<&BashCommandMode> for BashCommandModeSnapshot {
241 fn from(mode: &BashCommandMode) -> Self {
242 Self {
243 bash_mode: match mode.bash_mode {
244 BashMode::RestrictedMode => "restricted_mode".to_string(),
245 BashMode::NormalMode => "normal_mode".to_string(),
246 },
247 allowed_commands: match &mode.allowed_commands {
248 AllowedCommands::All(s) => AllowedCommandsSnapshot::All(s.clone()),
249 AllowedCommands::List(l) => AllowedCommandsSnapshot::List(l.clone()),
250 },
251 }
252 }
253}
254
255impl BashCommandModeSnapshot {
256 fn to_bash_command_mode(&self) -> BashCommandMode {
257 BashCommandMode {
258 bash_mode: if self.bash_mode == "restricted_mode" {
259 BashMode::RestrictedMode
260 } else {
261 BashMode::NormalMode
262 },
263 allowed_commands: match &self.allowed_commands {
264 AllowedCommandsSnapshot::All(s) => AllowedCommands::All(s.clone()),
265 AllowedCommandsSnapshot::List(l) => AllowedCommands::List(l.clone()),
266 },
267 }
268 }
269}
270
271impl From<&FileEditMode> for FileEditModeSnapshot {
272 fn from(mode: &FileEditMode) -> Self {
273 Self {
274 allowed_globs: match &mode.allowed_globs {
275 AllowedGlobs::All(s) => AllowedGlobsSnapshot::All(s.clone()),
276 AllowedGlobs::List(l) => AllowedGlobsSnapshot::List(l.clone()),
277 },
278 }
279 }
280}
281
282impl FileEditModeSnapshot {
283 fn to_file_edit_mode(&self) -> FileEditMode {
284 FileEditMode {
285 allowed_globs: match &self.allowed_globs {
286 AllowedGlobsSnapshot::All(s) => AllowedGlobs::All(s.clone()),
287 AllowedGlobsSnapshot::List(l) => AllowedGlobs::List(l.clone()),
288 },
289 }
290 }
291}
292
293impl From<&WriteIfEmptyMode> for WriteIfEmptyModeSnapshot {
294 fn from(mode: &WriteIfEmptyMode) -> Self {
295 Self {
296 allowed_globs: match &mode.allowed_globs {
297 AllowedGlobs::All(s) => AllowedGlobsSnapshot::All(s.clone()),
298 AllowedGlobs::List(l) => AllowedGlobsSnapshot::List(l.clone()),
299 },
300 }
301 }
302}
303
304impl WriteIfEmptyModeSnapshot {
305 fn to_write_if_empty_mode(&self) -> WriteIfEmptyMode {
306 WriteIfEmptyMode {
307 allowed_globs: match &self.allowed_globs {
308 AllowedGlobsSnapshot::All(s) => AllowedGlobs::All(s.clone()),
309 AllowedGlobsSnapshot::List(l) => AllowedGlobs::List(l.clone()),
310 },
311 }
312 }
313}
314
315impl From<&FileWhitelistData> for FileWhitelistDataSnapshot {
316 fn from(d: &FileWhitelistData) -> Self {
317 Self {
318 file_hash: d.file_hash.clone(),
319 line_ranges_read: d.line_ranges_read.clone(),
320 total_lines: d.total_lines,
321 }
322 }
323}
324
325impl FileWhitelistDataSnapshot {
326 fn to_whitelist_data(&self) -> FileWhitelistData {
327 FileWhitelistData {
328 file_hash: self.file_hash.clone(),
329 line_ranges_read: self.line_ranges_read.clone(),
330 total_lines: self.total_lines,
331 }
332 }
333}
334
335#[cfg(test)]
336mod tests {
337 use super::{atomic_write, load_bash_state_from_path};
338 use anyhow::Result;
339
340 #[test]
341 fn atomic_write_round_trips_and_replaces() -> Result<()> {
342 let dir = tempfile::tempdir()?;
343 let path = dir.path().join("data.bin");
344 atomic_write(&path, b"hello atomic")?;
345 assert_eq!(std::fs::read(&path)?, b"hello atomic");
346 atomic_write(&path, b"second")?;
348 assert_eq!(std::fs::read(&path)?, b"second");
349 Ok(())
350 }
351
352 #[test]
353 fn missing_state_file_is_ok_none() -> Result<()> {
354 let dir = tempfile::tempdir()?;
355 let path = dir.path().join("absent_bash_state.json");
356 assert!(load_bash_state_from_path(&path)?.is_none());
357 Ok(())
358 }
359
360 #[test]
361 fn corrupt_state_is_quarantined_and_surfaces_error() -> Result<()> {
362 let dir = tempfile::tempdir()?;
363 let path = dir.path().join("t1_bash_state.json");
364 std::fs::write(&path, "{ not valid json ")?;
365 assert!(load_bash_state_from_path(&path).is_err());
367 assert!(!path.exists(), "corrupt file should be quarantined");
369 assert!(path.with_extension("json.corrupt").exists(), "quarantine file should exist");
370 Ok(())
371 }
372}