Skip to main content

conflic/
workspace.rs

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}