1use std::collections::BTreeSet;
2use std::fmt;
3use std::hash::{Hash, Hasher};
4use std::path::{Path, PathBuf};
5use std::str::FromStr;
6use std::sync::Arc;
7use std::time::{Duration, SystemTime};
8
9use serde::{Deserialize, Serialize};
10
11use crate::cache_freshness::FileFreshness;
12use crate::config::Config;
13use crate::parser::SharedSymbolCache;
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
16#[serde(rename_all = "snake_case")]
17pub enum InspectCategory {
18 Diagnostics,
19 Metrics,
20 Todos,
21 DeadCode,
22 UnusedExports,
23 Duplicates,
24 Complexity,
25 CircularDeps,
26 OutdatedDeps,
27 Vulnerabilities,
28 TestCoverageGaps,
29 ApiSurface,
30}
31
32impl InspectCategory {
33 pub const ACTIVE: [InspectCategory; 6] = [
34 InspectCategory::Diagnostics,
35 InspectCategory::Metrics,
36 InspectCategory::Todos,
37 InspectCategory::DeadCode,
38 InspectCategory::UnusedExports,
39 InspectCategory::Duplicates,
40 ];
41
42 pub const DISABLED: [InspectCategory; 6] = [
43 InspectCategory::Complexity,
44 InspectCategory::CircularDeps,
45 InspectCategory::OutdatedDeps,
46 InspectCategory::Vulnerabilities,
47 InspectCategory::TestCoverageGaps,
48 InspectCategory::ApiSurface,
49 ];
50
51 pub fn as_str(self) -> &'static str {
52 match self {
53 InspectCategory::Diagnostics => "diagnostics",
54 InspectCategory::Metrics => "metrics",
55 InspectCategory::Todos => "todos",
56 InspectCategory::DeadCode => "dead_code",
57 InspectCategory::UnusedExports => "unused_exports",
58 InspectCategory::Duplicates => "duplicates",
59 InspectCategory::Complexity => "complexity",
60 InspectCategory::CircularDeps => "circular_deps",
61 InspectCategory::OutdatedDeps => "outdated_deps",
62 InspectCategory::Vulnerabilities => "vulnerabilities",
63 InspectCategory::TestCoverageGaps => "test_coverage_gaps",
64 InspectCategory::ApiSurface => "api_surface",
65 }
66 }
67
68 pub fn tier(self) -> InspectTier {
69 match self {
70 InspectCategory::Diagnostics | InspectCategory::Metrics | InspectCategory::Todos => {
71 InspectTier::Tier1
72 }
73 InspectCategory::DeadCode
74 | InspectCategory::UnusedExports
75 | InspectCategory::Duplicates
76 | InspectCategory::Complexity
77 | InspectCategory::CircularDeps
78 | InspectCategory::ApiSurface => InspectTier::Tier2,
79 InspectCategory::OutdatedDeps
80 | InspectCategory::Vulnerabilities
81 | InspectCategory::TestCoverageGaps => InspectTier::Tier3,
82 }
83 }
84
85 pub fn is_tier2(self) -> bool {
86 self.tier() == InspectTier::Tier2
87 }
88
89 pub fn is_active(self) -> bool {
90 Self::ACTIVE.contains(&self)
91 }
92
93 pub fn active() -> &'static [InspectCategory] {
94 &Self::ACTIVE
95 }
96
97 pub fn disabled() -> &'static [InspectCategory] {
98 &Self::DISABLED
99 }
100}
101
102impl fmt::Display for InspectCategory {
103 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
104 formatter.write_str(self.as_str())
105 }
106}
107
108impl FromStr for InspectCategory {
109 type Err = InspectCategoryParseError;
110
111 fn from_str(value: &str) -> Result<Self, Self::Err> {
112 match value {
113 "diagnostics" => Ok(Self::Diagnostics),
114 "metrics" => Ok(Self::Metrics),
115 "todos" => Ok(Self::Todos),
116 "dead_code" => Ok(Self::DeadCode),
117 "unused_exports" => Ok(Self::UnusedExports),
118 "duplicates" => Ok(Self::Duplicates),
119 "complexity" => Ok(Self::Complexity),
120 "circular_deps" => Ok(Self::CircularDeps),
121 "outdated_deps" => Ok(Self::OutdatedDeps),
122 "vulnerabilities" => Ok(Self::Vulnerabilities),
123 "test_coverage_gaps" => Ok(Self::TestCoverageGaps),
124 "api_surface" => Ok(Self::ApiSurface),
125 other => Err(InspectCategoryParseError(other.to_string())),
126 }
127 }
128}
129
130#[derive(Debug, Clone, PartialEq, Eq)]
131pub struct InspectCategoryParseError(String);
132
133impl fmt::Display for InspectCategoryParseError {
134 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
135 write!(formatter, "unknown inspect category '{}'", self.0)
136 }
137}
138
139impl std::error::Error for InspectCategoryParseError {}
140
141#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
142#[serde(rename_all = "snake_case")]
143pub enum InspectTier {
144 Tier1,
145 Tier2,
146 Tier3,
147}
148
149#[derive(Debug, Clone, PartialEq, Eq)]
150pub struct JobScope {
151 project_root: PathBuf,
152 roots: Vec<PathBuf>,
153 scope_hash: String,
154}
155
156impl JobScope {
157 pub fn for_project(project_root: impl Into<PathBuf>) -> Self {
158 let project_root = project_root.into();
159 Self {
160 roots: Vec::new(),
161 scope_hash: "project".to_string(),
162 project_root,
163 }
164 }
165
166 pub fn from_roots(project_root: impl Into<PathBuf>, roots: Vec<PathBuf>) -> Self {
167 let project_root = project_root.into();
168 let mut roots = roots
169 .into_iter()
170 .map(|root| normalize_path(&root))
171 .collect::<Vec<_>>();
172 roots.sort();
173 roots.dedup();
174
175 if roots.is_empty() || (roots.len() == 1 && normalize_path(&project_root) == roots[0]) {
176 return Self::for_project(project_root);
177 }
178
179 let mut hasher = std::collections::hash_map::DefaultHasher::new();
180 for root in &roots {
181 root.to_string_lossy().hash(&mut hasher);
182 "\0".hash(&mut hasher);
183 }
184
185 Self {
186 project_root,
187 roots,
188 scope_hash: format!("{:016x}", hasher.finish()),
189 }
190 }
191
192 pub fn project_root(&self) -> &Path {
193 &self.project_root
194 }
195
196 pub fn roots(&self) -> &[PathBuf] {
197 &self.roots
198 }
199
200 pub fn scope_hash(&self) -> &str {
201 &self.scope_hash
202 }
203
204 pub fn is_project_wide(&self) -> bool {
205 self.roots.is_empty()
206 }
207
208 pub fn contains(&self, path: &Path) -> bool {
209 if self.roots.is_empty() {
210 return true;
211 }
212 let normalized = normalize_path(path);
213 self.roots.iter().any(|root| normalized.starts_with(root))
214 }
215
216 pub fn contains_display_path(&self, value: &str) -> bool {
217 let path = PathBuf::from(value);
218 if path.is_absolute() {
219 self.contains(&path)
220 } else {
221 self.contains(&self.project_root.join(path))
222 }
223 }
224}
225
226#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
227pub struct JobKey {
228 pub category: InspectCategory,
229 #[serde(skip_serializing_if = "Option::is_none")]
230 pub scope_hash: Option<String>,
231}
232
233impl JobKey {
234 pub fn for_category_scope(category: InspectCategory, scope: &JobScope) -> Self {
235 if category.is_tier2() {
236 Self::for_project_category(category)
237 } else {
238 Self {
239 category,
240 scope_hash: Some(scope.scope_hash().to_string()),
241 }
242 }
243 }
244
245 pub fn for_project_category(category: InspectCategory) -> Self {
246 Self {
247 category,
248 scope_hash: None,
249 }
250 }
251
252 pub fn display_key(&self) -> String {
253 match &self.scope_hash {
254 Some(scope_hash) => format!("{}:{scope_hash}", self.category),
255 None => self.category.to_string(),
256 }
257 }
258}
259
260#[derive(Clone)]
261pub struct InspectSnapshot {
262 pub project_root: PathBuf,
263 pub inspect_dir: PathBuf,
264 pub config: Arc<Config>,
265 pub symbol_cache: SharedSymbolCache,
266}
267
268impl InspectSnapshot {
269 pub fn new(
270 project_root: PathBuf,
271 inspect_dir: PathBuf,
272 config: Arc<Config>,
273 symbol_cache: SharedSymbolCache,
274 ) -> Self {
275 Self {
276 project_root,
277 inspect_dir,
278 config,
279 symbol_cache,
280 }
281 }
282}
283
284#[derive(Clone)]
285pub struct WorkerCtx {
286 pub project_root: PathBuf,
287 pub inspect_dir: PathBuf,
288 pub config: Arc<Config>,
289 pub symbol_cache: SharedSymbolCache,
290}
291
292impl From<&InspectSnapshot> for WorkerCtx {
293 fn from(snapshot: &InspectSnapshot) -> Self {
294 Self {
295 project_root: snapshot.project_root.clone(),
296 inspect_dir: snapshot.inspect_dir.clone(),
297 config: Arc::clone(&snapshot.config),
298 symbol_cache: Arc::clone(&snapshot.symbol_cache),
299 }
300 }
301}
302
303#[derive(Clone)]
304pub struct InspectJob {
305 pub job_id: u64,
306 pub key: JobKey,
307 pub category: InspectCategory,
308 pub scope_files: Vec<PathBuf>,
309 pub project_root: PathBuf,
310 pub inspect_dir: PathBuf,
311 pub config: Arc<Config>,
312 pub symbol_cache: SharedSymbolCache,
313 pub callgraph_snapshot: Option<Arc<CallgraphSnapshot>>,
314}
315
316impl InspectJob {
317 pub fn worker_ctx(&self) -> WorkerCtx {
318 WorkerCtx {
319 project_root: self.project_root.clone(),
320 inspect_dir: self.inspect_dir.clone(),
321 config: Arc::clone(&self.config),
322 symbol_cache: Arc::clone(&self.symbol_cache),
323 }
324 }
325}
326
327#[derive(Debug, Clone, Default)]
328pub struct CallgraphSnapshot {
329 pub generated_at: Option<SystemTime>,
330 pub files: Vec<PathBuf>,
331 pub exported_symbols: Vec<CallgraphExport>,
332 pub outbound_calls: Vec<CallgraphOutboundCall>,
333 pub entry_points: BTreeSet<PathBuf>,
334}
335
336#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
337pub struct CallgraphExport {
338 pub file: PathBuf,
339 pub symbol: String,
340 pub kind: String,
341 pub line: u32,
342}
343
344#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
345pub struct CallgraphOutboundCall {
346 pub caller_file: PathBuf,
347 pub caller_symbol: String,
348 pub target: String,
349 pub line: u32,
350}
351
352#[derive(Debug, Clone)]
353pub struct FileContribution {
354 pub category: InspectCategory,
355 pub file_path: PathBuf,
356 pub freshness: FileFreshness,
357 pub contribution: serde_json::Value,
358}
359
360impl FileContribution {
361 pub fn new(
362 category: InspectCategory,
363 file_path: impl Into<PathBuf>,
364 freshness: FileFreshness,
365 contribution: serde_json::Value,
366 ) -> Self {
367 Self {
368 category,
369 file_path: file_path.into(),
370 freshness,
371 contribution,
372 }
373 }
374}
375
376#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
377#[serde(rename_all = "snake_case")]
378pub enum JobStatus {
379 Queued,
380 Running,
381 Completed,
382 Failed,
383}
384
385#[derive(Debug, Clone)]
386pub struct InspectScanSuccess {
387 pub scanned_files: Vec<PathBuf>,
388 pub contributions: Vec<FileContribution>,
389 pub aggregate: serde_json::Value,
390}
391
392#[derive(Debug, Clone)]
393pub struct InspectResult {
394 pub job_id: u64,
395 pub key: JobKey,
396 pub category: InspectCategory,
397 pub project_root: PathBuf,
398 pub inspect_dir: PathBuf,
399 pub outcome: Result<InspectScanSuccess, String>,
400 pub duration: Duration,
401}
402
403impl InspectResult {
404 pub fn success(job: &InspectJob, success: InspectScanSuccess, duration: Duration) -> Self {
405 Self {
406 job_id: job.job_id,
407 key: job.key.clone(),
408 category: job.category,
409 project_root: job.project_root.clone(),
410 inspect_dir: job.inspect_dir.clone(),
411 outcome: Ok(success),
412 duration,
413 }
414 }
415
416 pub fn failed(job: &InspectJob, message: impl Into<String>, duration: Duration) -> Self {
417 Self {
418 job_id: job.job_id,
419 key: job.key.clone(),
420 category: job.category,
421 project_root: job.project_root.clone(),
422 inspect_dir: job.inspect_dir.clone(),
423 outcome: Err(message.into()),
424 duration,
425 }
426 }
427}
428
429#[derive(Debug, Clone, Serialize)]
430#[serde(tag = "status", rename_all = "snake_case")]
431pub enum JobOutcome {
432 Fresh {
433 payload: serde_json::Value,
434 },
435 Stale {
436 cached: Option<serde_json::Value>,
437 in_flight: bool,
438 },
439 Pending {
440 in_flight: bool,
441 },
442 Failed {
443 message: String,
444 },
445}
446
447impl JobOutcome {
448 pub fn payload(&self) -> Option<&serde_json::Value> {
449 match self {
450 JobOutcome::Fresh { payload } => Some(payload),
451 JobOutcome::Stale { cached, .. } => cached.as_ref(),
452 JobOutcome::Pending { .. } | JobOutcome::Failed { .. } => None,
453 }
454 }
455
456 pub fn is_stale(&self) -> bool {
457 matches!(self, JobOutcome::Stale { .. })
458 }
459
460 pub fn is_pending(&self) -> bool {
461 matches!(self, JobOutcome::Pending { .. })
462 }
463
464 pub fn summary_status(&self) -> Option<&'static str> {
465 match self {
466 JobOutcome::Fresh { .. } => None,
467 JobOutcome::Stale { .. } => Some("stale"),
468 JobOutcome::Pending { .. } => Some("pending"),
469 JobOutcome::Failed { .. } => Some("failed"),
470 }
471 }
472}
473
474pub(crate) fn normalize_path(path: &Path) -> PathBuf {
475 let mut result = PathBuf::new();
476 for component in path.components() {
477 match component {
478 std::path::Component::CurDir => {}
479 std::path::Component::ParentDir => {
480 if !result.pop() {
481 result.push(component);
482 }
483 }
484 other => result.push(other.as_os_str()),
485 }
486 }
487 result
488}