1use std::path::{Component, Path};
5
6use super::{Blob, ContentHash, Tree, TreeEntry};
7use crate::{error::HeddleError, store::ObjectSource};
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub enum LeafPolicy {
12 Entry,
14 BlobOnly,
16 LeafContentBlob,
19}
20
21#[derive(Debug, Clone, PartialEq, Eq)]
23pub struct ResolvedTreeTarget {
24 pub entry: TreeEntry,
25 pub content_hash: Option<ContentHash>,
26 pub blob: Option<Blob>,
27}
28
29#[derive(Debug)]
31pub enum TreePathResolveError {
32 Store {
33 hash: ContentHash,
34 source: Box<HeddleError>,
35 },
36 SubtreeMissing(ContentHash),
37}
38
39impl std::error::Error for TreePathResolveError {
40 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
41 match self {
42 TreePathResolveError::Store { source, .. } => Some(source.as_ref()),
43 TreePathResolveError::SubtreeMissing(_) => None,
44 }
45 }
46}
47
48impl From<TreePathResolveError> for HeddleError {
49 fn from(value: TreePathResolveError) -> Self {
50 match value {
51 TreePathResolveError::Store { source, .. } => *source,
52 TreePathResolveError::SubtreeMissing(hash) => {
53 HeddleError::InvalidObject(format!("subtree {} missing from store", hash.short()))
54 }
55 }
56 }
57}
58
59impl std::fmt::Display for TreePathResolveError {
60 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
61 match self {
62 TreePathResolveError::Store { hash, .. } => {
63 write!(f, "failed to load tree {}", hash.short())
64 }
65 TreePathResolveError::SubtreeMissing(hash) => {
66 write!(f, "subtree {} missing from store", hash.short())
67 }
68 }
69 }
70}
71
72pub fn split_path(path: &Path) -> Option<(&str, &Path)> {
74 let mut components = path.components();
75 let first = components.next()?;
76 let Component::Normal(name) = first else {
77 return None;
78 };
79 Some((name.to_str()?, components.as_path()))
80}
81
82pub fn resolve_tree_path<S: ObjectSource>(
89 store: &S,
90 root: &ContentHash,
91 path: &Path,
92 policy: LeafPolicy,
93) -> std::result::Result<Option<ResolvedTreeTarget>, TreePathResolveError> {
94 let Some(segments) = segments_for_policy(path, policy) else {
95 return Ok(None);
96 };
97 if segments.is_empty() {
98 return Ok(None);
99 }
100
101 let Some(tree) = load_subtree(store, root, policy)? else {
102 return Ok(None);
103 };
104 resolve_from_tree(store, &tree, &segments, policy)
105}
106
107#[cfg(feature = "async-source")]
108pub async fn resolve_tree_path_async<S: crate::store::AsyncObjectSource + ?Sized>(
109 store: &S,
110 root: &ContentHash,
111 path: &Path,
112 policy: LeafPolicy,
113) -> std::result::Result<Option<ResolvedTreeTarget>, TreePathResolveError> {
114 let Some(segments) = segments_for_policy(path, policy) else {
115 return Ok(None);
116 };
117 if segments.is_empty() {
118 return Ok(None);
119 }
120
121 let Some(tree) = load_subtree_async(store, root, policy).await? else {
122 return Ok(None);
123 };
124 resolve_from_tree_async(store, &tree, &segments, policy).await
125}
126
127fn segments_for_policy(path: &Path, policy: LeafPolicy) -> Option<Vec<String>> {
128 match policy {
129 LeafPolicy::Entry => path_segments(path),
130 LeafPolicy::BlobOnly => {
131 let path_str = path.to_str()?;
132 Some(
133 path_str
134 .split('/')
135 .filter(|part| !part.is_empty())
136 .map(str::to_string)
137 .collect(),
138 )
139 }
140 LeafPolicy::LeafContentBlob => Some(
141 path.to_string_lossy()
142 .split('/')
143 .map(str::to_string)
144 .collect(),
145 ),
146 }
147}
148
149fn path_segments(path: &Path) -> Option<Vec<String>> {
150 if path.as_os_str().is_empty() {
151 return None;
152 }
153 let mut segments = Vec::new();
154 for component in path.components() {
155 match component {
156 Component::Normal(name) => segments.push(name.to_str()?.to_string()),
157 _ => return None,
158 }
159 }
160 if segments.is_empty() {
161 return None;
162 }
163 Some(segments)
164}
165
166fn resolve_from_tree<S: ObjectSource>(
167 store: &S,
168 tree: &Tree,
169 segments: &[String],
170 policy: LeafPolicy,
171) -> std::result::Result<Option<ResolvedTreeTarget>, TreePathResolveError> {
172 let name = segments[0].as_str();
173 let Some(entry) = tree.get(name) else {
174 return Ok(None);
175 };
176
177 if segments.len() == 1 {
178 return resolve_leaf(store, entry.clone(), policy);
179 }
180
181 if !entry.is_tree() {
182 return Ok(None);
183 }
184 let Some(tree_hash) = entry.tree_hash() else {
185 return Ok(None);
186 };
187 let Some(subtree) = load_subtree(store, &tree_hash, policy)? else {
188 return Ok(None);
189 };
190 resolve_from_tree(store, &subtree, &segments[1..], policy)
191}
192
193#[cfg(feature = "async-source")]
194async fn resolve_from_tree_async<S: crate::store::AsyncObjectSource + ?Sized>(
195 store: &S,
196 tree: &Tree,
197 segments: &[String],
198 policy: LeafPolicy,
199) -> std::result::Result<Option<ResolvedTreeTarget>, TreePathResolveError> {
200 let name = segments[0].as_str();
201 let Some(entry) = tree.get(name) else {
202 return Ok(None);
203 };
204
205 if segments.len() == 1 {
206 return resolve_leaf_async(store, entry.clone(), policy).await;
207 }
208
209 if !entry.is_tree() {
210 return Ok(None);
211 }
212 let Some(tree_hash) = entry.tree_hash() else {
213 return Ok(None);
214 };
215 let Some(subtree) = load_subtree_async(store, &tree_hash, policy).await? else {
216 return Ok(None);
217 };
218 Box::pin(resolve_from_tree_async(
219 store,
220 &subtree,
221 &segments[1..],
222 policy,
223 ))
224 .await
225}
226
227fn resolve_leaf<S: ObjectSource>(
228 store: &S,
229 entry: TreeEntry,
230 policy: LeafPolicy,
231) -> std::result::Result<Option<ResolvedTreeTarget>, TreePathResolveError> {
232 match policy {
233 LeafPolicy::Entry => {
234 let content_hash = entry_content_hash(&entry);
235 Ok(Some(ResolvedTreeTarget {
236 entry,
237 content_hash,
238 blob: None,
239 }))
240 }
241 LeafPolicy::BlobOnly => {
242 let Some(content_hash) = entry.blob_hash() else {
243 return Ok(None);
244 };
245 Ok(Some(ResolvedTreeTarget {
246 entry,
247 content_hash: Some(content_hash),
248 blob: None,
249 }))
250 }
251 LeafPolicy::LeafContentBlob => {
252 let Some(content_hash) = entry.leaf_content_hash() else {
253 return Ok(None);
254 };
255 let blob = match store.get_blob(&content_hash) {
256 Ok(Some(blob)) => Some(blob),
257 Ok(None) => None,
258 Err(source) => {
259 return Err(TreePathResolveError::Store {
260 hash: content_hash,
261 source: Box::new(source),
262 });
263 }
264 };
265 Ok(blob.map(|blob| ResolvedTreeTarget {
266 entry,
267 content_hash: Some(content_hash),
268 blob: Some(blob),
269 }))
270 }
271 }
272}
273
274#[cfg(feature = "async-source")]
275async fn resolve_leaf_async<S: crate::store::AsyncObjectSource + ?Sized>(
276 store: &S,
277 entry: TreeEntry,
278 policy: LeafPolicy,
279) -> std::result::Result<Option<ResolvedTreeTarget>, TreePathResolveError> {
280 match policy {
281 LeafPolicy::Entry => {
282 let content_hash = entry_content_hash(&entry);
283 Ok(Some(ResolvedTreeTarget {
284 entry,
285 content_hash,
286 blob: None,
287 }))
288 }
289 LeafPolicy::BlobOnly => {
290 let Some(content_hash) = entry.blob_hash() else {
291 return Ok(None);
292 };
293 Ok(Some(ResolvedTreeTarget {
294 entry,
295 content_hash: Some(content_hash),
296 blob: None,
297 }))
298 }
299 LeafPolicy::LeafContentBlob => {
300 let Some(content_hash) = entry.leaf_content_hash() else {
301 return Ok(None);
302 };
303 let blob = match store.get_blob(&content_hash).await {
304 Ok(Some(blob)) => Some(blob),
305 Ok(None) => None,
306 Err(source) => {
307 return Err(TreePathResolveError::Store {
308 hash: content_hash,
309 source: Box::new(source),
310 });
311 }
312 };
313 Ok(blob.map(|blob| ResolvedTreeTarget {
314 entry,
315 content_hash: Some(content_hash),
316 blob: Some(blob),
317 }))
318 }
319 }
320}
321
322fn entry_content_hash(entry: &TreeEntry) -> Option<ContentHash> {
323 entry
324 .content_hash()
325 .or_else(|| entry.tree_hash())
326 .or_else(|| entry.leaf_content_hash())
327}
328
329fn load_subtree<S: ObjectSource>(
330 store: &S,
331 hash: &ContentHash,
332 policy: LeafPolicy,
333) -> std::result::Result<Option<Tree>, TreePathResolveError> {
334 match policy {
335 LeafPolicy::Entry => Ok(store.get_tree(hash).ok().flatten()),
336 LeafPolicy::LeafContentBlob => match store.get_tree(hash) {
337 Ok(tree) => Ok(tree),
338 Err(source) => Err(TreePathResolveError::Store {
339 hash: *hash,
340 source: Box::new(source),
341 }),
342 },
343 LeafPolicy::BlobOnly => match store.get_tree(hash) {
344 Ok(Some(tree)) => Ok(Some(tree)),
345 Ok(None) => Err(TreePathResolveError::SubtreeMissing(*hash)),
346 Err(source) => Err(TreePathResolveError::Store {
347 hash: *hash,
348 source: Box::new(source),
349 }),
350 },
351 }
352}
353
354#[cfg(feature = "async-source")]
355async fn load_subtree_async<S: crate::store::AsyncObjectSource + ?Sized>(
356 store: &S,
357 hash: &ContentHash,
358 policy: LeafPolicy,
359) -> std::result::Result<Option<Tree>, TreePathResolveError> {
360 match policy {
361 LeafPolicy::Entry => Ok(store.get_tree(hash).await.ok().flatten()),
362 LeafPolicy::LeafContentBlob => match store.get_tree(hash).await {
363 Ok(tree) => Ok(tree),
364 Err(source) => Err(TreePathResolveError::Store {
365 hash: *hash,
366 source: Box::new(source),
367 }),
368 },
369 LeafPolicy::BlobOnly => match store.get_tree(hash).await {
370 Ok(Some(tree)) => Ok(Some(tree)),
371 Ok(None) => Err(TreePathResolveError::SubtreeMissing(*hash)),
372 Err(source) => Err(TreePathResolveError::Store {
373 hash: *hash,
374 source: Box::new(source),
375 }),
376 },
377 }
378}
379
380#[cfg(test)]
381mod tests {
382 use super::*;
383 use crate::{
384 object::{EntryType, TreeEntry},
385 store::{InMemoryStore, ObjectStore},
386 };
387
388 fn create_blob(store: &InMemoryStore, content: &[u8]) -> ContentHash {
389 ObjectStore::put_blob(store, &Blob::from_slice(content)).unwrap()
390 }
391
392 fn create_tree(
393 store: &InMemoryStore,
394 entries: Vec<(&str, ContentHash, EntryType)>,
395 ) -> ContentHash {
396 let entries = entries
397 .into_iter()
398 .map(|(name, hash, entry_type)| match entry_type {
399 EntryType::Blob => TreeEntry::file(name.to_string(), hash, false),
400 EntryType::Tree => TreeEntry::directory(name.to_string(), hash),
401 EntryType::Symlink => TreeEntry::symlink(name.to_string(), hash),
402 EntryType::Gitlink => unreachable!("tree path tests do not build gitlinks"),
403 EntryType::Spoollink => {
404 unreachable!("tree path tests do not build spoollinks")
405 }
406 })
407 .collect::<std::result::Result<Vec<_>, _>>()
408 .unwrap();
409 ObjectStore::put_tree(store, &Tree::from_entries(entries)).unwrap()
410 }
411
412 struct Fixture {
413 store: InMemoryStore,
414 root: ContentHash,
415 blob_hash: ContentHash,
416 symlink_hash: ContentHash,
417 nested_blob_hash: ContentHash,
418 missing_subtree_hash: ContentHash,
419 }
420
421 fn fixture() -> Fixture {
422 let store = InMemoryStore::new();
423 let blob_hash = create_blob(&store, b"blob content");
424 let symlink_hash = create_blob(&store, b"target.txt");
425 let nested_blob_hash = create_blob(&store, b"nested content");
426
427 let nested_tree = create_tree(
428 &store,
429 vec![("inner.txt", nested_blob_hash, EntryType::Blob)],
430 );
431 let missing_subtree_hash = ContentHash::compute(b"not-in-store");
432 let missing_subtree_parent = create_tree(
433 &store,
434 vec![("ghost", missing_subtree_hash, EntryType::Tree)],
435 );
436 let root = create_tree(
437 &store,
438 vec![
439 ("file.txt", blob_hash, EntryType::Blob),
440 ("link", symlink_hash, EntryType::Symlink),
441 ("dir", nested_tree, EntryType::Tree),
442 ("missing", missing_subtree_parent, EntryType::Tree),
443 ],
444 );
445
446 Fixture {
447 store,
448 root,
449 blob_hash,
450 symlink_hash,
451 nested_blob_hash,
452 missing_subtree_hash,
453 }
454 }
455
456 #[test]
457 fn leaf_content_blob_resolves_symlinks_and_nested_paths() {
458 let fx = fixture();
459
460 let file = resolve_tree_path(
461 &fx.store,
462 &fx.root,
463 Path::new("file.txt"),
464 LeafPolicy::LeafContentBlob,
465 )
466 .unwrap()
467 .unwrap();
468 assert_eq!(file.content_hash, Some(fx.blob_hash));
469 assert_eq!(file.blob.as_ref().unwrap().content(), b"blob content");
470
471 let link = resolve_tree_path(
472 &fx.store,
473 &fx.root,
474 Path::new("link"),
475 LeafPolicy::LeafContentBlob,
476 )
477 .unwrap()
478 .unwrap();
479 assert_eq!(link.content_hash, Some(fx.symlink_hash));
480 assert_eq!(link.blob.as_ref().unwrap().content(), b"target.txt");
481
482 let nested = resolve_tree_path(
483 &fx.store,
484 &fx.root,
485 Path::new("dir/inner.txt"),
486 LeafPolicy::LeafContentBlob,
487 )
488 .unwrap()
489 .unwrap();
490 assert_eq!(nested.content_hash, Some(fx.nested_blob_hash));
491
492 assert!(
493 resolve_tree_path(
494 &fx.store,
495 &fx.root,
496 Path::new("dir"),
497 LeafPolicy::LeafContentBlob,
498 )
499 .unwrap()
500 .is_none()
501 );
502 assert!(
503 resolve_tree_path(
504 &fx.store,
505 &fx.root,
506 Path::new("nope.txt"),
507 LeafPolicy::LeafContentBlob,
508 )
509 .unwrap()
510 .is_none()
511 );
512 assert!(
513 resolve_tree_path(
514 &fx.store,
515 &fx.root,
516 Path::new("missing/ghost/inner.txt"),
517 LeafPolicy::LeafContentBlob,
518 )
519 .unwrap()
520 .is_none()
521 );
522 }
523
524 #[test]
525 fn entry_policy_returns_terminal_entry_for_any_leaf_type() {
526 let fx = fixture();
527
528 let file = resolve_tree_path(
529 &fx.store,
530 &fx.root,
531 Path::new("file.txt"),
532 LeafPolicy::Entry,
533 )
534 .unwrap()
535 .unwrap();
536 assert_eq!(file.entry.blob_hash(), Some(fx.blob_hash));
537
538 let link = resolve_tree_path(&fx.store, &fx.root, Path::new("link"), LeafPolicy::Entry)
539 .unwrap()
540 .unwrap();
541 assert!(link.entry.is_symlink());
542 assert_eq!(link.entry.leaf_content_hash(), Some(fx.symlink_hash));
543
544 let dir = resolve_tree_path(&fx.store, &fx.root, Path::new("dir"), LeafPolicy::Entry)
545 .unwrap()
546 .unwrap();
547 assert!(dir.entry.is_tree());
548
549 assert!(
550 resolve_tree_path(
551 &fx.store,
552 &fx.root,
553 Path::new("dir/missing"),
554 LeafPolicy::Entry
555 )
556 .unwrap()
557 .is_none()
558 );
559 assert!(
560 resolve_tree_path(
561 &fx.store,
562 &fx.root,
563 Path::new("missing/ghost/inner.txt"),
564 LeafPolicy::Entry,
565 )
566 .unwrap()
567 .is_none()
568 );
569 }
570
571 #[test]
572 fn blob_only_excludes_symlinks_and_errors_on_missing_subtree() {
573 let fx = fixture();
574
575 let file = resolve_tree_path(
576 &fx.store,
577 &fx.root,
578 Path::new("file.txt"),
579 LeafPolicy::BlobOnly,
580 )
581 .unwrap()
582 .unwrap();
583 assert_eq!(file.content_hash, Some(fx.blob_hash));
584
585 assert!(
586 resolve_tree_path(&fx.store, &fx.root, Path::new("link"), LeafPolicy::BlobOnly)
587 .unwrap()
588 .is_none()
589 );
590
591 let nested = resolve_tree_path(
592 &fx.store,
593 &fx.root,
594 Path::new("dir/inner.txt"),
595 LeafPolicy::BlobOnly,
596 )
597 .unwrap()
598 .unwrap();
599 assert_eq!(nested.content_hash, Some(fx.nested_blob_hash));
600
601 assert!(
602 resolve_tree_path(&fx.store, &fx.root, Path::new("dir"), LeafPolicy::BlobOnly)
603 .unwrap()
604 .is_none()
605 );
606
607 let err = resolve_tree_path(
608 &fx.store,
609 &fx.root,
610 Path::new("missing/ghost/inner.txt"),
611 LeafPolicy::BlobOnly,
612 )
613 .unwrap_err();
614 assert!(matches!(
615 err,
616 TreePathResolveError::SubtreeMissing(hash) if hash == fx.missing_subtree_hash
617 ));
618 }
619}