use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use crate::git::GitBackend;
use crate::pack::validate::child_path::check_one as check_child_path;
use crate::pack::{ChildRef, PackManifest, PackType, PackValidationError, SchemaVersion};
use super::error::TreeError;
use super::graph::{EdgeKind, PackEdge, PackGraph, PackNode};
use super::loader::PackLoader;
pub struct Walker<'a> {
loader: &'a dyn PackLoader,
backend: &'a dyn GitBackend,
workspace: PathBuf,
ref_override: Option<String>,
}
impl<'a> Walker<'a> {
#[must_use]
pub fn new(
loader: &'a dyn PackLoader,
backend: &'a dyn GitBackend,
workspace: PathBuf,
) -> Self {
Self { loader, backend, workspace, ref_override: None }
}
#[must_use]
pub fn with_ref_override(mut self, r#ref: Option<String>) -> Self {
self.ref_override = r#ref.filter(|s| !s.is_empty());
self
}
pub fn walk(&self, root_pack_path: &Path) -> Result<PackGraph, TreeError> {
let mut state = BuildState::default();
let root_manifest = self.loader.load(root_pack_path)?;
validate_children_paths(&root_manifest)?;
let root_commit_sha = probe_head_sha(self.backend, root_pack_path);
let root_id = state.push_node(PackNode {
id: 0,
name: root_manifest.name.clone(),
path: root_pack_path.to_path_buf(),
source_url: None,
manifest: root_manifest.clone(),
parent: None,
commit_sha: root_commit_sha,
synthetic: false,
});
let root_identity = pack_identity_for_root(root_pack_path);
self.walk_recursive(root_id, &root_manifest, &mut state, &mut vec![root_identity])?;
Ok(PackGraph::new(state.nodes, state.edges))
}
fn walk_recursive(
&self,
parent_id: usize,
manifest: &PackManifest,
state: &mut BuildState,
stack: &mut Vec<String>,
) -> Result<(), TreeError> {
self.record_depends_on(parent_id, manifest, state);
self.process_children(parent_id, manifest, state, stack)
}
fn record_depends_on(&self, parent_id: usize, manifest: &PackManifest, state: &mut BuildState) {
for dep in &manifest.depends_on {
if let Some(to) = find_node_id_by_name_or_url(&state.nodes, dep) {
state.edges.push(PackEdge { from: parent_id, to, kind: EdgeKind::DependsOn });
}
}
}
fn process_children(
&self,
parent_id: usize,
manifest: &PackManifest,
state: &mut BuildState,
stack: &mut Vec<String>,
) -> Result<(), TreeError> {
for child in &manifest.children {
self.handle_child(parent_id, child, state, stack)?;
}
Ok(())
}
fn handle_child(
&self,
parent_id: usize,
child: &ChildRef,
state: &mut BuildState,
stack: &mut Vec<String>,
) -> Result<(), TreeError> {
let identity = pack_identity_for_child(child);
if stack.iter().any(|s| s == &identity) {
let mut chain = stack.clone();
chain.push(identity);
return Err(TreeError::CycleDetected { chain });
}
let dest = self.resolve_destination(child, state)?;
let (child_manifest, is_synthetic) = match self.loader.load(&dest) {
Ok(m) => (m, false),
Err(TreeError::ManifestNotFound(_)) if dest_has_git_repo(&dest) => {
(synthesize_plain_git_manifest(child), true)
}
Err(e) => return Err(e),
};
verify_child_name(&child_manifest.name, child, &dest)?;
validate_children_paths(&child_manifest)?;
let commit_sha = probe_head_sha(self.backend, &dest);
let child_id = state.push_node(PackNode {
id: state.nodes.len(),
name: child_manifest.name.clone(),
path: dest.clone(),
source_url: Some(child.url.clone()),
manifest: child_manifest.clone(),
parent: Some(parent_id),
commit_sha,
synthetic: is_synthetic,
});
state.edges.push(PackEdge { from: parent_id, to: child_id, kind: EdgeKind::Child });
stack.push(identity);
let result = self.walk_recursive(child_id, &child_manifest, state, stack);
stack.pop();
result
}
fn resolve_destination(
&self,
child: &ChildRef,
_state: &mut BuildState,
) -> Result<PathBuf, TreeError> {
let dest = self.workspace.join(child.effective_path());
let effective_ref = self.ref_override.as_deref().or(child.r#ref.as_deref());
if dest_has_git_repo(&dest) {
self.backend.fetch(&dest)?;
if let Some(r) = effective_ref {
self.backend.checkout(&dest, r)?;
}
} else {
self.backend.clone(&child.url, &dest, effective_ref)?;
}
Ok(dest)
}
}
fn probe_head_sha(backend: &dyn GitBackend, path: &Path) -> Option<String> {
let dir =
if path.extension().and_then(|e| e.to_str()).is_some_and(|e| matches!(e, "yaml" | "yml")) {
path.parent()
.and_then(Path::parent)
.map_or_else(|| path.to_path_buf(), Path::to_path_buf)
} else {
path.to_path_buf()
};
if !dir.join(".git").exists() {
return None;
}
match backend.head_sha(&dir) {
Ok(s) => Some(s),
Err(e) => {
tracing::warn!(
target: "grex::walker",
"HEAD probe failed for {}: {e}",
dir.display()
);
None
}
}
}
#[derive(Default)]
struct BuildState {
nodes: Vec<PackNode>,
edges: Vec<PackEdge>,
}
impl BuildState {
fn push_node(&mut self, node: PackNode) -> usize {
let id = node.id;
self.nodes.push(node);
id
}
}
fn pack_identity_for_root(path: &Path) -> String {
format!("path:{}", path.display())
}
fn pack_identity_for_child(child: &ChildRef) -> String {
let rref = child.r#ref.as_deref().unwrap_or("");
format!("url:{}@{}", child.url, rref)
}
pub fn dest_has_git_repo(dest: &Path) -> bool {
if let Ok(meta) = std::fs::symlink_metadata(dest) {
if meta.file_type().is_symlink() {
return false;
}
}
dest.join(".git").exists()
}
pub fn synthesize_plain_git_manifest(child: &ChildRef) -> PackManifest {
PackManifest {
schema_version: SchemaVersion::current(),
name: child.effective_path(),
r#type: PackType::Scripted,
version: None,
depends_on: Vec::new(),
children: Vec::new(),
actions: Vec::new(),
teardown: None,
extensions: BTreeMap::new(),
}
}
fn verify_child_name(got: &str, child: &ChildRef, dest: &Path) -> Result<(), TreeError> {
let expected = child.effective_path();
if got == expected {
return Ok(());
}
Err(TreeError::PackNameMismatch { got: got.to_string(), expected, path: dest.to_path_buf() })
}
fn find_node_id_by_name_or_url(nodes: &[PackNode], dep: &str) -> Option<usize> {
if looks_like_url(dep) {
nodes.iter().find(|n| n.source_url.as_deref() == Some(dep)).map(|n| n.id)
} else {
nodes.iter().find(|n| n.name == dep).map(|n| n.id)
}
}
fn validate_children_paths(manifest: &PackManifest) -> Result<(), TreeError> {
for child in &manifest.children {
let Some(err) = check_child_path(child) else { continue };
match err {
PackValidationError::ChildPathInvalid { child_name, path, reason } => {
return Err(TreeError::ChildPathInvalid { child_name, path, reason });
}
other @ (PackValidationError::DuplicateSymlinkDst { .. }
| PackValidationError::GraphCycle { .. }
| PackValidationError::DependsOnUnsatisfied { .. }
| PackValidationError::ChildPathDuplicate { .. }) => {
tracing::error!(
target: "grex::walker",
"check_child_path returned unexpected variant: {other:?}",
);
debug_assert!(false, "check_child_path returned unexpected variant: {other:?}");
}
}
}
Ok(())
}
pub(super) fn looks_like_url(s: &str) -> bool {
s.starts_with("http://")
|| s.starts_with("https://")
|| s.starts_with("ssh://")
|| s.starts_with("git@")
|| s.ends_with(".git")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn synthesize_plain_git_manifest_yields_leaf_scripted_pack() {
let child = ChildRef {
url: "https://example.com/algo-leet.git".to_string(),
path: None,
r#ref: None,
};
let manifest = synthesize_plain_git_manifest(&child);
assert_eq!(manifest.name, child.effective_path());
assert_eq!(manifest.name, "algo-leet");
assert_eq!(manifest.r#type, PackType::Scripted);
assert_eq!(manifest.schema_version.as_str(), "1");
assert!(manifest.depends_on.is_empty());
assert!(manifest.children.is_empty());
assert!(manifest.actions.is_empty());
assert!(manifest.teardown.is_none());
assert!(manifest.extensions.is_empty());
assert!(manifest.version.is_none());
}
#[test]
fn synthesize_plain_git_manifest_honours_explicit_path() {
let child = ChildRef {
url: "https://example.com/some-repo.git".to_string(),
path: Some("custom-name".to_string()),
r#ref: None,
};
let manifest = synthesize_plain_git_manifest(&child);
assert_eq!(manifest.name, "custom-name");
}
#[test]
fn dest_has_git_repo_rejects_symlinked_dest() {
let outer = tempfile::tempdir().unwrap();
let real = outer.path().join("real-repo");
std::fs::create_dir_all(real.join(".git")).unwrap();
let link = outer.path().join("via-link");
#[cfg(unix)]
let symlink_result = std::os::unix::fs::symlink(&real, &link);
#[cfg(windows)]
let symlink_result = std::os::windows::fs::symlink_dir(&real, &link);
if symlink_result.is_err() {
return;
}
assert!(link.join(".git").exists(), "symlink target should expose .git through traversal");
assert!(
!dest_has_git_repo(&link),
"dest_has_git_repo must refuse a symlinked destination even when target has .git"
);
assert!(dest_has_git_repo(&real));
}
}