use crate::hash::content_hash_dir;
use indexmap::IndexMap;
use kdo_core::{DepKind, Dependency, KdoError, Project};
use kdo_resolver::{manifest_filenames, parse_manifest};
use petgraph::algo::is_cyclic_directed;
use petgraph::graph::{DiGraph, NodeIndex};
use petgraph::visit::Bfs;
use petgraph::Direction;
use rayon::prelude::*;
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use tracing::{debug, info, warn};
#[derive(Debug)]
pub struct WorkspaceGraph {
pub root: PathBuf,
pub graph: DiGraph<Project, DepKind>,
name_to_idx: IndexMap<String, NodeIndex>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct ProjectSummary {
pub name: String,
pub language: String,
pub summary: Option<String>,
pub dep_count: usize,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct GraphOutput {
pub projects: Vec<ProjectSummary>,
pub edges: Vec<(String, String, String)>,
}
impl WorkspaceGraph {
pub fn discover(root: &Path) -> Result<Self, KdoError> {
let root = root
.canonicalize()
.map_err(|_| KdoError::ManifestNotFound(root.to_path_buf()))?;
info!(root = %root.display(), "discovering workspace");
let manifest_names = manifest_filenames();
let walker = ignore::WalkBuilder::new(&root)
.hidden(true)
.git_ignore(true)
.add_custom_ignore_filename(".kdoignore")
.filter_entry(|entry| {
let name = entry.file_name().to_string_lossy();
!matches!(
name.as_ref(),
"node_modules" | "target" | ".git" | ".kdo" | "dist" | "build" | "__pycache__"
)
})
.build();
let manifest_paths: Vec<PathBuf> = walker
.filter_map(|entry| entry.ok())
.filter(|entry| {
entry.file_type().map(|ft| ft.is_file()).unwrap_or(false)
&& entry
.file_name()
.to_str()
.map(|name| manifest_names.contains(&name))
.unwrap_or(false)
})
.map(|entry| entry.into_path())
.collect();
let manifest_paths = apply_workspace_globs(&root, manifest_paths);
debug!(count = manifest_paths.len(), "found manifest files");
let filtered = filter_manifests(&manifest_paths);
let results: Vec<Result<(Project, Vec<Dependency>), KdoError>> = filtered
.par_iter()
.map(|path| parse_manifest(path, &root))
.collect();
let mut graph = DiGraph::new();
let mut name_to_idx = IndexMap::new();
let mut all_deps: Vec<(String, Vec<Dependency>)> = Vec::new();
for result in results {
match result {
Ok((mut project, deps)) => {
project.content_hash = content_hash_dir(&project.path);
let name = project.name.clone();
let idx = graph.add_node(project);
name_to_idx.insert(name.clone(), idx);
all_deps.push((name, deps));
}
Err(KdoError::ManifestNotFound(_)) => {
continue;
}
Err(e) => {
warn!(error = %e, "skipping unparseable manifest");
}
}
}
for (source_name, deps) in &all_deps {
if let Some(&source_idx) = name_to_idx.get(source_name) {
for dep in deps {
if let Some(&target_idx) = name_to_idx.get(&dep.name) {
graph.add_edge(source_idx, target_idx, dep.kind.clone());
}
}
}
}
info!(
projects = name_to_idx.len(),
edges = graph.edge_count(),
"workspace graph built"
);
Ok(Self {
root,
graph,
name_to_idx,
})
}
pub fn get_project(&self, name: &str) -> Result<&Project, KdoError> {
let idx = self
.name_to_idx
.get(name)
.ok_or_else(|| KdoError::ProjectNotFound(name.to_string()))?;
Ok(&self.graph[*idx])
}
pub fn projects(&self) -> Vec<&Project> {
self.graph.node_weights().collect()
}
pub fn dependency_closure(&self, name: &str) -> Result<Vec<&Project>, KdoError> {
let start = self
.name_to_idx
.get(name)
.ok_or_else(|| KdoError::ProjectNotFound(name.to_string()))?;
let mut visited = Vec::new();
let mut stack = vec![*start];
let mut seen = std::collections::HashSet::new();
seen.insert(*start);
while let Some(node) = stack.pop() {
if node != *start {
visited.push(&self.graph[node]);
}
for neighbor in self.graph.neighbors_directed(node, Direction::Outgoing) {
if seen.insert(neighbor) {
stack.push(neighbor);
}
}
}
Ok(visited)
}
pub fn affected_set(&self, name: &str) -> Result<Vec<&Project>, KdoError> {
let start = self
.name_to_idx
.get(name)
.ok_or_else(|| KdoError::ProjectNotFound(name.to_string()))?;
let reversed = petgraph::visit::Reversed(&self.graph);
let mut bfs = Bfs::new(&reversed, *start);
let mut affected = Vec::new();
while let Some(node) = bfs.next(&reversed) {
if node != *start {
affected.push(&self.graph[node]);
}
}
Ok(affected)
}
pub fn detect_cycles(&self) -> Result<(), KdoError> {
if is_cyclic_directed(&self.graph) {
let cycle_desc = find_cycle_description(&self.graph);
return Err(KdoError::CircularDependency(cycle_desc));
}
Ok(())
}
pub fn project_summaries(&self) -> Vec<ProjectSummary> {
self.graph
.node_indices()
.map(|idx| {
let project = &self.graph[idx];
let dep_count = self
.graph
.neighbors_directed(idx, Direction::Outgoing)
.count();
ProjectSummary {
name: project.name.clone(),
language: project.language.to_string(),
summary: project.context_summary.clone(),
dep_count,
}
})
.collect()
}
pub fn to_graph_output(&self) -> GraphOutput {
let projects = self.project_summaries();
let edges = self
.graph
.edge_indices()
.filter_map(|edge_idx| {
let (source, target) = self.graph.edge_endpoints(edge_idx)?;
let kind = &self.graph[edge_idx];
Some((
self.graph[source].name.clone(),
self.graph[target].name.clone(),
kind.to_string(),
))
})
.collect();
GraphOutput { projects, edges }
}
pub fn to_dot(&self) -> String {
let mut dot = String::from("digraph workspace {\n rankdir=LR;\n");
for idx in self.graph.node_indices() {
let project = &self.graph[idx];
dot.push_str(&format!(
" \"{}\" [label=\"{}\\n({})\"];\n",
project.name, project.name, project.language
));
}
for edge_idx in self.graph.edge_indices() {
if let Some((source, target)) = self.graph.edge_endpoints(edge_idx) {
let kind = &self.graph[edge_idx];
dot.push_str(&format!(
" \"{}\" -> \"{}\" [label=\"{}\"];\n",
self.graph[source].name, self.graph[target].name, kind
));
}
}
dot.push_str("}\n");
dot
}
pub fn to_text(&self) -> String {
let mut out = String::new();
for idx in self.graph.node_indices() {
let project = &self.graph[idx];
out.push_str(&format!("{} ({})\n", project.name, project.language));
for neighbor in self.graph.neighbors_directed(idx, Direction::Outgoing) {
let dep = &self.graph[neighbor];
out.push_str(&format!(" -> {}\n", dep.name));
}
}
out
}
pub fn dependency_closure_json(&self, project: &str) -> Result<String, KdoError> {
let deps = self.dependency_closure(project)?;
let names: Vec<&str> = deps.iter().map(|p| p.name.as_str()).collect();
serde_json::to_string_pretty(&names).map_err(|e| KdoError::ParseError {
path: std::path::PathBuf::from("<json>"),
source: e.into(),
})
}
pub fn affected_set_json(&self, project: &str) -> Result<String, KdoError> {
let affected = self.affected_set(project)?;
let names: Vec<&str> = affected.iter().map(|p| p.name.as_str()).collect();
serde_json::to_string_pretty(&names).map_err(|e| KdoError::ParseError {
path: std::path::PathBuf::from("<json>"),
source: e.into(),
})
}
pub fn topological_order(&self) -> Vec<&Project> {
match petgraph::algo::toposort(&self.graph, None) {
Ok(indices) => indices.iter().map(|idx| &self.graph[*idx]).collect(),
Err(_) => self.projects(),
}
}
pub fn affected_since_ref(&self, base_ref: &str) -> Result<Vec<String>, KdoError> {
let output = std::process::Command::new("git")
.args(["diff", "--name-only", &format!("{base_ref}...HEAD")])
.current_dir(&self.root)
.output()?;
if !output.status.success() {
let output = std::process::Command::new("git")
.args(["diff", "--name-only", base_ref])
.current_dir(&self.root)
.output()?;
if !output.status.success() {
return Ok(Vec::new());
}
return self.map_paths_to_projects(&String::from_utf8_lossy(&output.stdout));
}
self.map_paths_to_projects(&String::from_utf8_lossy(&output.stdout))
}
fn map_paths_to_projects(&self, diff_output: &str) -> Result<Vec<String>, KdoError> {
let mut affected = std::collections::HashSet::new();
for line in diff_output.lines() {
let changed_path = self.root.join(line.trim());
for project in self.projects() {
if changed_path.starts_with(&project.path) {
affected.insert(project.name.clone());
}
}
}
let mut result: Vec<String> = affected.into_iter().collect();
result.sort();
Ok(result)
}
}
fn apply_workspace_globs(root: &Path, paths: Vec<PathBuf>) -> Vec<PathBuf> {
use globset::{Glob, GlobSetBuilder};
let kdo_toml = root.join("kdo.toml");
if !kdo_toml.exists() {
return paths;
}
let Ok(config) = kdo_core::WorkspaceConfig::load(&kdo_toml) else {
return paths;
};
let build_set = |patterns: &[String]| -> Option<globset::GlobSet> {
if patterns.is_empty() {
return None;
}
let mut builder = GlobSetBuilder::new();
for p in patterns {
if let Ok(g) = Glob::new(p) {
builder.add(g);
}
}
builder.build().ok()
};
let include = build_set(&config.workspace.project_globs);
let exclude = build_set(&config.workspace.exclude);
paths
.into_iter()
.filter(|path| {
let Ok(rel) = path.strip_prefix(root) else {
return true;
};
let parent_rel = rel.parent().unwrap_or(Path::new(""));
if let Some(ex) = &exclude {
if ex.is_match(parent_rel) {
return false;
}
}
if let Some(inc) = &include {
return inc.is_match(parent_rel);
}
true
})
.collect()
}
fn filter_manifests(paths: &[PathBuf]) -> Vec<PathBuf> {
let mut by_dir: IndexMap<PathBuf, Vec<PathBuf>> = IndexMap::new();
for path in paths {
let dir = path.parent().unwrap_or(Path::new(".")).to_path_buf();
by_dir.entry(dir).or_default().push(path.clone());
}
let mut filtered = Vec::new();
for (_dir, manifests) in &by_dir {
let has_anchor = manifests
.iter()
.any(|p| p.file_name().map(|f| f == "Anchor.toml").unwrap_or(false));
for manifest in manifests {
let filename = manifest
.file_name()
.map(|f| f.to_string_lossy().to_string())
.unwrap_or_default();
if has_anchor && filename == "Cargo.toml" {
continue;
}
filtered.push(manifest.clone());
}
}
filtered
}
fn find_cycle_description(graph: &DiGraph<Project, DepKind>) -> String {
match petgraph::algo::toposort(graph, None) {
Ok(_) => "unknown cycle".to_string(),
Err(cycle) => {
let node = &graph[cycle.node_id()];
format!("cycle involving '{}'", node.name)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_filter_manifests_anchor_priority() {
let paths = vec![
PathBuf::from("/project/Anchor.toml"),
PathBuf::from("/project/Cargo.toml"),
PathBuf::from("/project/sub/Cargo.toml"),
];
let filtered = filter_manifests(&paths);
assert!(filtered.contains(&PathBuf::from("/project/Anchor.toml")));
assert!(!filtered.contains(&PathBuf::from("/project/Cargo.toml")));
assert!(filtered.contains(&PathBuf::from("/project/sub/Cargo.toml")));
}
}