Skip to content
Open
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
29 changes: 27 additions & 2 deletions container/lib/wolts.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@

import json
import os
import re
from pathlib import Path

WOLTS_DIR = Path(os.environ.get("WOLTS_DIR", "/workspace/wolts"))
Expand All @@ -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)."""
Expand Down Expand Up @@ -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))}")

Expand Down Expand Up @@ -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}
111 changes: 111 additions & 0 deletions test/test_wolts.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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"
Expand Down