use std::collections::BTreeSet;
use anyhow::Context;
pub(crate) const TAGGABLE: &[&str] = &[
"SL", "ADR", "POL", "STD", "RFC", "ISS", "IMP", "CHR", "RSK", "IDE", "ASM", "CM", "DEC", "QUE",
"CON", "EVD", "HYP", "PRD", "SPEC", "REQ", "REC", "REV", "RV",
];
pub(crate) fn fold_filter_tag(raw: &str) -> String {
raw.trim().to_lowercase()
}
pub(crate) fn normalize_tag(raw: &str) -> anyhow::Result<String> {
let tag = raw.trim().to_lowercase();
if tag.is_empty() {
anyhow::bail!("empty tag `{raw}` — tags must be non-empty `[a-z0-9_:-]`");
}
if !tag
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '_' | ':' | '-'))
{
anyhow::bail!(
"invalid tag `{raw}` — tags must be `[a-z0-9_:-]` (lowercased, e.g. `area:backlog`)"
);
}
Ok(tag)
}
pub(crate) fn apply_tags_set(
doc: &mut toml_edit::DocumentMut,
adds: &BTreeSet<String>,
removes: &BTreeSet<String>,
today: &str,
) -> anyhow::Result<bool> {
{
let table = doc.as_table_mut();
if table.get("tags").is_none() {
table.insert("tags", toml_edit::value(toml_edit::Array::new()));
}
}
let array = doc
.as_table()
.get("tags")
.and_then(toml_edit::Item::as_array)
.context("malformed backlog item: tags key exists but is not an array")?;
let current: BTreeSet<String> = array
.iter()
.filter_map(|v| v.as_str().map(str::to_string))
.collect();
let mut new: BTreeSet<String> = current.clone();
new.extend(adds.iter().cloned());
for r in removes {
new.remove(r);
}
if new == current {
return Ok(false);
}
let mut fresh = toml_edit::Array::new();
for tag in &new {
fresh.push(tag.as_str());
}
{
let table = doc.as_table_mut();
table.insert("tags", toml_edit::value(fresh));
if table.contains_key("updated") {
table.insert("updated", toml_edit::value(today));
}
}
Ok(true)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn normalises_trim_and_lowercase() {
assert_eq!(normalize_tag(" Area:Backlog ").unwrap(), "area:backlog");
}
#[test]
fn accepts_valid_charset() {
assert_eq!(normalize_tag("a_b-1:c").unwrap(), "a_b-1:c");
}
#[test]
fn rejects_invalid_chars() {
for bad in ["a b", "a@b"] {
let err = normalize_tag(bad).unwrap_err().to_string();
assert!(
err.contains(bad),
"the reject names the offending token: {err}"
);
}
}
#[test]
fn rejects_empty_after_trim() {
assert!(
normalize_tag(" ").is_err(),
"empty-after-trim is rejected"
);
}
#[test]
fn apply_tags_set_insert_if_missing_seeds_empty_array() {
let text = "id = 1\nslug = \"a\"\ntitle = \"A\"\nkind = \"issue\"\n";
let mut doc = text.parse::<toml_edit::DocumentMut>().unwrap();
let adds: BTreeSet<String> = ["x".into()].into();
let removes: BTreeSet<String> = BTreeSet::new();
let changed = apply_tags_set(&mut doc, &adds, &removes, "today").unwrap();
assert!(changed, "should write");
let out = doc.to_string();
assert!(
out.contains("tags = [\"x\"]"),
"tags seeded and populated: {out}"
);
}
#[test]
fn apply_tags_set_noop_guard_compares_as_sets() {
let text = "id = 1\nslug = \"a\"\ntitle = \"A\"\nkind = \"issue\"\ntags = [\"b\", \"a\"]\n";
let mut doc = text.parse::<toml_edit::DocumentMut>().unwrap();
let adds: BTreeSet<String> = ["a".into()].into();
let removes: BTreeSet<String> = BTreeSet::new();
let changed = apply_tags_set(&mut doc, &adds, &removes, "today").unwrap();
assert!(!changed, "no-op when set already contains tag");
assert!(
doc.to_string().contains("tags = [\"b\", \"a\"]"),
"unsorted store unchanged on no-op"
);
}
#[test]
fn apply_tags_set_stores_sorted_union_diff() {
let text = "id = 1\nslug = \"a\"\ntitle = \"A\"\nkind = \"issue\"\ntags = [\"a\", \"c\"]\n";
let mut doc = text.parse::<toml_edit::DocumentMut>().unwrap();
let adds: BTreeSet<String> = ["b".into()].into();
let removes: BTreeSet<String> = ["a".into()].into();
let changed = apply_tags_set(&mut doc, &adds, &removes, "today").unwrap();
assert!(changed, "should write");
let out = doc.to_string();
assert!(
out.contains("tags = [\"b\", \"c\"]"),
"sorted union-diff: {out}"
);
}
#[test]
fn apply_tags_set_updated_stamped_if_present() {
let text =
"id = 1\nslug = \"a\"\ntitle = \"A\"\nkind = \"issue\"\nupdated = \"old\"\ntags = []\n";
let mut doc = text.parse::<toml_edit::DocumentMut>().unwrap();
let adds: BTreeSet<String> = ["x".into()].into();
let removes: BTreeSet<String> = BTreeSet::new();
let changed = apply_tags_set(&mut doc, &adds, &removes, "today").unwrap();
assert!(changed, "should write");
let out = doc.to_string();
assert!(!out.contains("updated = \"old\""), "updated stamped: {out}");
assert!(
out.contains("updated = \"today\""),
"updated stamped with today: {out}"
);
let text2 = "id = 2\nslug = \"b\"\ntitle = \"B\"\nkind = \"issue\"\ntags = []\n";
let mut doc2 = text2.parse::<toml_edit::DocumentMut>().unwrap();
let changed2 = apply_tags_set(&mut doc2, &adds, &removes, "today").unwrap();
assert!(changed2, "should write");
let out2 = doc2.to_string();
assert!(
!out2.contains("updated"),
"no updated key written when absent: {out2}"
);
}
#[test]
fn apply_tags_set_clear_on_untagged_is_noop() {
let text = "id = 1\nslug = \"a\"\ntitle = \"A\"\nkind = \"issue\"\ntags = []\n";
let mut doc = text.parse::<toml_edit::DocumentMut>().unwrap();
let adds: BTreeSet<String> = BTreeSet::new();
let removes: BTreeSet<String> = ["x".into()].into();
let changed = apply_tags_set(&mut doc, &adds, &removes, "today").unwrap();
assert!(!changed, "remove from empty is no-op");
}
use crate::kinds;
#[test]
fn fold_filter_tag_lenient() {
assert_eq!(fold_filter_tag(" Security "), "security");
assert_eq!(fold_filter_tag("a b"), "a b");
}
#[test]
fn record_kinds_are_taggable() {
for prefix in kinds::RECORD {
assert!(TAGGABLE.contains(prefix), "{prefix} missing from TAGGABLE");
}
}
}