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) => HeddleError::MissingObject {
58                object_type: "tree".to_string(),
59                id: hash.to_hex(),
60            },
61        }
62    }
63}
64
65impl std::fmt::Display for TreePathResolveError {
66    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
67        match self {
68            TreePathResolveError::Store { hash, source } => {
69                write!(f, "failed to load object {}: {source}", hash.short())
70            }
71            TreePathResolveError::SubtreeMissing(hash) => {
72                write!(f, "missing tree object: {hash} is not available locally")
73            }
74        }
75    }
76}
77
78/// Split a repository-relative path into its first component and the remainder.
79pub fn split_path(path: &Path) -> Option<(&str, &Path)> {
80    let mut components = path.components();
81    let first = components.next()?;
82    let Component::Normal(name) = first else {
83        return None;
84    };
85    Some((name.to_str()?, components.as_path()))
86}
87
88/// Walk `path` from `root` through nested subtrees and resolve the terminal entry
89/// according to `policy`.
90///
91/// `Ok(None)` means the path is absent or terminates at the wrong entry type for the
92/// policy. Referenced objects must exist for every policy; missing trees and
93/// blobs return errors instead of masquerading as absent paths.
94pub fn resolve_tree_path<S: ObjectSource>(
95    store: &S,
96    root: &ContentHash,
97    path: &Path,
98    policy: LeafPolicy,
99) -> std::result::Result<Option<ResolvedTreeTarget>, TreePathResolveError> {
100    let Some(segments) = segments_for_policy(path, policy) else {
101        return Ok(None);
102    };
103    if segments.is_empty() {
104        return Ok(None);
105    }
106
107    let tree = load_subtree(store, root)?;
108    resolve_from_tree(store, &tree, &segments, policy)
109}
110
111#[cfg(feature = "async-source")]
112pub async fn resolve_tree_path_async<S: AsyncObjectSource + ?Sized>(
113    store: &S,
114    root: &ContentHash,
115    path: &Path,
116    policy: LeafPolicy,
117) -> std::result::Result<Option<ResolvedTreeTarget>, TreePathResolveError> {
118    let Some(segments) = segments_for_policy(path, policy) else {
119        return Ok(None);
120    };
121    if segments.is_empty() {
122        return Ok(None);
123    }
124
125    let tree = load_subtree_async(store, root).await?;
126    resolve_from_tree_async(store, &tree, &segments, policy).await
127}
128
129fn segments_for_policy(path: &Path, policy: LeafPolicy) -> Option<Vec<String>> {
130    match policy {
131        LeafPolicy::Entry => path_segments(path),
132        LeafPolicy::BlobOnly => {
133            let path_str = path.to_str()?;
134            Some(
135                path_str
136                    .split('/')
137                    .filter(|part| !part.is_empty())
138                    .map(str::to_string)
139                    .collect(),
140            )
141        }
142        LeafPolicy::LeafContentBlob => Some(
143            path.to_string_lossy()
144                .split('/')
145                .map(str::to_string)
146                .collect(),
147        ),
148    }
149}
150
151fn path_segments(path: &Path) -> Option<Vec<String>> {
152    if path.as_os_str().is_empty() {
153        return None;
154    }
155    let mut segments = Vec::new();
156    for component in path.components() {
157        match component {
158            Component::Normal(name) => segments.push(name.to_str()?.to_string()),
159            _ => return None,
160        }
161    }
162    if segments.is_empty() {
163        return None;
164    }
165    Some(segments)
166}
167
168fn resolve_from_tree<S: ObjectSource>(
169    store: &S,
170    tree: &Tree,
171    segments: &[String],
172    policy: LeafPolicy,
173) -> std::result::Result<Option<ResolvedTreeTarget>, TreePathResolveError> {
174    let mut current_tree = Cow::Borrowed(tree);
175
176    for (index, segment) in segments.iter().enumerate() {
177        let Some(entry) = current_tree.get(segment) else {
178            return Ok(None);
179        };
180
181        if index + 1 == segments.len() {
182            return resolve_leaf(store, entry.clone(), policy);
183        }
184
185        let Some(tree_hash) = entry.tree_hash() else {
186            return Ok(None);
187        };
188        let subtree = load_subtree(store, &tree_hash)?;
189        current_tree = Cow::Owned(subtree);
190    }
191
192    Ok(None)
193}
194
195#[cfg(feature = "async-source")]
196async fn resolve_from_tree_async<S: AsyncObjectSource + ?Sized>(
197    store: &S,
198    tree: &Tree,
199    segments: &[String],
200    policy: LeafPolicy,
201) -> std::result::Result<Option<ResolvedTreeTarget>, TreePathResolveError> {
202    let mut current_tree = Cow::Borrowed(tree);
203
204    for (index, segment) in segments.iter().enumerate() {
205        let Some(entry) = current_tree.get(segment) else {
206            return Ok(None);
207        };
208
209        if index + 1 == segments.len() {
210            return resolve_leaf_async(store, entry.clone(), policy).await;
211        }
212
213        let Some(tree_hash) = entry.tree_hash() else {
214            return Ok(None);
215        };
216        let subtree = load_subtree_async(store, &tree_hash).await?;
217        current_tree = Cow::Owned(subtree);
218    }
219
220    Ok(None)
221}
222
223fn resolve_leaf<S: ObjectSource>(
224    store: &S,
225    entry: TreeEntry,
226    policy: LeafPolicy,
227) -> std::result::Result<Option<ResolvedTreeTarget>, TreePathResolveError> {
228    match policy {
229        LeafPolicy::Entry => {
230            let content_hash = entry_content_hash(&entry);
231            Ok(Some(ResolvedTreeTarget {
232                entry,
233                content_hash,
234                blob: None,
235            }))
236        }
237        LeafPolicy::BlobOnly => {
238            let Some(content_hash) = entry.blob_hash() else {
239                return Ok(None);
240            };
241            Ok(Some(ResolvedTreeTarget {
242                entry,
243                content_hash: Some(content_hash),
244                blob: None,
245            }))
246        }
247        LeafPolicy::LeafContentBlob => {
248            let Some(content_hash) = entry.leaf_content_hash() else {
249                return Ok(None);
250            };
251            let blob = store.require_blob(&content_hash).map_err(|source| {
252                TreePathResolveError::Store {
253                    hash: content_hash,
254                    source: Box::new(source),
255                }
256            })?;
257            Ok(Some(ResolvedTreeTarget {
258                entry,
259                content_hash: Some(content_hash),
260                blob: Some(blob),
261            }))
262        }
263    }
264}
265
266#[cfg(feature = "async-source")]
267async fn resolve_leaf_async<S: AsyncObjectSource + ?Sized>(
268    store: &S,
269    entry: TreeEntry,
270    policy: LeafPolicy,
271) -> std::result::Result<Option<ResolvedTreeTarget>, TreePathResolveError> {
272    match policy {
273        LeafPolicy::Entry => {
274            let content_hash = entry_content_hash(&entry);
275            Ok(Some(ResolvedTreeTarget {
276                entry,
277                content_hash,
278                blob: None,
279            }))
280        }
281        LeafPolicy::BlobOnly => {
282            let Some(content_hash) = entry.blob_hash() else {
283                return Ok(None);
284            };
285            Ok(Some(ResolvedTreeTarget {
286                entry,
287                content_hash: Some(content_hash),
288                blob: None,
289            }))
290        }
291        LeafPolicy::LeafContentBlob => {
292            let Some(content_hash) = entry.leaf_content_hash() else {
293                return Ok(None);
294            };
295            let blob = store.require_blob(&content_hash).await.map_err(|source| {
296                TreePathResolveError::Store {
297                    hash: content_hash,
298                    source: Box::new(source),
299                }
300            })?;
301            Ok(Some(ResolvedTreeTarget {
302                entry,
303                content_hash: Some(content_hash),
304                blob: Some(blob),
305            }))
306        }
307    }
308}
309
310fn entry_content_hash(entry: &TreeEntry) -> Option<ContentHash> {
311    entry
312        .content_hash()
313        .or_else(|| entry.tree_hash())
314        .or_else(|| entry.leaf_content_hash())
315}
316
317fn load_subtree<S: ObjectSource>(
318    store: &S,
319    hash: &ContentHash,
320) -> std::result::Result<Tree, TreePathResolveError> {
321    match store.get_tree(hash) {
322        Ok(Some(tree)) => Ok(tree),
323        Ok(None) => Err(TreePathResolveError::SubtreeMissing(*hash)),
324        Err(source) => Err(TreePathResolveError::Store {
325            hash: *hash,
326            source: Box::new(source),
327        }),
328    }
329}
330
331#[cfg(feature = "async-source")]
332async fn load_subtree_async<S: AsyncObjectSource + ?Sized>(
333    store: &S,
334    hash: &ContentHash,
335) -> std::result::Result<Tree, TreePathResolveError> {
336    match store.get_tree(hash).await {
337        Ok(Some(tree)) => Ok(tree),
338        Ok(None) => Err(TreePathResolveError::SubtreeMissing(*hash)),
339        Err(source) => Err(TreePathResolveError::Store {
340            hash: *hash,
341            source: Box::new(source),
342        }),
343    }
344}