1#![allow(clippy::unwrap_used)]
2use anyhow::Result;
3use rand::RngExt;
4use serde::{Deserialize, Serialize};
5use sha2::{Digest, Sha256};
6use std::collections::{HashMap, VecDeque};
7use std::path::{Path, PathBuf};
8use std::sync::Arc;
9use tokio::sync::Mutex;
10use tracing::info;
11
12use crate::state::persistence::{
13 delete_bash_state as delete_state_file, load_bash_state as load_state_file,
14 save_bash_state as save_state_file, BashStateSnapshot,
15};
16use crate::state::pty::PtyShell;
17use crate::types::{
18 AllowedCommands, AllowedGlobs, BashCommandMode, BashMode, FileEditMode, Modes, WriteIfEmptyMode,
19};
20
21#[derive(Debug, Clone, Serialize, Deserialize)]
22pub struct FileWhitelistData {
23 pub file_hash: String,
24 pub line_ranges_read: Vec<(usize, usize)>,
25 pub total_lines: usize,
26}
27
28fn clamped_sorted(ranges: &[(usize, usize)], total_lines: usize) -> Vec<(usize, usize)> {
31 let mut v: Vec<(usize, usize)> = ranges
32 .iter()
33 .map(|&(s, e)| (s.max(1), e.min(total_lines)))
34 .filter(|&(s, e)| s <= e)
35 .collect();
36 v.sort_unstable();
37 v
38}
39
40fn covered_line_count(ranges: &[(usize, usize)], total_lines: usize) -> usize {
43 let mut covered = 0usize;
44 let mut last_end = 0usize; for (s, e) in clamped_sorted(ranges, total_lines) {
46 let s = s.max(last_end + 1);
47 if s <= e {
48 covered += e - s + 1;
49 last_end = e;
50 }
51 }
52 covered
53}
54
55impl FileWhitelistData {
56 pub fn new(
57 file_hash: String,
58 line_ranges_read: Vec<(usize, usize)>,
59 total_lines: usize,
60 ) -> Self {
61 let mut data = Self { file_hash, line_ranges_read: Vec::new(), total_lines };
62 data.merge_ranges(line_ranges_read);
63 data
64 }
65
66 pub fn is_read_enough(&self) -> bool {
67 self.get_percentage_read() >= 99.0
68 }
69
70 pub fn get_percentage_read(&self) -> f64 {
71 if self.total_lines == 0 {
72 return 100.0;
73 }
74 let covered = covered_line_count(&self.line_ranges_read, self.total_lines);
78 (covered as f64 / self.total_lines as f64) * 100.0
79 }
80
81 pub fn get_unread_ranges(&self) -> Vec<(usize, usize)> {
82 if self.total_lines == 0 {
83 return vec![];
84 }
85 let sorted = clamped_sorted(&self.line_ranges_read, self.total_lines);
86 let mut unread = vec![];
87 let mut next = 1usize; for (s, e) in sorted {
89 if s > next {
90 unread.push((next, s - 1));
91 }
92 next = next.max(e.saturating_add(1));
93 }
94 if next <= self.total_lines {
95 unread.push((next, self.total_lines));
96 }
97 unread
98 }
99
100 pub fn add_range(&mut self, start: usize, end: usize) {
105 self.merge_ranges(std::iter::once((start, end)));
106 }
107
108 pub fn merge_ranges(&mut self, new: impl IntoIterator<Item = (usize, usize)>) {
111 self.line_ranges_read.extend(new);
112 self.line_ranges_read.retain(|(s, e)| s <= e);
113 self.line_ranges_read.sort_unstable();
114 let mut merged: Vec<(usize, usize)> = Vec::with_capacity(self.line_ranges_read.len());
115 for (s, e) in self.line_ranges_read.drain(..) {
116 match merged.last_mut() {
117 Some(last) if s <= last.1.saturating_add(1) => last.1 = last.1.max(e),
118 _ => merged.push((s, e)),
119 }
120 }
121 self.line_ranges_read = merged;
122 }
123
124 pub fn needs_more_reading(&self) -> bool {
125 !self.is_read_enough()
126 }
127}
128
129const EDIT_CHECKPOINT_CAP: usize = 10;
132
133const EDIT_CHECKPOINT_MAX_CONTENT_BYTES: usize = 1_000_000;
138
139#[derive(Debug, Clone)]
143pub struct EditCheckpoint {
144 pub file_path_str: String,
146 pub path: PathBuf,
147 pub prior_content: String,
149 pub prior_whitelist: Option<FileWhitelistData>,
152}
153
154#[derive(Debug, Clone)]
155pub struct BashState {
156 pub cwd: PathBuf,
157 pub workspace_root: PathBuf,
158 pub current_thread_id: String,
159 pub mode: Modes,
160 pub bash_command_mode: BashCommandMode,
161 pub file_edit_mode: FileEditMode,
162 pub write_if_empty_mode: WriteIfEmptyMode,
163 pub whitelist_for_overwrite: HashMap<String, FileWhitelistData>,
164 pub pty_shell: Arc<Mutex<Option<PtyShell>>>,
165 pub foreground_command_gate: Arc<Mutex<()>>,
168 pub initialized: bool,
169 pub edit_checkpoints: VecDeque<EditCheckpoint>,
173}
174
175impl Default for BashState {
176 fn default() -> Self {
177 Self::new()
178 }
179}
180
181impl BashState {
182 pub fn new() -> Self {
183 let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("/tmp"));
184 Self {
185 cwd: cwd.clone(),
186 workspace_root: cwd,
187 current_thread_id: generate_thread_id(),
188 mode: Modes::Wcgw,
189 bash_command_mode: BashCommandMode {
190 bash_mode: BashMode::NormalMode,
191 allowed_commands: AllowedCommands::All("all".to_string()),
192 },
193 file_edit_mode: FileEditMode { allowed_globs: AllowedGlobs::All("all".to_string()) },
194 write_if_empty_mode: WriteIfEmptyMode {
195 allowed_globs: AllowedGlobs::All("all".to_string()),
196 },
197 whitelist_for_overwrite: HashMap::new(),
198 pty_shell: Arc::new(Mutex::new(None)),
199 foreground_command_gate: Arc::new(Mutex::new(())),
200 initialized: false,
201 edit_checkpoints: VecDeque::new(),
202 }
203 }
204
205 pub fn push_edit_checkpoint(&mut self, checkpoint: EditCheckpoint) {
208 if checkpoint.prior_content.len() > EDIT_CHECKPOINT_MAX_CONTENT_BYTES {
209 info!(
210 file = %checkpoint.file_path_str,
211 "UndoEdit: not checkpointing a file over 1 MB (too large to hold in memory)"
212 );
213 return;
214 }
215 self.edit_checkpoints.push_back(checkpoint);
216 while self.edit_checkpoints.len() > EDIT_CHECKPOINT_CAP {
217 self.edit_checkpoints.pop_front();
218 }
219 }
220
221 pub fn pop_edit_checkpoint_for(&mut self, file_path_str: &str) -> Option<EditCheckpoint> {
225 let index =
226 self.edit_checkpoints.iter().rposition(|cp| cp.file_path_str == file_path_str)?;
227 self.edit_checkpoints.remove(index)
228 }
229
230 pub async fn init_pty_shell(&mut self) -> Result<()> {
231 let cwd = self.cwd.clone();
232 let restricted = self.bash_command_mode.bash_mode == BashMode::RestrictedMode;
233 let shell = tokio::task::spawn_blocking(move || PtyShell::new(&cwd, restricted))
237 .await
238 .map_err(|e| {
239 crate::errors::WinxError::ShellInitializationError(format!("PTY init task failed: {e}"))
240 })??;
241 *self.pty_shell.lock().await = Some(shell);
242 Ok(())
243 }
244
245 pub fn update_cwd(&mut self, path: &Path) -> Result<()> {
246 self.cwd = path.to_path_buf();
247 Ok(())
248 }
249
250 pub fn update_workspace_root(&mut self, path: &Path) -> Result<()> {
251 self.workspace_root = path.to_path_buf();
252 Ok(())
253 }
254
255 pub fn is_command_allowed(&self, command: &str) -> bool {
256 if self.mode == Modes::Architect {
257 crate::utils::bash_parser::is_architect_command_allowed(command)
258 } else {
259 self.bash_command_mode.allowed_commands.is_allowed(command)
260 }
261 }
262
263 pub fn is_file_edit_allowed(&self, path: &str) -> bool {
264 self.file_edit_mode.allowed_globs.is_allowed(path)
265 }
266
267 pub fn is_file_write_allowed(&self, path: &str) -> bool {
268 self.write_if_empty_mode.allowed_globs.is_allowed(path)
269 }
270 pub fn save_state_to_disk(&self) -> Result<()> {
271 let snapshot = self.snapshot();
272 save_state_file(&self.current_thread_id, &snapshot)?;
273 Ok(())
274 }
275
276 pub fn snapshot(&self) -> BashStateSnapshot {
277 BashStateSnapshot::from_state(
278 &self.cwd.to_string_lossy(),
279 &self.workspace_root.to_string_lossy(),
280 &self.mode,
281 &self.bash_command_mode,
282 &self.file_edit_mode,
283 &self.write_if_empty_mode,
284 &self.whitelist_for_overwrite,
285 &self.current_thread_id,
286 )
287 }
288
289 pub fn apply_snapshot(&mut self, snapshot: &BashStateSnapshot) {
290 let (cwd, root, mode, bmode, emode, wmode, whitelist, tid) = snapshot.to_state_components();
291
292 self.cwd = PathBuf::from(cwd);
293 self.workspace_root = PathBuf::from(root);
294 self.mode = mode;
295 self.bash_command_mode = bmode;
296 self.file_edit_mode = emode;
297 self.write_if_empty_mode = wmode;
298 self.whitelist_for_overwrite = whitelist;
299 self.current_thread_id = tid;
300 self.initialized = true;
301 }
302
303 pub fn load_state_from_disk(&mut self, thread_id: &str) -> Result<bool> {
304 if let Some(snapshot) = load_state_file(thread_id)? {
305 self.apply_snapshot(&snapshot);
306 Ok(true)
307 } else {
308 Ok(false)
309 }
310 }
311
312 pub fn new_with_thread_id(thread_id: Option<&str>) -> Self {
313 let mut state = Self::new();
314
315 if let Some(tid) = thread_id {
316 if !tid.is_empty() {
317 if let Ok(true) = state.load_state_from_disk(tid) {
318 info!("Loaded state for thread_id '{}'", tid);
319 } else {
320 state.current_thread_id = tid.to_string();
321 }
322 }
323 }
324
325 state
326 }
327}
328
329pub fn generate_thread_id() -> String {
330 let mut rng = rand::rng();
331 format!("tid_{:x}", rng.random::<u64>())
332}
333
334#[cfg(test)]
335mod whitelist_range_tests {
336 use super::FileWhitelistData;
337
338 fn wl(ranges: &[(usize, usize)], total: usize) -> FileWhitelistData {
339 FileWhitelistData::new("h".to_string(), ranges.to_vec(), total)
340 }
341
342 #[test]
343 fn merge_collapses_overlap_and_adjacency() {
344 let w = wl(&[(4, 5), (1, 3), (8, 12), (7, 9)], 20);
346 assert_eq!(w.line_ranges_read, vec![(1, 5), (7, 12)]);
347 }
348
349 #[test]
350 fn re_reading_does_not_grow_unbounded() {
351 let mut w = wl(&[(1, 10)], 100);
352 for _ in 0..1000 {
353 w.merge_ranges(std::iter::once((1, 10)));
354 w.merge_ranges(std::iter::once((5, 15)));
355 }
356 assert_eq!(w.line_ranges_read, vec![(1, 15)]);
358 }
359
360 #[test]
361 fn percentage_counts_distinct_lines_with_overlap() {
362 let w = wl(&[(1, 5), (3, 8)], 10);
364 assert!((w.get_percentage_read() - 80.0).abs() < 1e-9);
365 assert!(wl(&[(1, 10)], 10).is_read_enough());
366 }
367
368 #[test]
369 fn unread_ranges_are_the_gaps() {
370 let w = wl(&[(2, 4), (7, 8)], 10);
372 assert_eq!(w.get_unread_ranges(), vec![(1, 1), (5, 6), (9, 10)]);
373 }
374
375 #[test]
376 fn out_of_range_entries_are_clamped() {
377 let w = wl(&[(0, 999)], 10);
379 assert!((w.get_percentage_read() - 100.0).abs() < 1e-9);
380 assert!(w.get_unread_ranges().is_empty());
381 }
382}