grex_core/tree/loader.rs
1//! Pluggable [`PackLoader`] for the tree walker.
2//!
3//! The walker never reads the filesystem directly: every manifest arrives via
4//! a [`PackLoader`] impl. This exists so:
5//!
6//! * Tests substitute an in-memory mock and avoid disk I/O entirely.
7//! * A future plugin backend (e.g. an HTTP-fetched manifest or a cached
8//! manifest store) can slot in without touching walker logic.
9//! * Tree-walk tests stay hermetic on CI.
10
11use std::io;
12use std::path::{Path, PathBuf};
13
14use crate::pack::{parse, PackManifest};
15
16use super::error::{is_not_a_directory, TreeError};
17
18/// Strategy object for turning a path into a parsed manifest.
19///
20/// Implementors must be `Send + Sync` so the walker can be used behind an
21/// `Arc` in a future parallel-walk slice.
22pub trait PackLoader: Send + Sync {
23 /// Resolve `path` and return the parsed manifest.
24 ///
25 /// # Path semantics
26 ///
27 /// * If `path` is a directory, the loader looks up
28 /// `path.join(".grex/pack.yaml")`.
29 /// * If `path` ends in `.yaml` or `.yml`, it is read verbatim.
30 ///
31 /// The distinction is documented at the trait level so every backend
32 /// observes the same contract.
33 ///
34 /// # Errors
35 ///
36 /// Returns [`TreeError::ManifestNotFound`] when no manifest exists at
37 /// the resolved location; [`TreeError::ManifestPermissionDenied`],
38 /// [`TreeError::ManifestNotADir`], or [`TreeError::ManifestIo`] for
39 /// categorised IO failures; [`TreeError::ManifestRead`] as a
40 /// back-compat catch-all for unmatched `io::ErrorKind` cases; and
41 /// [`TreeError::ManifestParse`] for structural failures.
42 fn load(&self, path: &Path) -> Result<PackManifest, TreeError>;
43}
44
45/// Filesystem-backed [`PackLoader`] used by the real walker.
46#[derive(Debug, Default)]
47pub struct FsPackLoader;
48
49impl FsPackLoader {
50 /// Construct a new loader. Equivalent to [`FsPackLoader::default`].
51 #[must_use]
52 pub const fn new() -> Self {
53 Self
54 }
55}
56
57impl PackLoader for FsPackLoader {
58 fn load(&self, path: &Path) -> Result<PackManifest, TreeError> {
59 let manifest_path = resolve_manifest_path(path);
60 if !manifest_path.is_file() {
61 return Err(TreeError::ManifestNotFound(manifest_path));
62 }
63 let raw = match std::fs::read_to_string(&manifest_path) {
64 Ok(s) => s,
65 Err(e) => return Err(map_manifest_read_error(manifest_path, e)),
66 };
67 parse(&raw)
68 .map_err(|e| TreeError::ManifestParse { path: manifest_path, detail: e.to_string() })
69 }
70}
71
72/// Route a manifest-read [`io::Error`] into the most specific
73/// [`TreeError`] variant available. Unmatched `io::ErrorKind` cases fall
74/// through to [`TreeError::ManifestRead`] for back-compat with v1.2.0+
75/// downstream consumers that may have matched it explicitly.
76fn map_manifest_read_error(manifest_path: PathBuf, e: io::Error) -> TreeError {
77 match e.kind() {
78 io::ErrorKind::NotFound => TreeError::ManifestNotFound(manifest_path),
79 io::ErrorKind::PermissionDenied => {
80 TreeError::ManifestPermissionDenied { path: manifest_path }
81 }
82 _ if is_not_a_directory(&e) => TreeError::ManifestNotADir { path: manifest_path },
83 _ => TreeError::ManifestIo { path: manifest_path, source: e },
84 }
85}
86
87/// Resolve a user-supplied path to the concrete `pack.yaml` location.
88///
89/// Split out so cyclomatic budget on [`FsPackLoader::load`] stays tiny.
90fn resolve_manifest_path(path: &Path) -> PathBuf {
91 if has_yaml_extension(path) {
92 path.to_path_buf()
93 } else {
94 path.join(".grex").join("pack.yaml")
95 }
96}
97
98fn has_yaml_extension(path: &Path) -> bool {
99 matches!(path.extension().and_then(|e| e.to_str()), Some("yaml" | "yml"))
100}