sqry_core/workspace/stats.rs
1//! Workspace statistics and staleness tracking.
2//!
3//! # Live symbol counts (issue #515 staleness fix)
4//!
5//! [`DetailedWorkspaceStats::from_registry`] (the path `sqry workspace
6//! stats` runs) reads each indexed member's symbol count **live** from
7//! its `.sqry/graph/manifest.json` at stats-computation time, via
8//! [`WorkspaceRepository::symbol_count_from_manifest`]. It deliberately
9//! does not use the registry's cached
10//! [`WorkspaceRepository::symbol_count_at_registration`] field, because
11//! that value is only refreshed at registration time
12//! (`discover_repositories` / `sqry workspace add`) and goes stale the
13//! moment a member is reindexed directly (`sqry index --force`) without
14//! a matching `workspace remove` + `workspace add` round-trip. `sqry
15//! workspace query` already reads member graphs live via
16//! `SessionManager`; `stats` now matches that freshness contract instead
17//! of trusting a point-in-time snapshot that a reindex can silently
18//! invalidate.
19
20use std::time::{Duration, SystemTime};
21
22use super::registry::{WorkspaceRegistry, WorkspaceRepository};
23
24fn u64_to_f64(value: u64) -> f64 {
25 #[allow(clippy::cast_precision_loss)]
26 {
27 value as f64
28 }
29}
30
31fn usize_to_f64(value: usize) -> f64 {
32 #[allow(clippy::cast_precision_loss)]
33 {
34 value as f64
35 }
36}
37
38/// Detailed workspace statistics including staleness tracking.
39#[derive(Debug, Clone)]
40pub struct DetailedWorkspaceStats {
41 /// Total number of repositories in the workspace.
42 pub total_repos: usize,
43 /// Number of repositories that have been indexed.
44 pub indexed_repos: usize,
45 /// Number of repositories that have never been indexed.
46 pub unindexed_repos: usize,
47 /// Total symbol count across all indexed repositories whose
48 /// `manifest.json` sidecar could be read. Repositories counted in
49 /// `unknown_symbol_count_repos` do not contribute here.
50 pub total_symbols: u64,
51 /// Freshness buckets categorizing repositories by age.
52 pub freshness: FreshnessBuckets,
53 /// Average symbols per repository with a known symbol count
54 /// (`total_symbols` divided by the repos that contributed to it, not
55 /// by `indexed_repos`, so an unreadable manifest cannot silently
56 /// drag the average down).
57 pub avg_symbols_per_repo: f64,
58 /// Indexed repositories (`last_indexed_at.is_some()`) whose
59 /// `.sqry/graph/manifest.json` could not be read or parsed, so their
60 /// symbol count is unknown rather than zero. Surfaced separately so
61 /// `sqry workspace stats` can report them instead of silently
62 /// folding them into `total_symbols` as zero contributions.
63 pub unknown_symbol_count_repos: usize,
64}
65
66/// Freshness buckets categorizing repositories by last indexed time.
67#[derive(Debug, Clone, Default)]
68pub struct FreshnessBuckets {
69 /// Repositories indexed within the last hour.
70 pub fresh: usize,
71 /// Repositories indexed within the last 24 hours.
72 pub recent: usize,
73 /// Repositories indexed within the last 7 days.
74 pub stale: usize,
75 /// Repositories indexed more than 7 days ago.
76 pub very_stale: usize,
77 /// Repositories that have never been indexed.
78 pub never_indexed: usize,
79}
80
81impl FreshnessBuckets {
82 /// Calculate freshness buckets from a registry.
83 #[must_use]
84 pub fn from_registry(registry: &WorkspaceRegistry) -> Self {
85 let now = SystemTime::now();
86 let mut buckets = Self::default();
87
88 for repo in ®istry.repositories {
89 if let Some(last_indexed) = repo.last_indexed_at {
90 if let Ok(elapsed) = now.duration_since(last_indexed) {
91 buckets.categorize(elapsed);
92 } else {
93 // Future timestamp (clock skew) - treat as fresh
94 buckets.fresh += 1;
95 }
96 } else {
97 buckets.never_indexed += 1;
98 }
99 }
100
101 buckets
102 }
103
104 fn categorize(&mut self, elapsed: Duration) {
105 const HOUR: Duration = Duration::from_secs(3600);
106 const DAY: Duration = Duration::from_secs(86400);
107 const WEEK: Duration = Duration::from_secs(604_800);
108
109 if elapsed < HOUR {
110 self.fresh += 1;
111 } else if elapsed < DAY {
112 self.recent += 1;
113 } else if elapsed < WEEK {
114 self.stale += 1;
115 } else {
116 self.very_stale += 1;
117 }
118 }
119
120 /// Get the total number of indexed repositories across all buckets.
121 #[must_use]
122 pub fn indexed_total(&self) -> usize {
123 self.fresh + self.recent + self.stale + self.very_stale
124 }
125
126 /// Get the total number of repositories (including never indexed).
127 #[must_use]
128 pub fn total(&self) -> usize {
129 self.indexed_total() + self.never_indexed
130 }
131}
132
133impl DetailedWorkspaceStats {
134 /// Compute detailed statistics from a workspace registry.
135 ///
136 /// Reads every indexed member's symbol count **live** from its
137 /// `.sqry/graph/manifest.json` sidecar (via
138 /// [`WorkspaceRepository::symbol_count_from_manifest`]), not from the
139 /// registry's cached `symbol_count_at_registration` field. See the
140 /// module-level docs for why: this is the fix for issue #515's
141 /// staleness gap (stats reporting a stale count after a direct `sqry
142 /// index --force` reindex of a member).
143 #[must_use]
144 pub fn from_registry(registry: &WorkspaceRegistry) -> Self {
145 Self::from_registry_with_resolver(registry, |repo| {
146 WorkspaceRepository::symbol_count_from_manifest(&repo.root)
147 })
148 }
149
150 /// Compute detailed statistics from a workspace registry using a
151 /// caller-supplied symbol-count resolver instead of always reading
152 /// `.sqry/graph/manifest.json` from disk.
153 ///
154 /// [`Self::from_registry`] is the production entry point (used by
155 /// `sqry workspace stats`) and always resolves live from each
156 /// member's manifest. This variant exists so the aggregation logic
157 /// (freshness split, `total_symbols` sum, `unknown_symbol_count_repos`
158 /// bucketing, `avg_symbols_per_repo` denominator) stays unit-testable
159 /// without needing real manifest fixtures on disk for every test:
160 /// callers can inject a resolver that returns canned known/unknown
161 /// counts per repository.
162 #[must_use]
163 pub fn from_registry_with_resolver(
164 registry: &WorkspaceRegistry,
165 resolve_symbol_count: impl Fn(&WorkspaceRepository) -> Option<u64>,
166 ) -> Self {
167 let total_repos = registry.repositories.len();
168 let indexed_repos = registry
169 .repositories
170 .iter()
171 .filter(|r| r.last_indexed_at.is_some())
172 .count();
173 let unindexed_repos = total_repos - indexed_repos;
174
175 // Split indexed repos into "symbol count known" (manifest.json
176 // read cleanly) versus "unknown" (manifest missing/corrupt).
177 // Repositories that were never indexed at all contribute to
178 // neither bucket; they are already accounted for by
179 // `unindexed_repos` / `freshness.never_indexed`.
180 let mut known_symbol_count_repos = 0usize;
181 let mut unknown_symbol_count_repos = 0usize;
182 let mut total_symbols: u64 = 0;
183 for repo in ®istry.repositories {
184 if repo.last_indexed_at.is_none() {
185 continue;
186 }
187 match resolve_symbol_count(repo) {
188 Some(count) => {
189 total_symbols += count;
190 known_symbol_count_repos += 1;
191 }
192 None => unknown_symbol_count_repos += 1,
193 }
194 }
195
196 let avg_symbols_per_repo = if known_symbol_count_repos > 0 {
197 u64_to_f64(total_symbols) / usize_to_f64(known_symbol_count_repos)
198 } else {
199 0.0
200 };
201
202 let freshness = FreshnessBuckets::from_registry(registry);
203
204 Self {
205 total_repos,
206 indexed_repos,
207 unindexed_repos,
208 total_symbols,
209 freshness,
210 avg_symbols_per_repo,
211 unknown_symbol_count_repos,
212 }
213 }
214
215 /// Get repositories that need reindexing (older than threshold).
216 #[must_use]
217 pub fn stale_repos<'a>(
218 &self,
219 registry: &'a WorkspaceRegistry,
220 threshold: Duration,
221 ) -> Vec<&'a WorkspaceRepository> {
222 let now = SystemTime::now();
223 registry
224 .repositories
225 .iter()
226 .filter(|repo| {
227 if let Some(last_indexed) = repo.last_indexed_at
228 && let Ok(elapsed) = now.duration_since(last_indexed)
229 {
230 return elapsed > threshold;
231 }
232 // Never indexed or future timestamp
233 repo.last_indexed_at.is_none()
234 })
235 .collect()
236 }
237
238 /// Calculate a health score (0.0-1.0) based on freshness.
239 ///
240 /// Score factors:
241 /// - Fresh repos (< 1 hour): 1.0 weight
242 /// - Recent repos (< 1 day): 0.8 weight
243 /// - Stale repos (< 1 week): 0.5 weight
244 /// - Very stale repos (> 1 week): 0.2 weight
245 /// - Never indexed: 0.0 weight
246 #[must_use]
247 #[allow(
248 clippy::cast_precision_loss,
249 reason = "Freshness scoring is informational; f64 is adequate for UX metrics"
250 )]
251 pub fn health_score(&self) -> f64 {
252 if self.total_repos == 0 {
253 return 1.0;
254 }
255
256 // Casts to f64 are lossy for very large counts; acceptable for display-level scoring.
257 #[allow(
258 clippy::cast_precision_loss,
259 reason = "Freshness scoring is informational; f64 is adequate for UX metrics"
260 )]
261 let score = (self.freshness.fresh as f64 * 1.0)
262 + (self.freshness.recent as f64 * 0.8)
263 + (self.freshness.stale as f64 * 0.5)
264 + (self.freshness.very_stale as f64 * 0.2)
265 + (self.freshness.never_indexed as f64 * 0.0);
266
267 score / self.total_repos as f64
268 }
269
270 /// Get a human-readable health status.
271 #[must_use]
272 pub fn health_status(&self) -> &'static str {
273 let score = self.health_score();
274 match score {
275 s if s >= 0.9 => "Excellent",
276 s if s >= 0.7 => "Good",
277 s if s >= 0.5 => "Fair",
278 s if s >= 0.3 => "Poor",
279 _ => "Critical",
280 }
281 }
282}
283
284#[cfg(test)]
285mod tests {
286 use super::*;
287 use crate::graph::unified::persistence::{BuildProvenance, Manifest};
288 use crate::workspace::WorkspaceRepoId;
289 use std::path::PathBuf;
290 use tempfile::tempdir;
291
292 fn create_test_repo(name: &str, indexed_ago: Option<Duration>) -> WorkspaceRepository {
293 let last_indexed_at = indexed_ago.map(|duration| SystemTime::now() - duration);
294 WorkspaceRepository {
295 id: WorkspaceRepoId::new(name),
296 name: name.to_string(),
297 root: PathBuf::from(format!("/workspace/{name}")),
298 index_path: PathBuf::from(format!("/workspace/{name}/.sqry-index")),
299 last_indexed_at,
300 symbol_count_at_registration: if indexed_ago.is_some() {
301 Some(100)
302 } else {
303 None
304 },
305 primary_language: Some("rust".to_string()),
306 }
307 }
308
309 /// Resolver mirroring the pre-#515-staleness-fix behavior: reads the
310 /// registration-time snapshot field directly, with no filesystem
311 /// access. Used by tests that only exercise the aggregation logic
312 /// (freshness split, `total_symbols` sum, `unknown_symbol_count_repos`
313 /// bucketing, `avg_symbols_per_repo` denominator) and don't need a
314 /// real `.sqry/graph/manifest.json` fixture on disk.
315 fn resolve_from_registration_snapshot(repo: &WorkspaceRepository) -> Option<u64> {
316 repo.symbol_count_at_registration
317 }
318
319 #[test]
320 fn test_freshness_buckets() {
321 let mut registry = WorkspaceRegistry::new(Some("Test".into()));
322
323 // Fresh (< 1 hour)
324 registry
325 .upsert_repo(create_test_repo("fresh", Some(Duration::from_secs(1800))))
326 .unwrap();
327
328 // Recent (< 1 day)
329 registry
330 .upsert_repo(create_test_repo("recent", Some(Duration::from_secs(7200))))
331 .unwrap();
332
333 // Stale (< 1 week)
334 registry
335 .upsert_repo(create_test_repo(
336 "stale",
337 Some(Duration::from_secs(172_800)),
338 ))
339 .unwrap();
340
341 // Very stale (> 1 week)
342 registry
343 .upsert_repo(create_test_repo(
344 "very-stale",
345 Some(Duration::from_secs(691_200)),
346 ))
347 .unwrap();
348
349 // Never indexed
350 registry
351 .upsert_repo(create_test_repo("never", None))
352 .unwrap();
353
354 let stats = DetailedWorkspaceStats::from_registry(®istry);
355
356 assert_eq!(stats.freshness.fresh, 1);
357 assert_eq!(stats.freshness.recent, 1);
358 assert_eq!(stats.freshness.stale, 1);
359 assert_eq!(stats.freshness.very_stale, 1);
360 assert_eq!(stats.freshness.never_indexed, 1);
361 assert_eq!(stats.total_repos, 5);
362 assert_eq!(stats.indexed_repos, 4);
363 assert_eq!(stats.unindexed_repos, 1);
364 }
365
366 #[test]
367 fn test_health_score() {
368 let mut registry = WorkspaceRegistry::new(Some("Test".into()));
369
370 // All fresh repos
371 for i in 0..5 {
372 registry
373 .upsert_repo(create_test_repo(
374 &format!("repo-{i}"),
375 Some(Duration::from_secs(1800)),
376 ))
377 .unwrap();
378 }
379
380 let stats = DetailedWorkspaceStats::from_registry(®istry);
381 assert!(stats.health_score() >= 0.9);
382 assert_eq!(stats.health_status(), "Excellent");
383 }
384
385 #[test]
386 fn test_stale_repos() {
387 let mut registry = WorkspaceRegistry::new(Some("Test".into()));
388
389 // Fresh repo
390 registry
391 .upsert_repo(create_test_repo("fresh", Some(Duration::from_secs(1800))))
392 .unwrap();
393
394 // Stale repo (3 days old)
395 registry
396 .upsert_repo(create_test_repo(
397 "stale",
398 Some(Duration::from_secs(259_200)),
399 ))
400 .unwrap();
401
402 let stats = DetailedWorkspaceStats::from_registry(®istry);
403 let stale = stats.stale_repos(®istry, Duration::from_secs(86400)); // 1 day threshold
404
405 assert_eq!(stale.len(), 1);
406 assert_eq!(stale[0].name, "stale");
407 }
408
409 /// Issue #515 edge case: an indexed repo whose resolved symbol count
410 /// is `None` (manifest missing/corrupt) must be excluded from
411 /// `total_symbols` and the `avg_symbols_per_repo` denominator, and
412 /// reported via `unknown_symbol_count_repos`, rather than silently
413 /// treated as a zero-symbol repo that drags the average down.
414 ///
415 /// Uses [`from_registry_with_resolver`] with
416 /// [`resolve_from_registration_snapshot`] so it exercises the
417 /// aggregation logic in isolation, without needing real
418 /// `.sqry/graph/manifest.json` fixtures on disk (that live-read path
419 /// is covered separately by
420 /// `test_from_registry_reads_symbol_count_live_not_cached` below).
421 #[test]
422 fn test_unknown_symbol_count_repo_excluded_from_average() {
423 let mut registry = WorkspaceRegistry::new(Some("Test".into()));
424
425 // Two indexed repos with known counts.
426 registry
427 .upsert_repo(create_test_repo("known-a", Some(Duration::from_secs(60))))
428 .unwrap();
429 registry
430 .upsert_repo(create_test_repo("known-b", Some(Duration::from_secs(60))))
431 .unwrap();
432
433 // One indexed repo whose manifest could not be read: last_indexed_at
434 // is set (mtime of the manifest file was still readable) but the
435 // resolved symbol count is None.
436 let mut unknown_repo = create_test_repo("unknown", Some(Duration::from_secs(60)));
437 unknown_repo.symbol_count_at_registration = None;
438 registry.upsert_repo(unknown_repo).unwrap();
439
440 // One never-indexed repo: must not be double-counted into
441 // unknown_symbol_count_repos, it already has its own bucket.
442 registry
443 .upsert_repo(create_test_repo("never", None))
444 .unwrap();
445
446 let stats = DetailedWorkspaceStats::from_registry_with_resolver(
447 ®istry,
448 resolve_from_registration_snapshot,
449 );
450
451 assert_eq!(stats.total_repos, 4);
452 assert_eq!(stats.indexed_repos, 3);
453 assert_eq!(stats.unindexed_repos, 1);
454 assert_eq!(
455 stats.total_symbols, 200,
456 "total_symbols must sum only the repos with a known count (100 + 100)"
457 );
458 assert_eq!(
459 stats.unknown_symbol_count_repos, 1,
460 "the corrupt-manifest repo must be counted separately, not folded into total_symbols as 0"
461 );
462 assert!(
463 (stats.avg_symbols_per_repo - 100.0).abs() < f64::EPSILON,
464 "average must divide by the 2 repos with a known count (200 / 2 = 100), \
465 not by all 3 indexed repos (which would wrongly yield 66.67): got {}",
466 stats.avg_symbols_per_repo
467 );
468 }
469
470 /// Issue #515 staleness regression (the cross-LLM gate's blocker on
471 /// the original fix): `DetailedWorkspaceStats::from_registry` must
472 /// read each member's symbol count *live* from its
473 /// `.sqry/graph/manifest.json` at stats-computation time, not from
474 /// the registry's `symbol_count_at_registration` snapshot.
475 ///
476 /// Before this fix, `from_registry` read
477 /// `WorkspaceRepository::symbol_count` directly (the same field this
478 /// test renamed to `symbol_count_at_registration`), a value only
479 /// refreshed by `discover_repositories` / `sqry workspace add`. A
480 /// direct `sqry index --force` reindex of a member changes
481 /// `.sqry/graph/manifest.json`'s `node_count` without touching the
482 /// workspace registry at all, so `stats` reported the old count
483 /// forever until the member was removed and re-added, even though
484 /// `sqry workspace query` (which loads member graphs live via
485 /// `SessionManager`) already reflected the new one.
486 ///
487 /// This test reindexes the manifest between two `from_registry` calls
488 /// with no registry mutation in between (mirroring the exact
489 /// reproduction the gate used: 8 -> 18 nodes without `workspace add`
490 /// / `remove`) and asserts the second call's `total_symbols` tracks
491 /// the new manifest, not the untouched registration-time snapshot.
492 /// This fails against the pre-fix code (which would still report 8).
493 #[test]
494 fn test_from_registry_reads_symbol_count_live_not_cached() {
495 let temp = tempdir().unwrap();
496 let root = temp.path();
497
498 let repo_dir = root.join("member");
499 let graph_dir = repo_dir.join(".sqry/graph");
500 std::fs::create_dir_all(&graph_dir).unwrap();
501
502 let write_manifest = |node_count: usize| {
503 let manifest = Manifest::new(
504 repo_dir.display().to_string(),
505 node_count,
506 node_count * 2,
507 "test-sha256",
508 BuildProvenance::new("test", "test"),
509 );
510 manifest.save(graph_dir.join("manifest.json")).unwrap();
511 };
512
513 // Initial index: 8 symbols.
514 write_manifest(8);
515
516 let mut registry = WorkspaceRegistry::new(Some("Test".into()));
517 let mut repo = WorkspaceRepository::new(
518 WorkspaceRepoId::new("member"),
519 "member".to_string(),
520 repo_dir.clone(),
521 graph_dir.join("manifest.json"),
522 Some(SystemTime::now()),
523 );
524 // Registration-time snapshot matches the initial manifest, then
525 // is deliberately never touched again for the rest of the test
526 // (no `discover_repositories` / `workspace add` re-run), exactly
527 // like a real `sqry index --force` reindex that never goes
528 // through `workspace add`/`remove`.
529 repo.symbol_count_at_registration = Some(8);
530 registry.upsert_repo(repo).unwrap();
531
532 let stats_before = DetailedWorkspaceStats::from_registry(®istry);
533 assert_eq!(
534 stats_before.total_symbols, 8,
535 "sanity check: initial stats must match the initial manifest node_count"
536 );
537
538 // Reindex the member directly: the manifest's node_count changes
539 // to 18, but nothing touches the workspace registry.
540 write_manifest(18);
541
542 let stats_after = DetailedWorkspaceStats::from_registry(®istry);
543 assert_eq!(
544 stats_after.total_symbols, 18,
545 "stats must reflect the reindexed manifest's node_count (18) even though \
546 the registry's symbol_count_at_registration snapshot is still stuck at 8; \
547 this is the issue #515 staleness gap the gate flagged"
548 );
549 assert_eq!(
550 stats_after.unknown_symbol_count_repos, 0,
551 "the reindexed manifest is still readable, so the count stays known"
552 );
553 assert!(
554 (stats_after.avg_symbols_per_repo - 18.0).abs() < f64::EPSILON,
555 "avg must also reflect the live count: got {}",
556 stats_after.avg_symbols_per_repo
557 );
558 }
559}