huskies: merge 610_story_extract_wizard_service
This commit is contained in:
@@ -0,0 +1,435 @@
|
||||
//! 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<str>]) -> 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<PathBuf> {
|
||||
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"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user