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
9#[derive(Debug, Default, Serialize, Deserialize)]
10struct WorkspaceStats {
11    files: HashMap<String, FileStats>,
12}
13
14#[derive(Debug, Default, Serialize, Deserialize)]
15struct FileStats {
16    reads: u64,
17    writes: u64,
18    edits: u64,
19}
20
21pub fn record_read(root: &Path, path: &Path) -> Result<()> {
22    record(root, path, |stats| stats.reads += 1)
23}
24
25pub fn record_write(root: &Path, path: &Path) -> Result<()> {
26    record(root, path, |stats| stats.writes += 1)
27}
28
29pub fn record_edit(root: &Path, path: &Path) -> Result<()> {
30    record(root, path, |stats| stats.edits += 1)
31}
32
33pub fn active_files(root: &Path) -> Vec<String> {
34    let Ok(stats) = load(root) else {
35        return Vec::new();
36    };
37
38    let mut files = stats.files.into_iter().collect::<Vec<_>>();
39    files.sort_by_key(|(path, stats)| {
40        let score = stats.reads + (stats.edits * 4) + (stats.writes * 3);
41        (std::cmp::Reverse(score), path.clone())
42    });
43    files.truncate(MAX_ACTIVE_FILES);
44    files.into_iter().map(|(path, _)| path).collect()
45}
46
47/// Most-active files for repo context, using wcgw's scoring: `reads*2 + edits +
48/// writes`, top 5 (see `repo_context.py:222-238`). Kept separate from
49/// [`active_files`] so the standalone status view can use its own weighting.
50pub fn active_files_for_context(root: &Path) -> Vec<String> {
51    const CONTEXT_ACTIVE_FILES: usize = 5;
52    let Ok(stats) = load(root) else {
53        return Vec::new();
54    };
55
56    let mut files = stats.files.into_iter().collect::<Vec<_>>();
57    files.sort_by_key(|(path, stats)| {
58        let score = (stats.reads * 2) + stats.edits + stats.writes;
59        (std::cmp::Reverse(score), path.clone())
60    });
61    files.truncate(CONTEXT_ACTIVE_FILES);
62    files.into_iter().map(|(path, _)| path).collect()
63}
64
65fn record(root: &Path, path: &Path, update: impl FnOnce(&mut FileStats)) -> Result<()> {
66    let relative = path.strip_prefix(root).unwrap_or(path).to_string_lossy().to_string();
67    let mut stats = load(root).unwrap_or_default();
68    update(stats.files.entry(relative).or_default());
69    save(root, &stats)
70}
71
72fn load(root: &Path) -> Result<WorkspaceStats> {
73    let path = stats_path(root);
74    if !path.exists() {
75        return Ok(WorkspaceStats::default());
76    }
77    let content = fs::read_to_string(&path).map_err(|e| WinxError::FileAccessError {
78        path: path.clone(),
79        message: format!("Failed to read workspace stats: {e}"),
80    })?;
81    serde_json::from_str(&content)
82        .map_err(|e| WinxError::SerializationError(format!("Failed to parse workspace stats: {e}")))
83}
84
85fn save(root: &Path, stats: &WorkspaceStats) -> Result<()> {
86    let path = stats_path(root);
87    if let Some(parent) = path.parent() {
88        fs::create_dir_all(parent).map_err(|e| WinxError::FileAccessError {
89            path: parent.to_path_buf(),
90            message: format!("Failed to create workspace stats directory: {e}"),
91        })?;
92    }
93    let content = serde_json::to_string_pretty(stats)
94        .map_err(|e| WinxError::SerializationError(format!("Failed to serialize stats: {e}")))?;
95    fs::write(&path, content).map_err(|e| WinxError::FileAccessError {
96        path,
97        message: format!("Failed to write workspace stats: {e}"),
98    })
99}
100
101fn stats_path(root: &Path) -> PathBuf {
102    // Stored outside the repo (XDG data dir), keyed by a hash of the absolute
103    // workspace path — survives wiping the repo and never pollutes it. Mirrors
104    // wcgw's `~/.local/share/wcgw/workspace_stats/<name>_<hash>.json`.
105    data_base().join("winx").join("workspace_stats").join(format!("{}.json", stats_key(root)))
106}
107
108/// XDG data base dir (`$XDG_DATA_HOME` or `~/.local/share`).
109fn data_base() -> PathBuf {
110    match std::env::var("XDG_DATA_HOME") {
111        Ok(dir) if !dir.is_empty() => PathBuf::from(dir),
112        _ => home::home_dir()
113            .map_or_else(|| PathBuf::from("."), |home| home.join(".local").join("share")),
114    }
115}
116
117/// Stable per-workspace filename: `<dir-name>_<hash-of-absolute-path>`.
118fn stats_key(root: &Path) -> String {
119    use std::hash::{Hash, Hasher};
120    let abs = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());
121    let name = abs.file_name().and_then(|n| n.to_str()).unwrap_or("workspace");
122    let mut hasher = std::collections::hash_map::DefaultHasher::new();
123    abs.to_string_lossy().hash(&mut hasher);
124    format!("{name}_{:016x}", hasher.finish())
125}