Skip to main content

forgekit_core/workspace/
mod.rs

1use std::path::{Path, PathBuf};
2
3use crate::error::{ForgeError, Result};
4use crate::project::ProjectInfo;
5
6#[derive(Debug, Clone)]
7pub struct Workspace {
8    pub root: PathBuf,
9    pub projects: Vec<ProjectInfo>,
10}
11
12impl Workspace {
13    pub fn detect(path: &Path) -> Result<Option<Workspace>> {
14        let root = find_workspace_root(path)?;
15        let Some(root) = root else {
16            return Ok(None);
17        };
18        let projects = discover_projects(&root);
19        Ok(Some(Workspace { root, projects }))
20    }
21
22    pub fn open(path: &Path) -> Result<Workspace> {
23        let root = find_workspace_root(path)?.ok_or_else(|| {
24            ForgeError::ToolError(format!("no workspace root found from {}", path.display()))
25        })?;
26        let projects = discover_projects(&root);
27        Ok(Workspace { root, projects })
28    }
29
30    pub fn project_for_path(&self, file: &Path) -> Option<&ProjectInfo> {
31        let canonical = file.canonicalize().ok()?;
32        self.projects
33            .iter()
34            .filter(|p| {
35                p.root
36                    .canonicalize()
37                    .ok()
38                    .is_some_and(|r| canonical.starts_with(r))
39            })
40            .max_by_key(|p| {
41                p.root
42                    .canonicalize()
43                    .ok()
44                    .map(|r| r.components().count())
45                    .unwrap_or(0)
46            })
47    }
48}
49
50fn find_workspace_root(start: &Path) -> Result<Option<PathBuf>> {
51    let mut current = if start.is_absolute() {
52        start.to_path_buf()
53    } else {
54        std::env::current_dir()?.join(start)
55    };
56
57    loop {
58        if is_workspace_marker(&current) {
59            return Ok(Some(current));
60        }
61        current = match current.parent() {
62            Some(p) => p.to_path_buf(),
63            None => return Ok(None),
64        };
65    }
66}
67
68fn is_workspace_marker(dir: &Path) -> bool {
69    [
70        "Cargo.toml",
71        "package.json",
72        "go.mod",
73        "pnpm-workspace.yaml",
74        "rush.json",
75        "Lerna.json",
76        "bazel/WORKSPACE",
77        "WORKSPACE",
78        "BUCK",
79    ]
80    .iter()
81    .any(|marker| dir.join(marker).exists())
82}
83
84fn discover_projects(root: &Path) -> Vec<ProjectInfo> {
85    let mut projects = Vec::new();
86
87    if root.join("Cargo.toml").exists() {
88        if let Some(info) = detect_rust_workspace(root) {
89            projects.extend(info);
90        } else {
91            projects.push(single_rust_project(root));
92        }
93    }
94
95    if root.join("pnpm-workspace.yaml").exists() {
96        projects.extend(discover_pnpm_packages(root));
97    } else if root.join("package.json").exists() {
98        projects.push(single_node_project(root));
99    }
100
101    if root.join("go.mod").exists() {
102        projects.push(single_go_project(root));
103    }
104
105    if projects.is_empty() {
106        projects.push(generic_project(root));
107    }
108
109    projects
110}
111
112fn detect_rust_workspace(root: &Path) -> Option<Vec<ProjectInfo>> {
113    let cargo_toml = std::fs::read_to_string(root.join("Cargo.toml")).ok()?;
114    if !cargo_toml.contains("[workspace]") {
115        return None;
116    }
117    let mut members = Vec::new();
118
119    for line in cargo_toml.lines() {
120        let trimmed = line.trim();
121        if let Some(rest) = trimmed.strip_prefix("members") {
122            let rest = rest.trim_start_matches([' ', '=']);
123            for part in rest.split(',') {
124                let part =
125                    part.trim_matches(|c: char| c == '"' || c == ' ' || c == '[' || c == ']');
126                if !part.is_empty() {
127                    let member_path = root.join(part).join("Cargo.toml");
128                    if member_path.exists() {
129                        members.push(single_rust_project(&root.join(part)));
130                    }
131                }
132            }
133        } else if trimmed.contains("members =") && trimmed.contains('[') {
134            let start = trimmed.find('[').unwrap_or(0);
135            let end = trimmed.rfind(']').unwrap_or(trimmed.len());
136            let inner = &trimmed[start + 1..end];
137            for part in inner.split(',') {
138                let part = part.trim_matches(|c: char| c == '"' || c == ' ');
139                if !part.is_empty() {
140                    let member_path = root.join(part).join("Cargo.toml");
141                    if member_path.exists() {
142                        members.push(single_rust_project(&root.join(part)));
143                    }
144                }
145            }
146        } else {
147            let part = trimmed.trim_matches(|c: char| c == '"' || c == ',' || c == ' ');
148            if !part.is_empty()
149                && !part.starts_with('#')
150                && !part.starts_with('[')
151                && !part.contains('=')
152            {
153                let member_path = root.join(part).join("Cargo.toml");
154                if member_path.exists() {
155                    members.push(single_rust_project(&root.join(part)));
156                }
157            }
158        }
159    }
160
161    if members.is_empty() {
162        return None;
163    }
164    Some(members)
165}
166
167fn single_rust_project(root: &Path) -> ProjectInfo {
168    let src_dir = if root.join("src").exists() {
169        root.join("src")
170    } else {
171        root.to_path_buf()
172    };
173    ProjectInfo {
174        root: root.to_path_buf(),
175        language: crate::types::Language::Rust,
176        entry_point: src_dir.join("main.rs"),
177        manifest: Some(root.join("Cargo.toml")),
178        source_dir: src_dir,
179    }
180}
181
182fn single_node_project(root: &Path) -> ProjectInfo {
183    let src_dir = if root.join("src").exists() {
184        root.join("src")
185    } else if root.join("lib").exists() {
186        root.join("lib")
187    } else {
188        root.to_path_buf()
189    };
190    ProjectInfo {
191        root: root.to_path_buf(),
192        language: crate::types::Language::TypeScript,
193        entry_point: src_dir.join("index.ts"),
194        manifest: Some(root.join("package.json")),
195        source_dir: src_dir,
196    }
197}
198
199fn single_go_project(root: &Path) -> ProjectInfo {
200    ProjectInfo {
201        root: root.to_path_buf(),
202        language: crate::types::Language::Go,
203        entry_point: root.join("main.go"),
204        manifest: Some(root.join("go.mod")),
205        source_dir: root.to_path_buf(),
206    }
207}
208
209fn discover_pnpm_packages(root: &Path) -> Vec<ProjectInfo> {
210    let mut packages = Vec::new();
211    for entry in walk_dirs(root, 3) {
212        if entry.join("package.json").exists() {
213            packages.push(single_node_project(&entry));
214        }
215    }
216    if packages.is_empty() {
217        packages.push(single_node_project(root));
218    }
219    packages
220}
221
222fn generic_project(root: &Path) -> ProjectInfo {
223    ProjectInfo {
224        root: root.to_path_buf(),
225        language: crate::types::Language::Unknown("generic".to_string()),
226        entry_point: root.to_path_buf(),
227        manifest: None,
228        source_dir: root.to_path_buf(),
229    }
230}
231
232fn walk_dirs(root: &Path, max_depth: usize) -> Vec<PathBuf> {
233    let mut result = Vec::new();
234    let mut stack = vec![(root.to_path_buf(), 0)];
235    while let Some((dir, depth)) = stack.pop() {
236        if depth >= max_depth {
237            continue;
238        }
239        if let Ok(entries) = std::fs::read_dir(&dir) {
240            for entry in entries.flatten() {
241                let path = entry.path();
242                if path.is_dir() {
243                    let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
244                    if name.starts_with('.') || name == "node_modules" || name == "target" {
245                        continue;
246                    }
247                    result.push(path.clone());
248                    stack.push((path, depth + 1));
249                }
250            }
251        }
252    }
253    result
254}
255
256#[cfg(test)]
257mod tests {
258    use super::*;
259
260    #[test]
261    fn test_detect_empty_dir_is_none() {
262        let temp = tempfile::tempdir().unwrap();
263        let result = Workspace::detect(temp.path()).unwrap();
264        assert!(result.is_none());
265    }
266
267    #[test]
268    fn test_detect_cargo_toml() {
269        let temp = tempfile::tempdir().unwrap();
270        std::fs::write(temp.path().join("Cargo.toml"), "").unwrap();
271        let ws = Workspace::detect(temp.path()).unwrap().unwrap();
272        assert_eq!(ws.projects.len(), 1);
273        assert!(matches!(
274            ws.projects[0].language,
275            crate::types::Language::Rust
276        ));
277    }
278
279    #[test]
280    fn test_detect_go_mod() {
281        let temp = tempfile::tempdir().unwrap();
282        std::fs::write(temp.path().join("go.mod"), "module example.com/m\n").unwrap();
283        let ws = Workspace::detect(temp.path()).unwrap().unwrap();
284        assert!(ws
285            .projects
286            .iter()
287            .any(|p| matches!(p.language, crate::types::Language::Go)));
288    }
289
290    #[test]
291    fn test_detect_package_json() {
292        let temp = tempfile::tempdir().unwrap();
293        std::fs::write(temp.path().join("package.json"), r#"{"name": "test"}"#).unwrap();
294        let ws = Workspace::detect(temp.path()).unwrap().unwrap();
295        assert!(ws
296            .projects
297            .iter()
298            .any(|p| matches!(p.language, crate::types::Language::TypeScript)));
299    }
300
301    #[test]
302    fn test_workspace_walks_up() {
303        let temp = tempfile::tempdir().unwrap();
304        std::fs::write(temp.path().join("Cargo.toml"), "").unwrap();
305        let deep = temp.path().join("a").join("b").join("c");
306        std::fs::create_dir_all(&deep).unwrap();
307        let ws = Workspace::detect(&deep).unwrap().unwrap();
308        assert_eq!(ws.root, temp.path());
309    }
310
311    #[test]
312    fn test_rust_workspace_members() {
313        let temp = tempfile::tempdir().unwrap();
314        std::fs::write(
315            temp.path().join("Cargo.toml"),
316            "[workspace]\nmembers = [\"crates/core\", \"crates/cli\"]\n",
317        )
318        .unwrap();
319
320        let core = temp.path().join("crates").join("core");
321        let cli = temp.path().join("crates").join("cli");
322        std::fs::create_dir_all(core.join("src")).unwrap();
323        std::fs::create_dir_all(cli.join("src")).unwrap();
324        std::fs::write(core.join("Cargo.toml"), "").unwrap();
325        std::fs::write(cli.join("Cargo.toml"), "").unwrap();
326
327        let ws = Workspace::detect(temp.path()).unwrap().unwrap();
328        assert_eq!(ws.projects.len(), 2);
329    }
330
331    #[test]
332    fn test_project_for_path() {
333        let temp = tempfile::tempdir().unwrap();
334        std::fs::write(temp.path().join("Cargo.toml"), "").unwrap();
335
336        let sub = temp.path().join("crates").join("lib");
337        std::fs::create_dir_all(sub.join("src")).unwrap();
338        std::fs::write(sub.join("Cargo.toml"), "").unwrap();
339
340        std::fs::write(
341            temp.path().join("Cargo.toml"),
342            "[workspace]\nmembers = [\"crates/lib\"]\n",
343        )
344        .unwrap();
345
346        let ws = Workspace::detect(temp.path()).unwrap().unwrap();
347        let file = sub.join("src").join("lib.rs");
348        std::fs::write(&file, "").unwrap();
349        let found = ws.project_for_path(&file);
350        assert!(found.is_some());
351        assert!(found.unwrap().root.ends_with("lib"));
352    }
353
354    #[test]
355    fn test_open_fails_without_root() {
356        let temp = tempfile::tempdir().unwrap();
357        let result = Workspace::open(temp.path());
358        assert!(result.is_err());
359    }
360
361    #[test]
362    fn test_no_markers_finds_generic() {
363        let temp = tempfile::tempdir().unwrap();
364        let marker = temp.path().join("WORKSPACE");
365        std::fs::write(&marker, "").unwrap();
366        let ws = Workspace::detect(temp.path()).unwrap().unwrap();
367        assert_eq!(ws.projects.len(), 1);
368        assert!(matches!(
369            ws.projects[0].language,
370            crate::types::Language::Unknown(_)
371        ));
372    }
373}