use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};
use std::time::Duration;
use chrono::TimeDelta;
use ratatui::style::Color;
use regex_lite::{Captures, Regex};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
pub struct Config {
#[serde(default)]
pub projects: Vec<Project>,
#[serde(default)]
pub roots: Roots,
#[serde(default)]
pub badges: Vec<Badge>,
#[serde(default)]
pub anomalies: Anomalies,
#[serde(default)]
pub join: Join,
#[serde(default)]
pub changes: Changes,
#[serde(default)]
pub tui: Tui,
#[serde(default)]
pub theme: Theme,
#[serde(skip)]
pub scope: Scope,
#[serde(skip)]
pub named_without_git: bool,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub enum Scope {
#[default]
Everything,
Asked(Vec<String>),
Directory {
project: String,
widened: Vec<String>,
},
}
impl Scope {
pub fn reads(&self, name: &str) -> bool {
match self {
Scope::Everything => true,
Scope::Asked(named) => named.iter().any(|n| n == name),
Scope::Directory { project, widened } => {
project == name || widened.iter().any(|n| n == name)
}
}
}
}
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct Project {
pub name: String,
pub path: PathBuf,
#[serde(default)]
pub environment_command: Option<Command>,
#[serde(default)]
pub credential_command: Option<String>,
#[serde(default = "polls")]
pub poll: bool,
#[serde(default)]
pub badges: Vec<Badge>,
#[serde(skip)]
pub worktrees: Vec<PathBuf>,
}
fn polls() -> bool {
true
}
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
#[serde(untagged)]
pub enum Command {
Line(String),
Words(Vec<String>),
}
impl Command {
pub fn words(&self) -> Vec<&str> {
match self {
Command::Line(line) => line.split_whitespace().collect(),
Command::Words(words) => words.iter().map(String::as_str).collect(),
}
}
pub fn names_no_program(&self) -> bool {
self.words()
.first()
.is_none_or(|program| program.is_empty())
}
}
impl Project {
pub fn holds(&self, path: &Path) -> Option<usize> {
std::iter::once(&self.path)
.chain(&self.worktrees)
.filter(|tree| path.starts_with(tree))
.map(|tree| tree.components().count())
.max()
}
}
#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
#[serde(default)]
pub struct Roots {
pub explicit: BTreeMap<String, Vec<String>>,
}
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct Badge {
pub key: String,
#[serde(rename = "match")]
pub match_value: Option<Pattern>,
pub render: String,
pub short: Option<String>,
pub link: Option<String>,
pub colour: Option<Colour>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Colour {
Status,
Slot(Slot),
Absolute(Color),
}
#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum Slot {
StatusOpen,
StatusInProgress,
StatusBlocked,
StatusClosed,
StatusDeferred,
Identity,
Agent,
Attention,
TierStaffed,
TierOpen,
TierFinished,
Structure,
Quiet,
Page,
Plain,
Code,
Selected,
Title,
Section,
Heading,
Emphasis,
Strong,
Link,
}
impl<'de> Deserialize<'de> for Colour {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
use serde::de::IntoDeserializer;
let written = String::deserialize(deserializer)?;
if written == STATUS {
return Ok(Colour::Status);
}
let slot: Result<Slot, serde::de::value::Error> =
Slot::deserialize(written.as_str().into_deserializer());
if let Ok(slot) = slot {
return Ok(Colour::Slot(slot));
}
crate::view::palette::colour_named(&written)
.map(Colour::Absolute)
.ok_or_else(|| {
serde::de::Error::custom(format!(
"{written:?} is neither a slot of bdi's palette nor a colour"
))
})
}
}
impl Serialize for Colour {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
match self {
Colour::Status => serializer.serialize_str(STATUS),
Colour::Slot(slot) => slot.serialize(serializer),
Colour::Absolute(colour) => serializer.collect_str(colour),
}
}
}
const STATUS: &str = "status";
#[derive(Debug, Clone)]
pub struct Pattern {
source: String,
anchored: Regex,
}
impl Pattern {
pub fn new(source: &str) -> Result<Self, regex_lite::Error> {
Ok(Self {
source: source.to_string(),
anchored: Regex::new(&format!("^(?:{source})$"))?,
})
}
fn captures<'v>(&self, value: &'v str) -> Option<Captures<'v>> {
self.anchored.captures(value)
}
}
impl PartialEq for Pattern {
fn eq(&self, other: &Self) -> bool {
self.source == other.source
}
}
impl Eq for Pattern {}
impl<'de> Deserialize<'de> for Pattern {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let source = String::deserialize(deserializer)?;
Pattern::new(&source)
.map_err(|e| serde::de::Error::custom(format!("{source:?} is no pattern: {e}")))
}
}
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
#[serde(default)]
pub struct Anomalies {
pub stale_claim_days: i64,
}
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
#[serde(default)]
pub struct Join {
pub pane_key: String,
}
#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
#[serde(default)]
pub struct Changes {
pub socket: Option<PathBuf>,
}
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
#[serde(default)]
pub struct Tui {
pub refresh_seconds: u64,
pub unanswered_after_seconds: u64,
pub tail_refresh_millis: u64,
pub wheel_notch_lines: usize,
}
#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
#[serde(default)]
pub struct Theme {
pub background: Background,
}
#[derive(Debug, Clone, Copy, Default, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum Background {
#[default]
Dark,
Light,
}
impl Default for Anomalies {
fn default() -> Self {
Self {
stale_claim_days: 30,
}
}
}
impl Default for Join {
fn default() -> Self {
Self {
pane_key: "agent_pane".to_string(),
}
}
}
impl Default for Tui {
fn default() -> Self {
Self {
refresh_seconds: 30,
unanswered_after_seconds: 30,
tail_refresh_millis: 250,
wheel_notch_lines: 3,
}
}
}
impl Tui {
pub fn refresh(&self) -> Duration {
Duration::from_secs(self.refresh_seconds)
}
pub fn tail_refresh(&self) -> Duration {
Duration::from_millis(self.tail_refresh_millis)
}
pub fn unanswered_after(&self) -> TimeDelta {
i64::try_from(self.unanswered_after_seconds)
.ok()
.and_then(TimeDelta::try_seconds)
.unwrap_or(TimeDelta::MAX)
}
}
impl Config {
pub fn naming(projects: Vec<Project>) -> Self {
Config {
projects,
roots: Roots::default(),
badges: Vec::new(),
anomalies: Anomalies::default(),
join: Join::default(),
changes: Changes::default(),
tui: Tui::default(),
theme: Theme::default(),
scope: Scope::default(),
named_without_git: false,
}
}
pub fn badges_for_project(&self, project: &str) -> Vec<Badge> {
let Some(own) = self
.projects
.iter()
.find(|p| p.name == project)
.map(|p| p.badges.as_slice())
.filter(|own| !own.is_empty())
else {
return self.badges.clone();
};
let mut drawn: Vec<Badge> = Vec::new();
let mut went_ahead_of: BTreeSet<&str> = BTreeSet::new();
for global in &self.badges {
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| !went_ahead_of.contains(b.key.as_str()))
.cloned(),
);
drawn
}
pub fn projects_named_without_git(&self) -> Vec<String> {
match self.named_without_git {
true => self.read().map(|project| project.name.clone()).collect(),
false => Vec::new(),
}
}
pub fn from_toml(s: &str) -> anyhow::Result<Self> {
let table: toml::Table = toml::from_str(s)?;
if table
.get("roots")
.and_then(|roots| roots.get("metadata_keys"))
.is_some()
{
anyhow::bail!(
"[roots] metadata_keys is gone: every unfinished bead is a root, so a key \
could name nothing bd's statuses do not; remove it"
);
}
let cfg: Config = table.try_into()?;
if cfg.projects.is_empty() {
anyhow::bail!("config names no projects; bdi has nothing to read");
}
let repeated = cfg.names_borne_by_more_than_one_project();
if !repeated.is_empty() {
anyhow::bail!(
"a project's name is how bdi tells its beads from another tracker's, so \
two projects cannot answer to one; repeated: {}",
repeated.join(", ")
);
}
if let Some(project) = cfg.projects.iter().find(|p| {
p.environment_command
.as_ref()
.is_some_and(Command::names_no_program)
}) {
anyhow::bail!(
"{} names an environment_command with no program in it; bdi appends its own \
probe to what you write, so an empty one would read the project in bdi's \
environment while saying it was read in its own",
project.name
);
}
for (named, ids) in &cfg.roots.explicit {
if !cfg.is_configured(named) {
anyhow::bail!(
"[roots.explicit] gives {} to {named}, which is no project of this \
config; bdi is reading {}",
ids.join(", "),
names_of(&cfg.projects).join(", ")
);
}
}
Ok(cfg)
}
pub fn read(&self) -> impl Iterator<Item = &Project> {
self.projects.iter().filter(|p| self.reads(&p.name))
}
pub fn reads(&self, name: &str) -> bool {
self.scope.reads(name)
}
pub fn scoped_to(mut self, names: &[String]) -> anyhow::Result<Self> {
if names.is_empty() {
return Ok(self);
}
let unknown: Vec<&str> = names
.iter()
.map(String::as_str)
.filter(|named| !self.is_configured(named))
.collect();
if !unknown.is_empty() {
anyhow::bail!(
"--project names {}, which is no project of this config; bdi is \
configured for {}",
unknown.join(", "),
names_of(&self.projects).join(", ")
);
}
self.scope = Scope::Asked(names.to_vec());
Ok(self)
}
pub fn scoped_to_the_project_holding(self, path: &Path) -> Self {
self.scoped_to_the_project_holding_any_of(&[path.to_path_buf()])
}
pub fn scoped_to_the_project_holding_any_of(mut self, places: &[PathBuf]) -> Self {
let holding = places
.iter()
.flat_map(|place| {
self.projects
.iter()
.filter_map(move |p| Some((p.holds(place)?, p)))
})
.max_by_key(|(depth, _)| *depth)
.map(|(_, project)| project.name.clone());
if let Some(project) = holding {
self.scope = Scope::Directory {
project,
widened: Vec::new(),
};
}
self
}
pub fn with_roots_named_on_the_command_line(
mut self,
beads: &[String],
) -> anyhow::Result<Self> {
for named in beads {
let (project, id) = self.placed(named)?;
self.roots.explicit.entry(project).or_default().push(id);
}
Ok(self)
}
fn placed(&mut self, named: &str) -> anyhow::Result<(String, String)> {
let Some((project, id)) = named.split_once(':') else {
let reading: Vec<&Project> = self.read().collect();
return match reading.as_slice() {
[only] => Ok((only.name.clone(), named.to_string())),
several => anyhow::bail!(
"{named} names no project, and bdi is reading {}; write it as \
<project>:{named}",
several
.iter()
.map(|p| p.name.as_str())
.collect::<Vec<_>>()
.join(", ")
),
};
};
if project.is_empty() || id.is_empty() {
anyhow::bail!("{named} is not <project>:<bead-id>");
}
if !self.is_configured(project) {
anyhow::bail!(
"{named} gives {id} to {project}, which is not among the projects \
bdi is configured for: {}",
names_of(&self.projects).join(", ")
);
}
if !self.reads(project) {
match &mut self.scope {
Scope::Directory { widened, .. } => widened.push(project.to_string()),
Scope::Asked(_) | Scope::Everything => anyhow::bail!(
"{named} gives {id} to {project}, which is not among the projects \
bdi is reading: {}",
self.read()
.map(|p| p.name.as_str())
.collect::<Vec<_>>()
.join(", ")
),
}
}
Ok((project.to_string(), id.to_string()))
}
fn is_configured(&self, name: &str) -> bool {
self.projects.iter().any(|p| p.name == name)
}
fn names_borne_by_more_than_one_project(&self) -> Vec<&str> {
let mut seen = BTreeSet::new();
let mut repeated = BTreeSet::new();
for name in self.projects.iter().map(|p| p.name.as_str()) {
if !seen.insert(name) {
repeated.insert(name);
}
}
repeated.into_iter().collect()
}
}
fn names_of(projects: &[Project]) -> Vec<&str> {
projects.iter().map(|p| p.name.as_str()).collect()
}
impl Badge {
pub fn apply(&self, value: &str) -> Option<String> {
Some(self.fill(&self.render, value)?.text)
}
pub fn short_for(&self, value: &str) -> Option<String> {
let filled = self.fill(self.short.as_ref()?, value)?;
filled.whole.then_some(filled.text)
}
pub fn link_for(&self, value: &str) -> Option<String> {
let filled = self.fill(self.link.as_ref()?, value)?;
filled.whole.then_some(filled.text)
}
fn fill(&self, template: &str, value: &str) -> Option<Filled> {
let taken = match &self.match_value {
Some(pattern) => Some(pattern.captures(value)?),
None => None,
};
let mut text = String::new();
let mut whole = true;
let mut rest = template;
while let Some(close) = rest.find('}') {
let Some(open) = rest[..close].rfind('{') else {
text.push_str(&rest[..=close]);
rest = &rest[close + 1..];
continue;
};
let name = &rest[open + 1..close];
let placed = match name {
"" => Some(value),
_ => taken
.as_ref()
.and_then(|taken| taken.name(name))
.map(|capture| capture.as_str()),
};
whole &= placed.is_some();
text.push_str(&rest[..open]);
text.push_str(placed.unwrap_or(&rest[open..=close]));
rest = &rest[close + 1..];
}
text.push_str(rest);
Some(Filled { text, whole })
}
}
struct Filled {
text: String,
whole: bool,
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::Path;
const EVERY_SECTION: &str = r#"
[[projects]]
name = "atlas"
path = "/home/user/atlas"
credential_command = "secret-tool lookup tracker atlas"
[[projects]]
name = "beacon"
path = "/home/user/dev/beacon"
credential_command = "cat /home/user/dev/beacon/.beads-password"
[[projects.badges]]
key = "metadata.delivery_pr"
render = "⇢ beacon/{}"
[roots.explicit]
atlas = ["a-1", "a-9"]
beacon = ["b-1"]
[[badges]]
key = "metadata.delivery_pr"
render = "⇢ {}"
[[badges]]
key = "metadata.blocked_on"
match = "human"
render = "⏸ waiting"
[anomalies]
stale_claim_days = 7
[join]
pane_key = "herdr_pane"
[changes]
socket = "/var/folders/T/beady-eye/changes.sock"
[tui]
refresh_seconds = 5
unanswered_after_seconds = 90
tail_refresh_millis = 100
wheel_notch_lines = 1
[theme]
background = "light"
"#;
const ONE_PROJECT: &str = r#"
[[projects]]
name = "beacon"
path = "/home/user/dev/beacon"
"#;
const ONE_CREDENTIALLED_ONE_AMBIENT: &str = r#"
[[projects]]
name = "atlas"
path = "/home/user/atlas"
credential_command = "secret-tool lookup tracker atlas"
[[projects]]
name = "beacon"
path = "/home/user/dev/beacon"
"#;
const TWO_AMBIENT: &str = r#"
[[projects]]
name = "atlas"
path = "/home/user/atlas"
credential_command = "secret-tool lookup tracker atlas"
[[projects]]
name = "beacon"
path = "/home/user/dev/beacon"
[[projects]]
name = "cinder"
path = "/home/user/dev/cinder"
"#;
#[test]
fn parses_every_section() {
let cfg = Config::from_toml(EVERY_SECTION).expect("parses");
assert_eq!(
cfg.projects,
vec![
Project {
name: "atlas".to_string(),
path: PathBuf::from("/home/user/atlas"),
environment_command: None,
credential_command: Some("secret-tool lookup tracker atlas".to_string()),
poll: true,
badges: Vec::new(),
worktrees: Vec::new(),
},
Project {
name: "beacon".to_string(),
path: PathBuf::from("/home/user/dev/beacon"),
environment_command: None,
credential_command: Some(
"cat /home/user/dev/beacon/.beads-password".to_string()
),
poll: true,
badges: vec![Badge {
key: "metadata.delivery_pr".to_string(),
match_value: None,
render: "⇢ beacon/{}".to_string(),
link: None,
short: None,
colour: None,
}],
worktrees: Vec::new(),
},
]
);
assert_eq!(
cfg.roots,
Roots {
explicit: BTreeMap::from([
(
"atlas".to_string(),
vec!["a-1".to_string(), "a-9".to_string()]
),
("beacon".to_string(), vec!["b-1".to_string()]),
]),
}
);
assert_eq!(
cfg.badges,
vec![
Badge {
key: "metadata.delivery_pr".to_string(),
match_value: None,
render: "⇢ {}".to_string(),
link: None,
short: None,
colour: None,
},
Badge {
key: "metadata.blocked_on".to_string(),
match_value: Some(pattern("human")),
render: "⏸ waiting".to_string(),
link: None,
short: None,
colour: None,
},
]
);
assert_eq!(cfg.anomalies.stale_claim_days, 7);
assert_eq!(cfg.join.pane_key, "herdr_pane");
assert_eq!(
cfg.changes.socket,
Some(PathBuf::from("/var/folders/T/beady-eye/changes.sock"))
);
assert_eq!(cfg.tui.refresh_seconds, 5);
assert_eq!(cfg.tui.unanswered_after_seconds, 90);
assert_eq!(cfg.tui.tail_refresh_millis, 100);
assert_eq!(cfg.tui.wheel_notch_lines, 1);
assert_eq!(cfg.theme.background, Background::Light);
}
fn pattern(source: &str) -> Pattern {
Pattern::new(source).expect("the pattern compiles")
}
fn badge(key: &str, render: &str) -> Badge {
Badge {
key: key.to_string(),
match_value: None,
render: render.to_string(),
link: None,
short: None,
colour: None,
}
}
fn matching(key: &str, value: &str, render: &str) -> Badge {
Badge {
match_value: Some(pattern(value)),
..badge(key, render)
}
}
fn drawing(name: &str, badges: Vec<Badge>) -> Project {
Project {
name: name.to_string(),
path: PathBuf::from("/home/user").join(name),
environment_command: None,
credential_command: None,
poll: true,
badges,
worktrees: Vec::new(),
}
}
#[test]
fn a_projects_badge_stands_where_the_first_global_one_for_its_key_stood() {
let cfg = Config {
badges: vec![
badge("metadata.delivery_pr", "⇢ {}"),
matching("metadata.blocked_on", "human", "⏸ waiting"),
],
..Config::naming(vec![drawing(
"beacon",
vec![
badge("metadata.delivery_pr", "⇢ beacon/{}"),
badge("metadata.epic", "▣ {}"),
],
)])
};
assert_eq!(
cfg.badges_for_project("beacon"),
vec![
badge("metadata.delivery_pr", "⇢ beacon/{}"),
badge("metadata.delivery_pr", "⇢ {}"),
matching("metadata.blocked_on", "human", "⏸ waiting"),
badge("metadata.epic", "▣ {}"),
]
);
}
#[test]
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"),
matching("metadata.blocked_on", "dependency", "⏸ blocked"),
],
..Config::naming(vec![drawing(
"beacon",
vec![matching("metadata.blocked_on", "human", "⏸ ask Ada")],
)])
};
assert_eq!(
cfg.badges_for_project("beacon"),
vec![
matching("metadata.blocked_on", "human", "⏸ ask Ada"),
matching("metadata.blocked_on", "human", "⏸ waiting"),
matching("metadata.blocked_on", "dependency", "⏸ blocked"),
]
);
}
#[test]
fn a_project_naming_no_badges_draws_the_global_list() {
let cfg = Config {
badges: vec![badge("metadata.delivery_pr", "⇢ {}")],
..Config::naming(vec![drawing("atlas", Vec::new())])
};
assert_eq!(
cfg.badges_for_project("atlas"),
vec![badge("metadata.delivery_pr", "⇢ {}")]
);
}
#[test]
fn a_project_key_written_after_its_badges_is_refused_by_the_badge() {
let misplaced = r#"
[[projects]]
name = "beacon"
[[projects.badges]]
key = "metadata.delivery_pr"
render = "⇢ beacon/{}"
path = "/home/user/dev/beacon"
"#;
let refused = Config::from_toml(misplaced).expect_err("a badge has no path");
let said = refused.to_string();
assert_eq!(
said.trim_end(),
"unknown field `path`, expected one of `key`, `match`, `render`, `short`, `link`, \
`colour`\n\
in `projects.badges`"
);
assert!(!said.contains("missing field"), "{said}");
}
#[test]
fn a_config_of_one_project_gets_every_default() {
let cfg = Config::from_toml(ONE_PROJECT).expect("parses");
assert_eq!(cfg.projects[0].credential_command, None);
assert_eq!(cfg.roots, Roots::default());
assert!(cfg.badges.is_empty());
assert_eq!(cfg.anomalies.stale_claim_days, 30);
assert_eq!(cfg.join.pane_key, "agent_pane");
assert_eq!(cfg.changes.socket, None);
assert_eq!(cfg.tui.refresh_seconds, 30);
assert_eq!(cfg.tui.unanswered_after_seconds, 30);
assert_eq!(cfg.tui.tail_refresh_millis, 250);
assert_eq!(
cfg.tui.wheel_notch_lines, 3,
"three lines a notch is the convention a reader who says nothing gets"
);
assert_eq!(cfg.theme.background, Background::Dark);
}
#[test]
fn an_undeclared_background_is_the_fallback() {
assert_eq!(Theme::default().background, Background::Dark);
assert_eq!(
Config::from_toml(ONE_PROJECT)
.expect("parses")
.theme
.background,
Background::Dark
);
}
#[test]
fn a_background_that_is_not_one_of_the_two_is_refused() {
let mistyped = format!("{ONE_PROJECT}\n[theme]\nbackground = \"Light\"\n");
let refused =
Config::from_toml(&mistyped).expect_err("a background bdi has no palette for");
assert!(refused.to_string().contains("background"), "{refused}");
}
#[test]
fn the_refresh_interval_is_read_as_a_duration() {
let cfg = Config::from_toml(EVERY_SECTION).expect("parses");
assert_eq!(cfg.tui.refresh(), Duration::from_secs(5));
assert_eq!(Tui::default().refresh(), Duration::from_secs(30));
}
#[test]
fn the_tails_interval_is_read_as_a_duration() {
let cfg = Config::from_toml(EVERY_SECTION).expect("parses");
assert_eq!(cfg.tui.tail_refresh(), Duration::from_millis(100));
assert_eq!(Tui::default().tail_refresh(), Duration::from_millis(250));
}
#[test]
fn how_long_a_collection_may_go_unanswered_is_read_as_a_duration() {
let cfg = Config::from_toml(EVERY_SECTION).expect("parses");
assert_eq!(cfg.tui.unanswered_after(), TimeDelta::seconds(90));
assert_eq!(Tui::default().unanswered_after(), TimeDelta::seconds(30));
}
#[test]
fn a_patience_longer_than_an_interval_can_hold_is_the_longest_there_is() {
let longest: u64 = TimeDelta::MAX
.num_seconds()
.try_into()
.expect("the longest interval there is runs forwards");
assert_eq!(
patient_for(longest).unanswered_after(),
TimeDelta::seconds(TimeDelta::MAX.num_seconds())
);
assert_eq!(patient_for(longest + 1).unanswered_after(), TimeDelta::MAX);
}
#[test]
fn a_patience_too_large_to_count_in_signed_seconds_is_the_longest_there_is() {
assert_eq!(patient_for(u64::MAX).unanswered_after(), TimeDelta::MAX);
}
#[test]
fn a_config_naming_a_patience_no_interval_can_hold_is_read_as_the_longest_there_is() {
let cfg = Config::from_toml(&format!(
"{ONE_PROJECT}[tui]\nunanswered_after_seconds = {}\n",
i64::MAX
))
.expect("parses");
assert_eq!(cfg.tui.unanswered_after(), TimeDelta::MAX);
}
fn patient_for(seconds: u64) -> Tui {
Tui {
unanswered_after_seconds: seconds,
..Tui::default()
}
}
#[test]
fn a_project_needs_only_a_path_however_many_the_config_names() {
for spelling in [ONE_PROJECT, TWO_AMBIENT, ONE_CREDENTIALLED_ONE_AMBIENT] {
let cfg = Config::from_toml(spelling).expect("a path is the whole of an entry");
assert!(cfg.projects.iter().any(|p| p.credential_command.is_none()));
}
}
#[test]
fn a_project_may_still_name_a_credential_command() {
let cfg = Config::from_toml(ONE_CREDENTIALLED_ONE_AMBIENT).expect("parses");
assert_eq!(
cfg.projects[0].credential_command.as_deref(),
Some("secret-tool lookup tracker atlas")
);
}
#[test]
fn a_project_saying_nothing_about_its_environment_is_read_in_bdis_own() {
let cfg = Config::from_toml(ONE_PROJECT).expect("parses");
assert_eq!(cfg.projects[0].environment_command, None);
}
const ONE_ENTERED_WITH_DIRENV: &str = r#"
[[projects]]
name = "beacon"
path = "/home/user/dev/beacon"
environment_command = "direnv exec ."
"#;
#[test]
fn a_project_may_name_the_command_that_gives_its_environment() {
let cfg = Config::from_toml(ONE_ENTERED_WITH_DIRENV).expect("parses");
assert_eq!(
cfg.projects[0].environment_command,
Some(Command::Line("direnv exec .".to_string()))
);
}
const ENTERED_WITH_A_SPACE_IN_AN_ARGUMENT: &str = r#"
[[projects]]
name = "beacon"
path = "/home/user/dev/beacon"
environment_command = ["nix", "develop", ".#dev shell", "-c"]
"#;
#[test]
fn an_argument_holding_a_space_is_written_as_a_list() {
let cfg = Config::from_toml(ENTERED_WITH_A_SPACE_IN_AN_ARGUMENT).expect("parses");
assert_eq!(
cfg.projects[0]
.environment_command
.as_ref()
.expect("the project named one")
.words(),
vec!["nix", "develop", ".#dev shell", "-c"],
);
}
#[test]
fn a_line_and_a_list_of_its_words_name_the_same_command() {
assert_eq!(
Command::Line("direnv exec .".to_string()).words(),
Command::Words(vec!["direnv".into(), "exec".into(), ".".into()]).words(),
);
}
const ENTERED_SOME_OTHER_WAY: &str = r#"
[[projects]]
name = "beacon"
path = "/home/user/dev/beacon"
environment_command = "nix develop -c"
"#;
#[test]
fn a_mechanism_bdi_has_never_heard_of_is_named_the_same_way() {
let cfg = Config::from_toml(ENTERED_SOME_OTHER_WAY).expect("parses");
assert_eq!(
cfg.projects[0].environment_command,
Some(Command::Line("nix develop -c".to_string()))
);
}
#[test]
fn an_environment_command_with_no_program_in_it_is_refused() {
for empty in [r#""""#, r#"" ""#, "[]", r#"[""]"#, r#"["", "exec"]"#] {
let err = Config::from_toml(&format!(
r#"
[[projects]]
name = "beacon"
path = "/home/user/dev/beacon"
environment_command = {empty}
"#
))
.unwrap_err()
.to_string();
assert!(err.contains("beacon"), "for {empty}, got: {err}");
assert!(
err.contains("environment_command"),
"for {empty}, got: {err}"
);
}
}
const ENTERED_THE_OLD_WAY: &str = r#"
[[projects]]
name = "beacon"
path = "/home/user/dev/beacon"
environment = "direnv"
"#;
#[test]
fn a_config_still_naming_the_key_this_replaced_is_refused() {
let err = Config::from_toml(ENTERED_THE_OLD_WAY)
.unwrap_err()
.to_string();
assert!(err.contains("environment"), "got: {err}");
}
const TWO_PROJECTS: &str = r#"
[[projects]]
name = "atlas"
path = "/home/user/atlas"
credential_command = "secret-tool lookup tracker atlas"
[[projects]]
name = "beacon"
path = "/home/user/dev/beacon"
credential_command = "cat /home/user/dev/beacon/.beads-password"
"#;
const ROOT_IN_NO_CONFIGURED_PROJECT: &str = r#"
[[projects]]
name = "atlas"
path = "/home/user/atlas"
credential_command = "secret-tool lookup tracker atlas"
[[projects]]
name = "beacon"
path = "/home/user/dev/beacon"
credential_command = "cat /home/user/dev/beacon/.beads-password"
[roots.explicit]
cinder = ["c-1"]
"#;
#[test]
fn an_explicit_root_under_a_project_the_config_does_not_name_is_rejected() {
let err = Config::from_toml(ROOT_IN_NO_CONFIGURED_PROJECT)
.unwrap_err()
.to_string();
assert!(err.contains("cinder"), "got: {err}");
assert!(err.contains("atlas"), "got: {err}");
assert!(err.contains("beacon"), "got: {err}");
}
const ROOT_IN_A_SECOND_PROJECT: &str = r#"
[[projects]]
name = "atlas"
path = "/home/user/atlas"
credential_command = "secret-tool lookup tracker atlas"
[[projects]]
name = "beacon"
path = "/home/user/dev/beacon"
credential_command = "cat /home/user/dev/beacon/.beads-password"
[roots.explicit]
beacon = ["b-7"]
"#;
const ONE_NAME_ON_TWO_PROJECTS: &str = r#"
[[projects]]
name = "atlas"
path = "/home/user/atlas"
credential_command = "secret-tool lookup tracker atlas"
[[projects]]
name = "atlas"
path = "/home/user/dev/atlas-fork"
credential_command = "cat /home/user/dev/atlas-fork/.beads-password"
"#;
#[test]
fn two_projects_of_one_name_are_rejected() {
let err = Config::from_toml(ONE_NAME_ON_TWO_PROJECTS)
.unwrap_err()
.to_string();
assert!(err.contains("atlas"), "got: {err}");
}
const ONE_NAME_ON_TWO_AMBIENT_PROJECTS: &str = r#"
[[projects]]
name = "atlas"
path = "/home/user/atlas"
[[projects]]
name = "atlas"
path = "/home/user/dev/atlas-fork"
"#;
#[test]
fn a_repeated_name_is_reported_before_a_missing_credential() {
let err = Config::from_toml(ONE_NAME_ON_TWO_AMBIENT_PROJECTS)
.unwrap_err()
.to_string();
assert!(err.contains("atlas"), "got: {err}");
assert!(!err.contains("credential_command"), "got: {err}");
}
fn two_projects() -> Config {
Config::from_toml(TWO_PROJECTS).expect("the config parses")
}
#[test]
fn a_qualified_root_from_the_command_line_goes_to_the_project_it_names() {
let cfg = two_projects()
.with_roots_named_on_the_command_line(&["beacon:b-7".to_string()])
.expect("beacon is configured");
assert_eq!(
cfg.roots.explicit,
BTreeMap::from([("beacon".to_string(), vec!["b-7".to_string()])])
);
}
#[test]
fn a_root_from_the_command_line_joins_those_the_config_names() {
let cfg = Config::from_toml(EVERY_SECTION)
.expect("the config parses")
.with_roots_named_on_the_command_line(&["atlas:a-3".to_string()])
.expect("atlas is configured");
assert_eq!(
cfg.roots.explicit["atlas"],
["a-1", "a-9", "a-3"],
"the command line appends rather than replacing"
);
}
#[test]
fn a_bare_root_belongs_to_the_only_project_there_is() {
let cfg = Config::from_toml(ONE_PROJECT)
.expect("the config parses")
.with_roots_named_on_the_command_line(&["b-7".to_string()])
.expect("there is only one project it can mean");
assert_eq!(
cfg.roots.explicit,
BTreeMap::from([("beacon".to_string(), vec!["b-7".to_string()])])
);
}
#[test]
fn a_bare_root_with_several_projects_configured_is_rejected() {
let err = two_projects()
.with_roots_named_on_the_command_line(&["b-7".to_string()])
.unwrap_err()
.to_string();
assert!(err.contains("b-7"), "got: {err}");
assert!(err.contains("atlas"), "got: {err}");
assert!(err.contains("beacon"), "got: {err}");
}
#[test]
fn a_root_from_the_command_line_naming_no_configured_project_is_rejected() {
let err = two_projects()
.with_roots_named_on_the_command_line(&["cinder:c-1".to_string()])
.unwrap_err()
.to_string();
assert!(err.contains("cinder"), "got: {err}");
assert!(err.contains("beacon"), "got: {err}");
}
#[test]
fn a_root_that_is_all_colon_and_no_bead_is_rejected() {
for named in ["atlas:", ":a-1", ":"] {
let err = two_projects()
.with_roots_named_on_the_command_line(&[named.to_string()])
.unwrap_err()
.to_string();
assert!(err.contains(named), "got: {err}");
}
}
fn read_by(cfg: &Config) -> Vec<&str> {
cfg.read().map(|p| p.name.as_str()).collect()
}
#[test]
fn a_scope_reads_only_the_projects_it_names() {
let cfg = two_projects()
.scoped_to(&["beacon".to_string()])
.expect("beacon is configured");
assert_eq!(read_by(&cfg), ["beacon"]);
}
#[test]
fn a_scope_leaves_the_config_naming_every_project() {
let cfg = two_projects()
.scoped_to(&["beacon".to_string()])
.expect("beacon is configured");
assert_eq!(names_of(&cfg.projects), ["atlas", "beacon"]);
assert!(cfg.reads("beacon"));
assert!(!cfg.reads("atlas"));
}
#[test]
fn a_scope_leaves_the_projects_it_keeps_in_the_order_the_config_names() {
let cfg = two_projects()
.scoped_to(&["beacon".to_string(), "atlas".to_string()])
.expect("both are configured");
assert_eq!(read_by(&cfg), ["atlas", "beacon"]);
}
#[test]
fn naming_no_project_leaves_every_project() {
let cfg = two_projects()
.scoped_to(&[])
.expect("a scope of nothing scopes nothing");
assert_eq!(read_by(&cfg), ["atlas", "beacon"]);
assert_eq!(cfg.scope, Scope::Everything);
}
#[test]
fn the_project_holding_the_directory_is_the_one_read() {
let cfg =
two_projects().scoped_to_the_project_holding(Path::new("/home/user/dev/beacon/src"));
assert_eq!(read_by(&cfg), ["beacon"]);
assert_eq!(
cfg.scope,
Scope::Directory {
project: "beacon".to_string(),
widened: Vec::new(),
}
);
assert_eq!(
names_of(&cfg.projects),
["atlas", "beacon"],
"the projects the directory left out stay known"
);
}
#[test]
fn the_deepest_project_holding_the_directory_is_the_one_read() {
let cfg = Config::from_toml(
r#"
[[projects]]
name = "outer"
path = "/home/user/dev"
[[projects]]
name = "inner"
path = "/home/user/dev/inner"
"#,
)
.expect("the config parses")
.scoped_to_the_project_holding(Path::new("/home/user/dev/inner/src"));
assert_eq!(read_by(&cfg), ["inner"]);
}
#[test]
fn a_directory_no_project_holds_leaves_every_project_read() {
let cfg = two_projects().scoped_to_the_project_holding(Path::new("/home/user/elsewhere"));
assert_eq!(read_by(&cfg), ["atlas", "beacon"]);
assert_eq!(cfg.scope, Scope::Everything);
}
#[test]
fn a_root_under_a_project_the_directory_left_out_widens_the_read_set() {
let cfg = two_projects()
.scoped_to_the_project_holding(Path::new("/home/user/dev/beacon"))
.with_roots_named_on_the_command_line(&["atlas:a-1".to_string()])
.expect("a root elsewhere widens a scope the directory chose");
assert_eq!(read_by(&cfg), ["atlas", "beacon"]);
assert_eq!(
cfg.roots.explicit,
BTreeMap::from([("atlas".to_string(), vec!["a-1".to_string()])])
);
assert_eq!(
cfg.scope,
Scope::Directory {
project: "beacon".to_string(),
widened: vec!["atlas".to_string()],
}
);
}
#[test]
fn a_bare_root_belongs_to_the_project_the_directory_chose() {
let cfg = two_projects()
.scoped_to_the_project_holding(Path::new("/home/user/dev/beacon"))
.with_roots_named_on_the_command_line(&["b-7".to_string()])
.expect("the directory leaves only beacon");
assert_eq!(
cfg.roots.explicit,
BTreeMap::from([("beacon".to_string(), vec!["b-7".to_string()])])
);
}
#[test]
fn a_scope_naming_no_configured_project_is_rejected() {
let err = two_projects()
.scoped_to(&["cinder".to_string()])
.unwrap_err()
.to_string();
assert!(err.contains("cinder"), "got: {err}");
assert!(err.contains("atlas"), "got: {err}");
assert!(err.contains("beacon"), "got: {err}");
}
#[test]
fn a_configured_root_under_a_project_the_scope_left_out_is_kept_and_unread() {
let cfg = Config::from_toml(ROOT_IN_A_SECOND_PROJECT)
.expect("the config parses")
.scoped_to(&["atlas".to_string()])
.expect("a config root elsewhere is not a contradiction");
assert_eq!(read_by(&cfg), ["atlas"]);
assert_eq!(
cfg.roots.explicit["beacon"],
["b-7"],
"nothing reads it, so nothing has to take it out"
);
}
#[test]
fn a_root_naming_a_project_the_scope_left_out_is_rejected() {
let err = two_projects()
.scoped_to(&["atlas".to_string()])
.expect("atlas is configured")
.with_roots_named_on_the_command_line(&["beacon:b-7".to_string()])
.unwrap_err()
.to_string();
assert!(err.contains("beacon"), "got: {err}");
assert!(err.contains("atlas"), "got: {err}");
}
#[test]
fn a_bare_root_belongs_to_the_only_project_a_scope_leaves() {
let cfg = two_projects()
.scoped_to(&["beacon".to_string()])
.expect("beacon is configured")
.with_roots_named_on_the_command_line(&["b-7".to_string()])
.expect("the scope leaves only beacon");
assert_eq!(
cfg.roots.explicit,
BTreeMap::from([("beacon".to_string(), vec!["b-7".to_string()])])
);
}
#[test]
fn config_without_projects_is_rejected() {
let err = Config::from_toml("[roots]\n").unwrap_err();
assert!(err.to_string().contains("no projects"), "got: {err}");
}
#[test]
fn a_config_naming_the_retired_metadata_keys_is_told_the_field_is_gone() {
let err = Config::from_toml(
r#"
[[projects]]
name = "atlas"
path = "/home/user/atlas"
[roots]
metadata_keys = ["working_topic"]
"#,
)
.unwrap_err();
assert!(err.to_string().contains("metadata_keys"), "got: {err}");
assert!(err.to_string().contains("gone"), "got: {err}");
}
#[test]
fn badge_without_match_renders_any_value() {
let b = Badge {
key: "metadata.delivery_pr".to_string(),
match_value: None,
render: "⇢ {}".to_string(),
link: None,
short: None,
colour: None,
};
assert_eq!(b.apply("owner/repo#7"), Some("⇢ owner/repo#7".to_string()));
}
#[test]
fn badge_with_match_is_selective() {
let b = Badge {
key: "metadata.blocked_on".to_string(),
match_value: Some(pattern("human")),
render: "⏸ waiting".to_string(),
link: None,
short: None,
colour: None,
};
assert_eq!(b.apply("human"), Some("⏸ waiting".to_string()));
assert_eq!(b.apply("dependency"), None);
}
#[test]
fn a_match_written_as_a_literal_draws_on_that_value_and_no_other() {
let values = ["human", "dependency", "pr", "a", "owner/repo#7", "⏸"];
let anything_near = |v: &str| {
[
v.to_string(),
format!("in{v}"),
format!("{v}e"),
format!("in{v}e"),
format!("{v} {v}"),
format!(" {v}"),
format!("{v}\n"),
v.to_uppercase(),
String::new(),
]
};
for value in values {
let badge = Badge {
key: "metadata.blocked_on".to_string(),
match_value: Some(pattern(value)),
render: "drawn".to_string(),
link: None,
short: None,
colour: None,
};
for candidate in values.iter().flat_map(|v| anything_near(v)) {
assert_eq!(
badge.apply(&candidate).is_some(),
candidate == value,
"{value:?} against {candidate:?}"
);
}
}
}
#[test]
fn render_substitutes_a_capture_by_name_and_braces_by_the_whole_value() {
let b = Badge {
key: "metadata.delivery_pr".to_string(),
match_value: Some(pattern(r"[^/]+/(?<repo>[^#]+)#(?<number>[0-9]+)")),
render: "⇢ {repo} #{number} of {}".to_string(),
link: None,
short: None,
colour: None,
};
assert_eq!(
b.apply("owner/atlas#7"),
Some("⇢ atlas #7 of owner/atlas#7".to_string())
);
assert_eq!(b.apply("owner/atlas"), None);
}
#[test]
fn braces_written_around_the_braces_are_drawn_around_the_value() {
let b = Badge {
key: "metadata.delivery_pr".to_string(),
match_value: None,
render: "{{}}".to_string(),
link: None,
short: None,
colour: None,
};
assert_eq!(b.apply("owner/repo#7"), Some("{owner/repo#7}".to_string()));
}
#[test]
fn a_value_spelled_like_a_placeholder_is_placed_and_not_read() {
let b = Badge {
key: "metadata.working_topic".to_string(),
match_value: Some(pattern(r"(?<channel>[^/]+)/(?<topic>.+)")),
render: "{channel} · {topic}".to_string(),
link: None,
short: None,
colour: None,
};
assert_eq!(
b.apply("{topic}/atlas"),
Some("{topic} · atlas".to_string())
);
}
#[test]
fn a_link_is_built_from_the_captures_render_reads() {
let b = Badge {
key: "metadata.delivery_pr".to_string(),
match_value: Some(pattern(r"(?<owner>[^/]+)/(?<repo>[^#]+)#(?<number>[0-9]+)")),
render: "⇢ #{number}".to_string(),
link: Some("https://forge.invalid/{owner}/{repo}/pull/{number}".to_string()),
short: None,
colour: None,
};
assert_eq!(b.apply("orbital/atlas#7"), Some("⇢ #7".to_string()));
assert_eq!(
b.link_for("orbital/atlas#7"),
Some("https://forge.invalid/orbital/atlas/pull/7".to_string())
);
}
#[test]
fn a_link_missing_one_of_its_captures_is_no_link_at_all() {
let b = Badge {
key: "metadata.delivery_pr".to_string(),
match_value: Some(pattern(
r"(?:(?<owner>[^/]+)/(?<repo>[^#]+))?#?(?<number>[0-9]+)",
)),
render: "⇢ #{number}".to_string(),
link: Some("https://forge.invalid/{owner}/{repo}/pull/{number}".to_string()),
short: None,
colour: None,
};
assert_eq!(b.apply("12"), Some("⇢ #12".to_string()));
assert_eq!(b.link_for("12"), None);
assert_eq!(
b.link_for("orbital/atlas#12"),
Some("https://forge.invalid/orbital/atlas/pull/12".to_string())
);
}
#[test]
fn a_link_naming_a_capture_the_pattern_never_had_is_no_link() {
let b = Badge {
key: "metadata.delivery_pr".to_string(),
match_value: Some(pattern(r"(?<number>[0-9]+)")),
render: "⇢ #{number}".to_string(),
link: Some("https://forge.invalid/{repo}/pull/{number}".to_string()),
short: None,
colour: None,
};
assert_eq!(b.link_for("12"), None);
}
#[test]
fn a_badge_that_does_not_apply_points_nowhere() {
let b = Badge {
key: "metadata.blocked_on".to_string(),
match_value: Some(pattern("human")),
render: "⏸ waiting".to_string(),
link: Some("https://forge.invalid/waiting".to_string()),
short: None,
colour: None,
};
assert_eq!(
b.link_for("human"),
Some("https://forge.invalid/waiting".to_string())
);
assert_eq!(b.link_for("dependency"), None);
}
#[test]
fn a_badge_whose_config_names_no_link_points_nowhere() {
let b = Badge {
key: "metadata.delivery_pr".to_string(),
match_value: None,
render: "⇢ {}".to_string(),
link: None,
short: None,
colour: None,
};
assert_eq!(b.link_for("orbital/atlas#7"), None);
}
#[test]
fn a_short_form_is_built_from_the_captures_render_reads() {
let b = Badge {
key: "metadata.delivery_pr".to_string(),
match_value: Some(pattern(r"(?<owner>[^/]+)/(?<repo>[^#]+)#(?<number>[0-9]+)")),
render: "⇢ {repo} #{number}".to_string(),
short: Some("⇢ #{number}".to_string()),
link: None,
colour: None,
};
assert_eq!(b.apply("orbital/atlas#7"), Some("⇢ atlas #7".to_string()));
assert_eq!(b.short_for("orbital/atlas#7"), Some("⇢ #7".to_string()));
}
#[test]
fn a_short_form_missing_one_of_its_captures_is_no_short_form_at_all() {
let b = Badge {
key: "metadata.delivery_pr".to_string(),
match_value: Some(pattern(
r"(?:(?<owner>[^/]+)/)?(?<repo>[^#]+)#(?<number>[0-9]+)",
)),
render: "⇢ {repo} #{number}".to_string(),
short: Some("⇢ {owner} #{number}".to_string()),
link: None,
colour: None,
};
assert_eq!(b.apply("atlas#12"), Some("⇢ atlas #12".to_string()));
assert_eq!(b.short_for("atlas#12"), None);
assert_eq!(
b.short_for("orbital/atlas#12"),
Some("⇢ orbital #12".to_string())
);
}
#[test]
fn a_badge_whose_config_names_no_short_form_has_none() {
let b = Badge {
key: "metadata.delivery_pr".to_string(),
match_value: None,
render: "⇢ {}".to_string(),
short: None,
link: None,
colour: None,
};
assert_eq!(b.short_for("orbital/atlas#7"), None);
}
#[test]
fn a_badge_that_does_not_apply_has_no_short_form_either() {
let b = Badge {
key: "metadata.blocked_on".to_string(),
match_value: Some(pattern("human")),
render: "⏸ waiting".to_string(),
short: Some("⏸".to_string()),
link: None,
colour: None,
};
assert_eq!(b.short_for("human"), Some("⏸".to_string()));
assert_eq!(b.short_for("dependency"), None);
}
#[test]
fn a_badges_short_form_is_read_out_of_the_config() {
let cfg = Config::from_toml(&format!(
r#"{ONE_PROJECT}
[[badges]]
key = "metadata.delivery_pr"
match = "(?<owner>[^/]+)/(?<repo>[^#]+)#(?<number>[0-9]+)"
render = "⇢ {{repo}} #{{number}}"
short = "⇢ #{{number}}"
"#
))
.expect("the config reads");
assert_eq!(
cfg.badges[0].short_for("orbital/atlas#7"),
Some("⇢ #7".to_string())
);
}
#[test]
fn a_badges_link_is_read_out_of_the_config() {
let cfg = Config::from_toml(&format!(
r#"{ONE_PROJECT}
[[badges]]
key = "metadata.delivery_pr"
match = "(?<owner>[^/]+)/(?<repo>[^#]+)#(?<number>[0-9]+)"
render = "⇢ #{{number}}"
link = "https://forge.invalid/{{owner}}/{{repo}}/pull/{{number}}"
"#
))
.expect("the config reads");
assert_eq!(
cfg.badges[0].link_for("orbital/atlas#7"),
Some("https://forge.invalid/orbital/atlas/pull/7".to_string())
);
}
#[test]
fn a_match_that_does_not_parse_refuses_the_config() {
let err = Config::from_toml(&format!(
r#"{ONE_PROJECT}
[[badges]]
key = "metadata.blocked_on"
match = "(unclosed"
render = "⏸ waiting"
"#
))
.unwrap_err();
assert!(err.to_string().contains("(unclosed"), "got: {err}");
}
#[test]
fn a_badge_may_name_the_colour_the_row_draws_its_status_in() {
let cfg = Config::from_toml(&format!(
r#"{ONE_PROJECT}
[[badges]]
key = "metadata.jira"
render = "{{}}"
colour = "status"
"#
))
.expect("the config reads");
assert_eq!(cfg.badges[0].colour, Some(Colour::Status));
}
#[test]
fn a_badge_may_name_any_slot_of_the_palette() {
for (written, slot) in [
("agent", Slot::Agent),
("attention", Slot::Attention),
("identity", Slot::Identity),
("status_blocked", Slot::StatusBlocked),
("tier_finished", Slot::TierFinished),
("quiet", Slot::Quiet),
("code", Slot::Code),
("heading", Slot::Heading),
("link", Slot::Link),
] {
let cfg = Config::from_toml(&format!(
r#"{ONE_PROJECT}
[[badges]]
key = "metadata.jira"
render = "{{}}"
colour = "{written}"
"#
))
.unwrap_or_else(|err| panic!("{written:?} is a slot of the palette: {err}"));
assert_eq!(cfg.badges[0].colour, Some(Colour::Slot(slot)));
}
}
#[test]
fn a_badge_may_name_an_absolute_colour() {
for (written, colour) in [
("#c71585", Color::Rgb(199, 21, 133)),
("red", Color::Red),
("light-blue", Color::LightBlue),
("12", Color::Indexed(12)),
] {
let cfg = Config::from_toml(&format!(
r#"{ONE_PROJECT}
[[badges]]
key = "metadata.jira"
render = "{{}}"
colour = "{written}"
"#
))
.unwrap_or_else(|err| panic!("{written:?} is a colour: {err}"));
assert_eq!(cfg.badges[0].colour, Some(Colour::Absolute(colour)));
}
}
#[test]
fn a_colour_is_written_back_as_a_name_that_reads_again() {
for colour in [
Colour::Status,
Colour::Slot(Slot::TierStaffed),
Colour::Absolute(Color::Rgb(199, 21, 133)),
Colour::Absolute(Color::Indexed(12)),
Colour::Absolute(Color::Red),
] {
let written = serde_json::to_string(&colour).expect("a colour serialises");
assert_eq!(
serde_json::from_str::<Colour>(&written).expect("and reads again"),
colour,
"went out as {written}"
);
}
}
#[test]
fn a_colour_the_palette_does_not_have_refuses_the_config() {
let err = Config::from_toml(&format!(
r#"{ONE_PROJECT}
[[badges]]
key = "metadata.jira"
render = "{{}}"
colour = "chartreuse"
"#
))
.unwrap_err();
assert!(err.to_string().contains("chartreuse"), "got: {err}");
}
#[test]
fn a_config_cannot_write_the_worktrees_a_project_occupies() {
let err = Config::from_toml(
r#"
[[projects]]
name = "beacon"
path = "/home/user/dev/beacon"
worktrees = ["/home/user/anywhere-at-all"]
"#,
)
.unwrap_err()
.to_string();
assert!(err.contains("worktrees"), "got: {err}");
}
}