Skip to main content

agentmux_common/
slug.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Name → slug / id / path-component rules, one named function per rule.
5//!
6//! These rules differ ON PURPOSE (a display slug and a path component are
7//! different things), so they are not merged. They used to be written inline
8//! at each call site, which is how the same rule was derived two different
9//! ways (PR #2901: a work dir computed two ways broke Personal Bundle) and how
10//! a key purge that must reproduce every naming rule ever used missed some
11//! (#3633). Here each rule has a name, one body, and a table test that runs
12//! every rule over the same inputs so the differences are stated, not
13//! discovered (docs/specs/SPEC_LARGE_FILE_MODULE_ANALYSIS_2026_09_30.md §5.1 #2).
14//!
15//! Changing a rule's output moves existing agents' directories, registry files
16//! or signing-key names. Don't.
17
18/// The longest agent id the registry accepts.
19pub const AGENT_ID_MAX_LEN: usize = 64;
20
21/// A valid agent id: 1 to 64 ASCII alphanumerics, `-` or `_`. What the
22/// registry, MuxBus and a message's target accept.
23pub fn is_agent_id(id: &str) -> bool {
24    !id.is_empty() && id.len() <= AGENT_ID_MAX_LEN && id.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')
25}
26
27/// ASCII alphanumerics, `-` and `_` kept, everything else `-`; runs of dashes
28/// collapsed and trimmed; at most 64 chars; `"agent"` if nothing is left.
29fn dashed(name: &str) -> String {
30    let filtered: String = name
31        .chars()
32        .map(|c| {
33            if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
34                c
35            } else {
36                '-'
37            }
38        })
39        .collect();
40    let collapsed: String = filtered
41        .split('-')
42        .filter(|s| !s.is_empty())
43        .collect::<Vec<_>>()
44        .join("-");
45    let trimmed: String = collapsed.chars().take(AGENT_ID_MAX_LEN).collect();
46    if trimmed.is_empty() {
47        "agent".to_string()
48    } else {
49        trimmed
50    }
51}
52
53/// A definition's slug: lowercase; ASCII alphanumerics, `-` and `_` kept,
54/// everything else `-`; runs of dashes collapsed and trimmed; at most 64
55/// chars; `"agent"` if nothing is left.
56pub fn definition_slug(name: &str) -> String {
57    dashed(&name.to_lowercase())
58}
59
60/// A fork's slug: its lineage root's slug and its own label, never its
61/// source's slug, so forking a fork doesn't chain every generation
62/// (`loap-loap-2-loap-3`, REPORT_FORK_AGENT_ISOLATION_2026_10_12.md F4). A
63/// label that already starts with the root's name isn't prefixed twice
64/// (`Loap #3` → `loap-3`), whether it matches the root's slug or its display
65/// name's: a root named `Claude` may hold `claude-2` (a template owns
66/// `claude`), and its `Claude #3` is still `claude-3`. Capped like any
67/// definition slug.
68pub fn fork_slug(root_slug: &str, root_name: &str, branch_label: &str) -> String {
69    let label = definition_slug(branch_label);
70    let named = definition_slug(root_name);
71    let prefixed = |root: &str| label == root || label.starts_with(&format!("{root}-"));
72    if prefixed(root_slug) || prefixed(&named) {
73        label
74    } else {
75        definition_slug(&format!("{root_slug}-{label}"))
76    }
77}
78
79/// The name an agent is registered and addressed by: `AGENTMUX_AGENT_ID`, the
80/// registry's display binding, a message's target. A valid agent id stays as
81/// it is, natural casing included (`Agent2`, which PR titles and git authors
82/// carry); anything else gets `definition_slug`'s rule without lowercasing, so
83/// a fork's automatic name `Loap #3` routes as `Loap-3` instead of being
84/// refused (REPORT_FORK_AGENT_ISOLATION_2026_10_12.md F3). A name with an
85/// `@` is an address (`agent2@narko`), not a name: it is left as it is, so it
86/// fails as an id instead of resolving to a local agent called `agent2-narko`.
87pub fn agent_route_name(name: &str) -> String {
88    if is_agent_id(name) || name.contains('@') {
89        name.to_string()
90    } else {
91        dashed(name)
92    }
93}
94
95/// A path component, and `agent.open`'s id for a definition with no slug:
96/// lowercase; Unicode alphanumerics, `-` and `_` kept, everything else `-`;
97/// no collapsing, no cap. `Zed Bot` → `zed-bot`, `Ä` → `ä`.
98pub fn path_slug(name: &str) -> String {
99    name.to_lowercase()
100        .chars()
101        .map(|c| {
102            if c.is_alphanumeric() || c == '-' || c == '_' {
103                c
104            } else {
105                '-'
106            }
107        })
108        .collect()
109}
110
111/// A registry file stem: `path_slug`'s rule, but everything else becomes `_`,
112/// so it can never introduce a path separator or `..`.
113pub fn file_stem(name: &str) -> String {
114    name.to_lowercase()
115        .chars()
116        .map(|c| {
117            if c.is_alphanumeric() || c == '-' || c == '_' {
118                c
119            } else {
120                '_'
121            }
122        })
123        .collect()
124}
125
126/// The frontend's id for a launch with no slug: JavaScript's
127/// `name.toLowerCase().replace(/[^a-z0-9-_]/g, "-")`. ASCII only, unlike
128/// `path_slug`; no collapsing. The regex has no `u` flag, so it replaces each
129/// UTF-16 **code unit**: a character outside the BMP becomes two dashes
130/// (`Agent 🚀` → `agent---`), which signing-key names depend on (Codex P1 on
131/// #3633).
132pub fn js_ascii_slug(name: &str) -> String {
133    let mut id = String::new();
134    for c in name.to_lowercase().chars() {
135        if c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_' {
136            id.push(c);
137        } else {
138            id.extend(std::iter::repeat('-').take(c.len_utf16()));
139        }
140    }
141    id
142}
143
144#[cfg(test)]
145mod tests {
146    use super::*;
147
148    /// A fork's slug is its root's plus its own label, for every generation:
149    /// `Loap #3` is `loap-3` whether forked from Loap or from Loap #2.
150    #[test]
151    fn fork_slugs_never_chain() {
152        assert_eq!(fork_slug("loap", "Loap", "Loap #3"), "loap-3");
153        assert_eq!(fork_slug("loap", "Loap", "Loap"), "loap");
154        assert_eq!(fork_slug("loap", "Loap", "experiment"), "loap-experiment");
155        assert_eq!(fork_slug("loap", "Loap", "Loapish"), "loap-loapish", "a prefix of the name isn't the root's");
156        assert_eq!(fork_slug("claude-2", "Claude", "Claude #3"), "claude-3", "a root whose slug was collision-suffixed");
157        assert_eq!(fork_slug(&"r".repeat(60), "R", "Long Branch Label").len(), 64);
158    }
159
160    /// Every rule over the same inputs: the differences, stated.
161    #[test]
162    fn the_rules_side_by_side() {
163        // (input, definition_slug, path_slug, file_stem, js_ascii_slug, agent_route_name)
164        let table: &[(&str, &str, &str, &str, &str, &str)] = &[
165            ("Zed Bot", "zed-bot", "zed-bot", "zed_bot", "zed-bot", "Zed-Bot"),
166            ("Ä", "agent", "ä", "ä", "-", "agent"),
167            ("Agent 🚀", "agent", "agent--", "agent__", "agent---", "Agent"),
168            ("--x--", "x", "--x--", "--x--", "--x--", "--x--"),
169            ("a/b\\c..d", "a-b-c-d", "a-b-c--d", "a_b_c__d", "a-b-c--d", "a-b-c-d"),
170            ("", "agent", "", "", "", "agent"),
171            (
172                "My_Agent-2",
173                "my_agent-2",
174                "my_agent-2",
175                "my_agent-2",
176                "my_agent-2",
177                "My_Agent-2",
178            ),
179            ("Loap #3", "loap-3", "loap--3", "loap__3", "loap--3", "Loap-3"),
180            ("agent2@narko", "agent2-narko", "agent2-narko", "agent2_narko", "agent2-narko", "agent2@narko"),
181        ];
182        for (input, def, path, stem, js, route) in table {
183            assert_eq!(definition_slug(input), *def, "definition_slug({input:?})");
184            assert_eq!(path_slug(input), *path, "path_slug({input:?})");
185            assert_eq!(file_stem(input), *stem, "file_stem({input:?})");
186            assert_eq!(js_ascii_slug(input), *js, "js_ascii_slug({input:?})");
187            assert_eq!(agent_route_name(input), *route, "agent_route_name({input:?})");
188            if !input.contains('@') {
189                assert!(is_agent_id(&agent_route_name(input)), "agent_route_name({input:?}) is a valid id");
190            }
191        }
192    }
193
194    #[test]
195    fn agent_route_name_caps_at_64_chars_and_keeps_valid_ids_as_they_are() {
196        assert_eq!(agent_route_name(&"Research Assistant #3 ".repeat(5)).len(), AGENT_ID_MAX_LEN);
197        assert_eq!(agent_route_name("Agent2"), "Agent2");
198        assert!(!is_agent_id(&"a".repeat(65)));
199    }
200
201    #[test]
202    fn definition_slug_caps_at_64_chars() {
203        assert_eq!(definition_slug(&"a".repeat(100)).len(), 64);
204    }
205
206    // The bodies these replace, frozen as they were at each call site, so the
207    // move is proven not to change any output.
208    mod oracle {
209        pub fn default_agent_working_dir_slug(n: &str) -> String {
210            n.to_lowercase()
211                .chars()
212                .map(|c| {
213                    if c.is_alphanumeric() || c == '-' || c == '_' {
214                        c
215                    } else {
216                        '-'
217                    }
218                })
219                .collect()
220        }
221        pub fn registry_safe(n: &str) -> String {
222            n.to_lowercase()
223                .chars()
224                .map(|c| {
225                    if c.is_alphanumeric() || c == '-' || c == '_' {
226                        c
227                    } else {
228                        '_'
229                    }
230                })
231                .collect()
232        }
233    }
234
235    #[test]
236    fn identical_to_the_inline_copies_they_replace() {
237        let inputs = [
238            "Zed Bot",
239            "Ä",
240            "Agent 🚀",
241            "--x--",
242            "a/b\\c..d",
243            "",
244            "ÉCOLE Ñ ß",
245            "tab\there",
246            "日本語",
247            "x".repeat(80).leak(),
248            "İstanbul",
249            "Dž",
250            "..",
251            "/",
252            "a\u{0301}",
253        ];
254        for n in inputs {
255            assert_eq!(
256                path_slug(n),
257                oracle::default_agent_working_dir_slug(n),
258                "path_slug({n:?})"
259            );
260            assert_eq!(file_stem(n), oracle::registry_safe(n), "file_stem({n:?})");
261        }
262    }
263}