Skip to main content

suture_core/engine/
tree.rs

1//! FileTree — a virtual filesystem snapshot.
2//!
3//! A FileTree maps relative paths to CAS blob hashes. It represents the
4//! state of a project at a particular point in the patch DAG. FileTrees
5//! are immutable snapshots — applying a patch produces a new tree.
6
7use std::collections::BTreeMap;
8use suture_common::Hash;
9
10/// A virtual filesystem snapshot: path → CAS blob hash.
11///
12/// Uses BTreeMap for deterministic ordering (important for hashing and diffs).
13#[derive(Clone, Debug, PartialEq, Eq)]
14pub struct FileTree {
15    /// Map of relative path (e.g., "src/main.rs") to BLAKE3 blob hash.
16    entries: BTreeMap<String, Hash>,
17}
18
19impl FileTree {
20    /// Create an empty file tree.
21    pub fn empty() -> Self {
22        Self {
23            entries: BTreeMap::new(),
24        }
25    }
26
27    /// Create a file tree from a pre-populated map.
28    pub fn from_map(entries: BTreeMap<String, Hash>) -> Self {
29        Self { entries }
30    }
31
32    /// Get the blob hash for a given path.
33    pub fn get(&self, path: &str) -> Option<&Hash> {
34        self.entries.get(path)
35    }
36
37    /// Check if a path exists in the tree.
38    pub fn contains(&self, path: &str) -> bool {
39        self.entries.contains_key(path)
40    }
41
42    /// Insert or update a path → hash mapping.
43    pub fn insert(&mut self, path: String, hash: Hash) {
44        self.entries.insert(path, hash);
45    }
46
47    /// Remove a path from the tree.
48    pub fn remove(&mut self, path: &str) -> Option<Hash> {
49        self.entries.remove(path)
50    }
51
52    /// Rename a path in the tree.
53    pub fn rename(&mut self, old_path: &str, new_path: String) -> Option<Hash> {
54        if let Some(hash) = self.entries.remove(old_path) {
55            self.entries.insert(new_path, hash);
56            Some(hash)
57        } else {
58            None
59        }
60    }
61
62    /// Get the number of files in the tree.
63    pub fn len(&self) -> usize {
64        self.entries.len()
65    }
66
67    /// Check if the tree is empty.
68    pub fn is_empty(&self) -> bool {
69        self.entries.is_empty()
70    }
71
72    /// Iterate over all (path, hash) entries in sorted order.
73    pub fn iter(&self) -> impl Iterator<Item = (&String, &Hash)> {
74        self.entries.iter()
75    }
76
77    /// Get all paths in the tree (sorted).
78    pub fn paths(&self) -> Vec<&String> {
79        self.entries.keys().collect()
80    }
81
82    /// Compute a BLAKE3 hash of the entire tree state.
83    ///
84    /// This provides a content-addressed identifier for a snapshot.
85    pub fn content_hash(&self) -> Hash {
86        let mut hasher = blake3::Hasher::new();
87        for (path, hash) in &self.entries {
88            hasher.update(path.as_bytes());
89            hasher.update(b"\0");
90            hasher.update(&hash.0);
91            hasher.update(b"\0");
92        }
93        Hash::from(*hasher.finalize().as_bytes())
94    }
95}
96
97impl Default for FileTree {
98    fn default() -> Self {
99        Self::empty()
100    }
101}
102
103#[cfg(test)]
104mod tests {
105    use super::*;
106
107    #[test]
108    fn test_empty_tree() {
109        let tree = FileTree::empty();
110        assert!(tree.is_empty());
111        assert_eq!(tree.len(), 0);
112    }
113
114    #[test]
115    fn test_insert_and_get() {
116        let mut tree = FileTree::empty();
117        let hash = Hash::from_data(b"hello");
118        tree.insert("src/main.rs".to_string(), hash);
119        assert_eq!(tree.len(), 1);
120        assert!(tree.contains("src/main.rs"));
121        assert_eq!(tree.get("src/main.rs"), Some(&hash));
122    }
123
124    #[test]
125    fn test_remove() {
126        let mut tree = FileTree::empty();
127        tree.insert("file.txt".to_string(), Hash::from_data(b"data"));
128        assert!(tree.contains("file.txt"));
129        tree.remove("file.txt");
130        assert!(!tree.contains("file.txt"));
131        assert!(tree.is_empty());
132    }
133
134    #[test]
135    fn test_rename() {
136        let mut tree = FileTree::empty();
137        let hash = Hash::from_data(b"data");
138        tree.insert("old.txt".to_string(), hash);
139        tree.rename("old.txt", "new.txt".to_string());
140        assert!(!tree.contains("old.txt"));
141        assert!(tree.contains("new.txt"));
142        assert_eq!(tree.get("new.txt"), Some(&hash));
143    }
144
145    #[test]
146    fn test_content_hash_deterministic() {
147        let mut tree1 = FileTree::empty();
148        let mut tree2 = FileTree::empty();
149        let h1 = Hash::from_data(b"file1");
150        let h2 = Hash::from_data(b"file2");
151        tree1.insert("a.txt".to_string(), h1);
152        tree1.insert("b.txt".to_string(), h2);
153        tree2.insert("a.txt".to_string(), h1);
154        tree2.insert("b.txt".to_string(), h2);
155        assert_eq!(tree1.content_hash(), tree2.content_hash());
156    }
157
158    #[test]
159    fn test_content_hash_different() {
160        let mut tree1 = FileTree::empty();
161        let mut tree2 = FileTree::empty();
162        tree1.insert("a.txt".to_string(), Hash::from_data(b"v1"));
163        tree2.insert("a.txt".to_string(), Hash::from_data(b"v2"));
164        assert_ne!(tree1.content_hash(), tree2.content_hash());
165    }
166
167    #[test]
168    fn test_paths_sorted() {
169        let mut tree = FileTree::empty();
170        tree.insert("z.txt".to_string(), Hash::ZERO);
171        tree.insert("a.txt".to_string(), Hash::ZERO);
172        tree.insert("m.txt".to_string(), Hash::ZERO);
173        let paths = tree.paths();
174        assert_eq!(paths[0], &"a.txt".to_string());
175        assert_eq!(paths[1], &"m.txt".to_string());
176        assert_eq!(paths[2], &"z.txt".to_string());
177    }
178
179    mod proptests {
180        use super::*;
181        use proptest::prelude::*;
182        use suture_common::Hash;
183
184        fn valid_path() -> impl Strategy<Value = String> {
185            proptest::string::string_regex("[a-zA-Z0-9_/:-]{1,100}").unwrap()
186        }
187
188        fn hash_bytes_strategy() -> impl Strategy<Value = [u8; 32]> {
189            proptest::array::uniform32(proptest::num::u8::ANY)
190        }
191
192        proptest! {
193            #[test]
194            fn insert_then_contains(path in valid_path(), hash_bytes in hash_bytes_strategy()) {
195                let mut tree = FileTree::empty();
196                let hash = Hash::from(hash_bytes);
197                tree.insert(path.clone(), hash);
198                prop_assert!(tree.contains(&path));
199                prop_assert_eq!(tree.get(&path), Some(&hash));
200            }
201
202            #[test]
203            fn remove_then_not_contains(path in valid_path(), hash_bytes in hash_bytes_strategy()) {
204                let mut tree = FileTree::empty();
205                let hash = Hash::from(hash_bytes);
206                tree.insert(path.clone(), hash);
207                tree.remove(&path);
208                prop_assert!(!tree.contains(&path));
209                prop_assert_eq!(tree.get(&path), None);
210            }
211
212            #[test]
213            fn insert_remove_insert(path in valid_path(), hash_bytes in hash_bytes_strategy()) {
214                let mut tree = FileTree::empty();
215                let hash = Hash::from(hash_bytes);
216                tree.insert(path.clone(), hash);
217                tree.remove(&path);
218                prop_assert!(!tree.contains(&path));
219                tree.insert(path.clone(), hash);
220                prop_assert!(tree.contains(&path));
221                prop_assert_eq!(tree.get(&path), Some(&hash));
222            }
223
224            #[test]
225            fn rename(
226                old_path in valid_path(),
227                new_path in valid_path(),
228                hash_bytes in hash_bytes_strategy()
229            ) {
230                prop_assume!(old_path != new_path);
231                let mut tree = FileTree::empty();
232                let hash = Hash::from(hash_bytes);
233                tree.insert(old_path.clone(), hash);
234                tree.rename(&old_path, new_path.clone());
235                prop_assert!(!tree.contains(&old_path));
236                prop_assert!(tree.contains(&new_path));
237                prop_assert_eq!(tree.get(&new_path), Some(&hash));
238            }
239
240            #[test]
241            fn trees_equal_self(entries in proptest::collection::btree_map(valid_path(), hash_bytes_strategy().prop_map(Hash::from), 0..20)) {
242                let tree = FileTree::from_map(entries);
243                prop_assert_eq!(&tree, &tree);
244            }
245
246            #[test]
247            fn trees_equal_symmetry(entries in proptest::collection::btree_map(valid_path(), hash_bytes_strategy().prop_map(Hash::from), 0..20)) {
248                let tree1 = FileTree::from_map(entries.clone());
249                let tree2 = FileTree::from_map(entries);
250                prop_assert_eq!(tree1, tree2);
251            }
252        }
253    }
254}