1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
//! Leaf-tier tag normalization — shared write-path chokepoint for backlog &
//! memory. This module imports NOTHING from the command/engine tier; callers
//! (backlog, memory) sit in the command tier and import this leaf.
//!
//! SL-100 PHASE-01 — extracted from `backlog.rs`.
// ---------------------------------------------------------------------------
// normalize_tag — the single WRITE chokepoint
// ---------------------------------------------------------------------------
/// Normalise ONE tag on the WRITE path — the single chokepoint that decides what
/// lands in the store (cf. `resolve_slug` for authored slugs). Trim, lowercase,
/// then validate every char is `[a-z0-9_:-]` (colon allowed for namespacing, e.g.
/// `area:backlog`); empty after trim, or any other char, is a HARD user error
/// (`bail!`) NAMING the offending token so the author can fix it.
///
/// DISTINCT from the filter-fold in `backlog::fold_filter_tag` — the filter fold
/// is lenient by design and MUST NOT route through this.
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)
}
// ---------------------------------------------------------------------------
// Tests — charset gate
// ---------------------------------------------------------------------------
#[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() {
// colon namespacing, underscore, hyphen, digits all accepted.
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"
);
}
}