1use 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
31pub 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#[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, 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#[derive(Debug, Clone)]
72pub struct StateArtifact {
73 pub id: String,
74 pub type_name: String, pub title: Option<String>,
76 pub status: &'static str,
77}
78
79pub struct StateEntry {
82 pub rel: String,
83 pub artifact: Artifact,
84 pub raw: Vec<u8>,
85 pub info: StateArtifact,
86}
87
88pub struct RepoState {
90 pub label: String,
91 pub directory: String,
92 pub portfolio: PortfolioSummary,
93 pub relationships: Vec<Relationship>,
96 pub entries: Vec<StateEntry>,
98 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 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 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
139pub 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 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#[derive(Debug)]
209pub struct ArtifactChange {
210 pub change: &'static str, pub type_name: String, pub id: Option<String>,
213 pub title: Option<String>,
214 pub path: String, pub base_status: Option<&'static str>,
216 pub head_status: Option<&'static str>,
217 pub diff: Option<Diff>, }
219
220pub 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
230pub struct RelationshipDelta {
232 pub base: RelationshipSummary,
233 pub head: RelationshipSummary,
234 pub new_issues: Vec<RelationshipIssueRef>,
235 pub resolved_issues: Vec<RelationshipIssueRef>,
236}
237
238pub struct StatsDelta {
240 pub by_type: Vec<(String, (usize, usize))>, pub total: (usize, usize),
242}
243
244pub 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
276fn 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
293pub 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 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}