winx_code_agent/utils/
workspace_stats.rs1use crate::errors::{Result, WinxError};
2use serde::{Deserialize, Serialize};
3use std::collections::HashMap;
4use std::fs;
5use std::path::{Path, PathBuf};
6
7const MAX_ACTIVE_FILES: usize = 30;
8const MAX_TRACKED_FILES: usize = 4_096;
12
13#[derive(Debug, Default, Serialize, Deserialize)]
14struct WorkspaceStats {
15 files: HashMap<String, FileStats>,
16}
17
18#[derive(Debug, Default, Serialize, Deserialize)]
19struct FileStats {
20 reads: u64,
21 writes: u64,
22 edits: u64,
23}
24
25pub fn record_read(root: &Path, path: &Path) -> Result<()> {
26 record(root, path, Activity::Read)
27}
28
29pub fn record_reads(root: &Path, paths: &[PathBuf]) -> Result<()> {
31 record_many(root, paths.iter().map(PathBuf::as_path), Activity::Read)
32}
33
34pub fn record_write(root: &Path, path: &Path) -> Result<()> {
35 record(root, path, Activity::Write)
36}
37
38pub fn record_edit(root: &Path, path: &Path) -> Result<()> {
39 record(root, path, Activity::Edit)
40}
41
42pub fn active_files(root: &Path) -> Vec<String> {
43 let Ok(stats) = load(root) else {
44 return Vec::new();
45 };
46
47 let mut files = stats.files.into_iter().collect::<Vec<_>>();
48 files.sort_by_key(|(path, stats)| {
49 let score = stats
50 .reads
51 .saturating_add(stats.edits.saturating_mul(4))
52 .saturating_add(stats.writes.saturating_mul(3));
53 (std::cmp::Reverse(score), path.clone())
54 });
55 files.truncate(MAX_ACTIVE_FILES);
56 files.into_iter().map(|(path, _)| path).collect()
57}
58
59pub fn active_files_for_context(root: &Path) -> Vec<String> {
63 const CONTEXT_ACTIVE_FILES: usize = 5;
64 let Ok(stats) = load(root) else {
65 return Vec::new();
66 };
67
68 let mut files = stats.files.into_iter().collect::<Vec<_>>();
69 files.sort_by_key(|(path, stats)| {
70 let score =
71 stats.reads.saturating_mul(2).saturating_add(stats.edits).saturating_add(stats.writes);
72 (std::cmp::Reverse(score), path.clone())
73 });
74 files.truncate(CONTEXT_ACTIVE_FILES);
75 files.into_iter().map(|(path, _)| path).collect()
76}
77
78#[derive(Clone, Copy)]
79enum Activity {
80 Read,
81 Write,
82 Edit,
83}
84
85fn record(root: &Path, path: &Path, activity: Activity) -> Result<()> {
86 record_many(root, std::iter::once(path), activity)
87}
88
89fn record_many<'a>(
90 root: &Path,
91 paths: impl IntoIterator<Item = &'a Path>,
92 activity: Activity,
93) -> Result<()> {
94 let mut stats = load(root).unwrap_or_default();
95 let mut last_recorded = None;
96 for path in paths {
97 let relative = path.strip_prefix(root).unwrap_or(path).to_string_lossy().to_string();
98 let file = stats.files.entry(relative.clone()).or_default();
99 match activity {
100 Activity::Read => file.reads = file.reads.saturating_add(1),
101 Activity::Write => file.writes = file.writes.saturating_add(1),
102 Activity::Edit => file.edits = file.edits.saturating_add(1),
103 }
104 last_recorded = Some(relative);
105 }
106 let Some(last_recorded) = last_recorded else { return Ok(()) };
107 prune_stats(&mut stats, &last_recorded);
108 save(root, &stats)
109}
110
111fn prune_stats(stats: &mut WorkspaceStats, preserve: &str) {
112 let remove_count = stats.files.len().saturating_sub(MAX_TRACKED_FILES);
113 if remove_count == 0 {
114 return;
115 }
116
117 let mut candidates = stats
118 .files
119 .iter()
120 .filter(|(path, _)| path.as_str() != preserve)
121 .map(|(path, file)| {
122 let activity = file.reads.saturating_add(file.writes).saturating_add(file.edits);
123 (activity, path.clone())
124 })
125 .collect::<Vec<_>>();
126 candidates.sort_unstable();
127 for (_, path) in candidates.into_iter().take(remove_count) {
128 stats.files.remove(&path);
129 }
130}
131
132fn load(root: &Path) -> Result<WorkspaceStats> {
133 let path = stats_path(root);
134 if !path.exists() {
135 return Ok(WorkspaceStats::default());
136 }
137 let content = fs::read_to_string(&path).map_err(|e| WinxError::FileAccessError {
138 path: path.clone(),
139 message: format!("Failed to read workspace stats: {e}"),
140 })?;
141 serde_json::from_str(&content)
142 .map_err(|e| WinxError::SerializationError(format!("Failed to parse workspace stats: {e}")))
143}
144
145fn save(root: &Path, stats: &WorkspaceStats) -> Result<()> {
146 let path = stats_path(root);
147 if let Some(parent) = path.parent() {
148 fs::create_dir_all(parent).map_err(|e| WinxError::FileAccessError {
149 path: parent.to_path_buf(),
150 message: format!("Failed to create workspace stats directory: {e}"),
151 })?;
152 }
153 let content = serde_json::to_string_pretty(stats)
154 .map_err(|e| WinxError::SerializationError(format!("Failed to serialize stats: {e}")))?;
155 fs::write(&path, content).map_err(|e| WinxError::FileAccessError {
156 path,
157 message: format!("Failed to write workspace stats: {e}"),
158 })
159}
160
161fn stats_path(root: &Path) -> PathBuf {
162 data_base().join("winx").join("workspace_stats").join(format!("{}.json", stats_key(root)))
166}
167
168fn data_base() -> PathBuf {
170 match std::env::var("XDG_DATA_HOME") {
171 Ok(dir) if !dir.is_empty() => PathBuf::from(dir),
172 _ => home::home_dir()
173 .map_or_else(|| PathBuf::from("."), |home| home.join(".local").join("share")),
174 }
175}
176
177fn stats_key(root: &Path) -> String {
179 use std::hash::{Hash, Hasher};
180 let abs =
181 root.canonicalize().map_or_else(|_| root.to_path_buf(), crate::utils::path::simplified);
182 let name = abs.file_name().and_then(|n| n.to_str()).unwrap_or("workspace");
183 let mut hasher = std::collections::hash_map::DefaultHasher::new();
184 abs.to_string_lossy().hash(&mut hasher);
185 format!("{name}_{:016x}", hasher.finish())
186}
187
188#[cfg(test)]
189mod tests {
190 use super::{prune_stats, FileStats, WorkspaceStats, MAX_TRACKED_FILES};
191
192 #[test]
193 fn persisted_history_is_bounded_and_preserves_current_file() {
194 let mut stats = WorkspaceStats::default();
195 for index in 0..(MAX_TRACKED_FILES + 3) {
196 stats.files.insert(
197 format!("file-{index:04}.rs"),
198 FileStats { reads: index as u64, writes: 0, edits: 0 },
199 );
200 }
201 let current = "file-0000.rs";
202
203 prune_stats(&mut stats, current);
204
205 assert_eq!(stats.files.len(), MAX_TRACKED_FILES);
206 assert!(stats.files.contains_key(current));
207 assert!(!stats.files.contains_key("file-0001.rs"));
208 assert!(!stats.files.contains_key("file-0002.rs"));
209 assert!(!stats.files.contains_key("file-0003.rs"));
210 }
211}