//! Pure wizard state machine helpers — no I/O, no side effects. //! //! All functions here take data (strings, booleans, enum values), never paths. //! Filesystem probes belong in `io.rs`; orchestration in `mod.rs`. //! These functions are unit-testable without tempdirs or async runtimes. use crate::io::wizard::WizardStep; use std::path::{Path, PathBuf}; /// Project build-tool detection flags produced by `io::detect_project_tools`. pub(crate) struct ProjectTools { pub has_cargo: bool, pub has_pkg: bool, pub has_pnpm: bool, pub has_frontend_subdir: bool, pub has_go: bool, pub has_python: bool, } /// Return true when the directory contains no meaningful source files. /// /// A "bare" project has only huskies scaffolding — no code, build manifests, /// or source directories. Takes a slice of filename strings so it can be /// called without touching the filesystem. pub(crate) fn is_bare_project(file_names: &[impl AsRef]) -> bool { file_names.iter().all(|n| { let n = n.as_ref(); n.starts_with('.') || n == "CLAUDE.md" || n == "LICENSE" || n == "README.md" || n == "script" }) } /// Return true when `step` produces a script file that must be made executable. pub(crate) fn is_script_step(step: WizardStep) -> bool { matches!( step, WizardStep::TestScript | WizardStep::BuildScript | WizardStep::LintScript | WizardStep::ReleaseScript | WizardStep::TestCoverage ) } /// Serialise a `WizardStep` to its snake_case slug (e.g. `"test_script"`). pub(crate) fn step_slug(step: WizardStep) -> String { serde_json::to_value(step) .ok() .and_then(|v| v.as_str().map(String::from)) .unwrap_or_default() } /// Return the output path for a step relative to `project_root`. /// /// Pure path concatenation — no filesystem access. Returns `None` for /// `Scaffold` which has no single output file. pub(crate) fn step_output_path(project_root: &Path, step: WizardStep) -> Option { match step { WizardStep::Context => Some( project_root .join(".huskies") .join("specs") .join("00_CONTEXT.md"), ), WizardStep::Stack => Some( project_root .join(".huskies") .join("specs") .join("tech") .join("STACK.md"), ), WizardStep::TestScript => Some(project_root.join("script").join("test")), WizardStep::BuildScript => Some(project_root.join("script").join("build")), WizardStep::LintScript => Some(project_root.join("script").join("lint")), WizardStep::ReleaseScript => Some(project_root.join("script").join("release")), WizardStep::TestCoverage => Some(project_root.join("script").join("test_coverage")), WizardStep::Scaffold => None, } } /// Generate an agent hint for a wizard step. /// /// `is_bare` — true when the project has no source files beyond scaffolding. /// `tools` — detected build system presence (no filesystem access needed here). pub(crate) fn generation_hint(step: WizardStep, is_bare: bool, tools: &ProjectTools) -> String { match step { WizardStep::Context => { if is_bare { "This is a bare project with no existing code. Ask the user what they want \ to build — the project's purpose, goals, target users, and key features. \ Then generate `.huskies/specs/00_CONTEXT.md` from their answers covering:\n\ - High-level goal of the project\n\ - Core features\n\ - Domain concepts and entities\n\ - Glossary of abbreviations and technical terms" .to_string() } else { "Read the project source tree and generate a `.huskies/specs/00_CONTEXT.md` describing:\n\ - High-level goal of the project\n\ - Core features\n\ - Domain concepts and entities\n\ - Glossary of abbreviations and technical terms" .to_string() } } WizardStep::Stack => { if is_bare { "This is a bare project with no existing code. Ask the user what language, \ frameworks, and tools they plan to use. Then generate `.huskies/specs/tech/STACK.md` \ from their answers covering:\n\ - Language, frameworks, and runtimes\n\ - Coding standards and linting rules\n\ - Quality gates (commands that must pass before merging)\n\ - Approved libraries and their purpose" .to_string() } else { "Read the project source tree and generate a `.huskies/specs/tech/STACK.md` describing:\n\ - Language, frameworks, and runtimes\n\ - Coding standards and linting rules\n\ - Quality gates (commands that must pass before merging)\n\ - Approved libraries and their purpose" .to_string() } } WizardStep::TestScript => { if is_bare { "This is a bare project with no existing code. Read the STACK.md generated \ in the previous step (or ask the user about their stack if it was skipped) \ and generate a `script/test` shell script (#!/usr/bin/env bash, set -euo pipefail) \ with appropriate test commands for their chosen language and framework." .to_string() } else { let mut cmds = Vec::new(); if tools.has_cargo { cmds.push("cargo nextest run"); } if tools.has_pkg { cmds.push(if tools.has_pnpm { "pnpm test" } else { "npm test" }); } if cmds.is_empty() { "Generate a `script/test` shell script (#!/usr/bin/env bash, set -euo pipefail) that runs the project's test suite.".to_string() } else { format!( "Generate a `script/test` shell script (#!/usr/bin/env bash, set -euo pipefail) that runs: {}", cmds.join(", ") ) } } } WizardStep::BuildScript => { if is_bare { "This is a bare project with no existing code. Read the STACK.md generated \ in the previous step (or ask the user about their stack if it was skipped) \ and generate a `script/build` shell script (#!/usr/bin/env bash, set -euo pipefail) \ with appropriate build commands for their chosen language and framework." .to_string() } else { let mut cmds = Vec::new(); if tools.has_cargo { cmds.push("cargo build --release"); } if tools.has_pkg { cmds.push(if tools.has_pnpm { "pnpm run build" } else { "npm run build" }); } if tools.has_frontend_subdir { cmds.push("(cd frontend && npm run build)"); } if tools.has_go { cmds.push("go build ./..."); } if cmds.is_empty() { "Generate a `script/build` shell script (#!/usr/bin/env bash, set -euo pipefail) that builds the project.".to_string() } else { format!( "Generate a `script/build` shell script (#!/usr/bin/env bash, set -euo pipefail) that runs: {}", cmds.join(", ") ) } } } WizardStep::LintScript => { if is_bare { "This is a bare project with no existing code. Read the STACK.md generated \ in the previous step (or ask the user about their stack if it was skipped) \ and generate a `script/lint` shell script (#!/usr/bin/env bash, set -euo pipefail) \ with appropriate lint commands for their chosen language and framework." .to_string() } else { let mut cmds = Vec::new(); if tools.has_cargo { cmds.push("cargo fmt --all --check"); cmds.push("cargo clippy -- -D warnings"); } if tools.has_pkg { cmds.push(if tools.has_pnpm { "pnpm run lint" } else { "npm run lint" }); } if tools.has_python { cmds.push("flake8 . (or ruff check . if ruff is configured)"); } if tools.has_go { cmds.push("go vet ./..."); } if cmds.is_empty() { "Generate a `script/lint` shell script (#!/usr/bin/env bash, set -euo pipefail) that runs the project's linters.".to_string() } else { format!( "Generate a `script/lint` shell script (#!/usr/bin/env bash, set -euo pipefail) that runs: {}", cmds.join(", ") ) } } } WizardStep::ReleaseScript => { if is_bare { "This is a bare project with no existing code. Read the STACK.md generated \ in the previous step (or ask the user about their stack if it was skipped) \ and generate a `script/release` shell script (#!/usr/bin/env bash, set -euo pipefail) \ with appropriate build/release commands for their chosen language and framework." .to_string() } else { "Generate a `script/release` shell script (#!/usr/bin/env bash, set -euo pipefail) that builds and releases the project (e.g. `cargo build --release` or `npm run build`).".to_string() } } WizardStep::TestCoverage => { if is_bare { "This is a bare project with no existing code. Read the STACK.md generated \ in the previous step (or ask the user about their stack if it was skipped) \ and generate a `script/test_coverage` shell script (#!/usr/bin/env bash, set -euo pipefail) \ with appropriate test coverage commands for their chosen language and framework." .to_string() } else { "Generate a `script/test_coverage` shell script (#!/usr/bin/env bash, set -euo pipefail) that generates a test coverage report (e.g. `cargo llvm-cov nextest` or `npm run coverage`).".to_string() } } WizardStep::Scaffold => { "Scaffold step is handled automatically by `huskies init`.".to_string() } } } // ── tests ───────────────────────────────────────────────────────────────────── #[cfg(test)] mod tests { use super::*; fn no_tools() -> ProjectTools { ProjectTools { has_cargo: false, has_pkg: false, has_pnpm: false, has_frontend_subdir: false, has_go: false, has_python: false, } } fn cargo_tools() -> ProjectTools { ProjectTools { has_cargo: true, ..no_tools() } } #[test] fn is_bare_project_empty_dir() { let names: Vec<&str> = vec![]; assert!(is_bare_project(&names)); } #[test] fn is_bare_project_scaffold_only() { let names = [".huskies", "CLAUDE.md", "README.md", "script", ".git"]; assert!(is_bare_project(&names)); } #[test] fn is_bare_project_false_with_cargo_toml() { let names = [".huskies", "Cargo.toml"]; assert!(!is_bare_project(&names)); } #[test] fn is_bare_project_false_with_src_dir() { let names = ["src"]; assert!(!is_bare_project(&names)); } #[test] fn is_script_step_includes_all_scripts() { assert!(is_script_step(WizardStep::TestScript)); assert!(is_script_step(WizardStep::BuildScript)); assert!(is_script_step(WizardStep::LintScript)); assert!(is_script_step(WizardStep::ReleaseScript)); assert!(is_script_step(WizardStep::TestCoverage)); } #[test] fn is_script_step_excludes_non_scripts() { assert!(!is_script_step(WizardStep::Scaffold)); assert!(!is_script_step(WizardStep::Context)); assert!(!is_script_step(WizardStep::Stack)); } #[test] fn step_slug_context() { assert_eq!(step_slug(WizardStep::Context), "context"); } #[test] fn step_slug_test_script() { assert_eq!(step_slug(WizardStep::TestScript), "test_script"); } #[test] fn step_slug_scaffold() { assert_eq!(step_slug(WizardStep::Scaffold), "scaffold"); } #[test] fn step_output_path_context() { let p = step_output_path(Path::new("/proj"), WizardStep::Context).unwrap(); assert!(p.ends_with(".huskies/specs/00_CONTEXT.md")); } #[test] fn step_output_path_build_script() { let p = step_output_path(Path::new("/proj"), WizardStep::BuildScript).unwrap(); assert!(p.ends_with("script/build")); } #[test] fn step_output_path_lint_script() { let p = step_output_path(Path::new("/proj"), WizardStep::LintScript).unwrap(); assert!(p.ends_with("script/lint")); } #[test] fn step_output_path_scaffold_is_none() { assert!(step_output_path(Path::new("/proj"), WizardStep::Scaffold).is_none()); } #[test] fn generation_hint_bare_context_asks_user() { let hint = generation_hint(WizardStep::Context, true, &no_tools()); assert!(hint.contains("bare project")); assert!(hint.contains("Ask the user")); } #[test] fn generation_hint_bare_stack_asks_user() { let hint = generation_hint(WizardStep::Stack, true, &no_tools()); assert!(hint.contains("bare project")); assert!(hint.contains("Ask the user")); } #[test] fn generation_hint_bare_test_script_references_stack() { let hint = generation_hint(WizardStep::TestScript, true, &no_tools()); assert!(hint.contains("bare project")); assert!(hint.contains("STACK.md")); } #[test] fn generation_hint_bare_build_script_references_stack() { let hint = generation_hint(WizardStep::BuildScript, true, &no_tools()); assert!(hint.contains("bare project")); assert!(hint.contains("STACK.md")); } #[test] fn generation_hint_bare_lint_script_references_stack() { let hint = generation_hint(WizardStep::LintScript, true, &no_tools()); assert!(hint.contains("bare project")); assert!(hint.contains("STACK.md")); } #[test] fn generation_hint_bare_release_script_references_stack() { let hint = generation_hint(WizardStep::ReleaseScript, true, &no_tools()); assert!(hint.contains("bare project")); assert!(hint.contains("STACK.md")); } #[test] fn generation_hint_bare_test_coverage_references_stack() { let hint = generation_hint(WizardStep::TestCoverage, true, &no_tools()); assert!(hint.contains("bare project")); assert!(hint.contains("STACK.md")); } #[test] fn generation_hint_existing_context_reads_code() { let hint = generation_hint(WizardStep::Context, false, &cargo_tools()); assert!(hint.contains("Read the project")); assert!(!hint.contains("bare project")); } #[test] fn generation_hint_existing_test_script_detects_cargo() { let hint = generation_hint(WizardStep::TestScript, false, &cargo_tools()); assert!(hint.contains("cargo nextest")); assert!(!hint.contains("bare project")); } #[test] fn generation_hint_existing_build_script_detects_cargo() { let hint = generation_hint(WizardStep::BuildScript, false, &cargo_tools()); assert!(hint.contains("cargo build --release")); assert!(!hint.contains("bare project")); } #[test] fn generation_hint_existing_lint_script_detects_cargo() { let hint = generation_hint(WizardStep::LintScript, false, &cargo_tools()); assert!(hint.contains("cargo fmt --all --check")); assert!(hint.contains("cargo clippy -- -D warnings")); assert!(!hint.contains("bare project")); } }