|
1 | | -use nostr_sdk::hashes::{Hash, sha256}; |
2 | | - |
3 | | -/// Compute a deterministic 8-hex-char suffix from a preimage string. |
4 | | -fn suffix(preimage: &str) -> String { |
5 | | - sha256::Hash::hash(preimage.as_bytes()) |
6 | | - .to_string() |
7 | | - .chars() |
8 | | - .take(8) |
9 | | - .collect() |
10 | | -} |
11 | | - |
12 | | -/// Normalize a human string into a URL-safe slug (`[a-z0-9-]`). |
13 | | -/// |
14 | | -/// Returns `fallback` if the input normalizes to empty. |
15 | | -pub fn normalize(input: &str, fallback: &str) -> String { |
16 | | - let slug: String = input |
17 | | - .trim() |
18 | | - .to_lowercase() |
19 | | - .chars() |
20 | | - .map(|c| if c.is_ascii_whitespace() { '-' } else { c }) |
21 | | - .filter(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || *c == '-') |
22 | | - .collect::<String>() |
23 | | - .split('-') |
24 | | - .filter(|s| !s.is_empty()) |
25 | | - .collect::<Vec<_>>() |
26 | | - .join("-"); |
27 | | - |
28 | | - if slug.is_empty() { |
29 | | - fallback.to_string() |
30 | | - } else { |
31 | | - slug |
32 | | - } |
33 | | -} |
34 | | - |
35 | | -/// Generate a deterministic d-tag for a list header (kind 39998). |
36 | | -/// |
37 | | -/// Format: `{slug}--{8-char-hex-suffix}` |
38 | | -pub fn header_dtag(name_singular: &str, pubkey_hex: &str) -> String { |
39 | | - let slug = normalize(name_singular, "list"); |
40 | | - let sfx = suffix(&format!("header|{pubkey_hex}|{slug}")); |
41 | | - format!("{slug}--{sfx}") |
42 | | -} |
43 | | - |
44 | | -/// Generate a deterministic d-tag for a list item (kind 39999). |
45 | | -/// |
46 | | -/// Format: `{slug}--{8-char-hex-suffix}` |
47 | | -/// |
48 | | -/// The suffix is derived from the raw `anchor_value` (not the slug) to preserve |
49 | | -/// sensitivity to the original input. |
50 | | -pub fn item_dtag(parent_z: &str, anchor_value: &str) -> String { |
51 | | - let slug = normalize(anchor_value, "item"); |
52 | | - let sfx = suffix(&format!("item|{parent_z}|{anchor_value}")); |
53 | | - format!("{slug}--{sfx}") |
54 | | -} |
55 | | - |
56 | | -#[cfg(test)] |
57 | | -mod tests { |
58 | | - use super::*; |
59 | | - |
60 | | - // ----------------------------------------------------------------------- |
61 | | - // normalize |
62 | | - // ----------------------------------------------------------------------- |
63 | | - |
64 | | - #[test] |
65 | | - fn normalize_simple_lowercase() { |
66 | | - assert_eq!(normalize("Hello World", "x"), "hello-world"); |
67 | | - } |
68 | | - |
69 | | - #[test] |
70 | | - fn normalize_trims_whitespace() { |
71 | | - assert_eq!(normalize(" spaced ", "x"), "spaced"); |
72 | | - } |
73 | | - |
74 | | - #[test] |
75 | | - fn normalize_collapses_whitespace_runs() { |
76 | | - assert_eq!(normalize("a b c", "x"), "a-b-c"); |
77 | | - } |
78 | | - |
79 | | - #[test] |
80 | | - fn normalize_strips_special_chars() { |
81 | | - assert_eq!( |
82 | | - normalize("AI Agents! On @Nostr?", "x"), |
83 | | - "ai-agents-on-nostr" |
84 | | - ); |
85 | | - } |
86 | | - |
87 | | - #[test] |
88 | | - fn normalize_preserves_digits() { |
89 | | - assert_eq!(normalize("Web3 Tools 42", "x"), "web3-tools-42"); |
90 | | - } |
91 | | - |
92 | | - #[test] |
93 | | - fn normalize_strips_unicode() { |
94 | | - assert_eq!(normalize("café résumé", "x"), "caf-rsum"); |
95 | | - } |
96 | | - |
97 | | - #[test] |
98 | | - fn normalize_empty_input_returns_fallback() { |
99 | | - assert_eq!(normalize("", "item"), "item"); |
100 | | - } |
101 | | - |
102 | | - #[test] |
103 | | - fn normalize_all_special_chars_returns_fallback() { |
104 | | - assert_eq!(normalize("!@#$%^&*()", "list"), "list"); |
105 | | - } |
106 | | - |
107 | | - #[test] |
108 | | - fn normalize_only_whitespace_returns_fallback() { |
109 | | - assert_eq!(normalize(" ", "list"), "list"); |
110 | | - } |
111 | | - |
112 | | - #[test] |
113 | | - fn normalize_leading_trailing_hyphens_trimmed() { |
114 | | - assert_eq!(normalize("--hello--", "x"), "hello"); |
115 | | - } |
116 | | - |
117 | | - #[test] |
118 | | - fn normalize_repeated_hyphens_collapsed() { |
119 | | - assert_eq!(normalize("a---b", "x"), "a-b"); |
120 | | - } |
121 | | - |
122 | | - #[test] |
123 | | - fn normalize_numeric_only() { |
124 | | - assert_eq!(normalize("12345", "x"), "12345"); |
125 | | - } |
126 | | - |
127 | | - // ----------------------------------------------------------------------- |
128 | | - // header_dtag |
129 | | - // ----------------------------------------------------------------------- |
130 | | - |
131 | | - #[test] |
132 | | - fn header_dtag_format() { |
133 | | - let result = header_dtag("AI Agents on Nostr", "aabbccdd"); |
134 | | - assert!(result.starts_with("ai-agents-on-nostr--")); |
135 | | - assert_eq!(result.len(), "ai-agents-on-nostr--".len() + 8); |
136 | | - } |
137 | | - |
138 | | - #[test] |
139 | | - fn header_dtag_deterministic() { |
140 | | - let a = header_dtag("test", "pubkey1"); |
141 | | - let b = header_dtag("test", "pubkey1"); |
142 | | - assert_eq!(a, b); |
143 | | - } |
144 | | - |
145 | | - #[test] |
146 | | - fn header_dtag_different_names_differ() { |
147 | | - let a = header_dtag("alpha", "pubkey1"); |
148 | | - let b = header_dtag("beta", "pubkey1"); |
149 | | - assert_ne!(a, b); |
150 | | - } |
151 | | - |
152 | | - #[test] |
153 | | - fn header_dtag_different_pubkeys_differ() { |
154 | | - let a = header_dtag("test", "pubkey1"); |
155 | | - let b = header_dtag("test", "pubkey2"); |
156 | | - assert_ne!(a, b); |
157 | | - } |
158 | | - |
159 | | - #[test] |
160 | | - fn header_dtag_empty_name_uses_fallback() { |
161 | | - let result = header_dtag("", "pubkey1"); |
162 | | - assert!(result.starts_with("list--")); |
163 | | - } |
164 | | - |
165 | | - // ----------------------------------------------------------------------- |
166 | | - // item_dtag |
167 | | - // ----------------------------------------------------------------------- |
168 | | - |
169 | | - #[test] |
170 | | - fn item_dtag_format() { |
171 | | - let result = item_dtag("39998:pk:my-list", "https://example.com/resource"); |
172 | | - assert!(result.contains("--")); |
173 | | - let parts: Vec<&str> = result.rsplitn(2, "--").collect(); |
174 | | - assert_eq!(parts[0].len(), 8); // suffix |
175 | | - } |
176 | | - |
177 | | - #[test] |
178 | | - fn item_dtag_deterministic() { |
179 | | - let a = item_dtag("parent-z", "https://example.com"); |
180 | | - let b = item_dtag("parent-z", "https://example.com"); |
181 | | - assert_eq!(a, b); |
182 | | - } |
183 | | - |
184 | | - #[test] |
185 | | - fn item_dtag_different_parents_differ() { |
186 | | - let a = item_dtag("parent-a", "https://example.com"); |
187 | | - let b = item_dtag("parent-b", "https://example.com"); |
188 | | - assert_ne!(a, b); |
189 | | - } |
190 | | - |
191 | | - #[test] |
192 | | - fn item_dtag_different_anchors_differ() { |
193 | | - let a = item_dtag("parent", "https://a.com"); |
194 | | - let b = item_dtag("parent", "https://b.com"); |
195 | | - assert_ne!(a, b); |
196 | | - } |
197 | | - |
198 | | - #[test] |
199 | | - fn item_dtag_empty_anchor_uses_fallback() { |
200 | | - let result = item_dtag("parent-z", ""); |
201 | | - assert!(result.starts_with("item--")); |
202 | | - } |
203 | | - |
204 | | - // ----------------------------------------------------------------------- |
205 | | - // suffix |
206 | | - // ----------------------------------------------------------------------- |
207 | | - |
208 | | - #[test] |
209 | | - fn suffix_length_is_8() { |
210 | | - assert_eq!(suffix("anything").len(), 8); |
211 | | - } |
212 | | - |
213 | | - #[test] |
214 | | - fn suffix_is_hex() { |
215 | | - let s = suffix("test-input"); |
216 | | - assert!(s.chars().all(|c| c.is_ascii_hexdigit())); |
217 | | - } |
218 | | - |
219 | | - #[test] |
220 | | - fn suffix_deterministic() { |
221 | | - assert_eq!(suffix("same"), suffix("same")); |
222 | | - } |
223 | | - |
224 | | - #[test] |
225 | | - fn suffix_different_inputs_differ() { |
226 | | - assert_ne!(suffix("alpha"), suffix("beta")); |
227 | | - } |
228 | | -} |
| 1 | +// Re-export from dcosl-core |
| 2 | +pub use dcosl_core::dtag::*; |
0 commit comments