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, HashSet};
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    /// Files currently open/being edited
81    #[expect(dead_code)]
82    open_files: HashSet<PathBuf>,
83
84    /// Recent changes
85    recent_changes: Vec<FileChange>,
86
87    /// Hot files (most frequently edited)
88    hot_files: Vec<(PathBuf, usize)>,
89
90    /// Value snapshots for inference
91    value_snapshots: HashMap<String, ValueHistory>,
92
93    /// Last user intent/request
94    last_user_intent: Option<String>,
95
96    /// Pending unresolved references
97    #[expect(dead_code)]
98    pending_references: Vec<UnresolvedReference>,
99}
100
101impl Default for WorkspaceState {
102    fn default() -> Self {
103        Self::new()
104    }
105}
106
107impl WorkspaceState {
108    /// Create a new workspace state tracker
109    pub fn new() -> Self {
110        Self {
111            recent_files: VecDeque::with_capacity(MAX_RECENT_FILES),
112            open_files: HashSet::new(),
113            recent_changes: Vec::with_capacity(MAX_RECENT_CHANGES),
114            hot_files: Vec::with_capacity(MAX_HOT_FILES),
115            value_snapshots: HashMap::new(),
116            last_user_intent: None,
117            pending_references: Vec::new(),
118        }
119    }
120
121    /// Record a file access
122    pub fn record_file_access(&mut self, path: &Path, access_type: ActivityType) {
123        let activity = FileActivity {
124            path: path.to_path_buf(),
125            action: access_type,
126            timestamp: Instant::now(),
127            related_terms: self.extract_terms_from_path(path),
128        };
129
130        self.recent_files.push_back(activity);
131
132        // Keep bounded
133        while self.recent_files.len() > MAX_RECENT_FILES {
134            self.recent_files.pop_front();
135        }
136
137        // Update hot files on edit
138        if access_type == ActivityType::Edit {
139            self.update_hot_files(path);
140        }
141    }
142
143    /// Update hot files list with edit count
144    fn update_hot_files(&mut self, path: &Path) {
145        // Find existing entry
146        if let Some(entry) = self.hot_files.iter_mut().find(|(p, _)| p == path) {
147            entry.1 += 1;
148        } else {
149            self.hot_files.push((path.to_path_buf(), 1));
150        }
151
152        // Sort by edit count (descending)
153        self.hot_files.sort_by(|a, b| b.1.cmp(&a.1));
154
155        // Keep bounded
156        self.hot_files.truncate(MAX_HOT_FILES);
157    }
158
159    /// Extract terms from file path (for entity matching)
160    fn extract_terms_from_path(&self, path: &Path) -> Vec<String> {
161        let mut terms = Vec::new();
162
163        // Extract filename without extension
164        if let Some(file_stem) = path.file_stem()
165            && let Some(name) = file_stem.to_str()
166        {
167            // Split on common separators
168            for term in name.split(|c: char| !c.is_alphanumeric()) {
169                if !term.is_empty() {
170                    terms.push(term.to_lowercase());
171                }
172            }
173        }
174
175        terms
176    }
177
178    /// Infer reference target from vague term
179    pub fn infer_reference_target(&self, vague_term: &str) -> Option<PathBuf> {
180        let term_lower = vague_term.to_lowercase();
181
182        // Priority: most recent file containing term
183        self.recent_files
184            .iter()
185            .rev()
186            .find(|activity| activity.related_terms.contains(&term_lower))
187            .map(|activity| activity.path.clone())
188    }
189
190    /// Resolve relative value expression
191    pub fn resolve_relative_value(&self, expression: &str) -> Option<String> {
192        let op = self.parse_relative_expression(expression)?;
193
194        match op {
195            RelativeOp::Half => {
196                let current = self.get_recent_numeric_value()?;
197                Some(format!("{}", current / 2.0))
198            }
199            RelativeOp::Double => {
200                let current = self.get_recent_numeric_value()?;
201                Some(format!("{}", current * 2.0))
202            }
203            RelativeOp::Increase(pct) => {
204                let current = self.get_recent_numeric_value()?;
205                let multiplier = 1.0 + (pct as f64 / 100.0);
206                Some(format!("{}", current * multiplier))
207            }
208            RelativeOp::Decrease(pct) => {
209                let current = self.get_recent_numeric_value()?;
210                let multiplier = 1.0 - (pct as f64 / 100.0);
211                Some(format!("{}", current * multiplier))
212            }
213        }
214    }
215
216    /// Parse relative expression to operation
217    fn parse_relative_expression(&self, expression: &str) -> Option<RelativeOp> {
218        let expr_lower = expression.to_lowercase();
219
220        // Try to extract percentage first so "increase by 20%" doesn't
221        // accidentally match "by 2" from the half shortcut.
222        if let Some(pct) = self.extract_percentage(&expr_lower) {
223            if expr_lower.contains("increase") {
224                return Some(RelativeOp::Increase(pct));
225            }
226            if expr_lower.contains("decrease") || expr_lower.contains("reduce") {
227                return Some(RelativeOp::Decrease(pct));
228            }
229        }
230
231        if expr_lower.contains("half") || expr_lower.contains("by 2") {
232            return Some(RelativeOp::Half);
233        }
234
235        if expr_lower.contains("double") || expr_lower.contains("twice") {
236            return Some(RelativeOp::Double);
237        }
238
239        None
240    }
241
242    /// Extract percentage from string
243    fn extract_percentage(&self, text: &str) -> Option<u32> {
244        // Look for patterns like "20%", "20 percent", etc.
245        for word in text.split_whitespace() {
246            if let Some(num_str) = word.strip_suffix('%')
247                && let Ok(num) = num_str.parse::<u32>()
248            {
249                return Some(num);
250            }
251            if let Ok(num) = word.parse::<u32>() {
252                return Some(num);
253            }
254        }
255        None
256    }
257
258    /// Get most recent numeric value from edits
259    fn get_recent_numeric_value(&self) -> Option<f64> {
260        // Look at recent changes for numeric values
261        for change in self.recent_changes.iter().rev() {
262            if let Some(value) = self.extract_numeric_value(&change.content_after) {
263                return Some(value);
264            }
265        }
266
267        // Fallback to value snapshots
268        if let Some((_, history)) = self.value_snapshots.iter().next() {
269            return self.parse_value_string(&history.current);
270        }
271
272        None
273    }
274
275    /// Extract numeric value from content
276    fn extract_numeric_value(&self, content: &str) -> Option<f64> {
277        // Try multiple patterns in order of specificity
278        for line in content.lines().rev().take(10) {
279            // CSS patterns: padding: 16px, width: 50%, etc.
280            if let Some(value) = self.extract_css_value(line) {
281                return Some(value);
282            }
283
284            // JSON/TOML patterns: "timeout": 5000, timeout = 30
285            if let Some(value) = self.extract_config_value(line) {
286                return Some(value);
287            }
288
289            // Programming language patterns: padding = 16, const size = 20
290            if let Some(value) = self.extract_code_value(line) {
291                return Some(value);
292            }
293        }
294
295        None
296    }
297
298    /// Extract numeric value from config files (JSON, TOML, YAML)
299    fn extract_config_value(&self, line: &str) -> Option<f64> {
300        // JSON: "key": 123 or "key": "123px"
301        // TOML: key = 123
302        // YAML: key: 123
303
304        if let Some(colon_pos) = line.find(':').or_else(|| line.find('=')) {
305            let value_part = line[colon_pos + 1..].trim();
306
307            // Remove quotes and commas
308            let mut cleaned = value_part
309                .trim_matches(',')
310                .trim_matches('"')
311                .trim_matches('\'');
312
313            // Try to strip common unit suffixes
314            for suffix in &["px", "rem", "em", "ms", "s", "pt"] {
315                if let Some(stripped) = cleaned.strip_suffix(suffix) {
316                    cleaned = stripped;
317                    break;
318                }
319            }
320
321            if let Ok(num) = cleaned.parse::<f64>() {
322                return Some(num);
323            }
324        }
325
326        None
327    }
328
329    /// Extract numeric value from code (Python, JavaScript, Rust, etc.)
330    fn extract_code_value(&self, line: &str) -> Option<f64> {
331        // Patterns: const x = 10, let y = 20, var z = 30, x = 40
332
333        if let Some(eq_pos) = line.find('=') {
334            let value_part = line[eq_pos + 1..].trim();
335
336            // Extract first numeric token
337            for word in value_part.split_whitespace() {
338                let cleaned = word
339                    .trim_matches(';')
340                    .trim_matches(',')
341                    .trim_end_matches("px")
342                    .trim_end_matches("rem");
343
344                if let Ok(num) = cleaned.parse::<f64>() {
345                    return Some(num);
346                }
347            }
348        }
349
350        None
351    }
352
353    /// Extract numeric value from CSS line
354    fn extract_css_value(&self, line: &str) -> Option<f64> {
355        // Look for patterns like "padding: 16px"
356        if let Some(colon_pos) = line.find(':') {
357            let value_part = line[colon_pos + 1..].trim();
358
359            // Extract number (handling px, rem, %, etc.)
360            for word in value_part.split_whitespace() {
361                // Strip semicolon first
362                let mut num_str = word.trim_end_matches(';');
363
364                // Try to strip common CSS units (use strip_suffix for literal matching)
365                for suffix in &["px", "rem", "em", "%", "pt", "vh", "vw"] {
366                    if let Some(stripped) = num_str.strip_suffix(suffix) {
367                        num_str = stripped;
368                        break;
369                    }
370                }
371
372                if let Ok(num) = num_str.parse::<f64>() {
373                    return Some(num);
374                }
375            }
376        }
377
378        None
379    }
380
381    /// Parse value string to number
382    fn parse_value_string(&self, value: &str) -> Option<f64> {
383        let mut num_str = value;
384
385        // Try to strip common units
386        for suffix in &["px", "rem", "em", "%", "pt", "ms", "s"] {
387            if let Some(stripped) = num_str.strip_suffix(suffix) {
388                num_str = stripped;
389                break;
390            }
391        }
392
393        num_str.parse::<f64>().ok()
394    }
395
396    /// Record a file change
397    pub fn record_change(
398        &mut self,
399        path: PathBuf,
400        content_before: Option<String>,
401        content_after: String,
402    ) {
403        let change = FileChange {
404            path,
405            content_before,
406            content_after,
407            timestamp: Instant::now(),
408        };
409
410        self.recent_changes.push(change);
411
412        // Keep bounded
413        while self.recent_changes.len() > MAX_RECENT_CHANGES {
414            self.recent_changes.remove(0);
415        }
416    }
417
418    /// Record value snapshot
419    pub fn record_value(&mut self, key: String, value: String, file: PathBuf, line: usize) {
420        if let Some(history) = self.value_snapshots.get_mut(&key) {
421            // Move current to previous
422            history.previous.push(history.current.clone());
423            history.current = value;
424            history.file = file;
425            history.line = line;
426
427            // Keep bounded
428            if history.previous.len() > 10 {
429                history.previous.remove(0);
430            }
431        } else {
432            // Create new history
433            self.value_snapshots.insert(
434                key.clone(),
435                ValueHistory {
436                    key,
437                    current: value,
438                    previous: Vec::new(),
439                    file,
440                    line,
441                },
442            );
443        }
444    }
445
446    /// Set last user intent
447    pub fn set_user_intent(&mut self, intent: String) {
448        self.last_user_intent = Some(intent);
449    }
450
451    /// Get last user intent
452    pub fn last_user_intent(&self) -> Option<&str> {
453        self.last_user_intent.as_deref()
454    }
455
456    /// Get recent files (up to N)
457    pub fn recent_files(&self, count: usize) -> Vec<&FileActivity> {
458        self.recent_files.iter().rev().take(count).collect()
459    }
460
461    /// Check if file was recently accessed
462    pub fn was_recently_accessed(&self, path: &Path) -> bool {
463        self.recent_files
464            .iter()
465            .any(|activity| activity.path == path)
466    }
467
468    /// Get hot files (most edited)
469    pub fn hot_files(&self) -> &[(PathBuf, usize)] {
470        &self.hot_files
471    }
472}
473
474#[cfg(test)]
475mod tests {
476    use super::*;
477
478    #[test]
479    fn test_parse_relative_expression_half() {
480        let state = WorkspaceState::new();
481        assert_eq!(
482            state.parse_relative_expression("by half"),
483            Some(RelativeOp::Half)
484        );
485        assert_eq!(
486            state.parse_relative_expression("divide by 2"),
487            Some(RelativeOp::Half)
488        );
489    }
490
491    #[test]
492    fn test_parse_relative_expression_double() {
493        let state = WorkspaceState::new();
494        assert_eq!(
495            state.parse_relative_expression("double it"),
496            Some(RelativeOp::Double)
497        );
498        assert_eq!(
499            state.parse_relative_expression("twice as much"),
500            Some(RelativeOp::Double)
501        );
502    }
503
504    #[test]
505    fn test_parse_relative_expression_percentage() {
506        let state = WorkspaceState::new();
507        assert_eq!(
508            state.parse_relative_expression("increase by 20%"),
509            Some(RelativeOp::Increase(20))
510        );
511        assert_eq!(
512            state.parse_relative_expression("decrease by 50%"),
513            Some(RelativeOp::Decrease(50))
514        );
515    }
516
517    #[test]
518    fn test_extract_css_value() {
519        let state = WorkspaceState::new();
520        assert_eq!(state.extract_css_value("  padding: 16px;"), Some(16.0));
521        assert_eq!(state.extract_css_value("  width: 50%;"), Some(50.0));
522        assert_eq!(state.extract_css_value("  margin: 1.5rem;"), Some(1.5));
523    }
524
525    #[test]
526    fn test_record_file_access() {
527        let mut state = WorkspaceState::new();
528        let path = PathBuf::from("src/components/Sidebar.tsx");
529
530        state.record_file_access(&path, ActivityType::Edit);
531
532        assert_eq!(state.recent_files.len(), 1);
533        assert!(state.was_recently_accessed(&path));
534    }
535
536    #[test]
537    fn test_hot_files_tracking() {
538        let mut state = WorkspaceState::new();
539        let path1 = PathBuf::from("src/App.tsx");
540        let path2 = PathBuf::from("src/Sidebar.tsx");
541
542        // Edit path1 three times
543        state.record_file_access(&path1, ActivityType::Edit);
544        state.record_file_access(&path1, ActivityType::Edit);
545        state.record_file_access(&path1, ActivityType::Edit);
546
547        // Edit path2 once
548        state.record_file_access(&path2, ActivityType::Edit);
549
550        let hot = state.hot_files();
551        assert_eq!(hot.len(), 2);
552        assert_eq!(hot[0].0, path1); // Most edited
553        assert_eq!(hot[0].1, 3);
554        assert_eq!(hot[1].0, path2);
555        assert_eq!(hot[1].1, 1);
556    }
557
558    // Phase 4: Enhanced value extraction tests
559    #[test]
560    fn test_extract_config_value_json() {
561        let state = WorkspaceState::new();
562        assert_eq!(
563            state.extract_config_value(r#"  "timeout": 5000,"#),
564            Some(5000.0)
565        );
566        assert_eq!(
567            state.extract_config_value(r#"  "padding": "16px","#),
568            Some(16.0)
569        );
570    }
571
572    #[test]
573    fn test_extract_config_value_toml() {
574        let state = WorkspaceState::new();
575        assert_eq!(state.extract_config_value("timeout = 30"), Some(30.0));
576        assert_eq!(state.extract_config_value("max_retries = 5"), Some(5.0));
577    }
578
579    #[test]
580    fn test_extract_code_value_javascript() {
581        let state = WorkspaceState::new();
582        assert_eq!(state.extract_code_value("const padding = 16;"), Some(16.0));
583        assert_eq!(state.extract_code_value("let width = 320;"), Some(320.0));
584    }
585
586    #[test]
587    fn test_extract_code_value_python() {
588        let state = WorkspaceState::new();
589        assert_eq!(state.extract_code_value("padding = 24"), Some(24.0));
590        assert_eq!(state.extract_code_value("TIMEOUT = 1000"), Some(1000.0));
591    }
592
593    #[test]
594    fn test_extract_code_value_rust() {
595        let state = WorkspaceState::new();
596        assert_eq!(state.extract_code_value("let size = 42;"), Some(42.0));
597        assert_eq!(
598            state.extract_code_value("const MAX_SIZE: usize = 100;"),
599            Some(100.0)
600        );
601    }
602}