1use hashbrown::{HashMap, HashSet};
7use serde::{Deserialize, Serialize};
8use std::collections::VecDeque;
9use std::path::{Path, PathBuf};
10use std::time::Instant;
11
12const MAX_RECENT_FILES: usize = 20;
14
15const MAX_RECENT_CHANGES: usize = 50;
17
18const MAX_HOT_FILES: usize = 10;
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
23pub enum ActivityType {
24 Read,
25 Edit,
26 Create,
27 Delete,
28}
29
30#[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#[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#[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#[derive(Debug, Clone)]
60pub struct UnresolvedReference {
61 pub reference: String,
62 pub context: String,
63 pub timestamp: Instant,
64}
65
66#[derive(Debug, Clone, Copy, PartialEq, Eq)]
68pub enum RelativeOp {
69 Half,
70 Double,
71 Increase(u32), Decrease(u32), }
74
75pub struct WorkspaceState {
77 recent_files: VecDeque<FileActivity>,
79
80 #[expect(dead_code)]
82 open_files: HashSet<PathBuf>,
83
84 recent_changes: Vec<FileChange>,
86
87 hot_files: Vec<(PathBuf, usize)>,
89
90 value_snapshots: HashMap<String, ValueHistory>,
92
93 last_user_intent: Option<String>,
95
96 #[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 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 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 while self.recent_files.len() > MAX_RECENT_FILES {
134 self.recent_files.pop_front();
135 }
136
137 if access_type == ActivityType::Edit {
139 self.update_hot_files(path);
140 }
141 }
142
143 fn update_hot_files(&mut self, path: &Path) {
145 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 self.hot_files.sort_by(|a, b| b.1.cmp(&a.1));
154
155 self.hot_files.truncate(MAX_HOT_FILES);
157 }
158
159 fn extract_terms_from_path(&self, path: &Path) -> Vec<String> {
161 let mut terms = Vec::new();
162
163 if let Some(file_stem) = path.file_stem()
165 && let Some(name) = file_stem.to_str()
166 {
167 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 pub fn infer_reference_target(&self, vague_term: &str) -> Option<PathBuf> {
180 let term_lower = vague_term.to_lowercase();
181
182 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 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 fn parse_relative_expression(&self, expression: &str) -> Option<RelativeOp> {
218 let expr_lower = expression.to_lowercase();
219
220 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 fn extract_percentage(&self, text: &str) -> Option<u32> {
244 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 fn get_recent_numeric_value(&self) -> Option<f64> {
260 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 if let Some((_, history)) = self.value_snapshots.iter().next() {
269 return self.parse_value_string(&history.current);
270 }
271
272 None
273 }
274
275 fn extract_numeric_value(&self, content: &str) -> Option<f64> {
277 for line in content.lines().rev().take(10) {
279 if let Some(value) = self.extract_css_value(line) {
281 return Some(value);
282 }
283
284 if let Some(value) = self.extract_config_value(line) {
286 return Some(value);
287 }
288
289 if let Some(value) = self.extract_code_value(line) {
291 return Some(value);
292 }
293 }
294
295 None
296 }
297
298 fn extract_config_value(&self, line: &str) -> Option<f64> {
300 if let Some(colon_pos) = line.find(':').or_else(|| line.find('=')) {
305 let value_part = line[colon_pos + 1..].trim();
306
307 let mut cleaned = value_part
309 .trim_matches(',')
310 .trim_matches('"')
311 .trim_matches('\'');
312
313 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 fn extract_code_value(&self, line: &str) -> Option<f64> {
331 if let Some(eq_pos) = line.find('=') {
334 let value_part = line[eq_pos + 1..].trim();
335
336 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 fn extract_css_value(&self, line: &str) -> Option<f64> {
355 if let Some(colon_pos) = line.find(':') {
357 let value_part = line[colon_pos + 1..].trim();
358
359 for word in value_part.split_whitespace() {
361 let mut num_str = word.trim_end_matches(';');
363
364 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 fn parse_value_string(&self, value: &str) -> Option<f64> {
383 let mut num_str = value;
384
385 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 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 while self.recent_changes.len() > MAX_RECENT_CHANGES {
414 self.recent_changes.remove(0);
415 }
416 }
417
418 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 history.previous.push(history.current.clone());
423 history.current = value;
424 history.file = file;
425 history.line = line;
426
427 if history.previous.len() > 10 {
429 history.previous.remove(0);
430 }
431 } else {
432 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 pub fn set_user_intent(&mut self, intent: String) {
448 self.last_user_intent = Some(intent);
449 }
450
451 pub fn last_user_intent(&self) -> Option<&str> {
453 self.last_user_intent.as_deref()
454 }
455
456 pub fn recent_files(&self, count: usize) -> Vec<&FileActivity> {
458 self.recent_files.iter().rev().take(count).collect()
459 }
460
461 pub fn was_recently_accessed(&self, path: &Path) -> bool {
463 self.recent_files
464 .iter()
465 .any(|activity| activity.path == path)
466 }
467
468 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 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 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); assert_eq!(hot[0].1, 3);
554 assert_eq!(hot[1].0, path2);
555 assert_eq!(hot[1].1, 1);
556 }
557
558 #[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}