Skip to main content

trusty_common/
slug.rs

1//! Canonical project-slug derivation shared across the trusty-* workspace.
2//!
3//! Why: trusty-memory derives palace names from a directory basename (and from
4//! arbitrary repo names supplied on the CLI), and trusty-controller's `tctl
5//! ensure` must produce the *byte-for-byte identical* slug or the trusty-memory
6//! daemon rejects `POST /api/v1/palaces` with a 400 (its `validate_palace_name`
7//! re-derives the slug and compares). Before #1348 each crate carried its own
8//! copy of the rule, a silent-divergence hazard: a tweak in one would let the
9//! two drift apart without any compile-time signal. Hoisting the single rule
10//! into `trusty-common` makes it the one source of truth both crates call.
11//!
12//! What: [`slugify_string`] — the one canonicalisation function. trusty-memory
13//! re-exports it as `trusty_memory::messaging::slugify_string` (a thin shim) and
14//! trusty-controller calls it directly.
15//!
16//! Test: `cargo test -p trusty-common -- slug::tests` pins the canonical
17//! behaviour here; the consuming crates inherit it transitively.
18
19/// Canonicalise an arbitrary string into a stable project slug.
20///
21/// Why: a project's palace name / index id must be derived deterministically so
22/// re-running derivation (across renames, casing differences, or
23/// underscore-vs-hyphen typing) yields the same token — otherwise two callers
24/// produce two different palaces for the same project, or the trusty-memory
25/// daemon's `validate_palace_name` rejects creation because the controller's
26/// slug disagrees with the daemon's. This is the single source of truth for that
27/// rule (issue #1348).
28/// What: lower-cases the trimmed input, strips a trailing `.git`, then maps each
29/// character: `[a-z0-9]` pass through verbatim; `_`, `-`, space, and tab each
30/// become a single `-` (runs collapse to one, never leading); every other
31/// character (including `/`, `!`, and non-ASCII) is stripped entirely. Leading
32/// and trailing `-` are trimmed. A pure-unicode input yields an empty string —
33/// callers must guard that case.
34/// Test: `slug::tests::slug_derivation_cases`.
35pub fn slugify_string(input: &str) -> String {
36    let lowered = input.trim().to_ascii_lowercase();
37    let stripped = lowered.strip_suffix(".git").unwrap_or(&lowered);
38    let mut out = String::with_capacity(stripped.len());
39    let mut prev_hyphen = false;
40    for c in stripped.chars() {
41        let next = match c {
42            'a'..='z' | '0'..='9' => Some(c),
43            '_' | '-' | ' ' | '\t' => Some('-'),
44            // Strip everything else.
45            _ => None,
46        };
47        if let Some(c) = next {
48            if c == '-' {
49                if !prev_hyphen && !out.is_empty() {
50                    out.push('-');
51                    prev_hyphen = true;
52                }
53            } else {
54                out.push(c);
55                prev_hyphen = false;
56            }
57        }
58    }
59    while out.ends_with('-') {
60        out.pop();
61    }
62    out
63}
64
65#[cfg(test)]
66mod tests {
67    use super::*;
68
69    /// Why: this is the canonical behaviour both trusty-memory and
70    /// trusty-controller depend on; pinning every representative case here
71    /// guarantees the rule cannot silently change for either consumer.
72    /// What: case folding, `_`/space/tab → `-`, hyphen-run collapse, `.git`
73    /// strip, leading/trailing trim, foreign-char stripping, and the empty
74    /// (pure-unicode) fallthrough.
75    /// Test: This is the test.
76    #[test]
77    fn slug_derivation_cases() {
78        // Basic lowercase + hyphenation.
79        assert_eq!(slugify_string("trusty-tools"), "trusty-tools");
80        assert_eq!(slugify_string("Trusty_Tools"), "trusty-tools");
81        assert_eq!(slugify_string("trusty tools"), "trusty-tools");
82        assert_eq!(slugify_string("  trusty   tools  "), "trusty-tools");
83        // Git suffix stripped.
84        assert_eq!(slugify_string("trusty-tools.git"), "trusty-tools");
85        // Non-alphanumerics (other than the separator set) are stripped, not
86        // mapped to a hyphen.
87        assert_eq!(slugify_string("trusty/tools!"), "trustytools");
88        // Multiple consecutive hyphens collapse to one.
89        assert_eq!(slugify_string("foo--bar"), "foo-bar");
90        // Mixed separators + leading/trailing junk (trusty-controller parity).
91        assert_eq!(slugify_string("My_Cool Project"), "my-cool-project");
92        assert_eq!(slugify_string("  --weird__name--  "), "weird-name");
93        assert_eq!(slugify_string("!!!"), "");
94        // Pure unicode -> empty (caller must guard).
95        assert_eq!(slugify_string("漢字"), "");
96    }
97}