jacquardSnapshot

← snapshot

4134 bytes
//! Trees: directories in the DAG.

use core::fmt;

use jac_core::{BlobId, TreeId};
use thiserror::Error;

/// A single path segment naming a tree entry.
///
/// Validated at construction: non-empty, no `/`, not `.` or `..`. The same
/// rules as [`jac_core::RepoPath`] segments, enforced here so a tree can
/// never contain a name that a path could not reach.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct Segment(String);

impl Segment {
    /// Parses and validates a segment.
    ///
    /// # Errors
    ///
    /// Returns [`TreeError::InvalidSegment`] for an empty segment, a segment
    /// containing `/`, or `.`/`..`.
    pub fn parse(s: &str) -> Result<Self, TreeError> {
        if s.is_empty() || s.contains('/') || s == "." || s == ".." {
            return Err(TreeError::InvalidSegment);
        }
        Ok(Self(s.to_owned()))
    }

    /// The segment as a string slice.
    #[must_use]
    pub fn as_str(&self) -> &str {
        &self.0
    }
}

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

/// What a tree entry points at.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum TreeNode {
    /// File content.
    Blob(BlobId),
    /// A subdirectory.
    Tree(TreeId),
}

/// One named entry in a tree.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TreeEntry {
    /// The entry's name within this directory.
    pub name: Segment,
    /// What the name points at.
    pub node: TreeNode,
}

/// A directory: named references to blobs and subtrees.
///
/// Entries are held sorted by name and duplicate-free — enforced by the only
/// constructor, so equal directories always hash equal. There is no `push`:
/// a tree is made whole or not at all, because an incrementally-mutated tree
/// is how two spellings of the same directory end up with two ids.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Tree {
    entries: Vec<TreeEntry>,
}

impl Tree {
    /// Builds a tree from entries, sorting by name.
    ///
    /// # Errors
    ///
    /// Returns [`TreeError::DuplicateName`] if two entries share a name.
    pub fn new(mut entries: Vec<TreeEntry>) -> Result<Self, TreeError> {
        entries.sort_by(|a, b| a.name.cmp(&b.name));
        for pair in entries.windows(2) {
            if pair[0].name == pair[1].name {
                return Err(TreeError::DuplicateName);
            }
        }
        Ok(Self { entries })
    }

    /// The empty tree.
    #[must_use]
    pub const fn empty() -> Self {
        Self {
            entries: Vec::new(),
        }
    }

    /// The entries, sorted by name.
    #[must_use]
    pub fn entries(&self) -> &[TreeEntry] {
        &self.entries
    }
}

/// Failures constructing tree values.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
#[non_exhaustive]
pub enum TreeError {
    /// A segment was empty, contained `/`, or was `.`/`..`.
    #[error("invalid tree entry name")]
    InvalidSegment,
    /// Two entries shared a name.
    #[error("duplicate name in tree")]
    DuplicateName,
}

#[cfg(test)]
#[expect(
    clippy::unwrap_used,
    reason = "test assertions read better with unwrap"
)]
mod tests {
    use jac_core::Digest;

    use super::*;

    fn blob_entry(name: &str) -> TreeEntry {
        TreeEntry {
            name: Segment::parse(name).unwrap(),
            node: TreeNode::Blob(BlobId::from_digest(Digest::of(name.as_bytes()))),
        }
    }

    #[test]
    fn entries_are_sorted_regardless_of_input_order() {
        let a = Tree::new(vec![blob_entry("b"), blob_entry("a")]).unwrap();
        let b = Tree::new(vec![blob_entry("a"), blob_entry("b")]).unwrap();
        assert_eq!(a, b);
    }

    #[test]
    fn duplicates_are_rejected() {
        assert_eq!(
            Tree::new(vec![blob_entry("a"), blob_entry("a")]),
            Err(TreeError::DuplicateName)
        );
    }

    #[test]
    fn bad_segments_are_rejected() {
        for bad in ["", "a/b", ".", ".."] {
            assert!(Segment::parse(bad).is_err(), "{bad:?} should be rejected");
        }
    }
}