#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Tracker {
raw: String,
sep: usize,
}
impl Tracker {
pub fn new(s: &str) -> anyhow::Result<Self> {
let trimmed = s.trim();
let sep = trimmed
.find(':')
.ok_or_else(|| anyhow::anyhow!("tracker '{trimmed}' must contain ':'"))?;
let system = &trimmed[..sep];
let locator = &trimmed[sep + 1..];
if system.is_empty() {
anyhow::bail!("tracker system part cannot be empty");
}
if !system
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit())
{
anyhow::bail!("tracker system '{system}' must be lowercase alphanumeric");
}
if locator.is_empty() {
anyhow::bail!("tracker locator part cannot be empty");
}
Ok(Tracker {
raw: trimmed.to_owned(),
sep,
})
}
pub fn local(id: &str) -> Self {
Tracker {
raw: format!("local:{id}"),
sep: 5, }
}
pub fn system(&self) -> &str {
&self.raw[..self.sep]
}
pub fn locator(&self) -> &str {
&self.raw[self.sep + 1..]
}
pub fn as_str(&self) -> &str {
&self.raw
}
pub fn is_local(&self) -> bool {
self.system() == "local"
}
}
impl std::fmt::Display for Tracker {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.raw)
}
}
impl std::str::FromStr for Tracker {
type Err = anyhow::Error;
fn from_str(s: &str) -> anyhow::Result<Self> {
Tracker::new(s)
}
}
impl serde::Serialize for Tracker {
fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
s.serialize_str(&self.raw)
}
}
#[cfg(test)]
pub mod strategy {
use super::*;
use proptest::prelude::*;
pub fn tracker() -> impl Strategy<Value = Tracker> {
("[a-z]{2,10}", "[a-zA-Z0-9/_#.-]{1,30}")
.prop_map(|(system, locator)| Tracker::new(&format!("{system}:{locator}")).unwrap())
}
}
#[cfg(test)]
mod tests {
use super::*;
use proptest::prelude::*;
#[test]
fn local_creates_a_local_tracker() {
let s = Tracker::local("ISSUE-0001");
assert_eq!(s.system(), "local");
assert_eq!(s.locator(), "ISSUE-0001");
assert!(s.is_local());
}
#[test]
fn accepts_github_tracker() {
let s = Tracker::new("github:owner/repo#42").unwrap();
assert_eq!(s.system(), "github");
assert_eq!(s.locator(), "owner/repo#42");
assert!(!s.is_local());
}
#[test]
fn accepts_gitlab_tracker() {
let s = Tracker::new("gitlab:group/project#17").unwrap();
assert_eq!(s.system(), "gitlab");
assert_eq!(s.locator(), "group/project#17");
}
#[test]
fn accepts_jira_tracker() {
let s = Tracker::new("jira:PROJ-123").unwrap();
assert_eq!(s.system(), "jira");
assert_eq!(s.locator(), "PROJ-123");
}
#[test]
fn rejects_empty() {
assert!(Tracker::new("").is_err());
}
#[test]
fn rejects_no_colon() {
assert!(Tracker::new("github").is_err());
}
#[test]
fn rejects_empty_system() {
assert!(Tracker::new(":foo").is_err());
}
#[test]
fn rejects_empty_locator() {
assert!(Tracker::new("github:").is_err());
}
#[test]
fn rejects_uppercase_system() {
assert!(Tracker::new("GitHub:foo").is_err());
}
#[test]
fn rejects_whitespace_in_system() {
assert!(Tracker::new("git hub:foo").is_err());
}
#[test]
fn trims_surrounding_whitespace() {
let s = Tracker::new(" github:foo ").unwrap();
assert_eq!(s.system(), "github");
assert_eq!(s.locator(), "foo");
}
#[test]
fn display_matches_as_str() {
let s = Tracker::new("github:owner/repo#42").unwrap();
assert_eq!(s.to_string(), "github:owner/repo#42");
}
#[test]
fn from_str_roundtrips() {
let s: Tracker = "jira:PROJ-123".parse().unwrap();
assert_eq!(s.as_str(), "jira:PROJ-123");
}
#[test]
fn locator_can_contain_colons() {
let s = Tracker::new("custom:some:complex:ref").unwrap();
assert_eq!(s.system(), "custom");
assert_eq!(s.locator(), "some:complex:ref");
}
proptest! {
#[test]
fn prop_clone_equals_original(s in strategy::tracker()) {
prop_assert_eq!(s.clone(), s);
}
#[test]
fn prop_system_is_lowercase_alphanum(s in strategy::tracker()) {
prop_assert!(s.system().chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit()));
}
#[test]
fn prop_locator_is_non_empty(s in strategy::tracker()) {
prop_assert!(!s.locator().is_empty());
}
#[test]
fn prop_as_str_contains_colon(s in strategy::tracker()) {
prop_assert!(s.as_str().contains(':'));
}
}
}