agent-first-slug 0.7.1

Rust slug generation with explicit caller configuration for path and URL path segments.
Documentation

Agent-First Slug

Rust slug generation with explicit caller configuration for path and URL path segments.

Ask your agent: "Add agent-first-slug to my project and use it to slugify titles for URL path segments."

Start by choosing the target surface: a local filesystem path segment, a URL path segment, or a legacy slug format you need to preserve. The examples below show complete config values so the behavior is visible at the call site.

Install the Library

cargo add agent-first-slug --no-default-features

Install the CLI

# prebuilt binary
brew install agentfirstkit/tap/afslug   # macOS / Linux
scoop bucket add agentfirstkit https://github.com/agentfirstkit/scoop-bucket && scoop install afslug   # Windows

# or from crates.io
cargo install agent-first-slug

Prebuilt archives are also available from GitHub Releases.

CLI

afslug generates and validates slugs, emitting one AFDATA protocol event per run. JSON is the default; YAML and plain output are also available.

afslug slugify "Hello, 世界!"
# {"kind":"result","result":{"changed_from_input":true,"code":"slugify","slug":"hello-世界"},"trace":{}}

afslug slugify "Hello, World!" --output plain
# kind=result result.changed_from_input=true result.code=slugify result.slug=hello-world

afslug validate "my-slug" --policy url-path

slugify exposes the SlugConfig surface as flags — delimiter, case, truncation, character set, dot handling, validation, and an empty-slug fallback (afslug slugify --help lists them); validate checks an existing value as a local or URL path segment. Transliteration stays library-only: its static replacement map cannot be built from CLI arguments.

Agent Skill

Use skills/agent-first-slug/SKILL.md to teach a coding agent when to choose the Rust library or the default-only afslug CLI, and how to preserve stable identifier behavior.

Default Unicode Slugs

use agent_first_slug::{slugify, SlugConfig};

let config = SlugConfig::default();

assert_eq!(slugify("Hello", &config)?.slug, "hello");
assert_eq!(slugify("Hello World", &config)?.slug, "hello-world");
assert_eq!(slugify("Hello,  world!!!", &config)?.slug, "hello-world");
assert_eq!(slugify("-- already -- spaced --", &config)?.slug, "already-spaced");
assert_eq!(slugify("!!!", &config)?.slug, "");
# Ok::<(), agent_first_slug::SlugError>(())

Unicode letters and numbers are preserved by default:

use agent_first_slug::{slugify, SlugConfig};

let config = SlugConfig::default();

assert_eq!(
    slugify("現在的Nobody,未來的Somebody!", &config)?.slug,
    "現在的nobody-未來的somebody"
);
assert_eq!(
    slugify("牙好,胃口就好,身体倍儿棒,吃嘛嘛香。", &config)?.slug,
    "牙好-胃口就好-身体倍儿棒-吃嘛嘛香"
);
assert_eq!(slugify("お元気ですか?", &config)?.slug, "お元気ですか");
# Ok::<(), agent_first_slug::SlugError>(())

Local Filesystem Path Segment

Use this when the slug will be one segment in a local path. Slugify each segment separately; do not pass a full path through one slug call.

Requirements for this target:

  • Reject empty output unless the caller provides a fallback.
  • Reject /, \\, Unicode whitespace, and control characters.
  • Reject . and .. to avoid current-directory and parent-directory meanings.
  • Prefer replacing dots unless the caller explicitly wants dots in filenames.
  • Choose ASCII-only if the path must be portable across legacy filesystems or tools.
use agent_first_slug::{
    slugify, AllowedCharacterSet, DotHandlingPolicy, EmptyOutputPolicy, SlugConfig,
    SlugValidationPolicy, TransliterationPolicy,
};

let config = SlugConfig {
    replacement_delimiter: '-',
    lowercase_enabled: true,
    max_slug_chars: None,
    allowed_character_set: AllowedCharacterSet::UnicodeAlphanumericCharacters,
    dot_handling_policy: DotHandlingPolicy::ReplaceAllDots,
    transliteration_policy: TransliterationPolicy::None,
    validation_policy: SlugValidationPolicy::LocalPathSegment,
    empty_output_policy: EmptyOutputPolicy::UseFallbackSlug("fallback".to_string()),
};

assert_eq!(slugify("Ubuntu 16.04", &config)?.slug, "ubuntu-16-04");
assert_eq!(slugify("你好,世界", &config)?.slug, "你好-世界");
assert_eq!(slugify("!!!", &config)?.slug, "fallback");
# Ok::<(), agent_first_slug::SlugError>(())

ASCII-only path segment with fallback:

use agent_first_slug::{
    slugify, AllowedCharacterSet, DotHandlingPolicy, EmptyOutputPolicy, SlugConfig,
    SlugValidationPolicy, TransliterationPolicy,
};

let config = SlugConfig {
    replacement_delimiter: '-',
    lowercase_enabled: true,
    max_slug_chars: None,
    allowed_character_set: AllowedCharacterSet::AsciiAlphanumericCharacters,
    dot_handling_policy: DotHandlingPolicy::ReplaceAllDots,
    transliteration_policy: TransliterationPolicy::None,
    validation_policy: SlugValidationPolicy::LocalPathSegment,
    empty_output_policy: EmptyOutputPolicy::UseFallbackSlug("fallback".to_string()),
};

assert_eq!(slugify("Hello 世界", &config)?.slug, "hello");
assert_eq!(slugify("Ubuntu 16.04", &config)?.slug, "ubuntu-16-04");
assert_eq!(slugify("你好,世界", &config)?.slug, "fallback");
# Ok::<(), agent_first_slug::SlugError>(())

URL Path Segment

Use this when the slug will be one segment in a URL path. The returned slug is raw UTF-8; percent-encode it or use a URL library's path-segment API when building the final URL.

Requirements for this target:

  • Reject empty output unless the caller provides a fallback.
  • Reject /, ?, #, raw %, Unicode whitespace, controls, and \\.
  • Reject . and .. for route safety.
  • Preserve dots between decimal digits if version numbers matter.
  • Do not manually concatenate unescaped slugs into URLs.
use agent_first_slug::{
    slugify, AllowedCharacterSet, DotHandlingPolicy, EmptyOutputPolicy, SlugConfig,
    SlugValidationPolicy, TransliterationPolicy,
};

let config = SlugConfig {
    replacement_delimiter: '-',
    lowercase_enabled: true,
    max_slug_chars: None,
    allowed_character_set: AllowedCharacterSet::UnicodeLettersAndDecimalDigits,
    dot_handling_policy: DotHandlingPolicy::PreserveDotsBetweenDecimalDigits,
    transliteration_policy: TransliterationPolicy::None,
    validation_policy: SlugValidationPolicy::UrlPathSegment,
    empty_output_policy: EmptyOutputPolicy::KeepEmptySlug,
};

assert_eq!(slugify("Ubuntu 16.04", &config)?.slug, "ubuntu-16.04");
assert_eq!(slugify("T.U.S.F.G.E.3.0.8", &config)?.slug, "t-u-s-f-g-e-3.0.8");
assert_eq!(slugify(".18 increased ! ", &config)?.slug, "18-increased");
assert_eq!(slugify("お元気ですか?", &config)?.slug, "お元気ですか");
# Ok::<(), agent_first_slug::SlugError>(())

Dot Handling

use agent_first_slug::{slugify, DotHandlingPolicy, SlugConfig};

let replace_all_dots = SlugConfig {
    dot_handling_policy: DotHandlingPolicy::ReplaceAllDots,
    ..SlugConfig::default()
};
let preserve_all_dots = SlugConfig {
    dot_handling_policy: DotHandlingPolicy::PreserveAllDots,
    ..SlugConfig::default()
};
let preserve_version_dots = SlugConfig {
    dot_handling_policy: DotHandlingPolicy::PreserveDotsBetweenDecimalDigits,
    ..SlugConfig::default()
};

assert_eq!(slugify("Ubuntu 16.04", &replace_all_dots)?.slug, "ubuntu-16-04");
assert_eq!(slugify("A.B..C", &preserve_all_dots)?.slug, "a.b..c");
assert_eq!(slugify("T.U.S.F.G.E.3.0.8", &preserve_version_dots)?.slug, "t-u-s-f-g-e-3.0.8");
# Ok::<(), agent_first_slug::SlugError>(())

Transliteration

Transliteration is caller-provided, so legacy behavior can be expressed without a named preset in the library.

use agent_first_slug::{
    slugify, AllowedCharacterSet, SlugConfig, TransliterationPolicy,
};

static MAP: &[(&str, &str)] = &[("Æ", "AE"), ("東京", "Tokyo")];
let config = SlugConfig {
    allowed_character_set: AllowedCharacterSet::AsciiAlphanumericCharacters,
    transliteration_policy: TransliterationPolicy::StaticReplacementMap(MAP),
    ..SlugConfig::default()
};

assert_eq!(slugify("Æther 東京", &config)?.slug, "aether-tokyo");
# Ok::<(), agent_first_slug::SlugError>(())

Truncation And Empty Output

max_slug_chars counts Unicode scalar values; any trailing delimiter the cut exposes is then stripped. Empty handling runs after truncation.

A fallback is inserted as written rather than run through the pipeline, but UseFallbackSlug still requires it to satisfy the same configuration — character set, delimiter, dot policy, case and length. A fallback that does not is a configuration error, not a slug: without that check an ASCII-only, length-capped configuration could return an arbitrary Unicode string and report it as validated. UseVerbatimFallbackSlug waives it for a value that has to match something already stored, and checks only the target surface.

use agent_first_slug::{slugify, EmptyOutputPolicy, SlugConfig, SlugError};

let truncated = SlugConfig {
    max_slug_chars: Some(8),
    ..SlugConfig::default()
};
let fallback = SlugConfig {
    max_slug_chars: Some(8),
    empty_output_policy: EmptyOutputPolicy::UseFallbackSlug("untitled".to_string()),
    ..SlugConfig::default()
};
let legacy = SlugConfig {
    max_slug_chars: Some(8),
    empty_output_policy: EmptyOutputPolicy::UseVerbatimFallbackSlug(
        "Legacy Name".to_string(),
    ),
    ..SlugConfig::default()
};

assert_eq!(slugify("Long Example", &truncated)?.slug, "long-exa");
assert_eq!(slugify("!!!", &fallback)?.slug, "untitled");
assert_eq!(slugify("!!!", &legacy)?.slug, "Legacy Name");

// Too long for this configuration's own budget, so it is refused rather than
// returned as if it had been generated.
let over_budget = SlugConfig {
    empty_output_policy: EmptyOutputPolicy::UseFallbackSlug("far-too-long".to_string()),
    ..fallback
};
assert!(matches!(
    slugify("!!!", &over_budget),
    Err(SlugError::FallbackViolatesConfig { .. })
));
# Ok::<(), agent_first_slug::SlugError>(())

What Changed, And What To Check Before Upgrading

Two rules that affect generated slugs changed, because both let a configuration mean something other than what it said. If you have slugs already stored, run the old and new versions over your corpus and diff before upgrading.

Case mapping now runs before filtering. It ran after, and Unicode case mapping is not one scalar for one scalar: İ lowercases to i plus a combining dot, and that dot ended up in the slug even though no character set here would have kept it. İstanbul was i̇stanbul; it is now i-stanbul. Only inputs whose case mapping expands are affected — every scalar in a slug is now one the character set admits, so allowed_character_set describes the output again.

A replacement_delimiter the configuration would keep is refused. With a as the delimiter, alpha beta and lpha beta both produced lphabet: the run boundary was skipped because the output already ended in a, and the trim then ate real letters off real words. Configurations using -, _, ~ or any other character the filter removes are unaffected.

Nothing normalizes input, then or now — see the skill for what that means for a stable-identifier contract.

Validation Only

use agent_first_slug::{validate_slug, SlugError, SlugValidationPolicy};

assert_eq!(validate_slug("safe-name", SlugValidationPolicy::LocalPathSegment), Ok(()));
assert_eq!(
    validate_slug("a/b", SlugValidationPolicy::LocalPathSegment),
    Err(SlugError::PathSegmentSeparator { character: '/' })
);
assert_eq!(
    validate_slug("a?b", SlugValidationPolicy::UrlPathSegment),
    Err(SlugError::UrlPathSegmentReservedCharacter { character: '?' })
);

License

MIT