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
344pub(crate) const DISPATCHED_CALLEE_SEPARATOR: char = '\u{1f}';
345
346#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
347pub struct CallgraphOutboundCall {
348 pub caller_file: PathBuf,
349 pub caller_symbol: String,
350 pub target: String,
351 pub line: u32,
352}
353
354#[derive(Debug, Clone)]
355pub struct FileContribution {
356 pub category: InspectCategory,
357 pub file_path: PathBuf,
358 pub freshness: FileFreshness,
359 pub contribution: serde_json::Value,
360 pub type_ref_names: BTreeSet<String>,
361}
362
363impl FileContribution {
364 pub fn new(
365 category: InspectCategory,
366 file_path: impl Into<PathBuf>,
367 freshness: FileFreshness,
368 contribution: serde_json::Value,
369 ) -> Self {
370 let type_ref_names = type_ref_names_from_contribution(&contribution);
371 Self {
372 category,
373 file_path: file_path.into(),
374 freshness,
375 contribution,
376 type_ref_names,
377 }
378 }
379
380 pub fn with_type_ref_names<I>(mut self, type_ref_names: I) -> Self
381 where
382 I: IntoIterator<Item = String>,
383 {
384 self.type_ref_names = type_ref_names.into_iter().collect();
385 self.contribution =
386 contribution_with_type_ref_names(self.contribution, &self.type_ref_names);
387 self
388 }
389}
390
391pub(crate) fn type_ref_names_from_contribution(
392 contribution: &serde_json::Value,
393) -> BTreeSet<String> {
394 contribution
395 .get("type_ref_names")
396 .and_then(serde_json::Value::as_array)
397 .into_iter()
398 .flatten()
399 .filter_map(serde_json::Value::as_str)
400 .map(str::trim)
401 .filter(|name| !name.is_empty())
402 .map(str::to_string)
403 .collect()
404}
405
406pub(crate) fn contribution_with_type_ref_names(
407 mut contribution: serde_json::Value,
408 type_ref_names: &BTreeSet<String>,
409) -> serde_json::Value {
410 if let serde_json::Value::Object(object) = &mut contribution {
411 if type_ref_names.is_empty() {
412 object.remove("type_ref_names");
413 } else {
414 object.insert(
415 "type_ref_names".to_string(),
416 serde_json::Value::Array(
417 type_ref_names
418 .iter()
419 .map(|name| serde_json::Value::String(name.clone()))
420 .collect(),
421 ),
422 );
423 }
424 }
425 contribution
426}
427
428#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
429#[serde(rename_all = "snake_case")]
430pub enum JobStatus {
431 Queued,
432 Running,
433 Completed,
434 Failed,
435}
436
437#[derive(Debug, Clone)]
438pub struct InspectScanSuccess {
439 pub scanned_files: Vec<PathBuf>,
440 pub contributions: Vec<FileContribution>,
441 pub aggregate: serde_json::Value,
442}
443
444#[derive(Debug, Clone)]
445pub struct InspectResult {
446 pub job_id: u64,
447 pub key: JobKey,
448 pub category: InspectCategory,
449 pub project_root: PathBuf,
450 pub inspect_dir: PathBuf,
451 pub outcome: Result<InspectScanSuccess, String>,
452 pub duration: Duration,
453}
454
455impl InspectResult {
456 pub fn success(job: &InspectJob, success: InspectScanSuccess, duration: Duration) -> Self {
457 Self {
458 job_id: job.job_id,
459 key: job.key.clone(),
460 category: job.category,
461 project_root: job.project_root.clone(),
462 inspect_dir: job.inspect_dir.clone(),
463 outcome: Ok(success),
464 duration,
465 }
466 }
467
468 pub fn failed(job: &InspectJob, message: impl Into<String>, duration: Duration) -> Self {
469 Self {
470 job_id: job.job_id,
471 key: job.key.clone(),
472 category: job.category,
473 project_root: job.project_root.clone(),
474 inspect_dir: job.inspect_dir.clone(),
475 outcome: Err(message.into()),
476 duration,
477 }
478 }
479}
480
481#[derive(Debug, Clone, Serialize)]
482#[serde(tag = "status", rename_all = "snake_case")]
483pub enum JobOutcome {
484 Fresh {
485 payload: serde_json::Value,
486 },
487 Stale {
488 cached: Option<serde_json::Value>,
489 in_flight: bool,
490 },
491 Pending {
492 in_flight: bool,
493 },
494 Failed {
495 message: String,
496 },
497}
498
499impl JobOutcome {
500 pub fn payload(&self) -> Option<&serde_json::Value> {
501 match self {
502 JobOutcome::Fresh { payload } => Some(payload),
503 JobOutcome::Stale { cached, .. } => cached.as_ref(),
504 JobOutcome::Pending { .. } | JobOutcome::Failed { .. } => None,
505 }
506 }
507
508 pub fn is_stale(&self) -> bool {
509 matches!(self, JobOutcome::Stale { .. })
510 }
511
512 pub fn is_pending(&self) -> bool {
513 matches!(self, JobOutcome::Pending { .. })
514 }
515
516 pub fn summary_status(&self) -> Option<&'static str> {
517 match self {
518 JobOutcome::Fresh { .. } => None,
519 JobOutcome::Stale { .. } => Some("stale"),
520 JobOutcome::Pending { .. } => Some("pending"),
521 JobOutcome::Failed { .. } => Some("failed"),
522 }
523 }
524}
525
526pub(crate) fn is_test_support_file(relative_path: &str) -> bool {
537 let normalized = relative_path.replace('\\', "/");
538 normalized.split('/').any(|segment| {
539 matches!(
540 segment,
541 "fixtures"
542 | "__fixtures__"
543 | "testdata"
544 | "test-data"
545 | "__mocks__"
546 | "__snapshots__"
547 | "corpora"
548 )
549 })
550}
551
552pub(crate) fn normalize_path(path: &Path) -> PathBuf {
553 let mut result = PathBuf::new();
554 for component in path.components() {
555 match component {
556 std::path::Component::CurDir => {}
557 std::path::Component::ParentDir => {
558 if !result.pop() {
559 result.push(component);
560 }
561 }
562 other => result.push(other.as_os_str()),
563 }
564 }
565 result
566}
567
568#[cfg(test)]
569mod test_support_tests {
570 use super::is_test_support_file;
571
572 #[test]
573 fn matches_conventional_support_dirs() {
574 assert!(is_test_support_file("crates/aft/tests/fixtures/sample.ts"));
575 assert!(is_test_support_file(
576 "packages/x/__tests__/e2e/fixtures/a.ts"
577 ));
578 assert!(is_test_support_file(
579 "benchmarks/codegraph/corpora/repo/lib.go"
580 ));
581 assert!(is_test_support_file("src/__mocks__/fs.ts"));
582 assert!(is_test_support_file("src/__snapshots__/render.snap"));
583 assert!(is_test_support_file("internal/testdata/golden.json"));
584 assert!(is_test_support_file("crates\\aft\\tests\\fixtures\\x.rs"));
586 }
587
588 #[test]
589 fn does_not_match_product_or_test_files() {
590 assert!(!is_test_support_file("crates/aft/src/inspect/job.rs"));
592 assert!(!is_test_support_file(
594 "packages/x/__tests__/reading.test.ts"
595 ));
596 assert!(!is_test_support_file(
597 "crates/aft/tests/integration/main.rs"
598 ));
599 assert!(!is_test_support_file("src/fixturesHelper.ts"));
601 assert!(!is_test_support_file("src/my_corpora_loader.rs"));
602 }
603}