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}