Skip to main content

aft/inspect/
job.rs

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
344#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
345pub struct CallgraphOutboundCall {
346    pub caller_file: PathBuf,
347    pub caller_symbol: String,
348    pub target: String,
349    pub line: u32,
350}
351
352#[derive(Debug, Clone)]
353pub struct FileContribution {
354    pub category: InspectCategory,
355    pub file_path: PathBuf,
356    pub freshness: FileFreshness,
357    pub contribution: serde_json::Value,
358}
359
360impl FileContribution {
361    pub fn new(
362        category: InspectCategory,
363        file_path: impl Into<PathBuf>,
364        freshness: FileFreshness,
365        contribution: serde_json::Value,
366    ) -> Self {
367        Self {
368            category,
369            file_path: file_path.into(),
370            freshness,
371            contribution,
372        }
373    }
374}
375
376#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
377#[serde(rename_all = "snake_case")]
378pub enum JobStatus {
379    Queued,
380    Running,
381    Completed,
382    Failed,
383}
384
385#[derive(Debug, Clone)]
386pub struct InspectScanSuccess {
387    pub scanned_files: Vec<PathBuf>,
388    pub contributions: Vec<FileContribution>,
389    pub aggregate: serde_json::Value,
390}
391
392#[derive(Debug, Clone)]
393pub struct InspectResult {
394    pub job_id: u64,
395    pub key: JobKey,
396    pub category: InspectCategory,
397    pub project_root: PathBuf,
398    pub inspect_dir: PathBuf,
399    pub outcome: Result<InspectScanSuccess, String>,
400    pub duration: Duration,
401}
402
403impl InspectResult {
404    pub fn success(job: &InspectJob, success: InspectScanSuccess, duration: Duration) -> Self {
405        Self {
406            job_id: job.job_id,
407            key: job.key.clone(),
408            category: job.category,
409            project_root: job.project_root.clone(),
410            inspect_dir: job.inspect_dir.clone(),
411            outcome: Ok(success),
412            duration,
413        }
414    }
415
416    pub fn failed(job: &InspectJob, message: impl Into<String>, duration: Duration) -> Self {
417        Self {
418            job_id: job.job_id,
419            key: job.key.clone(),
420            category: job.category,
421            project_root: job.project_root.clone(),
422            inspect_dir: job.inspect_dir.clone(),
423            outcome: Err(message.into()),
424            duration,
425        }
426    }
427}
428
429#[derive(Debug, Clone, Serialize)]
430#[serde(tag = "status", rename_all = "snake_case")]
431pub enum JobOutcome {
432    Fresh {
433        payload: serde_json::Value,
434    },
435    Stale {
436        cached: Option<serde_json::Value>,
437        in_flight: bool,
438    },
439    Pending {
440        in_flight: bool,
441    },
442    Failed {
443        message: String,
444    },
445}
446
447impl JobOutcome {
448    pub fn payload(&self) -> Option<&serde_json::Value> {
449        match self {
450            JobOutcome::Fresh { payload } => Some(payload),
451            JobOutcome::Stale { cached, .. } => cached.as_ref(),
452            JobOutcome::Pending { .. } | JobOutcome::Failed { .. } => None,
453        }
454    }
455
456    pub fn is_stale(&self) -> bool {
457        matches!(self, JobOutcome::Stale { .. })
458    }
459
460    pub fn is_pending(&self) -> bool {
461        matches!(self, JobOutcome::Pending { .. })
462    }
463
464    pub fn summary_status(&self) -> Option<&'static str> {
465        match self {
466            JobOutcome::Fresh { .. } => None,
467            JobOutcome::Stale { .. } => Some("stale"),
468            JobOutcome::Pending { .. } => Some("pending"),
469            JobOutcome::Failed { .. } => Some("failed"),
470        }
471    }
472}
473
474pub(crate) fn normalize_path(path: &Path) -> PathBuf {
475    let mut result = PathBuf::new();
476    for component in path.components() {
477        match component {
478            std::path::Component::CurDir => {}
479            std::path::Component::ParentDir => {
480                if !result.pop() {
481                    result.push(component);
482                }
483            }
484            other => result.push(other.as_os_str()),
485        }
486    }
487    result
488}