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
//! § id generation — the `id_scheme` and the one shipped (random) generator.
//!
//! `id_scheme = { prefix, length, alphabet }`, no generator enum: base balls
//! ships ONE generator (random); non-default generation (timestamp/sequential/
//! uuid) is a `create/pre` plugin via the same id-reassign seam. An id is the
//! filename basename of `tasks/<id>.md` (Model A — id IS the path, never a
//! field), so the only constraint core puts on it is **string-safety**, not a
//! fixed charset: it must be a safe path token on any filesystem.
//!
//! Collision is the § id-generation rule, split by WHO chose the id: core's own
//! draw RE-ROLLS off the live set ([`IdScheme::mint`], bounded), a plugin's
//! explicit reassignment ABORTS ([`crate::change::Create`]) — an
//! explicit choice is authoritative, a draw is not.
use std::io;
/// How `create` mints a fresh id: a `prefix` followed by `length` characters
/// drawn from `alphabet`. FIXED, not config — the default (`bl-` + four lower
/// hex digits) is the one scheme base balls ships; a team wanting any other
/// (different prefix/length/alphabet, or a non-random strategy) supplies a
/// `create/pre` plugin via the id-reassign seam. Lowercase sidesteps
/// case-insensitive-FS collisions.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct IdScheme {
pub prefix: String,
pub length: usize,
pub alphabet: String,
}
impl Default for IdScheme {
fn default() -> IdScheme {
IdScheme {
prefix: "bl-".to_string(),
length: 4,
alphabet: "0123456789abcdef".to_string(),
}
}
}
impl IdScheme {
/// Mint an id, drawing raw bytes from `next`. Each position uses rejection
/// sampling so every alphabet character is equally likely regardless of
/// the alphabet's length (no modulo bias). The byte source is injected so
/// the mapping is testable without entropy; [`IdScheme::generate`] wires in
/// the system source. Precondition: `alphabet` is non-empty.
pub fn generate_with(&self, next: &mut dyn FnMut() -> u8) -> String {
let alphabet = self.alphabet.as_bytes();
let n = alphabet.len();
// Largest multiple of `n` that fits in a byte; bytes at/above it would
// skew the distribution, so we redraw.
let ceiling = (256 / n) * n;
let mut id = String::with_capacity(self.prefix.len() + self.length);
id.push_str(&self.prefix);
for _ in 0..self.length {
let mut byte = usize::from(next());
while byte >= ceiling {
byte = usize::from(next());
}
id.push(alphabet[byte % n] as char);
}
id
}
/// Mint an id from system entropy — the one generator base balls ships.
///
/// # Panics
/// Only if the system entropy source is unavailable, which does not occur
/// on a supported platform.
pub fn generate(&self) -> String {
self.generate_with(&mut entropy)
}
/// [`IdScheme::generate`] re-rolled until the draw misses `taken` — the §
/// id-generation "auto-gen → retry (bounded)" half, `None` when [`DRAWS`]
/// consecutive draws all hit. The byte source is injected like
/// [`IdScheme::generate_with`]'s, so the re-roll is testable without luck.
pub fn mint_with(&self, taken: &[String], next: &mut dyn FnMut() -> u8) -> Option<String> {
(0..DRAWS).map(|_| self.generate_with(next)).find(|id| !taken.contains(id))
}
/// [`IdScheme::mint_with`] over system entropy: an id no LIVE ball holds
/// (`taken` is the live id set — a dead incarnation's id is legally reused,
/// § id generation). Exhausting [`DRAWS`] is reported, never papered over:
/// with the shipped scheme it means the 65536-id space is genuinely full of
/// open balls, which no re-roll can fix.
///
/// # Panics
/// As [`IdScheme::generate`].
pub fn mint(&self, taken: &[String]) -> io::Result<String> {
self.mint_with(taken, &mut entropy).ok_or_else(|| {
io::Error::other(format!(
"no free id after {DRAWS} draws from the `{}` + {} space — every draw hit a live ball",
self.prefix, self.length
))
})
}
}
/// How many times [`IdScheme::mint`] re-draws before calling the space full.
/// Bounded because termination must be a property of the SPACE, not of luck: a
/// store half-filling the shipped 65536 ids (32768 open balls — orders past any
/// real one) still mints on the first draw 255 times in 256, so a bound a real
/// store can trip does not exist, while an unbounded loop would hang silently
/// on the one shape that is genuinely broken.
const DRAWS: usize = 8;
/// One byte of system entropy — the source both generators draw from.
///
/// # Panics
/// Only if the system entropy source is unavailable, which does not occur on a
/// supported platform.
fn entropy() -> u8 {
let mut byte = [0u8; 1];
getrandom::fill(&mut byte).expect("system entropy unavailable");
byte[0]
}
/// Whether `id` is a safe path token: `^[A-Za-z0-9][A-Za-z0-9_-]*$`. No `/`,
/// `.`, whitespace or shell metacharacters, and no leading `-`. This is the
/// only check core makes — it bounds safety, not aesthetics, so a plugin may
/// assign any id that survives it. Applies to the full id (prefix included).
pub fn is_valid(id: &str) -> bool {
let mut chars = id.chars();
let Some(first) = chars.next() else {
return false;
};
first.is_ascii_alphanumeric()
&& chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
}
#[cfg(test)]
#[path = "id_tests.rs"]
mod tests;