use std::fmt;
use std::str::FromStr;
const SEPARATOR: char = '-';
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct IdSpace {
prefix: &'static str,
}
impl IdSpace {
pub const fn new(prefix: &'static str) -> Self {
Self { prefix }
}
pub const fn try_new(prefix: &'static str) -> Option<Self> {
let bytes = prefix.as_bytes();
let mut i = 0;
while i < bytes.len() {
if bytes[i] == SEPARATOR as u8 {
return None;
}
i += 1;
}
Some(Self { prefix })
}
pub fn prefix(&self) -> &'static str {
self.prefix
}
pub fn id(&self, key: impl fmt::Display) -> String {
format!("{}{SEPARATOR}{key}", self.prefix)
}
pub fn key<'a>(&self, id: &'a str) -> Option<&'a str> {
let (head, key) = id.split_once(SEPARATOR)?;
(head == self.prefix).then_some(key)
}
pub fn parse<T: FromStr>(&self, id: &str) -> Option<T> {
self.key(id)?.parse().ok()
}
}
#[cfg(test)]
mod tests {
use super::*;
const TASK: IdSpace = IdSpace::new("task");
const CHECKED: IdSpace = match IdSpace::try_new("task") {
Some(space) => space,
None => panic!("`task` holds no separator"),
};
#[test]
fn id_and_key_roundtrip() {
let cases = ["0", "7", "1-2", ""];
for key in cases {
let id = TASK.id(key);
assert_eq!(TASK.key(&id), Some(key), "key: {key:?}");
}
assert_eq!(TASK.id(7), "task-7");
assert_eq!(TASK.prefix(), "task");
}
#[test]
fn key_rejects_ids_from_other_spaces() {
let cases = ["task", "other-1", "taskfoo", "", "-1", "Task-1"];
for id in cases {
assert_eq!(TASK.key(id), None, "id: {id:?}");
}
}
#[test]
fn empty_key_is_in_the_space() {
assert_eq!(TASK.key("task-"), Some(""));
assert_eq!(TASK.key("task-1-2"), Some("1-2"));
}
#[test]
fn try_new_rejects_prefixes_holding_the_separator() {
assert_eq!(CHECKED, TASK);
assert_eq!(IdSpace::try_new("task"), Some(TASK));
assert_eq!(
IdSpace::try_new("").map(|space| space.id(7)),
Some("-7".to_string())
);
assert_eq!(IdSpace::try_new("task-delete"), None);
assert_eq!(IdSpace::try_new("-"), None);
assert_eq!(IdSpace::try_new("task-"), None);
}
#[test]
fn a_prefix_holding_the_separator_owns_nothing() {
const OVERLAP: IdSpace = IdSpace::new("task-delete");
let id = OVERLAP.id(7);
assert_eq!(id, TASK.id("delete-7"));
assert_eq!(OVERLAP.key(&id), None);
assert_eq!(OVERLAP.parse::<u64>(&id), None);
assert_eq!(TASK.key(&id), Some("delete-7"));
}
#[test]
fn parse_extracts_typed_keys() {
assert_eq!(TASK.parse::<u64>("task-7"), Some(7));
assert_eq!(TASK.parse::<usize>(&TASK.id(42)), Some(42));
}
#[test]
fn parse_returns_none_for_unparseable_or_foreign_keys() {
assert_eq!(TASK.parse::<u64>("task-nope"), None);
assert_eq!(TASK.parse::<u64>("task-"), None);
assert_eq!(TASK.parse::<u64>("task-1-2"), None);
assert_eq!(TASK.parse::<u64>("other-1"), None);
assert_eq!(TASK.parse::<u64>("task"), None);
}
}