Skip to main content

git_slop/report_ops/
compare.rs

1use super::*;
2
3fn optional_number(value: Option<&Value>) -> Option<f64> {
4    value.and_then(Value::as_f64).map(round6)
5}
6
7fn optional_integer(value: Option<&Value>) -> Option<i64> {
8    value.and_then(Value::as_i64)
9}
10
11fn records_by_path(report: &Value, collection: &str) -> BTreeMap<String, Value> {
12    array_at(report, &[collection])
13        .iter()
14        .filter_map(|record| {
15            record
16                .get("path")
17                .and_then(Value::as_str)
18                .map(|path| (path.to_string(), record.clone()))
19        })
20        .collect()
21}
22
23fn record_score(record: Option<&Value>) -> Option<f64> {
24    optional_number(record.and_then(|record| record.get("slop_score")))
25}
26
27fn record_load_pressure(record: Option<&Value>) -> Option<f64> {
28    optional_number(record.and_then(|record| value_at(record, &["costs", "load", "load_pressure"])))
29}
30
31fn record_tokens(record: Option<&Value>) -> Option<i64> {
32    record.and_then(|record| {
33        optional_integer(record.get("tokens"))
34            .or_else(|| optional_integer(value_at(record, &["costs", "load", "file_token_count"])))
35    })
36}
37
38fn record_band(record: Option<&Value>, key: &str) -> Option<String> {
39    optional_string(record.and_then(|record| record.get(key)))
40}
41
42fn band_rank(value: &str) -> i64 {
43    match value {
44        "compact" | "low" => 0,
45        "healthy" | "moderate" => 1,
46        "warning" | "high" => 2,
47        "critical" | "refactor_required" => 3,
48        _ => 0,
49    }
50}
51
52fn band_delta(base: Option<&str>, head: Option<&str>) -> Option<i64> {
53    Some(band_rank(head?) - band_rank(base?))
54}
55
56fn overlay_pressures(record: Option<&Value>) -> BTreeMap<String, f64> {
57    strongest_pressures(record.and_then(|record| record.get("overlays")), 20)
58        .into_iter()
59        .map(|(label, value)| (label, round6(value)))
60        .collect()
61}
62
63fn record_overlay_delta(base: Option<&Value>, head: Option<&Value>) -> Vec<Value> {
64    let base_values = overlay_pressures(base);
65    let head_values = overlay_pressures(head);
66    let labels: BTreeSet<String> = base_values
67        .keys()
68        .chain(head_values.keys())
69        .cloned()
70        .collect();
71    let mut changes: Vec<Value> = labels
72        .into_iter()
73        .filter_map(|label| {
74            let base_value = base_values.get(&label).copied().unwrap_or(0.0);
75            let head_value = head_values.get(&label).copied().unwrap_or(0.0);
76            let delta = round6(head_value - base_value);
77            (delta != 0.0).then(|| {
78                json!({
79                    "label": label,
80                    "base": base_value,
81                    "head": head_value,
82                    "delta": delta,
83                })
84            })
85        })
86        .collect();
87    changes.sort_by(|left, right| {
88        cmp_f64_desc(
89            number(left.get("delta")).abs(),
90            number(right.get("delta")).abs(),
91        )
92        .then_with(|| string(left.get("label")).cmp(&string(right.get("label"))))
93    });
94    changes.truncate(5);
95    changes
96}
97
98fn record_delta_status(base: Option<&Value>, head: Option<&Value>) -> &'static str {
99    match (base, head) {
100        (None, Some(_)) => "added",
101        (Some(_), None) => "removed",
102        (None, None) => "unchanged",
103        (Some(base), Some(head))
104            if record_score(Some(base)) == record_score(Some(head))
105                && record_tokens(Some(base)) == record_tokens(Some(head))
106                && record_load_pressure(Some(base)) == record_load_pressure(Some(head))
107                && record_band(Some(base), "context_band")
108                    == record_band(Some(head), "context_band")
109                && record_band(Some(base), "slop_band") == record_band(Some(head), "slop_band")
110                && record_overlay_delta(Some(base), Some(head)).is_empty() =>
111        {
112            "unchanged"
113        }
114        _ => "changed",
115    }
116}
117
118fn option_f64_delta(base: Option<f64>, head: Option<f64>) -> Option<f64> {
119    if base.is_none() && head.is_none() {
120        None
121    } else {
122        Some(round6(head.unwrap_or(0.0) - base.unwrap_or(0.0)))
123    }
124}
125
126fn option_i64_delta(base: Option<i64>, head: Option<i64>) -> Option<i64> {
127    if base.is_none() && head.is_none() {
128        None
129    } else {
130        Some(head.unwrap_or(0) - base.unwrap_or(0))
131    }
132}
133
134fn build_record_delta(path: &str, base: Option<&Value>, head: Option<&Value>) -> Value {
135    let base_score = record_score(base);
136    let head_score = record_score(head);
137    let base_tokens = record_tokens(base);
138    let head_tokens = record_tokens(head);
139    let base_load = record_load_pressure(base);
140    let head_load = record_load_pressure(head);
141    let base_context = record_band(base, "context_band");
142    let head_context = record_band(head, "context_band");
143    let base_slop = record_band(base, "slop_band");
144    let head_slop = record_band(head, "slop_band");
145    let context_delta = band_delta(base_context.as_deref(), head_context.as_deref());
146    let slop_delta = band_delta(base_slop.as_deref(), head_slop.as_deref());
147    json!({
148        "path": path,
149        "status": record_delta_status(base, head),
150        "base_slop_score": base_score,
151        "head_slop_score": head_score,
152        "slop_score_delta": option_f64_delta(base_score, head_score),
153        "base_tokens": base_tokens,
154        "head_tokens": head_tokens,
155        "token_delta": option_i64_delta(base_tokens, head_tokens),
156        "base_load_pressure": base_load,
157        "head_load_pressure": head_load,
158        "load_pressure_delta": option_f64_delta(base_load, head_load),
159        "base_context_band": base_context,
160        "head_context_band": head_context,
161        "context_band_delta": context_delta,
162        "base_slop_band": base_slop,
163        "head_slop_band": head_slop,
164        "slop_band_delta": slop_delta,
165        "overlay_deltas": record_overlay_delta(base, head),
166    })
167}
168
169fn build_record_deltas(base: &Value, head: &Value, collection: &str) -> Vec<Value> {
170    let base_records = records_by_path(base, collection);
171    let head_records = records_by_path(head, collection);
172    let paths: BTreeSet<String> = base_records
173        .keys()
174        .chain(head_records.keys())
175        .cloned()
176        .collect();
177    paths
178        .into_iter()
179        .map(|path| build_record_delta(&path, base_records.get(&path), head_records.get(&path)))
180        .collect()
181}
182
183fn queue_positions(report: &Value) -> BTreeMap<String, usize> {
184    let mut positions = BTreeMap::new();
185    for (index, item) in array_at(report, &["action_queue"]).iter().enumerate() {
186        if let Some(path) = item.get("path").and_then(Value::as_str) {
187            positions.entry(path.to_string()).or_insert(index + 1);
188        }
189    }
190    positions
191}
192
193fn build_queue_movement(base: &Value, head: &Value) -> Vec<Value> {
194    let base_positions = queue_positions(base);
195    let head_positions = queue_positions(head);
196    let paths: BTreeSet<String> = base_positions
197        .keys()
198        .chain(head_positions.keys())
199        .cloned()
200        .collect();
201    let mut result: Vec<Value> = paths
202        .into_iter()
203        .map(|path| {
204            let base_position = base_positions.get(&path).copied();
205            let head_position = head_positions.get(&path).copied();
206            let (status, position_delta): (&str, Option<i64>) = match (base_position, head_position)
207            {
208                (None, Some(_)) => ("newly_queued", None),
209                (Some(_), None) => ("dropped_from_queue", None),
210                (Some(base), Some(head)) if head < base => {
211                    ("moved_up", Some(head as i64 - base as i64))
212                }
213                (Some(base), Some(head)) if head > base => {
214                    ("moved_down", Some(head as i64 - base as i64))
215                }
216                _ => ("unchanged_position", Some(0)),
217            };
218            json!({
219                "path": path,
220                "status": status,
221                "base_position": base_position,
222                "head_position": head_position,
223                "position_delta": position_delta,
224            })
225        })
226        .collect();
227    result.sort_by(|left, right| {
228        let rank = |status: &str| match status {
229            "newly_queued" => 0,
230            "moved_up" => 1,
231            "moved_down" => 2,
232            "dropped_from_queue" => 3,
233            _ => 4,
234        };
235        rank(&string(left.get("status")))
236            .cmp(&rank(&string(right.get("status"))))
237            .then_with(|| {
238                let left_position = left
239                    .get("head_position")
240                    .and_then(Value::as_u64)
241                    .or_else(|| left.get("base_position").and_then(Value::as_u64))
242                    .and_then(|value| usize::try_from(value).ok())
243                    .unwrap_or(10_000);
244                let right_position = right
245                    .get("head_position")
246                    .and_then(Value::as_u64)
247                    .or_else(|| right.get("base_position").and_then(Value::as_u64))
248                    .and_then(|value| usize::try_from(value).ok())
249                    .unwrap_or(10_000);
250                left_position.cmp(&right_position)
251            })
252            .then_with(|| string(left.get("path")).cmp(&string(right.get("path"))))
253    });
254    result
255}
256
257fn delta_counts(items: &[Value]) -> Value {
258    let count = |status: &str| {
259        items
260            .iter()
261            .filter(|item| string(item.get("status")) == status)
262            .count()
263    };
264    json!({
265        "added": count("added"),
266        "removed": count("removed"),
267        "changed": count("changed"),
268        "unchanged": count("unchanged"),
269    })
270}
271
272fn aggregate_overlay_deltas(items: &[Value]) -> Vec<Value> {
273    let mut aggregate: BTreeMap<String, f64> = BTreeMap::new();
274    for item in items {
275        for overlay in array_at(item, &["overlay_deltas"]) {
276            let label = string(overlay.get("label"));
277            let delta = number(overlay.get("delta"));
278            aggregate
279                .entry(label)
280                .and_modify(|value| *value = round6(*value + delta))
281                .or_insert(delta);
282        }
283    }
284    let mut result: Vec<Value> = aggregate
285        .into_iter()
286        .filter(|(_, delta)| *delta != 0.0)
287        .map(|(label, total_delta)| json!({"label": label, "total_delta": total_delta}))
288        .collect();
289    result.sort_by(|left, right| {
290        cmp_f64_desc(
291            number(left.get("total_delta")).abs(),
292            number(right.get("total_delta")).abs(),
293        )
294        .then_with(|| string(left.get("label")).cmp(&string(right.get("label"))))
295    });
296    result
297}
298
299fn report_descriptor(report: &Value, path: Option<&str>) -> Value {
300    let repo = report.get("repo").unwrap_or(&Value::Null);
301    json!({
302        "path": path,
303        "repo_name": repo.get("repo_name").cloned().unwrap_or(Value::Null),
304        "head_sha": repo.get("head_sha").or_else(|| repo.get("head_commit")).cloned().unwrap_or(Value::Null),
305        "generated_at": report.get("generated_at")
306            .or_else(|| value_at(report, &["summary", "generated_at"]))
307            .cloned()
308            .unwrap_or(Value::Null),
309        "schema_version": report.get("schema_version").cloned().unwrap_or(Value::Null),
310    })
311}
312
313pub fn compare_payload(
314    base_report: &Value,
315    head_report: &Value,
316    base_path: Option<&str>,
317    head_path: Option<&str>,
318    top: usize,
319) -> Result<Value> {
320    if report_schema(base_report) != REPORT_SCHEMA_VERSION {
321        bail!("base report must use schema {REPORT_SCHEMA_VERSION}.");
322    }
323    if report_schema(head_report) != REPORT_SCHEMA_VERSION {
324        bail!("head report must use schema {REPORT_SCHEMA_VERSION}.");
325    }
326    if top == 0 {
327        bail!("--top must be greater than zero.");
328    }
329    let file_deltas = build_record_deltas(base_report, head_report, "files");
330    let folder_deltas = build_record_deltas(base_report, head_report, "folders");
331    let worsened = file_deltas
332        .iter()
333        .filter(|item| number(item.get("slop_score_delta")) > 0.0)
334        .count();
335    let improved = file_deltas
336        .iter()
337        .filter(|item| number(item.get("slop_score_delta")) < 0.0)
338        .count();
339    let mut queue_movement = build_queue_movement(base_report, head_report);
340    queue_movement.truncate(top);
341    let overlay_deltas = aggregate_overlay_deltas(&file_deltas);
342    Ok(json!({
343        "schema_version": COMPARE_SCHEMA_VERSION,
344        "report_schema_version": REPORT_SCHEMA_VERSION,
345        "command": "compare",
346        "base_report": report_descriptor(base_report, base_path),
347        "head_report": report_descriptor(head_report, head_path),
348        "summary": {
349            "files": delta_counts(&file_deltas),
350            "folders": delta_counts(&folder_deltas),
351            "worsened_file_count": worsened,
352            "improved_file_count": improved,
353        },
354        "file_deltas": file_deltas,
355        "folder_deltas": folder_deltas,
356        "queue_movement": queue_movement,
357        "overlay_deltas": overlay_deltas,
358        "boundary_note": COMPARE_BOUNDARY_NOTE,
359    }))
360}
361
362fn file_name_or_fallback(value: &str, fallback: &str) -> String {
363    std::path::Path::new(value)
364        .file_name()
365        .and_then(|value| value.to_str())
366        .unwrap_or(fallback)
367        .to_string()
368}
369
370pub fn render_compare_text(payload: &Value, top: usize) -> String {
371    let base_path = string(value_at(payload, &["base_report", "path"]));
372    let head_path = string(value_at(payload, &["head_report", "path"]));
373    let base_name = file_name_or_fallback(
374        if base_path.is_empty() {
375            "<base>"
376        } else {
377            &base_path
378        },
379        "<base>",
380    );
381    let head_name = file_name_or_fallback(
382        if head_path.is_empty() {
383            "<head>"
384        } else {
385            &head_path
386        },
387        "<head>",
388    );
389    let mut lines = vec![
390        format!("Compare: {base_name} -> {head_name}"),
391        String::new(),
392        "Summary".to_string(),
393        format!(
394            "- files: added={}, removed={}, changed={}, unchanged={}",
395            integer(value_at(payload, &["summary", "files", "added"])),
396            integer(value_at(payload, &["summary", "files", "removed"])),
397            integer(value_at(payload, &["summary", "files", "changed"])),
398            integer(value_at(payload, &["summary", "files", "unchanged"])),
399        ),
400        format!(
401            "- folders: added={}, removed={}, changed={}, unchanged={}",
402            integer(value_at(payload, &["summary", "folders", "added"])),
403            integer(value_at(payload, &["summary", "folders", "removed"])),
404            integer(value_at(payload, &["summary", "folders", "changed"])),
405            integer(value_at(payload, &["summary", "folders", "unchanged"])),
406        ),
407        format!(
408            "- slop score movement: worsened_files={}, improved_files={}",
409            integer(value_at(payload, &["summary", "worsened_file_count"])),
410            integer(value_at(payload, &["summary", "improved_file_count"])),
411        ),
412        String::new(),
413        "Top Worsened Files".to_string(),
414    ];
415    let mut worsened: Vec<&Value> = array_at(payload, &["file_deltas"])
416        .iter()
417        .filter(|item| number(item.get("slop_score_delta")) > 0.0)
418        .collect();
419    worsened.sort_by(|left, right| {
420        cmp_f64_desc(
421            number(left.get("slop_score_delta")),
422            number(right.get("slop_score_delta")),
423        )
424        .then_with(|| string(left.get("path")).cmp(&string(right.get("path"))))
425    });
426    if worsened.is_empty() {
427        lines.push("- none".to_string());
428    } else {
429        lines.extend(worsened.into_iter().take(top).map(|item| {
430            format!(
431                "- {}: {} -> {} (delta={})",
432                string(item.get("path")),
433                json_scalar_text(item.get("base_slop_score")),
434                json_scalar_text(item.get("head_slop_score")),
435                json_scalar_text(item.get("slop_score_delta")),
436            )
437        }));
438    }
439    lines.extend([String::new(), "Top Improved Files".to_string()]);
440    let mut improved: Vec<&Value> = array_at(payload, &["file_deltas"])
441        .iter()
442        .filter(|item| number(item.get("slop_score_delta")) < 0.0)
443        .collect();
444    improved.sort_by(|left, right| {
445        number(left.get("slop_score_delta"))
446            .partial_cmp(&number(right.get("slop_score_delta")))
447            .unwrap_or(Ordering::Equal)
448            .then_with(|| string(left.get("path")).cmp(&string(right.get("path"))))
449    });
450    if improved.is_empty() {
451        lines.push("- none".to_string());
452    } else {
453        lines.extend(improved.into_iter().take(top).map(|item| {
454            format!(
455                "- {}: {} -> {} (delta={})",
456                string(item.get("path")),
457                json_scalar_text(item.get("base_slop_score")),
458                json_scalar_text(item.get("head_slop_score")),
459                json_scalar_text(item.get("slop_score_delta")),
460            )
461        }));
462    }
463    lines.extend([String::new(), "Queue Movement".to_string()]);
464    let movement = array_at(payload, &["queue_movement"]);
465    if movement.is_empty() {
466        lines.push("- none".to_string());
467    } else {
468        lines.extend(movement.iter().take(top).map(|item| {
469            format!(
470                "- {}: {} base={} head={}",
471                string(item.get("path")),
472                string(item.get("status")),
473                json_scalar_text(item.get("base_position")),
474                json_scalar_text(item.get("head_position")),
475            )
476        }));
477    }
478    lines.extend([String::new(), string(payload.get("boundary_note"))]);
479    lines.join("\n")
480}