|
@@ -0,0 +1,491 @@
|
|
|
|
|
+//! `sm121 report` — one table of all tested recipes and their benchmark
|
|
|
|
|
+//! history. Default renders a Markdown table to stdout; `--serve` (implied
|
|
|
|
|
+//! by `--open`) serves an interactive HTML version (tree grouping, column
|
|
|
|
|
+//! visibility, sorting, search, pagination) on a local port.
|
|
|
|
|
+//!
|
|
|
|
|
+//! Data sources are read-only and local: the recipe dirs from
|
|
|
|
|
+//! [`crate::config::recipe_dirs`] (each `<name>/recipe.toml`) plus each
|
|
|
|
|
+//! recipe's `reports/` folder (`<YYYY-MM-DD>_<label>.md|.log`, the
|
|
|
|
|
+//! `sm121 publish`-style campaign reports).
|
|
|
|
|
+
|
|
|
|
|
+use crate::config::{Config, Recipe};
|
|
|
|
|
+use anyhow::Result;
|
|
|
|
|
+use serde::{Deserialize, Serialize};
|
|
|
|
|
+use std::collections::HashSet;
|
|
|
|
|
+use std::path::Path;
|
|
|
|
|
+
|
|
|
|
|
+const PAGE_TEMPLATE: &str = include_str!("page.html");
|
|
|
|
|
+
|
|
|
|
|
+#[derive(Serialize, Deserialize)]
|
|
|
|
|
+pub(crate) struct ReportEntry {
|
|
|
|
|
+ date: String,
|
|
|
|
|
+ /// `HH:MM` run time (from the bench `Date`/`Run ID` timestamp), null when
|
|
|
|
|
+ /// the file carries no timestamp.
|
|
|
|
|
+ time: Option<String>,
|
|
|
|
|
+ label: String,
|
|
|
|
|
+ /// Primary file in the recipe's `reports/` dir (for the open button):
|
|
|
|
|
+ /// the human `.md` report when present, else the `.json`, else the log.
|
|
|
|
|
+ file: String,
|
|
|
|
|
+ /// All files of this run (`<date>_<label>.{md,json,log}`); the run is
|
|
|
|
|
+ /// grouped by stem so one bench never shows as three entries.
|
|
|
|
|
+ files: Vec<String>,
|
|
|
|
|
+ /// Normalized 0–100 quality score (Final Score / box summary / the
|
|
|
|
|
+ /// parenthesized value in `## Score: 159/176 (90.4)`).
|
|
|
|
|
+ pct: Option<f64>,
|
|
|
|
|
+ /// Raw `points/max` (e.g. "159/176") — only hardmode reports carry one;
|
|
|
|
|
+ /// tool-eval-bench's `Final Score: 81 / 100` is already normalized.
|
|
|
|
|
+ points: Option<String>,
|
|
|
|
|
+ /// points/max as a ratio, for sorting the Points column (null when absent).
|
|
|
|
|
+ ratio: Option<f64>,
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+#[derive(Serialize, Deserialize)]
|
|
|
|
|
+pub(crate) struct ReportRow {
|
|
|
|
|
+ name: String,
|
|
|
|
|
+ title: String,
|
|
|
|
|
+ model: String,
|
|
|
|
|
+ quant: String,
|
|
|
|
|
+ engine: String,
|
|
|
|
|
+ image: String,
|
|
|
|
|
+ nodes: Option<u32>,
|
|
|
|
|
+ tp: u32,
|
|
|
|
|
+ deploy: String,
|
|
|
|
|
+ port: u16,
|
|
|
|
|
+ reports: Vec<ReportEntry>,
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/// Quantization tokens, longest first so `nvfp4kv` wins over `nvfp4`, etc.
|
|
|
|
|
+const QUANTS: &[&str] = &[
|
|
|
|
|
+ "nvfp4kv", "nvfp4", "mxfp8", "mxfp4", "w4a16", "dcp4", "exl3", "int8", "int4", "nf4", "fp8",
|
|
|
|
|
+ "awq", "gptq", "bf16", "fp16",
|
|
|
|
|
+];
|
|
|
|
|
+
|
|
|
|
|
+fn detect_quant(name: &str, model: &str) -> String {
|
|
|
|
|
+ let hay = format!("{name} {model}").to_lowercase();
|
|
|
|
|
+ // ponytail: token-scan heuristic over dir+model names; per-recipe `quant`
|
|
|
|
|
+ // field if recipes ever grow one.
|
|
|
|
|
+ for q in QUANTS {
|
|
|
|
|
+ if hay.contains(q) {
|
|
|
|
|
+ return q.to_uppercase();
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ "—".to_string()
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/// Parse the score headers used by campaign reports and raw bench logs:
|
|
|
|
|
+/// `## Score: 159/176 (90.4)`, `- **Final Score**: **81** / 100`, and the
|
|
|
|
|
+/// box-drawn log summary `│ Score: 88 / 100 │`. Lines must start with
|
|
|
|
|
+/// a score prefix (after stripping markdown bullets / box-drawing chars),
|
|
|
|
|
+/// so prose and embedded tool transcripts can't produce false positives.
|
|
|
|
|
+/// Returns `(points, max, normalized_pct)`.
|
|
|
|
|
+fn parse_score(content: &str) -> Option<(u32, u32, Option<f64>)> {
|
|
|
|
|
+ for line in content.lines() {
|
|
|
|
|
+ let l = line.trim_start_matches(|c: char| {
|
|
|
|
|
+ matches!(c, '#' | '-' | '•' | '|' | '│' | '┃') || c.is_whitespace()
|
|
|
|
|
+ });
|
|
|
|
|
+ let is_score = l.starts_with("**Final Score**") || l.starts_with("Score:");
|
|
|
|
|
+ if is_score && let Some((p, m)) = slash_pair(l) {
|
|
|
|
|
+ let pct = paren_pct(l)
|
|
|
|
|
+ // `Final Score` / log box are already out of 100
|
|
|
|
|
+ .or_else(|| (m == 100).then_some(p as f64))
|
|
|
|
|
+ .or_else(|| Some(p as f64 / m as f64 * 100.0));
|
|
|
|
|
+ return Some((p, m, pct));
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ None
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/// First parenthesized number, e.g. `(90.4)` in `## Score: 159/176 (90.4)`.
|
|
|
|
|
+fn paren_pct(s: &str) -> Option<f64> {
|
|
|
|
|
+ let open = s.find('(')?;
|
|
|
|
|
+ let close = open + s[open..].find(')')?;
|
|
|
|
|
+ s[open + 1..close].trim().parse().ok()
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/// First `YYYY-MM-DDTHH:MM` (bench `**Date**` line) or `YYYY-MM-DDTHH-MM`
|
|
|
|
|
+/// (Run ID / full-report path) timestamp; returns `HH:MM`.
|
|
|
|
|
+fn extract_time(content: &str) -> Option<String> {
|
|
|
|
|
+ for line in content.lines() {
|
|
|
|
|
+ let b = line.as_bytes();
|
|
|
|
|
+ for i in 0..b.len().saturating_sub(15) {
|
|
|
|
|
+ let d = |j: usize| b[i + j].is_ascii_digit();
|
|
|
|
|
+ if d(0)
|
|
|
|
|
+ && d(1)
|
|
|
|
|
+ && d(2)
|
|
|
|
|
+ && d(3)
|
|
|
|
|
+ && b[i + 4] == b'-'
|
|
|
|
|
+ && d(5)
|
|
|
|
|
+ && d(6)
|
|
|
|
|
+ && b[i + 7] == b'-'
|
|
|
|
|
+ && d(8)
|
|
|
|
|
+ && d(9)
|
|
|
|
|
+ && b[i + 10] == b'T'
|
|
|
|
|
+ && d(11)
|
|
|
|
|
+ && d(12)
|
|
|
|
|
+ && (b[i + 13] == b':' || b[i + 13] == b'-')
|
|
|
|
|
+ && d(14)
|
|
|
|
|
+ && d(15)
|
|
|
|
|
+ {
|
|
|
|
|
+ let mut t = line[i + 11..i + 16].to_string();
|
|
|
|
|
+ if t.as_bytes()[2] == b'-' {
|
|
|
|
|
+ t.replace_range(2..3, ":");
|
|
|
|
|
+ }
|
|
|
|
|
+ return Some(t);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ None
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/// First `N…/M` pair in `s`, tolerating `*` and spaces around both sides
|
|
|
|
|
+/// (bold markdown). Plain digit scan — no regex dependency.
|
|
|
|
|
+fn slash_pair(s: &str) -> Option<(u32, u32)> {
|
|
|
|
|
+ let b = s.as_bytes();
|
|
|
|
|
+ let mut i = 0;
|
|
|
|
|
+ while i < b.len() {
|
|
|
|
|
+ if !b[i].is_ascii_digit() {
|
|
|
|
|
+ i += 1;
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+ let start = i;
|
|
|
|
|
+ while i < b.len() && b[i].is_ascii_digit() {
|
|
|
|
|
+ i += 1;
|
|
|
|
|
+ }
|
|
|
|
|
+ let mut j = i;
|
|
|
|
|
+ while j < b.len() && matches!(b[j], b' ' | b'*') {
|
|
|
|
|
+ j += 1;
|
|
|
|
|
+ }
|
|
|
|
|
+ if j < b.len() && b[j] == b'/' {
|
|
|
|
|
+ let mut k = j + 1;
|
|
|
|
|
+ while k < b.len() && matches!(b[k], b' ' | b'*') {
|
|
|
|
|
+ k += 1;
|
|
|
|
|
+ }
|
|
|
|
|
+ let dstart = k;
|
|
|
|
|
+ while k < b.len() && b[k].is_ascii_digit() {
|
|
|
|
|
+ k += 1;
|
|
|
|
|
+ }
|
|
|
|
|
+ if let (Ok(n), Ok(m)) = (s[start..i].parse(), s[dstart..k].parse()) {
|
|
|
|
|
+ return Some((n, m));
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ None
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/// Parse the tool-eval-bench `--json-file` output (authoritative for
|
|
|
|
|
+/// `sm121 bench` runs): `scores.final_score` (0–100), `scores.total_points`
|
|
|
|
|
+/// / `max_points` (hardmode). Returns `(pct, points, ratio)`; `points` is
|
|
|
|
|
+/// suppressed when max is 100 (already normalized, same rule as the text
|
|
|
|
|
+/// parsers).
|
|
|
|
|
+fn parse_bench_json(content: &str) -> Option<(f64, Option<String>, Option<f64>)> {
|
|
|
|
|
+ let v: serde_json::Value = serde_json::from_str(content).ok()?;
|
|
|
|
|
+ let s = v.get("scores")?;
|
|
|
|
|
+ let pct = s.get("final_score").and_then(|x| x.as_f64())?;
|
|
|
|
|
+ let tp = s.get("total_points").and_then(|x| x.as_u64());
|
|
|
|
|
+ let mp = s.get("max_points").and_then(|x| x.as_u64());
|
|
|
|
|
+ let (points, ratio) = match (tp, mp) {
|
|
|
|
|
+ (Some(p), Some(m)) if m > 0 && m != 100 => {
|
|
|
|
|
+ (Some(format!("{p}/{m}")), Some(p as f64 / m as f64))
|
|
|
|
|
+ }
|
|
|
|
|
+ _ => (None, None),
|
|
|
|
|
+ };
|
|
|
|
|
+ Some((pct, points, ratio))
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+fn scan_reports(recipe_dir: &Path) -> Vec<ReportEntry> {
|
|
|
|
|
+ let reports = recipe_dir.join("reports");
|
|
|
|
|
+ let Ok(rd) = std::fs::read_dir(&reports) else {
|
|
|
|
|
+ return vec![];
|
|
|
|
|
+ };
|
|
|
|
|
+ // Group files by run stem: `<YYYY-MM-DD>_<label>.{md,json,log}` → one
|
|
|
|
|
+ // entry per bench run (a run's three files are one report, not three).
|
|
|
|
|
+ let mut groups: std::collections::BTreeMap<String, Vec<String>> =
|
|
|
|
|
+ std::collections::BTreeMap::new();
|
|
|
|
|
+ for entry in rd.flatten() {
|
|
|
|
|
+ let path = entry.path();
|
|
|
|
|
+ let Some(file) = path.file_name().and_then(|f| f.to_str()).map(String::from) else {
|
|
|
|
|
+ continue;
|
|
|
|
|
+ };
|
|
|
|
|
+ let (stem, _ext) = match file.rsplit_once('.') {
|
|
|
|
|
+ Some((s, e)) if matches!(e, "md" | "json" | "log") => (s, e),
|
|
|
|
|
+ _ => continue,
|
|
|
|
|
+ };
|
|
|
|
|
+ groups.entry(stem.to_string()).or_default().push(file);
|
|
|
|
|
+ }
|
|
|
|
|
+ let read = |files: &[String], ext: &str| -> Option<String> {
|
|
|
|
|
+ files
|
|
|
|
|
+ .iter()
|
|
|
|
|
+ .find(|f| f.ends_with(&format!(".{ext}")))
|
|
|
|
|
+ .and_then(|f| std::fs::read_to_string(reports.join(f)).ok())
|
|
|
|
|
+ };
|
|
|
|
|
+ let mut out = Vec::new();
|
|
|
|
|
+ for (stem, mut files) in groups {
|
|
|
|
|
+ files.sort();
|
|
|
|
|
+ let (date, label) = match stem.split_once('_') {
|
|
|
|
|
+ Some((d, l)) if d.len() == 10 && d.as_bytes()[4] == b'-' => {
|
|
|
|
|
+ (d.to_string(), l.to_string())
|
|
|
|
|
+ }
|
|
|
|
|
+ _ => (String::new(), stem.clone()),
|
|
|
|
|
+ };
|
|
|
|
|
+ let json = read(&files, "json");
|
|
|
|
|
+ let md = read(&files, "md");
|
|
|
|
|
+ let log = read(&files, "log");
|
|
|
|
|
+ // Score: the bench JSON is authoritative; fall back to the .md text,
|
|
|
|
|
+ // then the raw .log (old reports are md/log only).
|
|
|
|
|
+ let (pct, points, ratio) = match json.as_deref().and_then(parse_bench_json) {
|
|
|
|
|
+ Some((p, pts, rt)) => (Some(p), pts, rt),
|
|
|
|
|
+ None => md
|
|
|
|
|
+ .as_deref()
|
|
|
|
|
+ .or(log.as_deref())
|
|
|
|
|
+ .and_then(parse_score)
|
|
|
|
|
+ .map(|(p, m, pct)| {
|
|
|
|
|
+ if m != 100 {
|
|
|
|
|
+ (pct, Some(format!("{p}/{m}")), Some(p as f64 / m as f64))
|
|
|
|
|
+ } else {
|
|
|
|
|
+ (pct, None, None)
|
|
|
|
|
+ }
|
|
|
|
|
+ })
|
|
|
|
|
+ .unwrap_or((None, None, None)),
|
|
|
|
|
+ };
|
|
|
|
|
+ let time = md
|
|
|
|
|
+ .as_deref()
|
|
|
|
|
+ .and_then(extract_time)
|
|
|
|
|
+ .or_else(|| log.as_deref().and_then(extract_time));
|
|
|
|
|
+ let file = files
|
|
|
|
|
+ .iter()
|
|
|
|
|
+ .find(|f| f.ends_with(".md"))
|
|
|
|
|
+ .or_else(|| files.iter().find(|f| f.ends_with(".json")))
|
|
|
|
|
+ .or_else(|| files.first())
|
|
|
|
|
+ .cloned()
|
|
|
|
|
+ .unwrap_or_default();
|
|
|
|
|
+ out.push(ReportEntry {
|
|
|
|
|
+ date,
|
|
|
|
|
+ time,
|
|
|
|
|
+ label,
|
|
|
|
|
+ file,
|
|
|
|
|
+ files,
|
|
|
|
|
+ pct,
|
|
|
|
|
+ points,
|
|
|
|
|
+ ratio,
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+ out.sort_by(|a, b| (&a.date, &a.label).cmp(&(&b.date, &b.label)));
|
|
|
|
|
+ out
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/// Collect all discoverable recipes (cwd + recipe_sources) with their
|
|
|
|
|
+/// report history. First dir wins on name collisions (same precedence as
|
|
|
|
|
+/// `load_recipe`).
|
|
|
|
|
+pub(crate) fn collect(config: &Config) -> Vec<ReportRow> {
|
|
|
|
|
+ let mut rows = Vec::new();
|
|
|
|
|
+ let mut seen = HashSet::new();
|
|
|
|
|
+ for dir in crate::config::recipe_dirs(config) {
|
|
|
|
|
+ let Ok(entries) = std::fs::read_dir(&dir) else {
|
|
|
|
|
+ continue;
|
|
|
|
|
+ };
|
|
|
|
|
+ for entry in entries.flatten() {
|
|
|
|
|
+ let path = entry.path();
|
|
|
|
|
+ if !path.is_dir() {
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+ let Some(name) = entry.file_name().to_str().map(String::from) else {
|
|
|
|
|
+ continue;
|
|
|
|
|
+ };
|
|
|
|
|
+ if !seen.insert(name.clone()) {
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+ let Ok(toml_str) = std::fs::read_to_string(path.join("recipe.toml")) else {
|
|
|
|
|
+ continue;
|
|
|
|
|
+ };
|
|
|
|
|
+ let Ok(r) = toml::from_str::<Recipe>(&toml_str) else {
|
|
|
|
|
+ continue;
|
|
|
|
|
+ };
|
|
|
|
|
+ let quant = detect_quant(&name, &r.model);
|
|
|
|
|
+ let reports = scan_reports(&path);
|
|
|
|
|
+ rows.push(ReportRow {
|
|
|
|
|
+ name,
|
|
|
|
|
+ title: r.name,
|
|
|
|
|
+ model: r.model,
|
|
|
|
|
+ quant,
|
|
|
|
|
+ engine: r.engine,
|
|
|
|
|
+ image: r.docker_image,
|
|
|
|
|
+ nodes: r.required_nodes,
|
|
|
|
|
+ tp: r.tensor_parallel_size,
|
|
|
|
|
+ deploy: format!("{:?}", r.deploy).to_lowercase(),
|
|
|
|
|
+ port: r.port,
|
|
|
|
|
+ reports,
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ rows.sort_by(|a, b| (&a.model, &a.quant, &a.name).cmp(&(&b.model, &b.quant, &b.name)));
|
|
|
|
|
+ rows
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+fn latest_score(r: &ReportRow) -> Option<&ReportEntry> {
|
|
|
|
|
+ r.reports
|
|
|
|
|
+ .iter()
|
|
|
|
|
+ .rev()
|
|
|
|
|
+ .find(|e| e.pct.is_some() || e.points.is_some())
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/// Normalized score, whole numbers without decimals (81, not 81.0).
|
|
|
|
|
+fn fmt_pct(pct: f64) -> String {
|
|
|
|
|
+ if (pct - pct.trunc()).abs() < 1e-9 {
|
|
|
|
|
+ format!("{}", pct as i64)
|
|
|
|
|
+ } else {
|
|
|
|
|
+ format!("{pct:.1}")
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+fn markdown(rows: &[ReportRow]) -> String {
|
|
|
|
|
+ let mut s = format!(
|
|
|
|
|
+ "# {}\n\n",
|
|
|
|
|
+ fluent_i18n::t!("report-title", { "count" => rows.len() })
|
|
|
|
|
+ );
|
|
|
|
|
+ s.push_str(&format!(
|
|
|
|
|
+ "| {} | {} | {} | {} | {} | {} | {} | {} | {} |\n",
|
|
|
|
|
+ t("report-col-model"),
|
|
|
|
|
+ t("report-col-quant"),
|
|
|
|
|
+ t("report-col-recipe"),
|
|
|
|
|
+ t("report-col-engine"),
|
|
|
|
|
+ t("report-col-nodes"),
|
|
|
|
|
+ t("report-col-reports"),
|
|
|
|
|
+ t("report-col-last"),
|
|
|
|
|
+ t("report-col-points"),
|
|
|
|
|
+ t("report-col-score"),
|
|
|
|
|
+ ));
|
|
|
|
|
+ s.push_str("|---|---|---|---|---|---|---|---|---|\n");
|
|
|
|
|
+ for r in rows {
|
|
|
|
|
+ let last = r.reports.last();
|
|
|
|
|
+ let latest = latest_score(r);
|
|
|
|
|
+ s.push_str(&format!(
|
|
|
|
|
+ "| {} | {} | {} | {} | {} | {} | {} | {} | {} |\n",
|
|
|
|
|
+ r.model,
|
|
|
|
|
+ r.quant,
|
|
|
|
|
+ r.name,
|
|
|
|
|
+ r.engine,
|
|
|
|
|
+ r.nodes
|
|
|
|
|
+ .map(|n| format!("{n}×{}", r.tp))
|
|
|
|
|
+ .unwrap_or_else(|| format!("1×{}", r.tp)),
|
|
|
|
|
+ r.reports.len(),
|
|
|
|
|
+ last.map(|e| match &e.time {
|
|
|
|
|
+ Some(t) => format!("{} {t}", e.date),
|
|
|
|
|
+ None => e.date.clone(),
|
|
|
|
|
+ })
|
|
|
|
|
+ .unwrap_or("—".into()),
|
|
|
|
|
+ latest
|
|
|
|
|
+ .and_then(|e| e.points.clone())
|
|
|
|
|
+ .unwrap_or_else(|| "—".into()),
|
|
|
|
|
+ latest
|
|
|
|
|
+ .and_then(|e| e.pct)
|
|
|
|
|
+ .map(fmt_pct)
|
|
|
|
|
+ .unwrap_or_else(|| "—".into()),
|
|
|
|
|
+ ));
|
|
|
|
|
+ }
|
|
|
|
|
+ s
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+fn t(key: &str) -> String {
|
|
|
|
|
+ fluent_i18n::t!(key)
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/// Load a report file for the `/r/<recipe>/<file>` route. Both path parts
|
|
|
|
|
+/// must be plain names (no traversal) — the route is loopback-only, but the
|
|
|
|
|
+/// resolve walks every recipe dir, so stay strict.
|
|
|
|
|
+fn load_report(config: &Config, recipe: &str, file: &str) -> Option<String> {
|
|
|
|
|
+ let plain = |s: &str| {
|
|
|
|
|
+ !s.contains("..")
|
|
|
|
|
+ && s.chars()
|
|
|
|
|
+ .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'))
|
|
|
|
|
+ };
|
|
|
|
|
+ if !plain(recipe) || !plain(file) {
|
|
|
|
|
+ return None;
|
|
|
|
|
+ }
|
|
|
|
|
+ crate::config::recipe_dirs(config)
|
|
|
|
|
+ .into_iter()
|
|
|
|
|
+ .find_map(|dir| {
|
|
|
|
|
+ let p = dir.join(recipe).join("reports").join(file);
|
|
|
|
|
+ p.is_file()
|
|
|
|
|
+ .then(|| std::fs::read_to_string(p).ok())
|
|
|
|
|
+ .flatten()
|
|
|
|
|
+ })
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/// `GET /r/<recipe>/<file>` — full report text in a minimal pre page.
|
|
|
|
|
+async fn report_file(
|
|
|
|
|
+ axum::extract::State(state): axum::extract::State<std::sync::Arc<Config>>,
|
|
|
|
|
+ axum::extract::Path((recipe, file)): axum::extract::Path<(String, String)>,
|
|
|
|
|
+) -> axum::response::Response {
|
|
|
|
|
+ use axum::http::StatusCode;
|
|
|
|
|
+ use axum::response::IntoResponse;
|
|
|
|
|
+ match load_report(&state, &recipe, &file) {
|
|
|
|
|
+ Some(text) => axum::response::Html(format!(
|
|
|
|
|
+ "<!doctype html><meta charset=\"utf-8\"><title>{recipe} — {file}</title>\
|
|
|
|
|
+ <pre style=\"font:12px/1.45 ui-monospace,monospace;white-space:pre-wrap;\
|
|
|
|
|
+ word-break:break-word;margin:16px;\">{}</pre>",
|
|
|
|
|
+ text.replace('&', "&")
|
|
|
|
|
+ .replace('<', "<")
|
|
|
|
|
+ .replace('>', ">")
|
|
|
|
|
+ ))
|
|
|
|
|
+ .into_response(),
|
|
|
|
|
+ None => (
|
|
|
|
|
+ StatusCode::NOT_FOUND,
|
|
|
|
|
+ axum::response::Html(
|
|
|
|
|
+ "<!doctype html><meta charset=\"utf-8\"><title>404</title>report not found"
|
|
|
|
|
+ .to_string(),
|
|
|
|
|
+ ),
|
|
|
|
|
+ )
|
|
|
|
|
+ .into_response(),
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/// The interactive page: template with `{{DATA}}` replaced by the JSON
|
|
|
|
|
+/// rows (`<` escaped so `</script>` in data can't break out).
|
|
|
|
|
+fn html_page(rows: &[ReportRow]) -> String {
|
|
|
|
|
+ let json = serde_json::to_string(rows).unwrap_or_else(|_| "[]".into());
|
|
|
|
|
+ PAGE_TEMPLATE.replace("{{DATA}}", &json.replace('<', "\\u003c"))
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/// Entry point: Markdown to stdout, or the interactive page on
|
|
|
|
|
+/// `http://127.0.0.1:<port>/` (`--open` also spawns the browser).
|
|
|
|
|
+pub async fn run(config: &Config, serve: bool, port: u16, open: bool) -> Result<()> {
|
|
|
|
|
+ let rows = collect(config);
|
|
|
|
|
+ if !serve {
|
|
|
|
|
+ print!("{}", markdown(&rows));
|
|
|
|
|
+ return Ok(());
|
|
|
|
|
+ }
|
|
|
|
|
+ let page = html_page(&rows);
|
|
|
|
|
+ let listener = tokio::net::TcpListener::bind(("127.0.0.1", port)).await?;
|
|
|
|
|
+ let addr = listener.local_addr()?;
|
|
|
|
|
+ let app = axum::Router::new()
|
|
|
|
|
+ .route(
|
|
|
|
|
+ "/",
|
|
|
|
|
+ axum::routing::get(move || {
|
|
|
|
|
+ let page = page.clone();
|
|
|
|
|
+ async move { axum::response::Html(page) }
|
|
|
|
|
+ }),
|
|
|
|
|
+ )
|
|
|
|
|
+ .route("/r/{recipe}/{file}", axum::routing::get(report_file))
|
|
|
|
|
+ .with_state(std::sync::Arc::new(config.clone()));
|
|
|
|
|
+ if open {
|
|
|
|
|
+ let url = format!("http://{addr}/");
|
|
|
|
|
+ tokio::spawn(async move {
|
|
|
|
|
+ tokio::time::sleep(std::time::Duration::from_millis(400)).await;
|
|
|
|
|
+ let prog = if cfg!(target_os = "macos") {
|
|
|
|
|
+ "open"
|
|
|
|
|
+ } else {
|
|
|
|
|
+ "xdg-open"
|
|
|
|
|
+ };
|
|
|
|
|
+ let _ = std::process::Command::new(prog).arg(&url).spawn();
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+ println!(
|
|
|
|
|
+ "{}",
|
|
|
|
|
+ fluent_i18n::t!("report-serving", { "url" => format!("http://{addr}/") })
|
|
|
|
|
+ );
|
|
|
|
|
+ axum::serve(listener, app).await?;
|
|
|
|
|
+ Ok(())
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+#[cfg(test)]
|
|
|
|
|
+mod tests;
|