use serde::Serialize;
use crate::config::{Badge, Colour};
use crate::model::types::Bead;
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct Badged {
pub key: String,
pub text: String,
pub short: Option<String>,
pub link: Option<String>,
pub colour: Option<Colour>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(tag = "undrawn", rename_all = "kebab-case")]
pub enum Undrawn {
Badge { key: String },
Link { key: String },
Short { key: String },
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Badges {
pub drawn: Vec<Badged>,
pub undrawn: Vec<Undrawn>,
}
pub fn badges_for(bead: &Bead, badges: &[Badge]) -> Badges {
let mut drawn = Vec::new();
let mut undrawn = Vec::new();
for badge in badges {
let Some(value) = bead.values.get(&badge.key).map(String::as_str) else {
continue;
};
let promised = badge.link.is_some();
let Some(text) = badge.apply(value) else {
if promised {
undrawn.push(Undrawn::Badge {
key: badge.key.clone(),
});
}
continue;
};
let link = badge.link_for(value);
if promised && link.is_none() {
undrawn.push(Undrawn::Link {
key: badge.key.clone(),
});
}
let short = badge.short_for(value);
if badge.short.is_some() && short.is_none() {
undrawn.push(Undrawn::Short {
key: badge.key.clone(),
});
}
drawn.push(Badged {
key: badge.key.clone(),
text,
short,
link,
colour: badge.colour,
});
}
Badges { drawn, undrawn }
}
#[cfg(test)]
mod tests {
use super::*;
use crate::collect::bd::parse_beads;
use pretty_assertions::assert_eq;
use crate::config::Pattern;
fn matching(pattern: &str) -> Pattern {
Pattern::new(pattern).expect("the pattern compiles")
}
fn bead_with(metadata: &str) -> Bead {
let json =
format!(r#"[{{"id":"p-1","title":"root","status":"open","metadata":{metadata}}}]"#);
parse_beads(&json).expect("the bead parses").remove(0)
}
#[test]
fn badges_render_only_where_the_key_and_match_agree() {
let bead = bead_with(r#"{"blocked_on":"human","delivery_pr":"owner/repo#7"}"#);
let cfg = vec![
Badge {
key: "metadata.blocked_on".into(),
match_value: Some(matching("human")),
render: "waiting".into(),
link: None,
short: None,
colour: None,
},
Badge {
key: "metadata.blocked_on".into(),
match_value: Some(matching("dependency")),
render: "dep".into(),
link: None,
short: None,
colour: None,
},
Badge {
key: "metadata.absent_key".into(),
match_value: None,
render: "never".into(),
link: None,
short: None,
colour: None,
},
];
let got = badges_for(&bead, &cfg);
assert_eq!(
got.drawn,
vec![Badged {
key: "metadata.blocked_on".to_string(),
text: "waiting".to_string(),
link: None,
short: None,
colour: None,
}]
);
}
#[test]
fn badges_render_a_configured_key_without_interpreting_it() {
let bead = bead_with(r#"{"xyzzy":"plugh"}"#);
let cfg = vec![Badge {
key: "metadata.xyzzy".into(),
match_value: None,
render: "→ {}".into(),
link: None,
short: None,
colour: None,
}];
let got = badges_for(&bead, &cfg);
assert_eq!(
got.drawn,
vec![Badged {
key: "metadata.xyzzy".to_string(),
text: "→ plugh".to_string(),
link: None,
short: None,
colour: None,
}]
);
}
#[test]
fn a_badges_colour_travels_with_its_text() {
let bead = bead_with(r#"{"jira":"ATLAS-19"}"#);
let cfg = vec![Badge {
key: "metadata.jira".into(),
match_value: None,
render: "{}".into(),
link: None,
short: None,
colour: Some(Colour::Status),
}];
let got = badges_for(&bead, &cfg);
assert_eq!(
got.drawn,
vec![Badged {
key: "metadata.jira".to_string(),
text: "ATLAS-19".to_string(),
link: None,
short: None,
colour: Some(Colour::Status),
}]
);
}
#[test]
fn a_bead_with_no_configured_badges_renders_none() {
let bead = bead_with(r#"{"blocked_on":"human"}"#);
assert_eq!(badges_for(&bead, &[]), Badges::default());
}
fn bead_referencing(external_ref: &str) -> Bead {
let json = format!(
r#"[{{"id":"p-1","title":"root","status":"open",
"external_ref":{external_ref},"metadata":{{"jira":"ATLAS-19"}}}}]"#
);
parse_beads(&json).expect("the bead parses").remove(0)
}
#[test]
fn a_badge_on_the_external_reference_draws_it_as_it_draws_metadata() {
let bead = bead_referencing(r#""https://jira.invalid/browse/HELIO-412""#);
let cfg = vec![
Badge {
key: "external_ref".into(),
match_value: Some(matching(r".*/(?<ticket>[A-Z]+-[0-9]+)")),
render: "{ticket}".into(),
link: Some("https://jira.invalid/browse/{ticket}".into()),
short: None,
colour: None,
},
Badge {
key: "metadata.jira".into(),
match_value: None,
render: "{}".into(),
link: None,
short: None,
colour: None,
},
];
let got = badges_for(&bead, &cfg);
assert_eq!(
got.drawn,
vec![
Badged {
key: "external_ref".to_string(),
text: "HELIO-412".to_string(),
link: Some("https://jira.invalid/browse/HELIO-412".to_string()),
short: None,
colour: None,
},
Badged {
key: "metadata.jira".to_string(),
text: "ATLAS-19".to_string(),
link: None,
short: None,
colour: None,
},
]
);
assert_eq!(got.undrawn, Vec::new());
}
#[test]
fn a_value_a_bead_does_not_hold_draws_no_badge_and_reports_nothing() {
let promising = |key: &str| Badge {
key: key.to_string(),
match_value: None,
render: "{}".into(),
link: Some("https://jira.invalid/browse/{}".into()),
short: Some("{}".into()),
colour: None,
};
for spelling in [r#""""#, "null"] {
let got = badges_for(&bead_referencing(spelling), &[promising("external_ref")]);
assert_eq!(got.drawn, Vec::new(), "drew on {spelling}");
assert_eq!(got.undrawn, Vec::new(), "reported on {spelling}");
}
let carrying_neither = bead_with(r#"{"jira":"ATLAS-19"}"#);
for key in ["external_ref", "metadata.nobody_wrote_this"] {
let got = badges_for(&carrying_neither, &[promising(key)]);
assert_eq!(got.drawn, Vec::new(), "drew on {key}");
assert_eq!(got.undrawn, Vec::new(), "reported on {key}");
}
}
fn qualified_only() -> Badge {
Badge {
key: "metadata.delivery_pr".into(),
match_value: Some(matching(
r"(?<owner>[^/]+)/(?<repo>[^#]+)#(?<number>[0-9]+)",
)),
render: "⇢ #{number}".into(),
link: Some("https://forge.invalid/{owner}/{repo}/pull/{number}".into()),
short: None,
colour: None,
}
}
#[test]
fn a_badge_meant_to_draw_reports_a_value_its_pattern_cannot_read() {
let bead = bead_with(r#"{"delivery_pr":"30"}"#);
let got = badges_for(&bead, &[qualified_only()]);
assert_eq!(got.drawn, Vec::new());
assert_eq!(
got.undrawn,
vec![Undrawn::Badge {
key: "metadata.delivery_pr".to_string()
}]
);
}
#[test]
fn a_badge_configured_to_decline_stays_silent() {
let bead = bead_with(r#"{"blocked_on":"dependency"}"#);
let filter = Badge {
key: "metadata.blocked_on".into(),
match_value: Some(matching("human")),
render: "⏸ waiting".into(),
link: None,
short: None,
colour: None,
};
let got = badges_for(&bead, &[filter]);
assert_eq!(got.drawn, Vec::new());
assert_eq!(got.undrawn, Vec::new());
}
#[test]
fn a_badge_whose_key_the_bead_does_not_carry_reports_nothing() {
let bead = bead_with(r#"{"blocked_on":"human"}"#);
let got = badges_for(&bead, &[qualified_only()]);
assert_eq!(got.drawn, Vec::new());
assert_eq!(got.undrawn, Vec::new());
}
#[test]
fn a_badge_reports_a_link_its_value_could_not_fill() {
let bead = bead_with(r#"{"delivery_pr":"30"}"#);
let either_form = Badge {
match_value: Some(matching(
r"(?:(?<owner>[^/]+)/(?<repo>[^#]+))?#?(?<number>[0-9]+)",
)),
..qualified_only()
};
let got = badges_for(&bead, &[either_form]);
assert_eq!(
got.drawn,
vec![Badged {
key: "metadata.delivery_pr".to_string(),
text: "⇢ #30".to_string(),
link: None,
short: None,
colour: None,
}]
);
assert_eq!(
got.undrawn,
vec![Undrawn::Link {
key: "metadata.delivery_pr".to_string()
}]
);
}
#[test]
fn a_badge_that_draws_its_link_reports_nothing() {
let bead = bead_with(r#"{"delivery_pr":"orbital/atlas#30"}"#);
let got = badges_for(&bead, &[qualified_only()]);
assert_eq!(
got.drawn,
vec![Badged {
key: "metadata.delivery_pr".to_string(),
text: "⇢ #30".to_string(),
link: Some("https://forge.invalid/orbital/atlas/pull/30".to_string()),
short: None,
colour: None,
}]
);
assert_eq!(got.undrawn, Vec::new());
}
#[test]
fn a_badge_carries_the_short_form_its_config_named() {
let bead = bead_with(r#"{"delivery_pr":"orbital/atlas#30"}"#);
let both_forms = Badge {
render: "⇢ {repo} #{number}".into(),
short: Some("⇢ #{number}".into()),
..qualified_only()
};
let got = badges_for(&bead, &[both_forms]);
assert_eq!(
got.drawn,
vec![Badged {
key: "metadata.delivery_pr".to_string(),
text: "⇢ atlas #30".to_string(),
short: Some("⇢ #30".to_string()),
link: Some("https://forge.invalid/orbital/atlas/pull/30".to_string()),
colour: None,
}]
);
assert_eq!(got.undrawn, Vec::new());
}
#[test]
fn a_badge_whose_config_names_no_short_form_carries_none() {
let bead = bead_with(r#"{"delivery_pr":"orbital/atlas#30"}"#);
let got = badges_for(&bead, &[qualified_only()]);
assert_eq!(got.drawn[0].short, None);
assert_eq!(got.undrawn, Vec::new());
}
#[test]
fn a_badge_reports_a_short_form_its_value_could_not_fill() {
let bead = bead_with(r#"{"delivery_pr":"30"}"#);
let unlinked_either_form = Badge {
match_value: Some(matching(
r"(?:(?<owner>[^/]+)/(?<repo>[^#]+))?#?(?<number>[0-9]+)",
)),
render: "⇢ #{number}".into(),
short: Some("⇢ {repo}".into()),
link: None,
..qualified_only()
};
let got = badges_for(&bead, &[unlinked_either_form]);
assert_eq!(
got.drawn,
vec![Badged {
key: "metadata.delivery_pr".to_string(),
text: "⇢ #30".to_string(),
short: None,
link: None,
colour: None,
}]
);
assert_eq!(
got.undrawn,
vec![Undrawn::Short {
key: "metadata.delivery_pr".to_string()
}]
);
}
#[test]
fn a_badge_configured_to_decline_reports_no_short_form() {
let bead = bead_with(r#"{"blocked_on":"dependency"}"#);
let filter = Badge {
key: "metadata.blocked_on".into(),
match_value: Some(matching("human")),
render: "⏸ waiting".into(),
short: Some("⏸".into()),
link: None,
colour: None,
};
let got = badges_for(&bead, &[filter]);
assert_eq!(got.drawn, Vec::new());
assert_eq!(got.undrawn, Vec::new());
}
}