Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions src/specleft/commands/init.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,43 @@

from specleft.utils.messaging import print_support_footer

_PRD_TEMPLATE_CONTENT = """\
version: "1.0"

features:
heading_level: 2
patterns:
- "Feature: {title}"
- "Feature {title}"
contains: []
match_mode: "any" # any=pattern OR contains, all=pattern AND contains, patterns=pattern only, contains=contains only
exclude:
- "Overview"
- "Goals"
- "Non-Goals"
- "Open Questions"
- "Notes"

scenarios:
heading_level: [3, 4]
patterns:
- "Scenario: {title}"
contains: []
match_mode: "any" # any=pattern OR contains, all=pattern AND contains, patterns=pattern only, contains=contains only
step_keywords:
- "Given"
- "When"
- "Then"
- "And"
- "But"

priorities:
patterns:
- "priority: {value}"
- "Priority: {value}"
mapping: {}
"""


def _init_example_content() -> dict[str, str]:
"""Generate single-file example feature using canonical template."""
Expand Down Expand Up @@ -65,13 +102,15 @@ def _init_plan(example: bool) -> tuple[list[Path], list[tuple[Path, str]]]:
Path("tests"),
Path(".specleft"),
Path(".specleft/policies"),
Path(".specleft/templates"),
]
files: list[tuple[Path, str]] = []
if example:
for rel_path, content in _init_example_content().items():
files.append((Path(rel_path), content))
files.append((Path(".specleft/.gitkeep"), ""))
files.append((Path(".specleft/policies/.gitkeep"), ""))
files.append((Path(".specleft/templates/prd-template.yml"), _PRD_TEMPLATE_CONTENT))
return directories, files


Expand Down
15 changes: 14 additions & 1 deletion src/specleft/commands/plan.py
Original file line number Diff line number Diff line change
Expand Up @@ -400,7 +400,7 @@ def _render_scenarios(scenarios: list[dict[str, object]]) -> str:
blocks: list[str] = []
for scenario in scenarios:
title = str(scenario.get("title", "Scenario"))
priority = scenario.get("priority")
priority = scenario.get("priority", "medium")
steps_value = scenario.get("steps")
steps: list[str] = []
if isinstance(steps_value, list):
Expand Down Expand Up @@ -606,12 +606,25 @@ def plan(
prd_file = Path(prd_path)
template = default_template()
template_info: dict[str, str] | None = None

default_template_path = Path(".specleft/templates/prd-template.yml")
if template_path is not None:
template = load_template(template_path)
template_info = {
"path": str(template_path),
"version": template.version,
}
elif default_template_path.exists():
template = load_template(default_template_path)
template_path = default_template_path
template_info = {
"path": str(default_template_path),
"version": template.version,
}

if template_path is not None and format_type != "json":
click.echo(f"Using template: {template_path}")
click.echo("")
prd_content, warnings = _read_prd(prd_file)
if prd_content is None:
if format_type == "json":
Expand Down
6 changes: 5 additions & 1 deletion tests/commands/test_init.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ def test_init_creates_single_file_example(self) -> None:
result = runner.invoke(cli, ["init"], input="1\n")
assert result.exit_code == 0
assert Path(".specleft/specs/example-feature.md").exists()
assert Path(".specleft/templates/prd-template.yml").exists()

def test_init_json_dry_run(self) -> None:
runner = CliRunner()
Expand All @@ -26,8 +27,9 @@ def test_init_json_dry_run(self) -> None:
assert result.exit_code == 0
payload = json.loads(result.output)
assert payload["dry_run"] is True
assert payload["summary"]["directories"] == 4
assert payload["summary"]["directories"] == 5
assert ".specleft/specs/example-feature.md" in payload["would_create"]
assert ".specleft/templates/prd-template.yml" in payload["would_create"]

def test_init_json_requires_dry_run(self) -> None:
runner = CliRunner()
Expand All @@ -46,6 +48,8 @@ def test_init_blank_creates_directories(self) -> None:
assert Path("tests").exists()
assert Path(".specleft").exists()
assert Path(".specleft/policies").exists()
assert Path(".specleft/templates").exists()
assert Path(".specleft/templates/prd-template.yml").exists()
assert "Creating SpecLeft directory structure" in result.output

def test_init_example_and_blank_conflict(self) -> None:
Expand Down
94 changes: 94 additions & 0 deletions tests/commands/test_plan.py
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,36 @@ def test_extract_prd_scenarios_with_contains_only(self) -> None:
]
}

def test_render_scenarios_defaults_priority_to_medium(self) -> None:
plan_module: ModuleType = importlib.import_module("specleft.commands.plan")
scenarios: list[dict[str, object]] = [
{
"title": "No explicit priority",
"steps": ["Given something", "When action", "Then result"],
},
]
rendered = plan_module._render_scenarios(scenarios)
assert "priority: medium" in rendered

def test_plan_scenario_without_priority_gets_medium(self) -> None:
runner = CliRunner()
with runner.isolated_filesystem():
Path("prd.md").write_text(
"# PRD\n\n## Feature: Billing\n\n"
"### Scenario: Refund requested\n"
"- Given a customer\n"
"- When they request a refund\n"
"- Then we mark it pending\n"
)
result = runner.invoke(cli, ["plan"])

assert result.exit_code == 0
feature_file = Path(".specleft/specs/feature-billing.md")
assert feature_file.exists()
content = feature_file.read_text()
assert "### Scenario: Refund requested" in content
assert "priority: medium" in content


class TestPlanAnalyzeMode:
def test_analyze_flag_is_recognized(self) -> None:
Expand Down Expand Up @@ -370,3 +400,67 @@ def test_plan_template_json_includes_metadata(self) -> None:
payload = json.loads(result.output)
assert payload["template"]["path"] == "template.yml"
assert payload["template"]["version"] == "1.0"


class TestPlanTemplateAutoDetect:
def _write_default_template(self) -> None:
template_dir = Path(".specleft/templates")
template_dir.mkdir(parents=True, exist_ok=True)
(template_dir / "prd-template.yml").write_text(
'version: "1.0"\n'
"features:\n"
" heading_level: 2\n"
" patterns:\n"
' - "Feature: {title}"\n'
)

def test_auto_detects_template_when_present(self) -> None:
runner = CliRunner()
with runner.isolated_filesystem():
Path("prd.md").write_text("# PRD\n\n## Feature: Billing\n")
self._write_default_template()
result = runner.invoke(cli, ["plan"])

assert result.exit_code == 0
assert (
"Using template: .specleft/templates/prd-template.yml" in result.output
)

def test_auto_detect_template_json_includes_metadata(self) -> None:
runner = CliRunner()
with runner.isolated_filesystem():
Path("prd.md").write_text("# PRD\n\n## Feature: Billing\n")
self._write_default_template()
result = runner.invoke(cli, ["plan", "--format", "json"])

assert result.exit_code == 0
payload = json.loads(result.output)
assert payload["template"]["path"] == ".specleft/templates/prd-template.yml"
assert payload["template"]["version"] == "1.0"

def test_no_template_message_when_file_absent(self) -> None:
runner = CliRunner()
with runner.isolated_filesystem():
Path("prd.md").write_text("# PRD\n\n## Feature: Billing\n")
result = runner.invoke(cli, ["plan"])

assert result.exit_code == 0
assert "Using template:" not in result.output

def test_explicit_template_overrides_auto_detect(self) -> None:
runner = CliRunner()
with runner.isolated_filesystem():
Path("prd.md").write_text("# PRD\n\n## Epic: Billing\n")
self._write_default_template()
Path("custom.yml").write_text(
'version: "2.0"\n'
"features:\n"
" heading_level: 2\n"
" patterns:\n"
' - "Epic: {title}"\n'
)
result = runner.invoke(cli, ["plan", "--template", "custom.yml"])

assert result.exit_code == 0
assert "Using template: custom.yml" in result.output
assert Path(".specleft/specs/epic-billing.md").exists()
Loading