Skip to main content

guild_cli/
init.rs

1//! Manifest detection and guild.toml generation for `guild init`.
2
3use 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/// Represents a detected project manifest.
14#[derive(Debug, Clone)]
15pub struct DetectedProject {
16    /// Project name extracted from the manifest.
17    pub name: String,
18    /// Relative path from workspace root to the project directory.
19    pub relative_path: PathBuf,
20    /// Absolute path to the project directory.
21    pub absolute_path: PathBuf,
22    /// The type of project (Node, Rust, Go, Python).
23    #[allow(dead_code)]
24    pub project_type: ProjectType,
25    /// Detected targets for this project.
26    pub targets: BTreeMap<String, DetectedTarget>,
27    /// Tags for this project based on its type and location.
28    pub tags: Vec<String>,
29}
30
31/// The type of project detected from its manifest.
32#[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/// A detected target for a project.
52#[derive(Debug, Clone)]
53pub struct DetectedTarget {
54    pub command: String,
55    pub depends_on: Vec<String>,
56}
57
58/// Result of running `guild init`.
59#[derive(Debug)]
60pub struct InitResult {
61    /// Files that were written.
62    pub written: Vec<PathBuf>,
63    /// Files that were skipped because they already exist.
64    pub skipped: Vec<PathBuf>,
65}
66
67/// Scan a directory tree for project manifests.
68pub 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            // Skip common non-project directories
78            !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        // Skip if we've already found a project in this directory
102        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    // Sort by relative path for consistent output
121    projects.sort_by(|a, b| a.relative_path.cmp(&b.relative_path));
122
123    Ok(projects)
124}
125
126/// Detect a Node.js project from package.json.
127fn 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    // Extract common targets from scripts
156    if let Some(scripts) = pkg.scripts {
157        // Map script names to guild target names
158        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                // build target depends on upstream builds
172                if *target_name == "build" {
173                    depends_on.push("^build".to_string());
174                }
175                // test depends on local build
176                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    // Skip root package.json if it's at the workspace root
198    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
215/// Detect a Rust project from Cargo.toml.
216fn 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    // Skip workspace-only Cargo.toml files (no package section)
239    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    // Skip root Cargo.toml if it's at the workspace root
252    if relative_path.as_os_str().is_empty() {
253        return Ok(None);
254    }
255
256    // Standard Rust targets
257    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
293/// Detect a Go project from go.mod.
294fn 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    // Parse module name from go.mod
302    let name = content
303        .lines()
304        .find(|line| line.starts_with("module "))
305        .and_then(|line| line.strip_prefix("module "))
306        .map(|s| {
307            // Extract just the last path component as the name
308            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    // Skip root go.mod if it's at the workspace root
328    if relative_path.as_os_str().is_empty() {
329        return Ok(None);
330    }
331
332    // Standard Go targets
333    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
369/// Detect a Python project from pyproject.toml.
370fn 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    // Try to get name from [project] or [tool.poetry]
404    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    // Skip root pyproject.toml if it's at the workspace root
423    if relative_path.as_os_str().is_empty() {
424        return Ok(None);
425    }
426
427    // Standard Python targets
428    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
457/// Sanitize a name to be a valid project name (lowercase, no special chars).
458fn 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
476/// Infer tags from a project's relative path.
477fn 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
486/// Generate workspace glob patterns from detected projects.
487pub 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
502/// Generate the root guild.toml content.
503pub 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
518/// Generate a project guild.toml content.
519pub fn generate_project_toml(project: &DetectedProject) -> String {
520    let mut toml = String::new();
521
522    // [project] section
523    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    // [targets.*] sections
537    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
555/// Initialize a workspace with guild.toml files.
556pub 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    // Generate and write root guild.toml
572    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    // Generate and write per-project guild.toml files
606    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
646/// Prompt the user to confirm writing a file.
647fn 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
670/// Run init with stdin/stdout for interactive mode.
671pub 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        // Create apps/web with package.json
689        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        // Create libs/core with Cargo.toml
698        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        // Workspace-only Cargo.toml at root
759        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); // root + 2 projects
883        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        // Pre-create guild.toml
893        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        // Should still create project files
908        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}