1use anyhow::{Context, Result};
7use serde::{Deserialize, Serialize};
8use std::collections::HashMap;
9use std::fs;
10use std::path::{Path, PathBuf};
11use tracing::{error, 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(|| {
72 warn!(
73 "$HOME is not set; persisting bash session state under /tmp \
74 (ephemeral across reboots, and shared if multiple users hit the same thread_id)"
75 );
76 PathBuf::from("/tmp")
77 });
78 let bash_state_dir = home.join(".local/share/wcgw/bash_state");
79 if !bash_state_dir.exists() {
80 fs::create_dir_all(&bash_state_dir)?;
81 }
82 Ok(bash_state_dir)
83}
84
85fn get_state_file_path(thread_id: &str) -> Result<PathBuf> {
86 Ok(get_state_dir()?.join(format!("{thread_id}_bash_state.json")))
87}
88
89fn atomic_write(path: &Path, content: &[u8]) -> Result<()> {
99 use std::io::Write;
100 let parent =
101 path.parent().filter(|p| !p.as_os_str().is_empty()).unwrap_or_else(|| Path::new("."));
102 let mut tmp = tempfile::Builder::new()
103 .prefix(".winx-state-")
104 .tempfile_in(parent)
105 .with_context(|| format!("create temp state file in {}", parent.display()))?;
106 tmp.write_all(content)?;
107 tmp.persist(path).map_err(|e| e.error)?;
108 Ok(())
109}
110
111fn parse_state_or_quarantine(path: &Path, json: &str) -> Result<BashStateSnapshot> {
118 match serde_json::from_str(json) {
119 Ok(snapshot) => Ok(snapshot),
120 Err(parse_err) => {
121 let quarantine = path.with_extension("json.corrupt");
122 match fs::rename(path, &quarantine) {
123 Ok(()) => error!(
124 path = %path.display(),
125 quarantine = %quarantine.display(),
126 "corrupt bash state quarantined; saved session (incl. its mode) lost: {parse_err}"
127 ),
128 Err(rename_err) => warn!(
129 path = %path.display(),
130 "failed to quarantine corrupt bash state: {rename_err}"
131 ),
132 }
133 Err(parse_err).context("parsing persisted bash state")
134 }
135 }
136}
137
138pub fn save_bash_state(thread_id: &str, state: &BashStateSnapshot) -> Result<()> {
139 if thread_id.is_empty() {
140 return Ok(());
141 }
142 let json = serde_json::to_string_pretty(state)?;
143 atomic_write(&get_state_file_path(thread_id)?, json.as_bytes())?;
144 Ok(())
145}
146
147pub fn save_bash_state_to_path(path: &Path, state: &BashStateSnapshot) -> Result<()> {
148 let json = serde_json::to_string_pretty(state)?;
149 atomic_write(path, json.as_bytes())?;
150 Ok(())
151}
152
153pub fn load_bash_state(thread_id: &str) -> Result<Option<BashStateSnapshot>> {
154 if thread_id.is_empty() {
155 return Ok(None);
156 }
157 let path = get_state_file_path(thread_id)?;
158 if !path.exists() {
159 return Ok(None);
160 }
161 let json = fs::read_to_string(&path)?;
162 Ok(Some(parse_state_or_quarantine(&path, &json)?))
163}
164
165pub fn load_bash_state_from_path(path: &Path) -> Result<Option<BashStateSnapshot>> {
166 if !path.exists() {
167 return Ok(None);
168 }
169 let json = fs::read_to_string(path)?;
170 Ok(Some(parse_state_or_quarantine(path, &json)?))
171}
172
173pub fn delete_bash_state(thread_id: &str) -> Result<()> {
174 if thread_id.is_empty() {
175 return Ok(());
176 }
177 let path = get_state_file_path(thread_id)?;
178 if path.exists() {
179 fs::remove_file(path)?;
180 }
181 Ok(())
182}
183
184impl BashStateSnapshot {
185 #[allow(clippy::too_many_arguments)]
186 pub fn from_state(
187 cwd: &str,
188 workspace_root: &str,
189 mode: &Modes,
190 bash_command_mode: &BashCommandMode,
191 file_edit_mode: &FileEditMode,
192 write_if_empty_mode: &WriteIfEmptyMode,
193 whitelist: &HashMap<String, FileWhitelistData>,
194 thread_id: &str,
195 ) -> Self {
196 Self {
197 bash_command_mode: BashCommandModeSnapshot::from(bash_command_mode),
198 file_edit_mode: FileEditModeSnapshot::from(file_edit_mode),
199 write_if_empty_mode: WriteIfEmptyModeSnapshot::from(write_if_empty_mode),
200 whitelist_for_overwrite: whitelist
201 .iter()
202 .map(|(k, v)| (k.clone(), FileWhitelistDataSnapshot::from(v)))
203 .collect(),
204 mode: mode.to_string(),
205 workspace_root: workspace_root.to_string(),
206 chat_id: thread_id.to_string(),
207 cwd: if cwd == workspace_root { String::new() } else { cwd.to_string() },
208 }
209 }
210
211 pub fn to_state_components(
212 &self,
213 ) -> (
214 String,
215 String,
216 Modes,
217 BashCommandMode,
218 FileEditMode,
219 WriteIfEmptyMode,
220 HashMap<String, FileWhitelistData>,
221 String,
222 ) {
223 let mode = match self.mode.as_str() {
224 "architect" => Modes::Architect,
225 "code_writer" => Modes::CodeWriter,
226 _ => Modes::Wcgw,
227 };
228 let whitelist = self
229 .whitelist_for_overwrite
230 .iter()
231 .map(|(k, v)| (k.clone(), v.to_whitelist_data()))
232 .collect();
233 let cwd = if self.cwd.is_empty() { self.workspace_root.clone() } else { self.cwd.clone() };
234 (
235 cwd,
236 self.workspace_root.clone(),
237 mode,
238 self.bash_command_mode.to_bash_command_mode(),
239 self.file_edit_mode.to_file_edit_mode(),
240 self.write_if_empty_mode.to_write_if_empty_mode(),
241 whitelist,
242 self.chat_id.clone(),
243 )
244 }
245}
246
247impl From<&BashCommandMode> for BashCommandModeSnapshot {
248 fn from(mode: &BashCommandMode) -> Self {
249 Self {
250 bash_mode: match mode.bash_mode {
251 BashMode::RestrictedMode => "restricted_mode".to_string(),
252 BashMode::NormalMode => "normal_mode".to_string(),
253 },
254 allowed_commands: match &mode.allowed_commands {
255 AllowedCommands::All(s) => AllowedCommandsSnapshot::All(s.clone()),
256 AllowedCommands::List(l) => AllowedCommandsSnapshot::List(l.clone()),
257 },
258 }
259 }
260}
261
262impl BashCommandModeSnapshot {
263 fn to_bash_command_mode(&self) -> BashCommandMode {
264 BashCommandMode {
265 bash_mode: if self.bash_mode == "restricted_mode" {
266 BashMode::RestrictedMode
267 } else {
268 BashMode::NormalMode
269 },
270 allowed_commands: match &self.allowed_commands {
271 AllowedCommandsSnapshot::All(s) => AllowedCommands::All(s.clone()),
272 AllowedCommandsSnapshot::List(l) => AllowedCommands::List(l.clone()),
273 },
274 }
275 }
276}
277
278impl From<&FileEditMode> for FileEditModeSnapshot {
279 fn from(mode: &FileEditMode) -> Self {
280 Self {
281 allowed_globs: match &mode.allowed_globs {
282 AllowedGlobs::All(s) => AllowedGlobsSnapshot::All(s.clone()),
283 AllowedGlobs::List(l) => AllowedGlobsSnapshot::List(l.clone()),
284 },
285 }
286 }
287}
288
289impl FileEditModeSnapshot {
290 fn to_file_edit_mode(&self) -> FileEditMode {
291 FileEditMode {
292 allowed_globs: match &self.allowed_globs {
293 AllowedGlobsSnapshot::All(s) => AllowedGlobs::All(s.clone()),
294 AllowedGlobsSnapshot::List(l) => AllowedGlobs::List(l.clone()),
295 },
296 }
297 }
298}
299
300impl From<&WriteIfEmptyMode> for WriteIfEmptyModeSnapshot {
301 fn from(mode: &WriteIfEmptyMode) -> Self {
302 Self {
303 allowed_globs: match &mode.allowed_globs {
304 AllowedGlobs::All(s) => AllowedGlobsSnapshot::All(s.clone()),
305 AllowedGlobs::List(l) => AllowedGlobsSnapshot::List(l.clone()),
306 },
307 }
308 }
309}
310
311impl WriteIfEmptyModeSnapshot {
312 fn to_write_if_empty_mode(&self) -> WriteIfEmptyMode {
313 WriteIfEmptyMode {
314 allowed_globs: match &self.allowed_globs {
315 AllowedGlobsSnapshot::All(s) => AllowedGlobs::All(s.clone()),
316 AllowedGlobsSnapshot::List(l) => AllowedGlobs::List(l.clone()),
317 },
318 }
319 }
320}
321
322impl From<&FileWhitelistData> for FileWhitelistDataSnapshot {
323 fn from(d: &FileWhitelistData) -> Self {
324 Self {
325 file_hash: d.file_hash.clone(),
326 line_ranges_read: d.line_ranges_read.clone(),
327 total_lines: d.total_lines,
328 }
329 }
330}
331
332impl FileWhitelistDataSnapshot {
333 fn to_whitelist_data(&self) -> FileWhitelistData {
334 FileWhitelistData {
335 file_hash: self.file_hash.clone(),
336 line_ranges_read: self.line_ranges_read.clone(),
337 total_lines: self.total_lines,
338 }
339 }
340}
341
342#[cfg(test)]
343mod tests {
344 use super::{atomic_write, load_bash_state_from_path};
345 use anyhow::Result;
346
347 #[test]
348 fn atomic_write_round_trips_and_replaces() -> Result<()> {
349 let dir = tempfile::tempdir()?;
350 let path = dir.path().join("data.bin");
351 atomic_write(&path, b"hello atomic")?;
352 assert_eq!(std::fs::read(&path)?, b"hello atomic");
353 atomic_write(&path, b"second")?;
355 assert_eq!(std::fs::read(&path)?, b"second");
356 Ok(())
357 }
358
359 #[test]
360 fn missing_state_file_is_ok_none() -> Result<()> {
361 let dir = tempfile::tempdir()?;
362 let path = dir.path().join("absent_bash_state.json");
363 assert!(load_bash_state_from_path(&path)?.is_none());
364 Ok(())
365 }
366
367 #[test]
368 fn corrupt_state_is_quarantined_and_surfaces_error() -> Result<()> {
369 let dir = tempfile::tempdir()?;
370 let path = dir.path().join("t1_bash_state.json");
371 std::fs::write(&path, "{ not valid json ")?;
372 assert!(load_bash_state_from_path(&path).is_err());
374 assert!(!path.exists(), "corrupt file should be quarantined");
376 assert!(path.with_extension("json.corrupt").exists(), "quarantine file should exist");
377 Ok(())
378 }
379}