Merge pull request #338 from fiddlerwoaroof/adr007/slugify-amendment

feat(core): slugify, id syntax, natural order and display suffixes per ADR 007 amendment 1 (improvise-cq1q.14)
This commit is contained in:
Ed Langley
2026-10-10 11:15:34 -07:00
committed by GitHub
4 changed files with 471 additions and 104 deletions
@@ -178,10 +178,11 @@ different thing that generates the same slug: the merged file declares that id t
and the load is refused naming both. Stable identity across copies of a file is not
promised (improvise-8tk5).
**Built code.** `crates/improvise-core/src/model/id.rs` implements the accepted steps
(`context/code/core.md` §20). A follow-up must replace rather than strip, collapse
`_`, trim a trailing `_` and both ends' `-`, prepend `n`, make `claim` refuse what a
generated id could not be, and add the virtual views and `IdKind::Group`.
**Built code.** `crates/improvise-core/src/model/id.rs` implements this clause
(`context/code/core.md` §20): `slugify` follows the six steps, `is_id_syntax` is the
syntax, `claim` refuses what a generated id could not be, `VIRTUAL_IDS` holds the five
virtual ids, `IdKind::Group` exists, and `natural_cmp` and `display_suffixes` implement
natural order and clause 2's suffixes. No other code calls them yet.
### 5. Persistence
@@ -310,7 +311,7 @@ therefore shows B as `east-1`, never as a bare `east`.
- Scripts that name things by display break and must be rewritten over ids.
- Two measures can share a display, and are shown as `Profit` and `Profit #2`.
- Old files' `_Measure` declarations and hyphen- or digit-led names change on first save.
- `id.rs` and its tests need the follow-up in clause 4.
- `id.rs` implements clause 4; the code that sorts, shows and creates ids does not call it yet.
## Open questions
+55 -43
View File
@@ -1688,48 +1688,63 @@ probability (`> ||[||]` is a self-parent), so `generated_file_parses` accepts a
## 20. How an id is made — `model/id.rs` (ADR 007 clause 4, improvise-cq1q.2)
`slugify` and `IdAllocator` are the first piece of ADR 007. They are pure
functions of the display and the allocator's own history. Nothing calls them
yet: the creation sites move onto them in the re-key step (improvise-cq1q.4).
`slugify`, `IdAllocator`, `natural_cmp` and `display_suffixes` are the first
piece of ADR 007. They are pure functions of their arguments and the
allocator's own history. Nothing calls them yet: the creation sites move onto them in the re-key step (improvise-cq1q.4).
Each rule traces to ADR 007's clause 4 (slugify, the counter, hand-written
ids) or clause 1 (reserved ids).
ids, natural order), clause 1 (reserved ids) or clause 2 (display suffixes),
as amended by amendment 1.
**Case folding is full Unicode folding, and it comes from `unicase`.**
`str::to_lowercase` is not folding: `ß` lowercases to itself and is then
stripped, so `Straße` would slug to `strae` (clause 4, step 1: full case folding). The same goes for `ſ`
(long s) and the `fi` ligature. `unicase` 2.9 is `no_std` and has no
`str::to_lowercase` is not folding: `ß` lowercases to itself and then
becomes `_`, so `Straße` would slug to `stra_e` (clause 4, step 1: full case
folding). The same goes for `ſ` (long s) and the `fi` ligature. `unicase` 2.9 is `no_std` and has no
dependencies, so it passes the wasm gate. Its `to_folded_case` applies the
C+F mappings of Unicode's CaseFolding.txt, and its own tests pin `Maße` →
`masse`. Most of what folding produces is stripped anyway: only an ASCII
letter survives step 3. Deriving that ASCII-producing subset of the table by
hand was rejected. It is a second copy of the Unicode data, and nothing would
`masse`. Most of what folding produces becomes `_` anyway: only ASCII
letters, digits and `-` survive step 2. Deriving that ASCII-producing subset of
the table by hand was rejected. It is a second copy of the Unicode data, and nothing would
say when it drifted.
**A "space" is U+0020.** The accepted clause 4 says "replace each run of spaces
with one `_`". A tab or a no-break space is not a space here, so it is
stripped by step 3. Only a run of U+0020 becomes one `_`. An underscore the
display already had is never merged with the one a space run makes. So `a_ b`
slugs to `a__b`: the steps are applied as written, and no extra
underscore-collapsing step exists. A trailing run survives, so `East ` slugs
to `east_`. Only *leading* underscores are removed (step 4).
**Slugify is the amendment's six steps** (ADR 007 amendment 1, clause 4):
casefold; every character outside `a`–`z`, `0`–`9` and `-` becomes `_`; runs of
`_` collapse; leading and trailing `_` and `-` are removed, all of them (the
trim is `trim_matches` over both characters, so `_-_East-_-` is `east`);
`n` is prepended to a slug that starts with a digit; nothing left is the
caller's `<kind>-<n>`. The order matters in one place: the trim runs before
the `n`, so `-_7up` is `n7up` and a leading `-` can never be hidden behind the
prefix. Whitespace of every kind, punctuation and every non-ASCII letter
become `_`, so `Region(code)` is `region_code` and `Crème Brûlée` is
`cr_me_br_l_e`. Folding is `unicase`'s, which maps `ß` to `ss`; a character
that folds to a combining mark (`İ` folds to `i` plus U+0307) leaves the `i`
and turns the mark into `_`, which the trim then removes. A hyphen between
underscores survives (`a - b` is `a_-_b`).
**ADR 007's amendment changes these rules, and this code predates it.** The
amendment (`context/adr/007-stable-identities-amendment-1.md`, accepted 2026-10-09)
says in its clause 4:
- every character outside `a`–`z`, `0`–`9` and `-` becomes `_` instead of being
stripped (not only U+0020), so `Region(code)` slugs to `region_code`, while
`Q1-2024` keeps its `-`;
- runs of `_` collapse, so `a_ b` and `a__b` both slug to `a_b`;
- leading and trailing `_` and `-` are removed, so `East ` → `east`, `-East-` →
`east`, and `-` alone takes `<kind>-<n>`;
- `n` is prepended to a slug that starts with a digit, so `2024` → `n2024`;
- a hand-written id must obey every syntax rule of a generated one.
**The id syntax has one validator, `is_id_syntax`**: a lower-case ASCII
letter, then `a`–`z`, `0`–`9`, `-` and single underscores, never ending in
`_` or `-`. Slugify's non-empty output always satisfies it and is a fixed point
of itself, and a `-<n>` suffix keeps the shape, so a generated id always
validates (properties in `model::id::tests`). A virtual id fails it by design,
because the leading `_` is the reservation.
This section, `slugify`, and the tests
`slugify_collapses_a_run_of_spaces_to_one_underscore` (its `east_` case) and
`slugify_keeps_an_underscore_the_display_already_had` describe the earlier rule
above. They change in a follow-up the amendment calls for. The amendment's
clause 4 lists every difference.
**Natural order is `natural_cmp`** (clause 4, "Ids are always sorted in
natural order"). An id is split into alternating digit and non-digit runs, and
the runs are compared pairwise: non-digit runs by bytes; digit runs by value
(strip leading zeros, shorter is smaller, then bytes), and a tie on value goes
to the run with fewer leading zeros, immediately and not after the later runs.
The id that runs out of runs first sorts first. No digit run is parsed into an
integer, so a run of any length compares correctly. Where two ids disagree
about the kind of run at a position (only possible for text that is not an
id), the runs compare as bytes. It is a total order and equal only for equal
strings. **Nothing calls it yet**: `CellKey`, `DataStore`, the writer and the GP
dataset still sort with `String::cmp` until the re-key step.
**Equal displays are suffixed by `display_suffixes`** (clause 2). Given
`(id, display)` pairs it returns what each is shown as, in input order: the
first of a group of equal displays in natural id order keeps the bare display,
the rest get ` #2`, ` #3`, …, and a number is skipped when a literal display
already reads `<display> #<n>` anywhere in the scope. Displays are equal
byte for byte, not case-folded. It is pure and nothing renders through it yet.
**The allocator records every id it admits and releases none.** That is the
whole of retirement (clause 4, "within a session a removed id is never handed
@@ -1750,17 +1765,14 @@ name happens to be free, because clause 4 spells the result
`<kind>-<n>`.
**`claim` is the only door for a hand-written id.** It checks, in this order:
the alphabet (non-empty, `a`–`z`, `0`–`9`, `-`, `_`), the reserved leading
`_` (only `_index`, `_dim` and `_measure` may carry one, per clause 1, and
the match is exact: `_indexer` and `_measure-1` are Reserved, since "no other
id may start with `_`"), and
duplicates. It accepts `-`, `-x`, `x-`, `a__b` and `2024`, all of which the
amendment refuses (a hand-written id obeys the syntax of a generated one). It never renames.
Refusing is the loader's job, and the loader
the reserved leading `_` (only `_index`, `_dim`, `_measure`, `_records` and
`_drill` may carry one, and the match is exact: `_indexer` and `_measure-1`
are Reserved), the syntax (`is_id_syntax`; `-x`, `x-`, `2024`, `a__b` and `x_`
are Malformed, `east-1`, `item-4`, `a-7` and `q1_-_2024` pass), and
duplicates. A virtual id skips the syntax check, since it fails it by design.
It never renames. Refusing is the loader's job, and the loader
must name both lines of a duplicate; `ClaimError` carries only the id, so the
loader keeps the line numbers. The virtual views `_records` and `_drill` are
not yet in `VIRTUAL_IDS`. improvise-p003 item 1 asks for them, and they arrive
with the ADR text fix (improvise-cq1q.1) rather than ahead of it.
loader keeps the line numbers. `IdKind::Group` spells `group-<n>`.
## 21. A display beside the key (ADR 007 clause 2, improvise-cq1q.3)
+91 -13
View File
@@ -1914,30 +1914,108 @@ identity on the view.
header stays for the sibling quarter that has data. Rival: `prune_axis_items`
keying open runs by level alone. This test and the `NESTED` enumeration fail.
## `model/id.rs` (ADR 007 clause 4, improvise-cq1q.2)
## `model/id.rs` (ADR 007 clause 4 and amendment 1, improvise-cq1q.2, improvise-cq1q.14)
These tests were written before `slugify` and `IdAllocator` had bodies. With
`todo!()` bodies all eleven failed. Each rival below was then injected into the
finished module, run against `cargo test -p improvise-core --lib model::id`, and
Each rival below was injected into the finished module, run against `cargo test -p improvise-core --lib model::id`, and
reverted. The reasoning is `context/code/core.md` §20.
### model::id::tests::slugify_case_folds_rather_than_lowercases
Rival: `display.to_lowercase()` in place of `unicase`'s full folding. Only
this test fails: `left: "strae"`, `right: "strasse"`. `ß` lowercases to itself
and step 3 strips it. `slugify_follows_the_adrs_examples` cannot see this rival,
this test fails: `ß` lowercases to itself and becomes `_`, so `stra_e` against
`strasse`. `slugify_follows_the_amendments_table` cannot see this rival,
because each of its displays lowercases and folds alike.
### model::id::tests::slugify_collapses_a_run_of_spaces_to_one_underscore
### model::id::tests::slugify_follows_the_amendments_table
Rival: every space becomes `_`, with runs not collapsed. Only this test fails:
`"time___period"`. No other fixture has two spaces in a row.
Every row of the amendment's clause 4 table, plus `-`, `%` and the empty
display. Rivals, each of which fails this test: trimming only leading `_`
(`East_` stays), trimming `_` and not `-` (`-East-`), not collapsing runs of
`_` (`a__b`), not prepending `n` (`2024`), and stripping instead of replacing
(`Region(code)` is `regioncode`). The last also fails
`slugify_keeps_a_hyphen_between_underscores` and
`a_slug_taken_by_another_kind_takes_a_suffix`.
### model::id::tests::slugify_keeps_an_underscore_the_display_already_had
### model::id::tests::slugify_trims_hyphens_and_underscores_until_neither_is_at_an_end
Rival: `.replace("__", "_")` after the space step, which reads the accepted
clause 4 as "no doubled underscores". Only this test fails: `left: "a_b"`. The rule
collapses runs of *spaces*. An underscore that was in the display is kept.
Rival: trim leading `_` only, or `_` only at both ends. `_-_East-_-` keeps a
hyphen, and `-_-` is not empty. A single `trim_matches` over both characters
passes; two sequential single-character trims would not.
### model::id::tests::slugify_trims_before_it_prepends_the_n
Rival: no `n` prefix, which fails this test, the table and the property below.
`-_7up` is `n7up` only if the trim runs first; prepending first would leave the
`-_` in front of the `n`.
### model::id::tests::slugify_output_has_the_id_syntax_and_is_a_fixed_point
The property over arbitrary displays: a non-empty slug satisfies
`is_id_syntax` and slugifying it returns it. Rivals that fail it (and
`generated_ids_are_distinct_well_formed_and_never_reserved`): no `_`
collapse, no `n` prefix, keeping a trailing `_` or `-`.
### model::id::tests::id_syntax_agrees_with_an_independent_checker
`is_id_syntax` against a hand-written scanner of `^[a-z](_?[a-z0-9-])*$` that
does not end in `-`, over `[a-cA-C0-9 _%é-]{0,8}`, so uppercase, space, symbols
and non-ASCII are reached. Dropping the alphabet check also fails it. Rivals:
allowing `__`, allowing a leading digit and allowing a trailing `-` each fail
here and in `claim_refuses_what_a_generated_id_could_not_be`.
### model::id::tests::natural_order_decides_the_zeros_tie_at_its_own_run_not_after_the_rest
Rival: compare leading zeros only after every run compares equal in value.
`a-7c` then sorts after `a-007b`, and this test fails alone.
### model::id::tests::suffixed_displays_are_distinct_when_literals_already_hold_suffixes
Displays are drawn from `A`, `A #2`, `A #3` and `B`, so literal suffixes meet
generated ones. Rival: numbering without the skip fails it.
### model::id::tests::claim_refuses_what_a_generated_id_could_not_be
Rival: `claim` keeps the old alphabet-only check. `-x`, `x-`, `2024`, `a__b`,
`x_` and `-` are admitted, and the test fails. It also pins the accepted ids
`east-1`, `item-4`, `a-7` and `q1_-_2024`.
### model::id::tests::claim_admits_each_virtual_id_once
Rival: `VIRTUAL_IDS` without `_records` and `_drill`. The test spells the five
ids out instead of iterating the constant, because an iteration over the
constant passes with the constant shortened (the first version did).
### model::id::tests::natural_order_puts_a_counter_suffix_in_numeric_order
Rivals: comparing digit runs as strings (`profit-10` before `profit-2`) fails
this test, `natural_order_compares_digit_runs_of_any_length_by_value` and
`a_suffix_numbers_equal_displays_in_natural_id_order`; plain `str::cmp` fails
those and `natural_order_ties_on_value_go_to_fewer_leading_zeros`.
### model::id::tests::natural_order_ties_on_value_go_to_fewer_leading_zeros
Rivals: more zeros first, or no tie-break (`a-7` equal to `a-007`). Both fail this test and the any-length test (`x000` after `x0`).
### model::id::tests::natural_order_puts_the_id_that_runs_out_of_runs_first
Rival: the longer id first. Only this test fails.
### model::id::tests::natural_order_is_a_total_order
Antisymmetry, equality only for equal strings and transitivity over
`[a-c0-3_-]{0,7}`, which includes ids starting with a digit.
### model::id::tests::a_suffix_numbers_equal_displays_in_natural_id_order
Rival: ordering the group by `str::cmp` on the ids. `profit-10` is numbered
before `profit-2` and this test fails alone.
### model::id::tests::a_suffix_skips_a_number_a_literal_display_already_holds
Rival: numbering without the skip. A literal `Profit #2` and a generated
`Profit #2` are then both shown, and this test fails alone. A rival that starts
numbering at ` #1` also fails it and
`a_suffix_is_gapless_when_a_thing_goes_away`.
### model::id::tests::the_suffix_is_the_next_counter_value_not_the_lowest_free_number
+319 -43
View File
@@ -1,6 +1,7 @@
//! How an id is made: `slugify`, and the document's `IdAllocator` (ADR 007 clause 4).
//! Reasoning: `context/code/core.md` §20.
use std::collections::HashSet;
//! How an id is made: `slugify`, and the document's `IdAllocator` (ADR 007 clause 4
//! and its amendment). Reasoning: `context/code/core.md` §20.
use std::cmp::Ordering;
use std::collections::{HashMap, HashSet};
/// What a generated id names; spells the `<kind>` of an empty slug's `<kind>-<n>`.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -9,6 +10,7 @@ pub enum IdKind {
Item,
Formula,
View,
Group,
}
impl IdKind {
@@ -18,38 +20,111 @@ impl IdKind {
IdKind::Item => "item",
IdKind::Formula => "formula",
IdKind::View => "view",
IdKind::Group => "group",
}
}
}
/// The only ids that may begin with `_` (answer 23).
pub const VIRTUAL_IDS: [&str; 3] = ["_index", "_dim", "_measure"];
/// The only ids that may begin with `_` (amendment 1, clause 1).
pub const VIRTUAL_IDS: [&str; 5] = ["_index", "_dim", "_measure", "_records", "_drill"];
/// A display name's slug; empty when nothing survives (answers 16, 18, 21, 23, 26).
/// A display name's slug; empty when nothing survives (amendment 1, clause 4).
pub fn slugify(display: &str) -> String {
let folded = unicase::UniCase::new(display).to_folded_case();
let kept: String = underscore_space_runs(&folded)
let mut slug = String::new();
for c in folded
.chars()
.filter(|&c| in_id_alphabet(c))
.collect();
kept.trim_start_matches('_').to_string()
.map(|c| if in_slug_alphabet(c) { c } else { '_' })
{
if !(c == '_' && slug.ends_with('_')) {
slug.push(c);
}
}
let trimmed = slug.trim_matches(|c| c == '_' || c == '-');
if trimmed.starts_with(|c: char| c.is_ascii_digit()) {
format!("n{trimmed}")
} else {
trimmed.to_string()
}
}
fn underscore_space_runs(s: &str) -> String {
let (out, _) = s
.chars()
.fold((String::new(), false), |(mut out, prev), c| {
let space = c == ' ';
if !(space && prev) {
out.push(if space { '_' } else { c });
}
(out, space)
});
out
fn in_slug_alphabet(c: char) -> bool {
matches!(c, 'a'..='z' | '0'..='9' | '-')
}
fn in_id_alphabet(c: char) -> bool {
matches!(c, 'a'..='z' | '0'..='9' | '-' | '_')
/// Whether `id` has the syntax of a generated id: a letter, then `a`-`z`, `0`-`9`,
/// `-` and single underscores, never ending in `_` or `-`. A virtual id fails it.
pub fn is_id_syntax(id: &str) -> bool {
let b = id.as_bytes();
let edges = matches!(b.first(), Some(b'a'..=b'z')) && !matches!(b.last(), Some(b'_' | b'-'));
edges && b.windows(2).all(|w| w != b"__") && id.chars().all(|c| in_slug_alphabet(c) || c == '_')
}
/// Ids in natural order: digit runs by value, then fewer leading zeros first.
pub fn natural_cmp(a: &str, b: &str) -> Ordering {
let (mut ra, mut rb) = (runs(a), runs(b));
loop {
match (ra.next(), rb.next()) {
(None, None) => return Ordering::Equal,
(None, Some(_)) => return Ordering::Less,
(Some(_), None) => return Ordering::Greater,
(Some(x), Some(y)) => match run_cmp(x, y) {
Ordering::Equal => {}
other => return other,
},
}
}
}
fn runs(s: &str) -> impl Iterator<Item = &str> {
let mut rest = s;
std::iter::from_fn(move || {
let digit = rest.chars().next()?.is_ascii_digit();
let end = rest
.find(|c: char| c.is_ascii_digit() != digit)
.unwrap_or(rest.len());
let (run, tail) = rest.split_at(end);
rest = tail;
Some(run)
})
}
fn run_cmp(x: &str, y: &str) -> Ordering {
let digits = |r: &str| r.starts_with(|c: char| c.is_ascii_digit());
if digits(x) && digits(y) {
let (vx, vy) = (x.trim_start_matches('0'), y.trim_start_matches('0'));
vx.len()
.cmp(&vy.len())
.then_with(|| vx.cmp(vy))
.then_with(|| x.len().cmp(&y.len()))
} else {
x.cmp(y)
}
}
/// What each `(id, display)` pair is shown as: equal displays get ` #2`, ` #3`, …
/// in natural id order, skipping numbers a literal display already holds.
pub fn display_suffixes(items: &[(&str, &str)]) -> Vec<String> {
let literal: HashSet<&str> = items.iter().map(|&(_, d)| d).collect();
let mut groups: HashMap<&str, Vec<usize>> = HashMap::new();
for (i, &(_, d)) in items.iter().enumerate() {
groups.entry(d).or_default().push(i);
}
let mut shown: Vec<String> = items.iter().map(|&(_, d)| d.to_string()).collect();
for (display, mut at) in groups {
at.sort_by(|&i, &j| natural_cmp(items[i].0, items[j].0));
let mut n = 2;
for &i in at.iter().skip(1) {
shown[i] = loop {
let candidate = format!("{display} #{n}");
n += 1;
if !literal.contains(candidate.as_str()) {
break candidate;
}
};
}
}
shown
}
/// Why a hand-written id cannot enter the document.
@@ -81,12 +156,13 @@ impl IdAllocator {
Self::default()
}
/// Admit a hand-written id verbatim: it is canonical and never renamed (answer 29).
/// Admit a hand-written id verbatim: it is canonical and never renamed.
pub fn claim(&mut self, id: &str) -> Result<(), ClaimError> {
let refusal = if id.is_empty() || !id.chars().all(in_id_alphabet) {
Some(ClaimError::Malformed(id.to_string()))
} else if id.starts_with('_') && !VIRTUAL_IDS.contains(&id) {
let virtual_id = VIRTUAL_IDS.contains(&id);
let refusal = if id.starts_with('_') && !virtual_id {
Some(ClaimError::Reserved(id.to_string()))
} else if !virtual_id && !is_id_syntax(id) {
Some(ClaimError::Malformed(id.to_string()))
} else if self.seen.contains(id) {
Some(ClaimError::Duplicate(id.to_string()))
} else {
@@ -132,16 +208,26 @@ mod tests {
use proptest::prelude::*;
#[test]
fn slugify_follows_the_adrs_examples() {
fn slugify_follows_the_amendments_table() {
let cases = [
("East", "east"),
("Time Period", "time_period"),
("Coastal_East", "coastal_east"),
("Q1-2024", "q1-2024"),
("Region(code)", "regioncode"),
("Café", "caf"),
("_Draft", "draft"),
("Time\tPeriod", "time_period"),
("Time \u{200B} Period", "time_period"),
("a_ b", "a_b"),
("a__b", "a_b"),
("Q1 - 2024", "q1_-_2024"),
("East ", "east"),
("East_", "east"),
("-East-", "east"),
("Region(code)", "region_code"),
("Crème Brûlée", "cr_me_br_l_e"),
("2024", "n2024"),
("_2024", "n2024"),
("2024-Q1", "n2024-q1"),
("Ü", ""),
("-", ""),
("%", ""),
("", ""),
];
for (display, slug) in cases {
assert_eq!(slugify(display), slug, "slugify({display:?})");
@@ -153,18 +239,25 @@ mod tests {
assert_eq!(slugify("Straße"), "strasse");
assert_eq!(slugify("ſtate"), "state");
assert_eq!(slugify("file"), "file");
assert_eq!(slugify("İ"), "i");
}
#[test]
fn slugify_collapses_a_run_of_spaces_to_one_underscore() {
assert_eq!(slugify("Time Period"), "time_period");
assert_eq!(slugify("East "), "east_");
fn slugify_trims_hyphens_and_underscores_until_neither_is_at_an_end() {
assert_eq!(slugify("_-_East-_-"), "east");
assert_eq!(slugify("-_-"), "");
}
#[test]
fn slugify_keeps_an_underscore_the_display_already_had() {
assert_eq!(slugify("a_ b"), "a__b");
assert_eq!(slugify("a__b"), "a__b");
fn slugify_trims_before_it_prepends_the_n() {
assert_eq!(slugify("-_7up"), "n7up");
assert_eq!(slugify("n-7"), "n-7");
}
#[test]
fn slugify_keeps_a_hyphen_between_underscores() {
assert_eq!(slugify("a - b"), "a_-_b");
assert_eq!(slugify("a_-b"), "a_-b");
}
#[test]
@@ -173,6 +266,8 @@ mod tests {
assert_eq!(ids.generate(IdKind::Category, "Ü"), "category-1");
assert_eq!(ids.generate(IdKind::Item, "Ö"), "item-2");
assert_eq!(ids.generate(IdKind::View, ""), "view-3");
assert_eq!(ids.generate(IdKind::Group, "-"), "group-4");
assert_eq!(ids.generate(IdKind::Formula, "%"), "formula-5");
}
#[test]
@@ -197,6 +292,15 @@ mod tests {
assert_eq!(ids.generate(IdKind::Item, "EAST"), "east-3");
}
#[test]
fn a_hyphenated_slug_and_a_counter_suffix_never_collide() {
let mut ids = IdAllocator::new();
assert_eq!(ids.generate(IdKind::Item, "East"), "east");
assert_eq!(ids.generate(IdKind::Item, "East-1"), "east-1");
assert_eq!(ids.generate(IdKind::Item, "east"), "east-2");
assert_eq!(ids.generate(IdKind::Item, "East-1"), "east-1-3");
}
#[test]
fn a_claimed_id_keeps_its_spelling_and_generation_steps_past_it() {
let mut ids = IdAllocator::new();
@@ -224,10 +328,21 @@ mod tests {
);
}
#[test]
fn claim_refuses_what_a_generated_id_could_not_be() {
let mut ids = IdAllocator::new();
for bad in ["-x", "x-", "2024", "a__b", "x_", "-", "a b", "é"] {
assert_eq!(ids.claim(bad), Err(ClaimError::Malformed(bad.into())));
}
for good in ["east-1", "item-4", "a-7", "q1_-_2024", "a_b", "x"] {
assert_eq!(ids.claim(good), Ok(()), "{good}");
}
}
#[test]
fn claim_refuses_an_id_that_only_begins_like_a_virtual_one() {
let mut ids = IdAllocator::new();
for lookalike in ["_indexer", "_measure-1", "_dim_2", "_measures"] {
for lookalike in ["_indexer", "_measure-1", "_dim_2", "_measures", "_recordsx"] {
assert_eq!(
ids.claim(lookalike),
Err(ClaimError::Reserved(lookalike.into()))
@@ -235,10 +350,20 @@ mod tests {
}
}
#[test]
fn claim_admits_each_virtual_id_once() {
let mut ids = IdAllocator::new();
for v in ["_index", "_dim", "_measure", "_records", "_drill"] {
assert_eq!(ids.claim(v), Ok(()));
assert_eq!(ids.claim(v), Err(ClaimError::Duplicate(v.into())));
}
}
#[test]
fn a_display_with_leading_spaces_never_slugs_into_the_reserved_space() {
assert_eq!(slugify(" Draft"), "draft");
assert_eq!(slugify(" _ Draft"), "draft");
assert_eq!(slugify("_Drill"), "drill");
}
#[test]
@@ -272,7 +397,159 @@ mod tests {
assert_eq!(run(), run());
}
#[test]
fn natural_order_puts_a_counter_suffix_in_numeric_order() {
let mut ids = vec!["profit-10", "profit-2", "profit", "profit-1"];
ids.sort_by(|a, b| natural_cmp(a, b));
assert_eq!(ids, ["profit", "profit-1", "profit-2", "profit-10"]);
}
#[test]
fn natural_order_ties_on_value_go_to_fewer_leading_zeros() {
assert_eq!(natural_cmp("a-7", "a-007"), Ordering::Less);
assert_eq!(natural_cmp("a-007", "a-7"), Ordering::Greater);
assert_eq!(natural_cmp("a-07", "a-007"), Ordering::Less);
assert_eq!(natural_cmp("a-7", "a-7"), Ordering::Equal);
}
#[test]
fn natural_order_compares_digit_runs_of_any_length_by_value() {
let big = "9".repeat(40);
let bigger = format!("1{}", "0".repeat(40));
assert_eq!(
natural_cmp(&format!("x{big}"), &format!("x{bigger}")),
Ordering::Less
);
assert_eq!(natural_cmp("x9", "x10"), Ordering::Less);
assert_eq!(natural_cmp("x000", "x0"), Ordering::Greater);
}
#[test]
fn natural_order_decides_the_zeros_tie_at_its_own_run_not_after_the_rest() {
assert_eq!(natural_cmp("a-7c", "a-007b"), Ordering::Less);
assert_eq!(natural_cmp("a-7b", "a-007a"), Ordering::Less);
assert_eq!(natural_cmp("a-007a", "a-7b"), Ordering::Greater);
}
#[test]
fn natural_order_puts_the_id_that_runs_out_of_runs_first() {
assert_eq!(natural_cmp("a", "a-1"), Ordering::Less);
assert_eq!(natural_cmp("a-1", "a-1-b"), Ordering::Less);
assert_eq!(natural_cmp("a-1b", "a-1"), Ordering::Greater);
}
#[test]
fn natural_order_compares_non_digit_runs_by_bytes() {
assert_eq!(natural_cmp("b1", "a2"), Ordering::Greater);
assert_eq!(natural_cmp("a-1", "a_1"), Ordering::Less);
}
#[test]
fn a_suffix_numbers_equal_displays_in_natural_id_order() {
let items = [
("profit-10", "Profit"),
("profit", "Profit"),
("profit-2", "Profit"),
("profit-1", "Profit"),
("cost", "Cost"),
];
assert_eq!(
display_suffixes(&items),
["Profit #4", "Profit", "Profit #3", "Profit #2", "Cost"]
);
}
#[test]
fn a_suffix_skips_a_number_a_literal_display_already_holds() {
let items = [
("profit", "Profit"),
("profit-1", "Profit"),
("profit-2", "Profit"),
("p2", "Profit #2"),
];
assert_eq!(
display_suffixes(&items),
["Profit", "Profit #3", "Profit #4", "Profit #2"]
);
}
#[test]
fn a_suffix_is_gapless_when_a_thing_goes_away() {
let three = [("p", "Profit"), ("p-1", "Profit"), ("p-2", "Profit")];
assert_eq!(
display_suffixes(&three),
["Profit", "Profit #2", "Profit #3"]
);
let two = [("p", "Profit"), ("p-2", "Profit")];
assert_eq!(display_suffixes(&two), ["Profit", "Profit #2"]);
}
#[test]
fn a_unique_display_is_shown_unchanged() {
let items = [("a", "East"), ("b", "West")];
assert_eq!(display_suffixes(&items), ["East", "West"]);
assert!(display_suffixes(&[]).is_empty());
}
/// `^[a-z](_?[a-z0-9-])*$` and not ending in `-`, without the crate's own helpers.
fn regex_free_match(id: &str) -> bool {
let mut cs = id.chars().peekable();
if !matches!(cs.next(), Some('a'..='z')) {
return false;
}
while cs.peek().is_some() {
if cs.peek() == Some(&'_') {
cs.next();
}
if !matches!(cs.next(), Some('a'..='z' | '0'..='9' | '-')) {
return false;
}
}
!id.ends_with('-')
}
fn id_text() -> impl Strategy<Value = String> {
"[a-c0-3_-]{0,7}"
}
proptest! {
#[test]
fn slugify_output_has_the_id_syntax_and_is_a_fixed_point(display in ".{0,12}") {
let slug = slugify(&display);
prop_assert!(slug.is_empty() || is_id_syntax(&slug), "{slug:?}");
prop_assert_eq!(slugify(&slug), slug);
}
#[test]
fn id_syntax_agrees_with_an_independent_checker(id in "[a-cA-C0-9 _%é-]{0,8}") {
prop_assert_eq!(is_id_syntax(&id), regex_free_match(&id), "{}", id);
}
#[test]
fn natural_order_is_a_total_order(a in id_text(), b in id_text(), c in id_text()) {
prop_assert_eq!(natural_cmp(&a, &b), natural_cmp(&b, &a).reverse());
prop_assert_eq!(natural_cmp(&a, &b) == Ordering::Equal, a == b);
if natural_cmp(&a, &b) != Ordering::Greater && natural_cmp(&b, &c) != Ordering::Greater {
prop_assert!(natural_cmp(&a, &c) != Ordering::Greater);
}
}
#[test]
fn suffixed_displays_are_distinct_when_literals_already_hold_suffixes(
shared in proptest::collection::vec(0usize..4, 0..8)
) {
let displays = ["A", "A #2", "A #3", "B"];
let ids: Vec<String> = (0..shared.len()).map(|n| format!("x-{n}")).collect();
let items: Vec<(&str, &str)> = ids
.iter()
.zip(&shared)
.map(|(i, &d)| (i.as_str(), displays[d]))
.collect();
let shown = display_suffixes(&items);
let distinct: HashSet<&String> = shown.iter().collect();
prop_assert_eq!(distinct.len(), shown.len());
}
#[test]
fn generated_ids_are_distinct_well_formed_and_never_reserved(
displays in proptest::collection::vec(".{0,6}", 0..24)
@@ -281,8 +558,7 @@ mod tests {
let mut out = HashSet::new();
for d in &displays {
let id = ids.generate(IdKind::Item, d);
prop_assert!(!id.is_empty() && !id.starts_with('_'), "{id:?}");
prop_assert!(id.chars().all(|c| matches!(c, 'a'..='z' | '0'..='9' | '-' | '_')));
prop_assert!(is_id_syntax(&id), "{id:?}");
prop_assert!(out.insert(id.clone()), "reissued {id:?}");
}
}