1use std::collections::{BTreeMap, HashMap, HashSet};
4use std::io::{self, BufRead, Write as IoWrite};
5use std::path::{Path, PathBuf};
6
7use serde::Deserialize;
8use walkdir::WalkDir;
9
10use crate::error::InitError;
11use crate::output::{print_success, print_warning};
12
13#[derive(Debug, Clone)]
15pub struct DetectedProject {
16 pub name: String,
18 pub relative_path: PathBuf,
20 pub absolute_path: PathBuf,
22 #[allow(dead_code)]
24 pub project_type: ProjectType,
25 pub targets: BTreeMap<String, DetectedTarget>,
27 pub tags: Vec<String>,
29}
30
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub enum ProjectType {
34 Node,
35 Rust,
36 Go,
37 Python,
38}
39
40impl ProjectType {
41 pub fn as_str(&self) -> &'static str {
42 match self {
43 ProjectType::Node => "node",
44 ProjectType::Rust => "rust",
45 ProjectType::Go => "go",
46 ProjectType::Python => "python",
47 }
48 }
49}
50
51#[derive(Debug, Clone)]
53pub struct DetectedTarget {
54 pub command: String,
55 pub depends_on: Vec<String>,
56}
57
58#[derive(Debug)]
60pub struct InitResult {
61 pub written: Vec<PathBuf>,
63 pub skipped: Vec<PathBuf>,
65}
66
67pub fn detect_projects(root: &Path) -> Result<Vec<DetectedProject>, InitError> {
69 let mut projects = Vec::new();
70 let mut seen_dirs = HashSet::new();
71
72 for entry in WalkDir::new(root)
73 .follow_links(true)
74 .into_iter()
75 .filter_entry(|e| {
76 let name = e.file_name().to_string_lossy();
77 !matches!(
79 name.as_ref(),
80 "node_modules" | "target" | ".git" | "vendor" | "__pycache__" | ".venv" | "venv"
81 )
82 })
83 {
84 let entry = entry.map_err(|e| InitError::WalkDir {
85 path: root.to_path_buf(),
86 source: e,
87 })?;
88
89 if !entry.file_type().is_file() {
90 continue;
91 }
92
93 let file_name = entry.file_name().to_string_lossy();
94 let dir = entry
95 .path()
96 .parent()
97 .ok_or_else(|| InitError::InvalidPath {
98 path: entry.path().to_path_buf(),
99 })?;
100
101 if seen_dirs.contains(dir) {
103 continue;
104 }
105
106 let project = match file_name.as_ref() {
107 "package.json" => detect_node_project(root, dir)?,
108 "Cargo.toml" => detect_rust_project(root, dir)?,
109 "go.mod" => detect_go_project(root, dir)?,
110 "pyproject.toml" => detect_python_project(root, dir)?,
111 _ => None,
112 };
113
114 if let Some(p) = project {
115 seen_dirs.insert(dir.to_path_buf());
116 projects.push(p);
117 }
118 }
119
120 projects.sort_by(|a, b| a.relative_path.cmp(&b.relative_path));
122
123 Ok(projects)
124}
125
126fn detect_node_project(root: &Path, dir: &Path) -> Result<Option<DetectedProject>, InitError> {
128 let manifest_path = dir.join("package.json");
129 let content = std::fs::read_to_string(&manifest_path).map_err(|e| InitError::ReadFile {
130 path: manifest_path.clone(),
131 source: e,
132 })?;
133
134 #[derive(Deserialize)]
135 struct PackageJson {
136 name: Option<String>,
137 scripts: Option<HashMap<String, String>>,
138 }
139
140 let pkg: PackageJson = serde_json::from_str(&content).map_err(|e| InitError::ParseJson {
141 path: manifest_path,
142 source: e,
143 })?;
144
145 let name = match pkg.name {
146 Some(n) => sanitize_project_name(&n),
147 None => dir
148 .file_name()
149 .map(|s| sanitize_project_name(&s.to_string_lossy()))
150 .unwrap_or_else(|| "unnamed".to_string()),
151 };
152
153 let mut targets = BTreeMap::new();
154
155 if let Some(scripts) = pkg.scripts {
157 let script_mappings = [
159 ("build", "build"),
160 ("test", "test"),
161 ("lint", "lint"),
162 ("dev", "dev"),
163 ("start", "start"),
164 ("typecheck", "typecheck"),
165 ("type-check", "typecheck"),
166 ];
167
168 for (script_name, target_name) in &script_mappings {
169 if scripts.contains_key(*script_name) {
170 let mut depends_on = Vec::new();
171 if *target_name == "build" {
173 depends_on.push("^build".to_string());
174 }
175 if *target_name == "test" && targets.contains_key("build") {
177 depends_on.push("build".to_string());
178 }
179 targets.insert(
180 (*target_name).to_string(),
181 DetectedTarget {
182 command: format!("npm run {script_name}"),
183 depends_on,
184 },
185 );
186 }
187 }
188 }
189
190 let relative_path = dir
191 .strip_prefix(root)
192 .map_err(|_| InitError::InvalidPath {
193 path: dir.to_path_buf(),
194 })?
195 .to_path_buf();
196
197 if relative_path.as_os_str().is_empty() {
199 return Ok(None);
200 }
201
202 let mut tags = vec![ProjectType::Node.as_str().to_string()];
203 infer_tags_from_path(&relative_path, &mut tags);
204
205 Ok(Some(DetectedProject {
206 name,
207 relative_path,
208 absolute_path: dir.to_path_buf(),
209 project_type: ProjectType::Node,
210 targets,
211 tags,
212 }))
213}
214
215fn detect_rust_project(root: &Path, dir: &Path) -> Result<Option<DetectedProject>, InitError> {
217 let manifest_path = dir.join("Cargo.toml");
218 let content = std::fs::read_to_string(&manifest_path).map_err(|e| InitError::ReadFile {
219 path: manifest_path.clone(),
220 source: e,
221 })?;
222
223 #[derive(Deserialize)]
224 struct CargoToml {
225 package: Option<CargoPackage>,
226 }
227
228 #[derive(Deserialize)]
229 struct CargoPackage {
230 name: String,
231 }
232
233 let cargo: CargoToml = toml::from_str(&content).map_err(|e| InitError::ParseToml {
234 path: manifest_path,
235 source: e,
236 })?;
237
238 let name = match cargo.package {
240 Some(pkg) => sanitize_project_name(&pkg.name),
241 None => return Ok(None),
242 };
243
244 let relative_path = dir
245 .strip_prefix(root)
246 .map_err(|_| InitError::InvalidPath {
247 path: dir.to_path_buf(),
248 })?
249 .to_path_buf();
250
251 if relative_path.as_os_str().is_empty() {
253 return Ok(None);
254 }
255
256 let mut targets = BTreeMap::new();
258 targets.insert(
259 "build".to_string(),
260 DetectedTarget {
261 command: "cargo build".to_string(),
262 depends_on: vec!["^build".to_string()],
263 },
264 );
265 targets.insert(
266 "test".to_string(),
267 DetectedTarget {
268 command: "cargo test".to_string(),
269 depends_on: vec!["build".to_string()],
270 },
271 );
272 targets.insert(
273 "lint".to_string(),
274 DetectedTarget {
275 command: "cargo clippy -- -D warnings".to_string(),
276 depends_on: vec![],
277 },
278 );
279
280 let mut tags = vec![ProjectType::Rust.as_str().to_string()];
281 infer_tags_from_path(&relative_path, &mut tags);
282
283 Ok(Some(DetectedProject {
284 name,
285 relative_path,
286 absolute_path: dir.to_path_buf(),
287 project_type: ProjectType::Rust,
288 targets,
289 tags,
290 }))
291}
292
293fn detect_go_project(root: &Path, dir: &Path) -> Result<Option<DetectedProject>, InitError> {
295 let manifest_path = dir.join("go.mod");
296 let content = std::fs::read_to_string(&manifest_path).map_err(|e| InitError::ReadFile {
297 path: manifest_path.clone(),
298 source: e,
299 })?;
300
301 let name = content
303 .lines()
304 .find(|line| line.starts_with("module "))
305 .and_then(|line| line.strip_prefix("module "))
306 .map(|s| {
307 s.trim()
309 .rsplit('/')
310 .next()
311 .map(sanitize_project_name)
312 .unwrap_or_else(|| "unnamed".to_string())
313 })
314 .unwrap_or_else(|| {
315 dir.file_name()
316 .map(|s| sanitize_project_name(&s.to_string_lossy()))
317 .unwrap_or_else(|| "unnamed".to_string())
318 });
319
320 let relative_path = dir
321 .strip_prefix(root)
322 .map_err(|_| InitError::InvalidPath {
323 path: dir.to_path_buf(),
324 })?
325 .to_path_buf();
326
327 if relative_path.as_os_str().is_empty() {
329 return Ok(None);
330 }
331
332 let mut targets = BTreeMap::new();
334 targets.insert(
335 "build".to_string(),
336 DetectedTarget {
337 command: "go build ./...".to_string(),
338 depends_on: vec!["^build".to_string()],
339 },
340 );
341 targets.insert(
342 "test".to_string(),
343 DetectedTarget {
344 command: "go test ./...".to_string(),
345 depends_on: vec!["build".to_string()],
346 },
347 );
348 targets.insert(
349 "lint".to_string(),
350 DetectedTarget {
351 command: "golangci-lint run".to_string(),
352 depends_on: vec![],
353 },
354 );
355
356 let mut tags = vec![ProjectType::Go.as_str().to_string()];
357 infer_tags_from_path(&relative_path, &mut tags);
358
359 Ok(Some(DetectedProject {
360 name,
361 relative_path,
362 absolute_path: dir.to_path_buf(),
363 project_type: ProjectType::Go,
364 targets,
365 tags,
366 }))
367}
368
369fn detect_python_project(root: &Path, dir: &Path) -> Result<Option<DetectedProject>, InitError> {
371 let manifest_path = dir.join("pyproject.toml");
372 let content = std::fs::read_to_string(&manifest_path).map_err(|e| InitError::ReadFile {
373 path: manifest_path.clone(),
374 source: e,
375 })?;
376
377 #[derive(Deserialize)]
378 struct PyProjectToml {
379 project: Option<PyProject>,
380 tool: Option<PyTool>,
381 }
382
383 #[derive(Deserialize)]
384 struct PyProject {
385 name: Option<String>,
386 }
387
388 #[derive(Deserialize)]
389 struct PyTool {
390 poetry: Option<PoetrySection>,
391 }
392
393 #[derive(Deserialize)]
394 struct PoetrySection {
395 name: Option<String>,
396 }
397
398 let pyproject: PyProjectToml = toml::from_str(&content).map_err(|e| InitError::ParseToml {
399 path: manifest_path,
400 source: e,
401 })?;
402
403 let name = pyproject
405 .project
406 .and_then(|p| p.name)
407 .or_else(|| pyproject.tool.and_then(|t| t.poetry.and_then(|p| p.name)))
408 .map(|n| sanitize_project_name(&n))
409 .unwrap_or_else(|| {
410 dir.file_name()
411 .map(|s| sanitize_project_name(&s.to_string_lossy()))
412 .unwrap_or_else(|| "unnamed".to_string())
413 });
414
415 let relative_path = dir
416 .strip_prefix(root)
417 .map_err(|_| InitError::InvalidPath {
418 path: dir.to_path_buf(),
419 })?
420 .to_path_buf();
421
422 if relative_path.as_os_str().is_empty() {
424 return Ok(None);
425 }
426
427 let mut targets = BTreeMap::new();
429 targets.insert(
430 "test".to_string(),
431 DetectedTarget {
432 command: "pytest".to_string(),
433 depends_on: vec![],
434 },
435 );
436 targets.insert(
437 "lint".to_string(),
438 DetectedTarget {
439 command: "ruff check .".to_string(),
440 depends_on: vec![],
441 },
442 );
443
444 let mut tags = vec![ProjectType::Python.as_str().to_string()];
445 infer_tags_from_path(&relative_path, &mut tags);
446
447 Ok(Some(DetectedProject {
448 name,
449 relative_path,
450 absolute_path: dir.to_path_buf(),
451 project_type: ProjectType::Python,
452 targets,
453 tags,
454 }))
455}
456
457fn sanitize_project_name(name: &str) -> String {
459 name.chars()
460 .filter_map(|c| {
461 if c.is_ascii_alphanumeric() {
462 Some(c.to_ascii_lowercase())
463 } else if c == '-' || c == '_' {
464 Some(c)
465 } else if c == ' ' || c == '/' || c == '@' {
466 Some('-')
467 } else {
468 None
469 }
470 })
471 .collect::<String>()
472 .trim_matches('-')
473 .to_string()
474}
475
476fn infer_tags_from_path(path: &Path, tags: &mut Vec<String>) {
478 let path_str = path.to_string_lossy().to_lowercase();
479 if path_str.contains("app") {
480 tags.push("app".to_string());
481 } else if path_str.contains("lib") || path_str.contains("package") {
482 tags.push("lib".to_string());
483 }
484}
485
486pub fn generate_workspace_patterns(projects: &[DetectedProject]) -> Vec<String> {
488 let mut patterns = HashSet::new();
489
490 for project in projects {
491 if let Some(first_component) = project.relative_path.components().next() {
492 let dir = first_component.as_os_str().to_string_lossy();
493 patterns.insert(format!("{dir}/*"));
494 }
495 }
496
497 let mut sorted: Vec<_> = patterns.into_iter().collect();
498 sorted.sort();
499 sorted
500}
501
502pub fn generate_workspace_toml(name: &str, patterns: &[String]) -> String {
504 let mut toml = String::new();
505 toml.push_str("[workspace]\n");
506 toml.push_str(&format!("name = \"{name}\"\n"));
507 toml.push_str("projects = [");
508 for (i, pattern) in patterns.iter().enumerate() {
509 if i > 0 {
510 toml.push_str(", ");
511 }
512 toml.push_str(&format!("\"{pattern}\""));
513 }
514 toml.push_str("]\n");
515 toml
516}
517
518pub fn generate_project_toml(project: &DetectedProject) -> String {
520 let mut toml = String::new();
521
522 toml.push_str("[project]\n");
524 toml.push_str(&format!("name = \"{}\"\n", project.name));
525 if !project.tags.is_empty() {
526 toml.push_str("tags = [");
527 for (i, tag) in project.tags.iter().enumerate() {
528 if i > 0 {
529 toml.push_str(", ");
530 }
531 toml.push_str(&format!("\"{tag}\""));
532 }
533 toml.push_str("]\n");
534 }
535
536 for (name, target) in &project.targets {
538 toml.push_str(&format!("\n[targets.{name}]\n"));
539 toml.push_str(&format!("command = \"{}\"\n", target.command));
540 if !target.depends_on.is_empty() {
541 toml.push_str("depends_on = [");
542 for (i, dep) in target.depends_on.iter().enumerate() {
543 if i > 0 {
544 toml.push_str(", ");
545 }
546 toml.push_str(&format!("\"{dep}\""));
547 }
548 toml.push_str("]\n");
549 }
550 }
551
552 toml
553}
554
555pub fn init_workspace(
557 root: &Path,
558 workspace_name: &str,
559 yes: bool,
560 reader: &mut dyn BufRead,
561 writer: &mut dyn IoWrite,
562) -> Result<InitResult, InitError> {
563 let projects = detect_projects(root)?;
564 let patterns = generate_workspace_patterns(&projects);
565
566 let mut result = InitResult {
567 written: Vec::new(),
568 skipped: Vec::new(),
569 };
570
571 let root_toml_path = root.join("guild.toml");
573 let workspace_toml = generate_workspace_toml(workspace_name, &patterns);
574
575 if root_toml_path.exists() {
576 print_warning(&format!(
577 "Skipping {} (already exists)",
578 root_toml_path.display()
579 ));
580 result.skipped.push(root_toml_path);
581 } else {
582 let should_write = if yes {
583 true
584 } else {
585 prompt_confirm(
586 &format!("Create {}?", root_toml_path.display()),
587 &workspace_toml,
588 reader,
589 writer,
590 )?
591 };
592
593 if should_write {
594 std::fs::write(&root_toml_path, &workspace_toml).map_err(|e| InitError::WriteFile {
595 path: root_toml_path.clone(),
596 source: e,
597 })?;
598 print_success(&format!("Created {}", root_toml_path.display()));
599 result.written.push(root_toml_path);
600 } else {
601 result.skipped.push(root_toml_path);
602 }
603 }
604
605 for project in &projects {
607 let project_toml_path = project.absolute_path.join("guild.toml");
608 let project_toml = generate_project_toml(project);
609
610 if project_toml_path.exists() {
611 print_warning(&format!(
612 "Skipping {} (already exists)",
613 project_toml_path.display()
614 ));
615 result.skipped.push(project_toml_path);
616 } else {
617 let should_write = if yes {
618 true
619 } else {
620 prompt_confirm(
621 &format!("Create {}?", project_toml_path.display()),
622 &project_toml,
623 reader,
624 writer,
625 )?
626 };
627
628 if should_write {
629 std::fs::write(&project_toml_path, &project_toml).map_err(|e| {
630 InitError::WriteFile {
631 path: project_toml_path.clone(),
632 source: e,
633 }
634 })?;
635 print_success(&format!("Created {}", project_toml_path.display()));
636 result.written.push(project_toml_path);
637 } else {
638 result.skipped.push(project_toml_path);
639 }
640 }
641 }
642
643 Ok(result)
644}
645
646fn prompt_confirm(
648 prompt: &str,
649 content: &str,
650 reader: &mut dyn BufRead,
651 writer: &mut dyn IoWrite,
652) -> Result<bool, InitError> {
653 writeln!(writer, "\n{prompt}").map_err(|e| InitError::Io { source: e })?;
654 writeln!(writer, "---").map_err(|e| InitError::Io { source: e })?;
655 for line in content.lines() {
656 writeln!(writer, " {line}").map_err(|e| InitError::Io { source: e })?;
657 }
658 writeln!(writer, "---").map_err(|e| InitError::Io { source: e })?;
659 write!(writer, "[y/n] ").map_err(|e| InitError::Io { source: e })?;
660 writer.flush().map_err(|e| InitError::Io { source: e })?;
661
662 let mut response = String::new();
663 reader
664 .read_line(&mut response)
665 .map_err(|e| InitError::Io { source: e })?;
666
667 Ok(response.trim().eq_ignore_ascii_case("y") || response.trim().eq_ignore_ascii_case("yes"))
668}
669
670pub fn run_init(root: &Path, workspace_name: &str, yes: bool) -> Result<InitResult, InitError> {
672 let stdin = io::stdin();
673 let mut reader = stdin.lock();
674 let stdout = io::stdout();
675 let mut writer = stdout.lock();
676 init_workspace(root, workspace_name, yes, &mut reader, &mut writer)
677}
678
679#[cfg(test)]
680mod tests {
681 use super::*;
682 use std::io::Cursor;
683 use tempfile::TempDir;
684
685 fn create_test_workspace() -> TempDir {
686 let dir = TempDir::new().unwrap();
687
688 let web_dir = dir.path().join("apps/web");
690 std::fs::create_dir_all(&web_dir).unwrap();
691 std::fs::write(
692 web_dir.join("package.json"),
693 r#"{"name": "web-app", "scripts": {"build": "vite build", "test": "vitest", "lint": "eslint ."}}"#,
694 )
695 .unwrap();
696
697 let core_dir = dir.path().join("libs/core");
699 std::fs::create_dir_all(&core_dir).unwrap();
700 std::fs::write(
701 core_dir.join("Cargo.toml"),
702 r#"[package]
703name = "core-lib"
704version = "0.1.0"
705"#,
706 )
707 .unwrap();
708
709 dir
710 }
711
712 #[test]
713 fn test_detect_node_project() {
714 let dir = TempDir::new().unwrap();
715 let project_dir = dir.path().join("apps/my-app");
716 std::fs::create_dir_all(&project_dir).unwrap();
717 std::fs::write(
718 project_dir.join("package.json"),
719 r#"{"name": "@scope/my-app", "scripts": {"build": "tsc", "test": "jest"}}"#,
720 )
721 .unwrap();
722
723 let projects = detect_projects(dir.path()).unwrap();
724 assert_eq!(projects.len(), 1);
725 assert_eq!(projects[0].name, "scope-my-app");
726 assert!(projects[0].targets.contains_key("build"));
727 assert!(projects[0].targets.contains_key("test"));
728 }
729
730 #[test]
731 fn test_detect_rust_project() {
732 let dir = TempDir::new().unwrap();
733 let project_dir = dir.path().join("libs/my-lib");
734 std::fs::create_dir_all(&project_dir).unwrap();
735 std::fs::write(
736 project_dir.join("Cargo.toml"),
737 r#"[package]
738name = "my-lib"
739version = "0.1.0"
740"#,
741 )
742 .unwrap();
743
744 let projects = detect_projects(dir.path()).unwrap();
745 assert_eq!(projects.len(), 1);
746 assert_eq!(projects[0].name, "my-lib");
747 assert!(projects[0].targets.contains_key("build"));
748 assert!(projects[0].targets.contains_key("test"));
749 assert!(projects[0].targets.contains_key("lint"));
750 }
751
752 #[test]
753 fn test_skip_workspace_only_cargo_toml() {
754 let dir = TempDir::new().unwrap();
755 let project_dir = dir.path().join("libs/my-lib");
756 std::fs::create_dir_all(&project_dir).unwrap();
757
758 std::fs::write(
760 project_dir.join("Cargo.toml"),
761 r#"[workspace]
762members = ["crates/*"]
763"#,
764 )
765 .unwrap();
766
767 let projects = detect_projects(dir.path()).unwrap();
768 assert_eq!(projects.len(), 0);
769 }
770
771 #[test]
772 fn test_detect_go_project() {
773 let dir = TempDir::new().unwrap();
774 let project_dir = dir.path().join("services/api");
775 std::fs::create_dir_all(&project_dir).unwrap();
776 std::fs::write(
777 project_dir.join("go.mod"),
778 "module github.com/example/api\n\ngo 1.21\n",
779 )
780 .unwrap();
781
782 let projects = detect_projects(dir.path()).unwrap();
783 assert_eq!(projects.len(), 1);
784 assert_eq!(projects[0].name, "api");
785 assert!(projects[0].targets.contains_key("build"));
786 assert!(projects[0].targets.contains_key("test"));
787 }
788
789 #[test]
790 fn test_detect_python_project() {
791 let dir = TempDir::new().unwrap();
792 let project_dir = dir.path().join("packages/my-pkg");
793 std::fs::create_dir_all(&project_dir).unwrap();
794 std::fs::write(
795 project_dir.join("pyproject.toml"),
796 r#"[project]
797name = "my-pkg"
798version = "0.1.0"
799"#,
800 )
801 .unwrap();
802
803 let projects = detect_projects(dir.path()).unwrap();
804 assert_eq!(projects.len(), 1);
805 assert_eq!(projects[0].name, "my-pkg");
806 assert!(projects[0].targets.contains_key("test"));
807 assert!(projects[0].targets.contains_key("lint"));
808 }
809
810 #[test]
811 fn test_generate_workspace_patterns() {
812 let projects = vec![
813 DetectedProject {
814 name: "web".to_string(),
815 relative_path: PathBuf::from("apps/web"),
816 absolute_path: PathBuf::from("/tmp/apps/web"),
817 project_type: ProjectType::Node,
818 targets: BTreeMap::new(),
819 tags: vec![],
820 },
821 DetectedProject {
822 name: "core".to_string(),
823 relative_path: PathBuf::from("libs/core"),
824 absolute_path: PathBuf::from("/tmp/libs/core"),
825 project_type: ProjectType::Rust,
826 targets: BTreeMap::new(),
827 tags: vec![],
828 },
829 ];
830
831 let patterns = generate_workspace_patterns(&projects);
832 assert_eq!(patterns, vec!["apps/*", "libs/*"]);
833 }
834
835 #[test]
836 fn test_generate_workspace_toml() {
837 let toml =
838 generate_workspace_toml("my-monorepo", &["apps/*".to_string(), "libs/*".to_string()]);
839 assert!(toml.contains("[workspace]"));
840 assert!(toml.contains("name = \"my-monorepo\""));
841 assert!(toml.contains("projects = [\"apps/*\", \"libs/*\"]"));
842 }
843
844 #[test]
845 fn test_generate_project_toml() {
846 let mut targets = BTreeMap::new();
847 targets.insert(
848 "build".to_string(),
849 DetectedTarget {
850 command: "npm run build".to_string(),
851 depends_on: vec!["^build".to_string()],
852 },
853 );
854
855 let project = DetectedProject {
856 name: "my-app".to_string(),
857 relative_path: PathBuf::from("apps/my-app"),
858 absolute_path: PathBuf::from("/tmp/apps/my-app"),
859 project_type: ProjectType::Node,
860 targets,
861 tags: vec!["node".to_string(), "app".to_string()],
862 };
863
864 let toml = generate_project_toml(&project);
865 assert!(toml.contains("[project]"));
866 assert!(toml.contains("name = \"my-app\""));
867 assert!(toml.contains("tags = [\"node\", \"app\"]"));
868 assert!(toml.contains("[targets.build]"));
869 assert!(toml.contains("command = \"npm run build\""));
870 assert!(toml.contains("depends_on = [\"^build\"]"));
871 }
872
873 #[test]
874 fn test_init_workspace_yes_mode() {
875 let dir = create_test_workspace();
876 let mut reader = Cursor::new(Vec::new());
877 let mut writer = Vec::new();
878
879 let result =
880 init_workspace(dir.path(), "test-workspace", true, &mut reader, &mut writer).unwrap();
881
882 assert_eq!(result.written.len(), 3); assert!(dir.path().join("guild.toml").exists());
884 assert!(dir.path().join("apps/web/guild.toml").exists());
885 assert!(dir.path().join("libs/core/guild.toml").exists());
886 }
887
888 #[test]
889 fn test_init_workspace_skips_existing() {
890 let dir = create_test_workspace();
891
892 std::fs::write(
894 dir.path().join("guild.toml"),
895 "[workspace]\nname = \"existing\"\nprojects = []\n",
896 )
897 .unwrap();
898
899 let mut reader = Cursor::new(Vec::new());
900 let mut writer = Vec::new();
901
902 let result =
903 init_workspace(dir.path(), "test-workspace", true, &mut reader, &mut writer).unwrap();
904
905 assert_eq!(result.skipped.len(), 1);
906 assert!(result.skipped[0].ends_with("guild.toml"));
907 assert_eq!(result.written.len(), 2);
909 }
910
911 #[test]
912 fn test_sanitize_project_name() {
913 assert_eq!(sanitize_project_name("My App"), "my-app");
914 assert_eq!(sanitize_project_name("@scope/pkg"), "scope-pkg");
915 assert_eq!(sanitize_project_name("my_lib-v2"), "my_lib-v2");
916 assert_eq!(sanitize_project_name("UPPERCASE"), "uppercase");
917 }
918}