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