15.4 KiB · text History 8e00315
//! Status badges: what a repository has, and the README block that shows
//! them.
//!
//! The block itself comes from the server (`GET …/badges`), the same
//! Markdown the badge sheet's README card builds, so a README patched
//! here and one pasted from the page say the same thing.
//!
//! `--patch` keeps the block between two markers:
//!
//! ```text
//! <!-- gg:badges ci coverage release license style=dot -->
//! [![CI status](…)](…) [![coverage](…)](…) …
//! <!-- /gg:badges -->
//! ```
//!
//! The opening marker records which badges and which options, so a
//! second run (an agent's, a CI job's) needs no arguments to reproduce
//! it, and any given override what's recorded. Everything outside the
//! markers is left exactly as it was.

use anyhow::{Context as _, Result, bail};
use serde_json::json;
use std::path::Path;

use crate::api::models::Badge;
use crate::cli::{BadgesCommand, ReadmeArgs};
use crate::commands::Ctx;
use crate::output::{Table, Tone, note, paint, print_json, truncate};

const OPEN: &str = "<!-- gg:badges";
const CLOSE: &str = "<!-- /gg:badges -->";

pub async fn run(ctx: &Ctx, command: Option<&BadgesCommand>) -> Result<()> {
    match command {
        None | Some(BadgesCommand::List) => list(ctx).await,
        Some(BadgesCommand::Readme(args)) => readme(ctx, args).await,
    }
}

async fn list(ctx: &Ctx) -> Result<()> {
    let repo = ctx.repo()?;
    let res = ctx.api.badges(&repo, &[]).await?;
    if ctx.emitted(&res.raw)? {
        return Ok(());
    }

    let mut table = Table::new(["KEY", "GROUP", "SAYS", "WHAT"]);
    for badge in &res.badges {
        let says = match (&badge.label, &badge.value) {
            (Some(label), Some(value)) => format!("{label}: {value}"),
            _ => "GG".to_string(),
        };
        let marker = if res.defaults.contains(&badge.key) {
            " *"
        } else {
            ""
        };
        table.row([
            format!("{}{marker}", badge.key),
            badge.group.clone(),
            paint(&says, tone(badge.status.as_deref())),
            truncate(badge.hint.as_deref().unwrap_or(&badge.title), 40),
        ]);
    }
    table.print();
    note("* in a README block by default. `gg badges readme [KEY…]` builds one.");
    Ok(())
}

fn tone(status: Option<&str>) -> Tone {
    match status {
        Some("success") => Tone::Good,
        Some("error") => Tone::Bad,
        Some("warning") => Tone::Warn,
        Some("info") => Tone::Accent,
        Some("muted") => Tone::Muted,
        _ => Tone::Plain,
    }
}

async fn readme(ctx: &Ctx, args: &ReadmeArgs) -> Result<()> {
    let repo = ctx.repo()?;

    // What the file's block already says, when patching one.
    let existing = match &args.patch {
        Some(path) => Some(read_existing(path)?),
        None => None,
    };
    let recorded = existing
        .as_deref()
        .and_then(find_block)
        .map(|b| b.recorded)
        .unwrap_or_default();

    let opts = Options::merge(args, &recorded.options);
    let res = ctx.api.badges(&repo, &opts.query()).await?;

    let keys: Vec<String> = if args.all {
        res.badges.iter().map(|b| b.key.clone()).collect()
    } else if !args.keys.is_empty() {
        args.keys.clone()
    } else if !recorded.keys.is_empty() {
        recorded.keys.clone()
    } else {
        res.defaults.clone()
    };

    let chosen = pick(&res.badges, &keys)?;
    // HTML if asked for, or if the block was HTML and Markdown wasn't.
    let html = args.html || (recorded.html && !args.markdown);
    let body = render(&chosen, html);

    let Some(path) = &args.patch else {
        if ctx.json {
            return print_json(&json!({
                "repository": res.repository,
                "keys": keys,
                "options": opts.as_json(),
                "format": if html { "html" } else { "markdown" },
                "block": body,
            }));
        }
        println!("{body}");
        return Ok(());
    };

    let before = existing.unwrap_or_default();
    let marker = opening_marker(&keys, &opts, html);
    let after = patch(&before, &marker, &body);
    let changed = after != before;

    if ctx.json {
        print_json(&json!({
            "file": path,
            "changed": changed,
            "checked": args.check,
            "keys": keys,
            "options": opts.as_json(),
        }))?;
    }

    if args.check {
        if changed {
            bail!(
                "{}'s badges are out of date: run `gg badges readme --patch {}`",
                path.display(),
                path.display()
            );
        }
        if !ctx.json {
            eprintln!("{} is up to date.", path.display());
        }
        return Ok(());
    }

    if changed {
        std::fs::write(path, &after).with_context(|| format!("writing {}", path.display()))?;
        if !ctx.json {
            eprintln!(
                "Updated {}: {} badge{}.",
                path.display(),
                chosen.len(),
                if chosen.len() == 1 { "" } else { "s" }
            );
        }
    } else if !ctx.json {
        eprintln!("{} is already up to date.", path.display());
    }
    Ok(())
}

/// A missing file is an empty one: `--patch NEW.md` creates it.
fn read_existing(path: &Path) -> Result<String> {
    match std::fs::read_to_string(path) {
        Ok(text) => Ok(text),
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(String::new()),
        Err(e) => Err(e).with_context(|| format!("reading {}", path.display())),
    }
}

/// The badges `keys` name, in that order. An unknown key is an error that
/// lists the ones there are, since a typo otherwise drops a badge silently.
fn pick<'a>(badges: &'a [Badge], keys: &[String]) -> Result<Vec<&'a Badge>> {
    keys.iter()
        .map(|key| {
            badges.iter().find(|b| &b.key == key).ok_or_else(|| {
                let known: Vec<&str> = badges.iter().map(|b| b.key.as_str()).collect();
                anyhow::anyhow!("no badge {key:?} here; there are: {}", known.join(", "))
            })
        })
        .collect()
}

/// Markdown on one line (a row of badges, as READMEs have them), or HTML
/// one badge per element.
fn render(badges: &[&Badge], html: bool) -> String {
    if html {
        badges
            .iter()
            .map(|b| b.html.as_str())
            .collect::<Vec<_>>()
            .join("\n")
    } else {
        badges
            .iter()
            .map(|b| b.markdown.as_str())
            .collect::<Vec<_>>()
            .join(" ")
    }
}

// ── options ──────────────────────────────────────────────────────────

/// The display options, as recorded in a marker or given as flags.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
struct Options {
    style: Option<String>,
    corners: Option<String>,
    scale: Option<String>,
    gg: bool,
    theme: Option<String>,
    format: Option<String>,
}

impl Options {
    /// Flags given win; anything not given keeps what the block recorded.
    fn merge(args: &ReadmeArgs, recorded: &Options) -> Self {
        Self {
            style: args.style.clone().or_else(|| recorded.style.clone()),
            corners: args.corners.clone().or_else(|| recorded.corners.clone()),
            scale: args.scale.clone().or_else(|| recorded.scale.clone()),
            gg: if args.gg {
                true
            } else if args.no_gg {
                false
            } else {
                recorded.gg
            },
            theme: args.theme.clone().or_else(|| recorded.theme.clone()),
            format: args.format.clone().or_else(|| recorded.format.clone()),
        }
    }

    fn query(&self) -> Vec<(&'static str, String)> {
        let mut q = Vec::new();
        for (name, value) in self.fields() {
            if let Some(v) = value {
                q.push((name, v.to_string()));
            }
        }
        if self.gg {
            q.push(("gg", "true".into()));
        }
        q
    }

    fn fields(&self) -> [(&'static str, Option<&str>); 5] {
        [
            ("style", self.style.as_deref()),
            ("corners", self.corners.as_deref()),
            ("scale", self.scale.as_deref()),
            ("theme", self.theme.as_deref()),
            ("format", self.format.as_deref()),
        ]
    }

    fn as_json(&self) -> serde_json::Value {
        json!({
            "style": self.style, "corners": self.corners, "scale": self.scale,
            "gg": self.gg, "theme": self.theme, "format": self.format,
        })
    }
}

// ── the block ────────────────────────────────────────────────────────

/// What an opening marker recorded.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
struct Recorded {
    keys: Vec<String>,
    options: Options,
    html: bool,
}

struct Block {
    /// Byte range of the whole block, both markers included.
    start: usize,
    end: usize,
    recorded: Recorded,
}

/// `<!-- gg:badges ci release style=dot gg html -->`: the keys, then
/// each option that was set. Keys are percent-encoded where they'd
/// otherwise break the comment or the parse (spaces, `=`, `>`).
fn opening_marker(keys: &[String], opts: &Options, html: bool) -> String {
    let mut parts: Vec<String> = keys.iter().map(|k| encode(k)).collect();
    for (name, value) in opts.fields() {
        if let Some(v) = value {
            parts.push(format!("{name}={}", encode(v)));
        }
    }
    if opts.gg {
        parts.push("gg".into());
    }
    if html {
        parts.push("html".into());
    }
    format!("{OPEN} {} -->", parts.join(" "))
}

fn parse_marker(inner: &str) -> Recorded {
    let mut rec = Recorded::default();
    for token in inner.split_whitespace() {
        match token.split_once('=') {
            Some(("style", v)) => rec.options.style = Some(decode(v)),
            Some(("corners", v)) => rec.options.corners = Some(decode(v)),
            Some(("scale", v)) => rec.options.scale = Some(decode(v)),
            Some(("theme", v)) => rec.options.theme = Some(decode(v)),
            Some(("format", v)) => rec.options.format = Some(decode(v)),
            Some(_) => {}
            None if token == "gg" => rec.options.gg = true,
            None if token == "html" => rec.html = true,
            None => rec.keys.push(decode(token)),
        }
    }
    rec
}

fn find_block(text: &str) -> Option<Block> {
    let start = text.find(OPEN)?;
    let open_end = start + text[start..].find("-->")? + 3;
    let inner = &text[start + OPEN.len()..open_end - 3];
    let close = open_end + text[open_end..].find(CLOSE)?;
    Some(Block {
        start,
        end: close + CLOSE.len(),
        recorded: parse_marker(inner),
    })
}

/// `text` with its badge block replaced by `marker` + `body`, or, with no
/// block yet, given one: under the first `# ` heading if there is one,
/// else at the top.
fn patch(text: &str, marker: &str, body: &str) -> String {
    let block = format!("{marker}\n{body}\n{CLOSE}");

    if let Some(found) = find_block(text) {
        return format!("{}{block}{}", &text[..found.start], &text[found.end..]);
    }

    if let Some(at) = after_first_heading(text) {
        let (head, tail) = text.split_at(at);
        let tail = tail.trim_start_matches('\n');
        return if tail.is_empty() {
            format!("{head}\n{block}\n")
        } else {
            format!("{head}\n{block}\n\n{tail}")
        };
    }

    if text.is_empty() {
        format!("{block}\n")
    } else {
        format!("{block}\n\n{text}")
    }
}

/// Byte offset just past the first ATX `# ` heading's line (its newline
/// included), outside fenced code.
fn after_first_heading(text: &str) -> Option<usize> {
    let mut offset = 0;
    let mut fenced = false;
    for line in text.split_inclusive('\n') {
        let trimmed = line.trim_start();
        if trimmed.starts_with("```") || trimmed.starts_with("~~~") {
            fenced = !fenced;
        } else if !fenced && (trimmed.starts_with("# ") || trimmed.trim_end() == "#") {
            return Some(offset + line.len());
        }
        offset += line.len();
    }
    None
}

fn encode(s: &str) -> String {
    let mut out = String::with_capacity(s.len());
    for c in s.chars() {
        match c {
            '%' | '=' | '>' | '<' => out.push_str(&format!("%{:02X}", c as u32)),
            c if c.is_whitespace() => out.push_str(&format!("%{:02X}", c as u32)),
            c => out.push(c),
        }
    }
    out
}

fn decode(s: &str) -> String {
    let bytes = s.as_bytes();
    let mut out = Vec::with_capacity(bytes.len());
    let mut i = 0;
    while i < bytes.len() {
        if bytes[i] == b'%'
            && i + 2 < bytes.len()
            && let Ok(hex) = std::str::from_utf8(&bytes[i + 1..i + 3])
            && let Ok(b) = u8::from_str_radix(hex, 16)
        {
            out.push(b);
            i += 3;
        } else {
            out.push(bytes[i]);
            i += 1;
        }
    }
    String::from_utf8_lossy(&out).into_owned()
}

#[cfg(test)]
mod tests {
    use super::*;

    fn opts() -> Options {
        Options::default()
    }

    #[test]
    fn markers_round_trip() {
        let keys = vec!["ci".to_string(), "workflow:nightly audit".into()];
        let o = Options {
            style: Some("dot".into()),
            gg: true,
            ..opts()
        };
        let marker = opening_marker(&keys, &o, true);
        assert_eq!(
            marker,
            "<!-- gg:badges ci workflow:nightly%20audit style=dot gg html -->"
        );

        let text = format!("{marker}\nx\n{CLOSE}");
        let found = find_block(&text).unwrap();
        assert_eq!(found.recorded.keys, keys);
        assert_eq!(found.recorded.options, o);
        assert!(found.recorded.html);
    }

    #[test]
    fn a_new_block_goes_under_the_first_heading() {
        let text = "# Widgets\n\nDoes things.\n";
        let out = patch(text, "<!-- gg:badges ci -->", "[![ci](u)](l)");
        assert_eq!(
            out,
            "# Widgets\n\n<!-- gg:badges ci -->\n[![ci](u)](l)\n<!-- /gg:badges -->\n\nDoes things.\n"
        );
    }

    #[test]
    fn headings_in_code_fences_dont_count() {
        let text = "```sh\n# not a heading\n```\n\n# Real\n";
        let at = after_first_heading(text).unwrap();
        assert_eq!(&text[..at], "```sh\n# not a heading\n```\n\n# Real\n");
    }

    #[test]
    fn with_no_heading_it_goes_on_top() {
        assert_eq!(
            patch("Hello.\n", "<!-- gg:badges ci -->", "B"),
            "<!-- gg:badges ci -->\nB\n<!-- /gg:badges -->\n\nHello.\n"
        );
        assert_eq!(
            patch("", "<!-- gg:badges ci -->", "B"),
            "<!-- gg:badges ci -->\nB\n<!-- /gg:badges -->\n"
        );
    }

    #[test]
    fn an_existing_block_is_replaced_and_nothing_else_moves() {
        let text = "# W\n\n<!-- gg:badges ci -->\nold\n<!-- /gg:badges -->\n\nBody.\n";
        let out = patch(text, "<!-- gg:badges ci release -->", "new");
        assert_eq!(
            out,
            "# W\n\n<!-- gg:badges ci release -->\nnew\n<!-- /gg:badges -->\n\nBody.\n"
        );
        // And patching with the same again changes nothing.
        assert_eq!(patch(&out, "<!-- gg:badges ci release -->", "new"), out);
    }

    #[test]
    fn decoding_leaves_stray_percents_alone() {
        assert_eq!(decode("100%"), "100%");
        assert_eq!(decode("a%2"), "a%2");
        assert_eq!(decode(&encode("x = <y> %z")), "x = <y> %z");
    }
}