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