spinne_core/traverse/
workspace.rs

1use ignore::{DirEntry, WalkBuilder};
2use petgraph::{algo::toposort, graph::NodeIndex, Graph};
3use spinne_logger::Logger;
4use std::path::PathBuf;
5
6use super::project_types::{ConsumerProject, Project, SourceProject};
7use crate::{graph::ComponentRegistry, package_json::PackageJson};
8
9/// Represents a workspace containing multiple projects.
10/// A workspace is a directory that contains multiple projects and holds a shared component registry
11pub struct Workspace {
12    workspace_root: PathBuf,
13    projects: Vec<Box<dyn Project>>,
14    graph: Graph<usize, ()>,
15    component_registry: ComponentRegistry,
16}
17
18impl Workspace {
19    /// Creates a new Workspace instance from a given path
20    pub fn new(workspace_root: PathBuf) -> Self {
21        Self {
22            workspace_root,
23            projects: Vec::new(),
24            graph: Graph::new(),
25            component_registry: ComponentRegistry::new(),
26        }
27    }
28
29    /// Gets a reference to the component registry
30    pub fn get_component_registry(&self) -> &ComponentRegistry {
31        &self.component_registry
32    }
33
34    /// Gets a mutable reference to the component registry
35    pub fn get_component_registry_mut(&mut self) -> &mut ComponentRegistry {
36        &mut self.component_registry
37    }
38
39    /// Discovers and analyzes all projects in the workspace
40    pub fn discover_projects(&mut self) {
41        Logger::info(&format!(
42            "Traversing workspace: {}",
43            self.workspace_root.display()
44        ));
45
46        // First pass: discover all projects
47        let mut discovered_projects = Vec::new();
48
49        let walker = WalkBuilder::new(&self.workspace_root)
50            .hidden(false) // We want to find .git folders
51            .git_ignore(true)
52            .build();
53
54        for entry in walker {
55            match entry {
56                Ok(entry) => self.discover_project(&entry, &mut discovered_projects),
57                Err(e) => Logger::error(&format!("Error while walking directory: {}", e)),
58            }
59        }
60
61        Logger::info(&format!("Found {} projects", discovered_projects.len()));
62
63        // Second pass: classify projects as source or consumer
64        self.classify_projects(discovered_projects);
65    }
66
67    /// Discovers a single project and adds it to the list of discovered projects
68    fn discover_project(&self, entry: &DirEntry, discovered_projects: &mut Vec<(PathBuf, String)>) {
69        let path = entry.path();
70
71        // Only check directories
72        if !path.is_dir() {
73            return;
74        }
75
76        // Check if this is a .git directory
77        if path.file_name().map_or(false, |name| name == ".git") {
78            let project_root = path.parent().unwrap_or(path).to_path_buf();
79
80            // Check if package.json exists in the project root
81            let package_json_path = project_root.join("package.json");
82            if package_json_path.exists() {
83                // Read the project name from package.json
84                if let Some(package_json) = PackageJson::read(&package_json_path, false) {
85                    if let Some(project_name) = package_json.name {
86                        Logger::info(&format!(
87                            "Found project at: {} ({})",
88                            project_root.display(),
89                            project_name
90                        ));
91                        discovered_projects.push((project_root, project_name));
92                    }
93                }
94            }
95        }
96    }
97
98    /// Classifies discovered projects as source or consumer projects
99    fn classify_projects(&mut self, discovered_projects: Vec<(PathBuf, String)>) {
100        // First, create a map of project names to their indices for quick lookup
101        let mut project_indices = std::collections::HashMap::new();
102
103        // Create a temporary graph to track dependencies
104        let mut temp_graph = Graph::<usize, ()>::new();
105
106        // First pass: add all projects to the graph and create source projects
107        for (i, (project_root, project_name)) in discovered_projects.iter().enumerate() {
108            // Add node to the graph
109            let node_idx = temp_graph.add_node(i);
110            project_indices.insert(project_name.clone(), node_idx);
111
112            // Create a source project with a reference to the workspace's component registry
113            let source_project =
114                SourceProject::new(project_root.clone(), &mut self.component_registry);
115            self.projects.push(Box::new(source_project));
116        }
117
118        // Second pass: add edges based on dependencies and identify consumer projects
119        for (i, (project_root, project_name)) in discovered_projects.iter().enumerate() {
120            // Read dependencies from package.json
121            if let Some(package_json) = PackageJson::read(&project_root.join("package.json"), true)
122            {
123                if let Some(deps) = package_json.get_all_dependencies() {
124                    // For each dependency, check if it matches any project name
125                    for dep_name in deps {
126                        if let Some(&dep_idx) = project_indices.get(&dep_name) {
127                            // Add edge from dependent to dependency
128                            let node_idx = NodeIndex::new(i);
129                            temp_graph.add_edge(node_idx, dep_idx, ());
130                        }
131                    }
132                }
133            }
134        }
135
136        // Third pass: identify consumer projects and update the graph
137        let mut consumer_indices = Vec::new();
138
139        for (i, (project_root, project_name)) in discovered_projects.iter().enumerate() {
140            // Check if this project has any outgoing edges (depends on other projects)
141            let node_idx = NodeIndex::new(i);
142            if temp_graph
143                .edges_directed(node_idx, petgraph::Direction::Outgoing)
144                .count()
145                > 0
146            {
147                // This is a consumer project
148                consumer_indices.push(i);
149
150                // Replace the source project with a consumer project
151                let mut consumer_project =
152                    ConsumerProject::new(project_root.clone(), &mut self.component_registry);
153
154                // Add source projects that this consumer depends on
155                if let Some(package_json) =
156                    PackageJson::read(&project_root.join("package.json"), true)
157                {
158                    if let Some(deps) = package_json.get_all_dependencies() {
159                        for dep_name in deps {
160                            if let Some(&dep_idx) = project_indices.get(&dep_name) {
161                                let dep_i = temp_graph[dep_idx];
162                                if let Some(source_project) = self
163                                    .projects
164                                    .get(dep_i)
165                                    .and_then(|p| p.as_any().downcast_ref::<SourceProject>())
166                                {
167                                    consumer_project.add_source_project(source_project.clone());
168                                }
169                            }
170                        }
171                    }
172                }
173
174                // Replace the source project with the consumer project
175                self.projects[i] = Box::new(consumer_project);
176            }
177        }
178
179        // Update the graph with the final project structure
180        self.graph = temp_graph;
181
182        Logger::info(&format!(
183            "Classified {} projects as consumers",
184            consumer_indices.len()
185        ));
186    }
187
188    /// Traverses all discovered projects to analyze their components in dependency order
189    pub fn traverse_projects(&mut self, exclude: &Vec<String>, include: &Vec<String>) {
190        // Build dependency graph
191        let dep_graph = self.build_dependency_graph();
192        self.graph = dep_graph;
193
194        // Get topological sort
195        match toposort(&self.graph, None) {
196            Ok(sorted_projects) => {
197                Logger::info("Traversing projects in dependency order");
198                // Traverse in reverse order to ensure dependencies are processed first
199                for node_idx in sorted_projects.iter().rev() {
200                    let project_idx = self.graph[*node_idx];
201                    let project = &mut self.projects[project_idx];
202                    Logger::info(&format!("Traversing project: {}", project.get_name()));
203                    project.traverse(exclude, include);
204                }
205            }
206            Err(_) => {
207                Logger::warn(
208                    "Circular dependencies detected, falling back to sequential traversal",
209                );
210                // Fallback to regular traversal
211                for project in &mut self.projects {
212                    project.traverse(exclude, include);
213                }
214            }
215        }
216    }
217
218    /// Gets a reference to all discovered projects
219    pub fn get_projects(&self) -> &Vec<Box<dyn Project>> {
220        &self.projects
221    }
222
223    fn build_dependency_graph(&self) -> Graph<usize, ()> {
224        let mut graph = Graph::<usize, ()>::new();
225
226        // Add nodes for each project with their index
227        let node_indices: Vec<_> = (0..self.projects.len())
228            .map(|i| graph.add_node(i))
229            .collect();
230
231        // Add edges based on dependencies
232        for (i, dependent_project) in self.projects.iter().enumerate() {
233            // Get all dependencies of the current project
234            if let Some(deps) = dependent_project.get_dependencies() {
235                // For each dependency, check if it matches any project name
236                for dep_name in deps {
237                    // Find the project index with this name
238                    if let Some(dep_idx) =
239                        self.projects.iter().position(|p| p.get_name() == dep_name)
240                    {
241                        Logger::debug(
242                            &format!(
243                                "Found dependency: {} -> {}",
244                                dependent_project.get_name(),
245                                self.projects[dep_idx].get_name()
246                            ),
247                            2,
248                        );
249                        // Add edge from dependent to dependency
250                        graph.add_edge(node_indices[i], node_indices[dep_idx], ());
251                    }
252                }
253            }
254        }
255
256        graph
257    }
258}
259
260#[cfg(test)]
261mod tests {
262    use super::*;
263    use crate::util::test_utils;
264
265    #[test]
266    fn test_workspace_discovery() {
267        let temp_dir = test_utils::create_mock_project(&vec![
268            // Project 1
269            ("projects/project1/.git/HEAD", "ref: refs/heads/main"),
270            ("projects/project1/package.json", r#"{"name": "project1"}"#),
271            (
272                "projects/project1/src/components/Button.tsx",
273                r#"
274                    import React from 'react';
275                    export const Button = () => <button>Click me</button>;
276                "#,
277            ),
278            // Project 2 in subdirectory
279            ("projects/project2/.git/HEAD", "ref: refs/heads/main"),
280            ("projects/project2/package.json", r#"{"name": "project2"}"#),
281            (
282                "projects/project2/src/App.tsx",
283                r#"
284                    import React from 'react';
285                    export const App = () => <div>Hello</div>;
286                "#,
287            ),
288        ]);
289
290        let mut workspace = Workspace::new(temp_dir.path().to_path_buf());
291        workspace.discover_projects();
292
293        assert_eq!(workspace.get_projects().len(), 2);
294    }
295
296    #[test]
297    fn test_project_sorting() {
298        let temp_dir = test_utils::create_mock_project(&vec![
299            // Project 1 - has no dependencies
300            ("project1/.git/HEAD", "ref: refs/heads/main"),
301            ("project1/package.json", r#"{"name": "project1"}"#),
302            // Project 2 - depends on project1
303            ("project2/.git/HEAD", "ref: refs/heads/main"),
304            (
305                "project2/package.json",
306                r#"{
307                "name": "project2",
308                "dependencies": {
309                    "project1": "1.0.0"
310                }
311            }"#,
312            ),
313            // Project 3 - depends on project2
314            ("project3/.git/HEAD", "ref: refs/heads/main"),
315            (
316                "project3/package.json",
317                r#"{
318                "name": "project3",
319                "dependencies": {
320                    "project2": "1.0.0"
321                }
322            }"#,
323            ),
324        ]);
325
326        let mut workspace = Workspace::new(temp_dir.path().to_path_buf());
327        workspace.discover_projects();
328        workspace.traverse_projects(&vec![], &vec![]);
329
330        let graph = workspace.graph;
331
332        assert_eq!(graph.edge_count(), 2);
333        assert_eq!(graph.node_weight(0.into()), Some(&0));
334        assert_eq!(graph.node_weight(1.into()), Some(&1));
335        assert_eq!(graph.node_weight(2.into()), Some(&2));
336    }
337
338    #[test]
339    fn test_source_consumer_component_flow() {
340        let temp_dir = test_utils::create_mock_project(&vec![
341            // Source project with a Button component
342            ("source-lib/.git/HEAD", "ref: refs/heads/main"),
343            ("source-lib/package.json", r#"{"name": "source-lib"}"#),
344            (
345                "source-lib/src/components/Button.tsx",
346                r#"
347                import React from 'react';
348
349                export interface ButtonProps {
350                    label: string;
351                    onClick: () => void;
352                }
353
354                export const Button: React.FC<ButtonProps> = ({ label, onClick }) => {
355                    return <button onClick={onClick}>{label}</button>;
356                };
357                "#,
358            ),
359            (
360                "source-lib/src/components/index.ts",
361                r#"export * from './Button';"#,
362            ),
363            // Consumer project that uses the Button component
364            ("consumer-app/.git/HEAD", "ref: refs/heads/main"),
365            (
366                "consumer-app/package.json",
367                r#"{
368                    "name": "consumer-app",
369                    "dependencies": {
370                        "source-lib": "1.0.0"
371                    }
372                }"#,
373            ),
374            (
375                "consumer-app/src/App.tsx",
376                r#"
377                import React from 'react';
378                import { Button } from 'source-lib';
379
380                export const App: React.FC = () => {
381                    const handleClick = () => console.log('clicked');
382                    return <Button label="Click me" onClick={handleClick} />;
383                };
384                "#,
385            ),
386        ]);
387
388        // Create and initialize workspace
389        let mut workspace = Workspace::new(temp_dir.path().to_path_buf());
390        workspace.discover_projects();
391
392        // Verify project discovery and classification
393        let projects = workspace.get_projects();
394        assert_eq!(
395            projects.len(),
396            2,
397            "Should find both source and consumer projects"
398        );
399
400        // Find the consumer project
401        let consumer_project = projects.iter().find(|p| p.get_name() == "consumer-app");
402        assert!(consumer_project.is_some(), "Should find consumer project");
403        let consumer_project = consumer_project.unwrap();
404
405        // Verify it's actually a ConsumerProject
406        assert!(
407            consumer_project
408                .as_any()
409                .downcast_ref::<ConsumerProject>()
410                .is_some(),
411            "consumer-app should be a ConsumerProject"
412        );
413
414        // Find the source project
415        let source_project = projects.iter().find(|p| p.get_name() == "source-lib");
416        assert!(source_project.is_some(), "Should find source project");
417        let source_project = source_project.unwrap();
418
419        // Verify it's actually a SourceProject
420        assert!(
421            source_project
422                .as_any()
423                .downcast_ref::<SourceProject>()
424                .is_some(),
425            "source-lib should be a SourceProject"
426        );
427
428        // Analyze all projects
429        workspace.traverse_projects(&vec![], &vec![]);
430
431        // Get the component registry to verify connections
432        let registry = workspace.get_component_registry();
433
434        // Verify Button component exists in source project
435        let button_component = registry.find_component("Button", "source-lib");
436        assert!(
437            button_component.is_some(),
438            "Button component should exist in source project"
439        );
440
441        // Verify App component exists in consumer project
442        let app_component = registry.find_component("App", "consumer-app");
443        assert!(
444            app_component.is_some(),
445            "App component should exist in consumer project"
446        );
447
448        println!("{}", workspace.get_component_registry().to_serializable());
449
450        // Verify the connection between App and Button
451        if let Some(app_info) = app_component {
452            let app_deps = registry.get_dependencies(app_info.node.id);
453            assert_eq!(app_deps.len(), 1, "App should have one dependency");
454
455            let button_dep = app_deps.first().unwrap();
456            assert_eq!(
457                button_dep.1.project_context,
458                Some("source-lib".to_string()),
459                "Button dependency should reference source-lib project"
460            );
461        }
462    }
463}