1use serde::{Deserialize, Serialize};
9use std::fmt;
10use thiserror::Error;
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
15#[serde(rename_all = "lowercase")]
16pub enum HandleKind {
18 Symbol,
19 Component,
20 Flow,
21 Contract,
22 State,
23 Route,
24 File,
25 Span,
26}
27
28impl HandleKind {
29 pub fn as_str(self) -> &'static str {
30 match self {
31 HandleKind::Symbol => "symbol",
32 HandleKind::Component => "component",
33 HandleKind::Flow => "flow",
34 HandleKind::Contract => "contract",
35 HandleKind::State => "state",
36 HandleKind::Route => "route",
37 HandleKind::File => "file",
38 HandleKind::Span => "span",
39 }
40 }
41
42 pub fn parse(s: &str) -> Option<Self> {
43 Some(match s {
44 "symbol" => HandleKind::Symbol,
45 "component" => HandleKind::Component,
46 "flow" => HandleKind::Flow,
47 "contract" => HandleKind::Contract,
48 "state" => HandleKind::State,
49 "route" => HandleKind::Route,
50 "file" => HandleKind::File,
51 "span" => HandleKind::Span,
52 _ => return None,
53 })
54 }
55}
56
57#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
66pub struct ContentHandle {
68 pub repo: String,
69 pub epoch: String,
70 pub kind: HandleKind,
71 pub key: String,
72 pub content_hash: String,
74}
75
76#[derive(Debug, Error, PartialEq, Eq)]
77pub enum HandleError {
78 #[error("malformed handle")]
79 Malformed,
80 #[error("stale handle: content hash mismatch")]
81 Stale,
82 #[error("ambiguous handle")]
83 Ambiguous,
84 #[error("unknown handle kind")]
85 UnknownKind,
86}
87
88impl ContentHandle {
90 pub fn new(
91 repo: impl Into<String>,
92 epoch: impl Into<String>,
93 kind: HandleKind,
94 key: impl Into<String>,
95 content_hash: impl Into<String>,
96 ) -> Self {
97 ContentHandle {
98 repo: repo.into(),
99 epoch: epoch.into(),
100 kind,
101 key: key.into(),
102 content_hash: content_hash.into(),
103 }
104 }
105
106 pub fn for_symbol(
108 repo: &str,
109 epoch: &str,
110 path: &str,
111 name: &str,
112 content_hash: &str,
113 ) -> Self {
114 ContentHandle::new(
115 repo,
116 epoch,
117 HandleKind::Symbol,
118 format!("{path}::{name}"),
119 content_hash,
120 )
121 }
122
123 pub fn for_file(repo: &str, epoch: &str, path: &str, content_hash: &str) -> Self {
124 ContentHandle::new(repo, epoch, HandleKind::File, path, content_hash)
125 }
126
127 pub fn for_span(
128 repo: &str,
129 epoch: &str,
130 path: &str,
131 start_line: u32,
132 end_line: u32,
133 content_hash: &str,
134 ) -> Self {
135 ContentHandle::new(
136 repo,
137 epoch,
138 HandleKind::Span,
139 format!("{path}:L{start_line}-L{end_line}"),
140 content_hash,
141 )
142 }
143
144 pub fn parse(s: &str) -> Result<Self, HandleError> {
145 let rest = s.strip_prefix("scc://").ok_or(HandleError::Malformed)?;
146 let (repo, rest) = rest.split_once('/').ok_or(HandleError::Malformed)?;
147 let (epoch, rest) = rest.split_once('/').ok_or(HandleError::Malformed)?;
148 let (kind_s, rest) = rest.split_once('/').ok_or(HandleError::Malformed)?;
149 let kind = HandleKind::parse(kind_s).ok_or(HandleError::UnknownKind)?;
150 let (key_enc, hash) = match rest.rsplit_once('@') {
151 Some((k, h)) => (k, h.to_string()),
152 None => (rest, String::new()),
153 };
154 if repo.is_empty() || epoch.is_empty() || key_enc.is_empty() {
155 return Err(HandleError::Malformed);
156 }
157 Ok(ContentHandle {
158 repo: percent_decode(repo),
159 epoch: epoch.to_string(),
160 kind,
161 key: percent_decode(key_enc),
162 content_hash: hash,
163 })
164 }
165
166 pub fn matches_content(&self, actual_hash: &str) -> bool {
169 self.content_hash.is_empty() || self.content_hash == actual_hash
170 }
171
172 pub fn refuse_if_stale(&self, actual_hash: &str) -> Result<(), HandleError> {
173 if self.matches_content(actual_hash) {
174 Ok(())
175 } else {
176 Err(HandleError::Stale)
177 }
178 }
179}
180
181impl fmt::Display for ContentHandle {
182 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
183 write!(
184 f,
185 "scc://{}/{}/{}/{}",
186 percent_encode(&self.repo),
187 self.epoch,
188 self.kind.as_str(),
189 percent_encode(&self.key)
190 )?;
191 if !self.content_hash.is_empty() {
192 write!(f, "@{}", self.content_hash)?;
193 }
194 Ok(())
195 }
196}
197
198pub fn fnv1a64_hex(bytes: &[u8]) -> String {
201 const OFFSET: u64 = 0xcbf29ce484222325;
202 const PRIME: u64 = 0x100000001b3;
203 let mut h = OFFSET;
204 for b in bytes {
205 h ^= u64::from(*b);
206 h = h.wrapping_mul(PRIME);
207 }
208 format!("{h:016x}")
209}
210
211fn percent_encode(s: &str) -> String {
212 let mut out = String::with_capacity(s.len());
213 for b in s.bytes() {
214 match b {
215 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
216 out.push(b as char);
217 }
218 _ => out.push_str(&format!("%{b:02X}")),
219 }
220 }
221 out
222}
223
224fn percent_decode(s: &str) -> String {
225 let bytes = s.as_bytes();
226 let mut out = Vec::with_capacity(bytes.len());
227 let mut i = 0;
228 while i < bytes.len() {
229 if bytes[i] == b'%' && i + 2 < bytes.len() {
230 if let (Some(hi), Some(lo)) = (from_hex(bytes[i + 1]), from_hex(bytes[i + 2])) {
231 out.push((hi << 4) | lo);
232 i += 3;
233 continue;
234 }
235 }
236 out.push(bytes[i]);
237 i += 1;
238 }
239 String::from_utf8_lossy(&out).into_owned()
240}
241
242fn from_hex(b: u8) -> Option<u8> {
243 match b {
244 b'0'..=b'9' => Some(b - b'0'),
245 b'a'..=b'f' => Some(b - b'a' + 10),
246 b'A'..=b'F' => Some(b - b'A' + 10),
247 _ => None,
248 }
249}
250
251#[cfg(test)]
252mod tests {
253 use super::*;
254
255 #[test]
256 fn roundtrip_is_stable_and_path_aware() {
258 let h = ContentHandle::for_symbol(
259 "repo",
260 "epoch1",
261 "src/foo.ts",
262 "Class::method",
263 "deadbeefcafebabe",
264 );
265 let s = h.to_string();
266 assert!(s.starts_with("scc://"), "{s}");
267 assert!(!s.contains("foo.ts::Class") || s.contains("src"), "{s}");
268 let parsed = ContentHandle::parse(&s).unwrap();
269 assert_eq!(parsed, h);
270 assert_eq!(parsed.key, "src/foo.ts::Class::method");
271 }
272
273 #[test]
274 fn basename_only_is_not_the_identity() {
275 let a = ContentHandle::for_symbol("r", "e", "a/foo.rs", "n", "1");
276 let b = ContentHandle::for_symbol("r", "e", "b/foo.rs", "n", "1");
277 assert_ne!(a.to_string(), b.to_string());
278 }
279
280 #[test]
281 fn stale_hash_refuses() {
282 let h = ContentHandle::for_file("r", "e", "x.rs", "aaaaaaaaaaaaaaaa");
283 assert_eq!(h.refuse_if_stale("bbbbbbbbbbbbbbbb"), Err(HandleError::Stale));
284 assert!(h.refuse_if_stale("aaaaaaaaaaaaaaaa").is_ok());
285 let open = ContentHandle::for_file("r", "e", "x.rs", "");
286 assert!(open.refuse_if_stale("anything").is_ok());
287 }
288
289 #[test]
290 fn malformed_refuses() {
291 assert_eq!(ContentHandle::parse("not-a-handle"), Err(HandleError::Malformed));
292 assert_eq!(ContentHandle::parse("scc://"), Err(HandleError::Malformed));
293 }
294
295 #[test]
296 fn fnv_is_deterministic() {
297 assert_eq!(fnv1a64_hex(b"abc"), fnv1a64_hex(b"abc"));
298 assert_ne!(fnv1a64_hex(b"abc"), fnv1a64_hex(b"abd"));
299 }
300}