Skip to main content

rac_engine/
compare.rs

1//! Repository state comparison (`decided.services.compare`): `load_state` walks
2//! one directory into a fully analysed `RepoState`; `compare_states` derives
3//! every delta between two states — changed artifacts, validation delta,
4//! relationship delta, statistics delta. Artifacts are matched by
5//! corpus-relative path (`os.path.relpath(entry.path, directory)`), so the
6//! two states may live anywhere on disk (a working tree and a materialized
7//! git revision, or two fixture directories). A rename reports as removed
8//! plus added.
9//!
10//! Numbers come from the existing analyses exactly as in the oracle:
11//! validation counts from the portfolio summary (validated WITHOUT the
12//! ticketing provider), per-file statuses from the directory-validation
13//! path (WITH the provider) — the two are kept distinct on purpose.
14
15use std::collections::{BTreeSet, HashMap};
16
17use crate::commands::{STATUS_INVALID, STATUS_SKIPPED, STATUS_VALID};
18use crate::diff::{diff as diff_products, Diff};
19use crate::identity::artifact_identifier;
20use crate::parse::Artifact;
21use crate::portfolio::{portfolio_from_corpus, PortfolioSummary};
22use crate::pycompat::py_relpath;
23use crate::relationships::{
24    corpus_items, relationships_from_corpus, rows_from_corpus_items, validation_from_rows,
25    Relationship, RelationshipIssue, RelationshipSummary,
26};
27use crate::validate::{
28    apply_overrides, has_errors, load_overrides, load_ticketing_provider, validate,
29};
30
31// Stable change kinds (part of the watchkeeper JSON contract, ADR-007).
32pub const CHANGE_ADDED: &str = "added";
33pub const CHANGE_MODIFIED: &str = "modified";
34pub const CHANGE_REMOVED: &str = "removed";
35
36fn change_order(kind: &str) -> u8 {
37    match kind {
38        CHANGE_ADDED => 0,
39        CHANGE_MODIFIED => 1,
40        _ => 2,
41    }
42}
43
44/// One relationship-validation finding, keyed for cross-state set diffing —
45/// fields mirror `RelationshipIssue` with paths made corpus-relative so the
46/// same broken reference compares equal across a materialized base revision
47/// and the working tree.
48#[derive(Debug, Clone, PartialEq, Eq)]
49pub struct RelationshipIssueRef {
50    pub code: String,
51    pub relationship: Option<String>,
52    pub target: Option<String>,
53    pub path: String, // corpus-relative source ("" for repository-level findings)
54    pub identifier: Option<String>,
55}
56
57type IssueKey<'a> = (&'a str, &'a str, &'a str, &'a str, &'a str);
58
59fn issue_sort_key(r: &RelationshipIssueRef) -> IssueKey<'_> {
60    (
61        &r.code,
62        &r.path,
63        r.relationship.as_deref().unwrap_or(""),
64        r.target.as_deref().unwrap_or(""),
65        r.identifier.as_deref().unwrap_or(""),
66    )
67}
68
69/// The per-path artifact join the comparison reads: index identity +
70/// directory-validation status (`valid`/`invalid`/`skipped`).
71#[derive(Debug, Clone)]
72pub struct StateArtifact {
73    pub id: String,
74    pub type_name: String, // canonical artifact name, or "unknown"
75    pub title: Option<String>,
76    pub status: &'static str,
77}
78
79/// One corpus file in a state: its corpus-relative key, parsed product,
80/// raw bytes (the change detector), and artifact join.
81pub struct StateEntry {
82    pub rel: String,
83    pub artifact: Artifact,
84    pub raw: Vec<u8>,
85    pub info: StateArtifact,
86}
87
88/// One fully analysed repository state, keyed by corpus-relative path.
89pub struct RepoState {
90    pub label: String,
91    pub directory: String,
92    pub portfolio: PortfolioSummary,
93    /// Every declared reference, resolved (source/resolved paths are the
94    /// walk's display paths — relativize with [`RepoState::rel_of`]).
95    pub relationships: Vec<Relationship>,
96    /// Walk-ordered entries; paths are unique.
97    pub entries: Vec<StateEntry>,
98    /// Relationship-validation findings, corpus-relative and sorted.
99    pub issues: Vec<RelationshipIssueRef>,
100}
101
102impl RepoState {
103    pub fn entry(&self, rel: &str) -> Option<&StateEntry> {
104        self.entries.iter().find(|e| e.rel == rel)
105    }
106
107    /// `os.path.relpath(path, state.directory)` for display paths.
108    pub fn rel_of(&self, path: &str) -> String {
109        py_relpath(path, &self.directory)
110    }
111}
112
113fn issue_ref(issue: &RelationshipIssue, directory: &str) -> RelationshipIssueRef {
114    let path = if let Some(source) = &issue.source_path {
115        py_relpath(source, directory)
116    } else if issue.paths.as_ref().is_some_and(|p| !p.is_empty()) {
117        // Duplicate-identifier findings span files; key on the sorted set.
118        let mut rels: Vec<String> = issue
119            .paths
120            .as_ref()
121            .unwrap()
122            .iter()
123            .map(|p| py_relpath(p, directory))
124            .collect();
125        rels.sort();
126        rels.join(", ")
127    } else {
128        String::new()
129    };
130    RelationshipIssueRef {
131        code: issue.code.clone(),
132        relationship: issue.relationship.clone(),
133        target: issue.target.clone(),
134        path,
135        identifier: issue.identifier.clone(),
136    }
137}
138
139/// `load_state(directory, label)` — walk `directory` once and analyse it as
140/// one comparison side.
141pub fn load_state(directory: &str, label: &str) -> RepoState {
142    let items = corpus_items(directory, true);
143    let rows = rows_from_corpus_items(&items);
144    let portfolio = portfolio_from_corpus(directory, &items, true);
145    let relationships = relationships_from_corpus(&items);
146    let rel_validation = validation_from_rows(directory, &rows, true);
147
148    let overrides = load_overrides(directory);
149    let provider = load_ticketing_provider(directory);
150
151    let mut entries: Vec<StateEntry> = Vec::with_capacity(items.len());
152    for item in items {
153        let rel = py_relpath(&item.path, directory);
154        let type_name = item
155            .spec
156            .map(|s| s.name.clone())
157            .unwrap_or_else(|| "unknown".to_string());
158        let status = if item.spec.is_none() {
159            STATUS_SKIPPED
160        } else {
161            let issues = apply_overrides(
162                validate(&item.artifact, provider.as_deref(), Some(&type_name)),
163                &type_name,
164                &overrides,
165            );
166            if has_errors(&issues) {
167                STATUS_INVALID
168            } else {
169                STATUS_VALID
170            }
171        };
172        let info = StateArtifact {
173            id: artifact_identifier(&item.artifact, item.spec, &item.path),
174            type_name,
175            title: item.artifact.product.title.clone(),
176            status,
177        };
178        // The oracle re-reads each file (`read_text`) as the change
179        // detector; byte equality == decoded-text equality for the valid
180        // UTF-8 the corpus contract requires.
181        let raw = std::fs::read(&item.path).unwrap_or_default();
182        entries.push(StateEntry {
183            rel,
184            artifact: item.artifact,
185            raw,
186            info,
187        });
188    }
189
190    let mut issues: Vec<RelationshipIssueRef> = rel_validation
191        .issues
192        .iter()
193        .map(|issue| issue_ref(issue, directory))
194        .collect();
195    issues.sort_by(|a, b| issue_sort_key(a).cmp(&issue_sort_key(b)));
196
197    RepoState {
198        label: label.to_string(),
199        directory: directory.to_string(),
200        portfolio,
201        relationships,
202        entries,
203        issues,
204    }
205}
206
207/// One artifact that differs between the base and head states.
208#[derive(Debug)]
209pub struct ArtifactChange {
210    pub change: &'static str, // CHANGE_ADDED | CHANGE_MODIFIED | CHANGE_REMOVED
211    pub type_name: String,    // canonical artifact name, or "unknown"
212    pub id: Option<String>,
213    pub title: Option<String>,
214    pub path: String, // corpus-relative (the matching key)
215    pub base_status: Option<&'static str>,
216    pub head_status: Option<&'static str>,
217    pub diff: Option<Diff>, // requirement-level diff for modified artifacts
218}
219
220/// How validation outcomes moved between the states.
221pub struct ValidationDelta {
222    pub base_valid: usize,
223    pub base_invalid: usize,
224    pub head_valid: usize,
225    pub head_invalid: usize,
226    pub newly_invalid: Vec<String>,
227    pub newly_valid: Vec<String>,
228}
229
230/// How relationship integrity moved between the states.
231pub struct RelationshipDelta {
232    pub base: RelationshipSummary,
233    pub head: RelationshipSummary,
234    pub new_issues: Vec<RelationshipIssueRef>,
235    pub resolved_issues: Vec<RelationshipIssueRef>,
236}
237
238/// How repository-level artifact counts moved between the states.
239pub struct StatsDelta {
240    pub by_type: Vec<(String, (usize, usize))>, // type -> (base, head)
241    pub total: (usize, usize),
242}
243
244/// Everything that changed between a base and a head repository state.
245pub struct RepositoryComparison {
246    pub base: RepoState,
247    pub head: RepoState,
248    pub changes: Vec<ArtifactChange>,
249    pub validation: ValidationDelta,
250    pub relationships: RelationshipDelta,
251    pub stats: StatsDelta,
252}
253
254fn make_change(
255    kind: &'static str,
256    rel: &str,
257    artifact: Option<&StateArtifact>,
258    base_status: Option<&'static str>,
259    head_status: Option<&'static str>,
260    diff: Option<Diff>,
261) -> ArtifactChange {
262    ArtifactChange {
263        change: kind,
264        type_name: artifact
265            .map(|a| a.type_name.clone())
266            .unwrap_or_else(|| "unknown".to_string()),
267        id: artifact.map(|a| a.id.clone()),
268        title: artifact.and_then(|a| a.title.clone()),
269        path: rel.to_string(),
270        base_status,
271        head_status,
272        diff,
273    }
274}
275
276/// One difference set the issue delta needs: items of `from` not present in
277/// `other`, unique, sorted by the Python tuple key (set-difference-then-sort
278/// semantics).
279fn issue_difference(
280    from: &[RelationshipIssueRef],
281    other: &[RelationshipIssueRef],
282) -> Vec<RelationshipIssueRef> {
283    let mut out: Vec<RelationshipIssueRef> = Vec::new();
284    for issue in from {
285        if !other.contains(issue) && !out.contains(issue) {
286            out.push(issue.clone());
287        }
288    }
289    out.sort_by(|a, b| issue_sort_key(a).cmp(&issue_sort_key(b)));
290    out
291}
292
293/// `compare_states(base, head)` — derive every delta between two states.
294pub fn compare_states(base: RepoState, head: RepoState) -> RepositoryComparison {
295    let base_paths: BTreeSet<&str> = base.entries.iter().map(|e| e.rel.as_str()).collect();
296    let head_paths: BTreeSet<&str> = head.entries.iter().map(|e| e.rel.as_str()).collect();
297
298    let mut changes: Vec<ArtifactChange> = Vec::new();
299    for rel in head_paths.difference(&base_paths) {
300        let entry = head.entry(rel).expect("head entry present");
301        changes.push(make_change(
302            CHANGE_ADDED,
303            rel,
304            Some(&entry.info),
305            None,
306            Some(entry.info.status),
307            None,
308        ));
309    }
310    for rel in base_paths.intersection(&head_paths) {
311        let base_entry = base.entry(rel).expect("base entry present");
312        let head_entry = head.entry(rel).expect("head entry present");
313        if base_entry.raw == head_entry.raw {
314            continue;
315        }
316        let product_diff = diff_products(&base_entry.artifact, &head_entry.artifact);
317        changes.push(make_change(
318            CHANGE_MODIFIED,
319            rel,
320            Some(&head_entry.info),
321            Some(base_entry.info.status),
322            Some(head_entry.info.status),
323            if product_diff.is_empty() {
324                None
325            } else {
326                Some(product_diff)
327            },
328        ));
329    }
330    for rel in base_paths.difference(&head_paths) {
331        let entry = base.entry(rel).expect("base entry present");
332        changes.push(make_change(
333            CHANGE_REMOVED,
334            rel,
335            Some(&entry.info),
336            Some(entry.info.status),
337            None,
338            None,
339        ));
340    }
341    changes.sort_by(|a, b| {
342        change_order(a.change)
343            .cmp(&change_order(b.change))
344            .then_with(|| a.path.cmp(&b.path))
345    });
346
347    let base_status: HashMap<&str, &'static str> = base
348        .entries
349        .iter()
350        .map(|e| (e.rel.as_str(), e.info.status))
351        .collect();
352    let mut newly_invalid: Vec<String> = head
353        .entries
354        .iter()
355        .filter(|e| {
356            e.info.status == STATUS_INVALID
357                && base_status
358                    .get(e.rel.as_str())
359                    .map(|s| *s != STATUS_INVALID)
360                    .unwrap_or(true)
361        })
362        .map(|e| e.rel.clone())
363        .collect();
364    newly_invalid.sort();
365    let mut newly_valid: Vec<String> = head
366        .entries
367        .iter()
368        .filter(|e| {
369            e.info.status == STATUS_VALID
370                && base_status.get(e.rel.as_str()) == Some(&STATUS_INVALID)
371        })
372        .map(|e| e.rel.clone())
373        .collect();
374    newly_valid.sort();
375    let validation = ValidationDelta {
376        base_valid: base.portfolio.valid_artifacts,
377        base_invalid: base.portfolio.invalid_artifacts,
378        head_valid: head.portfolio.valid_artifacts,
379        head_invalid: head.portfolio.invalid_artifacts,
380        newly_invalid,
381        newly_valid,
382    };
383
384    let relationships = RelationshipDelta {
385        base: base.portfolio.relationships.clone(),
386        head: head.portfolio.relationships.clone(),
387        new_issues: issue_difference(&head.issues, &base.issues),
388        resolved_issues: issue_difference(&base.issues, &head.issues),
389    };
390
391    // Head's by_type order first, then base-only types (both portfolios
392    // carry the six standard slots, so this is head order in practice).
393    let mut by_type: Vec<(String, (usize, usize))> = Vec::new();
394    for (name, head_count) in &head.portfolio.by_type {
395        let base_count = base
396            .portfolio
397            .by_type
398            .iter()
399            .find(|(n, _)| n == name)
400            .map(|(_, c)| *c)
401            .unwrap_or(0);
402        by_type.push((name.clone(), (base_count, *head_count)));
403    }
404    for (name, base_count) in &base.portfolio.by_type {
405        if !by_type.iter().any(|(n, _)| n == name) {
406            by_type.push((name.clone(), (*base_count, 0)));
407        }
408    }
409    let stats = StatsDelta {
410        by_type,
411        total: (
412            base.portfolio.total_artifacts(),
413            head.portfolio.total_artifacts(),
414        ),
415    };
416
417    RepositoryComparison {
418        base,
419        head,
420        changes,
421        validation,
422        relationships,
423        stats,
424    }
425}