xbp_analysis/application/
engine.rs1use crate::application::rules::catalog;
4use crate::application::suppress::{is_suppressed, parse_inline_suppressions};
5use crate::domain::report::{AnalysisReport, SkippedRule};
6use crate::domain::request::AnalysisRequest;
7use crate::domain::rule::Rule;
8use crate::domain::types::{LanguageId, SourceFile};
9use crate::ports::inbound::{AnalysisService, LanguageAdapter, LanguageRegistry};
10use crate::ports::outbound::{SourceLoader, WorkspaceDiscovery};
11use std::time::Instant;
12
13pub struct AdapterRegistry {
15 adapters: Vec<Box<dyn LanguageAdapter>>,
16}
17
18impl AdapterRegistry {
19 pub fn new(adapters: Vec<Box<dyn LanguageAdapter>>) -> Self {
20 Self { adapters }
21 }
22
23 pub fn with_defaults() -> Self {
24 Self::new(vec![Box::new(
25 crate::languages::rust::RustAdapter::default(),
26 )])
27 }
28}
29
30impl LanguageRegistry for AdapterRegistry {
31 fn supported(&self) -> Vec<LanguageId> {
32 self.adapters.iter().map(|a| a.id()).collect()
33 }
34
35 fn get(&self, id: &LanguageId) -> Option<&dyn LanguageAdapter> {
36 self.adapters
37 .iter()
38 .find(|a| &a.id() == id)
39 .map(|a| a.as_ref())
40 }
41}
42
43pub struct DefaultAnalysisService {
45 registry: AdapterRegistry,
46 discovery: Box<dyn WorkspaceDiscovery>,
47 loader: Box<dyn SourceLoader>,
48}
49
50impl DefaultAnalysisService {
51 pub fn new(
52 registry: AdapterRegistry,
53 discovery: Box<dyn WorkspaceDiscovery>,
54 loader: Box<dyn SourceLoader>,
55 ) -> Self {
56 Self {
57 registry,
58 discovery,
59 loader,
60 }
61 }
62
63 pub fn with_defaults() -> Self {
64 Self::new(
65 AdapterRegistry::with_defaults(),
66 Box::new(crate::adapters::filesystem::IgnoreWalkDiscovery::default()),
67 Box::new(crate::adapters::filesystem::FsSourceLoader),
68 )
69 }
70
71 fn languages_for_request(&self, request: &AnalysisRequest) -> Vec<LanguageId> {
72 if let Some(ref only) = request.language_filter {
73 return vec![only.clone()];
74 }
75 if !request.config.languages.is_empty() {
76 return request.config.languages.clone();
77 }
78 self.registry.supported()
79 }
80
81 fn select_rules(
82 &self,
83 adapter: &dyn LanguageAdapter,
84 request: &AnalysisRequest,
85 skipped: &mut Vec<SkippedRule>,
86 ) -> Vec<Box<dyn Rule>> {
87 let caps = adapter.capabilities();
88 let mut selected = Vec::new();
89 for rule in catalog() {
90 let meta = rule.meta();
91 if !request.config.rules.allows(&meta.id) {
92 continue;
93 }
94 if !caps.is_superset_of(&meta.required_capabilities) {
95 skipped.push(SkippedRule {
96 rule_id: meta.id.clone(),
97 reason: format!(
98 "adapter `{}` missing required capabilities for rule",
99 adapter.id()
100 ),
101 });
102 continue;
103 }
104 selected.push(rule);
105 }
106 selected
107 }
108}
109
110impl AnalysisService for DefaultAnalysisService {
111 fn analyze(&self, request: &AnalysisRequest) -> Result<AnalysisReport, String> {
112 let started = Instant::now();
113 let languages = self.languages_for_request(request);
114 let mut exclusions = request.config.path_exclusions.clone();
115 exclusions.extend(request.config.generated_exclusions.iter().cloned());
116
117 let mut paths = if request.paths.is_empty() {
118 self.discovery
119 .discover(&request.root, &languages, &exclusions)?
120 } else {
121 request.paths.clone()
122 };
123
124 let _ = request.workspace;
126 paths.sort();
127 paths.dedup();
128
129 let mut report = AnalysisReport {
130 findings: Vec::new(),
131 files_analyzed: 0,
132 languages: languages.clone(),
133 duration_ms: 0,
134 skipped_rules: Vec::new(),
135 stats: Default::default(),
136 };
137
138 for lang in &languages {
139 let Some(adapter) = self.registry.get(lang) else {
140 continue;
141 };
142 let mut sources: Vec<SourceFile> = Vec::new();
143 for path in &paths {
144 if !adapter.detect(path) {
145 continue;
146 }
147 match self.loader.load(path, lang.clone()) {
148 Ok(src) => sources.push(src),
149 Err(e) => {
150 let _ = e;
152 }
153 }
154 }
155 report.files_analyzed += sources.len();
156
157 let mut config = request.config.clone();
158 for src in &sources {
159 let inline = parse_inline_suppressions(&src.content, &src.path);
160 config.suppressions.extend(inline);
161 }
162
163 let model = adapter.build_model(&sources)?;
164 let rules = self.select_rules(adapter, request, &mut report.skipped_rules);
165 let mut findings = adapter.analyze_language(&model, &rules);
166 findings.extend(adapter.language_specific_findings(&model));
167
168 for f in findings {
169 if is_suppressed(&f, &config) {
170 continue;
171 }
172 if let Some(min) = request.config.min_severity.or(request.config.rules.min_severity)
173 {
174 if f.severity < min {
175 continue;
176 }
177 }
178 report.findings.push(f);
179 }
180 }
181
182 report.sort_stable();
183 report.recompute_stats();
184 report.duration_ms = started.elapsed().as_millis() as u64;
185 Ok(report)
186 }
187}
188
189pub struct AnalysisEngine {
191 service: DefaultAnalysisService,
192}
193
194impl AnalysisEngine {
195 pub fn new() -> Self {
196 Self {
197 service: DefaultAnalysisService::with_defaults(),
198 }
199 }
200
201 pub fn analyze(&self, request: &AnalysisRequest) -> Result<AnalysisReport, String> {
202 self.service.analyze(request)
203 }
204
205 pub fn supported_languages(&self) -> Vec<LanguageId> {
206 self.service.registry.supported()
207 }
208}
209
210impl Default for AnalysisEngine {
211 fn default() -> Self {
212 Self::new()
213 }
214}