Skip to main content

git_slop/report_ops/explain/
render.rs

1use super::super::*;
2
3fn format_reasons(value: Option<&Value>) -> String {
4    let values = string_array(value);
5    if values.is_empty() {
6        "none".to_string()
7    } else {
8        values.join(", ")
9    }
10}
11
12fn render_relationship_brief(value: &Value) -> String {
13    format!(
14        "- {} [{}] {} -> {} (score={:.3})",
15        string(value.get("id")),
16        string(value.get("kind")),
17        string(value.get("source_path")),
18        string(value.get("target_path")),
19        number(value.get("evidence_score"))
20    )
21}
22
23fn render_cluster_brief(value: &Value) -> String {
24    let candidate_type = string(value.get("candidate_type"));
25    format!(
26        "- {} [{}] members={} roots={}{}",
27        string(value.get("id")),
28        string(value.get("kind")),
29        usize_value(value.get("member_count")).max(string_array(value.get("member_paths")).len()),
30        {
31            let roots = string_array(value.get("top_level_roots"));
32            if roots.is_empty() {
33                "none".to_string()
34            } else {
35                roots.join(", ")
36            }
37        },
38        if candidate_type.is_empty() {
39            String::new()
40        } else {
41            format!(" candidate_type={candidate_type}")
42        },
43    )
44}
45
46fn title_case_bool(value: bool) -> &'static str {
47    if value { "True" } else { "False" }
48}
49
50fn shell_quote(value: &str) -> String {
51    format!("'{}'", value.replace('\'', "'\"'\"'"))
52}
53
54fn render_record_cost_lines(
55    label: &str,
56    fallback_path: &str,
57    record: Option<&Value>,
58) -> Vec<String> {
59    let path = string(record.and_then(|value| value.get("path")));
60    let path = if path.is_empty() {
61        fallback_path
62    } else {
63        path.as_str()
64    };
65    let costs = record.and_then(|value| value.get("costs"));
66    vec![
67        format!(
68            "- {label}={path} slop={} slop_score={:.1} context={}",
69            string(record.and_then(|value| value.get("slop_band"))),
70            number(record.and_then(|value| value.get("slop_score"))),
71            string(record.and_then(|value| value.get("context_band"))),
72        ),
73        format!(
74            "  - load: tokens={}, folder_tokens={}, pressure={:.3}",
75            integer(value_at(
76                costs.unwrap_or(&Value::Null),
77                &["load", "file_token_count"]
78            )),
79            integer(value_at(
80                costs.unwrap_or(&Value::Null),
81                &["load", "folder_token_count"]
82            )),
83            number(value_at(
84                costs.unwrap_or(&Value::Null),
85                &["load", "load_pressure"]
86            )),
87        ),
88        format!(
89            "  - volatility: commits={}, relative_token_churn={:.3}, pressure={:.3}",
90            json_scalar_text(value_at(
91                costs.unwrap_or(&Value::Null),
92                &["volatility", "commit_count_window"]
93            )),
94            number(value_at(
95                costs.unwrap_or(&Value::Null),
96                &["volatility", "relative_token_churn"]
97            )),
98            number(value_at(
99                costs.unwrap_or(&Value::Null),
100                &["volatility", "volatility_pressure"]
101            )),
102        ),
103        format!(
104            "  - coordination: diffusion={:.3}, degree={}, pressure={:.3}",
105            number(value_at(
106                costs.unwrap_or(&Value::Null),
107                &["coordination", "change_diffusion"]
108            )),
109            json_scalar_text(value_at(
110                costs.unwrap_or(&Value::Null),
111                &["coordination", "cochange_degree"]
112            )),
113            number(value_at(
114                costs.unwrap_or(&Value::Null),
115                &["coordination", "coordination_pressure"]
116            )),
117        ),
118    ]
119}
120
121fn render_overlay_lines(overlays: Option<&Value>) -> Vec<String> {
122    let Some(overlays) = overlays.and_then(Value::as_object) else {
123        return vec!["- none".to_string()];
124    };
125    if overlays.is_empty() {
126        return vec!["- none".to_string()];
127    }
128    let organization = overlays.get("organization_health");
129    let verification = overlays.get("verification");
130    let navigation = overlays.get("navigation");
131    let blast = overlays.get("blast_radius");
132    let stewardship = overlays.get("stewardship");
133    let drift = overlays.get("semantic_drift");
134    vec![
135        format!(
136            "- organization_health: duplication={:.3}, diffusion={:.3}, coupling={:.3}, boundary={:.3}, clusters={}",
137            number(organization.and_then(|value| value.get("duplication_pressure"))),
138            number(organization.and_then(|value| value.get("diffusion_pressure"))),
139            number(organization.and_then(|value| value.get("coupling_pressure"))),
140            number(organization.and_then(|value| value.get("boundary_pressure"))),
141            string_array(organization.and_then(|value| value.get("cluster_ids"))).len(),
142        ),
143        format!(
144            "- verification: gap={:.3}, adjacency={:.3}, test_cochange={:.3}, hotspot_without_nearby_tests={}",
145            number(verification.and_then(|value| value.get("verification_gap"))),
146            number(verification.and_then(|value| value.get("test_adjacency_score"))),
147            number(verification.and_then(|value| value.get("test_cochange_ratio"))),
148            title_case_bool(
149                verification
150                    .and_then(|value| value.get("hotspot_without_nearby_tests"))
151                    .and_then(Value::as_bool)
152                    .unwrap_or(false),
153            ),
154        ),
155        format!(
156            "- navigation: pressure={:.3}, ambiguity={:.3}, path_depth={}",
157            number(navigation.and_then(|value| value.get("navigation_pressure"))),
158            number(navigation.and_then(|value| value.get("search_ambiguity"))),
159            json_scalar_text(navigation.and_then(|value| value.get("path_depth"))),
160        ),
161        format!(
162            "- blast_radius: pressure={:.3}, degree={}, cross_folder={:.3}",
163            number(blast.and_then(|value| value.get("blast_radius_pressure"))),
164            json_scalar_text(blast.and_then(|value| value.get("cochange_degree"))),
165            number(blast.and_then(|value| value.get("cross_folder_coupling"))),
166        ),
167        format!(
168            "- stewardship: pressure={:.3}, authors={}, top_author_share={:.3}",
169            number(stewardship.and_then(|value| value.get("stewardship_pressure"))),
170            json_scalar_text(stewardship.and_then(|value| value.get("author_count_window"))),
171            number(stewardship.and_then(|value| value.get("top_author_share"))),
172        ),
173        format!(
174            "- semantic_drift: pressure={:.3}, terms={}",
175            number(drift.and_then(|value| value.get("semantic_drift_pressure"))),
176            {
177                let terms = string_array(drift.and_then(|value| value.get("drift_terms")));
178                if terms.is_empty() {
179                    "none".to_string()
180                } else {
181                    terms.into_iter().take(5).collect::<Vec<_>>().join(", ")
182                }
183            },
184        ),
185    ]
186}
187
188fn render_evidence_summary(payload: &Value) -> Vec<String> {
189    let summary = payload.get("evidence_summary").unwrap_or(&Value::Null);
190    let joined = |key: &str| {
191        let values = string_array(summary.get(key));
192        if values.is_empty() {
193            "none".to_string()
194        } else {
195            values.join("; ")
196        }
197    };
198    let relationships = string_array(value_at(
199        summary,
200        &["supporting_evidence", "relationship_ids"],
201    ));
202    let clusters = string_array(value_at(summary, &["supporting_evidence", "cluster_ids"]));
203    vec![
204        "Evidence Summary".to_string(),
205        format!("- strongest detector costs: {}", joined("detector_cost")),
206        format!("- strongest overlays: {}", joined("strongest_overlays")),
207        format!(
208            "- supporting relationships: {}",
209            if relationships.is_empty() {
210                "none".to_string()
211            } else {
212                relationships.join(", ")
213            }
214        ),
215        format!(
216            "- supporting clusters: {}",
217            if clusters.is_empty() {
218                "none".to_string()
219            } else {
220                clusters.join(", ")
221            }
222        ),
223        format!(
224            "- interpretation: {}",
225            string(summary.get("interpretation"))
226        ),
227    ]
228}
229
230fn render_top_explain(payload: &Value) -> String {
231    let count = usize_value(value_at(payload, &["target", "count"]));
232    let mut lines = vec![format!("Explain: top {count} hotspots"), String::new()];
233    for (index, item) in array_at(payload, &["items"]).iter().enumerate() {
234        let target = item.get("target").unwrap_or(&Value::Null);
235        lines.push(format!(
236            "{}. {} [{}] slop_score={:.1} context={}",
237            index + 1,
238            string(target.get("path")),
239            string(target.get("slop_band")),
240            number(target.get("slop_score")),
241            string(target.get("context_band"))
242        ));
243        lines.push(format!(
244            "   cost: load={:.3} volatility={:.3} coordination={:.3}",
245            number(value_at(item, &["cost_summary", "load", "load_pressure"])),
246            number(value_at(
247                item,
248                &["cost_summary", "volatility", "volatility_pressure"]
249            )),
250            number(value_at(
251                item,
252                &["cost_summary", "coordination", "coordination_pressure"]
253            ))
254        ));
255        let pressures = strongest_pressures(item.get("overlay_summary"), 3);
256        lines.push(format!(
257            "   overlays: {}",
258            if pressures.is_empty() {
259                "none".to_string()
260            } else {
261                pressures
262                    .iter()
263                    .map(|(label, value)| format!("{label}={value:.3}"))
264                    .collect::<Vec<_>>()
265                    .join(", ")
266            }
267        ));
268        let relationships: Vec<String> = array_at(item, &["supporting_relationships"])
269            .iter()
270            .take(2)
271            .map(|value| string(value.get("id")))
272            .collect();
273        let clusters: Vec<String> = array_at(item, &["supporting_clusters"])
274            .iter()
275            .take(2)
276            .map(|value| string(value.get("id")))
277            .collect();
278        lines.push(format!(
279            "   relationships: {}",
280            if relationships.is_empty() {
281                "none".to_string()
282            } else {
283                relationships.join(", ")
284            }
285        ));
286        lines.push(format!(
287            "   clusters: {}",
288            if clusters.is_empty() {
289                "none".to_string()
290            } else {
291                clusters.join(", ")
292            }
293        ));
294        lines.push(String::new());
295    }
296    lines.extend(render_evidence_summary(payload));
297    lines.push(String::new());
298    lines.push(string(payload.get("boundary_note")));
299    format!("{}\n", lines.join("\n"))
300}
301
302pub fn render_explain_text(payload: &Value) -> String {
303    if string(value_at(payload, &["selector", "kind"])) == "top" {
304        return render_top_explain(payload);
305    }
306    let target = payload.get("target").unwrap_or(&Value::Null);
307    let target_kind = string(target.get("kind"));
308    let target_label = match target_kind.as_str() {
309        "path" => string(target.get("path")),
310        _ => string(target.get("id")),
311    };
312    let target_detail = match target_kind.as_str() {
313        "path" => string(target.get("record_type")),
314        "relationship" => string(target.get("relationship_kind")),
315        "cluster" => string(target.get("cluster_kind")),
316        _ => String::new(),
317    };
318    let mut lines = vec![
319        format!("Explain: {target_kind} {target_label} [{target_detail}]"),
320        String::new(),
321    ];
322
323    if target_kind == "relationship" {
324        lines.push("Hotspot Cost".to_string());
325        lines.extend(render_record_cost_lines(
326            "source",
327            &string(target.get("source_path")),
328            value_at(payload, &["cost_summary", "source"]),
329        ));
330        lines.extend(render_record_cost_lines(
331            "target",
332            &string(target.get("target_path")),
333            value_at(payload, &["cost_summary", "target"]),
334        ));
335        lines.push(String::new());
336        lines.push("Overlay Evidence".to_string());
337        let organization = value_at(payload, &["overlay_summary", "organization_health"]);
338        lines.push(format!(
339            "- organization_health: evidence_score={:.3}, crosses_top_level_boundary={}",
340            number(organization.and_then(|value| value.get("evidence_score"))),
341            title_case_bool(
342                organization
343                    .and_then(|value| value.get("crosses_top_level_boundary"))
344                    .and_then(Value::as_bool)
345                    .unwrap_or(false)
346            ),
347        ));
348        lines.push("- source overlays:".to_string());
349        lines.extend(render_overlay_lines(value_at(
350            payload,
351            &["overlay_summary", "source_overlays"],
352        )));
353        lines.push("- target overlays:".to_string());
354        lines.extend(render_overlay_lines(value_at(
355            payload,
356            &["overlay_summary", "target_overlays"],
357        )));
358    } else if target_kind == "cluster" {
359        lines.push("Hotspot Cost".to_string());
360        let roots = string_array(target.get("top_level_roots"));
361        lines.push(format!(
362            "- members={} roots={} candidate_type={}",
363            usize_value(value_at(payload, &["cost_summary", "member_count"])),
364            if roots.is_empty() {
365                "none".to_string()
366            } else {
367                roots.join(", ")
368            },
369            string_or(target.get("candidate_type"), "n/a"),
370        ));
371        lines.push("- member hotspots:".to_string());
372        for item in array_at(payload, &["cost_summary", "member_hotspots"]) {
373            lines.push(format!(
374                "  - {} slop={} slop_score={:.1} context={}",
375                string(item.get("path")),
376                string(item.get("slop_band")),
377                number(item.get("slop_score")),
378                string(item.get("context_band"))
379            ));
380        }
381        lines.push(String::new());
382        lines.push("Overlay Evidence".to_string());
383        let organization = value_at(payload, &["overlay_summary", "organization_health"]);
384        lines.push(format!(
385            "- organization_health: candidate_type={}, evidence_score={:.3}",
386            string_or(
387                organization.and_then(|value| value.get("candidate_type")),
388                &string(organization.and_then(|value| value.get("kind"))),
389            ),
390            number(organization.and_then(|value| value.get("evidence_score"))),
391        ));
392        let maxima = value_at(payload, &["overlay_summary", "member_overlay_maxima"]);
393        if maxima.is_some() {
394            lines.push(format!(
395                "- member overlay maxima: organization.diffusion={:.3}, verification={:.3}, navigation={:.3}, blast_radius={:.3}, semantic_drift={:.3}",
396                number(value_at(maxima.unwrap_or(&Value::Null), &["organization_health", "diffusion_pressure"])),
397                number(value_at(maxima.unwrap_or(&Value::Null), &["verification", "verification_gap"])),
398                number(value_at(maxima.unwrap_or(&Value::Null), &["navigation", "navigation_pressure"])),
399                number(value_at(maxima.unwrap_or(&Value::Null), &["blast_radius", "blast_radius_pressure"])),
400                number(value_at(maxima.unwrap_or(&Value::Null), &["semantic_drift", "semantic_drift_pressure"])),
401            ));
402        }
403    } else {
404        let is_folder = string(target.get("record_type")) == "folder";
405        lines.push("Hotspot Cost".to_string());
406        lines.push(format!(
407            "- slop: {} ({:.1}) context={} reasons={}",
408            string(target.get("slop_band")),
409            number(target.get("slop_score")),
410            string(target.get("context_band")),
411            format_reasons(target.get("reason_codes"))
412        ));
413        lines.push(format!(
414            "- load: {}={}, folder_tokens={}, pressure={:.3}",
415            if is_folder {
416                "max_file_tokens"
417            } else {
418                "tokens"
419            },
420            integer(value_at(
421                payload,
422                &["cost_summary", "load", "file_token_count"]
423            )),
424            integer(value_at(
425                payload,
426                &["cost_summary", "load", "folder_token_count"]
427            )),
428            number(value_at(
429                payload,
430                &["cost_summary", "load", "load_pressure"]
431            ))
432        ));
433        lines.push(format!(
434            "- volatility: commits={}, relative_token_churn={:.3}, pressure={:.3}",
435            json_scalar_text(value_at(
436                payload,
437                &["cost_summary", "volatility", "commit_count_window"]
438            )),
439            number(value_at(
440                payload,
441                &["cost_summary", "volatility", "relative_token_churn"]
442            )),
443            number(value_at(
444                payload,
445                &["cost_summary", "volatility", "volatility_pressure"]
446            ))
447        ));
448        lines.push(format!(
449            "- coordination: diffusion={:.3}, degree={}, pressure={:.3}",
450            number(value_at(
451                payload,
452                &["cost_summary", "coordination", "change_diffusion"]
453            )),
454            value_at(
455                payload,
456                &["cost_summary", "coordination", "cochange_degree"]
457            )
458            .map(ToString::to_string)
459            .unwrap_or_else(|| "0".to_string()),
460            number(value_at(
461                payload,
462                &["cost_summary", "coordination", "coordination_pressure"]
463            ))
464        ));
465        let descendants = array_at(payload, &["cost_summary", "descendant_hotspots"]);
466        if is_folder {
467            lines.push("- descendant hotspots:".to_string());
468            if descendants.is_empty() {
469                lines.push("  - none".to_string());
470            } else {
471                for record in descendants.iter().take(5) {
472                    lines.push(format!(
473                        "  - {} slop={} slop_score={:.1} context={}",
474                        string(record.get("path")),
475                        string(record.get("slop_band")),
476                        number(record.get("slop_score")),
477                        string(record.get("context_band"))
478                    ));
479                }
480            }
481        }
482        lines.push(String::new());
483        lines.push("Overlay Evidence".to_string());
484        lines.extend(render_overlay_lines(payload.get("overlay_summary")));
485        if is_folder
486            && payload
487                .pointer("/overlay_summary/descendant_overlay_maxima")
488                .is_some()
489        {
490            lines.push(format!(
491                "- descendant overlay maxima: organization.diffusion={:.3}, verification={:.3}, navigation={:.3}, blast_radius={:.3}, semantic_drift={:.3}",
492                number(value_at(payload, &["overlay_summary", "descendant_overlay_maxima", "organization_health", "diffusion_pressure"])),
493                number(value_at(payload, &["overlay_summary", "descendant_overlay_maxima", "verification", "verification_gap"])),
494                number(value_at(payload, &["overlay_summary", "descendant_overlay_maxima", "navigation", "navigation_pressure"])),
495                number(value_at(payload, &["overlay_summary", "descendant_overlay_maxima", "blast_radius", "blast_radius_pressure"])),
496                number(value_at(payload, &["overlay_summary", "descendant_overlay_maxima", "semantic_drift", "semantic_drift_pressure"])),
497            ));
498        }
499    }
500    lines.push(String::new());
501    lines.push("Supporting Relationships".to_string());
502    let relationships = array_at(payload, &["supporting_relationships"]);
503    let path_is_folder = target_kind == "path" && string(target.get("record_type")) == "folder";
504    let relationship_limit = if path_is_folder {
505        3
506    } else {
507        relationships.len()
508    };
509    if relationships.is_empty() {
510        lines.push("- none".to_string());
511    } else {
512        lines.extend(
513            relationships
514                .iter()
515                .take(relationship_limit)
516                .map(render_relationship_brief),
517        );
518    }
519    lines.push(String::new());
520    lines.push("Supporting Clusters".to_string());
521    let clusters = array_at(payload, &["supporting_clusters"]);
522    let cluster_limit = if path_is_folder { 3 } else { clusters.len() };
523    if clusters.is_empty() {
524        lines.push("- none".to_string());
525    } else {
526        lines.extend(
527            clusters
528                .iter()
529                .take(cluster_limit)
530                .map(render_cluster_brief),
531        );
532    }
533    lines.push(String::new());
534    lines.extend(render_evidence_summary(payload));
535    if target_kind == "relationship" {
536        lines.push(String::new());
537        lines.push("Next Commands".to_string());
538        lines.push(format!(
539            "- Inspect source: git-slop show {}",
540            shell_quote(&string(target.get("source_path")))
541        ));
542        lines.push(format!(
543            "- Inspect target: git-slop show {}",
544            shell_quote(&string(target.get("target_path")))
545        ));
546        lines.push(format!(
547            "- Draft a bounded plan: git-slop plan --relationship {}",
548            shell_quote(&string(target.get("id")))
549        ));
550    } else if target_kind == "cluster" {
551        lines.push(String::new());
552        lines.push("Next Commands".to_string());
553        for member in string_array(target.get("member_paths"))
554            .into_iter()
555            .take(MAX_SLICE_FILES)
556        {
557            lines.push(format!(
558                "- Inspect member: git-slop show {}",
559                shell_quote(&member)
560            ));
561        }
562        lines.push(format!(
563            "- Draft a bounded plan: git-slop plan --cluster {}",
564            shell_quote(&string(target.get("id")))
565        ));
566    }
567    lines.push(String::new());
568    lines.push(string(payload.get("boundary_note")));
569    format!("{}\n", lines.join("\n"))
570}