cartulary 0.3.0-alpha.1

The knowledge layer of your project — decisions, issues, docs, all in one place.
Documentation
use std::fmt;

use crate::domain::model::is_valid_kebab_lowercase;

/// Identifier of a query. Doubles as the file-stem on disk
/// (`<name>.cozo`) and as the addressable label on the CLI surface.
///
/// Grammar: `[a-z0-9][a-z0-9-]*` — same as [`Status`] and [`RecordKind`],
/// so file-system portability and CLI ergonomics come for free (no
/// spaces, no path separators, no case folding).
///
/// [`Status`]: super::super::status::Status
/// [`RecordKind`]: super::super::record_kind::RecordKind
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct QueryIdentifier(String);

impl QueryIdentifier {
    pub fn new(s: &str) -> anyhow::Result<Self> {
        if is_valid_kebab_lowercase(s) {
            Ok(Self(s.to_owned()))
        } else {
            anyhow::bail!("invalid query name '{s}': must match [a-z0-9][a-z0-9-]*")
        }
    }

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

impl fmt::Display for QueryIdentifier {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.write_str(&self.0)
    }
}

#[cfg(test)]
pub mod strategy {
    use super::QueryIdentifier;
    use proptest::prelude::*;

    /// Generate a valid kebab-lowercase identifier.
    pub fn query_identifier() -> impl Strategy<Value = QueryIdentifier> {
        "[a-z0-9][a-z0-9-]{0,30}".prop_map(|s| QueryIdentifier::new(&s).unwrap())
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use proptest::prelude::*;

    proptest! {
        #[test]
        fn as_str_roundtrips_through_new(id in strategy::query_identifier()) {
            let s = id.as_str().to_string();
            let again = QueryIdentifier::new(&s).unwrap();
            prop_assert_eq!(again.as_str().to_string(), s);
        }
    }

    #[test]
    fn accepts_kebab_lowercase() {
        assert_eq!(
            QueryIdentifier::new("count-issues").unwrap().as_str(),
            "count-issues"
        );
        assert_eq!(QueryIdentifier::new("a").unwrap().as_str(), "a");
        assert_eq!(
            QueryIdentifier::new("0-leading-digit").unwrap().as_str(),
            "0-leading-digit"
        );
    }

    #[test]
    fn rejects_empty() {
        assert!(QueryIdentifier::new("").is_err());
    }

    #[test]
    fn rejects_uppercase_and_separators() {
        assert!(QueryIdentifier::new("CountIssues").is_err());
        assert!(QueryIdentifier::new("count_issues").is_err());
        assert!(QueryIdentifier::new("count issues").is_err());
        assert!(QueryIdentifier::new("-leading-hyphen").is_err());
    }
}