cartulary 0.3.0-alpha.1

The knowledge layer of your project — decisions, issues, docs, all in one place.
Documentation
/// The origin of an issue — where it was created or imported from.
///
/// Format: `system:locator` where:
/// - `system` is a non-empty lowercase alphanumeric identifier (e.g. `local`, `github`, `gitlab`, `jira`)
/// - `locator` is a non-empty free-form string (e.g. `ISSUE-0001`, `owner/repo#42`, `PROJ-123`)
///
/// The colon is the mandatory separator between system and locator.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Tracker {
    raw: String,
    /// Byte offset of the colon separator — system is `&raw[..sep]`, locator is `&raw[sep+1..]`.
    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,
        })
    }

    /// Build a local tracker for an issue created by cartulary (e.g. `local:ISSUE-0001`).
    pub fn local(id: &str) -> Self {
        Tracker {
            raw: format!("local:{id}"),
            sep: 5, // "local".len()
        }
    }

    /// The system identifier (e.g. `"local"`, `"github"`, `"gitlab"`, `"jira"`).
    pub fn system(&self) -> &str {
        &self.raw[..self.sep]
    }

    /// The system-specific locator (e.g. `"ISSUE-0001"`, `"owner/repo#42"`).
    pub fn locator(&self) -> &str {
        &self.raw[self.sep + 1..]
    }

    pub fn as_str(&self) -> &str {
        &self.raw
    }

    /// Return `true` if this tracker is local (created by cartulary).
    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(':'));
        }
    }
}