//! 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 -->
//! [](…) [](…) …
//! <!-- /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 -->", "[](l)");
assert_eq!(
out,
"# Widgets\n\n<!-- gg:badges ci -->\n[](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");
}
}
neiam
/gitgud
Git Gud
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
1
Log in to mark this repository.
15.4 KiB · text
History
b868907