1use hashbrown::HashMap;
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 recent_changes: Vec<FileChange>,
82
83 hot_files: Vec<(PathBuf, usize)>,
85
86 value_snapshots: HashMap<String, ValueHistory>,
88}
89
90impl Default for WorkspaceState {
91 fn default() -> Self {
92 Self::new()
93 }
94}
95
96impl WorkspaceState {
97 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 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 while self.recent_files.len() > MAX_RECENT_FILES {
120 self.recent_files.pop_front();
121 }
122
123 if access_type == ActivityType::Edit {
125 self.update_hot_files(path);
126 }
127 }
128
129 fn update_hot_files(&mut self, path: &Path) {
131 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 self.hot_files.sort_by_key(|a| std::cmp::Reverse(a.1));
140
141 self.hot_files.truncate(MAX_HOT_FILES);
143 }
144
145 fn extract_terms_from_path(&self, path: &Path) -> Vec<String> {
147 let mut terms = Vec::new();
148
149 if let Some(file_stem) = path.file_stem()
151 && let Some(name) = file_stem.to_str()
152 {
153 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 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 fn parse_relative_expression(&self, expression: &str) -> Option<RelativeOp> {
192 let expr_lower = expression.to_lowercase();
193
194 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 fn extract_percentage(&self, text: &str) -> Option<u32> {
218 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 fn get_recent_numeric_value(&self) -> Option<f64> {
234 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 if let Some((_, history)) = self.value_snapshots.iter().next() {
243 return self.parse_value_string(&history.current);
244 }
245
246 None
247 }
248
249 fn extract_numeric_value(&self, content: &str) -> Option<f64> {
251 for line in content.lines().rev().take(10) {
253 if let Some(value) = self.extract_css_value(line) {
255 return Some(value);
256 }
257
258 if let Some(value) = self.extract_config_value(line) {
260 return Some(value);
261 }
262
263 if let Some(value) = self.extract_code_value(line) {
265 return Some(value);
266 }
267 }
268
269 None
270 }
271
272 fn extract_config_value(&self, line: &str) -> Option<f64> {
274 if let Some(colon_pos) = line.find(':').or_else(|| line.find('=')) {
279 let value_part = line[colon_pos + 1..].trim();
280
281 let mut cleaned = value_part.trim_matches(',').trim_matches('"').trim_matches('\'');
283
284 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 fn extract_code_value(&self, line: &str) -> Option<f64> {
302 if let Some(eq_pos) = line.find('=') {
305 let value_part = line[eq_pos + 1..].trim();
306
307 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 fn extract_css_value(&self, line: &str) -> Option<f64> {
326 if let Some(colon_pos) = line.find(':') {
328 let value_part = line[colon_pos + 1..].trim();
329
330 for word in value_part.split_whitespace() {
332 let mut num_str = word.trim_end_matches(';');
334
335 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 fn parse_value_string(&self, value: &str) -> Option<f64> {
354 let mut num_str = value;
355
356 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 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 while self.recent_changes.len() > MAX_RECENT_CHANGES {
380 self.recent_changes.remove(0);
381 }
382 }
383
384 pub fn recent_files(&self, count: usize) -> Vec<&FileActivity> {
386 self.recent_files.iter().rev().take(count).collect()
387 }
388
389 pub fn was_recently_accessed(&self, path: &Path) -> bool {
391 self.recent_files.iter().any(|activity| activity.path == path)
392 }
393
394 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 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 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); assert_eq!(hot[0].1, 3);
462 assert_eq!(hot[1].0, path2);
463 assert_eq!(hot[1].1, 1);
464 }
465
466 #[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}