use std::collections::BTreeMap;
use std::path::Path;
use crate::git::GitBackend;
use crate::pack::{ChildRef, PackManifest, PackType, PackValidationError, SchemaVersion};
use super::error::TreeError;
use super::graph::{EdgeKind, PackEdge, PackGraph, PackNode};
use super::loader::PackLoader;
use super::walker::{check_dest_boundary, dest_has_git_repo, looks_like_url};
pub fn build_graph(
workspace: &Path,
backend: &dyn GitBackend,
loader: &dyn PackLoader,
ref_override: Option<&str>,
) -> Result<PackGraph, TreeError> {
let _ = ref_override; let root_manifest = loader.load(workspace)?;
validate_children_paths(&root_manifest)?;
let root_commit_sha = probe_head_sha(backend, workspace);
let mut state = BuildState::default();
let root_id = state.push_node(PackNode {
id: 0,
name: root_manifest.name.clone(),
path: workspace.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(workspace);
walk_recursive(
backend,
loader,
root_id,
workspace,
&root_manifest,
&mut state,
&mut vec![root_identity],
)?;
Ok(PackGraph::new(state.nodes, state.edges))
}
#[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 walk_recursive(
backend: &dyn GitBackend,
loader: &dyn PackLoader,
parent_id: usize,
parent_meta: &Path,
manifest: &PackManifest,
state: &mut BuildState,
ancestors: &mut Vec<String>,
) -> Result<(), TreeError> {
record_depends_on(parent_id, manifest, state);
process_children(backend, loader, parent_id, parent_meta, manifest, state, ancestors)
}
fn record_depends_on(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(
backend: &dyn GitBackend,
loader: &dyn PackLoader,
parent_id: usize,
parent_meta: &Path,
manifest: &PackManifest,
state: &mut BuildState,
ancestors: &mut Vec<String>,
) -> Result<(), TreeError> {
for child in &manifest.children {
handle_child(backend, loader, parent_id, parent_meta, child, state, ancestors)?;
}
Ok(())
}
fn handle_child(
backend: &dyn GitBackend,
loader: &dyn PackLoader,
parent_id: usize,
parent_meta: &Path,
child: &ChildRef,
state: &mut BuildState,
ancestors: &mut Vec<String>,
) -> Result<(), TreeError> {
let identity = pack_identity_for_child(child);
if ancestors.iter().any(|s| s == &identity) {
let mut chain = ancestors.clone();
chain.push(identity);
return Err(TreeError::CycleDetected { chain });
}
let dest = parent_meta.join(child.effective_path());
check_dest_boundary(&dest, &child.effective_path())?;
let (child_manifest, is_synthetic) = match 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(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 });
ancestors.push(identity);
let result =
walk_recursive(backend, loader, child_id, &dest, &child_manifest, state, ancestors);
ancestors.pop();
result
}
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::graph_build",
"HEAD probe failed for {}: {e}",
dir.display()
);
None
}
}
}
fn pack_identity_for_root(path: &Path) -> String {
format!("path:{}", path.display())
}
fn pack_identity_for_child(child: &ChildRef) -> String {
match child.r#ref.as_deref() {
Some(r) if !r.is_empty() => format!("url:{}@{}", child.url, r),
_ => format!("url:{}", child.url),
}
}
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 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 validate_children_paths(manifest: &PackManifest) -> Result<(), TreeError> {
use crate::pack::validate::child_path::{
boundary_reject_reason, check_one as check_child_path, nfc_duplicate_path,
};
if let Some(path) = nfc_duplicate_path(&manifest.children) {
return Err(TreeError::ManifestPathEscape {
path,
reason: "duplicate child path under Unicode NFC normalization (case-insensitive FS collision risk)"
.to_string(),
});
}
for child in &manifest.children {
let segment = child.path.as_deref().map_or_else(|| child.effective_path(), str::to_string);
if let Some(reason) = boundary_reject_reason(&segment) {
return Err(TreeError::ManifestPathEscape {
path: segment,
reason: reason.to_string(),
});
}
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::graph_build",
"check_child_path returned unexpected variant: {other:?}",
);
debug_assert!(false, "check_child_path returned unexpected variant: {other:?}");
}
}
}
Ok(())
}