huskies: merge 790

This commit is contained in:
dave
2026-04-28 15:09:54 +00:00
parent 7cd9706c0f
commit c1a50eab8e
6 changed files with 1034 additions and 991 deletions
-991
View File
@@ -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<String> = 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<String> = 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<String> = 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"));
}
}
+175
View File
@@ -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<String> = 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"));
}
}
@@ -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\""));
}
}
+218
View File
@@ -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<String> = 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"));
}
}
+63
View File
@@ -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()
}
}
@@ -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<String> = 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"
);
}
}