Skip to main content

xbp_analysis/application/
engine.rs

1//! Analysis orchestration (depends only on ports + domain + rules).
2
3use 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
13/// Default in-process registry.
14pub 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
43/// Default analysis service implementation.
44pub 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        // Workspace mode: include all packages under root already handled by discover.
125        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                        // Soft-skip unreadable files
151                        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
189/// Convenience façade used by the CLI.
190pub 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}