Skip to main content

sqry_core/workspace/
stats.rs

1//! Workspace statistics and staleness tracking.
2
3use 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/// Detailed workspace statistics including staleness tracking.
22#[derive(Debug, Clone)]
23pub struct DetailedWorkspaceStats {
24    /// Total number of repositories in the workspace.
25    pub total_repos: usize,
26    /// Number of repositories that have been indexed.
27    pub indexed_repos: usize,
28    /// Number of repositories that have never been indexed.
29    pub unindexed_repos: usize,
30    /// Total symbol count across all indexed repositories.
31    pub total_symbols: u64,
32    /// Freshness buckets categorizing repositories by age.
33    pub freshness: FreshnessBuckets,
34    /// Average symbols per indexed repository.
35    pub avg_symbols_per_repo: f64,
36}
37
38/// Freshness buckets categorizing repositories by last indexed time.
39#[derive(Debug, Clone, Default)]
40pub struct FreshnessBuckets {
41    /// Repositories indexed within the last hour.
42    pub fresh: usize,
43    /// Repositories indexed within the last 24 hours.
44    pub recent: usize,
45    /// Repositories indexed within the last 7 days.
46    pub stale: usize,
47    /// Repositories indexed more than 7 days ago.
48    pub very_stale: usize,
49    /// Repositories that have never been indexed.
50    pub never_indexed: usize,
51}
52
53impl FreshnessBuckets {
54    /// Calculate freshness buckets from a registry.
55    #[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 &registry.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                    // Future timestamp (clock skew) - treat as fresh
66                    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    /// Get the total number of indexed repositories across all buckets.
93    #[must_use]
94    pub fn indexed_total(&self) -> usize {
95        self.fresh + self.recent + self.stale + self.very_stale
96    }
97
98    /// Get the total number of repositories (including never indexed).
99    #[must_use]
100    pub fn total(&self) -> usize {
101        self.indexed_total() + self.never_indexed
102    }
103}
104
105impl DetailedWorkspaceStats {
106    /// Compute detailed statistics from a workspace registry.
107    #[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    /// Get repositories that need reindexing (older than threshold).
142    #[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                // Never indexed or future timestamp
159                repo.last_indexed_at.is_none()
160            })
161            .collect()
162    }
163
164    /// Calculate a health score (0.0-1.0) based on freshness.
165    ///
166    /// Score factors:
167    /// - Fresh repos (< 1 hour): 1.0 weight
168    /// - Recent repos (< 1 day): 0.8 weight
169    /// - Stale repos (< 1 week): 0.5 weight
170    /// - Very stale repos (> 1 week): 0.2 weight
171    /// - Never indexed: 0.0 weight
172    #[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        // Casts to f64 are lossy for very large counts; acceptable for display-level scoring.
183        #[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    /// Get a human-readable health status.
197    #[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        // Fresh (< 1 hour)
238        registry
239            .upsert_repo(create_test_repo("fresh", Some(Duration::from_secs(1800))))
240            .unwrap();
241
242        // Recent (< 1 day)
243        registry
244            .upsert_repo(create_test_repo("recent", Some(Duration::from_secs(7200))))
245            .unwrap();
246
247        // Stale (< 1 week)
248        registry
249            .upsert_repo(create_test_repo(
250                "stale",
251                Some(Duration::from_secs(172_800)),
252            ))
253            .unwrap();
254
255        // Very stale (> 1 week)
256        registry
257            .upsert_repo(create_test_repo(
258                "very-stale",
259                Some(Duration::from_secs(691_200)),
260            ))
261            .unwrap();
262
263        // Never indexed
264        registry
265            .upsert_repo(create_test_repo("never", None))
266            .unwrap();
267
268        let stats = DetailedWorkspaceStats::from_registry(&registry);
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        // All fresh repos
285        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(&registry);
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        // Fresh repo
304        registry
305            .upsert_repo(create_test_repo("fresh", Some(Duration::from_secs(1800))))
306            .unwrap();
307
308        // Stale repo (3 days old)
309        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(&registry);
317        let stale = stats.stale_repos(&registry, Duration::from_secs(86400)); // 1 day threshold
318
319        assert_eq!(stale.len(), 1);
320        assert_eq!(stale[0].name, "stale");
321    }
322}