sqry_core/workspace/
stats.rs1use std::time::{Duration, SystemTime};
4
5use super::registry::{WorkspaceRegistry, WorkspaceRepository};
6
7fn u64_to_f64(value: u64) -> f64 {
8 #[allow(clippy::cast_precision_loss)]
9 {
10 value as f64
11 }
12}
13
14fn usize_to_f64(value: usize) -> f64 {
15 #[allow(clippy::cast_precision_loss)]
16 {
17 value as f64
18 }
19}
20
21#[derive(Debug, Clone)]
23pub struct DetailedWorkspaceStats {
24 pub total_repos: usize,
26 pub indexed_repos: usize,
28 pub unindexed_repos: usize,
30 pub total_symbols: u64,
32 pub freshness: FreshnessBuckets,
34 pub avg_symbols_per_repo: f64,
36}
37
38#[derive(Debug, Clone, Default)]
40pub struct FreshnessBuckets {
41 pub fresh: usize,
43 pub recent: usize,
45 pub stale: usize,
47 pub very_stale: usize,
49 pub never_indexed: usize,
51}
52
53impl FreshnessBuckets {
54 #[must_use]
56 pub fn from_registry(registry: &WorkspaceRegistry) -> Self {
57 let now = SystemTime::now();
58 let mut buckets = Self::default();
59
60 for repo in ®istry.repositories {
61 if let Some(last_indexed) = repo.last_indexed_at {
62 if let Ok(elapsed) = now.duration_since(last_indexed) {
63 buckets.categorize(elapsed);
64 } else {
65 buckets.fresh += 1;
67 }
68 } else {
69 buckets.never_indexed += 1;
70 }
71 }
72
73 buckets
74 }
75
76 fn categorize(&mut self, elapsed: Duration) {
77 const HOUR: Duration = Duration::from_secs(3600);
78 const DAY: Duration = Duration::from_secs(86400);
79 const WEEK: Duration = Duration::from_secs(604_800);
80
81 if elapsed < HOUR {
82 self.fresh += 1;
83 } else if elapsed < DAY {
84 self.recent += 1;
85 } else if elapsed < WEEK {
86 self.stale += 1;
87 } else {
88 self.very_stale += 1;
89 }
90 }
91
92 #[must_use]
94 pub fn indexed_total(&self) -> usize {
95 self.fresh + self.recent + self.stale + self.very_stale
96 }
97
98 #[must_use]
100 pub fn total(&self) -> usize {
101 self.indexed_total() + self.never_indexed
102 }
103}
104
105impl DetailedWorkspaceStats {
106 #[must_use]
108 pub fn from_registry(registry: &WorkspaceRegistry) -> Self {
109 let total_repos = registry.repositories.len();
110 let indexed_repos = registry
111 .repositories
112 .iter()
113 .filter(|r| r.last_indexed_at.is_some())
114 .count();
115 let unindexed_repos = total_repos - indexed_repos;
116
117 let total_symbols: u64 = registry
118 .repositories
119 .iter()
120 .filter_map(|r| r.symbol_count)
121 .sum();
122
123 let avg_symbols_per_repo = if indexed_repos > 0 {
124 u64_to_f64(total_symbols) / usize_to_f64(indexed_repos)
125 } else {
126 0.0
127 };
128
129 let freshness = FreshnessBuckets::from_registry(registry);
130
131 Self {
132 total_repos,
133 indexed_repos,
134 unindexed_repos,
135 total_symbols,
136 freshness,
137 avg_symbols_per_repo,
138 }
139 }
140
141 #[must_use]
143 pub fn stale_repos<'a>(
144 &self,
145 registry: &'a WorkspaceRegistry,
146 threshold: Duration,
147 ) -> Vec<&'a WorkspaceRepository> {
148 let now = SystemTime::now();
149 registry
150 .repositories
151 .iter()
152 .filter(|repo| {
153 if let Some(last_indexed) = repo.last_indexed_at
154 && let Ok(elapsed) = now.duration_since(last_indexed)
155 {
156 return elapsed > threshold;
157 }
158 repo.last_indexed_at.is_none()
160 })
161 .collect()
162 }
163
164 #[must_use]
173 #[allow(
174 clippy::cast_precision_loss,
175 reason = "Freshness scoring is informational; f64 is adequate for UX metrics"
176 )]
177 pub fn health_score(&self) -> f64 {
178 if self.total_repos == 0 {
179 return 1.0;
180 }
181
182 #[allow(
184 clippy::cast_precision_loss,
185 reason = "Freshness scoring is informational; f64 is adequate for UX metrics"
186 )]
187 let score = (self.freshness.fresh as f64 * 1.0)
188 + (self.freshness.recent as f64 * 0.8)
189 + (self.freshness.stale as f64 * 0.5)
190 + (self.freshness.very_stale as f64 * 0.2)
191 + (self.freshness.never_indexed as f64 * 0.0);
192
193 score / self.total_repos as f64
194 }
195
196 #[must_use]
198 pub fn health_status(&self) -> &'static str {
199 let score = self.health_score();
200 match score {
201 s if s >= 0.9 => "Excellent",
202 s if s >= 0.7 => "Good",
203 s if s >= 0.5 => "Fair",
204 s if s >= 0.3 => "Poor",
205 _ => "Critical",
206 }
207 }
208}
209
210#[cfg(test)]
211mod tests {
212 use super::*;
213 use crate::workspace::WorkspaceRepoId;
214 use std::path::PathBuf;
215
216 fn create_test_repo(name: &str, indexed_ago: Option<Duration>) -> WorkspaceRepository {
217 let last_indexed_at = indexed_ago.map(|duration| SystemTime::now() - duration);
218 WorkspaceRepository {
219 id: WorkspaceRepoId::new(name),
220 name: name.to_string(),
221 root: PathBuf::from(format!("/workspace/{name}")),
222 index_path: PathBuf::from(format!("/workspace/{name}/.sqry-index")),
223 last_indexed_at,
224 symbol_count: if indexed_ago.is_some() {
225 Some(100)
226 } else {
227 None
228 },
229 primary_language: Some("rust".to_string()),
230 }
231 }
232
233 #[test]
234 fn test_freshness_buckets() {
235 let mut registry = WorkspaceRegistry::new(Some("Test".into()));
236
237 registry
239 .upsert_repo(create_test_repo("fresh", Some(Duration::from_secs(1800))))
240 .unwrap();
241
242 registry
244 .upsert_repo(create_test_repo("recent", Some(Duration::from_secs(7200))))
245 .unwrap();
246
247 registry
249 .upsert_repo(create_test_repo(
250 "stale",
251 Some(Duration::from_secs(172_800)),
252 ))
253 .unwrap();
254
255 registry
257 .upsert_repo(create_test_repo(
258 "very-stale",
259 Some(Duration::from_secs(691_200)),
260 ))
261 .unwrap();
262
263 registry
265 .upsert_repo(create_test_repo("never", None))
266 .unwrap();
267
268 let stats = DetailedWorkspaceStats::from_registry(®istry);
269
270 assert_eq!(stats.freshness.fresh, 1);
271 assert_eq!(stats.freshness.recent, 1);
272 assert_eq!(stats.freshness.stale, 1);
273 assert_eq!(stats.freshness.very_stale, 1);
274 assert_eq!(stats.freshness.never_indexed, 1);
275 assert_eq!(stats.total_repos, 5);
276 assert_eq!(stats.indexed_repos, 4);
277 assert_eq!(stats.unindexed_repos, 1);
278 }
279
280 #[test]
281 fn test_health_score() {
282 let mut registry = WorkspaceRegistry::new(Some("Test".into()));
283
284 for i in 0..5 {
286 registry
287 .upsert_repo(create_test_repo(
288 &format!("repo-{i}"),
289 Some(Duration::from_secs(1800)),
290 ))
291 .unwrap();
292 }
293
294 let stats = DetailedWorkspaceStats::from_registry(®istry);
295 assert!(stats.health_score() >= 0.9);
296 assert_eq!(stats.health_status(), "Excellent");
297 }
298
299 #[test]
300 fn test_stale_repos() {
301 let mut registry = WorkspaceRegistry::new(Some("Test".into()));
302
303 registry
305 .upsert_repo(create_test_repo("fresh", Some(Duration::from_secs(1800))))
306 .unwrap();
307
308 registry
310 .upsert_repo(create_test_repo(
311 "stale",
312 Some(Duration::from_secs(259_200)),
313 ))
314 .unwrap();
315
316 let stats = DetailedWorkspaceStats::from_registry(®istry);
317 let stale = stats.stale_repos(®istry, Duration::from_secs(86400)); assert_eq!(stale.len(), 1);
320 assert_eq!(stale[0].name, "stale");
321 }
322}