1use std::collections::{HashMap, HashSet};
2use std::path::{Path, PathBuf};
3use std::sync::Arc;
4
5use crate::config::ConflicConfig;
6use crate::extract::Extractor;
7use crate::model::{ConceptResult, ConfigAssertion, ParseDiagnostic, ScanResult};
8use crate::pipeline::{ScannedFile, process_file_plans};
9use crate::planning::{
10 FileScanPlan, build_file_scan_plan, normalize_changed_files, normalize_content_overrides,
11 prepare_scan,
12};
13use crate::solve;
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum IncrementalScanKind {
17 Full,
18 Incremental,
19}
20
21#[derive(Debug, Clone)]
22pub struct IncrementalScanStats {
23 pub kind: IncrementalScanKind,
24 pub parsed_files: usize,
25 pub changed_files: usize,
26 pub peer_files: usize,
27 pub impacted_concepts: usize,
28}
29
30impl Default for IncrementalScanStats {
31 fn default() -> Self {
32 Self {
33 kind: IncrementalScanKind::Full,
34 parsed_files: 0,
35 changed_files: 0,
36 peer_files: 0,
37 impacted_concepts: 0,
38 }
39 }
40}
41
42pub struct IncrementalWorkspace {
43 root: PathBuf,
44 config: ConflicConfig,
45 extractors: Vec<Box<dyn Extractor>>,
46 initial_diagnostics: Vec<ParseDiagnostic>,
47 file_plans: HashMap<PathBuf, FileScanPlan>,
48 concept_candidates: HashMap<String, HashSet<PathBuf>>,
49 file_outcomes: HashMap<PathBuf, ScannedFile>,
50 concept_assertion_files: HashMap<String, HashSet<PathBuf>>,
51 concept_results: HashMap<String, ConceptResult>,
52 parse_diagnostics: HashMap<PathBuf, Vec<ParseDiagnostic>>,
53 last_stats: IncrementalScanStats,
54}
55
56impl IncrementalWorkspace {
57 pub fn new(root: &Path, config: &ConflicConfig) -> Self {
58 let preparation = prepare_scan(root, config);
59 let mut workspace = Self {
60 root: root.to_path_buf(),
61 config: config.clone(),
62 extractors: preparation.extractors,
63 initial_diagnostics: preparation.initial_diagnostics,
64 file_plans: HashMap::new(),
65 concept_candidates: HashMap::new(),
66 file_outcomes: HashMap::new(),
67 concept_assertion_files: HashMap::new(),
68 concept_results: HashMap::new(),
69 parse_diagnostics: HashMap::new(),
70 last_stats: IncrementalScanStats::default(),
71 };
72
73 for plan in preparation.file_plans {
74 workspace.insert_file_plan(plan);
75 }
76
77 workspace
78 }
79
80 pub fn full_scan(&mut self, content_overrides: &HashMap<PathBuf, String>) -> ScanResult {
81 let normalized_overrides =
82 Arc::new(normalize_content_overrides(&self.root, content_overrides));
83 let mut plans: Vec<FileScanPlan> = self.file_plans.values().cloned().collect();
84 plans.sort_by(|left, right| left.path.cmp(&right.path));
85
86 let outcomes = process_file_plans(
87 &self.root,
88 &plans,
89 &self.extractors,
90 Some(&normalized_overrides),
91 );
92 self.rebuild_from_outcomes(outcomes);
93 self.last_stats = IncrementalScanStats {
94 kind: IncrementalScanKind::Full,
95 parsed_files: plans.len(),
96 changed_files: plans.len(),
97 peer_files: 0,
98 impacted_concepts: self.concept_results.len(),
99 };
100 self.current_scan_result()
101 }
102
103 pub fn scan_incremental(
104 &mut self,
105 changed_files: &[PathBuf],
106 content_overrides: &HashMap<PathBuf, String>,
107 ) -> ScanResult {
108 let changed_normalized = normalize_changed_files(&self.root, changed_files);
109 if changed_normalized.is_empty() {
110 self.last_stats = IncrementalScanStats {
111 kind: IncrementalScanKind::Incremental,
112 ..IncrementalScanStats::default()
113 };
114 return self.current_scan_result();
115 }
116
117 let normalized_overrides =
118 Arc::new(normalize_content_overrides(&self.root, content_overrides));
119 let mut changed_plans = Vec::new();
120 let mut changed_paths = HashSet::new();
121 let mut impacted_concepts = HashSet::new();
122
123 for normalized_path in changed_normalized {
124 impacted_concepts.extend(self.file_outcome_concepts(&normalized_path));
125
126 if let Some(previous_plan) = self.file_plans.get(&normalized_path) {
127 impacted_concepts.extend(previous_plan.concept_ids.iter().cloned());
128 }
129
130 match self.ensure_file_plan(&normalized_path, &normalized_overrides) {
131 Some(plan) => {
132 impacted_concepts.extend(plan.concept_ids.iter().cloned());
133 changed_paths.insert(plan.normalized_path.clone());
134 changed_plans.push(plan);
135 }
136 None => {
137 self.remove_file_plan(&normalized_path);
138 self.remove_file_outcome(&normalized_path);
139 }
140 }
141 }
142
143 let changed_outcomes = process_file_plans(
144 &self.root,
145 &changed_plans,
146 &self.extractors,
147 Some(&normalized_overrides),
148 );
149
150 for outcome in &changed_outcomes {
151 impacted_concepts.extend(
152 outcome
153 .assertions
154 .iter()
155 .map(|assertion| assertion.concept.id.clone()),
156 );
157 }
158
159 let peer_paths: HashSet<PathBuf> = impacted_concepts
160 .iter()
161 .flat_map(|concept_id| {
162 self.concept_candidates
163 .get(concept_id)
164 .into_iter()
165 .flat_map(|paths| paths.iter().cloned())
166 })
167 .filter(|path| !changed_paths.contains(path))
168 .collect();
169
170 let mut peer_plans: Vec<FileScanPlan> = peer_paths
171 .iter()
172 .filter_map(|path| self.file_plans.get(path).cloned())
173 .collect();
174 peer_plans.sort_by(|left, right| left.path.cmp(&right.path));
175
176 let peer_outcomes = process_file_plans(
177 &self.root,
178 &peer_plans,
179 &self.extractors,
180 Some(&normalized_overrides),
181 );
182
183 for outcome in changed_outcomes.into_iter().chain(peer_outcomes) {
184 self.upsert_file_outcome(outcome);
185 }
186
187 self.recompute_concept_results(&impacted_concepts);
188 self.last_stats = IncrementalScanStats {
189 kind: IncrementalScanKind::Incremental,
190 parsed_files: changed_plans.len() + peer_plans.len(),
191 changed_files: changed_plans.len(),
192 peer_files: peer_plans.len(),
193 impacted_concepts: impacted_concepts.len(),
194 };
195
196 self.current_scan_result()
197 }
198
199 pub fn current_scan_result(&self) -> ScanResult {
200 let mut concept_results: Vec<ConceptResult> =
201 self.concept_results.values().cloned().collect();
202 concept_results
203 .sort_by(|left, right| left.concept.display_name.cmp(&right.concept.display_name));
204
205 let mut parse_diagnostics = self.initial_diagnostics.clone();
206 let mut cached_diagnostics: Vec<ParseDiagnostic> = self
207 .parse_diagnostics
208 .values()
209 .flat_map(|diagnostics| diagnostics.iter().cloned())
210 .collect();
211 cached_diagnostics.sort_by(|left, right| {
212 left.file
213 .cmp(&right.file)
214 .then_with(|| left.rule_id.cmp(&right.rule_id))
215 .then_with(|| left.message.cmp(&right.message))
216 });
217 parse_diagnostics.extend(cached_diagnostics);
218
219 ScanResult {
220 concept_results,
221 parse_diagnostics,
222 }
223 }
224
225 pub fn last_stats(&self) -> IncrementalScanStats {
226 self.last_stats.clone()
227 }
228
229 fn rebuild_from_outcomes(&mut self, outcomes: Vec<ScannedFile>) {
230 self.file_outcomes.clear();
231 self.concept_assertion_files.clear();
232 self.concept_results.clear();
233 self.parse_diagnostics.clear();
234
235 for outcome in outcomes {
236 self.upsert_file_outcome(outcome);
237 }
238
239 let impacted_concepts: HashSet<String> =
240 self.concept_assertion_files.keys().cloned().collect();
241 self.recompute_concept_results(&impacted_concepts);
242 }
243
244 fn ensure_file_plan(
245 &mut self,
246 normalized_path: &Path,
247 content_overrides: &HashMap<PathBuf, String>,
248 ) -> Option<FileScanPlan> {
249 let path = crate::pathing::normalize_if_within_root(&self.root, normalized_path)?;
250
251 if let Some(plan) = self.file_plans.get(&path) {
252 return Some(plan.clone());
253 }
254
255 if !content_overrides.contains_key(&path) && !path.exists() {
256 return None;
257 }
258
259 let filename = path.file_name()?.to_str()?.to_string();
260 let plan = build_file_scan_plan(&self.root, &filename, &path, &self.extractors);
261 if plan.extractor_indices.is_empty() {
262 return None;
263 }
264
265 self.insert_file_plan(plan.clone());
266 Some(plan)
267 }
268
269 fn insert_file_plan(&mut self, plan: FileScanPlan) {
270 let normalized_path = plan.normalized_path.clone();
271 for concept_id in &plan.concept_ids {
272 self.concept_candidates
273 .entry(concept_id.clone())
274 .or_default()
275 .insert(normalized_path.clone());
276 }
277 self.file_plans.insert(normalized_path, plan);
278 }
279
280 fn remove_file_plan(&mut self, normalized_path: &Path) {
281 if let Some(plan) = self.file_plans.remove(normalized_path) {
282 for concept_id in &plan.concept_ids {
283 if let Some(paths) = self.concept_candidates.get_mut(concept_id) {
284 paths.remove(normalized_path);
285 if paths.is_empty() {
286 self.concept_candidates.remove(concept_id);
287 }
288 }
289 }
290 }
291 }
292
293 fn upsert_file_outcome(&mut self, outcome: ScannedFile) {
294 let normalized_path = outcome.normalized_path.clone();
295 self.remove_file_outcome(&normalized_path);
296
297 for assertion in &outcome.assertions {
298 self.concept_assertion_files
299 .entry(assertion.concept.id.clone())
300 .or_default()
301 .insert(normalized_path.clone());
302 }
303
304 if outcome.parse_diagnostics.is_empty() {
305 self.parse_diagnostics.remove(&normalized_path);
306 } else {
307 self.parse_diagnostics
308 .insert(normalized_path.clone(), outcome.parse_diagnostics.clone());
309 }
310
311 self.file_outcomes.insert(normalized_path, outcome);
312 }
313
314 fn remove_file_outcome(&mut self, normalized_path: &Path) {
315 if let Some(previous) = self.file_outcomes.remove(normalized_path) {
316 for concept_id in previous
317 .assertions
318 .iter()
319 .map(|assertion| &assertion.concept.id)
320 {
321 if let Some(paths) = self.concept_assertion_files.get_mut(concept_id) {
322 paths.remove(normalized_path);
323 if paths.is_empty() {
324 self.concept_assertion_files.remove(concept_id);
325 }
326 }
327 }
328 }
329
330 self.parse_diagnostics.remove(normalized_path);
331 }
332
333 fn file_outcome_concepts(&self, normalized_path: &Path) -> HashSet<String> {
334 self.file_outcomes
335 .get(normalized_path)
336 .map(|outcome| {
337 outcome
338 .assertions
339 .iter()
340 .map(|assertion| assertion.concept.id.clone())
341 .collect()
342 })
343 .unwrap_or_default()
344 }
345
346 fn recompute_concept_results(&mut self, impacted_concepts: &HashSet<String>) {
347 let solvers = self.build_solver_registry();
348 for concept_id in impacted_concepts {
349 let assertions = self.assertions_for_concept(concept_id);
350 let mut results = solve::compare_assertions_with_solvers(
351 &self.root,
352 assertions,
353 &self.config,
354 &solvers,
355 );
356
357 crate::policy::evaluate_policies(&mut results, &self.config);
358
359 let result = results
360 .into_iter()
361 .find(|concept_result| concept_result.concept.id == *concept_id);
362
363 match result {
364 Some(concept_result) => {
365 self.concept_results
366 .insert(concept_id.clone(), concept_result);
367 }
368 None => {
369 self.concept_results.remove(concept_id);
370 }
371 }
372 }
373 }
374
375 fn assertions_for_concept(&self, concept_id: &str) -> Vec<ConfigAssertion> {
376 self.concept_assertion_files
377 .get(concept_id)
378 .into_iter()
379 .flat_map(|paths| paths.iter())
380 .filter_map(|path| self.file_outcomes.get(path))
381 .flat_map(|outcome| {
382 outcome
383 .assertions
384 .iter()
385 .filter(|assertion| assertion.concept.id == concept_id)
386 .cloned()
387 })
388 .collect()
389 }
390
391 fn build_solver_registry(&self) -> solve::SolverRegistry {
392 let mut registry = solve::SolverRegistry::new();
393 for custom in &self.config.custom_extractor {
394 if let Some(ref solver_name) = custom.solver
395 && let Some(solver) = solve::registry::solver_from_name(solver_name)
396 {
397 registry.register(custom.concept.clone(), solver);
398 }
399 }
400 registry
401 }
402}