Skip to main content

cats/
state.rs

1//! State management for tools
2//!
3//! Maintains context for tool execution sessions.
4
5use serde::{Deserialize, Serialize};
6use std::collections::HashMap;
7use std::path::PathBuf;
8use std::sync::atomic::{AtomicBool, Ordering};
9use std::sync::Arc;
10
11/// Shared cancel signal that can be set from outside to request
12/// immediate termination of a running tool (e.g. bash command on ESC).
13#[derive(Debug, Clone)]
14pub struct CancelSignal {
15    inner: Arc<AtomicBool>,
16}
17
18impl CancelSignal {
19    /// Create a new (un-cancelled) signal.
20    pub fn new() -> Self {
21        Self {
22            inner: Arc::new(AtomicBool::new(false)),
23        }
24    }
25
26    /// Signal cancellation.
27    pub fn request_cancel(&self) {
28        self.inner.store(true, Ordering::Relaxed);
29    }
30
31    /// Reset the signal (e.g. before starting a new tool call).
32    pub fn reset(&self) {
33        self.inner.store(false, Ordering::Relaxed);
34    }
35
36    /// Check whether cancellation has been requested.
37    pub fn is_cancelled(&self) -> bool {
38        self.inner.load(Ordering::Relaxed)
39    }
40}
41
42impl Default for CancelSignal {
43    fn default() -> Self {
44        Self::new()
45    }
46}
47
48/// Represents the state of the tool system
49pub struct ToolState {
50    /// Currently open files with their windowed views
51    pub open_files: HashMap<PathBuf, FileState>,
52    /// The currently active file
53    pub current_file: Option<PathBuf>,
54    /// Session history for undo/redo
55    pub history: Vec<StateSnapshot>,
56    /// Current working directory
57    pub working_directory: PathBuf,
58    /// Cancellation signal for cooperative tool cancellation (e.g., ESC during bash execution).
59    /// Shared via `Arc<AtomicBool>` so tools can clone it before spawning child processes.
60    cancel_signal: Arc<AtomicBool>,
61}
62
63/// State of an individual file
64#[derive(Debug, Clone, Serialize, Deserialize)]
65pub struct FileState {
66    /// File content as lines
67    pub content: Vec<String>,
68    /// Current window start position (0-indexed line number)
69    pub window_start: usize,
70    /// Window size (number of lines to show)
71    pub window_size: usize,
72    /// File modification tracking
73    pub modified: bool,
74    /// Last modification timestamp
75    pub last_modified: Option<std::time::SystemTime>,
76}
77
78impl FileState {
79    /// Create new file state
80    pub fn new(content: Vec<String>, window_size: usize) -> Self {
81        Self {
82            content,
83            window_start: 0,
84            window_size,
85            modified: false,
86            last_modified: None,
87        }
88    }
89
90    /// Get the current window content
91    pub fn get_window(&self) -> Vec<&String> {
92        let end = std::cmp::min(self.window_start + self.window_size, self.content.len());
93        self.content[self.window_start..end].iter().collect()
94    }
95
96    /// Get window with line numbers
97    pub fn get_window_with_line_numbers(&self) -> Vec<String> {
98        let end = std::cmp::min(self.window_start + self.window_size, self.content.len());
99        self.content[self.window_start..end]
100            .iter()
101            .enumerate()
102            .map(|(i, line)| format!("{:4} | {}", self.window_start + i + 1, line))
103            .collect()
104    }
105
106    /// Move window to show specific line
107    pub fn goto_line(&mut self, line_number: usize) {
108        if line_number == 0 {
109            return;
110        }
111        let target_line = line_number.saturating_sub(1);
112        if target_line < self.content.len() {
113            let half_window = self.window_size / 2;
114            self.window_start = target_line.saturating_sub(half_window);
115
116            let max_start = self.content.len().saturating_sub(self.window_size);
117            if self.window_start > max_start {
118                self.window_start = max_start;
119            }
120        }
121    }
122
123    /// Scroll window up
124    pub fn scroll_up(&mut self) {
125        self.window_start = self.window_start.saturating_sub(self.window_size);
126    }
127
128    /// Scroll window down
129    pub fn scroll_down(&mut self) {
130        let max_start = self.content.len().saturating_sub(self.window_size);
131        self.window_start = std::cmp::min(self.window_start + self.window_size, max_start);
132    }
133
134    /// Get total number of lines
135    pub fn total_lines(&self) -> usize {
136        self.content.len()
137    }
138
139    /// Check if window is at the beginning
140    pub fn is_at_start(&self) -> bool {
141        self.window_start == 0
142    }
143
144    /// Check if window is at the end
145    pub fn is_at_end(&self) -> bool {
146        self.window_start + self.window_size >= self.content.len()
147    }
148}
149
150/// Snapshot of the tool state for history tracking
151#[derive(Debug, Clone, Serialize, Deserialize)]
152pub struct StateSnapshot {
153    pub timestamp: std::time::SystemTime,
154    pub current_file: Option<PathBuf>,
155    pub operation: String,
156    pub file_states: HashMap<PathBuf, FileState>,
157}
158
159impl ToolState {
160    /// Create a new tool state
161    pub fn new() -> Self {
162        Self {
163            open_files: HashMap::new(),
164            current_file: None,
165            history: Vec::new(),
166            working_directory: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
167            cancel_signal: Arc::new(AtomicBool::new(false)),
168        }
169    }
170
171    /// Open a file and add it to the state
172    pub fn open_file(
173        &mut self,
174        path: PathBuf,
175        content: Vec<String>,
176        window_size: usize,
177    ) -> anyhow::Result<()> {
178        let file_state = FileState::new(content, window_size);
179        self.open_files.insert(path.clone(), file_state);
180        self.current_file = Some(path);
181        Ok(())
182    }
183
184    /// Get the current file state
185    pub fn get_current_file_state(&self) -> Option<&FileState> {
186        self.current_file
187            .as_ref()
188            .and_then(|path| self.open_files.get(path))
189    }
190
191    /// Get mutable current file state
192    pub fn get_current_file_state_mut(&mut self) -> Option<&mut FileState> {
193        let current_file = self.current_file.clone()?;
194        self.open_files.get_mut(&current_file)
195    }
196
197    /// Switch to a different open file
198    pub fn switch_to_file(&mut self, path: &PathBuf) -> Result<(), crate::core::ToolError> {
199        if self.open_files.contains_key(path) {
200            self.current_file = Some(path.clone());
201            Ok(())
202        } else {
203            Err(crate::core::ToolError::FileNotFound {
204                path: path.to_string_lossy().to_string(),
205            })
206        }
207    }
208
209    /// Close a file
210    pub fn close_file(&mut self, path: &PathBuf) {
211        self.open_files.remove(path);
212        if self.current_file.as_ref() == Some(path) {
213            self.current_file = self.open_files.keys().next().cloned();
214        }
215    }
216
217    /// Create a snapshot for history
218    pub fn create_snapshot(&self, operation: String) -> StateSnapshot {
219        StateSnapshot {
220            timestamp: std::time::SystemTime::now(),
221            current_file: self.current_file.clone(),
222            operation,
223            file_states: self.open_files.clone(),
224        }
225    }
226
227    /// Add to history
228    pub fn push_history(&mut self, operation: String) {
229        let snapshot = self.create_snapshot(operation);
230        self.history.push(snapshot);
231
232        const MAX_HISTORY: usize = 100;
233        if self.history.len() > MAX_HISTORY {
234            self.history.remove(0);
235        }
236    }
237
238    /// Get state summary for display
239    pub fn get_summary(&self) -> String {
240        let mut summary = String::new();
241
242        summary.push_str(&format!(
243            "Working Directory: {}\n",
244            self.working_directory.display()
245        ));
246
247        if let Some(current) = &self.current_file {
248            summary.push_str(&format!("Current File: {}\n", current.display()));
249
250            if let Some(file_state) = self.open_files.get(current) {
251                summary.push_str(&format!(
252                    "Lines: {} | Window: {}-{} (size: {})\n",
253                    file_state.total_lines(),
254                    file_state.window_start + 1,
255                    std::cmp::min(
256                        file_state.window_start + file_state.window_size,
257                        file_state.total_lines()
258                    ),
259                    file_state.window_size
260                ));
261            }
262        } else {
263            summary.push_str("No file currently open\n");
264        }
265
266        if !self.open_files.is_empty() {
267            summary.push_str("\nOpen Files:\n");
268            for (path, file_state) in &self.open_files {
269                let modified = if file_state.modified {
270                    " (modified)"
271                } else {
272                    ""
273                };
274                summary.push_str(&format!(
275                    "  {} - {} lines{}\n",
276                    path.display(),
277                    file_state.total_lines(),
278                    modified
279                ));
280            }
281        }
282
283        summary
284    }
285
286    // ---- Cancel signal helpers ----
287
288    /// Signal cancellation — sets the shared flag so running tools can check it.
289    pub fn request_cancel(&self) {
290        self.cancel_signal.store(true, Ordering::Relaxed);
291    }
292
293    /// Reset the cancel signal (e.g. before starting a new tool call).
294    pub fn reset_cancel(&self) {
295        self.cancel_signal.store(false, Ordering::Relaxed);
296    }
297
298    /// Check whether cancellation has been requested.
299    pub fn is_cancelled(&self) -> bool {
300        self.cancel_signal.load(Ordering::Relaxed)
301    }
302
303    /// Return a clone of the inner `Arc<AtomicBool>` so a tool can check
304    /// the signal without holding the `Mutex<ToolState>` lock.
305    pub fn cancel_signal(&self) -> Arc<AtomicBool> {
306        Arc::clone(&self.cancel_signal)
307    }
308}
309
310impl Default for ToolState {
311    fn default() -> Self {
312        Self::new()
313    }
314}
315
316#[cfg(test)]
317mod tests {
318    use super::*;
319
320    #[test]
321    fn test_file_state_creation() {
322        let content = vec![
323            "line1".to_string(),
324            "line2".to_string(),
325            "line3".to_string(),
326        ];
327        let file_state = FileState::new(content, 2);
328
329        assert_eq!(file_state.window_size, 2);
330        assert_eq!(file_state.window_start, 0);
331        assert_eq!(file_state.total_lines(), 3);
332        assert!(!file_state.modified);
333    }
334
335    #[test]
336    fn test_file_state_windowing() {
337        let content: Vec<String> = (1..=10).map(|i| format!("line {}", i)).collect();
338        let mut file_state = FileState::new(content, 3);
339
340        let window = file_state.get_window();
341        assert_eq!(window.len(), 3);
342        assert_eq!(window[0], "line 1");
343        assert_eq!(window[2], "line 3");
344
345        file_state.scroll_down();
346        let window = file_state.get_window();
347        assert_eq!(window[0], "line 4");
348
349        file_state.goto_line(8);
350        assert!(file_state.window_start >= 5);
351    }
352
353    #[test]
354    fn test_tool_state_file_management() {
355        let mut state = ToolState::new();
356        let path = PathBuf::from("test.txt");
357        let content = vec!["line1".to_string(), "line2".to_string()];
358
359        state.open_file(path.clone(), content, 10).unwrap();
360        assert_eq!(state.current_file, Some(path.clone()));
361        assert!(state.open_files.contains_key(&path));
362
363        let file_state = state.get_current_file_state().unwrap();
364        assert_eq!(file_state.total_lines(), 2);
365
366        state.close_file(&path);
367        assert!(!state.open_files.contains_key(&path));
368        assert_eq!(state.current_file, None);
369    }
370}