1use std::collections::{BTreeMap, 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::{LangId, 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 Cycles,
25 Complexity,
26 CircularDeps,
27 OutdatedDeps,
28 Vulnerabilities,
29 TestCoverageGaps,
30 ApiSurface,
31}
32
33impl InspectCategory {
34 pub const ACTIVE: [InspectCategory; 8] = [
35 InspectCategory::Diagnostics,
36 InspectCategory::Metrics,
37 InspectCategory::Todos,
38 InspectCategory::DeadCode,
39 InspectCategory::UnusedExports,
40 InspectCategory::Duplicates,
41 InspectCategory::Cycles,
42 InspectCategory::Complexity,
43 ];
44
45 pub const DISABLED: [InspectCategory; 5] = [
46 InspectCategory::CircularDeps,
47 InspectCategory::OutdatedDeps,
48 InspectCategory::Vulnerabilities,
49 InspectCategory::TestCoverageGaps,
50 InspectCategory::ApiSurface,
51 ];
52
53 pub fn as_str(self) -> &'static str {
54 match self {
55 InspectCategory::Diagnostics => "diagnostics",
56 InspectCategory::Metrics => "metrics",
57 InspectCategory::Todos => "todos",
58 InspectCategory::DeadCode => "dead_code",
59 InspectCategory::UnusedExports => "unused_exports",
60 InspectCategory::Duplicates => "duplicates",
61 InspectCategory::Cycles => "cycles",
62 InspectCategory::Complexity => "complexity",
63 InspectCategory::CircularDeps => "circular_deps",
64 InspectCategory::OutdatedDeps => "outdated_deps",
65 InspectCategory::Vulnerabilities => "vulnerabilities",
66 InspectCategory::TestCoverageGaps => "test_coverage_gaps",
67 InspectCategory::ApiSurface => "api_surface",
68 }
69 }
70
71 pub fn tier(self) -> InspectTier {
72 match self {
73 InspectCategory::Diagnostics | InspectCategory::Metrics | InspectCategory::Todos => {
74 InspectTier::Tier1
75 }
76 InspectCategory::DeadCode
77 | InspectCategory::UnusedExports
78 | InspectCategory::Duplicates
79 | InspectCategory::Cycles
80 | InspectCategory::Complexity
81 | InspectCategory::CircularDeps
82 | InspectCategory::ApiSurface => InspectTier::Tier2,
83 InspectCategory::OutdatedDeps
84 | InspectCategory::Vulnerabilities
85 | InspectCategory::TestCoverageGaps => InspectTier::Tier3,
86 }
87 }
88
89 pub fn is_tier2(self) -> bool {
90 self.tier() == InspectTier::Tier2
91 }
92
93 pub fn is_active(self) -> bool {
94 Self::ACTIVE.contains(&self)
95 }
96
97 pub fn active() -> &'static [InspectCategory] {
98 &Self::ACTIVE
99 }
100
101 pub fn disabled() -> &'static [InspectCategory] {
102 &Self::DISABLED
103 }
104}
105
106impl fmt::Display for InspectCategory {
107 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
108 formatter.write_str(self.as_str())
109 }
110}
111
112impl FromStr for InspectCategory {
113 type Err = InspectCategoryParseError;
114
115 fn from_str(value: &str) -> Result<Self, Self::Err> {
116 match value {
117 "diagnostics" => Ok(Self::Diagnostics),
118 "metrics" => Ok(Self::Metrics),
119 "todos" => Ok(Self::Todos),
120 "dead_code" => Ok(Self::DeadCode),
121 "unused_exports" => Ok(Self::UnusedExports),
122 "duplicates" => Ok(Self::Duplicates),
123 "cycles" => Ok(Self::Cycles),
124 "complexity" => Ok(Self::Complexity),
125 "circular_deps" => Ok(Self::CircularDeps),
126 "outdated_deps" => Ok(Self::OutdatedDeps),
127 "vulnerabilities" => Ok(Self::Vulnerabilities),
128 "test_coverage_gaps" => Ok(Self::TestCoverageGaps),
129 "api_surface" => Ok(Self::ApiSurface),
130 other => Err(InspectCategoryParseError(other.to_string())),
131 }
132 }
133}
134
135#[derive(Debug, Clone, PartialEq, Eq)]
136pub struct InspectCategoryParseError(String);
137
138impl fmt::Display for InspectCategoryParseError {
139 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
140 write!(formatter, "unknown inspect category '{}'", self.0)
141 }
142}
143
144impl std::error::Error for InspectCategoryParseError {}
145
146#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
147#[serde(rename_all = "snake_case")]
148pub enum InspectTier {
149 Tier1,
150 Tier2,
151 Tier3,
152}
153
154#[derive(Debug, Clone, PartialEq, Eq)]
155pub struct JobScope {
156 project_root: PathBuf,
157 roots: Vec<PathBuf>,
158 scope_hash: String,
159}
160
161impl JobScope {
162 pub fn for_project(project_root: impl Into<PathBuf>) -> Self {
163 let project_root = project_root.into();
164 Self {
165 roots: Vec::new(),
166 scope_hash: "project".to_string(),
167 project_root,
168 }
169 }
170
171 pub fn from_roots(project_root: impl Into<PathBuf>, roots: Vec<PathBuf>) -> Self {
172 let project_root = project_root.into();
173 let mut roots = roots
174 .into_iter()
175 .map(|root| normalize_path(&root))
176 .collect::<Vec<_>>();
177 roots.sort();
178 roots.dedup();
179
180 if roots.is_empty() || (roots.len() == 1 && normalize_path(&project_root) == roots[0]) {
181 return Self::for_project(project_root);
182 }
183
184 let mut hasher = std::collections::hash_map::DefaultHasher::new();
185 for root in &roots {
186 root.to_string_lossy().hash(&mut hasher);
187 "\0".hash(&mut hasher);
188 }
189
190 Self {
191 project_root,
192 roots,
193 scope_hash: format!("{:016x}", hasher.finish()),
194 }
195 }
196
197 pub fn project_root(&self) -> &Path {
198 &self.project_root
199 }
200
201 pub fn roots(&self) -> &[PathBuf] {
202 &self.roots
203 }
204
205 pub fn scope_hash(&self) -> &str {
206 &self.scope_hash
207 }
208
209 pub fn is_project_wide(&self) -> bool {
210 self.roots.is_empty()
211 }
212
213 pub fn contains(&self, path: &Path) -> bool {
214 if self.roots.is_empty() {
215 return true;
216 }
217 let normalized = normalize_path(path);
218 self.roots.iter().any(|root| normalized.starts_with(root))
219 }
220
221 pub fn contains_display_path(&self, value: &str) -> bool {
222 let path = PathBuf::from(value);
223 if path.is_absolute() {
224 self.contains(&path)
225 } else {
226 self.contains(&self.project_root.join(path))
227 }
228 }
229}
230
231#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
232pub struct JobKey {
233 pub category: InspectCategory,
234 #[serde(skip_serializing_if = "Option::is_none")]
235 pub scope_hash: Option<String>,
236}
237
238impl JobKey {
239 pub fn for_category_scope(category: InspectCategory, scope: &JobScope) -> Self {
240 if category.is_tier2() {
241 Self::for_project_category(category)
242 } else {
243 Self {
244 category,
245 scope_hash: Some(scope.scope_hash().to_string()),
246 }
247 }
248 }
249
250 pub fn for_project_category(category: InspectCategory) -> Self {
251 Self {
252 category,
253 scope_hash: None,
254 }
255 }
256
257 pub fn display_key(&self) -> String {
258 match &self.scope_hash {
259 Some(scope_hash) => format!("{}:{scope_hash}", self.category),
260 None => self.category.to_string(),
261 }
262 }
263}
264
265#[derive(Clone)]
266pub struct InspectSnapshot {
267 pub project_root: PathBuf,
268 pub inspect_dir: PathBuf,
269 pub config: Arc<Config>,
270 pub symbol_cache: SharedSymbolCache,
271 pub inspect_writer: bool,
272 pub callgraph_writer: bool,
273}
274
275impl InspectSnapshot {
276 pub fn new(
277 project_root: PathBuf,
278 inspect_dir: PathBuf,
279 config: Arc<Config>,
280 symbol_cache: SharedSymbolCache,
281 ) -> Self {
282 Self::new_with_capabilities(project_root, inspect_dir, config, symbol_cache, true, true)
283 }
284
285 pub fn new_with_capabilities(
286 project_root: PathBuf,
287 inspect_dir: PathBuf,
288 config: Arc<Config>,
289 symbol_cache: SharedSymbolCache,
290 inspect_writer: bool,
291 callgraph_writer: bool,
292 ) -> Self {
293 Self {
294 project_root,
295 inspect_dir,
296 config,
297 symbol_cache,
298 inspect_writer,
299 callgraph_writer,
300 }
301 }
302}
303
304#[derive(Clone)]
305pub struct WorkerCtx {
306 pub project_root: PathBuf,
307 pub inspect_dir: PathBuf,
308 pub config: Arc<Config>,
309 pub symbol_cache: SharedSymbolCache,
310}
311
312impl From<&InspectSnapshot> for WorkerCtx {
313 fn from(snapshot: &InspectSnapshot) -> Self {
314 Self {
315 project_root: snapshot.project_root.clone(),
316 inspect_dir: snapshot.inspect_dir.clone(),
317 config: Arc::clone(&snapshot.config),
318 symbol_cache: Arc::clone(&snapshot.symbol_cache),
319 }
320 }
321}
322
323#[derive(Clone)]
324pub struct InspectJob {
325 pub job_id: u64,
326 pub key: JobKey,
327 pub category: InspectCategory,
328 pub scope_files: Vec<PathBuf>,
329 pub project_root: PathBuf,
330 pub inspect_dir: PathBuf,
331 pub config: Arc<Config>,
332 pub symbol_cache: SharedSymbolCache,
333 pub inspect_writer: bool,
334 pub callgraph_writer: bool,
335 pub callgraph_snapshot: Option<Arc<CallgraphSnapshot>>,
336}
337
338impl InspectJob {
339 pub fn worker_ctx(&self) -> WorkerCtx {
340 WorkerCtx {
341 project_root: self.project_root.clone(),
342 inspect_dir: self.inspect_dir.clone(),
343 config: Arc::clone(&self.config),
344 symbol_cache: Arc::clone(&self.symbol_cache),
345 }
346 }
347
348 pub fn is_full_project_scope(&self) -> bool {
351 self.key.scope_hash.as_deref() == Some("project")
352 }
353}
354
355pub(crate) fn is_js_ts_language(language: LangId) -> bool {
356 matches!(
357 language,
358 LangId::TypeScript | LangId::Tsx | LangId::JavaScript
359 )
360}
361
362pub(crate) fn dead_code_supports_language(language: LangId) -> bool {
363 is_js_ts_language(language) || callgraph_store_dead_code_supports_language(language)
364}
365
366pub(crate) fn dead_code_skipped_language(file: &Path) -> Option<&'static str> {
367 let language = crate::parser::detect_language(file)?;
368 (!dead_code_supports_language(language)).then(|| language_name(language))
369}
370
371fn callgraph_store_dead_code_supports_language(language: LangId) -> bool {
372 let supported_by_store_liveness = matches!(
378 language,
379 LangId::Rust
380 | LangId::Go
381 | LangId::C
382 | LangId::Cpp
383 | LangId::Cuda
384 | LangId::Metal
385 | LangId::Zig
386 | LangId::CSharp
387 );
388 supported_by_store_liveness && !crate::calls::call_node_kinds(language).is_empty()
389}
390
391pub(crate) fn language_name(language: LangId) -> &'static str {
392 match language {
393 LangId::TypeScript => "typescript",
394 LangId::Tsx => "tsx",
395 LangId::JavaScript => "javascript",
396 LangId::Python => "python",
397 LangId::Rust => "rust",
398 LangId::Go => "go",
399 LangId::C => "c",
400 LangId::Cpp => "cpp",
401 LangId::Cuda => "cuda",
402 LangId::Metal => "metal",
403 LangId::Zig => "zig",
404 LangId::CSharp => "csharp",
405 LangId::Bash => "bash",
406 LangId::Html => "html",
407 LangId::Markdown => "markdown",
408 LangId::Yaml => "yaml",
409 LangId::Solidity => "solidity",
410 LangId::Scss => "scss",
411 LangId::Vue => "vue",
412 LangId::Json => "json",
413 LangId::Scala => "scala",
414 LangId::Java => "java",
415 LangId::Ruby => "ruby",
416 LangId::Kotlin => "kotlin",
417 LangId::Swift => "swift",
418 LangId::Php => "php",
419 LangId::Lua => "lua",
420 LangId::Perl => "perl",
421 LangId::Pascal => "pascal",
422 LangId::R => "r",
423 LangId::Groovy => "groovy",
424 LangId::ObjC => "objc",
425 LangId::Toml => "toml",
426 }
427}
428
429#[derive(Debug, Clone, Default)]
430pub struct CallgraphSnapshot {
431 pub generated_at: Option<SystemTime>,
432 pub files: Vec<PathBuf>,
433 pub exported_symbols: Vec<CallgraphExport>,
434 pub outbound_calls: Vec<CallgraphOutboundCall>,
435 pub entry_points: BTreeSet<PathBuf>,
436 pub entry_point_symbols: BTreeMap<PathBuf, BTreeSet<String>>,
437}
438
439#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
440pub struct CallgraphExport {
441 pub file: PathBuf,
442 pub symbol: String,
443 pub kind: String,
444 pub line: u32,
445}
446
447pub(crate) const DISPATCHED_CALLEE_SEPARATOR: char = '\u{1f}';
448pub(crate) const CALLGRAPH_PROVENANCE_TREESITTER: &str = "treesitter";
449pub(crate) const CALLGRAPH_PROVENANCE_REEXPORT: &str = "reexport";
450
451fn default_callgraph_outbound_provenance() -> String {
452 CALLGRAPH_PROVENANCE_TREESITTER.to_string()
453}
454
455#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
456pub struct CallgraphOutboundCall {
457 pub caller_file: PathBuf,
458 pub caller_symbol: String,
459 pub target: String,
460 pub line: u32,
461 #[serde(default = "default_callgraph_outbound_provenance")]
462 pub provenance: String,
463}
464
465#[derive(Debug, Clone)]
466pub struct FileContribution {
467 pub category: InspectCategory,
468 pub file_path: PathBuf,
469 pub freshness: FileFreshness,
470 pub contribution: serde_json::Value,
471 pub type_ref_names: BTreeSet<String>,
472}
473
474impl FileContribution {
475 pub fn new(
476 category: InspectCategory,
477 file_path: impl Into<PathBuf>,
478 freshness: FileFreshness,
479 contribution: serde_json::Value,
480 ) -> Self {
481 let type_ref_names = type_ref_names_from_contribution(&contribution);
482 Self {
483 category,
484 file_path: file_path.into(),
485 freshness,
486 contribution,
487 type_ref_names,
488 }
489 }
490
491 pub fn with_type_ref_names<I>(mut self, type_ref_names: I) -> Self
492 where
493 I: IntoIterator<Item = String>,
494 {
495 self.type_ref_names = type_ref_names.into_iter().collect();
496 self.contribution =
497 contribution_with_type_ref_names(self.contribution, &self.type_ref_names);
498 self
499 }
500}
501
502pub(crate) fn type_ref_names_from_contribution(
503 contribution: &serde_json::Value,
504) -> BTreeSet<String> {
505 contribution
506 .get("type_ref_names")
507 .and_then(serde_json::Value::as_array)
508 .into_iter()
509 .flatten()
510 .filter_map(serde_json::Value::as_str)
511 .map(str::trim)
512 .filter(|name| !name.is_empty())
513 .map(str::to_string)
514 .collect()
515}
516
517pub(crate) fn contribution_with_type_ref_names(
518 mut contribution: serde_json::Value,
519 type_ref_names: &BTreeSet<String>,
520) -> serde_json::Value {
521 if let serde_json::Value::Object(object) = &mut contribution {
522 if type_ref_names.is_empty() {
523 object.remove("type_ref_names");
524 } else {
525 object.insert(
526 "type_ref_names".to_string(),
527 serde_json::Value::Array(
528 type_ref_names
529 .iter()
530 .map(|name| serde_json::Value::String(name.clone()))
531 .collect(),
532 ),
533 );
534 }
535 }
536 contribution
537}
538
539#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
540#[serde(rename_all = "snake_case")]
541pub enum JobStatus {
542 Queued,
543 Running,
544 Completed,
545 Failed,
546}
547
548#[derive(Debug, Clone)]
549pub struct InspectScanSuccess {
550 pub scanned_files: Vec<PathBuf>,
551 pub contributions: Vec<FileContribution>,
552 pub aggregate: serde_json::Value,
553}
554
555#[derive(Debug, Clone)]
556pub struct InspectResult {
557 pub job_id: u64,
558 pub key: JobKey,
559 pub category: InspectCategory,
560 pub project_root: PathBuf,
561 pub inspect_dir: PathBuf,
562 pub config: Arc<Config>,
563 pub outcome: Result<InspectScanSuccess, String>,
564 pub duration: Duration,
565}
566
567impl InspectResult {
568 pub fn success(job: &InspectJob, success: InspectScanSuccess, duration: Duration) -> Self {
569 Self {
570 job_id: job.job_id,
571 key: job.key.clone(),
572 category: job.category,
573 project_root: job.project_root.clone(),
574 inspect_dir: job.inspect_dir.clone(),
575 config: Arc::clone(&job.config),
576 outcome: Ok(success),
577 duration,
578 }
579 }
580
581 pub fn failed(job: &InspectJob, message: impl Into<String>, duration: Duration) -> Self {
582 Self {
583 job_id: job.job_id,
584 key: job.key.clone(),
585 category: job.category,
586 project_root: job.project_root.clone(),
587 inspect_dir: job.inspect_dir.clone(),
588 config: Arc::clone(&job.config),
589 outcome: Err(message.into()),
590 duration,
591 }
592 }
593}
594
595#[derive(Debug, Clone, Copy, Serialize)]
596#[serde(rename_all = "snake_case")]
597pub enum PendingWaitCause {
598 WaiterDropped,
599 ResultChannelDisconnected,
600 DeadlineElapsed,
601}
602
603#[derive(Debug, Clone, Serialize)]
604pub struct PendingWait {
605 pub cause: PendingWaitCause,
606 pub elapsed_ms: u64,
607 pub budget_ms: u64,
608}
609
610impl PendingWait {
611 pub fn new(cause: PendingWaitCause, elapsed: Duration, budget: Duration) -> Self {
612 Self {
613 cause,
614 elapsed_ms: duration_millis(elapsed),
615 budget_ms: duration_millis(budget),
616 }
617 }
618
619 pub fn detail(&self) -> String {
620 let elapsed = display_duration_millis(self.elapsed_ms);
621 let budget = display_duration_millis(self.budget_ms);
622 match self.cause {
623 PendingWaitCause::WaiterDropped => {
624 format!("waiter dropped without outcome after {elapsed}; budget {budget}")
625 }
626 PendingWaitCause::ResultChannelDisconnected => {
627 format!("result channel disconnected after {elapsed}; budget {budget}")
628 }
629 PendingWaitCause::DeadlineElapsed => {
630 format!("deadline elapsed after {elapsed}; budget {budget}")
631 }
632 }
633 }
634}
635
636fn duration_millis(duration: Duration) -> u64 {
637 duration.as_millis().min(u128::from(u64::MAX)) as u64
638}
639
640fn display_duration_millis(millis: u64) -> String {
641 if millis >= 1_000 && millis % 1_000 == 0 {
642 format!("{}s", millis / 1_000)
643 } else if millis >= 1_000 {
644 format!("{:.1}s", millis as f64 / 1_000.0)
645 } else {
646 format!("{millis}ms")
647 }
648}
649
650#[derive(Debug, Clone, Serialize)]
651#[serde(tag = "status", rename_all = "snake_case")]
652pub enum JobOutcome {
653 Fresh {
654 payload: serde_json::Value,
655 },
656 Stale {
657 cached: Option<serde_json::Value>,
658 in_flight: bool,
659 },
660 Pending {
661 in_flight: bool,
662 #[serde(skip_serializing_if = "Option::is_none")]
663 wait: Option<PendingWait>,
664 },
665 Failed {
666 message: String,
667 },
668}
669
670impl JobOutcome {
671 pub fn pending(in_flight: bool) -> Self {
672 Self::Pending {
673 in_flight,
674 wait: None,
675 }
676 }
677
678 pub fn pending_wait(
679 in_flight: bool,
680 cause: PendingWaitCause,
681 elapsed: Duration,
682 budget: Duration,
683 ) -> Self {
684 Self::Pending {
685 in_flight,
686 wait: Some(PendingWait::new(cause, elapsed, budget)),
687 }
688 }
689
690 pub fn pending_detail(&self) -> Option<String> {
691 match self {
692 Self::Pending {
693 wait: Some(wait), ..
694 } => Some(wait.detail()),
695 _ => None,
696 }
697 }
698
699 pub fn payload(&self) -> Option<&serde_json::Value> {
700 match self {
701 JobOutcome::Fresh { payload } => Some(payload),
702 JobOutcome::Stale { cached, .. } => cached.as_ref(),
703 JobOutcome::Pending { .. } | JobOutcome::Failed { .. } => None,
704 }
705 }
706
707 pub fn is_stale(&self) -> bool {
708 matches!(self, JobOutcome::Stale { .. })
709 }
710
711 pub fn is_pending(&self) -> bool {
712 matches!(self, JobOutcome::Pending { .. })
713 }
714
715 pub fn summary_status(&self) -> Option<&'static str> {
716 match self {
717 JobOutcome::Fresh { .. } => None,
718 JobOutcome::Stale { .. } => Some("stale"),
719 JobOutcome::Pending { .. } => Some("pending"),
720 JobOutcome::Failed { .. } => Some("failed"),
721 }
722 }
723}
724
725pub(crate) fn is_test_support_file(relative_path: &str) -> bool {
736 let normalized = relative_path.replace('\\', "/");
737 normalized.split('/').any(|segment| {
738 matches!(
739 segment,
740 "fixtures"
741 | "__fixtures__"
742 | "testdata"
743 | "test-data"
744 | "__mocks__"
745 | "__snapshots__"
746 | "corpora"
747 )
748 })
749}
750
751pub(crate) fn is_test_file(relative_path: &str) -> bool {
761 let normalized = relative_path.replace('\\', "/");
762
763 if normalized
767 .split('/')
768 .any(|segment| matches!(segment, "__tests__" | "__test__" | "tests"))
769 {
770 return true;
771 }
772
773 let file = normalized.rsplit('/').next().unwrap_or(&normalized);
774 let lower = file.to_ascii_lowercase();
775
776 if lower.contains(".test.") || lower.contains(".spec.") {
778 return true;
779 }
780
781 if lower.ends_with("_test.rs")
783 || lower.ends_with("_test.go")
784 || lower.ends_with("_test.py")
785 || lower.ends_with("_test.rb")
786 || lower.ends_with("_test.exs")
787 || lower.ends_with("_spec.rb")
788 || (lower.starts_with("test_") && lower.ends_with(".py"))
789 {
790 return true;
791 }
792
793 const CAMEL_SUFFIXES: &[&str] = &[
796 "Test.java",
797 "Tests.java",
798 "Test.kt",
799 "Tests.kt",
800 "Test.cs",
801 "Tests.cs",
802 "Test.swift",
803 "Tests.swift",
804 "Test.scala",
805 "Spec.scala",
806 ];
807 CAMEL_SUFFIXES.iter().any(|suffix| file.ends_with(suffix))
808}
809
810pub(crate) fn normalize_path(path: &Path) -> PathBuf {
811 #[cfg(windows)]
817 let path = &crate::windows_path::normalize_windows_path(path);
818
819 let mut result = PathBuf::new();
820 for component in path.components() {
821 match component {
822 std::path::Component::CurDir => {}
823 std::path::Component::ParentDir => {
824 if !result.pop() {
825 result.push(component);
826 }
827 }
828 other => result.push(other.as_os_str()),
829 }
830 }
831 result
832}
833
834pub(crate) fn canonicalize_normalized(path: &Path) -> PathBuf {
841 match std::fs::canonicalize(path) {
842 Ok(canonical) => normalize_path(&canonical),
843 Err(_) => normalize_path(path),
844 }
845}
846
847#[cfg(test)]
848mod test_support_tests {
849 use super::{is_test_file, is_test_support_file};
850
851 #[test]
852 fn is_test_file_matches_real_test_files() {
853 for p in [
855 "src/foo.test.ts",
856 "src/foo.test.tsx",
857 "src/bar.spec.js",
858 "packages/x/component.test.jsx",
859 "app/foo.test.mjs",
860 "src/comp.spec.vue",
861 ] {
862 assert!(is_test_file(p), "{p} should be a test file");
863 }
864 assert!(is_test_file("packages/x/__tests__/reading.ts"));
866 assert!(is_test_file("crates/aft/tests/integration/main.rs"));
867 assert!(is_test_file("crates/aft/src/foo_test.rs"));
869 assert!(is_test_file("pkg/handler_test.go"));
870 assert!(is_test_file("app/test_models.py"));
871 assert!(is_test_file("app/models_test.py"));
872 assert!(is_test_file("spec/user_spec.rb"));
873 assert!(is_test_file("src/main/UserServiceTest.java"));
875 assert!(is_test_file("src/FooTests.cs"));
876 assert!(is_test_file("Sources/AppTests.swift"));
877 assert!(is_test_file("packages\\x\\__tests__\\a.ts"));
879 }
880
881 #[test]
882 fn is_test_file_rejects_product_files() {
883 for p in [
884 "crates/aft/src/inspect/job.rs",
885 "packages/x/src/index.ts",
886 "src/contestant.ts", "src/greatest.ts", "src/latest.java", "src/my_attestation.py", "src/test/helper.ts", ] {
892 assert!(!is_test_file(p), "{p} must NOT be a test file");
893 }
894 }
895
896 #[test]
897 fn matches_conventional_support_dirs() {
898 assert!(is_test_support_file("crates/aft/tests/fixtures/sample.ts"));
899 assert!(is_test_support_file(
900 "packages/x/__tests__/e2e/fixtures/a.ts"
901 ));
902 assert!(is_test_support_file(
903 "benchmarks/codegraph/corpora/repo/lib.go"
904 ));
905 assert!(is_test_support_file("src/__mocks__/fs.ts"));
906 assert!(is_test_support_file("src/__snapshots__/render.snap"));
907 assert!(is_test_support_file("internal/testdata/golden.json"));
908 assert!(is_test_support_file("crates\\aft\\tests\\fixtures\\x.rs"));
910 }
911
912 #[test]
913 fn does_not_match_product_or_test_files() {
914 assert!(!is_test_support_file("crates/aft/src/inspect/job.rs"));
916 assert!(!is_test_support_file(
918 "packages/x/__tests__/reading.test.ts"
919 ));
920 assert!(!is_test_support_file(
921 "crates/aft/tests/integration/main.rs"
922 ));
923 assert!(!is_test_support_file("src/fixturesHelper.ts"));
925 assert!(!is_test_support_file("src/my_corpora_loader.rs"));
926 }
927}