Skip to main content

vtcode_core/context/
workspace_state.rs

1//! Workspace state tracking for vibe coding support
2//!
3//! Tracks file activity, edits, and value changes to provide context for
4//! lazy/vague user requests.
5
6use hashbrown::HashMap;
7use serde::{Deserialize, Serialize};
8use std::collections::VecDeque;
9use std::path::{Path, PathBuf};
10use std::time::Instant;
11
12/// Maximum number of recent files to track
13const MAX_RECENT_FILES: usize = 20;
14
15/// Maximum number of recent changes to track
16const MAX_RECENT_CHANGES: usize = 50;
17
18/// Maximum number of hot files to track
19const MAX_HOT_FILES: usize = 10;
20
21/// Type of file activity
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
23pub enum ActivityType {
24    Read,
25    Edit,
26    Create,
27    Delete,
28}
29
30/// A file activity event
31#[derive(Debug, Clone)]
32pub struct FileActivity {
33    pub path: PathBuf,
34    pub action: ActivityType,
35    pub timestamp: Instant,
36    pub related_terms: Vec<String>,
37}
38
39/// A file change event
40#[derive(Debug, Clone)]
41pub struct FileChange {
42    pub path: PathBuf,
43    pub content_before: Option<String>,
44    pub content_after: String,
45    pub timestamp: Instant,
46}
47
48/// History of a value over time
49#[derive(Debug, Clone, Serialize, Deserialize)]
50pub struct ValueHistory {
51    pub key: String,
52    pub current: String,
53    pub previous: Vec<String>,
54    pub file: PathBuf,
55    pub line: usize,
56}
57
58/// An unresolved reference that needs context
59#[derive(Debug, Clone)]
60pub struct UnresolvedReference {
61    pub reference: String,
62    pub context: String,
63    pub timestamp: Instant,
64}
65
66/// Relative operation types
67#[derive(Debug, Clone, Copy, PartialEq, Eq)]
68pub enum RelativeOp {
69    Half,
70    Double,
71    Increase(u32), // Increase by percentage
72    Decrease(u32), // Decrease by percentage
73}
74
75/// Tracks workspace state for contextual inference
76pub struct WorkspaceState {
77    /// Recent file activities (bounded queue)
78    recent_files: VecDeque<FileActivity>,
79
80    /// Recent changes
81    recent_changes: Vec<FileChange>,
82
83    /// Hot files (most frequently edited)
84    hot_files: Vec<(PathBuf, usize)>,
85
86    /// Value snapshots for inference
87    value_snapshots: HashMap<String, ValueHistory>,
88}
89
90impl Default for WorkspaceState {
91    fn default() -> Self {
92        Self::new()
93    }
94}
95
96impl WorkspaceState {
97    /// Create a new workspace state tracker
98    pub fn new() -> Self {
99        Self {
100            recent_files: VecDeque::with_capacity(MAX_RECENT_FILES),
101            recent_changes: Vec::with_capacity(MAX_RECENT_CHANGES),
102            hot_files: Vec::with_capacity(MAX_HOT_FILES),
103            value_snapshots: HashMap::new(),
104        }
105    }
106
107    /// Record a file access
108    pub fn record_file_access(&mut self, path: &Path, access_type: ActivityType) {
109        let activity = FileActivity {
110            path: path.to_path_buf(),
111            action: access_type,
112            timestamp: Instant::now(),
113            related_terms: self.extract_terms_from_path(path),
114        };
115
116        self.recent_files.push_back(activity);
117
118        // Keep bounded
119        while self.recent_files.len() > MAX_RECENT_FILES {
120            self.recent_files.pop_front();
121        }
122
123        // Update hot files on edit
124        if access_type == ActivityType::Edit {
125            self.update_hot_files(path);
126        }
127    }
128
129    /// Update hot files list with edit count
130    fn update_hot_files(&mut self, path: &Path) {
131        // Find existing entry
132        if let Some(entry) = self.hot_files.iter_mut().find(|(p, _)| p == path) {
133            entry.1 += 1;
134        } else {
135            self.hot_files.push((path.to_path_buf(), 1));
136        }
137
138        // Sort by edit count (descending)
139        self.hot_files.sort_by_key(|a| std::cmp::Reverse(a.1));
140
141        // Keep bounded
142        self.hot_files.truncate(MAX_HOT_FILES);
143    }
144
145    /// Extract terms from file path (for entity matching)
146    fn extract_terms_from_path(&self, path: &Path) -> Vec<String> {
147        let mut terms = Vec::new();
148
149        // Extract filename without extension
150        if let Some(file_stem) = path.file_stem()
151            && let Some(name) = file_stem.to_str()
152        {
153            // Split on common separators
154            for term in name.split(|c: char| !c.is_alphanumeric()) {
155                if !term.is_empty() {
156                    terms.push(term.to_lowercase());
157                }
158            }
159        }
160
161        terms
162    }
163
164    /// Resolve relative value expression
165    pub fn resolve_relative_value(&self, expression: &str) -> Option<String> {
166        let op = self.parse_relative_expression(expression)?;
167
168        match op {
169            RelativeOp::Half => {
170                let current = self.get_recent_numeric_value()?;
171                Some(format!("{}", current / 2.0))
172            }
173            RelativeOp::Double => {
174                let current = self.get_recent_numeric_value()?;
175                Some(format!("{}", current * 2.0))
176            }
177            RelativeOp::Increase(pct) => {
178                let current = self.get_recent_numeric_value()?;
179                let multiplier = 1.0 + (pct as f64 / 100.0);
180                Some(format!("{}", current * multiplier))
181            }
182            RelativeOp::Decrease(pct) => {
183                let current = self.get_recent_numeric_value()?;
184                let multiplier = 1.0 - (pct as f64 / 100.0);
185                Some(format!("{}", current * multiplier))
186            }
187        }
188    }
189
190    /// Parse relative expression to operation
191    fn parse_relative_expression(&self, expression: &str) -> Option<RelativeOp> {
192        let expr_lower = expression.to_lowercase();
193
194        // Try to extract percentage first so "increase by 20%" doesn't
195        // accidentally match "by 2" from the half shortcut.
196        if let Some(pct) = self.extract_percentage(&expr_lower) {
197            if expr_lower.contains("increase") {
198                return Some(RelativeOp::Increase(pct));
199            }
200            if expr_lower.contains("decrease") || expr_lower.contains("reduce") {
201                return Some(RelativeOp::Decrease(pct));
202            }
203        }
204
205        if expr_lower.contains("half") || expr_lower.contains("by 2") {
206            return Some(RelativeOp::Half);
207        }
208
209        if expr_lower.contains("double") || expr_lower.contains("twice") {
210            return Some(RelativeOp::Double);
211        }
212
213        None
214    }
215
216    /// Extract percentage from string
217    fn extract_percentage(&self, text: &str) -> Option<u32> {
218        // Look for patterns like "20%", "20 percent", etc.
219        for word in text.split_whitespace() {
220            if let Some(num_str) = word.strip_suffix('%')
221                && let Ok(num) = num_str.parse::<u32>()
222            {
223                return Some(num);
224            }
225            if let Ok(num) = word.parse::<u32>() {
226                return Some(num);
227            }
228        }
229        None
230    }
231
232    /// Get most recent numeric value from edits
233    fn get_recent_numeric_value(&self) -> Option<f64> {
234        // Look at recent changes for numeric values
235        for change in self.recent_changes.iter().rev() {
236            if let Some(value) = self.extract_numeric_value(&change.content_after) {
237                return Some(value);
238            }
239        }
240
241        // Fallback to value snapshots
242        if let Some((_, history)) = self.value_snapshots.iter().next() {
243            return self.parse_value_string(&history.current);
244        }
245
246        None
247    }
248
249    /// Extract numeric value from content
250    fn extract_numeric_value(&self, content: &str) -> Option<f64> {
251        // Try multiple patterns in order of specificity
252        for line in content.lines().rev().take(10) {
253            // CSS patterns: padding: 16px, width: 50%, etc.
254            if let Some(value) = self.extract_css_value(line) {
255                return Some(value);
256            }
257
258            // JSON/TOML patterns: "timeout": 5000, timeout = 30
259            if let Some(value) = self.extract_config_value(line) {
260                return Some(value);
261            }
262
263            // Programming language patterns: padding = 16, const size = 20
264            if let Some(value) = self.extract_code_value(line) {
265                return Some(value);
266            }
267        }
268
269        None
270    }
271
272    /// Extract numeric value from config files (JSON, TOML, YAML)
273    fn extract_config_value(&self, line: &str) -> Option<f64> {
274        // JSON: "key": 123 or "key": "123px"
275        // TOML: key = 123
276        // YAML: key: 123
277
278        if let Some(colon_pos) = line.find(':').or_else(|| line.find('=')) {
279            let value_part = line[colon_pos + 1..].trim();
280
281            // Remove quotes and commas
282            let mut cleaned = value_part.trim_matches(',').trim_matches('"').trim_matches('\'');
283
284            // Try to strip common unit suffixes
285            for suffix in &["px", "rem", "em", "ms", "s", "pt"] {
286                if let Some(stripped) = cleaned.strip_suffix(suffix) {
287                    cleaned = stripped;
288                    break;
289                }
290            }
291
292            if let Ok(num) = cleaned.parse::<f64>() {
293                return Some(num);
294            }
295        }
296
297        None
298    }
299
300    /// Extract numeric value from code (Python, JavaScript, Rust, etc.)
301    fn extract_code_value(&self, line: &str) -> Option<f64> {
302        // Patterns: const x = 10, let y = 20, var z = 30, x = 40
303
304        if let Some(eq_pos) = line.find('=') {
305            let value_part = line[eq_pos + 1..].trim();
306
307            // Extract first numeric token
308            for word in value_part.split_whitespace() {
309                let cleaned = word
310                    .trim_matches(';')
311                    .trim_matches(',')
312                    .trim_end_matches("px")
313                    .trim_end_matches("rem");
314
315                if let Ok(num) = cleaned.parse::<f64>() {
316                    return Some(num);
317                }
318            }
319        }
320
321        None
322    }
323
324    /// Extract numeric value from CSS line
325    fn extract_css_value(&self, line: &str) -> Option<f64> {
326        // Look for patterns like "padding: 16px"
327        if let Some(colon_pos) = line.find(':') {
328            let value_part = line[colon_pos + 1..].trim();
329
330            // Extract number (handling px, rem, %, etc.)
331            for word in value_part.split_whitespace() {
332                // Strip semicolon first
333                let mut num_str = word.trim_end_matches(';');
334
335                // Try to strip common CSS units (use strip_suffix for literal matching)
336                for suffix in &["px", "rem", "em", "%", "pt", "vh", "vw"] {
337                    if let Some(stripped) = num_str.strip_suffix(suffix) {
338                        num_str = stripped;
339                        break;
340                    }
341                }
342
343                if let Ok(num) = num_str.parse::<f64>() {
344                    return Some(num);
345                }
346            }
347        }
348
349        None
350    }
351
352    /// Parse value string to number
353    fn parse_value_string(&self, value: &str) -> Option<f64> {
354        let mut num_str = value;
355
356        // Try to strip common units
357        for suffix in &["px", "rem", "em", "%", "pt", "ms", "s"] {
358            if let Some(stripped) = num_str.strip_suffix(suffix) {
359                num_str = stripped;
360                break;
361            }
362        }
363
364        num_str.parse::<f64>().ok()
365    }
366
367    /// Record a file change
368    pub fn record_change(&mut self, path: PathBuf, content_before: Option<String>, content_after: String) {
369        let change = FileChange {
370            path,
371            content_before,
372            content_after,
373            timestamp: Instant::now(),
374        };
375
376        self.recent_changes.push(change);
377
378        // Keep bounded
379        while self.recent_changes.len() > MAX_RECENT_CHANGES {
380            self.recent_changes.remove(0);
381        }
382    }
383
384    /// Get recent files (up to N)
385    pub fn recent_files(&self, count: usize) -> Vec<&FileActivity> {
386        self.recent_files.iter().rev().take(count).collect()
387    }
388
389    /// Check if file was recently accessed
390    pub fn was_recently_accessed(&self, path: &Path) -> bool {
391        self.recent_files.iter().any(|activity| activity.path == path)
392    }
393
394    /// Get hot files (most edited)
395    pub fn hot_files(&self) -> &[(PathBuf, usize)] {
396        &self.hot_files
397    }
398}
399
400#[cfg(test)]
401mod tests {
402    use super::*;
403
404    #[test]
405    fn test_parse_relative_expression_half() {
406        let state = WorkspaceState::new();
407        assert_eq!(state.parse_relative_expression("by half"), Some(RelativeOp::Half));
408        assert_eq!(state.parse_relative_expression("divide by 2"), Some(RelativeOp::Half));
409    }
410
411    #[test]
412    fn test_parse_relative_expression_double() {
413        let state = WorkspaceState::new();
414        assert_eq!(state.parse_relative_expression("double it"), Some(RelativeOp::Double));
415        assert_eq!(state.parse_relative_expression("twice as much"), Some(RelativeOp::Double));
416    }
417
418    #[test]
419    fn test_parse_relative_expression_percentage() {
420        let state = WorkspaceState::new();
421        assert_eq!(state.parse_relative_expression("increase by 20%"), Some(RelativeOp::Increase(20)));
422        assert_eq!(state.parse_relative_expression("decrease by 50%"), Some(RelativeOp::Decrease(50)));
423    }
424
425    #[test]
426    fn test_extract_css_value() {
427        let state = WorkspaceState::new();
428        assert_eq!(state.extract_css_value("  padding: 16px;"), Some(16.0));
429        assert_eq!(state.extract_css_value("  width: 50%;"), Some(50.0));
430        assert_eq!(state.extract_css_value("  margin: 1.5rem;"), Some(1.5));
431    }
432
433    #[test]
434    fn test_record_file_access() {
435        let mut state = WorkspaceState::new();
436        let path = PathBuf::from("src/components/Sidebar.tsx");
437
438        state.record_file_access(&path, ActivityType::Edit);
439
440        assert_eq!(state.recent_files.len(), 1);
441        assert!(state.was_recently_accessed(&path));
442    }
443
444    #[test]
445    fn test_hot_files_tracking() {
446        let mut state = WorkspaceState::new();
447        let path1 = PathBuf::from("src/App.tsx");
448        let path2 = PathBuf::from("src/Sidebar.tsx");
449
450        // Edit path1 three times
451        state.record_file_access(&path1, ActivityType::Edit);
452        state.record_file_access(&path1, ActivityType::Edit);
453        state.record_file_access(&path1, ActivityType::Edit);
454
455        // Edit path2 once
456        state.record_file_access(&path2, ActivityType::Edit);
457
458        let hot = state.hot_files();
459        assert_eq!(hot.len(), 2);
460        assert_eq!(hot[0].0, path1); // Most edited
461        assert_eq!(hot[0].1, 3);
462        assert_eq!(hot[1].0, path2);
463        assert_eq!(hot[1].1, 1);
464    }
465
466    // Phase 4: Enhanced value extraction tests
467    #[test]
468    fn test_extract_config_value_json() {
469        let state = WorkspaceState::new();
470        assert_eq!(state.extract_config_value(r#"  "timeout": 5000,"#), Some(5000.0));
471        assert_eq!(state.extract_config_value(r#"  "padding": "16px","#), Some(16.0));
472    }
473
474    #[test]
475    fn test_extract_config_value_toml() {
476        let state = WorkspaceState::new();
477        assert_eq!(state.extract_config_value("timeout = 30"), Some(30.0));
478        assert_eq!(state.extract_config_value("max_retries = 5"), Some(5.0));
479    }
480
481    #[test]
482    fn test_extract_code_value_javascript() {
483        let state = WorkspaceState::new();
484        assert_eq!(state.extract_code_value("const padding = 16;"), Some(16.0));
485        assert_eq!(state.extract_code_value("let width = 320;"), Some(320.0));
486    }
487
488    #[test]
489    fn test_extract_code_value_python() {
490        let state = WorkspaceState::new();
491        assert_eq!(state.extract_code_value("padding = 24"), Some(24.0));
492        assert_eq!(state.extract_code_value("TIMEOUT = 1000"), Some(1000.0));
493    }
494
495    #[test]
496    fn test_extract_code_value_rust() {
497        let state = WorkspaceState::new();
498        assert_eq!(state.extract_code_value("let size = 42;"), Some(42.0));
499        assert_eq!(state.extract_code_value("const MAX_SIZE: usize = 100;"), Some(100.0));
500    }
501}