use std::time::{Duration, SystemTime};
use super::registry::{WorkspaceRegistry, WorkspaceRepository};
fn u64_to_f64(value: u64) -> f64 {
#[allow(clippy::cast_precision_loss)]
{
value as f64
}
}
fn usize_to_f64(value: usize) -> f64 {
#[allow(clippy::cast_precision_loss)]
{
value as f64
}
}
#[derive(Debug, Clone)]
pub struct DetailedWorkspaceStats {
pub total_repos: usize,
pub indexed_repos: usize,
pub unindexed_repos: usize,
pub total_symbols: u64,
pub freshness: FreshnessBuckets,
pub avg_symbols_per_repo: f64,
}
#[derive(Debug, Clone, Default)]
pub struct FreshnessBuckets {
pub fresh: usize,
pub recent: usize,
pub stale: usize,
pub very_stale: usize,
pub never_indexed: usize,
}
impl FreshnessBuckets {
#[must_use]
pub fn from_registry(registry: &WorkspaceRegistry) -> Self {
let now = SystemTime::now();
let mut buckets = Self::default();
for repo in ®istry.repositories {
if let Some(last_indexed) = repo.last_indexed_at {
if let Ok(elapsed) = now.duration_since(last_indexed) {
buckets.categorize(elapsed);
} else {
buckets.fresh += 1;
}
} else {
buckets.never_indexed += 1;
}
}
buckets
}
fn categorize(&mut self, elapsed: Duration) {
const HOUR: Duration = Duration::from_secs(3600);
const DAY: Duration = Duration::from_secs(86400);
const WEEK: Duration = Duration::from_secs(604_800);
if elapsed < HOUR {
self.fresh += 1;
} else if elapsed < DAY {
self.recent += 1;
} else if elapsed < WEEK {
self.stale += 1;
} else {
self.very_stale += 1;
}
}
#[must_use]
pub fn indexed_total(&self) -> usize {
self.fresh + self.recent + self.stale + self.very_stale
}
#[must_use]
pub fn total(&self) -> usize {
self.indexed_total() + self.never_indexed
}
}
impl DetailedWorkspaceStats {
#[must_use]
pub fn from_registry(registry: &WorkspaceRegistry) -> Self {
let total_repos = registry.repositories.len();
let indexed_repos = registry
.repositories
.iter()
.filter(|r| r.last_indexed_at.is_some())
.count();
let unindexed_repos = total_repos - indexed_repos;
let total_symbols: u64 = registry
.repositories
.iter()
.filter_map(|r| r.symbol_count)
.sum();
let avg_symbols_per_repo = if indexed_repos > 0 {
u64_to_f64(total_symbols) / usize_to_f64(indexed_repos)
} else {
0.0
};
let freshness = FreshnessBuckets::from_registry(registry);
Self {
total_repos,
indexed_repos,
unindexed_repos,
total_symbols,
freshness,
avg_symbols_per_repo,
}
}
#[must_use]
pub fn stale_repos<'a>(
&self,
registry: &'a WorkspaceRegistry,
threshold: Duration,
) -> Vec<&'a WorkspaceRepository> {
let now = SystemTime::now();
registry
.repositories
.iter()
.filter(|repo| {
if let Some(last_indexed) = repo.last_indexed_at
&& let Ok(elapsed) = now.duration_since(last_indexed)
{
return elapsed > threshold;
}
repo.last_indexed_at.is_none()
})
.collect()
}
#[must_use]
#[allow(
clippy::cast_precision_loss,
reason = "Freshness scoring is informational; f64 is adequate for UX metrics"
)]
pub fn health_score(&self) -> f64 {
if self.total_repos == 0 {
return 1.0;
}
#[allow(
clippy::cast_precision_loss,
reason = "Freshness scoring is informational; f64 is adequate for UX metrics"
)]
let score = (self.freshness.fresh as f64 * 1.0)
+ (self.freshness.recent as f64 * 0.8)
+ (self.freshness.stale as f64 * 0.5)
+ (self.freshness.very_stale as f64 * 0.2)
+ (self.freshness.never_indexed as f64 * 0.0);
score / self.total_repos as f64
}
#[must_use]
pub fn health_status(&self) -> &'static str {
let score = self.health_score();
match score {
s if s >= 0.9 => "Excellent",
s if s >= 0.7 => "Good",
s if s >= 0.5 => "Fair",
s if s >= 0.3 => "Poor",
_ => "Critical",
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::workspace::WorkspaceRepoId;
use std::path::PathBuf;
fn create_test_repo(name: &str, indexed_ago: Option<Duration>) -> WorkspaceRepository {
let last_indexed_at = indexed_ago.map(|duration| SystemTime::now() - duration);
WorkspaceRepository {
id: WorkspaceRepoId::new(name),
name: name.to_string(),
root: PathBuf::from(format!("/workspace/{name}")),
index_path: PathBuf::from(format!("/workspace/{name}/.sqry-index")),
last_indexed_at,
symbol_count: if indexed_ago.is_some() {
Some(100)
} else {
None
},
primary_language: Some("rust".to_string()),
}
}
#[test]
fn test_freshness_buckets() {
let mut registry = WorkspaceRegistry::new(Some("Test".into()));
registry
.upsert_repo(create_test_repo("fresh", Some(Duration::from_secs(1800))))
.unwrap();
registry
.upsert_repo(create_test_repo("recent", Some(Duration::from_secs(7200))))
.unwrap();
registry
.upsert_repo(create_test_repo(
"stale",
Some(Duration::from_secs(172_800)),
))
.unwrap();
registry
.upsert_repo(create_test_repo(
"very-stale",
Some(Duration::from_secs(691_200)),
))
.unwrap();
registry
.upsert_repo(create_test_repo("never", None))
.unwrap();
let stats = DetailedWorkspaceStats::from_registry(®istry);
assert_eq!(stats.freshness.fresh, 1);
assert_eq!(stats.freshness.recent, 1);
assert_eq!(stats.freshness.stale, 1);
assert_eq!(stats.freshness.very_stale, 1);
assert_eq!(stats.freshness.never_indexed, 1);
assert_eq!(stats.total_repos, 5);
assert_eq!(stats.indexed_repos, 4);
assert_eq!(stats.unindexed_repos, 1);
}
#[test]
fn test_health_score() {
let mut registry = WorkspaceRegistry::new(Some("Test".into()));
for i in 0..5 {
registry
.upsert_repo(create_test_repo(
&format!("repo-{i}"),
Some(Duration::from_secs(1800)),
))
.unwrap();
}
let stats = DetailedWorkspaceStats::from_registry(®istry);
assert!(stats.health_score() >= 0.9);
assert_eq!(stats.health_status(), "Excellent");
}
#[test]
fn test_stale_repos() {
let mut registry = WorkspaceRegistry::new(Some("Test".into()));
registry
.upsert_repo(create_test_repo("fresh", Some(Duration::from_secs(1800))))
.unwrap();
registry
.upsert_repo(create_test_repo(
"stale",
Some(Duration::from_secs(259_200)),
))
.unwrap();
let stats = DetailedWorkspaceStats::from_registry(®istry);
let stale = stats.stale_repos(®istry, Duration::from_secs(86400));
assert_eq!(stale.len(), 1);
assert_eq!(stale[0].name, "stale");
}
}