1#![allow(clippy::unwrap_used)]
2use anyhow::Result;
3use rand::RngExt;
4use serde::{Deserialize, Serialize};
5use std::collections::{HashMap, VecDeque};
6use std::path::{Path, PathBuf};
7use std::sync::Arc;
8use tokio::sync::Mutex;
9use tracing::info;
10
11use crate::state::persistence::{
12 load_bash_state as load_state_file, save_bash_state as save_state_file, BashStateSnapshot,
13};
14use crate::state::pty::PtyShell;
15use crate::types::{
16 AllowedCommands, AllowedGlobs, BashCommandMode, BashMode, FileEditMode, Modes, WriteIfEmptyMode,
17};
18
19#[derive(Debug, Clone, Serialize, Deserialize)]
20pub struct FileWhitelistData {
21 pub file_hash: String,
22 pub line_ranges_read: Vec<(usize, usize)>,
23 pub total_lines: usize,
24}
25
26fn clamped_sorted(ranges: &[(usize, usize)], total_lines: usize) -> Vec<(usize, usize)> {
29 let mut v: Vec<(usize, usize)> = ranges
30 .iter()
31 .map(|&(s, e)| (s.max(1), e.min(total_lines)))
32 .filter(|&(s, e)| s <= e)
33 .collect();
34 v.sort_unstable();
35 v
36}
37
38fn covered_line_count(ranges: &[(usize, usize)], total_lines: usize) -> usize {
41 let mut covered = 0usize;
42 let mut last_end = 0usize; for (s, e) in clamped_sorted(ranges, total_lines) {
44 let s = s.max(last_end + 1);
45 if s <= e {
46 covered += e - s + 1;
47 last_end = e;
48 }
49 }
50 covered
51}
52
53impl FileWhitelistData {
54 pub fn new(
55 file_hash: String,
56 line_ranges_read: Vec<(usize, usize)>,
57 total_lines: usize,
58 ) -> Self {
59 let mut data = Self { file_hash, line_ranges_read: Vec::new(), total_lines };
60 data.merge_ranges(line_ranges_read);
61 data
62 }
63
64 pub fn is_read_enough(&self) -> bool {
65 self.get_percentage_read() >= 99.0
66 }
67
68 #[allow(clippy::cast_precision_loss)]
69 pub fn get_percentage_read(&self) -> f64 {
70 if self.total_lines == 0 {
71 return 100.0;
72 }
73 let covered = covered_line_count(&self.line_ranges_read, self.total_lines);
77 (covered as f64 / self.total_lines as f64) * 100.0
78 }
79
80 pub fn get_unread_ranges(&self) -> Vec<(usize, usize)> {
81 if self.total_lines == 0 {
82 return vec![];
83 }
84 let sorted = clamped_sorted(&self.line_ranges_read, self.total_lines);
85 let mut unread = vec![];
86 let mut next = 1usize; for (s, e) in sorted {
88 if s > next {
89 unread.push((next, s - 1));
90 }
91 next = next.max(e.saturating_add(1));
92 }
93 if next <= self.total_lines {
94 unread.push((next, self.total_lines));
95 }
96 unread
97 }
98
99 pub fn add_range(&mut self, start: usize, end: usize) {
104 self.merge_ranges(std::iter::once((start, end)));
105 }
106
107 pub fn merge_ranges(&mut self, new: impl IntoIterator<Item = (usize, usize)>) {
110 self.line_ranges_read.extend(new);
111 self.line_ranges_read.retain(|(s, e)| s <= e);
112 self.line_ranges_read.sort_unstable();
113 let mut merged: Vec<(usize, usize)> = Vec::with_capacity(self.line_ranges_read.len());
114 for (s, e) in self.line_ranges_read.drain(..) {
115 match merged.last_mut() {
116 Some(last) if s <= last.1.saturating_add(1) => last.1 = last.1.max(e),
117 _ => merged.push((s, e)),
118 }
119 }
120 self.line_ranges_read = merged;
121 }
122
123 pub fn needs_more_reading(&self) -> bool {
124 !self.is_read_enough()
125 }
126}
127
128const EDIT_CHECKPOINT_CAP: usize = 10;
131
132const EDIT_CHECKPOINT_MAX_CONTENT_BYTES: usize = 1_000_000;
137
138const MAX_WHITELIST_FILES: usize = 1_024;
143
144#[derive(Debug, Clone)]
148pub struct EditCheckpoint {
149 pub file_path_str: String,
151 pub path: PathBuf,
152 pub prior_content: String,
154 pub prior_whitelist: Option<FileWhitelistData>,
157}
158
159#[derive(Debug, Clone)]
160pub struct BashState {
161 pub cwd: PathBuf,
162 pub workspace_root: PathBuf,
163 pub current_thread_id: String,
164 pub mode: Modes,
165 pub bash_command_mode: BashCommandMode,
166 pub file_edit_mode: FileEditMode,
167 pub write_if_empty_mode: WriteIfEmptyMode,
168 pub whitelist_for_overwrite: HashMap<String, FileWhitelistData>,
169 whitelist_recency: VecDeque<String>,
173 pub pty_shell: Arc<Mutex<Option<PtyShell>>>,
174 pub foreground_command_gate: Arc<Mutex<()>>,
177 pub initialized: bool,
178 pub edit_checkpoints: VecDeque<EditCheckpoint>,
182 pub derived_code_map_usage: crate::utils::agent_temp::DerivedCodeMapUsage,
185}
186
187impl Default for BashState {
188 fn default() -> Self {
189 Self::new()
190 }
191}
192
193impl BashState {
194 pub fn new() -> Self {
195 let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("/tmp"));
196 Self {
197 cwd: cwd.clone(),
198 workspace_root: cwd,
199 current_thread_id: generate_thread_id(),
200 mode: Modes::Wcgw,
201 bash_command_mode: BashCommandMode {
202 bash_mode: BashMode::NormalMode,
203 allowed_commands: AllowedCommands::All("all".to_string()),
204 },
205 file_edit_mode: FileEditMode { allowed_globs: AllowedGlobs::All("all".to_string()) },
206 write_if_empty_mode: WriteIfEmptyMode {
207 allowed_globs: AllowedGlobs::All("all".to_string()),
208 },
209 whitelist_for_overwrite: HashMap::new(),
210 whitelist_recency: VecDeque::new(),
211 pty_shell: Arc::new(Mutex::new(None)),
212 foreground_command_gate: Arc::new(Mutex::new(())),
213 initialized: false,
214 edit_checkpoints: VecDeque::new(),
215 derived_code_map_usage: crate::utils::agent_temp::DerivedCodeMapUsage::default(),
216 }
217 }
218
219 pub fn push_edit_checkpoint(&mut self, checkpoint: EditCheckpoint) {
222 if checkpoint.prior_content.len() > EDIT_CHECKPOINT_MAX_CONTENT_BYTES {
223 info!(
224 file = %checkpoint.file_path_str,
225 "UndoEdit: not checkpointing a file over 1 MB (too large to hold in memory)"
226 );
227 return;
228 }
229 self.edit_checkpoints.push_back(checkpoint);
230 while self.edit_checkpoints.len() > EDIT_CHECKPOINT_CAP {
231 self.edit_checkpoints.pop_front();
232 }
233 }
234
235 pub fn pop_edit_checkpoint_for(&mut self, file_path_str: &str) -> Option<EditCheckpoint> {
239 let index =
240 self.edit_checkpoints.iter().rposition(|cp| cp.file_path_str == file_path_str)?;
241 self.edit_checkpoints.remove(index)
242 }
243
244 pub fn record_read_coverage(
249 &mut self,
250 path: &str,
251 ranges: impl IntoIterator<Item = (usize, usize)>,
252 file_hash: String,
253 total_lines: usize,
254 ) {
255 let ranges = ranges.into_iter().collect::<Vec<_>>();
256 match self.whitelist_for_overwrite.get_mut(path) {
257 Some(existing)
258 if existing.file_hash == file_hash && existing.total_lines == total_lines =>
259 {
260 existing.merge_ranges(ranges);
261 }
262 Some(existing) => {
263 *existing = FileWhitelistData::new(file_hash, ranges, total_lines);
264 }
265 None => {
266 self.whitelist_for_overwrite.insert(
267 path.to_string(),
268 FileWhitelistData::new(file_hash, ranges, total_lines),
269 );
270 }
271 }
272 self.touch_whitelist(path);
273 self.enforce_whitelist_cap();
274 }
275
276 pub fn set_whitelist_entry(&mut self, path: &str, entry: FileWhitelistData) {
278 self.whitelist_for_overwrite.insert(path.to_string(), entry);
279 self.touch_whitelist(path);
280 self.enforce_whitelist_cap();
281 }
282
283 pub fn remove_whitelist_entry(&mut self, path: &str) -> Option<FileWhitelistData> {
285 self.whitelist_recency.retain(|candidate| candidate != path);
286 self.whitelist_for_overwrite.remove(path)
287 }
288
289 fn touch_whitelist(&mut self, path: &str) {
290 self.whitelist_recency.retain(|candidate| candidate != path);
291 self.whitelist_recency.push_back(path.to_string());
292 }
293
294 fn rebuild_whitelist_recency(&mut self) {
295 let mut paths = self.whitelist_for_overwrite.keys().cloned().collect::<Vec<_>>();
296 paths.sort_unstable();
297 self.whitelist_recency = paths.into();
298 self.enforce_whitelist_cap();
299 }
300
301 fn enforce_whitelist_cap(&mut self) {
302 self.whitelist_recency.retain(|path| self.whitelist_for_overwrite.contains_key(path));
303 while self.whitelist_for_overwrite.len() > MAX_WHITELIST_FILES {
304 let victim = self.whitelist_recency.pop_front().or_else(|| {
305 self.whitelist_for_overwrite.keys().min().cloned()
308 });
309 let Some(victim) = victim else { break };
310 self.whitelist_for_overwrite.remove(&victim);
311 }
312 }
313
314 pub async fn init_pty_shell(&mut self) -> Result<()> {
315 let cwd = self.cwd.clone();
316 let workspace_root = self.workspace_root.clone();
317 let temporary_artifact_dir =
318 crate::utils::agent_temp::session_info(&workspace_root, &self.current_thread_id)
319 .directory;
320 let restricted = self.bash_command_mode.bash_mode == BashMode::RestrictedMode;
321 let shell = tokio::task::spawn_blocking(move || {
325 PtyShell::new_with_agent_paths(
326 &cwd,
327 restricted,
328 Some(&workspace_root),
329 Some(&temporary_artifact_dir),
330 )
331 })
332 .await
333 .map_err(|e| {
334 crate::errors::WinxError::ShellInitializationError(format!("PTY init task failed: {e}"))
335 })??;
336 *self.pty_shell.lock().await = Some(shell);
337 Ok(())
338 }
339
340 pub fn update_cwd(&mut self, path: &Path) -> Result<()> {
341 self.cwd = path.to_path_buf();
342 Ok(())
343 }
344
345 pub fn update_workspace_root(&mut self, path: &Path) -> Result<()> {
346 self.workspace_root = path.to_path_buf();
347 Ok(())
348 }
349
350 pub fn is_command_allowed(&self, command: &str) -> bool {
351 if self.mode == Modes::Architect {
352 crate::utils::bash_parser::is_architect_command_allowed(command)
353 } else {
354 self.bash_command_mode.allowed_commands.is_allowed(command)
355 }
356 }
357
358 pub fn is_file_edit_allowed(&self, path: &str) -> bool {
359 self.file_edit_mode.allowed_globs.is_allowed(path)
360 }
361
362 pub fn is_file_write_allowed(&self, path: &str) -> bool {
363 self.write_if_empty_mode.allowed_globs.is_allowed(path)
364 }
365 pub fn save_state_to_disk(&self) -> Result<()> {
366 let snapshot = self.snapshot();
367 save_state_file(&self.current_thread_id, &snapshot)?;
368 Ok(())
369 }
370
371 pub fn snapshot(&self) -> BashStateSnapshot {
372 BashStateSnapshot::from_state(
373 &self.cwd.to_string_lossy(),
374 &self.workspace_root.to_string_lossy(),
375 &self.mode,
376 &self.bash_command_mode,
377 &self.file_edit_mode,
378 &self.write_if_empty_mode,
379 &self.whitelist_for_overwrite,
380 &self.current_thread_id,
381 )
382 }
383
384 pub fn apply_snapshot(&mut self, snapshot: &BashStateSnapshot) {
385 let (cwd, root, mode, bmode, emode, wmode, whitelist, tid) = snapshot.to_state_components();
386
387 self.cwd = PathBuf::from(cwd);
388 self.workspace_root = PathBuf::from(root);
389 self.mode = mode;
390 self.bash_command_mode = bmode;
391 self.file_edit_mode = emode;
392 self.write_if_empty_mode = wmode;
393 self.whitelist_for_overwrite = whitelist;
394 self.rebuild_whitelist_recency();
395 self.current_thread_id = tid;
396 self.initialized = true;
397 }
398
399 pub fn load_state_from_disk(&mut self, thread_id: &str) -> Result<bool> {
400 if let Some(snapshot) = load_state_file(thread_id)? {
401 self.apply_snapshot(&snapshot);
402 Ok(true)
403 } else {
404 Ok(false)
405 }
406 }
407
408 pub fn new_with_thread_id(thread_id: Option<&str>) -> Self {
409 let mut state = Self::new();
410
411 if let Some(tid) = thread_id {
412 if !tid.is_empty() {
413 if let Ok(true) = state.load_state_from_disk(tid) {
414 info!("Loaded state for thread_id '{}'", tid);
415 } else {
416 state.current_thread_id = tid.to_string();
417 }
418 }
419 }
420
421 state
422 }
423}
424
425pub fn generate_thread_id() -> String {
426 let mut rng = rand::rng();
427 format!("tid_{:x}", rng.random::<u64>())
428}
429
430#[cfg(test)]
431mod whitelist_range_tests {
432 use super::{BashState, FileWhitelistData, MAX_WHITELIST_FILES};
433
434 fn wl(ranges: &[(usize, usize)], total: usize) -> FileWhitelistData {
435 FileWhitelistData::new("h".to_string(), ranges.to_vec(), total)
436 }
437
438 #[test]
439 fn merge_collapses_overlap_and_adjacency() {
440 let w = wl(&[(4, 5), (1, 3), (8, 12), (7, 9)], 20);
442 assert_eq!(w.line_ranges_read, vec![(1, 5), (7, 12)]);
443 }
444
445 #[test]
446 fn re_reading_does_not_grow_unbounded() {
447 let mut w = wl(&[(1, 10)], 100);
448 for _ in 0..1000 {
449 w.merge_ranges(std::iter::once((1, 10)));
450 w.merge_ranges(std::iter::once((5, 15)));
451 }
452 assert_eq!(w.line_ranges_read, vec![(1, 15)]);
454 }
455
456 #[test]
457 fn percentage_counts_distinct_lines_with_overlap() {
458 let w = wl(&[(1, 5), (3, 8)], 10);
460 assert!((w.get_percentage_read() - 80.0).abs() < 1e-9);
461 assert!(wl(&[(1, 10)], 10).is_read_enough());
462 }
463
464 #[test]
465 fn unread_ranges_are_the_gaps() {
466 let w = wl(&[(2, 4), (7, 8)], 10);
468 assert_eq!(w.get_unread_ranges(), vec![(1, 1), (5, 6), (9, 10)]);
469 }
470
471 #[test]
472 fn out_of_range_entries_are_clamped() {
473 let w = wl(&[(0, 999)], 10);
475 assert!((w.get_percentage_read() - 100.0).abs() < 1e-9);
476 assert!(w.get_unread_ranges().is_empty());
477 }
478
479 #[test]
480 fn coverage_from_different_file_versions_is_not_combined() {
481 let mut state = BashState::new();
482 state.record_read_coverage("/workspace/file.rs", [(1, 50)], "old-hash".to_string(), 100);
483 state.record_read_coverage("/workspace/file.rs", [(51, 100)], "new-hash".to_string(), 100);
484
485 let coverage = &state.whitelist_for_overwrite["/workspace/file.rs"];
486 assert_eq!(coverage.file_hash, "new-hash");
487 assert_eq!(coverage.line_ranges_read, vec![(51, 100)]);
488 assert_eq!(coverage.get_unread_ranges(), vec![(1, 50)]);
489 }
490
491 #[test]
492 fn whitelist_is_lru_bounded_and_recent_entries_survive() {
493 let mut state = BashState::new();
494 for index in 0..MAX_WHITELIST_FILES {
495 let path = format!("/workspace/file-{index:04}.rs");
496 state.record_read_coverage(&path, [(1, 1)], format!("hash-{index}"), 1);
497 }
498
499 state.record_read_coverage("/workspace/file-0000.rs", [(1, 1)], "hash-0".to_string(), 1);
502 state.record_read_coverage("/workspace/newest.rs", [(1, 1)], "newest-hash".to_string(), 1);
503
504 assert_eq!(state.whitelist_for_overwrite.len(), MAX_WHITELIST_FILES);
505 assert!(state.whitelist_for_overwrite.contains_key("/workspace/file-0000.rs"));
506 assert!(!state.whitelist_for_overwrite.contains_key("/workspace/file-0001.rs"));
507 assert!(state.whitelist_for_overwrite.contains_key("/workspace/newest.rs"));
508 }
509}