Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 43 additions & 17 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,13 @@ could be read from.
to the values a pattern matches, and the pattern is anchored against the whole
value: `human` draws on `human` and not on `inhumane`.

**Several entries may name one key, and they are tried in the order you wrote
them.** The first whose `match` reads the value is the badge the row draws, and
the ones below it are never tried. So write the shape you expect first and the
shapes you will settle for under it. A value none of them reads draws nothing,
and [What a badge says when it cannot do what you asked](#what-a-badge-says-when-it-cannot-do-what-you-asked)
has that case.

A capture the pattern names is `render`'s to place by that name:

```toml
Expand Down Expand Up @@ -264,16 +271,28 @@ colour = "#c71585"

## `[[projects.badges]]`

What one project draws in place of this list, for the keys it names and no
others. A shared list can only say what the value itself carries, so a project
whose `delivery_pr` leaves out its owner and repository needs an entry of its
own to supply them. Every key a project stays silent about keeps drawing what
What one project draws ahead of this list, for the keys it names and no others.
A shared list can only say what the value itself carries, so a project whose
`delivery_pr` leaves out its owner and repository needs an entry of its own to
supply them. Every key a project stays silent about keeps drawing what
`[[badges]]` says.

It shadows by key rather than by entry: a project naming `blocked_on` replaces
*every* `[[badges]]` entry for `blocked_on`, however many values they match
between them. Its entries stand where the first `[[badges]]` entry for that key
stood, so overriding one badge does not reorder the row.
**A project's entries for a key are tried before the `[[badges]]` entries for
that key, and replace none of them.** They sit where the first `[[badges]]` entry
for the key sat, so naming a key does not move the row's other badges. The shared
entries for that key follow, in the order they were written. Keys only the
project names come last.

So a project wins a value by being tried first, not by taking anything away. A
value its own entries do not read falls through to the `[[badges]]` entries
underneath them, and draws whatever it would have drawn had the project named
nothing.

That is why one entry is usually all a project needs. It names the shape its own
tracker writes, and every other shape keeps being read by the shared list.

A project cannot silence a `[[badges]]` entry. Naming a key puts your own entries
first; it does not take the shared ones away.

**A project's own keys have to come before its badges.** `[[projects.badges]]`
opens a table of its own, so `name`, `path` or anything else written after it
Expand Down Expand Up @@ -355,7 +374,7 @@ and the badge drops the repository rather than the row dropping the badge, and

A bead carrying `delivery_pr = "12"` has nothing in the value to build an
address out of, and a shared list can only say what the value itself carries.
The project's own entry supplies the rest:
The project's own entry supplies the rest, and is tried before the shared one:

```toml
[[projects]]
Expand All @@ -369,8 +388,10 @@ render = "⇢ #{number}"
link = "https://forge.invalid/orbital/beacon/pull/{number}"
```

This shadows `delivery_pr` for `beacon` and for nothing else, by the rule
[`[[projects.badges]]`](#projectsbadges) gives.
This is tried first on `beacon`'s beads, and on no other project's, by the rule
[`[[projects.badges]]`](#projectsbadges) gives. The `[[badges]]` entry is still
there underneath it, so a `beacon` bead that does carry an owner and repository
is read by that one as before.

### A reference stored as a full URL

Expand All @@ -388,16 +409,21 @@ link = "{}"

### What a badge says when it cannot do what you asked

A badge with no `link` is a filter. It is written to decline, so a value its
pattern does not read draws nothing and reports nothing.
`match` is how you say which values you want, so a badge whose pattern does not
read the value draws nothing and reports nothing. The next entry for that key is
tried instead. A value no entry for the key reads stays silent: you said which
shapes you wanted, and none of them was this one. Where you would rather see
every value of a key, write a last entry with a permissive pattern, and it draws
whatever the ones above it declined.

A badge with a `link` is written to point somewhere, so a value it cannot point
at is a reference the reader has lost. The row says so where it says its
anomalies:
What is worth saying is a badge that read the value and then could not keep one
of the other promises its config made. A `link` was written to point somewhere
and a `short` to survive a narrow pane, and a value that defeats either takes
that away while leaving the badge looking ordinary. So the row says so where it
says its anomalies:

| what the badge met | what the row says |
|---|---|
| a value no pattern reads | `no badge for delivery_pr: no pattern reads this value` |
| a value that left part of the `link` unfilled | `no link for delivery_pr: this value leaves part of it unfilled` |
| a link holding a control character | `no link for delivery_pr: it holds a control character` |
| a value that left part of the `short` unfilled | `no short form for delivery_pr: this value leaves part of it unfilled` |
Expand Down
46 changes: 26 additions & 20 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -661,12 +661,13 @@ impl Config {
}

/// The badges this project draws: the global list, with a project's own
/// entries standing in for every global entry that shares a key with one
/// of them.
/// entries for a key ahead of the global entries for that same key.
///
/// A shadowed key's entries stand where the global list's first entry for
/// that key stood, so overriding one badge does not reorder the row. Keys
/// only the project names follow the rest.
/// A project wins a value by being tried first rather than by replacing
/// anything, so a value its own entries do not read falls through to the
/// shared shapes. Its entries stand where the global list's first entry for
/// that key stood, so naming a key does not reorder the row. Keys only the
/// project names follow the rest.
pub fn badges_for_project(&self, project: &str) -> Vec<Badge> {
let Some(own) = self
.projects
Expand All @@ -678,20 +679,16 @@ impl Config {
return self.badges.clone();
};
let mut drawn: Vec<Badge> = Vec::new();
let mut stood_in_for: BTreeSet<&str> = BTreeSet::new();
let mut went_ahead_of: BTreeSet<&str> = BTreeSet::new();
for global in &self.badges {
match own.iter().any(|b| b.key == global.key) {
false => drawn.push(global.clone()),
true => {
if stood_in_for.insert(&global.key) {
drawn.extend(own.iter().filter(|b| b.key == global.key).cloned());
}
}
if own.iter().any(|b| b.key == global.key) && went_ahead_of.insert(&global.key) {
drawn.extend(own.iter().filter(|b| b.key == global.key).cloned());
}
drawn.push(global.clone());
}
drawn.extend(
own.iter()
.filter(|b| !stood_in_for.contains(b.key.as_str()))
.filter(|b| !went_ahead_of.contains(b.key.as_str()))
.cloned(),
);
drawn
Expand Down Expand Up @@ -1198,7 +1195,7 @@ path = "/home/user/dev/cinder"
}

#[test]
fn a_projects_badge_stands_where_the_global_one_it_shadows_stood() {
fn a_projects_badge_stands_where_the_first_global_one_for_its_key_stood() {
let cfg = Config {
badges: vec![
badge("metadata.delivery_pr", "⇢ {}"),
Expand All @@ -1217,18 +1214,23 @@ path = "/home/user/dev/cinder"
cfg.badges_for_project("beacon"),
vec![
badge("metadata.delivery_pr", "⇢ beacon/{}"),
badge("metadata.delivery_pr", "⇢ {}"),
matching("metadata.blocked_on", "human", "⏸ waiting"),
badge("metadata.epic", "▣ {}"),
]
);
}

/// The global list may name one key several times, matched on a different
/// value each time. A project overriding that key replaces the whole group
/// rather than one of its entries: shadowing half a key would leave the
/// project drawing the shared wording for every value it did not name.
/// value each time. A project naming that key is tried ahead of the whole
/// group and replaces none of it, so the project says what it wants for the
/// values it names and keeps the shared wording for the rest.
///
/// One entry ahead of two is what makes the precedence visible: the project
/// draws its own words for `human`, and `dependency` still reaches the
/// shared entry that reads it.
#[test]
fn a_projects_badge_shadows_every_global_entry_for_its_key() {
fn a_projects_badge_is_tried_ahead_of_every_global_entry_for_its_key() {
let cfg = Config {
badges: vec![
matching("metadata.blocked_on", "human", "⏸ waiting"),
Expand All @@ -1242,7 +1244,11 @@ path = "/home/user/dev/cinder"

assert_eq!(
cfg.badges_for_project("beacon"),
vec![matching("metadata.blocked_on", "human", "⏸ ask Ada")]
vec![
matching("metadata.blocked_on", "human", "⏸ ask Ada"),
matching("metadata.blocked_on", "human", "⏸ waiting"),
matching("metadata.blocked_on", "dependency", "⏸ blocked"),
]
);
}

Expand Down
111 changes: 77 additions & 34 deletions src/model/badges.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
//! what to say for a value, are both config's to state; this renders what it
//! is given and knows no more about `blocked_on` than about any other key.

use std::collections::BTreeSet;

use serde::Serialize;

use crate::config::{Badge, Colour};
Expand Down Expand Up @@ -35,15 +37,13 @@ pub struct Badged {

/// A badge that drew less than its config asked for.
///
/// A badge with no `link` is a filter: it is written to decline, so a value
/// its pattern does not match is nothing to report and reaches neither of the
/// first two. A badge with a `link` is written to point somewhere, so a value
/// it cannot point at is a reference the reader has lost.
/// Every badge here read the value and then could not keep a promise its
/// config made about the badge it drew. A pattern that does not read the value
/// is a filter declining, which is what patterns are for, so it is nowhere
/// here however many of them decline in a row.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(tag = "undrawn", rename_all = "kebab-case")]
pub enum Undrawn {
/// No badge at all: the pattern does not read this bead's value.
Badge { key: String },
/// A badge without its link: the template named a capture this value did
/// not supply, and a destination built round a part that was never there
/// points somewhere else.
Expand Down Expand Up @@ -71,26 +71,27 @@ pub struct Badges {
pub fn badges_for(bead: &Bead, badges: &[Badge]) -> Badges {
let mut drawn = Vec::new();
let mut undrawn = Vec::new();
let mut read: BTreeSet<&str> = BTreeSet::new();

for badge in badges {
let Some(value) = bead.values.get(&badge.key).map(String::as_str) else {
continue;
};
// A badge with no `link` never reports: whatever it does with this
// value, it did what its config asked.
let promised = badge.link.is_some();
// Badges on one key are a chain read in config order, so the entries
// below the one that read this value are what the reader wrote for the
// values it does not read. They are not tried at all, and neither is
// what their config promised about a value they were never given.
if read.contains(badge.key.as_str()) {
continue;
}

let Some(text) = badge.apply(value) else {
if promised {
undrawn.push(Undrawn::Badge {
key: badge.key.clone(),
});
}
continue;
};
read.insert(badge.key.as_str());

let link = badge.link_for(value);
if promised && link.is_none() {
if badge.link.is_some() && link.is_none() {
undrawn.push(Undrawn::Link {
key: badge.key.clone(),
});
Expand Down Expand Up @@ -337,11 +338,67 @@ mod tests {
}
}

// ---- several badges on one key ---------------------------------------

/// Ordering is what a list of badges on one key says: each is tried until
/// one reads the value, so the qualified form above a permissive entry
/// draws the qualified form and the entry below it stands in for nothing.
///
/// The permissive entry names a `short` its own pattern cannot fill, which
/// is something running it would have had to report. Its silence is how
/// this asserts it never ran at all.
#[test]
fn two_badges_on_one_key_that_both_read_a_value_draw_the_first_alone() {
let bead = bead_with(r#"{"delivery_pr":"orbital/atlas#30"}"#);
let permissive = Badge {
match_value: Some(matching(".*")),
render: "⇢ {}".into(),
short: Some("⇢ {repo}".into()),
link: None,
..qualified_only()
};

let got = badges_for(&bead, &[qualified_only(), permissive]);

assert_eq!(
got.drawn,
vec![Badged {
key: "metadata.delivery_pr".to_string(),
text: "⇢ #30".to_string(),
short: None,
link: Some("https://forge.invalid/orbital/atlas/pull/30".to_string()),
colour: None,
}]
);
assert_eq!(got.undrawn, Vec::new());
}

/// A value every badge on its key declined is a value nothing was written
/// to read, and that is the reader's list saying what it wanted rather than
/// anything falling short. A `link` on the badges that declined is a
/// promise about where a badge points and not about which values reach one.
#[test]
fn a_value_no_badge_on_its_key_reads_draws_nothing_and_reports_nothing() {
let bead = bead_with(r#"{"delivery_pr":"30"}"#);
let url_only = Badge {
match_value: Some(matching(
r"https://forge\.invalid/[^/]+/(?<repo>[^/]+)/pull/(?<number>[0-9]+)",
)),
..qualified_only()
};

let got = badges_for(&bead, &[qualified_only(), url_only]);

assert_eq!(got.drawn, Vec::new());
assert_eq!(got.undrawn, Vec::new());
}

// ---- what a badge meant to draw could not draw -----------------------

/// The pattern a global list writes for a `delivery_pr` reads the
/// qualified form. A tracker holding a bare number as well has beads this
/// pattern cannot read at all, and dropping them tells the reader nothing.
/// pattern cannot read at all, and a permissive entry below it is how a
/// reader asks to see them.
fn qualified_only() -> Badge {
Badge {
key: "metadata.delivery_pr".into(),
Expand All @@ -355,24 +412,10 @@ mod tests {
}
}

#[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()
}]
);
}

/// The other half of the rule, and the half the shipped waiting badge
/// rests on: a config that names no `link` is a filter, and declining is
/// what it was written to do.
/// The same silence read off the badge the shipped waiting badge is written
/// as, where the one above is read off a pair that name a `link`: a pattern
/// is how a config says which values it wants, and declining is what every
/// pattern is there to do.
#[test]
fn a_badge_configured_to_decline_stays_silent() {
let bead = bead_with(r#"{"blocked_on":"dependency"}"#);
Expand Down
Loading
Loading