Skip to main content

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 &registry.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 &registry.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(&registry);
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(&registry);
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(&registry);
403        let stale = stats.stale_repos(&registry, 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            &registry,
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(&registry);
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(&registry);
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}