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
28impl FileWhitelistData {
29 pub fn new(
30 file_hash: String,
31 line_ranges_read: Vec<(usize, usize)>,
32 total_lines: usize,
33 ) -> Self {
34 Self { file_hash, line_ranges_read, total_lines }
35 }
36
37 pub fn is_read_enough(&self) -> bool {
38 self.get_percentage_read() >= 99.0
39 }
40
41 pub fn get_percentage_read(&self) -> f64 {
42 if self.total_lines == 0 {
43 return 100.0;
44 }
45 let mut lines_read = std::collections::HashSet::new();
46 for (start, end) in &self.line_ranges_read {
47 for line in *start..=*end {
48 lines_read.insert(line);
49 }
50 }
51 (lines_read.len() as f64 / self.total_lines as f64) * 100.0
52 }
53
54 pub fn get_unread_ranges(&self) -> Vec<(usize, usize)> {
55 if self.total_lines == 0 {
56 return vec![];
57 }
58 let mut lines_read = std::collections::HashSet::new();
59 for (start, end) in &self.line_ranges_read {
60 for line in *start..=*end {
61 lines_read.insert(line);
62 }
63 }
64 let mut unread = vec![];
65 let mut start_range = None;
66 for i in 1..=self.total_lines {
67 if !lines_read.contains(&i) {
68 if start_range.is_none() {
69 start_range = Some(i);
70 }
71 } else if let Some(start) = start_range {
72 unread.push((start, i - 1));
73 start_range = None;
74 }
75 }
76 if let Some(start) = start_range {
77 unread.push((start, self.total_lines));
78 }
79 unread
80 }
81
82 pub fn add_range(&mut self, start: usize, end: usize) {
83 self.line_ranges_read.push((start, end));
84 }
85
86 pub fn get_read_error_message(&self, file_path: &Path) -> String {
87 format!(
88 "File {} needs more reading. Coverage: {:.1}%",
89 file_path.display(),
90 self.get_percentage_read()
91 )
92 }
93
94 pub fn needs_more_reading(&self) -> bool {
95 !self.is_read_enough()
96 }
97}
98
99const EDIT_CHECKPOINT_CAP: usize = 10;
102
103const EDIT_CHECKPOINT_MAX_CONTENT_BYTES: usize = 1_000_000;
108
109#[derive(Debug, Clone)]
113pub struct EditCheckpoint {
114 pub file_path_str: String,
116 pub path: PathBuf,
117 pub prior_content: String,
119 pub prior_whitelist: Option<FileWhitelistData>,
122}
123
124#[derive(Debug, Clone)]
125pub struct BashState {
126 pub cwd: PathBuf,
127 pub workspace_root: PathBuf,
128 pub current_thread_id: String,
129 pub mode: Modes,
130 pub bash_command_mode: BashCommandMode,
131 pub file_edit_mode: FileEditMode,
132 pub write_if_empty_mode: WriteIfEmptyMode,
133 pub whitelist_for_overwrite: HashMap<String, FileWhitelistData>,
134 pub pty_shell: Arc<Mutex<Option<PtyShell>>>,
135 pub initialized: bool,
136 pub edit_checkpoints: VecDeque<EditCheckpoint>,
140}
141
142impl Default for BashState {
143 fn default() -> Self {
144 Self::new()
145 }
146}
147
148impl BashState {
149 pub fn new() -> Self {
150 let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("/tmp"));
151 Self {
152 cwd: cwd.clone(),
153 workspace_root: cwd,
154 current_thread_id: generate_thread_id(),
155 mode: Modes::Wcgw,
156 bash_command_mode: BashCommandMode {
157 bash_mode: BashMode::NormalMode,
158 allowed_commands: AllowedCommands::All("all".to_string()),
159 },
160 file_edit_mode: FileEditMode { allowed_globs: AllowedGlobs::All("all".to_string()) },
161 write_if_empty_mode: WriteIfEmptyMode {
162 allowed_globs: AllowedGlobs::All("all".to_string()),
163 },
164 whitelist_for_overwrite: HashMap::new(),
165 pty_shell: Arc::new(Mutex::new(None)),
166 initialized: false,
167 edit_checkpoints: VecDeque::new(),
168 }
169 }
170
171 pub fn push_edit_checkpoint(&mut self, checkpoint: EditCheckpoint) {
174 if checkpoint.prior_content.len() > EDIT_CHECKPOINT_MAX_CONTENT_BYTES {
175 info!(
176 file = %checkpoint.file_path_str,
177 "UndoEdit: not checkpointing a file over 1 MB (too large to hold in memory)"
178 );
179 return;
180 }
181 self.edit_checkpoints.push_back(checkpoint);
182 while self.edit_checkpoints.len() > EDIT_CHECKPOINT_CAP {
183 self.edit_checkpoints.pop_front();
184 }
185 }
186
187 pub fn pop_edit_checkpoint_for(&mut self, file_path_str: &str) -> Option<EditCheckpoint> {
191 let index =
192 self.edit_checkpoints.iter().rposition(|cp| cp.file_path_str == file_path_str)?;
193 self.edit_checkpoints.remove(index)
194 }
195
196 pub async fn init_pty_shell(&mut self) -> Result<()> {
197 let shell =
198 PtyShell::new(&self.cwd, self.bash_command_mode.bash_mode == BashMode::RestrictedMode)?;
199 *self.pty_shell.lock().await = Some(shell);
200 Ok(())
201 }
202
203 pub fn update_cwd(&mut self, path: &Path) -> Result<()> {
204 self.cwd = path.to_path_buf();
205 Ok(())
206 }
207
208 pub fn update_workspace_root(&mut self, path: &Path) -> Result<()> {
209 self.workspace_root = path.to_path_buf();
210 Ok(())
211 }
212
213 pub fn is_command_allowed(&self, command: &str) -> bool {
214 self.bash_command_mode.allowed_commands.is_allowed(command)
215 }
216
217 pub fn is_file_edit_allowed(&self, path: &str) -> bool {
218 self.file_edit_mode.allowed_globs.is_allowed(path)
219 }
220
221 pub fn is_file_write_allowed(&self, path: &str) -> bool {
222 self.write_if_empty_mode.allowed_globs.is_allowed(path)
223 }
224 pub fn get_mode_violation_message(&self, op: &str, _target: &str) -> String {
225 format!("Operation {op} not allowed")
226 }
227
228 pub fn save_state_to_disk(&self) -> Result<()> {
229 let snapshot = self.snapshot();
230 save_state_file(&self.current_thread_id, &snapshot)?;
231 Ok(())
232 }
233
234 pub fn snapshot(&self) -> BashStateSnapshot {
235 BashStateSnapshot::from_state(
236 &self.cwd.to_string_lossy(),
237 &self.workspace_root.to_string_lossy(),
238 &self.mode,
239 &self.bash_command_mode,
240 &self.file_edit_mode,
241 &self.write_if_empty_mode,
242 &self.whitelist_for_overwrite,
243 &self.current_thread_id,
244 )
245 }
246
247 pub fn apply_snapshot(&mut self, snapshot: &BashStateSnapshot) {
248 let (cwd, root, mode, bmode, emode, wmode, whitelist, tid) = snapshot.to_state_components();
249
250 self.cwd = PathBuf::from(cwd);
251 self.workspace_root = PathBuf::from(root);
252 self.mode = mode;
253 self.bash_command_mode = bmode;
254 self.file_edit_mode = emode;
255 self.write_if_empty_mode = wmode;
256 self.whitelist_for_overwrite = whitelist;
257 self.current_thread_id = tid;
258 self.initialized = true;
259 }
260
261 pub fn load_state_from_disk(&mut self, thread_id: &str) -> Result<bool> {
262 if let Some(snapshot) = load_state_file(thread_id)? {
263 self.apply_snapshot(&snapshot);
264 Ok(true)
265 } else {
266 Ok(false)
267 }
268 }
269
270 pub fn new_with_thread_id(thread_id: Option<&str>) -> Self {
271 let mut state = Self::new();
272
273 if let Some(tid) = thread_id {
274 if !tid.is_empty() {
275 if let Ok(true) = state.load_state_from_disk(tid) {
276 info!("Loaded state for thread_id '{}'", tid);
277 } else {
278 state.current_thread_id = tid.to_string();
279 }
280 }
281 }
282
283 state
284 }
285}
286
287pub fn generate_thread_id() -> String {
288 let mut rng = rand::rng();
289 format!("tid_{:x}", rng.random::<u64>())
290}