Sign in to edit tickets from this page.

← all tickets · home

Introduce Slug newtype; generalize framing

resolved d3c5fe99-9559-420f-ac08-6c6d047677b0

created_at
2026-04-24
updated_at
2026-04-24
code_context
src/slug.rs, src/worlds.rs, src/mcp.rs, docs/terms.md
priority
P2
ticket_type
chore
blocks
5a8af099
resolved_at
2026-04-24
resolution
accepted

Body

MOTIVATION

src/slug.rs already implements a validation function (validate), an error type (SlugError), and a character cap (MAX_SLUG_CHARS) that are world-agnostic in code. The framing in the module-level //! comment and in docs/terms.md is world-specific, but the actual primitives are generic.

A second slug-typed field is landing imminently: scenario_slug on a new filesystem-backed scenario format (follow-up ticket). Before that ticket lands, generalize the slug primitive so both callers share one validated type.

Today, worlds::create_world(slug: String, ...) validates internally via slug::validate(&slug). That's a by-convention contract — nothing at the type level prevents an unvalidated string from being passed into internal code paths that assume validation. A Slug newtype closes that gap: once you have a Slug, it's validated by construction, and functions that need a slug take Slug rather than String.

No behavior changes. Same grammar, same error type, same tests pass. This is a structural refactor in service of the follow-up ticket.

================================================================ THE CHANGE

Add to src/slug.rs:

#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct Slug(String);

impl Slug {
    /// Construct a `Slug` by validating the raw input against the slug
    /// grammar. Returns the same `SlugError` variants that `validate`
    /// produces — this is a convenience wrapper, not a new check.
    pub fn new(raw: impl Into<String>) -> Result<Self, SlugError> {
        let s = raw.into();
        validate(&s)?;
        Ok(Slug(s))
    }

    pub fn as_str(&self) -> &str {
        &self.0
    }

    pub fn into_string(self) -> String {
        self.0
    }
}

impl std::fmt::Display for Slug {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.write_str(&self.0)
    }
}

impl AsRef<str> for Slug {
    fn as_ref(&self) -> &str { &self.0 }
}

The free-standing validate() stays and remains the primitive. SlugError and MAX_SLUG_CHARS stay. Both Slug::new() and direct validate() calls are valid consumers — Slug::new() for "I want to hold onto this value," validate() for "I just want to check shape."

================================================================ CALLER MIGRATION

src/worlds.rs

create_world signature changes:

pub fn create_world(
    data_root: &Path,
    scenario: Scenario,
    slug: Slug,            // was: slug: String
    name: Option<String>,
) -> io::Result<WorldHandle>

Inside the function, REMOVE the existing slug::validate(&slug).map_err(...)?; call — it's redundant because the Slug type carries that guarantee by construction.

Internal uses of slug replace &slug / slug.as_str() / &slug depending on context:

The dir-exists check, meta.write, and Runtime::new all use &dir which is derived from slug earlier — no change there.

src/worlds.rs tests

Every test call site that passes a String or "literal".into() for the slug argument becomes Slug::new("literal").unwrap(). Six call sites total in the existing tests (ants-a, ants-b, Bad-Slug, twin x2, doomed, my-ants).

The Bad-Slug test stays behaviourally valid but shifts: instead of create_world returning an io::Error with ErrorKind::InvalidInput, Slug::new("Bad-Slug") now returns a SlugError::InvalidChar at the call site. Adapt the test to assert on Slug::new(...).unwrap_err() instead of passing the string through create_world. The test's intent ("uppercase is rejected") is preserved; the assertion moves up a layer.

src/mcp.rs::handle_create_world

Find the call site around line 1623 that does let scenario = Scenario::from_id(scenario_id).ok_or_else(...)?; and the preceding slug-handling block. Replace the existing slug validation+pass-through with:

let slug = Slug::new(raw_slug).map_err(|e| {
    McpError::new("BAD_SLUG", e.to_string())
})?;
let handle = worlds::create_world(&env.data_root, scenario, slug, name)?;

The BAD_SLUG error code stays exactly as it is today. The error message shape stays the same (delegates to SlugError::Display). External MCP behaviour is unchanged.

src/lib.rs

Export Slug alongside the existing exports:

pub use slug::{Slug, SlugError, MAX_SLUG_CHARS};

Or append Slug to whatever the current export line is without changing the existing items.

================================================================ DOCS

src/slug.rs module doc (//! at top)

Reframe from "World slug — grammar, validation" to "Slug — grammar, validation." Keep the grammar text, the single-char acceptance note, the no-normalization rationale. Remove the "A slug is a short human-typeable identifier for a world" framing; replace with something like:

A slug is a short human-typeable identifier. It is used as a routing key and filesystem name for worlds and scenarios. On the MCP wire, in URLs, in directory names, in registry keys — the string the caller typed is the string the system uses. No normalization, no UUIDs, specific grammar errors on violation.

Optionally add one line naming the current consumers: world directories / registry keys, scenario identifiers.

docs/terms.md

Reframe the slug section so it's not world-scoped. Same grammar, same rationale, just stop saying "world slug" when the text is really about slugs in general. Add a short note that scenarios use slugs too (handled in the follow-up ticket); OK to add that note speculatively here since the follow-up ticket will rely on the same grammar.

================================================================ TESTS

In src/slug.rs::tests, add:

#[test]
fn slug_newtype_round_trips_valid_input() {
    let slug = Slug::new("ant-smoke").unwrap();
    assert_eq!(slug.as_str(), "ant-smoke");
    assert_eq!(slug.to_string(), "ant-smoke");
    // Display, as_ref, AsRef<str> all point at the same string.
    let s_ref: &str = slug.as_ref();
    assert_eq!(s_ref, "ant-smoke");
}

#[test]
fn slug_newtype_rejects_invalid_input() {
    match Slug::new("Bad-Slug").unwrap_err() {
        SlugError::InvalidChar { ch: 'B', index: 0 } => {}
        other => panic!("expected InvalidChar 'B' at 0, got {:?}", other),
    }
}

#[test]
fn slug_newtype_into_string_roundtrips() {
    let slug = Slug::new("ant-smoke").unwrap();
    let raw: String = slug.into_string();
    assert_eq!(raw, "ant-smoke");
}

Existing validate()-based tests stay UNCHANGED. No test is removed.

================================================================ ACCEPTANCE

================================================================ EXPLICITLY OUT OF SCOPE

================================================================ NO OPEN KNOBS

Spec is prescriptive. Handler should not need to make judgment calls. If a question arises during implementation, leave a comment on the ticket rather than guessing.

Proposed resolution

Implemented per spec.

Source:

Scope discipline:

Verification:

Production smoke (https://chukwa.benac.dev on chukwa-6bb9864c4-mmgbv):

Per standing guidance I am not confirming — only proposing.

History (4 events)

Sign in as a human to drive this ticket from the page, or use the MCP tools.