use std::{
collections::HashMap,
sync::{Arc, Mutex},
time::{Duration, SystemTime, UNIX_EPOCH},
};
use serde::{Deserialize, Serialize};
#[derive(Debug)]
pub enum DiscoveryError {
LockPoisoned,
WorkspaceError(String),
}
impl std::fmt::Display for DiscoveryError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
DiscoveryError::LockPoisoned => write!(f, "discovery lock poisoned"),
DiscoveryError::WorkspaceError(msg) => write!(f, "workspace error: {msg}"),
}
}
}
impl std::error::Error for DiscoveryError {}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum FindingCategory {
DeadCode,
Dependency,
TestCoverage,
Performance,
ApiSurface,
}
impl FindingCategory {
pub fn label(self) -> &'static str {
match self {
Self::DeadCode => "dead-code",
Self::Dependency => "dependency",
Self::TestCoverage => "test-coverage",
Self::Performance => "performance",
Self::ApiSurface => "api-surface",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DiscoveryFinding {
pub id: String,
pub category: FindingCategory,
pub title: String,
pub description: String,
pub affected_files: Vec<String>,
pub suggested_action: String,
pub estimated_effort: String,
pub discovered_at_secs: u64,
pub resolved: bool,
}
impl DiscoveryFinding {
pub fn to_markdown(&self) -> String {
format!(
"### [{cat}] {title}\n\n\
**ID**: `{id}` \n\
**Effort**: {effort} \n\
**Status**: {status}\n\n\
{desc}\n\n\
**Suggested action**: {action}\n\n\
**Affected files**:\n{files}\n",
cat = self.category.label(),
title = self.title,
id = self.id,
effort = self.estimated_effort,
status = if self.resolved { "Resolved" } else { "Open" },
desc = self.description,
action = self.suggested_action,
files = self
.affected_files
.iter()
.map(|f| format!("- `{f}`"))
.collect::<Vec<_>>()
.join("\n"),
)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ScanResult {
pub findings: Vec<DiscoveryFinding>,
pub scan_duration_ms: u64,
pub scanned_at_secs: u64,
pub categories_scanned: Vec<FindingCategory>,
}
#[derive(Debug, Clone)]
pub struct DiscoveryConfig {
pub workspace_path: String,
pub enabled_categories: Vec<FindingCategory>,
pub scan_interval: Duration,
pub ignore_paths: Vec<String>,
}
impl Default for DiscoveryConfig {
fn default() -> Self {
Self {
workspace_path: ".".to_string(),
enabled_categories: vec![
FindingCategory::DeadCode,
FindingCategory::Dependency,
FindingCategory::TestCoverage,
FindingCategory::Performance,
FindingCategory::ApiSurface,
],
scan_interval: Duration::from_secs(3600),
ignore_paths: vec!["target/".to_string(), ".git/".to_string()],
}
}
}
struct DiscoveryInner {
cfg: DiscoveryConfig,
findings: Vec<DiscoveryFinding>,
scan_history: Vec<ScanResult>,
last_scan_at: Option<u64>,
finding_counter: u64,
}
#[derive(Clone)]
pub struct CapabilityDiscovery {
inner: Arc<Mutex<DiscoveryInner>>,
}
impl CapabilityDiscovery {
pub fn new(cfg: DiscoveryConfig) -> Self {
Self {
inner: Arc::new(Mutex::new(DiscoveryInner {
cfg,
findings: Vec::new(),
scan_history: Vec::new(),
last_scan_at: None,
finding_counter: 0,
})),
}
}
pub fn scan(&self) -> Result<ScanResult, DiscoveryError> {
let start = std::time::Instant::now();
let now = unix_now();
let (workspace, categories, ignore) = {
let inner = self
.inner
.lock()
.map_err(|_| DiscoveryError::LockPoisoned)?;
(
inner.cfg.workspace_path.clone(),
inner.cfg.enabled_categories.clone(),
inner.cfg.ignore_paths.clone(),
)
};
let mut new_findings: Vec<DiscoveryFinding> = Vec::new();
for category in &categories {
let mut cat_findings = self.scan_category(*category, &workspace, &ignore)?;
new_findings.append(&mut cat_findings);
}
let elapsed = start.elapsed().as_millis() as u64;
let result = ScanResult {
findings: new_findings.clone(),
scan_duration_ms: elapsed,
scanned_at_secs: now,
categories_scanned: categories,
};
let mut inner = self
.inner
.lock()
.map_err(|_| DiscoveryError::LockPoisoned)?;
for f in new_findings {
inner.findings.push(f);
}
if inner.scan_history.len() >= 100 {
inner.scan_history.remove(0);
}
inner.scan_history.push(result.clone());
inner.last_scan_at = Some(now);
Ok(result)
}
pub fn open_findings(&self) -> Vec<DiscoveryFinding> {
self.inner
.lock()
.map(|inner| {
inner
.findings
.iter()
.filter(|f| !f.resolved)
.cloned()
.collect()
})
.unwrap_or_default()
}
pub fn findings_by_category(&self, category: FindingCategory) -> Vec<DiscoveryFinding> {
self.inner
.lock()
.map(|inner| {
inner
.findings
.iter()
.filter(|f| f.category == category)
.cloned()
.collect()
})
.unwrap_or_default()
}
pub fn resolve(&self, finding_id: &str) -> Result<bool, DiscoveryError> {
let mut inner = self
.inner
.lock()
.map_err(|_| DiscoveryError::LockPoisoned)?;
if let Some(f) = inner.findings.iter_mut().find(|f| f.id == finding_id) {
f.resolved = true;
return Ok(true);
}
Ok(false)
}
pub fn register_finding(
&self,
category: FindingCategory,
title: impl Into<String>,
description: impl Into<String>,
affected_files: Vec<String>,
suggested_action: impl Into<String>,
effort: impl Into<String>,
) -> Result<String, DiscoveryError> {
let mut inner = self
.inner
.lock()
.map_err(|_| DiscoveryError::LockPoisoned)?;
inner.finding_counter += 1;
let id = format!("finding-{:06}", inner.finding_counter);
inner.findings.push(DiscoveryFinding {
id: id.clone(),
category,
title: title.into(),
description: description.into(),
affected_files,
suggested_action: suggested_action.into(),
estimated_effort: effort.into(),
discovered_at_secs: unix_now(),
resolved: false,
});
Ok(id)
}
pub fn summary(&self) -> HashMap<String, usize> {
let findings = self.open_findings();
let mut map: HashMap<String, usize> = HashMap::new();
for f in findings {
*map.entry(f.category.label().to_string()).or_insert(0) += 1;
}
map
}
pub fn last_scan(&self) -> Option<ScanResult> {
self.inner
.lock()
.ok()
.and_then(|inner| inner.scan_history.last().cloned())
}
pub fn last_scan_at(&self) -> Option<u64> {
self.inner.lock().ok().and_then(|inner| inner.last_scan_at)
}
pub fn total_finding_count(&self) -> usize {
self.inner
.lock()
.map(|inner| inner.findings.len())
.unwrap_or(0)
}
fn scan_category(
&self,
category: FindingCategory,
_workspace: &str,
_ignore: &[String],
) -> Result<Vec<DiscoveryFinding>, DiscoveryError> {
match category {
FindingCategory::DeadCode => Ok(self.scan_dead_code()),
FindingCategory::Dependency => Ok(self.scan_dependencies()),
FindingCategory::TestCoverage => Ok(self.scan_test_coverage()),
FindingCategory::Performance => Ok(self.scan_performance()),
FindingCategory::ApiSurface => Ok(self.scan_api_surface()),
}
}
fn scan_dead_code(&self) -> Vec<DiscoveryFinding> {
Vec::new()
}
fn scan_dependencies(&self) -> Vec<DiscoveryFinding> {
Vec::new()
}
fn scan_test_coverage(&self) -> Vec<DiscoveryFinding> {
Vec::new()
}
fn scan_performance(&self) -> Vec<DiscoveryFinding> {
Vec::new()
}
fn scan_api_surface(&self) -> Vec<DiscoveryFinding> {
Vec::new()
}
}
fn unix_now() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
#[cfg(test)]
mod tests {
use super::*;
fn make_scanner() -> CapabilityDiscovery {
CapabilityDiscovery::new(DiscoveryConfig::default())
}
#[test]
fn test_scan_completes_without_error() {
let s = make_scanner();
assert!(s.scan().is_ok());
}
#[test]
fn test_scan_stores_result() {
let s = make_scanner();
s.scan().unwrap();
assert!(s.last_scan().is_some());
}
#[test]
fn test_last_scan_at_set_after_scan() {
let s = make_scanner();
s.scan().unwrap();
assert!(s.last_scan_at().is_some());
}
#[test]
fn test_register_finding_returns_id() {
let s = make_scanner();
let id = s
.register_finding(
FindingCategory::DeadCode,
"Unused fn foo",
"fn foo is never called",
vec!["src/foo.rs".into()],
"Remove the function",
"low",
)
.unwrap();
assert!(!id.is_empty());
}
#[test]
fn test_open_findings_contains_registered() {
let s = make_scanner();
s.register_finding(FindingCategory::DeadCode, "t", "d", vec![], "a", "low")
.unwrap();
assert_eq!(s.open_findings().len(), 1);
}
#[test]
fn test_resolve_finding_removes_from_open() {
let s = make_scanner();
let id = s
.register_finding(FindingCategory::DeadCode, "t", "d", vec![], "a", "low")
.unwrap();
s.resolve(&id).unwrap();
assert!(s.open_findings().is_empty());
}
#[test]
fn test_resolve_nonexistent_returns_false() {
let s = make_scanner();
let result = s.resolve("does-not-exist").unwrap();
assert!(!result);
}
#[test]
fn test_findings_by_category_filters() {
let s = make_scanner();
s.register_finding(FindingCategory::DeadCode, "d1", "x", vec![], "a", "low")
.unwrap();
s.register_finding(FindingCategory::Dependency, "dep1", "y", vec![], "a", "low")
.unwrap();
let dead = s.findings_by_category(FindingCategory::DeadCode);
assert_eq!(dead.len(), 1);
assert_eq!(dead[0].category, FindingCategory::DeadCode);
}
#[test]
fn test_summary_counts_by_category() {
let s = make_scanner();
s.register_finding(FindingCategory::DeadCode, "a", "x", vec![], "act", "low")
.unwrap();
s.register_finding(FindingCategory::DeadCode, "b", "x", vec![], "act", "low")
.unwrap();
s.register_finding(FindingCategory::Dependency, "c", "x", vec![], "act", "low")
.unwrap();
let summary = s.summary();
assert_eq!(*summary.get("dead-code").unwrap_or(&0), 2);
assert_eq!(*summary.get("dependency").unwrap_or(&0), 1);
}
#[test]
fn test_total_finding_count_includes_resolved() {
let s = make_scanner();
let id = s
.register_finding(FindingCategory::DeadCode, "a", "d", vec![], "x", "low")
.unwrap();
s.resolve(&id).unwrap();
assert_eq!(s.total_finding_count(), 1);
}
#[test]
fn test_finding_to_markdown_contains_title() {
let f = DiscoveryFinding {
id: "f-1".into(),
category: FindingCategory::Performance,
title: "Hot loop in interceptor".into(),
description: "interceptor check is called 100k/s".into(),
affected_files: vec!["src/interceptor.rs".into()],
suggested_action: "Cache the result".into(),
estimated_effort: "medium".into(),
discovered_at_secs: 0,
resolved: false,
};
let md = f.to_markdown();
assert!(md.contains("Hot loop in interceptor"));
assert!(md.contains("performance"));
}
#[test]
fn test_finding_category_label() {
assert_eq!(FindingCategory::DeadCode.label(), "dead-code");
assert_eq!(FindingCategory::TestCoverage.label(), "test-coverage");
assert_eq!(FindingCategory::ApiSurface.label(), "api-surface");
}
#[test]
fn test_scanner_clone_shares_findings() {
let s = make_scanner();
let s2 = s.clone();
s.register_finding(FindingCategory::DeadCode, "x", "y", vec![], "z", "low")
.unwrap();
assert_eq!(s2.open_findings().len(), 1);
}
#[test]
fn test_finding_id_increments() {
let s = make_scanner();
let id1 = s
.register_finding(FindingCategory::DeadCode, "a", "d", vec![], "x", "low")
.unwrap();
let id2 = s
.register_finding(FindingCategory::DeadCode, "b", "d", vec![], "x", "low")
.unwrap();
assert_ne!(id1, id2);
}
#[test]
fn test_config_default_includes_all_categories() {
let cfg = DiscoveryConfig::default();
assert_eq!(cfg.enabled_categories.len(), 5);
}
#[test]
fn test_scan_result_fields_present() {
let s = make_scanner();
let result = s.scan().unwrap();
assert!(result.scanned_at_secs > 0);
assert!(!result.categories_scanned.is_empty());
}
#[test]
fn test_scan_history_capped_at_100() {
let s = make_scanner();
for _ in 0..105 {
s.scan().unwrap();
}
let inner = s.inner.lock().unwrap();
assert!(inner.scan_history.len() <= 100);
}
#[test]
fn test_resolved_finding_shows_in_markdown() {
let mut f = DiscoveryFinding {
id: "f-2".into(),
category: FindingCategory::DeadCode,
title: "Test".into(),
description: String::new(),
affected_files: vec![],
suggested_action: String::new(),
estimated_effort: String::new(),
discovered_at_secs: 0,
resolved: true,
};
let md = f.to_markdown();
assert!(md.contains("Resolved"));
f.resolved = false;
let md2 = f.to_markdown();
assert!(md2.contains("Open"));
}
#[test]
fn test_discovery_error_display() {
let e = DiscoveryError::LockPoisoned;
assert!(e.to_string().contains("lock poisoned"));
let e2 = DiscoveryError::WorkspaceError("not found".into());
assert!(e2.to_string().contains("not found"));
}
#[test]
fn test_finding_serde_roundtrip() {
let f = DiscoveryFinding {
id: "f-1".into(),
category: FindingCategory::DeadCode,
title: "test".into(),
description: "desc".into(),
affected_files: vec!["a.rs".into()],
suggested_action: "remove".into(),
estimated_effort: "low".into(),
discovered_at_secs: 100,
resolved: false,
};
let json = serde_json::to_string(&f).unwrap();
let back: DiscoveryFinding = serde_json::from_str(&json).unwrap();
assert_eq!(back.id, "f-1");
assert_eq!(back.category, FindingCategory::DeadCode);
}
#[test]
fn test_scan_result_serde_roundtrip() {
let r = ScanResult {
findings: vec![],
scan_duration_ms: 42,
scanned_at_secs: 100,
categories_scanned: vec![FindingCategory::DeadCode],
};
let json = serde_json::to_string(&r).unwrap();
let back: ScanResult = serde_json::from_str(&json).unwrap();
assert_eq!(back.scan_duration_ms, 42);
}
}