diffo 0.2.0

Semantic diffing for Rust structs via serde
Documentation
//! Path representation for nested structure navigation.

use std::fmt;

/// Represents a path to a field in a nested structure.
///
/// Examples: `"root"`, `"user.name"`, `"items[3].id"`, `"config.db.host"`
///
/// # Examples
///
/// ```
/// use diffo::Path;
///
/// let root = Path::root();
/// let user_name = root.field("user").field("name");
/// assert_eq!(user_name.as_str(), "user.name");
///
/// let item_id = root.field("items").index(3).field("id");
/// assert_eq!(item_id.as_str(), "items[3].id");
/// ```
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct Path(String);

impl Path {
    /// Create a root path.
    ///
    /// # Examples
    ///
    /// ```
    /// use diffo::Path;
    ///
    /// let root = Path::root();
    /// assert_eq!(root.as_str(), "");
    /// ```
    pub fn root() -> Self {
        Path(String::new())
    }

    /// Append a field name to the path.
    ///
    /// # Examples
    ///
    /// ```
    /// use diffo::Path;
    ///
    /// let path = Path::root().field("user").field("name");
    /// assert_eq!(path.as_str(), "user.name");
    /// ```
    pub fn field(&self, name: &str) -> Self {
        if self.0.is_empty() {
            Path(name.to_string())
        } else {
            let mut s = String::with_capacity(self.0.len() + 1 + name.len());
            s.push_str(&self.0);
            s.push('.');
            s.push_str(name);
            Path(s)
        }
    }

    /// Append an array index to the path.
    ///
    /// # Examples
    ///
    /// ```
    /// use diffo::Path;
    ///
    /// let path = Path::root().field("items").index(3);
    /// assert_eq!(path.as_str(), "items[3]");
    /// ```
    pub fn index(&self, idx: usize) -> Self {
        use std::fmt::Write;
        let mut s = String::with_capacity(self.0.len() + 10);
        s.push_str(&self.0);
        s.push('[');
        write!(&mut s, "{}", idx).unwrap();
        s.push(']');
        Path(s)
    }

    /// Get the path as a string slice.
    ///
    /// # Examples
    ///
    /// ```
    /// use diffo::Path;
    ///
    /// let path = Path::root().field("user");
    /// assert_eq!(path.as_str(), "user");
    /// ```
    pub fn as_str(&self) -> &str {
        &self.0
    }

    /// Convert path to JSON Pointer format (RFC 6901).
    ///
    /// # Examples
    ///
    /// ```
    /// use diffo::Path;
    ///
    /// let path = Path::root().field("user").field("name");
    /// assert_eq!(path.to_json_pointer(), "/user/name");
    ///
    /// let indexed = Path::root().field("items").index(0);
    /// assert_eq!(indexed.to_json_pointer(), "/items/0");
    /// ```
    pub fn to_json_pointer(&self) -> String {
        if self.0.is_empty() {
            return String::from("");
        }

        let mut result = String::from("/");
        let mut chars = self.0.chars().peekable();

        while let Some(ch) = chars.next() {
            match ch {
                '.' => result.push('/'),
                '[' => {
                    result.push('/');
                    // Skip until ']'
                    for c in chars.by_ref() {
                        if c == ']' {
                            break;
                        }
                        result.push(c);
                    }
                }
                _ => result.push(ch),
            }
        }

        result
    }

    /// Calculate the depth of the path (number of components).
    ///
    /// # Examples
    ///
    /// ```
    /// use diffo::Path;
    ///
    /// assert_eq!(Path::root().depth(), 0);
    /// assert_eq!(Path::root().field("user").depth(), 1);
    /// assert_eq!(Path::root().field("user").field("name").depth(), 2);
    /// assert_eq!(Path::root().field("items").index(0).depth(), 2);
    /// ```
    pub fn depth(&self) -> usize {
        if self.0.is_empty() {
            return 0;
        }

        let mut depth = 1;
        for ch in self.0.chars() {
            if ch == '.' || ch == '[' {
                depth += 1;
            }
        }
        depth
    }

    /// Extract the last array index from the path, if it ends with one.
    ///
    /// # Examples
    ///
    /// ```
    /// use diffo::Path;
    ///
    /// assert_eq!(Path::root().field("items").index(5).last_index(), Some(5));
    /// assert_eq!(Path::root().field("items").index(5).field("name").last_index(), None);
    /// assert_eq!(Path::root().field("user").last_index(), None);
    /// ```
    pub fn last_index(&self) -> Option<usize> {
        if self.0.is_empty() {
            return None;
        }

        if !self.0.ends_with(']') {
            return None;
        }

        if let Some(start) = self.0.rfind('[') {
            self.0[start + 1..self.0.len() - 1].parse().ok()
        } else {
            None
        }
    }
}

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

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

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

    #[test]
    fn test_root() {
        let root = Path::root();
        assert_eq!(root.as_str(), "");
        assert_eq!(root.to_string(), "");
    }

    #[test]
    fn test_single_field() {
        let path = Path::root().field("user");
        assert_eq!(path.as_str(), "user");
        assert_eq!(path.to_string(), "user");
    }

    #[test]
    fn test_nested_fields() {
        let path = Path::root().field("user").field("profile").field("name");
        assert_eq!(path.as_str(), "user.profile.name");
    }

    #[test]
    fn test_array_index() {
        let path = Path::root().field("items").index(3);
        assert_eq!(path.as_str(), "items[3]");
    }

    #[test]
    fn test_nested_array_index() {
        let path = Path::root().field("items").index(0).field("id");
        assert_eq!(path.as_str(), "items[0].id");
    }

    #[test]
    fn test_multiple_indices() {
        let path = Path::root().field("matrix").index(1).index(2);
        assert_eq!(path.as_str(), "matrix[1][2]");
    }

    #[test]
    fn test_json_pointer_simple() {
        let path = Path::root().field("user").field("name");
        assert_eq!(path.to_json_pointer(), "/user/name");
    }

    #[test]
    fn test_json_pointer_with_index() {
        let path = Path::root().field("items").index(0).field("value");
        assert_eq!(path.to_json_pointer(), "/items/0/value");
    }

    #[test]
    fn test_json_pointer_root() {
        let path = Path::root();
        assert_eq!(path.to_json_pointer(), "");
    }

    #[test]
    fn test_depth_root() {
        assert_eq!(Path::root().depth(), 0);
    }

    #[test]
    fn test_depth_single() {
        assert_eq!(Path::root().field("user").depth(), 1);
    }

    #[test]
    fn test_depth_nested() {
        assert_eq!(Path::root().field("a").field("b").field("c").depth(), 3);
    }

    #[test]
    fn test_depth_with_index() {
        assert_eq!(Path::root().field("items").index(0).depth(), 2);
    }

    #[test]
    fn test_ordering() {
        let p1 = Path::root().field("a");
        let p2 = Path::root().field("b");
        let p3 = Path::root().field("a").field("c");

        assert!(p1 < p2);
        assert!(p1 < p3);
        assert!(p3 < p2); // "a.c" < "b"
    }
}