diff --git a/container/lib/wolts.py b/container/lib/wolts.py index b1969fa..9106bd9 100644 --- a/container/lib/wolts.py +++ b/container/lib/wolts.py @@ -13,6 +13,7 @@ import json import os +import re from pathlib import Path WOLTS_DIR = Path(os.environ.get("WOLTS_DIR", "/workspace/wolts")) @@ -25,6 +26,23 @@ # Types that can only have one active at a time SINGLETON_TYPES = {"wolf", "dog"} +# Name validation — lowercase letters, numbers, hyphens. Must start with a letter. +WOLT_NAME_PATTERN = re.compile(r'^[a-z][a-z0-9-]*$') +WOLT_NAME_MAX_LENGTH = 20 + + +def slugify_wolt_name(name: str) -> str: + """Sanitize a wolt name into a valid slug. + + 'Wolter White' → 'wolter-white', ' My Wolt! ' → 'my-wolt', '123bad' → 'bad' + Returns empty string if nothing salvageable. + """ + s = name.strip().lower() + s = re.sub(r'[^a-z0-9]+', '-', s) # replace non-alphanumeric runs with single hyphen + s = s.strip('-') # trim leading/trailing hyphens + s = re.sub(r'^[0-9-]+', '', s) # strip leading numbers/hyphens + return s[:WOLT_NAME_MAX_LENGTH] + def is_rodent(creature_type: str) -> bool: """Check if a creature type is a rodent (chatty, runs Claude Code sessions).""" @@ -77,11 +95,18 @@ def set_active_creature(creature_type: str, wolt_name: str) -> None: def create_creature_wolt(name: str, creature_type: str, role: str = "", description: str = "") -> dict: """Create a minimal creature-wolt directory. + The name is auto-slugified: 'Wolter White' → 'wolter-white'. + Returns a dict with: - "dir": Path to the new wolt directory + - "name": the sanitized name (may differ from input) - "demoted": name of the old wolt that was demoted to rodent, or None - Raises ValueError if the type is invalid or the name already exists. + Raises ValueError if the name is unsalvageable, type is invalid, or the name already exists. """ + name = slugify_wolt_name(name) + if not name: + raise ValueError(f"Invalid wolt name: '{name}'. Must contain at least one letter.") + if creature_type not in VALID_TYPES: raise ValueError(f"Invalid creature type: {creature_type}. Must be one of: {', '.join(sorted(VALID_TYPES))}") @@ -136,4 +161,4 @@ def create_creature_wolt(name: str, creature_type: str, role: str = "", descript if creature_type in SINGLETON_TYPES: set_active_creature(creature_type, name) - return {"dir": wolt_dir, "demoted": demoted} + return {"dir": wolt_dir, "name": name, "demoted": demoted} diff --git a/test/test_wolts.py b/test/test_wolts.py index 9f0849a..f90fd86 100644 --- a/test/test_wolts.py +++ b/test/test_wolts.py @@ -167,6 +167,50 @@ def test_set_preserves_existing(self, tmp_path): assert config["telegram"]["active_wolt"] == "nw" +# --------------------------------------------------------------------------- +# Name slugification +# --------------------------------------------------------------------------- + +class TestSlugifyWoltName: + """Unit: slugify_wolt_name sanitizes names into valid slugs.""" + + def test_spaces_to_hyphens(self): + from wolts import slugify_wolt_name + assert slugify_wolt_name("wolter white") == "wolter-white" + + def test_lowercase(self): + from wolts import slugify_wolt_name + assert slugify_wolt_name("MyWolt") == "mywolt" + + def test_strips_leading_numbers(self): + from wolts import slugify_wolt_name + assert slugify_wolt_name("123wolt") == "wolt" + + def test_strips_special_chars(self): + from wolts import slugify_wolt_name + assert slugify_wolt_name("my!wolt@here") == "my-wolt-here" + + def test_empty_returns_empty(self): + from wolts import slugify_wolt_name + assert slugify_wolt_name("") == "" + + def test_all_special_returns_empty(self): + from wolts import slugify_wolt_name + assert slugify_wolt_name("!!!###") == "" + + def test_truncates_long_names(self): + from wolts import slugify_wolt_name + assert len(slugify_wolt_name("a" * 30)) == 20 + + def test_trims_whitespace(self): + from wolts import slugify_wolt_name + assert slugify_wolt_name(" chip ") == "chip" + + def test_collapses_multiple_separators(self): + from wolts import slugify_wolt_name + assert slugify_wolt_name("my wolt") == "my-wolt" + + # --------------------------------------------------------------------------- # Creature-wolt creation # --------------------------------------------------------------------------- @@ -259,6 +303,73 @@ def test_raises_on_invalid_type(self, tmp_path): with pytest.raises(ValueError, match="Invalid creature type"): create_creature_wolt("test", "dragon") + def test_slugifies_name_with_spaces(self, tmp_path): + from wolts import create_creature_wolt + config_file = tmp_path / "woltspace.json" + config_file.write_text(json.dumps({})) + + with patch("wolts.WOLTS_DIR", tmp_path), patch("wolts.CONFIG_FILE", config_file): + result = create_creature_wolt("wolter white", "beaver") + assert result["name"] == "wolter-white" + assert (tmp_path / "wolter-white" / "wolt" / "wolt.json").exists() + + def test_slugifies_uppercase(self, tmp_path): + from wolts import create_creature_wolt + config_file = tmp_path / "woltspace.json" + config_file.write_text(json.dumps({})) + + with patch("wolts.WOLTS_DIR", tmp_path), patch("wolts.CONFIG_FILE", config_file): + result = create_creature_wolt("MyWolt", "beaver") + assert result["name"] == "mywolt" + assert (tmp_path / "mywolt" / "wolt" / "wolt.json").exists() + + def test_slugifies_leading_numbers(self, tmp_path): + from wolts import create_creature_wolt + config_file = tmp_path / "woltspace.json" + config_file.write_text(json.dumps({})) + + with patch("wolts.WOLTS_DIR", tmp_path), patch("wolts.CONFIG_FILE", config_file): + result = create_creature_wolt("123wolt", "beaver") + assert result["name"] == "wolt" + assert (tmp_path / "wolt" / "wolt" / "wolt.json").exists() + + def test_raises_on_empty_name(self, tmp_path): + from wolts import create_creature_wolt + config_file = tmp_path / "woltspace.json" + config_file.write_text(json.dumps({})) + + with patch("wolts.WOLTS_DIR", tmp_path), patch("wolts.CONFIG_FILE", config_file): + with pytest.raises(ValueError, match="Invalid wolt name"): + create_creature_wolt("", "beaver") + + def test_raises_on_unsalvageable_name(self, tmp_path): + from wolts import create_creature_wolt + config_file = tmp_path / "woltspace.json" + config_file.write_text(json.dumps({})) + + with patch("wolts.WOLTS_DIR", tmp_path), patch("wolts.CONFIG_FILE", config_file): + with pytest.raises(ValueError, match="Invalid wolt name"): + create_creature_wolt("!!!###", "beaver") + + def test_slugifies_long_name(self, tmp_path): + from wolts import create_creature_wolt + config_file = tmp_path / "woltspace.json" + config_file.write_text(json.dumps({})) + + with patch("wolts.WOLTS_DIR", tmp_path), patch("wolts.CONFIG_FILE", config_file): + result = create_creature_wolt("a" * 30, "beaver") + assert len(result["name"]) == 20 + + def test_allows_valid_name_with_hyphens(self, tmp_path): + from wolts import create_creature_wolt + config_file = tmp_path / "woltspace.json" + config_file.write_text(json.dumps({})) + + with patch("wolts.WOLTS_DIR", tmp_path), patch("wolts.CONFIG_FILE", config_file): + result = create_creature_wolt("wolter-white", "beaver") + assert result["name"] == "wolter-white" + assert (tmp_path / "wolter-white" / "wolt" / "wolt.json").exists() + def test_rodent_no_singleton_tracking(self, tmp_path): from wolts import create_creature_wolt config_file = tmp_path / "woltspace.json"