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
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
//! 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`.
use std::collections::BTreeSet;
use anyhow::Context;
// ---------------------------------------------------------------------------
// TAGGABLE — entity kinds that accept tags (SL-136)
// ---------------------------------------------------------------------------
pub(crate) use crate::kinds::TAGGABLE;
// ---------------------------------------------------------------------------
// fold_filter_tag — lenient filter-fold (distinct from write normalize_tag)
// ---------------------------------------------------------------------------
/// Lenient fold for `-t/--tag` filter inputs — trim + lowercase only,
/// deliberately DISTINCT from the write-path [`normalize_tag`] so a mixed-case
/// input round-trips the lowercased store silently (a no-match filter succeeds
/// with zero rows, never errors).
pub(crate) fn fold_filter_tag(raw: &str) -> String {
raw.trim().to_lowercase()
}
// ---------------------------------------------------------------------------
// 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 [`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)
}
// ---------------------------------------------------------------------------
// apply_tags_set — shared write-core for tag set-replace
// ---------------------------------------------------------------------------
/// Pure write core: apply a tag add/remove SET edit to a held `&mut DocumentMut`,
/// edit-preserving. No disk, no clock — the shell injects `today`.
///
/// - **Self-heal**: if the `tags` key is absent, seeds `tags = []` and continues
/// (no `bail!`). CHR-019 proved root `insert` is safe in `toml_edit` 0.22 — no
/// tail-subtable corruption.
/// - **The set algebra**: `new = (current ∪ adds) ∖ removes`, stored SORTED.
/// The current set is read off the existing array verbatim (a hand-authored
/// store may be unsorted — that is fine, the no-op guard compares as SETS).
/// - **No-op guard (set-compare)**: if `set(new) == set(current)`, return
/// `Ok(false)` with NO mutation (content + mtime hold). Set-compare (not
/// ordered-vec) is REQUIRED so an idempotent re-add against an UNSORTED
/// hand-authored store does not spuriously write + stamp `updated`.
/// - Else replace `tags` with the fresh SORTED array. Only stamp
/// `updated = today` if the `updated` key already exists. Everything OUTSIDE
/// the array (comments, inert tables, unknown keys) is preserved by
/// `toml_edit`.
pub(crate) fn apply_tags_set(
doc: &mut toml_edit::DocumentMut,
adds: &BTreeSet<String>,
removes: &BTreeSet<String>,
today: &str,
) -> anyhow::Result<bool> {
// Self-heal: seed tags = [] when absent.
{
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);
}
// Set-compare no-op guard.
if new == current {
return Ok(false);
}
// Full sorted-array replace.
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));
// Only stamp `updated` if the key already exists.
if table.contains_key("updated") {
table.insert("updated", toml_edit::value(today));
}
}
Ok(true)
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[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"
);
}
// ── apply_tags_set ──────────────────────────────────────────────
#[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() {
// File with updated key — stamp it.
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}"
);
// File without updated key — no updated written.
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");
}
// ── fold_filter_tag ─────────────────────────────────────────────
#[test]
fn fold_filter_tag_lenient() {
assert_eq!(fold_filter_tag(" Security "), "security");
// The lenient fold accepts what the write chokepoint rejects.
assert_eq!(fold_filter_tag("a b"), "a b");
}
/// IMP-184: every record kind must be in TAGGABLE so tagging works on
/// knowledge records.
#[test]
fn record_kinds_are_taggable() {
for &prefix in crate::kinds::RECORD {
assert!(
crate::kinds::TAGGABLE.contains(&prefix),
"{prefix} missing from TAGGABLE"
);
}
}
}