heddle-object-model 0.28.13

Heddle's content-addressed object model and stable codecs.
Documentation
// SPDX-License-Identifier: Apache-2.0
//! Newtype wrappers for string identifiers that were previously bare
//! `String` / `&str`. The compiler enforces that a `ThreadName` cannot
//! be passed where a `MarkerName` is expected, catching mix-ups at
//! build time with zero runtime cost.
//!
//! Each type is `#[serde(transparent)]` so the on-disk / wire format
//! is byte-identical to a bare `String`. Existing oplog entries,
//! packed refs, and rmp-serde payloads decode unchanged.

use std::{fmt, hash::Hash};

use serde::{Deserialize, Serialize};

macro_rules! string_newtype {
    ($(#[$meta:meta])* $name:ident) => {
        $(#[$meta])*
        #[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
        #[serde(transparent)]
        pub struct $name(pub String);

        impl $name {
            pub fn new(s: impl Into<String>) -> Self {
                Self(s.into())
            }

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

            pub fn into_string(self) -> String {
                self.0
            }
        }

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

        impl AsRef<str> for $name {
            fn as_ref(&self) -> &str {
                &self.0
            }
        }

        impl std::ops::Deref for $name {
            type Target = str;
            fn deref(&self) -> &str {
                &self.0
            }
        }

        impl From<String> for $name {
            fn from(s: String) -> Self {
                Self(s)
            }
        }

        impl From<&str> for $name {
            fn from(s: &str) -> Self {
                Self(s.to_string())
            }
        }

        impl From<$name> for String {
            fn from(n: $name) -> String {
                n.0
            }
        }

        impl PartialEq<str> for $name {
            fn eq(&self, other: &str) -> bool {
                self.0 == other
            }
        }

        impl PartialEq<&str> for $name {
            fn eq(&self, other: &&str) -> bool {
                self.0 == *other
            }
        }

        impl PartialEq<String> for $name {
            fn eq(&self, other: &String) -> bool {
                self.0 == *other
            }
        }

        impl std::borrow::Borrow<str> for $name {
            fn borrow(&self) -> &str {
                &self.0
            }
        }
    };
}

string_newtype!(
    /// Name of a heddle thread (branch-like construct).
    ThreadName
);

string_newtype!(
    /// Name of a heddle marker (tag-like construct).
    MarkerName
);

/// First path segment reserved for Heddle-internal refs.
///
/// A user may still name a thread `heddle`, `heddlefoo`, or `my/heddle`.
/// Only a `heddle/`-rooted name is reserved (Invariant C).
pub const RESERVED_REF_SEGMENT: &str = "heddle";

/// True when `name` occupies the reserved `heddle/` namespace.
///
/// The check is case-insensitive on the first segment so a raw-Git branch
/// `Heddle/frontier/...` cannot slip past the reservation.
pub fn is_reserved_heddle_namespace(name: &str) -> bool {
    let mut parts = name.split('/');
    match (parts.next(), parts.next()) {
        (Some(first), Some(_)) => first.eq_ignore_ascii_case(RESERVED_REF_SEGMENT),
        _ => false,
    }
}

/// Rejection when a user thread or marker name occupies `heddle/`.
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[error(
    "ref name '{name}' is reserved: the heddle/ namespace is internal and cannot be a user thread or marker"
)]
pub struct ReservedRefNameError {
    pub name: String,
}

impl ThreadName {
    /// Fallible constructor for user or imported names. Rejects the reserved
    /// `heddle/` namespace. Trusted reconstruction of already-stored names
    /// still uses [`ThreadName::new`].
    pub fn try_new(s: impl Into<String>) -> Result<Self, ReservedRefNameError> {
        let name = s.into();
        if is_reserved_heddle_namespace(&name) {
            return Err(ReservedRefNameError { name });
        }
        Ok(Self(name))
    }
}

impl MarkerName {
    /// Fallible constructor for user or imported names. Rejects the reserved
    /// `heddle/` namespace. Trusted reconstruction of already-stored names
    /// still uses [`MarkerName::new`].
    pub fn try_new(s: impl Into<String>) -> Result<Self, ReservedRefNameError> {
        let name = s.into();
        if is_reserved_heddle_namespace(&name) {
            return Err(ReservedRefNameError { name });
        }
        Ok(Self(name))
    }
}

/// Invalid external Git ref syntax or the signed full-ref resource limit.
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[error("invalid Git ref name: {0}")]
pub struct GitRefNameError(pub String);

impl ThreadName {
    /// Validate a short Git branch and map only native namespace collisions.
    pub fn from_git_branch(name: &str) -> Result<Self, GitRefNameError> {
        if name == "HEAD"
            || name.len() + "refs/heads/".len() > 1024
            || sley_refs::BranchRefNameBuf::from_branch_name(name).is_err()
        {
            return Err(GitRefNameError(name.to_owned()));
        }
        Ok(Self(crate::name_encoding::native_git_name(name)))
    }
}

impl MarkerName {
    /// Validate an external tag in its full namespace before native mapping.
    pub fn from_git_tag(name: &str) -> Result<Self, GitRefNameError> {
        let full = format!("refs/tags/{name}");
        if full.len() > 1024 || sley_refs::check_refname_format(&full, false).is_err() {
            return Err(GitRefNameError(full));
        }
        Ok(Self(crate::name_encoding::native_git_name(name)))
    }
}

string_newtype!(
    /// Checkout/lane scope identifier for scoped operations.
    Scope
);

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

    #[test]
    fn git_branch_admission_matches_git_and_rejects_invalid_names() {
        for name in [
            "feat/mcp=timeout",
            "a,b",
            "ünicode/ブランチ",
            "@",
            "x+y",
            "trailing\u{a0}",
            "literal\u{fffd}",
            "heddle/foo",
            &"界".repeat(337),
        ] {
            let output = std::process::Command::new("git")
                .args(["check-ref-format", "--branch", name])
                .output()
                .expect("Git oracle");
            assert!(output.status.success(), "Git rejects {name:?}");
            let native = ThreadName::from_git_branch(name).expect("Sley branch");
            assert_eq!(crate::name_encoding::git_name(&native), name);
        }
        for name in [
            "a..b",
            "a@{b",
            "a.lock",
            "a.lock/b",
            "a\n",
            "-flag",
            "HEAD",
            ".",
            "team:scope",
            "a~b",
            "a^b",
            "a?b",
            "a*b",
            "a[b",
            "a\\b",
            "a//b",
            "a/",
            "a.",
            "a\x7f",
        ] {
            assert!(
                ThreadName::from_git_branch(name).is_err(),
                "accepted {name:?}"
            );
        }
        assert!(ThreadName::from_git_branch(&"界".repeat(338)).is_err());
    }

    #[test]
    fn thread_name_display() {
        let t = ThreadName::new("main");
        assert_eq!(t.0, "main");
        assert_eq!(t.0, "main");
        assert_eq!(&*t, "main");
    }

    #[test]
    fn serde_transparent_roundtrip() {
        let t = ThreadName::new("feature/foo");
        let json = serde_json::to_string(&t).unwrap();
        assert_eq!(json, "\"feature/foo\"");
        let back: ThreadName = serde_json::from_str(&json).unwrap();
        assert_eq!(back, t);
    }

    #[test]
    fn marker_name_distinct_from_thread_name() {
        let _t: ThreadName = "main".into();
        let _m: MarkerName = "v1.0".into();
        // These are different types — the compiler prevents mixing them.
    }

    #[test]
    fn comparison_with_str() {
        let t = ThreadName::from("main");
        assert!(t == "main");
        assert!(t == *"main");
        // Bind the owned value so the `PartialEq<String>` impl is still
        // exercised without triggering `clippy::cmp_owned`.
        let owned = String::from("main");
        assert!(t == owned);
    }

    #[test]
    fn borrow_for_hashmap_lookup() {
        use std::collections::HashMap;
        let mut map = HashMap::new();
        map.insert(ThreadName::new("main"), 1);
        assert_eq!(map.get("main"), Some(&1));
    }

    #[test]
    fn reserved_namespace_is_heddle_rooted_only() {
        assert!(!is_reserved_heddle_namespace("heddle"));
        assert!(!is_reserved_heddle_namespace("heddlefoo"));
        assert!(!is_reserved_heddle_namespace("my/heddle"));
        assert!(!is_reserved_heddle_namespace("main@review"));
        assert!(is_reserved_heddle_namespace("heddle/frontier/main/hc-abc"));
        assert!(is_reserved_heddle_namespace("Heddle/x"));
    }

    #[test]
    fn try_new_rejects_reserved_thread_and_marker_names() {
        assert!(ThreadName::try_new("heddle/frontier/main/hc-1").is_err());
        assert!(MarkerName::try_new("heddle/notes").is_err());
        assert_eq!(ThreadName::try_new("heddle").unwrap().as_str(), "heddle");
        assert_eq!(
            ThreadName::try_new("main@hd-abc").unwrap().as_str(),
            "main@hd-abc"
        );
    }
}