huskies: merge 790
This commit is contained in:
@@ -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"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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\""));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user