use std::collections::HashMap;
use serde::{Deserialize, Serialize};
use crate::scoring::percent;
use crate::types::*;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Direction {
Up,
Down,
Flat,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AxisDelta {
pub id: AxisId,
pub label: String,
pub before: f64,
pub after: f64,
pub delta: f64,
pub direction: Direction,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct LaunchDiffSummary {
pub title: String,
pub generated_at: String,
pub readiness: f64,
pub verdict_label: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct LaunchDiff {
pub before: LaunchDiffSummary,
pub after: LaunchDiffSummary,
pub readiness_delta: f64,
pub verdict_changed: bool,
pub axes: Vec<AxisDelta>,
}
pub fn diff_launch_runs(before: &LaunchRun, after: &LaunchRun) -> LaunchDiff {
let axis_map: HashMap<AxisId, f64> = before.axes.iter().map(|a| (a.id, a.score)).collect();
let axes: Vec<AxisDelta> = after
.axes
.iter()
.map(|a| {
let before_score = axis_map.get(&a.id).copied().unwrap_or(0.0);
let delta = a.score - before_score;
let direction = if delta > 0.005 {
Direction::Up
} else if delta < -0.005 {
Direction::Down
} else {
Direction::Flat
};
AxisDelta {
id: a.id,
label: a.label.clone(),
before: before_score,
after: a.score,
delta,
direction,
}
})
.collect();
LaunchDiff {
before: LaunchDiffSummary {
title: before.title.clone(),
generated_at: before.generated_at.clone(),
readiness: before.verdict.readiness,
verdict_label: before.verdict.label.clone(),
},
after: LaunchDiffSummary {
title: after.title.clone(),
generated_at: after.generated_at.clone(),
readiness: after.verdict.readiness,
verdict_label: after.verdict.label.clone(),
},
readiness_delta: after.verdict.readiness - before.verdict.readiness,
verdict_changed: before.verdict.winner_branch_id != after.verdict.winner_branch_id,
axes,
}
}
fn signed_percent(v: f64) -> String {
let p = (v * 100.0).round() as i64;
if p > 0 {
format!("+{p}%")
} else if p < 0 {
format!("{p}%")
} else {
"0%".into()
}
}
fn arrow(d: Direction) -> &'static str {
match d {
Direction::Up => "\u{25b2}",
Direction::Down => "\u{25bc}",
Direction::Flat => "\u{2500}",
}
}
fn escape_html(s: &str) -> String {
s.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
.replace('"', """)
}
pub fn render_diff_markdown(diff: &LaunchDiff) -> String {
let rows: String = diff
.axes
.iter()
.map(|a| {
format!(
"| {} | {} | {} | {} {} |",
a.label,
percent(a.before),
percent(a.after),
signed_percent(a.delta),
arrow(a.direction)
)
})
.collect::<Vec<_>>()
.join("\n");
let verdict_line = if diff.verdict_changed {
format!(
"Verdict changed: **{}** \u{2192} **{}**",
diff.before.verdict_label, diff.after.verdict_label
)
} else {
format!("Verdict unchanged: **{}**", diff.after.verdict_label)
};
format!(
r#"# Launch Diff
Before: {before_title} ({before_at})
After: {after_title} ({after_at})
## Readiness
{before_readiness} → {after_readiness} ({readiness_delta})
{verdict_line}
## Axes
| Axis | Before | After | Delta |
| --- | --- | --- | --- |
{rows}
"#,
before_title = diff.before.title,
before_at = diff.before.generated_at,
after_title = diff.after.title,
after_at = diff.after.generated_at,
before_readiness = percent(diff.before.readiness),
after_readiness = percent(diff.after.readiness),
readiness_delta = signed_percent(diff.readiness_delta),
verdict_line = verdict_line,
rows = rows,
)
}
pub fn render_diff_html(diff: &LaunchDiff) -> String {
let rows: String = diff
.axes
.iter()
.map(|a| {
let cls = match a.direction {
Direction::Up => "delta-up",
Direction::Down => "delta-down",
Direction::Flat => "delta-flat",
};
format!(
r#"<tr>
<td>{}</td>
<td>{}</td>
<td>{}</td>
<td class="{}">{} {}</td>
</tr>"#,
escape_html(&a.label),
percent(a.before),
percent(a.after),
cls,
signed_percent(a.delta),
arrow(a.direction),
)
})
.collect::<Vec<_>>()
.join("\n");
let verdict_line = if diff.verdict_changed {
format!(
"<strong>{}</strong> → <strong>{}</strong>",
escape_html(&diff.before.verdict_label),
escape_html(&diff.after.verdict_label)
)
} else {
format!(
"<strong>{}</strong> (unchanged)",
escape_html(&diff.after.verdict_label)
)
};
format!(
r##"<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Launch Diff</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=Atkinson+Hyperlegible+Mono:wght@200..800&display=swap');
* {{ box-sizing: border-box; margin: 0; padding: 0; }}
body {{
background: #000;
color: #fff;
font-family: "Atkinson Hyperlegible Mono", "SF Mono", Consolas, monospace;
font-weight: 300;
-webkit-font-smoothing: antialiased;
}}
main {{
width: min(720px, calc(100vw - 32px));
margin: 0 auto;
padding: 48px 0 80px;
}}
.gradient-text {{
background: linear-gradient(135deg, #ff44f5, #4fe9ea);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}}
.kicker {{
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.24em;
font-weight: 400;
}}
h1 {{ font-size: 1.8rem; font-weight: 200; margin-top: 12px; }}
.meta {{ color: #666; font-size: 0.8rem; margin-top: 8px; }}
.card {{
border-radius: 28px;
border: 1px solid rgba(128,128,128,0.25);
background: rgba(0,0,0,0.75);
padding: 24px;
margin-top: 20px;
}}
.section-title {{
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.24em;
color: #4fe9ea;
font-weight: 400;
margin-bottom: 16px;
}}
.readiness {{
font-size: 2rem;
font-weight: 200;
}}
.readiness-delta {{ color: #4fe9ea; font-size: 1rem; margin-left: 8px; }}
table {{ width: 100%; border-collapse: collapse; margin-top: 12px; }}
th, td {{ text-align: left; padding: 10px 12px; border-bottom: 1px solid rgba(128,128,128,0.12); }}
th {{ font-size: 10px; color: #666; text-transform: uppercase; letter-spacing: 0.24em; font-weight: 400; }}
td {{ font-size: 0.9rem; color: #999; }}
.delta-up {{ color: #4fe9ea; }}
.delta-down {{ color: #ff44f5; }}
.delta-flat {{ color: #666; }}
.footer {{ margin-top: 40px; text-align: center; font-size: 0.75rem; color: #333; letter-spacing: 0.08em; }}
</style>
</head>
<body>
<main>
<p class="kicker gradient-text">Reality Fork 分岐現界</p>
<h1>Launch Diff</h1>
<p class="meta">{before_at} → {after_at}</p>
<div class="card">
<p class="section-title">Readiness</p>
<span class="readiness">{before_readiness} → {after_readiness}</span>
<span class="readiness-delta">{readiness_delta}</span>
<p class="meta" style="margin-top: 14px">{verdict_line}</p>
</div>
<div class="card">
<p class="section-title">Axes</p>
<table>
<thead><tr><th>Axis</th><th>Before</th><th>After</th><th>Delta</th></tr></thead>
<tbody>{rows}</tbody>
</table>
</div>
<p class="footer">KAMIYO · Reality Fork</p>
</main>
</body>
</html>"##,
before_at = escape_html(&diff.before.generated_at),
after_at = escape_html(&diff.after.generated_at),
before_readiness = percent(diff.before.readiness),
after_readiness = percent(diff.after.readiness),
readiness_delta = signed_percent(diff.readiness_delta),
verdict_line = verdict_line,
rows = rows,
)
}