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::path::{Path, PathBuf};
12
13use crate::pack::{parse, PackManifest};
14
15use super::error::TreeError;
16
17/// Strategy object for turning a path into a parsed manifest.
18///
19/// Implementors must be `Send + Sync` so the walker can be used behind an
20/// `Arc` in a future parallel-walk slice.
21pub trait PackLoader: Send + Sync {
22 /// Resolve `path` and return the parsed manifest.
23 ///
24 /// # Path semantics
25 ///
26 /// * If `path` is a directory, the loader looks up
27 /// `path.join(".grex/pack.yaml")`.
28 /// * If `path` ends in `.yaml` or `.yml`, it is read verbatim.
29 ///
30 /// The distinction is documented at the trait level so every backend
31 /// observes the same contract.
32 ///
33 /// # Errors
34 ///
35 /// Returns [`TreeError::ManifestNotFound`] when no manifest exists at the
36 /// resolved location, [`TreeError::ManifestRead`] for IO failures, and
37 /// [`TreeError::ManifestParse`] for structural failures.
38 fn load(&self, path: &Path) -> Result<PackManifest, TreeError>;
39}
40
41/// Filesystem-backed [`PackLoader`] used by the real walker.
42#[derive(Debug, Default)]
43pub struct FsPackLoader;
44
45impl FsPackLoader {
46 /// Construct a new loader. Equivalent to [`FsPackLoader::default`].
47 #[must_use]
48 pub const fn new() -> Self {
49 Self
50 }
51}
52
53impl PackLoader for FsPackLoader {
54 fn load(&self, path: &Path) -> Result<PackManifest, TreeError> {
55 let manifest_path = resolve_manifest_path(path);
56 if !manifest_path.is_file() {
57 return Err(TreeError::ManifestNotFound(manifest_path));
58 }
59 let raw = std::fs::read_to_string(&manifest_path)
60 .map_err(|e| TreeError::ManifestRead(format!("{}: {e}", manifest_path.display())))?;
61 parse(&raw)
62 .map_err(|e| TreeError::ManifestParse { path: manifest_path, detail: e.to_string() })
63 }
64}
65
66/// Resolve a user-supplied path to the concrete `pack.yaml` location.
67///
68/// Split out so cyclomatic budget on [`FsPackLoader::load`] stays tiny.
69fn resolve_manifest_path(path: &Path) -> PathBuf {
70 if has_yaml_extension(path) {
71 path.to_path_buf()
72 } else {
73 path.join(".grex").join("pack.yaml")
74 }
75}
76
77fn has_yaml_extension(path: &Path) -> bool {
78 matches!(path.extension().and_then(|e| e.to_str()), Some("yaml" | "yml"))
79}