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
9pub 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 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 pub fn get_component_registry(&self) -> &ComponentRegistry {
31 &self.component_registry
32 }
33
34 pub fn get_component_registry_mut(&mut self) -> &mut ComponentRegistry {
36 &mut self.component_registry
37 }
38
39 pub fn discover_projects(&mut self) {
41 Logger::info(&format!(
42 "Traversing workspace: {}",
43 self.workspace_root.display()
44 ));
45
46 let mut discovered_projects = Vec::new();
48
49 let walker = WalkBuilder::new(&self.workspace_root)
50 .hidden(false) .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 self.classify_projects(discovered_projects);
65 }
66
67 fn discover_project(&self, entry: &DirEntry, discovered_projects: &mut Vec<(PathBuf, String)>) {
69 let path = entry.path();
70
71 if !path.is_dir() {
73 return;
74 }
75
76 if path.file_name().map_or(false, |name| name == ".git") {
78 let project_root = path.parent().unwrap_or(path).to_path_buf();
79
80 let package_json_path = project_root.join("package.json");
82 if package_json_path.exists() {
83 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 fn classify_projects(&mut self, discovered_projects: Vec<(PathBuf, String)>) {
100 let mut project_indices = std::collections::HashMap::new();
102
103 let mut temp_graph = Graph::<usize, ()>::new();
105
106 for (i, (project_root, project_name)) in discovered_projects.iter().enumerate() {
108 let node_idx = temp_graph.add_node(i);
110 project_indices.insert(project_name.clone(), node_idx);
111
112 let source_project =
114 SourceProject::new(project_root.clone(), &mut self.component_registry);
115 self.projects.push(Box::new(source_project));
116 }
117
118 for (i, (project_root, project_name)) in discovered_projects.iter().enumerate() {
120 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 dep_name in deps {
126 if let Some(&dep_idx) = project_indices.get(&dep_name) {
127 let node_idx = NodeIndex::new(i);
129 temp_graph.add_edge(node_idx, dep_idx, ());
130 }
131 }
132 }
133 }
134 }
135
136 let mut consumer_indices = Vec::new();
138
139 for (i, (project_root, project_name)) in discovered_projects.iter().enumerate() {
140 let node_idx = NodeIndex::new(i);
142 if temp_graph
143 .edges_directed(node_idx, petgraph::Direction::Outgoing)
144 .count()
145 > 0
146 {
147 consumer_indices.push(i);
149
150 let mut consumer_project =
152 ConsumerProject::new(project_root.clone(), &mut self.component_registry);
153
154 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 self.projects[i] = Box::new(consumer_project);
176 }
177 }
178
179 self.graph = temp_graph;
181
182 Logger::info(&format!(
183 "Classified {} projects as consumers",
184 consumer_indices.len()
185 ));
186 }
187
188 pub fn traverse_projects(&mut self, exclude: &Vec<String>, include: &Vec<String>) {
190 let dep_graph = self.build_dependency_graph();
192 self.graph = dep_graph;
193
194 match toposort(&self.graph, None) {
196 Ok(sorted_projects) => {
197 Logger::info("Traversing projects in dependency order");
198 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 for project in &mut self.projects {
212 project.traverse(exclude, include);
213 }
214 }
215 }
216 }
217
218 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 let node_indices: Vec<_> = (0..self.projects.len())
228 .map(|i| graph.add_node(i))
229 .collect();
230
231 for (i, dependent_project) in self.projects.iter().enumerate() {
233 if let Some(deps) = dependent_project.get_dependencies() {
235 for dep_name in deps {
237 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 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 ("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 ("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 ("project1/.git/HEAD", "ref: refs/heads/main"),
301 ("project1/package.json", r#"{"name": "project1"}"#),
302 ("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 ("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-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-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 let mut workspace = Workspace::new(temp_dir.path().to_path_buf());
390 workspace.discover_projects();
391
392 let projects = workspace.get_projects();
394 assert_eq!(
395 projects.len(),
396 2,
397 "Should find both source and consumer projects"
398 );
399
400 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 assert!(
407 consumer_project
408 .as_any()
409 .downcast_ref::<ConsumerProject>()
410 .is_some(),
411 "consumer-app should be a ConsumerProject"
412 );
413
414 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 assert!(
421 source_project
422 .as_any()
423 .downcast_ref::<SourceProject>()
424 .is_some(),
425 "source-lib should be a SourceProject"
426 );
427
428 workspace.traverse_projects(&vec![], &vec![]);
430
431 let registry = workspace.get_component_registry();
433
434 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 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 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}