forgekit_core/workspace/
mod.rs1use 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(¤t) {
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}