Skip to main content

guild_cli/graph/
project.rs

1use std::collections::{HashMap, HashSet};
2
3use crate::config::{ProjectConfig, ProjectName};
4use crate::error::GraphError;
5
6/// A directed acyclic graph of project dependencies.
7#[derive(Debug)]
8pub struct ProjectGraph {
9    /// Map of project name to its configuration.
10    projects: HashMap<ProjectName, ProjectConfig>,
11    /// Adjacency list: project -> set of projects it depends on.
12    edges: HashMap<ProjectName, HashSet<ProjectName>>,
13}
14
15impl ProjectGraph {
16    /// Build a project dependency graph from discovered projects.
17    ///
18    /// Validates that all dependency references are valid and that no cycles exist.
19    pub fn build(projects: Vec<ProjectConfig>) -> Result<Self, GraphError> {
20        let mut project_map: HashMap<ProjectName, ProjectConfig> = HashMap::new();
21        let mut edges: HashMap<ProjectName, HashSet<ProjectName>> = HashMap::new();
22
23        // Index all projects by name
24        for project in projects {
25            let name = project.name().clone();
26            edges.insert(name.clone(), HashSet::new());
27            project_map.insert(name, project);
28        }
29
30        // Build edges from depends_on declarations
31        for (name, project) in &project_map {
32            for dep in project.depends_on() {
33                if !project_map.contains_key(dep) {
34                    return Err(GraphError::UnknownProject {
35                        name: dep.to_string(),
36                        referenced_by: name.to_string(),
37                    });
38                }
39                edges.get_mut(name).unwrap().insert(dep.clone());
40            }
41        }
42
43        let graph = Self {
44            projects: project_map,
45            edges,
46        };
47
48        graph.check_cycles()?;
49
50        Ok(graph)
51    }
52
53    /// Returns all projects in topological order (dependencies before dependents).
54    pub fn topological_order(&self) -> Result<Vec<&ProjectName>, GraphError> {
55        let mut visited = HashSet::new();
56        let mut temp_visited = HashSet::new();
57        let mut order = Vec::new();
58
59        for name in self.projects.keys() {
60            if !visited.contains(name) {
61                self.topo_visit(name, &mut visited, &mut temp_visited, &mut order)?;
62            }
63        }
64
65        Ok(order)
66    }
67
68    /// Get a project configuration by name.
69    pub fn get(&self, name: &ProjectName) -> Option<&ProjectConfig> {
70        self.projects.get(name)
71    }
72
73    /// Get all project names.
74    pub fn project_names(&self) -> impl Iterator<Item = &ProjectName> {
75        self.projects.keys()
76    }
77
78    /// Get the direct dependencies of a project.
79    pub fn dependencies(&self, name: &ProjectName) -> Option<&HashSet<ProjectName>> {
80        self.edges.get(name)
81    }
82
83    /// Get the number of projects in the graph.
84    pub fn len(&self) -> usize {
85        self.projects.len()
86    }
87
88    /// Returns true if the graph has no projects.
89    pub fn is_empty(&self) -> bool {
90        self.projects.is_empty()
91    }
92
93    fn check_cycles(&self) -> Result<(), GraphError> {
94        // topological_order detects cycles via temp_visited
95        self.topological_order().map(|_| ())
96    }
97
98    fn topo_visit<'a>(
99        &'a self,
100        name: &'a ProjectName,
101        visited: &mut HashSet<ProjectName>,
102        temp_visited: &mut HashSet<ProjectName>,
103        order: &mut Vec<&'a ProjectName>,
104    ) -> Result<(), GraphError> {
105        if temp_visited.contains(name) {
106            return Err(GraphError::CycleDetected {
107                cycle: name.to_string(),
108            });
109        }
110        if visited.contains(name) {
111            return Ok(());
112        }
113
114        temp_visited.insert(name.clone());
115
116        if let Some(deps) = self.edges.get(name) {
117            for dep in deps {
118                self.topo_visit(dep, visited, temp_visited, order)?;
119            }
120        }
121
122        temp_visited.remove(name);
123        visited.insert(name.clone());
124        order.push(name);
125
126        Ok(())
127    }
128}
129
130#[cfg(test)]
131mod tests {
132    use super::*;
133    use crate::config::ProjectConfig;
134    use std::path::PathBuf;
135
136    fn make_project(name: &str, deps: &[&str]) -> ProjectConfig {
137        let deps_str = if deps.is_empty() {
138            String::new()
139        } else {
140            let dep_list: Vec<String> = deps.iter().map(|d| format!("\"{d}\"")).collect();
141            format!("depends_on = [{}]", dep_list.join(", "))
142        };
143        let toml = format!(
144            "[project]\nname = \"{name}\"\n{deps_str}\n\n[targets.build]\ncommand = \"echo build\"\n"
145        );
146        ProjectConfig::from_str(&toml, PathBuf::from(format!("/tmp/{name}"))).unwrap()
147    }
148
149    #[test]
150    fn test_build_simple_graph() {
151        let projects = vec![make_project("app", &["lib"]), make_project("lib", &[])];
152        let graph = ProjectGraph::build(projects).unwrap();
153        assert_eq!(graph.len(), 2);
154    }
155
156    #[test]
157    fn test_topological_order() {
158        let projects = vec![make_project("app", &["lib"]), make_project("lib", &[])];
159        let graph = ProjectGraph::build(projects).unwrap();
160        let order = graph.topological_order().unwrap();
161        let names: Vec<&str> = order.iter().map(|n| n.as_str()).collect();
162        let lib_idx = names.iter().position(|&n| n == "lib").unwrap();
163        let app_idx = names.iter().position(|&n| n == "app").unwrap();
164        assert!(lib_idx < app_idx);
165    }
166
167    #[test]
168    fn test_cycle_detection() {
169        let projects = vec![make_project("a", &["b"]), make_project("b", &["a"])];
170        assert!(ProjectGraph::build(projects).is_err());
171    }
172
173    #[test]
174    fn test_unknown_dependency() {
175        let projects = vec![make_project("app", &["nonexistent"])];
176        assert!(ProjectGraph::build(projects).is_err());
177    }
178
179    #[test]
180    fn test_empty_graph() {
181        let graph = ProjectGraph::build(vec![]).unwrap();
182        assert!(graph.is_empty());
183    }
184
185    #[test]
186    fn test_diamond_dependency() {
187        let projects = vec![
188            make_project("app", &["lib-a", "lib-b"]),
189            make_project("lib-a", &["core"]),
190            make_project("lib-b", &["core"]),
191            make_project("core", &[]),
192        ];
193        let graph = ProjectGraph::build(projects).unwrap();
194        let order = graph.topological_order().unwrap();
195        let names: Vec<&str> = order.iter().map(|n| n.as_str()).collect();
196        let core_idx = names.iter().position(|&n| n == "core").unwrap();
197        let app_idx = names.iter().position(|&n| n == "app").unwrap();
198        assert!(core_idx < app_idx);
199    }
200}