Skip to main content

dkp_core/
domain.rs

1use crate::error::{DkpError, DkpResult};
2
3pub const MIN_SLUG_LEN: usize = 2;
4pub const MAX_SLUG_LEN: usize = 40;
5
6/// Hardcoded, versioned-with-deploys denylist. Not DB-backed by design —
7/// domain moderation is a manual-SQL operator runbook, not an API surface.
8/// Distinct from the unrelated `BLOCKED_SCOPES` denylist (scope/namespace
9/// names are a different concept from domain slugs).
10///
11/// This is registry moderation policy, not structural validity — it's only
12/// enforced via `validate_domain_slug_for_registry`, not the plain
13/// `validate_domain_slug` used for local/offline pack builds.
14const RESERVED_DOMAIN_SLUGS: &[&str] = &[
15    "all",
16    "none",
17    "null",
18    "undefined",
19    "admin",
20    "root",
21    "system",
22    "test",
23    "example",
24    "unknown",
25    "n-a",
26    "na",
27];
28
29/// Lowercase, trim, collapse any run of whitespace/punctuation into a single
30/// hyphen, strip leading/trailing hyphens. Non-ASCII characters are dropped
31/// as separators (ASCII-only slugs).
32///
33/// Examples:
34///   "Startups"      -> "startups"
35///   " Startups "    -> "startups"
36///   "Health & Law"  -> "health-law"
37///   "AI/ML Tools"   -> "ai-ml-tools"
38///   "---weird---"   -> "weird"
39pub fn slugify_domain(display: &str) -> String {
40    let lowered = display.trim().to_lowercase();
41    let mut slug = String::with_capacity(lowered.len());
42    let mut prev_was_hyphen = true; // suppress leading hyphen
43    for ch in lowered.chars() {
44        if ch.is_ascii_alphanumeric() {
45            slug.push(ch);
46            prev_was_hyphen = false;
47        } else if !prev_was_hyphen {
48            slug.push('-');
49            prev_was_hyphen = true;
50        }
51    }
52    while slug.ends_with('-') {
53        slug.pop();
54    }
55    slug
56}
57
58/// Validate an already-computed slug: length, charset, hyphenation. This is
59/// structural validity only — it does not enforce the registry's
60/// reserved-word denylist, so it's safe to run for purely local/offline pack
61/// builds (e.g. `dkp build`) where there's no registry moderation concern.
62/// Called independently of `slugify_domain` (e.g. by backfill scripts
63/// re-deriving slugs from stored data).
64pub fn validate_domain_slug(slug: &str) -> DkpResult<()> {
65    if slug.len() < MIN_SLUG_LEN || slug.len() > MAX_SLUG_LEN {
66        return Err(DkpError::ManifestInvalid {
67            reason: format!(
68                "domain slug '{slug}' must be {MIN_SLUG_LEN}-{MAX_SLUG_LEN} characters (got {})",
69                slug.len()
70            ),
71        });
72    }
73    if !slug
74        .chars()
75        .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
76    {
77        return Err(DkpError::ManifestInvalid {
78            reason: format!(
79                "domain slug '{slug}' must contain only lowercase letters, digits, and hyphens"
80            ),
81        });
82    }
83    if slug.starts_with('-') || slug.ends_with('-') || slug.contains("--") {
84        return Err(DkpError::ManifestInvalid {
85            reason: format!("domain slug '{slug}' has malformed hyphenation"),
86        });
87    }
88    Ok(())
89}
90
91/// Validate a slug for registry publication: structural validity plus the
92/// reserved-word denylist. The registry must validate defensively rather
93/// than trust the CLI, so this is what the publish route calls.
94pub fn validate_domain_slug_for_registry(slug: &str) -> DkpResult<()> {
95    validate_domain_slug(slug)?;
96    if RESERVED_DOMAIN_SLUGS.contains(&slug) {
97        return Err(DkpError::ManifestInvalid {
98            reason: format!("domain '{slug}' is a reserved word and cannot be used"),
99        });
100    }
101    Ok(())
102}
103
104/// Convenience: slugify + validate (structural only) in one call. Used by
105/// the CLI loader (`Pack::open`) for local/offline commands like `dkp build`.
106pub fn derive_and_validate_domain_slug(display: &str) -> DkpResult<String> {
107    let slug = slugify_domain(display);
108    validate_domain_slug(&slug)?;
109    Ok(slug)
110}
111
112/// Convenience: slugify + validate (structural + reserved-word) in one call.
113/// Used by the registry publish handler.
114pub fn derive_and_validate_domain_slug_for_registry(display: &str) -> DkpResult<String> {
115    let slug = slugify_domain(display);
116    validate_domain_slug_for_registry(&slug)?;
117    Ok(slug)
118}
119
120#[cfg(test)]
121mod tests {
122    use super::*;
123
124    #[test]
125    fn basic_cases() {
126        assert_eq!(slugify_domain("Startups"), "startups");
127        assert_eq!(slugify_domain(" Startups "), "startups");
128        assert_eq!(slugify_domain("Health & Law"), "health-law");
129        assert_eq!(slugify_domain("AI/ML Tools"), "ai-ml-tools");
130        assert_eq!(slugify_domain("---weird---"), "weird");
131        assert_eq!(slugify_domain("support"), "support");
132    }
133
134    #[test]
135    fn length_bounds() {
136        assert!(validate_domain_slug("a").is_err());
137        assert!(validate_domain_slug(&"a".repeat(41)).is_err());
138        assert!(validate_domain_slug(&"a".repeat(40)).is_ok());
139        assert!(validate_domain_slug("ab").is_ok());
140    }
141
142    #[test]
143    fn reserved_words_rejected_for_registry() {
144        for w in ["all", "none", "admin", "undefined", "null"] {
145            assert!(
146                validate_domain_slug_for_registry(w).is_err(),
147                "{w} should be reserved"
148            );
149        }
150    }
151
152    #[test]
153    fn reserved_words_allowed_locally() {
154        // Local/offline builds shouldn't be blocked by registry moderation
155        // policy — only registry publication enforces the denylist.
156        for w in ["all", "none", "admin", "undefined", "null", "test"] {
157            assert!(
158                validate_domain_slug(w).is_ok(),
159                "{w} should be allowed for local validation"
160            );
161            assert!(derive_and_validate_domain_slug(w).is_ok());
162        }
163    }
164
165    #[test]
166    fn support_is_not_reserved() {
167        // "support" is real example data (dkps/*/manifest.json) and is
168        // blocked as a *scope* name elsewhere, but that's an unrelated
169        // denylist — it must remain allowed as a domain.
170        assert!(validate_domain_slug("support").is_ok());
171        assert!(validate_domain_slug_for_registry("support").is_ok());
172    }
173
174    #[test]
175    fn charset_rejected_if_hand_constructed_badly() {
176        assert!(validate_domain_slug("Has_Underscore").is_err());
177        assert!(validate_domain_slug("-leading").is_err());
178        assert!(validate_domain_slug("trailing-").is_err());
179        assert!(validate_domain_slug("double--hyphen").is_err());
180    }
181
182    #[test]
183    fn derive_round_trip() {
184        assert_eq!(
185            derive_and_validate_domain_slug("Startups").unwrap(),
186            "startups"
187        );
188        assert_eq!(derive_and_validate_domain_slug("Law").unwrap(), "law");
189        assert_eq!(
190            derive_and_validate_domain_slug("support").unwrap(),
191            "support"
192        );
193        assert!(derive_and_validate_domain_slug_for_registry("Admin").is_err());
194    }
195
196    #[test]
197    fn slugify_empty_and_punctuation_only() {
198        assert_eq!(slugify_domain(""), "");
199        assert_eq!(slugify_domain("   "), "");
200        assert_eq!(slugify_domain("!!!"), "");
201    }
202
203    #[test]
204    fn slugify_unicode_dropped_as_separator() {
205        // Non-ASCII chars are dropped as separators, not preserved.
206        assert_eq!(slugify_domain("Café Law"), "caf-law");
207    }
208
209    #[test]
210    fn validate_rejects_all_reserved_words_for_registry() {
211        for w in [
212            "all",
213            "none",
214            "null",
215            "undefined",
216            "admin",
217            "root",
218            "system",
219            "test",
220            "example",
221            "unknown",
222            "n-a",
223            "na",
224        ] {
225            assert!(
226                validate_domain_slug_for_registry(w).is_err(),
227                "{w} should be reserved"
228            );
229        }
230    }
231
232    #[test]
233    fn validate_rejects_uppercase() {
234        assert!(validate_domain_slug("Startups").is_err());
235    }
236
237    #[test]
238    fn validate_rejects_non_ascii() {
239        assert!(validate_domain_slug("café").is_err());
240    }
241
242    #[test]
243    fn derive_and_validate_reserved_word_collision_for_registry() {
244        // A display name that slugifies straight into a reserved word must
245        // fail registry validation, but remain fine for local validation.
246        assert!(derive_and_validate_domain_slug_for_registry("Root").is_err());
247        assert!(derive_and_validate_domain_slug_for_registry("  Test  ").is_err());
248        assert!(derive_and_validate_domain_slug("Root").is_ok());
249        assert!(derive_and_validate_domain_slug("  Test  ").is_ok());
250    }
251
252    #[test]
253    fn derive_and_validate_too_short_after_slugify() {
254        // Punctuation-only input slugifies to empty, which is below MIN_SLUG_LEN.
255        assert!(derive_and_validate_domain_slug("!!!").is_err());
256        assert!(derive_and_validate_domain_slug("a").is_err());
257    }
258}