Skip to main content

winx_code_agent/utils/
workspace_stats.rs

1use 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;
8/// Bound the persisted per-workspace history. This is deliberately much larger
9/// than either consumer's top-N view, while preventing monorepos and long-lived
10/// plugin sessions from growing the JSON file forever.
11const 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
29/// Record one `ReadFiles` batch with a single load/save cycle.
30pub 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
59/// Most-active files for repo context, using wcgw's scoring: `reads*2 + edits +
60/// writes`, top 5 (see `repo_context.py:222-238`). Kept separate from
61/// [`active_files`] so the standalone status view can use its own weighting.
62pub 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    // Stored outside the repo (XDG data dir), keyed by a hash of the absolute
163    // workspace path — survives wiping the repo and never pollutes it. Mirrors
164    // wcgw's `~/.local/share/wcgw/workspace_stats/<name>_<hash>.json`.
165    data_base().join("winx").join("workspace_stats").join(format!("{}.json", stats_key(root)))
166}
167
168/// XDG data base dir (`$XDG_DATA_HOME` or `~/.local/share`).
169fn 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
177/// Stable per-workspace filename: `<dir-name>_<hash-of-absolute-path>`.
178fn 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}