Skip to main content

aft/inspect/
job.rs

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; 7] = [
35        InspectCategory::Diagnostics,
36        InspectCategory::Metrics,
37        InspectCategory::Todos,
38        InspectCategory::DeadCode,
39        InspectCategory::UnusedExports,
40        InspectCategory::Duplicates,
41        InspectCategory::Cycles,
42    ];
43
44    pub const DISABLED: [InspectCategory; 6] = [
45        InspectCategory::Complexity,
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}
272
273impl InspectSnapshot {
274    pub fn new(
275        project_root: PathBuf,
276        inspect_dir: PathBuf,
277        config: Arc<Config>,
278        symbol_cache: SharedSymbolCache,
279    ) -> Self {
280        Self {
281            project_root,
282            inspect_dir,
283            config,
284            symbol_cache,
285        }
286    }
287}
288
289#[derive(Clone)]
290pub struct WorkerCtx {
291    pub project_root: PathBuf,
292    pub inspect_dir: PathBuf,
293    pub config: Arc<Config>,
294    pub symbol_cache: SharedSymbolCache,
295}
296
297impl From<&InspectSnapshot> for WorkerCtx {
298    fn from(snapshot: &InspectSnapshot) -> Self {
299        Self {
300            project_root: snapshot.project_root.clone(),
301            inspect_dir: snapshot.inspect_dir.clone(),
302            config: Arc::clone(&snapshot.config),
303            symbol_cache: Arc::clone(&snapshot.symbol_cache),
304        }
305    }
306}
307
308#[derive(Clone)]
309pub struct InspectJob {
310    pub job_id: u64,
311    pub key: JobKey,
312    pub category: InspectCategory,
313    pub scope_files: Vec<PathBuf>,
314    pub project_root: PathBuf,
315    pub inspect_dir: PathBuf,
316    pub config: Arc<Config>,
317    pub symbol_cache: SharedSymbolCache,
318    pub callgraph_snapshot: Option<Arc<CallgraphSnapshot>>,
319}
320
321impl InspectJob {
322    pub fn worker_ctx(&self) -> WorkerCtx {
323        WorkerCtx {
324            project_root: self.project_root.clone(),
325            inspect_dir: self.inspect_dir.clone(),
326            config: Arc::clone(&self.config),
327            symbol_cache: Arc::clone(&self.symbol_cache),
328        }
329    }
330}
331
332pub(crate) fn is_js_ts_language(language: LangId) -> bool {
333    matches!(
334        language,
335        LangId::TypeScript | LangId::Tsx | LangId::JavaScript
336    )
337}
338
339pub(crate) fn dead_code_supports_language(language: LangId) -> bool {
340    is_js_ts_language(language) || callgraph_store_dead_code_supports_language(language)
341}
342
343pub(crate) fn dead_code_skipped_language(file: &Path) -> Option<&'static str> {
344    let language = crate::parser::detect_language(file)?;
345    (!dead_code_supports_language(language)).then(|| language_name(language))
346}
347
348fn callgraph_store_dead_code_supports_language(language: LangId) -> bool {
349    // Dead-code reachability needs real call edges, not just outline symbols.
350    // Keep this narrower than every language with a tree-sitter grammar: the
351    // store-backed liveness path is trusted only for languages with call-edge
352    // extraction that dead-code consumes, and the call_node_kinds check keeps the
353    // gate tied to the extraction substrate instead of extension names alone.
354    let supported_by_store_liveness = matches!(
355        language,
356        LangId::Rust | LangId::Go | LangId::C | LangId::Cpp | LangId::Zig | LangId::CSharp
357    );
358    supported_by_store_liveness && !crate::calls::call_node_kinds(language).is_empty()
359}
360
361pub(crate) fn language_name(language: LangId) -> &'static str {
362    match language {
363        LangId::TypeScript => "typescript",
364        LangId::Tsx => "tsx",
365        LangId::JavaScript => "javascript",
366        LangId::Python => "python",
367        LangId::Rust => "rust",
368        LangId::Go => "go",
369        LangId::C => "c",
370        LangId::Cpp => "cpp",
371        LangId::Zig => "zig",
372        LangId::CSharp => "csharp",
373        LangId::Bash => "bash",
374        LangId::Html => "html",
375        LangId::Markdown => "markdown",
376        LangId::Yaml => "yaml",
377        LangId::Solidity => "solidity",
378        LangId::Scss => "scss",
379        LangId::Vue => "vue",
380        LangId::Json => "json",
381        LangId::Scala => "scala",
382        LangId::Java => "java",
383        LangId::Ruby => "ruby",
384        LangId::Kotlin => "kotlin",
385        LangId::Swift => "swift",
386        LangId::Php => "php",
387        LangId::Lua => "lua",
388        LangId::Perl => "perl",
389        LangId::Pascal => "pascal",
390        LangId::R => "r",
391        LangId::ObjC => "objc",
392    }
393}
394
395#[derive(Debug, Clone, Default)]
396pub struct CallgraphSnapshot {
397    pub generated_at: Option<SystemTime>,
398    pub files: Vec<PathBuf>,
399    pub exported_symbols: Vec<CallgraphExport>,
400    pub outbound_calls: Vec<CallgraphOutboundCall>,
401    pub entry_points: BTreeSet<PathBuf>,
402    pub entry_point_symbols: BTreeMap<PathBuf, BTreeSet<String>>,
403}
404
405#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
406pub struct CallgraphExport {
407    pub file: PathBuf,
408    pub symbol: String,
409    pub kind: String,
410    pub line: u32,
411}
412
413pub(crate) const DISPATCHED_CALLEE_SEPARATOR: char = '\u{1f}';
414pub(crate) const CALLGRAPH_PROVENANCE_TREESITTER: &str = "treesitter";
415pub(crate) const CALLGRAPH_PROVENANCE_REEXPORT: &str = "reexport";
416
417fn default_callgraph_outbound_provenance() -> String {
418    CALLGRAPH_PROVENANCE_TREESITTER.to_string()
419}
420
421#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
422pub struct CallgraphOutboundCall {
423    pub caller_file: PathBuf,
424    pub caller_symbol: String,
425    pub target: String,
426    pub line: u32,
427    #[serde(default = "default_callgraph_outbound_provenance")]
428    pub provenance: String,
429}
430
431#[derive(Debug, Clone)]
432pub struct FileContribution {
433    pub category: InspectCategory,
434    pub file_path: PathBuf,
435    pub freshness: FileFreshness,
436    pub contribution: serde_json::Value,
437    pub type_ref_names: BTreeSet<String>,
438}
439
440impl FileContribution {
441    pub fn new(
442        category: InspectCategory,
443        file_path: impl Into<PathBuf>,
444        freshness: FileFreshness,
445        contribution: serde_json::Value,
446    ) -> Self {
447        let type_ref_names = type_ref_names_from_contribution(&contribution);
448        Self {
449            category,
450            file_path: file_path.into(),
451            freshness,
452            contribution,
453            type_ref_names,
454        }
455    }
456
457    pub fn with_type_ref_names<I>(mut self, type_ref_names: I) -> Self
458    where
459        I: IntoIterator<Item = String>,
460    {
461        self.type_ref_names = type_ref_names.into_iter().collect();
462        self.contribution =
463            contribution_with_type_ref_names(self.contribution, &self.type_ref_names);
464        self
465    }
466}
467
468pub(crate) fn type_ref_names_from_contribution(
469    contribution: &serde_json::Value,
470) -> BTreeSet<String> {
471    contribution
472        .get("type_ref_names")
473        .and_then(serde_json::Value::as_array)
474        .into_iter()
475        .flatten()
476        .filter_map(serde_json::Value::as_str)
477        .map(str::trim)
478        .filter(|name| !name.is_empty())
479        .map(str::to_string)
480        .collect()
481}
482
483pub(crate) fn contribution_with_type_ref_names(
484    mut contribution: serde_json::Value,
485    type_ref_names: &BTreeSet<String>,
486) -> serde_json::Value {
487    if let serde_json::Value::Object(object) = &mut contribution {
488        if type_ref_names.is_empty() {
489            object.remove("type_ref_names");
490        } else {
491            object.insert(
492                "type_ref_names".to_string(),
493                serde_json::Value::Array(
494                    type_ref_names
495                        .iter()
496                        .map(|name| serde_json::Value::String(name.clone()))
497                        .collect(),
498                ),
499            );
500        }
501    }
502    contribution
503}
504
505#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
506#[serde(rename_all = "snake_case")]
507pub enum JobStatus {
508    Queued,
509    Running,
510    Completed,
511    Failed,
512}
513
514#[derive(Debug, Clone)]
515pub struct InspectScanSuccess {
516    pub scanned_files: Vec<PathBuf>,
517    pub contributions: Vec<FileContribution>,
518    pub aggregate: serde_json::Value,
519}
520
521#[derive(Debug, Clone)]
522pub struct InspectResult {
523    pub job_id: u64,
524    pub key: JobKey,
525    pub category: InspectCategory,
526    pub project_root: PathBuf,
527    pub inspect_dir: PathBuf,
528    pub config: Arc<Config>,
529    pub outcome: Result<InspectScanSuccess, String>,
530    pub duration: Duration,
531}
532
533impl InspectResult {
534    pub fn success(job: &InspectJob, success: InspectScanSuccess, duration: Duration) -> Self {
535        Self {
536            job_id: job.job_id,
537            key: job.key.clone(),
538            category: job.category,
539            project_root: job.project_root.clone(),
540            inspect_dir: job.inspect_dir.clone(),
541            config: Arc::clone(&job.config),
542            outcome: Ok(success),
543            duration,
544        }
545    }
546
547    pub fn failed(job: &InspectJob, message: impl Into<String>, duration: Duration) -> Self {
548        Self {
549            job_id: job.job_id,
550            key: job.key.clone(),
551            category: job.category,
552            project_root: job.project_root.clone(),
553            inspect_dir: job.inspect_dir.clone(),
554            config: Arc::clone(&job.config),
555            outcome: Err(message.into()),
556            duration,
557        }
558    }
559}
560
561#[derive(Debug, Clone, Serialize)]
562#[serde(tag = "status", rename_all = "snake_case")]
563pub enum JobOutcome {
564    Fresh {
565        payload: serde_json::Value,
566    },
567    Stale {
568        cached: Option<serde_json::Value>,
569        in_flight: bool,
570    },
571    Pending {
572        in_flight: bool,
573    },
574    Failed {
575        message: String,
576    },
577}
578
579impl JobOutcome {
580    pub fn payload(&self) -> Option<&serde_json::Value> {
581        match self {
582            JobOutcome::Fresh { payload } => Some(payload),
583            JobOutcome::Stale { cached, .. } => cached.as_ref(),
584            JobOutcome::Pending { .. } | JobOutcome::Failed { .. } => None,
585        }
586    }
587
588    pub fn is_stale(&self) -> bool {
589        matches!(self, JobOutcome::Stale { .. })
590    }
591
592    pub fn is_pending(&self) -> bool {
593        matches!(self, JobOutcome::Pending { .. })
594    }
595
596    pub fn summary_status(&self) -> Option<&'static str> {
597        match self {
598            JobOutcome::Fresh { .. } => None,
599            JobOutcome::Stale { .. } => Some("stale"),
600            JobOutcome::Pending { .. } => Some("pending"),
601            JobOutcome::Failed { .. } => Some("failed"),
602        }
603    }
604}
605
606/// Whether a project-relative path is a test-support / standalone file that
607/// should be EXCLUDED from dead_code and unused_exports reporting. These files
608/// (test fixtures, corpora, mock data, snapshots) are consumed by file path or
609/// loaded dynamically — never imported as modules — so static reachability
610/// always reports their symbols as dead/unused. They are noise, not signal.
611///
612/// Edges FROM these files are still honored elsewhere (their imports keep
613/// product code live); only their own exported symbols are suppressed from the
614/// dead/unused lists. The match is on conventional directory names anywhere in
615/// the path, kept conservative to avoid hiding real product directories.
616pub(crate) fn is_test_support_file(relative_path: &str) -> bool {
617    let normalized = relative_path.replace('\\', "/");
618    normalized.split('/').any(|segment| {
619        matches!(
620            segment,
621            "fixtures"
622                | "__fixtures__"
623                | "testdata"
624                | "test-data"
625                | "__mocks__"
626                | "__snapshots__"
627                | "corpora"
628        )
629    })
630}
631
632/// Whether a project-relative path is an actual automated-test file (unit /
633/// integration / spec), as opposed to product code. Used by `aft_search` to hide
634/// test files by default (the `include_tests` param shows them).
635///
636/// This is intentionally SEPARATE from `is_test_support_file`: dead_code and
637/// unused_exports must NOT use it, because a symbol called only from a test file
638/// is still live via that caller. Matching is high-precision to avoid hiding
639/// product code — filename conventions plus the `__tests__` directory segment,
640/// not bare `test`/`spec` directory names which collide with product modules.
641pub(crate) fn is_test_file(relative_path: &str) -> bool {
642    let normalized = relative_path.replace('\\', "/");
643
644    // Directory-segment conventions: `__tests__` (JS/TS) and a `tests` test root
645    // (Rust integration tests, Python). Singular `test`/`spec` are omitted —
646    // they collide with product modules and their files are caught by name below.
647    if normalized
648        .split('/')
649        .any(|segment| matches!(segment, "__tests__" | "__test__" | "tests"))
650    {
651        return true;
652    }
653
654    let file = normalized.rsplit('/').next().unwrap_or(&normalized);
655    let lower = file.to_ascii_lowercase();
656
657    // `*.test.<ext>` / `*.spec.<ext>` — JS/TS/JSX/TSX/Vue/mjs/cjs/…
658    if lower.contains(".test.") || lower.contains(".spec.") {
659        return true;
660    }
661
662    // Per-language filename suffixes (lowercase conventions).
663    if lower.ends_with("_test.rs")
664        || lower.ends_with("_test.go")
665        || lower.ends_with("_test.py")
666        || lower.ends_with("_test.rb")
667        || lower.ends_with("_test.exs")
668        || lower.ends_with("_spec.rb")
669        || (lower.starts_with("test_") && lower.ends_with(".py"))
670    {
671        return true;
672    }
673
674    // CamelCase test classes — matched case-SENSITIVELY so product files like
675    // `latest.java` (which ends with "test.java" lowercased) don't false-match.
676    const CAMEL_SUFFIXES: &[&str] = &[
677        "Test.java",
678        "Tests.java",
679        "Test.kt",
680        "Tests.kt",
681        "Test.cs",
682        "Tests.cs",
683        "Test.swift",
684        "Tests.swift",
685        "Test.scala",
686        "Spec.scala",
687    ];
688    CAMEL_SUFFIXES.iter().any(|suffix| file.ends_with(suffix))
689}
690
691pub(crate) fn normalize_path(path: &Path) -> PathBuf {
692    // Strip Windows verbatim prefixes before component normalization so paths
693    // that went through fs::canonicalize (\\?\C:\ form) compare equal to paths
694    // that never did. The oxc engine's resolver applies the same rule; the two
695    // normalizers must stay in agreement or membership/strip_prefix checks
696    // that cross the engine boundary silently miss on Windows.
697    #[cfg(windows)]
698    let path = &windows_non_verbatim_path(path);
699
700    let mut result = PathBuf::new();
701    for component in path.components() {
702        match component {
703            std::path::Component::CurDir => {}
704            std::path::Component::ParentDir => {
705                if !result.pop() {
706                    result.push(component);
707                }
708            }
709            other => result.push(other.as_os_str()),
710        }
711    }
712    result
713}
714
715/// Canonicalize a path for comparison inside the inspect subsystem.
716///
717/// Never returns the raw `fs::canonicalize` result: on Windows that is a
718/// verbatim (`\\?\C:\`) path, and mixing verbatim and non-verbatim forms in
719/// the same comparison is this subsystem's recurring bug class. Both branches
720/// route through [`normalize_path`].
721pub(crate) fn canonicalize_normalized(path: &Path) -> PathBuf {
722    match std::fs::canonicalize(path) {
723        Ok(canonical) => normalize_path(&canonical),
724        Err(_) => normalize_path(path),
725    }
726}
727
728#[cfg(windows)]
729fn windows_non_verbatim_path(path: &Path) -> PathBuf {
730    let mut raw = path.to_string_lossy().replace('/', "\\");
731    if let Some(stripped) = raw.strip_prefix("\\\\?\\UNC\\") {
732        raw = format!("\\\\{stripped}");
733    } else if let Some(stripped) = raw.strip_prefix("\\\\?\\") {
734        raw = stripped.to_string();
735    } else if let Some(stripped) = raw.strip_prefix("\\\\??\\") {
736        raw = stripped.to_string();
737    }
738
739    if raw.as_bytes().get(1) == Some(&b':') {
740        let drive = raw.as_bytes()[0];
741        if drive.is_ascii_lowercase() {
742            raw.replace_range(0..1, &(drive as char).to_ascii_uppercase().to_string());
743        }
744    }
745
746    PathBuf::from(raw)
747}
748
749#[cfg(test)]
750mod test_support_tests {
751    use super::{is_test_file, is_test_support_file};
752
753    #[test]
754    fn is_test_file_matches_real_test_files() {
755        // JS/TS family: *.test.* / *.spec.*
756        for p in [
757            "src/foo.test.ts",
758            "src/foo.test.tsx",
759            "src/bar.spec.js",
760            "packages/x/component.test.jsx",
761            "app/foo.test.mjs",
762            "src/comp.spec.vue",
763        ] {
764            assert!(is_test_file(p), "{p} should be a test file");
765        }
766        // __tests__ directory and tests roots.
767        assert!(is_test_file("packages/x/__tests__/reading.ts"));
768        assert!(is_test_file("crates/aft/tests/integration/main.rs"));
769        // Per-language filename suffixes.
770        assert!(is_test_file("crates/aft/src/foo_test.rs"));
771        assert!(is_test_file("pkg/handler_test.go"));
772        assert!(is_test_file("app/test_models.py"));
773        assert!(is_test_file("app/models_test.py"));
774        assert!(is_test_file("spec/user_spec.rb"));
775        // CamelCase test classes (case-sensitive).
776        assert!(is_test_file("src/main/UserServiceTest.java"));
777        assert!(is_test_file("src/FooTests.cs"));
778        assert!(is_test_file("Sources/AppTests.swift"));
779        // Windows separators normalize.
780        assert!(is_test_file("packages\\x\\__tests__\\a.ts"));
781    }
782
783    #[test]
784    fn is_test_file_rejects_product_files() {
785        for p in [
786            "crates/aft/src/inspect/job.rs",
787            "packages/x/src/index.ts",
788            "src/contestant.ts",     // "test" substring, not a test file
789            "src/greatest.ts",       // ends with "test" stem, not ".test."
790            "src/latest.java",       // case-sensitive guard: not "Test.java"
791            "src/my_attestation.py", // not test_*/*_test
792            "src/test/helper.ts",    // singular `test` dir must not blanket-match
793        ] {
794            assert!(!is_test_file(p), "{p} must NOT be a test file");
795        }
796    }
797
798    #[test]
799    fn matches_conventional_support_dirs() {
800        assert!(is_test_support_file("crates/aft/tests/fixtures/sample.ts"));
801        assert!(is_test_support_file(
802            "packages/x/__tests__/e2e/fixtures/a.ts"
803        ));
804        assert!(is_test_support_file(
805            "benchmarks/codegraph/corpora/repo/lib.go"
806        ));
807        assert!(is_test_support_file("src/__mocks__/fs.ts"));
808        assert!(is_test_support_file("src/__snapshots__/render.snap"));
809        assert!(is_test_support_file("internal/testdata/golden.json"));
810        // Windows-style separators normalize.
811        assert!(is_test_support_file("crates\\aft\\tests\\fixtures\\x.rs"));
812    }
813
814    #[test]
815    fn does_not_match_product_or_test_files() {
816        // A real product file under src must never be excluded.
817        assert!(!is_test_support_file("crates/aft/src/inspect/job.rs"));
818        // Test FILES are not support files (they hold real assertions/roots).
819        assert!(!is_test_support_file(
820            "packages/x/__tests__/reading.test.ts"
821        ));
822        assert!(!is_test_support_file(
823            "crates/aft/tests/integration/main.rs"
824        ));
825        // Substring of a segment must not match (only whole segments).
826        assert!(!is_test_support_file("src/fixturesHelper.ts"));
827        assert!(!is_test_support_file("src/my_corpora_loader.rs"));
828    }
829}