Skip to main content

heddle_object_model/object/
tree_path.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Typed tree path resolution with per-caller leaf policies.
3
4use std::{
5    borrow::Cow,
6    path::{Component, Path},
7};
8
9#[cfg(feature = "async-source")]
10use super::AsyncObjectSource;
11use super::{Blob, ContentHash, ObjectSource, Tree, TreeEntry};
12use crate::error::HeddleError;
13
14/// How a tree-path walk classifies and materializes the terminal entry.
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum LeafPolicy {
17    /// Return the terminal [`TreeEntry`] regardless of entry type (provenance).
18    Entry,
19    /// Return the blob content hash at the terminal path; symlinks are excluded (redact).
20    BlobOnly,
21    /// Load the terminal blob via [`TreeEntry::leaf_content_hash`], including symlinks
22    /// (staleness).
23    LeafContentBlob,
24}
25
26/// Successful resolution of a path within a tree.
27#[derive(Debug, Clone, PartialEq, Eq)]
28pub struct ResolvedTreeTarget {
29    pub entry: TreeEntry,
30    pub content_hash: Option<ContentHash>,
31    pub blob: Option<Blob>,
32}
33
34/// Errors surfaced by [`resolve_tree_path`] that callers map to their own messages.
35#[derive(Debug)]
36pub enum TreePathResolveError {
37    Store {
38        hash: ContentHash,
39        source: Box<HeddleError>,
40    },
41    SubtreeMissing(ContentHash),
42}
43
44impl std::error::Error for TreePathResolveError {
45    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
46        match self {
47            TreePathResolveError::Store { source, .. } => Some(source.as_ref()),
48            TreePathResolveError::SubtreeMissing(_) => None,
49        }
50    }
51}
52
53impl From<TreePathResolveError> for HeddleError {
54    fn from(value: TreePathResolveError) -> Self {
55        match value {
56            TreePathResolveError::Store { source, .. } => *source,
57            TreePathResolveError::SubtreeMissing(hash) => {
58                HeddleError::InvalidObject(format!("subtree {} missing from store", hash.short()))
59            }
60        }
61    }
62}
63
64impl std::fmt::Display for TreePathResolveError {
65    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
66        match self {
67            TreePathResolveError::Store { hash, .. } => {
68                write!(f, "failed to load tree {}", hash.short())
69            }
70            TreePathResolveError::SubtreeMissing(hash) => {
71                write!(f, "subtree {} missing from store", hash.short())
72            }
73        }
74    }
75}
76
77/// Split a repository-relative path into its first component and the remainder.
78pub fn split_path(path: &Path) -> Option<(&str, &Path)> {
79    let mut components = path.components();
80    let first = components.next()?;
81    let Component::Normal(name) = first else {
82        return None;
83    };
84    Some((name.to_str()?, components.as_path()))
85}
86
87/// Walk `path` from `root` through nested subtrees and resolve the terminal entry
88/// according to `policy`.
89///
90/// `Ok(None)` means the path is absent or terminates at the wrong entry type for the
91/// policy. Store failures and missing subtrees are policy-dependent; see
92/// [`TreePathResolveError`].
93pub fn resolve_tree_path<S: ObjectSource>(
94    store: &S,
95    root: &ContentHash,
96    path: &Path,
97    policy: LeafPolicy,
98) -> std::result::Result<Option<ResolvedTreeTarget>, TreePathResolveError> {
99    let Some(segments) = segments_for_policy(path, policy) else {
100        return Ok(None);
101    };
102    if segments.is_empty() {
103        return Ok(None);
104    }
105
106    let Some(tree) = load_subtree(store, root, policy)? else {
107        return Ok(None);
108    };
109    resolve_from_tree(store, &tree, &segments, policy)
110}
111
112#[cfg(feature = "async-source")]
113pub async fn resolve_tree_path_async<S: AsyncObjectSource + ?Sized>(
114    store: &S,
115    root: &ContentHash,
116    path: &Path,
117    policy: LeafPolicy,
118) -> std::result::Result<Option<ResolvedTreeTarget>, TreePathResolveError> {
119    let Some(segments) = segments_for_policy(path, policy) else {
120        return Ok(None);
121    };
122    if segments.is_empty() {
123        return Ok(None);
124    }
125
126    let Some(tree) = load_subtree_async(store, root, policy).await? else {
127        return Ok(None);
128    };
129    resolve_from_tree_async(store, &tree, &segments, policy).await
130}
131
132fn segments_for_policy(path: &Path, policy: LeafPolicy) -> Option<Vec<String>> {
133    match policy {
134        LeafPolicy::Entry => path_segments(path),
135        LeafPolicy::BlobOnly => {
136            let path_str = path.to_str()?;
137            Some(
138                path_str
139                    .split('/')
140                    .filter(|part| !part.is_empty())
141                    .map(str::to_string)
142                    .collect(),
143            )
144        }
145        LeafPolicy::LeafContentBlob => Some(
146            path.to_string_lossy()
147                .split('/')
148                .map(str::to_string)
149                .collect(),
150        ),
151    }
152}
153
154fn path_segments(path: &Path) -> Option<Vec<String>> {
155    if path.as_os_str().is_empty() {
156        return None;
157    }
158    let mut segments = Vec::new();
159    for component in path.components() {
160        match component {
161            Component::Normal(name) => segments.push(name.to_str()?.to_string()),
162            _ => return None,
163        }
164    }
165    if segments.is_empty() {
166        return None;
167    }
168    Some(segments)
169}
170
171fn resolve_from_tree<S: ObjectSource>(
172    store: &S,
173    tree: &Tree,
174    segments: &[String],
175    policy: LeafPolicy,
176) -> std::result::Result<Option<ResolvedTreeTarget>, TreePathResolveError> {
177    let mut current_tree = Cow::Borrowed(tree);
178
179    for (index, segment) in segments.iter().enumerate() {
180        let Some(entry) = current_tree.get(segment) else {
181            return Ok(None);
182        };
183
184        if index + 1 == segments.len() {
185            return resolve_leaf(store, entry.clone(), policy);
186        }
187
188        let Some(tree_hash) = entry.tree_hash() else {
189            return Ok(None);
190        };
191        let Some(subtree) = load_subtree(store, &tree_hash, policy)? else {
192            return Ok(None);
193        };
194        current_tree = Cow::Owned(subtree);
195    }
196
197    Ok(None)
198}
199
200#[cfg(feature = "async-source")]
201async fn resolve_from_tree_async<S: AsyncObjectSource + ?Sized>(
202    store: &S,
203    tree: &Tree,
204    segments: &[String],
205    policy: LeafPolicy,
206) -> std::result::Result<Option<ResolvedTreeTarget>, TreePathResolveError> {
207    let mut current_tree = Cow::Borrowed(tree);
208
209    for (index, segment) in segments.iter().enumerate() {
210        let Some(entry) = current_tree.get(segment) else {
211            return Ok(None);
212        };
213
214        if index + 1 == segments.len() {
215            return resolve_leaf_async(store, entry.clone(), policy).await;
216        }
217
218        let Some(tree_hash) = entry.tree_hash() else {
219            return Ok(None);
220        };
221        let Some(subtree) = load_subtree_async(store, &tree_hash, policy).await? else {
222            return Ok(None);
223        };
224        current_tree = Cow::Owned(subtree);
225    }
226
227    Ok(None)
228}
229
230fn resolve_leaf<S: ObjectSource>(
231    store: &S,
232    entry: TreeEntry,
233    policy: LeafPolicy,
234) -> std::result::Result<Option<ResolvedTreeTarget>, TreePathResolveError> {
235    match policy {
236        LeafPolicy::Entry => {
237            let content_hash = entry_content_hash(&entry);
238            Ok(Some(ResolvedTreeTarget {
239                entry,
240                content_hash,
241                blob: None,
242            }))
243        }
244        LeafPolicy::BlobOnly => {
245            let Some(content_hash) = entry.blob_hash() else {
246                return Ok(None);
247            };
248            Ok(Some(ResolvedTreeTarget {
249                entry,
250                content_hash: Some(content_hash),
251                blob: None,
252            }))
253        }
254        LeafPolicy::LeafContentBlob => {
255            let Some(content_hash) = entry.leaf_content_hash() else {
256                return Ok(None);
257            };
258            let blob = match store.get_blob(&content_hash) {
259                Ok(Some(blob)) => Some(blob),
260                Ok(None) => None,
261                Err(source) => {
262                    return Err(TreePathResolveError::Store {
263                        hash: content_hash,
264                        source: Box::new(source),
265                    });
266                }
267            };
268            Ok(blob.map(|blob| ResolvedTreeTarget {
269                entry,
270                content_hash: Some(content_hash),
271                blob: Some(blob),
272            }))
273        }
274    }
275}
276
277#[cfg(feature = "async-source")]
278async fn resolve_leaf_async<S: AsyncObjectSource + ?Sized>(
279    store: &S,
280    entry: TreeEntry,
281    policy: LeafPolicy,
282) -> std::result::Result<Option<ResolvedTreeTarget>, TreePathResolveError> {
283    match policy {
284        LeafPolicy::Entry => {
285            let content_hash = entry_content_hash(&entry);
286            Ok(Some(ResolvedTreeTarget {
287                entry,
288                content_hash,
289                blob: None,
290            }))
291        }
292        LeafPolicy::BlobOnly => {
293            let Some(content_hash) = entry.blob_hash() else {
294                return Ok(None);
295            };
296            Ok(Some(ResolvedTreeTarget {
297                entry,
298                content_hash: Some(content_hash),
299                blob: None,
300            }))
301        }
302        LeafPolicy::LeafContentBlob => {
303            let Some(content_hash) = entry.leaf_content_hash() else {
304                return Ok(None);
305            };
306            let blob = match store.get_blob(&content_hash).await {
307                Ok(Some(blob)) => Some(blob),
308                Ok(None) => None,
309                Err(source) => {
310                    return Err(TreePathResolveError::Store {
311                        hash: content_hash,
312                        source: Box::new(source),
313                    });
314                }
315            };
316            Ok(blob.map(|blob| ResolvedTreeTarget {
317                entry,
318                content_hash: Some(content_hash),
319                blob: Some(blob),
320            }))
321        }
322    }
323}
324
325fn entry_content_hash(entry: &TreeEntry) -> Option<ContentHash> {
326    entry
327        .content_hash()
328        .or_else(|| entry.tree_hash())
329        .or_else(|| entry.leaf_content_hash())
330}
331
332fn load_subtree<S: ObjectSource>(
333    store: &S,
334    hash: &ContentHash,
335    policy: LeafPolicy,
336) -> std::result::Result<Option<Tree>, TreePathResolveError> {
337    match policy {
338        LeafPolicy::Entry => Ok(store.get_tree(hash).ok().flatten()),
339        LeafPolicy::LeafContentBlob => match store.get_tree(hash) {
340            Ok(tree) => Ok(tree),
341            Err(source) => Err(TreePathResolveError::Store {
342                hash: *hash,
343                source: Box::new(source),
344            }),
345        },
346        LeafPolicy::BlobOnly => match store.get_tree(hash) {
347            Ok(Some(tree)) => Ok(Some(tree)),
348            Ok(None) => Err(TreePathResolveError::SubtreeMissing(*hash)),
349            Err(source) => Err(TreePathResolveError::Store {
350                hash: *hash,
351                source: Box::new(source),
352            }),
353        },
354    }
355}
356
357#[cfg(feature = "async-source")]
358async fn load_subtree_async<S: AsyncObjectSource + ?Sized>(
359    store: &S,
360    hash: &ContentHash,
361    policy: LeafPolicy,
362) -> std::result::Result<Option<Tree>, TreePathResolveError> {
363    match policy {
364        LeafPolicy::Entry => Ok(store.get_tree(hash).await.ok().flatten()),
365        LeafPolicy::LeafContentBlob => match store.get_tree(hash).await {
366            Ok(tree) => Ok(tree),
367            Err(source) => Err(TreePathResolveError::Store {
368                hash: *hash,
369                source: Box::new(source),
370            }),
371        },
372        LeafPolicy::BlobOnly => match store.get_tree(hash).await {
373            Ok(Some(tree)) => Ok(Some(tree)),
374            Ok(None) => Err(TreePathResolveError::SubtreeMissing(*hash)),
375            Err(source) => Err(TreePathResolveError::Store {
376                hash: *hash,
377                source: Box::new(source),
378            }),
379        },
380    }
381}