diff --git a/server/src/io/fs/scaffold/detect.rs b/server/src/io/fs/scaffold/detect.rs deleted file mode 100644 index af1beba8..00000000 --- a/server/src/io/fs/scaffold/detect.rs +++ /dev/null @@ -1,991 +0,0 @@ -//! Stack detection — inspect the project root for marker files and emit -//! TOML `[[component]]` entries plus `script/build|lint|test` content. - -use std::fs; -use std::path::Path; - -use super::templates::STORY_KIT_SCRIPT_TEST; - -/// Detect the tech stack from the project root and return TOML `[[component]]` entries. -/// -/// Inspects well-known marker files at the project root to identify which -/// tech stacks are present, then emits one `[[component]]` entry per detected -/// stack with sensible default `setup` commands. If no markers are found, a -/// single fallback `app` component with an empty `setup` list is returned so -/// that the pipeline never breaks on an unknown stack. -pub(crate) fn detect_components_toml(root: &Path) -> String { - let mut sections = Vec::new(); - - if root.join("Cargo.toml").exists() { - sections.push( - "[[component]]\nname = \"server\"\npath = \".\"\nsetup = [\"cargo check\"]\n" - .to_string(), - ); - } - - if root.join("package.json").exists() { - let setup_cmd = if root.join("pnpm-lock.yaml").exists() { - "pnpm install" - } else { - "npm install" - }; - sections.push(format!( - "[[component]]\nname = \"frontend\"\npath = \".\"\nsetup = [\"{setup_cmd}\"]\n" - )); - } - - if root.join("pyproject.toml").exists() || root.join("requirements.txt").exists() { - sections.push( - "[[component]]\nname = \"python\"\npath = \".\"\nsetup = [\"pip install -r requirements.txt\"]\n" - .to_string(), - ); - } - - if root.join("go.mod").exists() { - sections.push( - "[[component]]\nname = \"go\"\npath = \".\"\nsetup = [\"go build ./...\"]\n" - .to_string(), - ); - } - - if root.join("Gemfile").exists() { - sections.push( - "[[component]]\nname = \"ruby\"\npath = \".\"\nsetup = [\"bundle install\"]\n" - .to_string(), - ); - } - - if sections.is_empty() { - // No tech stack markers detected — emit a single generic component - // with an empty setup list. The ONBOARDING_PROMPT instructs the chat - // agent to inspect the project and replace this with real definitions. - sections.push("[[component]]\nname = \"app\"\npath = \".\"\nsetup = []\n".to_string()); - } - - sections.join("\n") -} - -/// Detect the appropriate Node.js test command for a directory containing `package.json`. -/// -/// Reads the `package.json` content to identify known test runners (vitest, jest). -/// Falls back to `npm test` or `pnpm test` based on which lock file is present. -fn detect_node_test_cmd(pkg_dir: &Path) -> String { - let has_pnpm = pkg_dir.join("pnpm-lock.yaml").exists(); - let content = std::fs::read_to_string(pkg_dir.join("package.json")).unwrap_or_default(); - - if content.contains("\"vitest\"") { - let pm = if has_pnpm { "pnpm" } else { "npx" }; - return format!("{} vitest run", pm); - } - if content.contains("\"jest\"") { - let pm = if has_pnpm { "pnpm" } else { "npx" }; - return format!("{} jest", pm); - } - - if has_pnpm { - "pnpm test".to_string() - } else { - "npm test".to_string() - } -} - -/// Detect the appropriate Node.js build command for a directory containing `package.json`. -fn detect_node_build_cmd(pkg_dir: &Path) -> String { - if pkg_dir.join("pnpm-lock.yaml").exists() { - "pnpm run build".to_string() - } else { - "npm run build".to_string() - } -} - -/// Detect the appropriate Node.js lint command for a directory containing `package.json`. -/// -/// Reads the `package.json` content to identify eslint. Falls back to -/// `npm run lint` or `pnpm run lint` based on which lock file is present. -fn detect_node_lint_cmd(pkg_dir: &Path) -> String { - let has_pnpm = pkg_dir.join("pnpm-lock.yaml").exists(); - let content = std::fs::read_to_string(pkg_dir.join("package.json")).unwrap_or_default(); - if content.contains("\"eslint\"") { - let pm = if has_pnpm { "pnpm" } else { "npx" }; - return format!("{pm} eslint ."); - } - if has_pnpm { - "pnpm run lint".to_string() - } else { - "npm run lint".to_string() - } -} - -/// Generate `script/build` content for a new project at `root`. -/// -/// Inspects well-known marker files to identify which tech stacks are present -/// and emits the appropriate build commands. Multi-stack projects get combined -/// commands run sequentially. Falls back to a generic stub when no markers -/// are found so the scaffold is always valid. -/// -/// For projects with a frontend in a known subdirectory (`frontend/`, `client/`), -/// the build command is detected from the presence of `pnpm-lock.yaml`. -pub(crate) fn detect_script_build(root: &Path) -> String { - let mut commands: Vec = Vec::new(); - - if root.join("Cargo.toml").exists() { - commands.push("cargo build --release".to_string()); - } - - if root.join("package.json").exists() { - commands.push(detect_node_build_cmd(root)); - } - - // Detect frontend in known subdirectories (e.g. frontend/, client/) - for subdir in &["frontend", "client"] { - let sub_path = root.join(subdir); - if sub_path.join("package.json").exists() { - let cmd = detect_node_build_cmd(&sub_path); - commands.push(format!("(cd {} && {})", subdir, cmd)); - } - } - - if root.join("pyproject.toml").exists() { - commands.push("python -m build".to_string()); - } - - if root.join("go.mod").exists() { - commands.push("go build ./...".to_string()); - } - - if commands.is_empty() { - return "#!/usr/bin/env bash\nset -euo pipefail\n\n# Add your project's build commands here.\necho \"No build configured\"\n".to_string(); - } - - let mut script = "#!/usr/bin/env bash\nset -euo pipefail\n\n".to_string(); - for cmd in commands { - script.push_str(&cmd); - script.push('\n'); - } - script -} - -/// Generate `script/lint` content for a new project at `root`. -/// -/// Inspects well-known marker files to identify which linters are present -/// and emits the appropriate lint commands. Multi-stack projects get combined -/// commands run sequentially. Falls back to a generic stub when no markers -/// are found so the scaffold is always valid. -/// -/// For projects with a frontend in a known subdirectory (`frontend/`, `client/`), -/// the lint command is detected from the `package.json` (eslint, npm, pnpm). -pub(crate) fn detect_script_lint(root: &Path) -> String { - let mut commands: Vec = Vec::new(); - - if root.join("Cargo.toml").exists() { - commands.push("cargo fmt --all --check".to_string()); - commands.push("cargo clippy -- -D warnings".to_string()); - } - - if root.join("package.json").exists() { - commands.push(detect_node_lint_cmd(root)); - } - - // Detect frontend in known subdirectories (e.g. frontend/, client/) - for subdir in &["frontend", "client"] { - let sub_path = root.join(subdir); - if sub_path.join("package.json").exists() { - let cmd = detect_node_lint_cmd(&sub_path); - commands.push(format!("(cd {} && {})", subdir, cmd)); - } - } - - if root.join("pyproject.toml").exists() || root.join("requirements.txt").exists() { - let mut content = std::fs::read_to_string(root.join("pyproject.toml")).unwrap_or_default(); - content - .push_str(&std::fs::read_to_string(root.join("requirements.txt")).unwrap_or_default()); - if content.contains("ruff") { - commands.push("ruff check .".to_string()); - } else { - commands.push("flake8 .".to_string()); - } - } - - if root.join("go.mod").exists() { - commands.push("go vet ./...".to_string()); - } - - if commands.is_empty() { - return "#!/usr/bin/env bash\nset -euo pipefail\n\n# Add your project's lint commands here.\necho \"No linters configured\"\n".to_string(); - } - - let mut script = "#!/usr/bin/env bash\nset -euo pipefail\n\n".to_string(); - for cmd in commands { - script.push_str(&cmd); - script.push('\n'); - } - script -} - -/// Generate `script/test` content for a new project at `root`. -/// -/// Inspects well-known marker files to identify which tech stacks are present -/// and emits the appropriate test commands. Multi-stack projects get combined -/// commands run sequentially. Falls back to the generic stub when no markers -/// are found so the scaffold is always valid. -/// -/// For projects with a frontend in a known subdirectory (`frontend/`, `client/`), -/// the test runner is detected from the `package.json` (vitest, jest, npm, pnpm). -pub(crate) fn detect_script_test(root: &Path) -> String { - let mut commands: Vec = Vec::new(); - - if root.join("Cargo.toml").exists() { - commands.push("cargo test".to_string()); - } - - if root.join("package.json").exists() { - if root.join("pnpm-lock.yaml").exists() { - commands.push("pnpm test".to_string()); - } else { - commands.push("npm test".to_string()); - } - } - - // Detect frontend in known subdirectories (e.g. frontend/, client/) - for subdir in &["frontend", "client"] { - let sub_path = root.join(subdir); - if sub_path.join("package.json").exists() { - let cmd = detect_node_test_cmd(&sub_path); - commands.push(format!("(cd {} && {})", subdir, cmd)); - } - } - - if root.join("pyproject.toml").exists() || root.join("requirements.txt").exists() { - commands.push("pytest".to_string()); - } - - if root.join("go.mod").exists() { - commands.push("go test ./...".to_string()); - } - - if commands.is_empty() { - return STORY_KIT_SCRIPT_TEST.to_string(); - } - - let mut script = "#!/usr/bin/env bash\nset -euo pipefail\n\n".to_string(); - for cmd in commands { - script.push_str(&cmd); - script.push('\n'); - } - script -} - -/// Generate a `project.toml` for a new project at `root`. -/// -/// Detects the tech stack via [`detect_components_toml`] and combines the -/// resulting `[[component]]` entries with the default project settings. -#[cfg(test)] -mod tests { - use super::*; - use tempfile::tempdir; - - #[test] - fn detect_no_markers_returns_fallback_components() { - let dir = tempdir().unwrap(); - let toml = detect_components_toml(dir.path()); - // At least one [[component]] entry should always be present - assert!( - toml.contains("[[component]]"), - "should always emit at least one component" - ); - // Fallback should use a generic app component with empty setup - assert!( - toml.contains("name = \"app\""), - "fallback should use generic 'app' component name" - ); - assert!( - toml.contains("setup = []"), - "fallback should have empty setup list" - ); - // Must not contain Rust-specific commands in a non-Rust project - assert!( - !toml.contains("cargo"), - "fallback must not contain Rust-specific commands" - ); - } - - #[test] - fn detect_cargo_toml_generates_rust_component() { - let dir = tempdir().unwrap(); - fs::write( - dir.path().join("Cargo.toml"), - "[package]\nname = \"test\"\n", - ) - .unwrap(); - - let toml = detect_components_toml(dir.path()); - assert!(toml.contains("name = \"server\"")); - assert!(toml.contains("setup = [\"cargo check\"]")); - } - - #[test] - fn detect_package_json_with_pnpm_lock_generates_pnpm_component() { - let dir = tempdir().unwrap(); - fs::write(dir.path().join("package.json"), "{}").unwrap(); - fs::write(dir.path().join("pnpm-lock.yaml"), "").unwrap(); - - let toml = detect_components_toml(dir.path()); - assert!(toml.contains("name = \"frontend\"")); - assert!(toml.contains("setup = [\"pnpm install\"]")); - } - - #[test] - fn detect_package_json_without_pnpm_lock_generates_npm_component() { - let dir = tempdir().unwrap(); - fs::write(dir.path().join("package.json"), "{}").unwrap(); - - let toml = detect_components_toml(dir.path()); - assert!(toml.contains("name = \"frontend\"")); - assert!(toml.contains("setup = [\"npm install\"]")); - } - - #[test] - fn detect_pyproject_toml_generates_python_component() { - let dir = tempdir().unwrap(); - fs::write( - dir.path().join("pyproject.toml"), - "[project]\nname = \"test\"\n", - ) - .unwrap(); - - let toml = detect_components_toml(dir.path()); - assert!(toml.contains("name = \"python\"")); - assert!(toml.contains("pip install")); - } - - #[test] - fn detect_requirements_txt_generates_python_component() { - let dir = tempdir().unwrap(); - fs::write(dir.path().join("requirements.txt"), "flask\n").unwrap(); - - let toml = detect_components_toml(dir.path()); - assert!(toml.contains("name = \"python\"")); - assert!(toml.contains("pip install")); - } - - #[test] - fn detect_go_mod_generates_go_component() { - let dir = tempdir().unwrap(); - fs::write(dir.path().join("go.mod"), "module example.com/app\n").unwrap(); - - let toml = detect_components_toml(dir.path()); - assert!(toml.contains("name = \"go\"")); - assert!(toml.contains("setup = [\"go build ./...\"]")); - } - - #[test] - fn detect_gemfile_generates_ruby_component() { - let dir = tempdir().unwrap(); - fs::write( - dir.path().join("Gemfile"), - "source \"https://rubygems.org\"\n", - ) - .unwrap(); - - let toml = detect_components_toml(dir.path()); - assert!(toml.contains("name = \"ruby\"")); - assert!(toml.contains("setup = [\"bundle install\"]")); - } - - #[test] - fn no_rust_commands_in_go_project() { - let dir = tempdir().unwrap(); - fs::write(dir.path().join("go.mod"), "module example.com/app\n").unwrap(); - - let toml = detect_components_toml(dir.path()); - assert!( - !toml.contains("cargo"), - "go project must not contain cargo commands" - ); - assert!(toml.contains("go build"), "go project must use Go tooling"); - } - - #[test] - fn no_rust_commands_in_node_project() { - let dir = tempdir().unwrap(); - fs::write(dir.path().join("package.json"), "{}").unwrap(); - - let toml = detect_components_toml(dir.path()); - assert!( - !toml.contains("cargo"), - "node project must not contain cargo commands" - ); - assert!( - toml.contains("npm install"), - "node project must use npm tooling" - ); - } - - #[test] - fn no_rust_commands_when_no_stack_detected() { - let dir = tempdir().unwrap(); - - let toml = detect_components_toml(dir.path()); - assert!( - !toml.contains("cargo"), - "unknown stack must not contain cargo commands" - ); - // setup list must be empty - assert!( - toml.contains("setup = []"), - "unknown stack must have empty setup list" - ); - } - - #[test] - fn detect_multiple_markers_generates_multiple_components() { - let dir = tempdir().unwrap(); - fs::write( - dir.path().join("Cargo.toml"), - "[package]\nname = \"server\"\n", - ) - .unwrap(); - fs::write(dir.path().join("package.json"), "{}").unwrap(); - - let toml = detect_components_toml(dir.path()); - assert!(toml.contains("name = \"server\"")); - assert!(toml.contains("name = \"frontend\"")); - // Both component entries should be present - let component_count = toml.matches("[[component]]").count(); - assert_eq!(component_count, 2); - } - - #[test] - fn detect_no_fallback_when_markers_found() { - let dir = tempdir().unwrap(); - fs::write(dir.path().join("Cargo.toml"), "[package]\nname = \"x\"\n").unwrap(); - - let toml = detect_components_toml(dir.path()); - // The fallback "app" component should NOT appear when a real stack is detected - assert!(!toml.contains("name = \"app\"")); - } - - #[test] - fn detect_script_test_no_markers_returns_stub() { - let dir = tempdir().unwrap(); - let script = detect_script_test(dir.path()); - assert!( - script.contains("No tests configured"), - "fallback should contain the generic stub message" - ); - assert!(script.starts_with("#!/usr/bin/env bash")); - } - - #[test] - fn detect_script_test_cargo_toml_adds_cargo_test() { - let dir = tempdir().unwrap(); - fs::write(dir.path().join("Cargo.toml"), "[package]\nname = \"x\"\n").unwrap(); - - let script = detect_script_test(dir.path()); - assert!( - script.contains("cargo test"), - "Rust project should run cargo test" - ); - assert!(!script.contains("No tests configured")); - } - - #[test] - fn detect_script_test_package_json_npm_adds_npm_test() { - let dir = tempdir().unwrap(); - fs::write(dir.path().join("package.json"), "{}").unwrap(); - - let script = detect_script_test(dir.path()); - assert!( - script.contains("npm test"), - "Node project without pnpm-lock should run npm test" - ); - assert!(!script.contains("No tests configured")); - } - - #[test] - fn detect_script_test_package_json_pnpm_adds_pnpm_test() { - let dir = tempdir().unwrap(); - fs::write(dir.path().join("package.json"), "{}").unwrap(); - fs::write(dir.path().join("pnpm-lock.yaml"), "").unwrap(); - - let script = detect_script_test(dir.path()); - assert!( - script.contains("pnpm test"), - "Node project with pnpm-lock should run pnpm test" - ); - // "pnpm test" is a substring of itself; verify there's no bare "npm test" line - assert!( - !script.lines().any(|l| l.trim() == "npm test"), - "should not use npm when pnpm-lock.yaml is present" - ); - } - - #[test] - fn detect_script_test_pyproject_toml_adds_pytest() { - let dir = tempdir().unwrap(); - fs::write( - dir.path().join("pyproject.toml"), - "[project]\nname = \"x\"\n", - ) - .unwrap(); - - let script = detect_script_test(dir.path()); - assert!( - script.contains("pytest"), - "Python project should run pytest" - ); - assert!(!script.contains("No tests configured")); - } - - #[test] - fn detect_script_test_requirements_txt_adds_pytest() { - let dir = tempdir().unwrap(); - fs::write(dir.path().join("requirements.txt"), "flask\n").unwrap(); - - let script = detect_script_test(dir.path()); - assert!( - script.contains("pytest"), - "Python project (requirements.txt) should run pytest" - ); - } - - #[test] - fn detect_script_test_go_mod_adds_go_test() { - let dir = tempdir().unwrap(); - fs::write(dir.path().join("go.mod"), "module example.com/app\n").unwrap(); - - let script = detect_script_test(dir.path()); - assert!( - script.contains("go test ./..."), - "Go project should run go test ./..." - ); - assert!(!script.contains("No tests configured")); - } - - #[test] - fn detect_script_test_multi_stack_combines_commands() { - let dir = tempdir().unwrap(); - fs::write(dir.path().join("go.mod"), "module example.com/app\n").unwrap(); - fs::write(dir.path().join("package.json"), "{}").unwrap(); - - let script = detect_script_test(dir.path()); - assert!( - script.contains("go test ./..."), - "multi-stack should include Go test command" - ); - assert!( - script.contains("npm test"), - "multi-stack should include Node test command" - ); - } - - #[test] - fn detect_script_test_frontend_subdir_with_vitest_uses_npx_vitest() { - let dir = tempdir().unwrap(); - let frontend = dir.path().join("frontend"); - fs::create_dir_all(&frontend).unwrap(); - fs::write( - frontend.join("package.json"), - r#"{"devDependencies":{"vitest":"^1.0.0"},"scripts":{"test":"vitest run"}}"#, - ) - .unwrap(); - - let script = detect_script_test(dir.path()); - assert!( - script.contains("vitest run"), - "frontend with vitest should emit vitest run" - ); - assert!( - script.contains("cd frontend"), - "should cd into the frontend directory" - ); - assert!( - !script.contains("No tests configured"), - "should not use stub when frontend is detected" - ); - } - - #[test] - fn detect_script_test_frontend_subdir_with_jest_uses_npx_jest() { - let dir = tempdir().unwrap(); - let frontend = dir.path().join("frontend"); - fs::create_dir_all(&frontend).unwrap(); - fs::write( - frontend.join("package.json"), - r#"{"devDependencies":{"jest":"^29.0.0"},"scripts":{"test":"jest"}}"#, - ) - .unwrap(); - - let script = detect_script_test(dir.path()); - assert!( - script.contains("jest"), - "frontend with jest should emit jest" - ); - assert!( - script.contains("cd frontend"), - "should cd into the frontend directory" - ); - } - - #[test] - fn detect_script_test_frontend_subdir_no_known_runner_uses_npm_test() { - let dir = tempdir().unwrap(); - let frontend = dir.path().join("frontend"); - fs::create_dir_all(&frontend).unwrap(); - fs::write( - frontend.join("package.json"), - r#"{"scripts":{"test":"mocha"}}"#, - ) - .unwrap(); - - let script = detect_script_test(dir.path()); - assert!( - script.contains("npm test"), - "frontend without known runner should fall back to npm test" - ); - assert!(script.contains("cd frontend")); - } - - #[test] - fn detect_script_test_frontend_subdir_pnpm_uses_pnpm_vitest() { - let dir = tempdir().unwrap(); - let frontend = dir.path().join("frontend"); - fs::create_dir_all(&frontend).unwrap(); - fs::write( - frontend.join("package.json"), - r#"{"devDependencies":{"vitest":"^1.0.0"}}"#, - ) - .unwrap(); - fs::write(frontend.join("pnpm-lock.yaml"), "").unwrap(); - - let script = detect_script_test(dir.path()); - assert!( - script.contains("pnpm vitest run"), - "pnpm frontend with vitest should use pnpm vitest run" - ); - } - - #[test] - fn detect_script_test_rust_plus_frontend_subdir_both_included() { - let dir = tempdir().unwrap(); - fs::write( - dir.path().join("Cargo.toml"), - "[package]\nname = \"server\"\n", - ) - .unwrap(); - let frontend = dir.path().join("frontend"); - fs::create_dir_all(&frontend).unwrap(); - fs::write( - frontend.join("package.json"), - r#"{"devDependencies":{"vitest":"^1.0.0"}}"#, - ) - .unwrap(); - - let script = detect_script_test(dir.path()); - assert!( - script.contains("cargo test"), - "Rust + frontend should include cargo test" - ); - assert!( - script.contains("vitest run"), - "Rust + frontend should include vitest run" - ); - assert!( - script.contains("cd frontend"), - "Rust + frontend should cd into frontend" - ); - } - - #[test] - fn detect_script_test_client_subdir_detected() { - let dir = tempdir().unwrap(); - let client = dir.path().join("client"); - fs::create_dir_all(&client).unwrap(); - fs::write( - client.join("package.json"), - r#"{"scripts":{"test":"jest"}}"#, - ) - .unwrap(); - - let script = detect_script_test(dir.path()); - assert!( - script.contains("cd client"), - "client/ subdir should also be detected" - ); - } - - #[test] - fn detect_script_test_output_starts_with_shebang() { - let dir = tempdir().unwrap(); - fs::write(dir.path().join("Cargo.toml"), "[package]\nname = \"x\"\n").unwrap(); - - let script = detect_script_test(dir.path()); - assert!( - script.starts_with("#!/usr/bin/env bash\nset -euo pipefail\n"), - "generated script should start with bash shebang and set -euo pipefail" - ); - } - - #[test] - fn detect_script_build_no_markers_returns_stub() { - let dir = tempdir().unwrap(); - let script = detect_script_build(dir.path()); - assert!( - script.contains("No build configured"), - "fallback should contain the generic stub message" - ); - assert!(script.starts_with("#!/usr/bin/env bash")); - } - - #[test] - fn detect_script_build_cargo_toml_adds_cargo_build_release() { - let dir = tempdir().unwrap(); - fs::write(dir.path().join("Cargo.toml"), "[package]\nname = \"x\"\n").unwrap(); - - let script = detect_script_build(dir.path()); - assert!( - script.contains("cargo build --release"), - "Rust project should run cargo build --release" - ); - assert!(!script.contains("No build configured")); - } - - #[test] - fn detect_script_build_package_json_npm_adds_npm_run_build() { - let dir = tempdir().unwrap(); - fs::write(dir.path().join("package.json"), "{}").unwrap(); - - let script = detect_script_build(dir.path()); - assert!( - script.contains("npm run build"), - "Node project without pnpm-lock should run npm run build" - ); - } - - #[test] - fn detect_script_build_package_json_pnpm_adds_pnpm_run_build() { - let dir = tempdir().unwrap(); - fs::write(dir.path().join("package.json"), "{}").unwrap(); - fs::write(dir.path().join("pnpm-lock.yaml"), "").unwrap(); - - let script = detect_script_build(dir.path()); - assert!( - script.contains("pnpm run build"), - "Node project with pnpm-lock should run pnpm run build" - ); - assert!( - !script.lines().any(|l| l.trim() == "npm run build"), - "should not use npm when pnpm-lock.yaml is present" - ); - } - - #[test] - fn detect_script_build_go_mod_adds_go_build() { - let dir = tempdir().unwrap(); - fs::write(dir.path().join("go.mod"), "module example.com/app\n").unwrap(); - - let script = detect_script_build(dir.path()); - assert!( - script.contains("go build ./..."), - "Go project should run go build ./..." - ); - } - - #[test] - fn detect_script_build_pyproject_toml_adds_python_build() { - let dir = tempdir().unwrap(); - fs::write( - dir.path().join("pyproject.toml"), - "[project]\nname = \"x\"\n", - ) - .unwrap(); - - let script = detect_script_build(dir.path()); - assert!( - script.contains("python -m build"), - "Python project should run python -m build" - ); - } - - #[test] - fn detect_script_build_frontend_subdir_detected() { - let dir = tempdir().unwrap(); - let frontend = dir.path().join("frontend"); - fs::create_dir_all(&frontend).unwrap(); - fs::write(frontend.join("package.json"), "{}").unwrap(); - - let script = detect_script_build(dir.path()); - assert!( - script.contains("cd frontend"), - "frontend subdir should be detected for build" - ); - assert!(script.contains("npm run build")); - } - - #[test] - fn detect_script_build_rust_plus_frontend_subdir_both_included() { - let dir = tempdir().unwrap(); - fs::write( - dir.path().join("Cargo.toml"), - "[package]\nname = \"server\"\n", - ) - .unwrap(); - let frontend = dir.path().join("frontend"); - fs::create_dir_all(&frontend).unwrap(); - fs::write(frontend.join("package.json"), "{}").unwrap(); - - let script = detect_script_build(dir.path()); - assert!(script.contains("cargo build --release")); - assert!(script.contains("cd frontend")); - assert!(script.contains("npm run build")); - } - - #[test] - fn detect_script_lint_no_markers_returns_stub() { - let dir = tempdir().unwrap(); - let script = detect_script_lint(dir.path()); - assert!( - script.contains("No linters configured"), - "fallback should contain the generic stub message" - ); - assert!(script.starts_with("#!/usr/bin/env bash")); - } - - #[test] - fn detect_script_lint_cargo_toml_adds_fmt_and_clippy() { - let dir = tempdir().unwrap(); - fs::write(dir.path().join("Cargo.toml"), "[package]\nname = \"x\"\n").unwrap(); - - let script = detect_script_lint(dir.path()); - assert!( - script.contains("cargo fmt --all --check"), - "Rust project should check formatting" - ); - assert!( - script.contains("cargo clippy -- -D warnings"), - "Rust project should run clippy" - ); - assert!(!script.contains("No linters configured")); - } - - #[test] - fn detect_script_lint_package_json_without_eslint_uses_npm_run_lint() { - let dir = tempdir().unwrap(); - fs::write(dir.path().join("package.json"), "{}").unwrap(); - - let script = detect_script_lint(dir.path()); - assert!( - script.contains("npm run lint"), - "Node project without eslint dep should fall back to npm run lint" - ); - } - - #[test] - fn detect_script_lint_package_json_with_eslint_uses_npx_eslint() { - let dir = tempdir().unwrap(); - fs::write( - dir.path().join("package.json"), - r#"{"devDependencies":{"eslint":"^8.0.0"}}"#, - ) - .unwrap(); - - let script = detect_script_lint(dir.path()); - assert!( - script.contains("npx eslint ."), - "Node project with eslint should use npx eslint ." - ); - } - - #[test] - fn detect_script_lint_pnpm_with_eslint_uses_pnpm_eslint() { - let dir = tempdir().unwrap(); - fs::write( - dir.path().join("package.json"), - r#"{"devDependencies":{"eslint":"^8.0.0"}}"#, - ) - .unwrap(); - fs::write(dir.path().join("pnpm-lock.yaml"), "").unwrap(); - - let script = detect_script_lint(dir.path()); - assert!( - script.contains("pnpm eslint ."), - "pnpm project with eslint should use pnpm eslint ." - ); - } - - #[test] - fn detect_script_lint_python_requirements_uses_flake8() { - let dir = tempdir().unwrap(); - fs::write(dir.path().join("requirements.txt"), "flask\n").unwrap(); - - let script = detect_script_lint(dir.path()); - assert!( - script.contains("flake8 ."), - "Python project without ruff should use flake8" - ); - } - - #[test] - fn detect_script_lint_python_with_ruff_uses_ruff() { - let dir = tempdir().unwrap(); - fs::write( - dir.path().join("pyproject.toml"), - "[project]\nname = \"x\"\n\n[tool.ruff]\n", - ) - .unwrap(); - - let script = detect_script_lint(dir.path()); - assert!( - script.contains("ruff check ."), - "Python project with ruff configured should use ruff" - ); - assert!( - !script.contains("flake8"), - "should not use flake8 when ruff is configured" - ); - } - - #[test] - fn detect_script_lint_go_mod_adds_go_vet() { - let dir = tempdir().unwrap(); - fs::write(dir.path().join("go.mod"), "module example.com/app\n").unwrap(); - - let script = detect_script_lint(dir.path()); - assert!( - script.contains("go vet ./..."), - "Go project should run go vet ./..." - ); - } - - #[test] - fn detect_script_lint_frontend_subdir_detected() { - let dir = tempdir().unwrap(); - let frontend = dir.path().join("frontend"); - fs::create_dir_all(&frontend).unwrap(); - fs::write(frontend.join("package.json"), "{}").unwrap(); - - let script = detect_script_lint(dir.path()); - assert!( - script.contains("cd frontend"), - "frontend subdir should be detected for lint" - ); - } - - #[test] - fn detect_script_lint_rust_plus_frontend_subdir_both_included() { - let dir = tempdir().unwrap(); - fs::write( - dir.path().join("Cargo.toml"), - "[package]\nname = \"server\"\n", - ) - .unwrap(); - let frontend = dir.path().join("frontend"); - fs::create_dir_all(&frontend).unwrap(); - fs::write(frontend.join("package.json"), "{}").unwrap(); - - let script = detect_script_lint(dir.path()); - assert!(script.contains("cargo fmt --all --check")); - assert!(script.contains("cargo clippy -- -D warnings")); - assert!(script.contains("cd frontend")); - } -} diff --git a/server/src/io/fs/scaffold/detect/build.rs b/server/src/io/fs/scaffold/detect/build.rs new file mode 100644 index 00000000..ef71dca5 --- /dev/null +++ b/server/src/io/fs/scaffold/detect/build.rs @@ -0,0 +1,175 @@ +//! Build script detection — generates `script/build` content for new projects. + +use std::path::Path; + +use super::detect_node_build_cmd; + +/// Generate `script/build` content for a new project at `root`. +/// +/// Inspects well-known marker files to identify which tech stacks are present +/// and emits the appropriate build commands. Multi-stack projects get combined +/// commands run sequentially. Falls back to a generic stub when no markers +/// are found so the scaffold is always valid. +/// +/// For projects with a frontend in a known subdirectory (`frontend/`, `client/`), +/// the build command is detected from the presence of `pnpm-lock.yaml`. +pub(crate) fn detect_script_build(root: &Path) -> String { + let mut commands: Vec = Vec::new(); + + if root.join("Cargo.toml").exists() { + commands.push("cargo build --release".to_string()); + } + + if root.join("package.json").exists() { + commands.push(detect_node_build_cmd(root)); + } + + // Detect frontend in known subdirectories (e.g. frontend/, client/) + for subdir in &["frontend", "client"] { + let sub_path = root.join(subdir); + if sub_path.join("package.json").exists() { + let cmd = detect_node_build_cmd(&sub_path); + commands.push(format!("(cd {} && {})", subdir, cmd)); + } + } + + if root.join("pyproject.toml").exists() { + commands.push("python -m build".to_string()); + } + + if root.join("go.mod").exists() { + commands.push("go build ./...".to_string()); + } + + if commands.is_empty() { + return "#!/usr/bin/env bash\nset -euo pipefail\n\n# Add your project's build commands here.\necho \"No build configured\"\n".to_string(); + } + + let mut script = "#!/usr/bin/env bash\nset -euo pipefail\n\n".to_string(); + for cmd in commands { + script.push_str(&cmd); + script.push('\n'); + } + script +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use tempfile::tempdir; + + #[test] + fn detect_script_build_no_markers_returns_stub() { + let dir = tempdir().unwrap(); + let script = detect_script_build(dir.path()); + assert!( + script.contains("No build configured"), + "fallback should contain the generic stub message" + ); + assert!(script.starts_with("#!/usr/bin/env bash")); + } + + #[test] + fn detect_script_build_cargo_toml_adds_cargo_build_release() { + let dir = tempdir().unwrap(); + fs::write(dir.path().join("Cargo.toml"), "[package]\nname = \"x\"\n").unwrap(); + + let script = detect_script_build(dir.path()); + assert!( + script.contains("cargo build --release"), + "Rust project should run cargo build --release" + ); + assert!(!script.contains("No build configured")); + } + + #[test] + fn detect_script_build_package_json_npm_adds_npm_run_build() { + let dir = tempdir().unwrap(); + fs::write(dir.path().join("package.json"), "{}").unwrap(); + + let script = detect_script_build(dir.path()); + assert!( + script.contains("npm run build"), + "Node project without pnpm-lock should run npm run build" + ); + } + + #[test] + fn detect_script_build_package_json_pnpm_adds_pnpm_run_build() { + let dir = tempdir().unwrap(); + fs::write(dir.path().join("package.json"), "{}").unwrap(); + fs::write(dir.path().join("pnpm-lock.yaml"), "").unwrap(); + + let script = detect_script_build(dir.path()); + assert!( + script.contains("pnpm run build"), + "Node project with pnpm-lock should run pnpm run build" + ); + assert!( + !script.lines().any(|l| l.trim() == "npm run build"), + "should not use npm when pnpm-lock.yaml is present" + ); + } + + #[test] + fn detect_script_build_go_mod_adds_go_build() { + let dir = tempdir().unwrap(); + fs::write(dir.path().join("go.mod"), "module example.com/app\n").unwrap(); + + let script = detect_script_build(dir.path()); + assert!( + script.contains("go build ./..."), + "Go project should run go build ./..." + ); + } + + #[test] + fn detect_script_build_pyproject_toml_adds_python_build() { + let dir = tempdir().unwrap(); + fs::write( + dir.path().join("pyproject.toml"), + "[project]\nname = \"x\"\n", + ) + .unwrap(); + + let script = detect_script_build(dir.path()); + assert!( + script.contains("python -m build"), + "Python project should run python -m build" + ); + } + + #[test] + fn detect_script_build_frontend_subdir_detected() { + let dir = tempdir().unwrap(); + let frontend = dir.path().join("frontend"); + fs::create_dir_all(&frontend).unwrap(); + fs::write(frontend.join("package.json"), "{}").unwrap(); + + let script = detect_script_build(dir.path()); + assert!( + script.contains("cd frontend"), + "frontend subdir should be detected for build" + ); + assert!(script.contains("npm run build")); + } + + #[test] + fn detect_script_build_rust_plus_frontend_subdir_both_included() { + let dir = tempdir().unwrap(); + fs::write( + dir.path().join("Cargo.toml"), + "[package]\nname = \"server\"\n", + ) + .unwrap(); + let frontend = dir.path().join("frontend"); + fs::create_dir_all(&frontend).unwrap(); + fs::write(frontend.join("package.json"), "{}").unwrap(); + + let script = detect_script_build(dir.path()); + assert!(script.contains("cargo build --release")); + assert!(script.contains("cd frontend")); + assert!(script.contains("npm run build")); + } +} diff --git a/server/src/io/fs/scaffold/detect/components.rs b/server/src/io/fs/scaffold/detect/components.rs new file mode 100644 index 00000000..8c5e5af2 --- /dev/null +++ b/server/src/io/fs/scaffold/detect/components.rs @@ -0,0 +1,251 @@ +//! Component TOML detection — inspects the project root for marker files and +//! emits `[[component]]` entries for the detected tech stacks. + +use std::path::Path; + +/// Detect the tech stack from the project root and return TOML `[[component]]` entries. +/// +/// Inspects well-known marker files at the project root to identify which +/// tech stacks are present, then emits one `[[component]]` entry per detected +/// stack with sensible default `setup` commands. If no markers are found, a +/// single fallback `app` component with an empty `setup` list is returned so +/// that the pipeline never breaks on an unknown stack. +pub(crate) fn detect_components_toml(root: &Path) -> String { + let mut sections = Vec::new(); + + if root.join("Cargo.toml").exists() { + sections.push( + "[[component]]\nname = \"server\"\npath = \".\"\nsetup = [\"cargo check\"]\n" + .to_string(), + ); + } + + if root.join("package.json").exists() { + let setup_cmd = if root.join("pnpm-lock.yaml").exists() { + "pnpm install" + } else { + "npm install" + }; + sections.push(format!( + "[[component]]\nname = \"frontend\"\npath = \".\"\nsetup = [\"{setup_cmd}\"]\n" + )); + } + + if root.join("pyproject.toml").exists() || root.join("requirements.txt").exists() { + sections.push( + "[[component]]\nname = \"python\"\npath = \".\"\nsetup = [\"pip install -r requirements.txt\"]\n" + .to_string(), + ); + } + + if root.join("go.mod").exists() { + sections.push( + "[[component]]\nname = \"go\"\npath = \".\"\nsetup = [\"go build ./...\"]\n" + .to_string(), + ); + } + + if root.join("Gemfile").exists() { + sections.push( + "[[component]]\nname = \"ruby\"\npath = \".\"\nsetup = [\"bundle install\"]\n" + .to_string(), + ); + } + + if sections.is_empty() { + // No tech stack markers detected — emit a single generic component + // with an empty setup list. The ONBOARDING_PROMPT instructs the chat + // agent to inspect the project and replace this with real definitions. + sections.push("[[component]]\nname = \"app\"\npath = \".\"\nsetup = []\n".to_string()); + } + + sections.join("\n") +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use tempfile::tempdir; + + #[test] + fn detect_no_markers_returns_fallback_components() { + let dir = tempdir().unwrap(); + let toml = detect_components_toml(dir.path()); + // At least one [[component]] entry should always be present + assert!( + toml.contains("[[component]]"), + "should always emit at least one component" + ); + // Fallback should use a generic app component with empty setup + assert!( + toml.contains("name = \"app\""), + "fallback should use generic 'app' component name" + ); + assert!( + toml.contains("setup = []"), + "fallback should have empty setup list" + ); + // Must not contain Rust-specific commands in a non-Rust project + assert!( + !toml.contains("cargo"), + "fallback must not contain Rust-specific commands" + ); + } + + #[test] + fn detect_cargo_toml_generates_rust_component() { + let dir = tempdir().unwrap(); + fs::write( + dir.path().join("Cargo.toml"), + "[package]\nname = \"test\"\n", + ) + .unwrap(); + + let toml = detect_components_toml(dir.path()); + assert!(toml.contains("name = \"server\"")); + assert!(toml.contains("setup = [\"cargo check\"]")); + } + + #[test] + fn detect_package_json_with_pnpm_lock_generates_pnpm_component() { + let dir = tempdir().unwrap(); + fs::write(dir.path().join("package.json"), "{}").unwrap(); + fs::write(dir.path().join("pnpm-lock.yaml"), "").unwrap(); + + let toml = detect_components_toml(dir.path()); + assert!(toml.contains("name = \"frontend\"")); + assert!(toml.contains("setup = [\"pnpm install\"]")); + } + + #[test] + fn detect_package_json_without_pnpm_lock_generates_npm_component() { + let dir = tempdir().unwrap(); + fs::write(dir.path().join("package.json"), "{}").unwrap(); + + let toml = detect_components_toml(dir.path()); + assert!(toml.contains("name = \"frontend\"")); + assert!(toml.contains("setup = [\"npm install\"]")); + } + + #[test] + fn detect_pyproject_toml_generates_python_component() { + let dir = tempdir().unwrap(); + fs::write( + dir.path().join("pyproject.toml"), + "[project]\nname = \"test\"\n", + ) + .unwrap(); + + let toml = detect_components_toml(dir.path()); + assert!(toml.contains("name = \"python\"")); + assert!(toml.contains("pip install")); + } + + #[test] + fn detect_requirements_txt_generates_python_component() { + let dir = tempdir().unwrap(); + fs::write(dir.path().join("requirements.txt"), "flask\n").unwrap(); + + let toml = detect_components_toml(dir.path()); + assert!(toml.contains("name = \"python\"")); + assert!(toml.contains("pip install")); + } + + #[test] + fn detect_go_mod_generates_go_component() { + let dir = tempdir().unwrap(); + fs::write(dir.path().join("go.mod"), "module example.com/app\n").unwrap(); + + let toml = detect_components_toml(dir.path()); + assert!(toml.contains("name = \"go\"")); + assert!(toml.contains("setup = [\"go build ./...\"]")); + } + + #[test] + fn detect_gemfile_generates_ruby_component() { + let dir = tempdir().unwrap(); + fs::write( + dir.path().join("Gemfile"), + "source \"https://rubygems.org\"\n", + ) + .unwrap(); + + let toml = detect_components_toml(dir.path()); + assert!(toml.contains("name = \"ruby\"")); + assert!(toml.contains("setup = [\"bundle install\"]")); + } + + #[test] + fn no_rust_commands_in_go_project() { + let dir = tempdir().unwrap(); + fs::write(dir.path().join("go.mod"), "module example.com/app\n").unwrap(); + + let toml = detect_components_toml(dir.path()); + assert!( + !toml.contains("cargo"), + "go project must not contain cargo commands" + ); + assert!(toml.contains("go build"), "go project must use Go tooling"); + } + + #[test] + fn no_rust_commands_in_node_project() { + let dir = tempdir().unwrap(); + fs::write(dir.path().join("package.json"), "{}").unwrap(); + + let toml = detect_components_toml(dir.path()); + assert!( + !toml.contains("cargo"), + "node project must not contain cargo commands" + ); + assert!( + toml.contains("npm install"), + "node project must use npm tooling" + ); + } + + #[test] + fn no_rust_commands_when_no_stack_detected() { + let dir = tempdir().unwrap(); + + let toml = detect_components_toml(dir.path()); + assert!( + !toml.contains("cargo"), + "unknown stack must not contain cargo commands" + ); + // setup list must be empty + assert!( + toml.contains("setup = []"), + "unknown stack must have empty setup list" + ); + } + + #[test] + fn detect_multiple_markers_generates_multiple_components() { + let dir = tempdir().unwrap(); + fs::write( + dir.path().join("Cargo.toml"), + "[package]\nname = \"server\"\n", + ) + .unwrap(); + fs::write(dir.path().join("package.json"), "{}").unwrap(); + + let toml = detect_components_toml(dir.path()); + assert!(toml.contains("name = \"server\"")); + assert!(toml.contains("name = \"frontend\"")); + // Both component entries should be present + let component_count = toml.matches("[[component]]").count(); + assert_eq!(component_count, 2); + } + + #[test] + fn detect_no_fallback_when_markers_found() { + let dir = tempdir().unwrap(); + fs::write(dir.path().join("Cargo.toml"), "[package]\nname = \"x\"\n").unwrap(); + + let toml = detect_components_toml(dir.path()); + // The fallback "app" component should NOT appear when a real stack is detected + assert!(!toml.contains("name = \"app\"")); + } +} diff --git a/server/src/io/fs/scaffold/detect/lint.rs b/server/src/io/fs/scaffold/detect/lint.rs new file mode 100644 index 00000000..e8992180 --- /dev/null +++ b/server/src/io/fs/scaffold/detect/lint.rs @@ -0,0 +1,218 @@ +//! Lint script detection — generates `script/lint` content for new projects. + +use std::path::Path; + +use super::detect_node_lint_cmd; + +/// Generate `script/lint` content for a new project at `root`. +/// +/// Inspects well-known marker files to identify which linters are present +/// and emits the appropriate lint commands. Multi-stack projects get combined +/// commands run sequentially. Falls back to a generic stub when no markers +/// are found so the scaffold is always valid. +/// +/// For projects with a frontend in a known subdirectory (`frontend/`, `client/`), +/// the lint command is detected from the `package.json` (eslint, npm, pnpm). +pub(crate) fn detect_script_lint(root: &Path) -> String { + let mut commands: Vec = Vec::new(); + + if root.join("Cargo.toml").exists() { + commands.push("cargo fmt --all --check".to_string()); + commands.push("cargo clippy -- -D warnings".to_string()); + } + + if root.join("package.json").exists() { + commands.push(detect_node_lint_cmd(root)); + } + + // Detect frontend in known subdirectories (e.g. frontend/, client/) + for subdir in &["frontend", "client"] { + let sub_path = root.join(subdir); + if sub_path.join("package.json").exists() { + let cmd = detect_node_lint_cmd(&sub_path); + commands.push(format!("(cd {} && {})", subdir, cmd)); + } + } + + if root.join("pyproject.toml").exists() || root.join("requirements.txt").exists() { + let mut content = std::fs::read_to_string(root.join("pyproject.toml")).unwrap_or_default(); + content + .push_str(&std::fs::read_to_string(root.join("requirements.txt")).unwrap_or_default()); + if content.contains("ruff") { + commands.push("ruff check .".to_string()); + } else { + commands.push("flake8 .".to_string()); + } + } + + if root.join("go.mod").exists() { + commands.push("go vet ./...".to_string()); + } + + if commands.is_empty() { + return "#!/usr/bin/env bash\nset -euo pipefail\n\n# Add your project's lint commands here.\necho \"No linters configured\"\n".to_string(); + } + + let mut script = "#!/usr/bin/env bash\nset -euo pipefail\n\n".to_string(); + for cmd in commands { + script.push_str(&cmd); + script.push('\n'); + } + script +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use tempfile::tempdir; + + #[test] + fn detect_script_lint_no_markers_returns_stub() { + let dir = tempdir().unwrap(); + let script = detect_script_lint(dir.path()); + assert!( + script.contains("No linters configured"), + "fallback should contain the generic stub message" + ); + assert!(script.starts_with("#!/usr/bin/env bash")); + } + + #[test] + fn detect_script_lint_cargo_toml_adds_fmt_and_clippy() { + let dir = tempdir().unwrap(); + fs::write(dir.path().join("Cargo.toml"), "[package]\nname = \"x\"\n").unwrap(); + + let script = detect_script_lint(dir.path()); + assert!( + script.contains("cargo fmt --all --check"), + "Rust project should check formatting" + ); + assert!( + script.contains("cargo clippy -- -D warnings"), + "Rust project should run clippy" + ); + assert!(!script.contains("No linters configured")); + } + + #[test] + fn detect_script_lint_package_json_without_eslint_uses_npm_run_lint() { + let dir = tempdir().unwrap(); + fs::write(dir.path().join("package.json"), "{}").unwrap(); + + let script = detect_script_lint(dir.path()); + assert!( + script.contains("npm run lint"), + "Node project without eslint dep should fall back to npm run lint" + ); + } + + #[test] + fn detect_script_lint_package_json_with_eslint_uses_npx_eslint() { + let dir = tempdir().unwrap(); + fs::write( + dir.path().join("package.json"), + r#"{"devDependencies":{"eslint":"^8.0.0"}}"#, + ) + .unwrap(); + + let script = detect_script_lint(dir.path()); + assert!( + script.contains("npx eslint ."), + "Node project with eslint should use npx eslint ." + ); + } + + #[test] + fn detect_script_lint_pnpm_with_eslint_uses_pnpm_eslint() { + let dir = tempdir().unwrap(); + fs::write( + dir.path().join("package.json"), + r#"{"devDependencies":{"eslint":"^8.0.0"}}"#, + ) + .unwrap(); + fs::write(dir.path().join("pnpm-lock.yaml"), "").unwrap(); + + let script = detect_script_lint(dir.path()); + assert!( + script.contains("pnpm eslint ."), + "pnpm project with eslint should use pnpm eslint ." + ); + } + + #[test] + fn detect_script_lint_python_requirements_uses_flake8() { + let dir = tempdir().unwrap(); + fs::write(dir.path().join("requirements.txt"), "flask\n").unwrap(); + + let script = detect_script_lint(dir.path()); + assert!( + script.contains("flake8 ."), + "Python project without ruff should use flake8" + ); + } + + #[test] + fn detect_script_lint_python_with_ruff_uses_ruff() { + let dir = tempdir().unwrap(); + fs::write( + dir.path().join("pyproject.toml"), + "[project]\nname = \"x\"\n\n[tool.ruff]\n", + ) + .unwrap(); + + let script = detect_script_lint(dir.path()); + assert!( + script.contains("ruff check ."), + "Python project with ruff configured should use ruff" + ); + assert!( + !script.contains("flake8"), + "should not use flake8 when ruff is configured" + ); + } + + #[test] + fn detect_script_lint_go_mod_adds_go_vet() { + let dir = tempdir().unwrap(); + fs::write(dir.path().join("go.mod"), "module example.com/app\n").unwrap(); + + let script = detect_script_lint(dir.path()); + assert!( + script.contains("go vet ./..."), + "Go project should run go vet ./..." + ); + } + + #[test] + fn detect_script_lint_frontend_subdir_detected() { + let dir = tempdir().unwrap(); + let frontend = dir.path().join("frontend"); + fs::create_dir_all(&frontend).unwrap(); + fs::write(frontend.join("package.json"), "{}").unwrap(); + + let script = detect_script_lint(dir.path()); + assert!( + script.contains("cd frontend"), + "frontend subdir should be detected for lint" + ); + } + + #[test] + fn detect_script_lint_rust_plus_frontend_subdir_both_included() { + let dir = tempdir().unwrap(); + fs::write( + dir.path().join("Cargo.toml"), + "[package]\nname = \"server\"\n", + ) + .unwrap(); + let frontend = dir.path().join("frontend"); + fs::create_dir_all(&frontend).unwrap(); + fs::write(frontend.join("package.json"), "{}").unwrap(); + + let script = detect_script_lint(dir.path()); + assert!(script.contains("cargo fmt --all --check")); + assert!(script.contains("cargo clippy -- -D warnings")); + assert!(script.contains("cd frontend")); + } +} diff --git a/server/src/io/fs/scaffold/detect/mod.rs b/server/src/io/fs/scaffold/detect/mod.rs new file mode 100644 index 00000000..001b3e2c --- /dev/null +++ b/server/src/io/fs/scaffold/detect/mod.rs @@ -0,0 +1,63 @@ +//! Stack detection — inspect the project root for marker files and emit +//! TOML `[[component]]` entries plus `script/build|lint|test` content. + +mod build; +mod components; +mod lint; +mod test_script; + +pub(crate) use build::detect_script_build; +pub(crate) use components::detect_components_toml; +pub(crate) use lint::detect_script_lint; +pub(crate) use test_script::detect_script_test; + +/// Detect the appropriate Node.js test command for a directory containing `package.json`. +/// +/// Reads the `package.json` content to identify known test runners (vitest, jest). +/// Falls back to `npm test` or `pnpm test` based on which lock file is present. +fn detect_node_test_cmd(pkg_dir: &std::path::Path) -> String { + let has_pnpm = pkg_dir.join("pnpm-lock.yaml").exists(); + let content = std::fs::read_to_string(pkg_dir.join("package.json")).unwrap_or_default(); + + if content.contains("\"vitest\"") { + let pm = if has_pnpm { "pnpm" } else { "npx" }; + return format!("{} vitest run", pm); + } + if content.contains("\"jest\"") { + let pm = if has_pnpm { "pnpm" } else { "npx" }; + return format!("{} jest", pm); + } + + if has_pnpm { + "pnpm test".to_string() + } else { + "npm test".to_string() + } +} + +/// Detect the appropriate Node.js build command for a directory containing `package.json`. +fn detect_node_build_cmd(pkg_dir: &std::path::Path) -> String { + if pkg_dir.join("pnpm-lock.yaml").exists() { + "pnpm run build".to_string() + } else { + "npm run build".to_string() + } +} + +/// Detect the appropriate Node.js lint command for a directory containing `package.json`. +/// +/// Reads the `package.json` content to identify eslint. Falls back to +/// `npm run lint` or `pnpm run lint` based on which lock file is present. +fn detect_node_lint_cmd(pkg_dir: &std::path::Path) -> String { + let has_pnpm = pkg_dir.join("pnpm-lock.yaml").exists(); + let content = std::fs::read_to_string(pkg_dir.join("package.json")).unwrap_or_default(); + if content.contains("\"eslint\"") { + let pm = if has_pnpm { "pnpm" } else { "npx" }; + return format!("{pm} eslint ."); + } + if has_pnpm { + "pnpm run lint".to_string() + } else { + "npm run lint".to_string() + } +} diff --git a/server/src/io/fs/scaffold/detect/test_script.rs b/server/src/io/fs/scaffold/detect/test_script.rs new file mode 100644 index 00000000..fcae6b13 --- /dev/null +++ b/server/src/io/fs/scaffold/detect/test_script.rs @@ -0,0 +1,327 @@ +//! Test script detection — generates `script/test` content for new projects. + +use std::path::Path; + +use super::super::templates::STORY_KIT_SCRIPT_TEST; +use super::detect_node_test_cmd; + +/// Generate `script/test` content for a new project at `root`. +/// +/// Inspects well-known marker files to identify which tech stacks are present +/// and emits the appropriate test commands. Multi-stack projects get combined +/// commands run sequentially. Falls back to the generic stub when no markers +/// are found so the scaffold is always valid. +/// +/// For projects with a frontend in a known subdirectory (`frontend/`, `client/`), +/// the test runner is detected from the `package.json` (vitest, jest, npm, pnpm). +pub(crate) fn detect_script_test(root: &Path) -> String { + let mut commands: Vec = Vec::new(); + + if root.join("Cargo.toml").exists() { + commands.push("cargo test".to_string()); + } + + if root.join("package.json").exists() { + if root.join("pnpm-lock.yaml").exists() { + commands.push("pnpm test".to_string()); + } else { + commands.push("npm test".to_string()); + } + } + + // Detect frontend in known subdirectories (e.g. frontend/, client/) + for subdir in &["frontend", "client"] { + let sub_path = root.join(subdir); + if sub_path.join("package.json").exists() { + let cmd = detect_node_test_cmd(&sub_path); + commands.push(format!("(cd {} && {})", subdir, cmd)); + } + } + + if root.join("pyproject.toml").exists() || root.join("requirements.txt").exists() { + commands.push("pytest".to_string()); + } + + if root.join("go.mod").exists() { + commands.push("go test ./...".to_string()); + } + + if commands.is_empty() { + return STORY_KIT_SCRIPT_TEST.to_string(); + } + + let mut script = "#!/usr/bin/env bash\nset -euo pipefail\n\n".to_string(); + for cmd in commands { + script.push_str(&cmd); + script.push('\n'); + } + script +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use tempfile::tempdir; + + #[test] + fn detect_script_test_no_markers_returns_stub() { + let dir = tempdir().unwrap(); + let script = detect_script_test(dir.path()); + assert!( + script.contains("No tests configured"), + "fallback should contain the generic stub message" + ); + assert!(script.starts_with("#!/usr/bin/env bash")); + } + + #[test] + fn detect_script_test_cargo_toml_adds_cargo_test() { + let dir = tempdir().unwrap(); + fs::write(dir.path().join("Cargo.toml"), "[package]\nname = \"x\"\n").unwrap(); + + let script = detect_script_test(dir.path()); + assert!( + script.contains("cargo test"), + "Rust project should run cargo test" + ); + assert!(!script.contains("No tests configured")); + } + + #[test] + fn detect_script_test_package_json_npm_adds_npm_test() { + let dir = tempdir().unwrap(); + fs::write(dir.path().join("package.json"), "{}").unwrap(); + + let script = detect_script_test(dir.path()); + assert!( + script.contains("npm test"), + "Node project without pnpm-lock should run npm test" + ); + assert!(!script.contains("No tests configured")); + } + + #[test] + fn detect_script_test_package_json_pnpm_adds_pnpm_test() { + let dir = tempdir().unwrap(); + fs::write(dir.path().join("package.json"), "{}").unwrap(); + fs::write(dir.path().join("pnpm-lock.yaml"), "").unwrap(); + + let script = detect_script_test(dir.path()); + assert!( + script.contains("pnpm test"), + "Node project with pnpm-lock should run pnpm test" + ); + // "pnpm test" is a substring of itself; verify there's no bare "npm test" line + assert!( + !script.lines().any(|l| l.trim() == "npm test"), + "should not use npm when pnpm-lock.yaml is present" + ); + } + + #[test] + fn detect_script_test_pyproject_toml_adds_pytest() { + let dir = tempdir().unwrap(); + fs::write( + dir.path().join("pyproject.toml"), + "[project]\nname = \"x\"\n", + ) + .unwrap(); + + let script = detect_script_test(dir.path()); + assert!( + script.contains("pytest"), + "Python project should run pytest" + ); + assert!(!script.contains("No tests configured")); + } + + #[test] + fn detect_script_test_requirements_txt_adds_pytest() { + let dir = tempdir().unwrap(); + fs::write(dir.path().join("requirements.txt"), "flask\n").unwrap(); + + let script = detect_script_test(dir.path()); + assert!( + script.contains("pytest"), + "Python project (requirements.txt) should run pytest" + ); + } + + #[test] + fn detect_script_test_go_mod_adds_go_test() { + let dir = tempdir().unwrap(); + fs::write(dir.path().join("go.mod"), "module example.com/app\n").unwrap(); + + let script = detect_script_test(dir.path()); + assert!( + script.contains("go test ./..."), + "Go project should run go test ./..." + ); + assert!(!script.contains("No tests configured")); + } + + #[test] + fn detect_script_test_multi_stack_combines_commands() { + let dir = tempdir().unwrap(); + fs::write(dir.path().join("go.mod"), "module example.com/app\n").unwrap(); + fs::write(dir.path().join("package.json"), "{}").unwrap(); + + let script = detect_script_test(dir.path()); + assert!( + script.contains("go test ./..."), + "multi-stack should include Go test command" + ); + assert!( + script.contains("npm test"), + "multi-stack should include Node test command" + ); + } + + #[test] + fn detect_script_test_frontend_subdir_with_vitest_uses_npx_vitest() { + let dir = tempdir().unwrap(); + let frontend = dir.path().join("frontend"); + fs::create_dir_all(&frontend).unwrap(); + fs::write( + frontend.join("package.json"), + r#"{"devDependencies":{"vitest":"^1.0.0"},"scripts":{"test":"vitest run"}}"#, + ) + .unwrap(); + + let script = detect_script_test(dir.path()); + assert!( + script.contains("vitest run"), + "frontend with vitest should emit vitest run" + ); + assert!( + script.contains("cd frontend"), + "should cd into the frontend directory" + ); + assert!( + !script.contains("No tests configured"), + "should not use stub when frontend is detected" + ); + } + + #[test] + fn detect_script_test_frontend_subdir_with_jest_uses_npx_jest() { + let dir = tempdir().unwrap(); + let frontend = dir.path().join("frontend"); + fs::create_dir_all(&frontend).unwrap(); + fs::write( + frontend.join("package.json"), + r#"{"devDependencies":{"jest":"^29.0.0"},"scripts":{"test":"jest"}}"#, + ) + .unwrap(); + + let script = detect_script_test(dir.path()); + assert!( + script.contains("jest"), + "frontend with jest should emit jest" + ); + assert!( + script.contains("cd frontend"), + "should cd into the frontend directory" + ); + } + + #[test] + fn detect_script_test_frontend_subdir_no_known_runner_uses_npm_test() { + let dir = tempdir().unwrap(); + let frontend = dir.path().join("frontend"); + fs::create_dir_all(&frontend).unwrap(); + fs::write( + frontend.join("package.json"), + r#"{"scripts":{"test":"mocha"}}"#, + ) + .unwrap(); + + let script = detect_script_test(dir.path()); + assert!( + script.contains("npm test"), + "frontend without known runner should fall back to npm test" + ); + assert!(script.contains("cd frontend")); + } + + #[test] + fn detect_script_test_frontend_subdir_pnpm_uses_pnpm_vitest() { + let dir = tempdir().unwrap(); + let frontend = dir.path().join("frontend"); + fs::create_dir_all(&frontend).unwrap(); + fs::write( + frontend.join("package.json"), + r#"{"devDependencies":{"vitest":"^1.0.0"}}"#, + ) + .unwrap(); + fs::write(frontend.join("pnpm-lock.yaml"), "").unwrap(); + + let script = detect_script_test(dir.path()); + assert!( + script.contains("pnpm vitest run"), + "pnpm frontend with vitest should use pnpm vitest run" + ); + } + + #[test] + fn detect_script_test_rust_plus_frontend_subdir_both_included() { + let dir = tempdir().unwrap(); + fs::write( + dir.path().join("Cargo.toml"), + "[package]\nname = \"server\"\n", + ) + .unwrap(); + let frontend = dir.path().join("frontend"); + fs::create_dir_all(&frontend).unwrap(); + fs::write( + frontend.join("package.json"), + r#"{"devDependencies":{"vitest":"^1.0.0"}}"#, + ) + .unwrap(); + + let script = detect_script_test(dir.path()); + assert!( + script.contains("cargo test"), + "Rust + frontend should include cargo test" + ); + assert!( + script.contains("vitest run"), + "Rust + frontend should include vitest run" + ); + assert!( + script.contains("cd frontend"), + "Rust + frontend should cd into frontend" + ); + } + + #[test] + fn detect_script_test_client_subdir_detected() { + let dir = tempdir().unwrap(); + let client = dir.path().join("client"); + fs::create_dir_all(&client).unwrap(); + fs::write( + client.join("package.json"), + r#"{"scripts":{"test":"jest"}}"#, + ) + .unwrap(); + + let script = detect_script_test(dir.path()); + assert!( + script.contains("cd client"), + "client/ subdir should also be detected" + ); + } + + #[test] + fn detect_script_test_output_starts_with_shebang() { + let dir = tempdir().unwrap(); + fs::write(dir.path().join("Cargo.toml"), "[package]\nname = \"x\"\n").unwrap(); + + let script = detect_script_test(dir.path()); + assert!( + script.starts_with("#!/usr/bin/env bash\nset -euo pipefail\n"), + "generated script should start with bash shebang and set -euo pipefail" + ); + } +}