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-installer's
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-installer 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-installer 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-installer 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}