import shutil import subprocess import zipfile import pytest from haiku.rag.skill_generator import ( AVAILABLE_TOOLS, generate_skill, render_templates, validate_db_path, validate_metadata, validate_output_dir, validate_tools, ) class TestAvailableTools: def test_all_tools_present(self): assert AVAILABLE_TOOLS == { "list_documents", "get_document", "search", "ask", "research", "analyze", } class TestValidateMetadata: def test_valid(self): validate_metadata("my-recipes", "A skill.") def test_rejects_invalid_name(self): with pytest.raises(ValueError): validate_metadata("Bad_Name!", "A skill.") class TestValidateTools: def test_valid_single_tool(self): validate_tools(["search"]) def test_valid_multiple_tools(self): validate_tools(["list_documents", "get_document", "search", "ask"]) def test_valid_all_tools(self): validate_tools(list(AVAILABLE_TOOLS)) def test_rejects_empty(self): with pytest.raises(ValueError, match="at least one"): validate_tools([]) def test_rejects_unknown_tool(self): with pytest.raises(ValueError, match="Unknown"): validate_tools(["search", "bogus"]) class TestValidateDbPath: def test_valid_path(self, tmp_path): db_path = tmp_path / "test.lancedb" db_path.mkdir() validate_db_path(db_path) def test_rejects_nonexistent(self, tmp_path): db_path = tmp_path / "nonexistent.lancedb" with pytest.raises(ValueError, match="does not exist"): validate_db_path(db_path) def test_rejects_file(self, tmp_path): db_path = tmp_path / "test.lancedb" db_path.touch() with pytest.raises(ValueError, match="not a directory"): validate_db_path(db_path) class TestValidateOutputDir: def test_valid_output_dir(self, tmp_path): validate_output_dir(tmp_path, "recipes") def test_rejects_nonexistent(self, tmp_path): output_dir = tmp_path / "nonexistent" with pytest.raises(ValueError, match="does not exist"): validate_output_dir(output_dir, "recipes") def test_rejects_existing_target(self, tmp_path): target = tmp_path / "recipes-skill" target.mkdir() with pytest.raises(ValueError, match="already exists"): validate_output_dir(tmp_path, "recipes") class TestRenderTemplates: def test_output_structure(self, tmp_path): result = render_templates( output_dir=tmp_path, name="recipes", description="A recipe skill.", tool_names=["list_documents", "get_document", "search", "ask"], ) assert result == tmp_path / "recipes-skill" assert result.is_dir() assert (result / "README.md").is_file() pkg = result / "recipes_skill" assert (pkg / "__init__.py").is_file() assert (pkg / "SKILL.md").is_file() assert (pkg / "assets").is_dir() def test_dashed_name_uses_underscores_for_python(self, tmp_path): result = render_templates( output_dir=tmp_path, name="my-recipes", description="A recipe skill.", tool_names=["search", "ask"], ) assert result == tmp_path / "my-recipes-skill" pkg = result / "my_recipes_skill" assert (pkg / "__init__.py").is_file() init = (pkg / "__init__.py").read_text() assert '"my-recipes.lancedb"' in init assert 'state_namespace="my-recipes"' in init toml = (result / "pyproject.toml").read_text() assert 'name = "my-recipes-skill"' in toml assert 'my-recipes = "my_recipes_skill:create_skill"' in toml def test_tool_names_list_matches_selection(self, tmp_path): render_templates( output_dir=tmp_path, name="docs", description="A docs skill.", tool_names=["search", "ask"], ) init = tmp_path / "docs-skill" / "docs_skill" / "__init__.py" content = init.read_text() assert '["search", "ask"]' in content def test_create_skill_tools_called(self, tmp_path): render_templates( output_dir=tmp_path, name="docs", description="A docs skill.", tool_names=list(AVAILABLE_TOOLS), ) init = tmp_path / "docs-skill" / "docs_skill" / "__init__.py" content = init.read_text() assert "create_skill_tools(db_path, config, SkillState, _TOOL_NAMES)" in content def test_tool_names_in_init(self, tmp_path): render_templates( output_dir=tmp_path, name="recipes", description="A recipe skill.", tool_names=["search", "ask"], ) init = tmp_path / "recipes-skill" / "recipes_skill" / "__init__.py" content = init.read_text() assert '"search"' in content assert '"ask"' in content def test_pyproject_toml(self, tmp_path): render_templates( output_dir=tmp_path, name="recipes", description="A recipe skill.", tool_names=["search"], ) toml = tmp_path / "recipes-skill" / "pyproject.toml" content = toml.read_text() assert 'name = "recipes-skill"' in content assert 'description = "A recipe skill."' in content assert 'readme = "README.md"' in content assert 'recipes = "recipes_skill:create_skill"' in content assert "haiku.rag-slim >= " in content assert "[tool.setuptools.package-data]" in content assert 'recipes_skill = ["SKILL.md", "assets/**/*"]' in content def test_skill_md_conditionals(self, tmp_path): render_templates( output_dir=tmp_path, name="docs", description="A docs skill.", tool_names=["search"], ) skill_md = tmp_path / "docs-skill" / "docs_skill" / "SKILL.md" content = skill_md.read_text() assert "**search**" in content assert "**ask**" not in content assert "**list_documents**" not in content assert "**research**" not in content assert "**analyze**" not in content def test_skill_md_includes_all_selected(self, tmp_path): render_templates( output_dir=tmp_path, name="docs", description="A docs skill.", tool_names=["search", "ask", "analyze"], ) skill_md = tmp_path / "docs-skill" / "docs_skill" / "SKILL.md" content = skill_md.read_text() assert "**search**" in content assert "**ask**" in content assert "**analyze**" in content def test_custom_preamble(self, tmp_path): render_templates( output_dir=tmp_path, name="docs", description="A docs skill.", tool_names=["search"], preamble="You are a docs expert.", ) skill_md = tmp_path / "docs-skill" / "docs_skill" / "SKILL.md" content = skill_md.read_text() assert "You are a docs expert." in content def test_state_namespace_is_skill_name(self, tmp_path): render_templates( output_dir=tmp_path, name="recipes", description="A recipe skill.", tool_names=["search"], ) init = tmp_path / "recipes-skill" / "recipes_skill" / "__init__.py" content = init.read_text() assert 'state_namespace="recipes"' in content def test_analyze_state_fields(self, tmp_path): render_templates( output_dir=tmp_path, name="docs", description="A docs skill.", tool_names=["search", "analyze"], ) init = tmp_path / "docs-skill" / "docs_skill" / "__init__.py" content = init.read_text() assert "analyses" in content def test_imports_from_shared_tools(self, tmp_path): render_templates( output_dir=tmp_path, name="recipes", description="A recipe skill.", tool_names=["search", "ask", "analyze"], ) init = tmp_path / "recipes-skill" / "recipes_skill" / "__init__.py" content = init.read_text() assert ( "from haiku.rag.skills._tools import create_skill_extras, create_skill_tools" in content ) def test_readme(self, tmp_path): render_templates( output_dir=tmp_path, name="recipes", description="A recipe skill.", tool_names=["search"], ) readme = tmp_path / "recipes-skill" / "README.md" content = readme.read_text() assert "recipes" in content assert "haiku-rag" in content def test_extras_in_create_skill(self, tmp_path): render_templates( output_dir=tmp_path, name="docs", description="A docs skill.", tool_names=["search"], ) init = tmp_path / "docs-skill" / "docs_skill" / "__init__.py" content = init.read_text() assert "create_skill_extras" in content assert "extras=extras" in content def test_domain_preamble_applied_to_instructions(self, tmp_path): render_templates( output_dir=tmp_path, name="docs", description="A docs skill.", tool_names=["search"], ) init = tmp_path / "docs-skill" / "docs_skill" / "__init__.py" content = init.read_text() assert "config.prompts.domain_preamble" in content assert ( 'instructions = f"{config.prompts.domain_preamble}\\n\\n{instructions}"' in content ) def test_create_skill_accepts_optional_params(self, tmp_path): render_templates( output_dir=tmp_path, name="docs", description="A docs skill.", tool_names=["search"], ) init = tmp_path / "docs-skill" / "docs_skill" / "__init__.py" content = init.read_text() assert "db_path: Path | None = None" in content assert "config: AppConfig | None = None" in content def test_generated_python_is_valid(self, tmp_path): render_templates( output_dir=tmp_path, name="recipes", description="A recipe skill.", tool_names=list(AVAILABLE_TOOLS), ) pkg = tmp_path / "recipes-skill" / "recipes_skill" for py_file in pkg.glob("*.py"): source = py_file.read_text() compile(source, str(py_file), "exec") def _make_fake_lancedb(path): path.mkdir() (path / "data.lance").touch() return path class TestGenerateSkill: def test_end_to_end(self, tmp_path): db_path = _make_fake_lancedb(tmp_path / "test.lancedb") result = generate_skill( db_path=db_path, output_dir=tmp_path, name="recipes", description="A recipe skill.", tool_names=["search", "ask"], ) assert result == tmp_path / "recipes-skill" assets = result / "recipes_skill" / "assets" assert (assets / "recipes.lancedb").is_dir() assert (assets / "recipes.lancedb" / "data.lance").is_file() assert not (assets / "haiku.rag.yaml").exists() def test_with_config(self, tmp_path): db_path = _make_fake_lancedb(tmp_path / "test.lancedb") config_file = tmp_path / "haiku.rag.yaml" config_file.write_text("storage:\n data_dir: /tmp\n") result = generate_skill( db_path=db_path, output_dir=tmp_path, name="recipes", description="A recipe skill.", tool_names=["search"], config_path=config_file, ) assets = result / "recipes_skill" / "assets" assert (assets / "haiku.rag.yaml").is_file() assert (assets / "haiku.rag.yaml").read_text() == ( "storage:\n data_dir: /tmp\n" ) def test_rejects_invalid_name(self, tmp_path): db_path = _make_fake_lancedb(tmp_path / "test.lancedb") with pytest.raises(ValueError): generate_skill( db_path=db_path, output_dir=tmp_path, name="Bad_Name!", description="A skill.", tool_names=["search"], ) def test_rejects_invalid_tools(self, tmp_path): db_path = _make_fake_lancedb(tmp_path / "test.lancedb") with pytest.raises(ValueError, match="Unknown"): generate_skill( db_path=db_path, output_dir=tmp_path, name="recipes", description="A skill.", tool_names=["bogus"], ) def test_rejects_nonexistent_db(self, tmp_path): with pytest.raises(ValueError, match="does not exist"): generate_skill( db_path=tmp_path / "nope.lancedb", output_dir=tmp_path, name="recipes", description="A skill.", tool_names=["search"], ) def test_rejects_existing_target(self, tmp_path): db_path = _make_fake_lancedb(tmp_path / "test.lancedb") (tmp_path / "recipes-skill").mkdir() with pytest.raises(ValueError, match="already exists"): generate_skill( db_path=db_path, output_dir=tmp_path, name="recipes", description="A skill.", tool_names=["search"], ) def test_with_preamble(self, tmp_path): db_path = _make_fake_lancedb(tmp_path / "test.lancedb") result = generate_skill( db_path=db_path, output_dir=tmp_path, name="recipes", description="A recipe skill.", tool_names=["search"], preamble="You are a recipe expert.", ) skill_md = result / "recipes_skill" / "SKILL.md" content = skill_md.read_text() assert "You are a recipe expert." in content def test_wheel_includes_package_data(self, tmp_path): if not shutil.which("uv"): pytest.skip("uv not available") db_path = _make_fake_lancedb(tmp_path / "test.lancedb") config_file = tmp_path / "haiku.rag.yaml" config_file.write_text("storage:\n data_dir: /tmp\n") result = generate_skill( db_path=db_path, output_dir=tmp_path, name="recipes", description="A recipe skill.", tool_names=["search"], config_path=config_file, ) dist_dir = result / "dist" subprocess.check_call( [ "uv", "run", "--with", "build", "--no-project", "python", "-m", "build", "--wheel", "--outdir", str(dist_dir), str(result), ] ) wheels = list(dist_dir.glob("*.whl")) assert len(wheels) == 1 with zipfile.ZipFile(wheels[0]) as zf: names = zf.namelist() assert any(n.endswith("SKILL.md") for n in names) assert any("assets/" in n and n.endswith("data.lance") for n in names) assert any(n.endswith("haiku.rag.yaml") for n in names)