1use std::collections::BTreeSet;
2use std::time::{SystemTime, UNIX_EPOCH};
3
4use sha2::{Digest, Sha256};
5
6use super::{
7 anchor,
8 types::{
9 AnchorKind, BootstrapIdentityParts, KnowledgeStatus, KnowledgeTier, MemoryDomain,
10 MemoryKind, Provenance, SourceType, TaxonomyEntry, TunnelEndpoint,
11 },
12};
13
14pub const DEFAULT_ROOM: &str = "default";
15const BOOTSTRAP_DRAWER_ID_HASH_LEN: usize = 12;
16
17pub fn build_drawer_id(wing: &str, room: Option<&str>, content: &str) -> String {
18 let room = room.unwrap_or(DEFAULT_ROOM);
19 let mut hasher = Sha256::new();
20 hasher.update(content.as_bytes());
21 let digest = format!("{:x}", hasher.finalize());
22
23 format!(
24 "drawer_{}_{}_{}",
25 sanitize_component(wing),
26 sanitize_component(room),
27 &digest[..8]
28 )
29}
30
31pub fn build_bootstrap_drawer_id(
32 wing: &str,
33 room: Option<&str>,
34 content: &str,
35 identity_components: &[String],
36) -> String {
37 let room = room.unwrap_or(DEFAULT_ROOM);
38 let mut hasher = Sha256::new();
39 hasher.update(content.as_bytes());
40 for component in identity_components {
41 hasher.update([0]);
42 hasher.update(component.as_bytes());
43 }
44 let digest = format!("{:x}", hasher.finalize());
45
46 format!(
47 "drawer_{}_{}_{}",
48 sanitize_component(wing),
49 sanitize_component(room),
50 &digest[..BOOTSTRAP_DRAWER_ID_HASH_LEN]
51 )
52}
53
54pub fn bootstrap_identity_components(parts: BootstrapIdentityParts<'_>) -> Vec<String> {
55 let supporting_refs = normalized_sorted_strings(parts.supporting_refs);
56 let counterexample_refs = normalized_sorted_strings(parts.counterexample_refs);
57 let teaching_refs = normalized_sorted_strings(parts.teaching_refs);
58 let verification_refs = normalized_sorted_strings(parts.verification_refs);
59 let mut components = vec![
60 format!("memory_kind={}", memory_kind_as_str(parts.memory_kind)),
61 format!("domain={}", memory_domain_as_str(parts.domain)),
62 format!("field={}", parts.field),
63 format!("anchor_kind={}", anchor_kind_as_str(parts.anchor_kind)),
64 format!("anchor_id={}", parts.anchor_id),
65 format!("parent_anchor_id={}", parts.parent_anchor_id.unwrap_or("")),
66 format!(
67 "provenance={}",
68 parts.provenance.map(provenance_as_str).unwrap_or("")
69 ),
70 format!("statement={}", parts.statement.unwrap_or("")),
71 format!(
72 "tier={}",
73 parts.tier.map(knowledge_tier_as_str).unwrap_or("")
74 ),
75 format!(
76 "status={}",
77 parts.status.map(knowledge_status_as_str).unwrap_or("")
78 ),
79 format!(
80 "scope_constraints={}",
81 parts.scope_constraints.unwrap_or("")
82 ),
83 format!("supporting_refs={}", supporting_refs.join(",")),
84 format!("counterexample_refs={}", counterexample_refs.join(",")),
85 format!("teaching_refs={}", teaching_refs.join(",")),
86 format!("verification_refs={}", verification_refs.join(",")),
87 ];
88
89 if let Some(trigger_hints) = parts.trigger_hints {
90 components.push(format!(
91 "intent_tags={}",
92 normalized_sorted_strings(&trigger_hints.intent_tags).join(",")
93 ));
94 components.push(format!(
95 "workflow_bias={}",
96 normalized_sorted_strings(&trigger_hints.workflow_bias).join(",")
97 ));
98 components.push(format!(
99 "tool_needs={}",
100 normalized_sorted_strings(&trigger_hints.tool_needs).join(",")
101 ));
102 }
103
104 components
105}
106
107pub fn build_bootstrap_drawer_id_from_parts(
108 wing: &str,
109 room: Option<&str>,
110 content: &str,
111 parts: BootstrapIdentityParts<'_>,
112) -> String {
113 build_bootstrap_drawer_id(wing, room, content, &bootstrap_identity_components(parts))
114}
115
116pub fn source_file_identity_component(source_file: Option<&str>) -> String {
117 format!(
118 "source_file={}",
119 source_file
120 .map(str::trim)
121 .filter(|value| !value.is_empty())
122 .unwrap_or("")
123 )
124}
125
126pub fn build_bootstrap_drawer_id_from_parts_with_source_file(
127 wing: &str,
128 room: Option<&str>,
129 content: &str,
130 parts: BootstrapIdentityParts<'_>,
131 source_file: Option<&str>,
132) -> String {
133 let mut components = bootstrap_identity_components(parts);
134 components.push(source_file_identity_component(source_file));
135 build_bootstrap_drawer_id(wing, room, content, &components)
136}
137
138pub fn build_bootstrap_evidence_drawer_id(
139 wing: &str,
140 room: Option<&str>,
141 content: &str,
142 source_type: &SourceType,
143 source_file: Option<&str>,
144) -> String {
145 let defaults = anchor::bootstrap_defaults(source_type);
146 let memory_kind = MemoryKind::Evidence;
147 let domain = MemoryDomain::Project;
148 let empty_refs: &[String] = &[];
149 build_bootstrap_drawer_id_from_parts_with_source_file(
150 wing,
151 room,
152 content,
153 BootstrapIdentityParts {
154 memory_kind: &memory_kind,
155 domain: &domain,
156 field: &defaults.field,
157 anchor_kind: &defaults.anchor_kind,
158 anchor_id: &defaults.anchor_id,
159 parent_anchor_id: defaults.parent_anchor_id.as_deref(),
160 provenance: Some(&defaults.provenance),
161 statement: None,
162 tier: None,
163 status: None,
164 supporting_refs: empty_refs,
165 counterexample_refs: empty_refs,
166 teaching_refs: empty_refs,
167 verification_refs: empty_refs,
168 scope_constraints: None,
169 trigger_hints: None,
170 },
171 source_file,
172 )
173}
174
175pub fn build_triple_id(subject: &str, predicate: &str, object: &str) -> String {
176 let mut hasher = Sha256::new();
177 hasher.update(subject.as_bytes());
178 hasher.update([0]);
179 hasher.update(predicate.as_bytes());
180 hasher.update([0]);
181 hasher.update(object.as_bytes());
182 let digest = format!("{:x}", hasher.finalize());
183
184 format!(
185 "triple_{}_{}_{}",
186 sanitize_component_prefix(subject, 8),
187 sanitize_component_prefix(predicate, 8),
188 &digest[..8]
189 )
190}
191
192pub fn build_tunnel_id(left: &TunnelEndpoint, right: &TunnelEndpoint) -> String {
193 let mut endpoints = [tunnel_endpoint_key(left), tunnel_endpoint_key(right)];
194 endpoints.sort();
195
196 let mut hasher = Sha256::new();
197 for component in [
198 endpoints[0].0.as_str(),
199 endpoints[0].1.as_str(),
200 endpoints[1].0.as_str(),
201 endpoints[1].1.as_str(),
202 ] {
203 hasher.update([0]);
204 hasher.update(component.as_bytes());
205 }
206 let digest = format!("{:x}", hasher.finalize());
207 format!("tunnel_{}", &digest[..16])
208}
209
210pub fn format_tunnel_endpoint(endpoint: &TunnelEndpoint) -> String {
211 match endpoint.room.as_deref() {
212 Some(room) if !room.is_empty() => format!("{}:{room}", endpoint.wing),
213 _ => endpoint.wing.clone(),
214 }
215}
216
217pub fn current_timestamp() -> String {
218 match SystemTime::now().duration_since(UNIX_EPOCH) {
219 Ok(duration) => duration.as_secs().to_string(),
220 Err(_) => "0".to_string(),
221 }
222}
223
224pub fn synthetic_source_file(drawer_id: &str) -> String {
225 format!("mempal://drawer/{drawer_id}")
226}
227
228pub fn knowledge_source_file(
229 domain: &MemoryDomain,
230 field: &str,
231 tier: &KnowledgeTier,
232 statement: &str,
233) -> String {
234 format!(
235 "knowledge://{}/{}/{}/{}",
236 enum_slug(memory_domain_as_str(domain)),
237 slugify_uri_component(field),
238 knowledge_tier_as_str(tier),
239 slugify_uri_component(statement)
240 )
241}
242
243pub fn source_file_or_synthetic(drawer_id: &str, source_file: Option<&str>) -> String {
244 source_file
245 .map(str::trim)
246 .filter(|value| !value.is_empty())
247 .map(ToOwned::to_owned)
248 .unwrap_or_else(|| synthetic_source_file(drawer_id))
249}
250
251pub fn route_room_from_taxonomy(content: &str, wing: &str, taxonomy: &[TaxonomyEntry]) -> String {
252 let normalized_content = content.to_lowercase();
253 let content_terms = content_terms(&normalized_content);
254
255 taxonomy
256 .iter()
257 .filter(|entry| entry.wing == wing)
258 .filter_map(|entry| {
259 let matched_keywords = matched_keywords(&normalized_content, &content_terms, entry);
260 (!matched_keywords.is_empty()).then_some((entry, matched_keywords))
261 })
262 .max_by(|(left_entry, left_matches), (right_entry, right_matches)| {
263 left_matches
264 .len()
265 .cmp(&right_matches.len())
266 .then_with(|| {
267 left_matches
268 .iter()
269 .map(String::len)
270 .sum::<usize>()
271 .cmp(&right_matches.iter().map(String::len).sum::<usize>())
272 })
273 .then_with(|| left_entry.keywords.len().cmp(&right_entry.keywords.len()))
274 })
275 .map(|(entry, _)| {
276 if entry.room.trim().is_empty() {
277 DEFAULT_ROOM.to_string()
278 } else {
279 entry.room.clone()
280 }
281 })
282 .unwrap_or_else(|| DEFAULT_ROOM.to_string())
283}
284
285fn tunnel_endpoint_key(endpoint: &TunnelEndpoint) -> (String, String) {
286 (
287 endpoint.wing.trim().to_string(),
288 endpoint
289 .room
290 .as_deref()
291 .map(str::trim)
292 .filter(|room| !room.is_empty())
293 .unwrap_or("")
294 .to_string(),
295 )
296}
297
298fn sanitize_component(value: &str) -> String {
299 value
300 .chars()
301 .map(|ch| {
302 if ch.is_ascii_alphanumeric() {
303 ch.to_ascii_lowercase()
304 } else {
305 '_'
306 }
307 })
308 .collect()
309}
310
311fn sanitize_component_prefix(value: &str, max_len: usize) -> String {
312 let sanitized = sanitize_component(value);
313 let prefix: String = sanitized.chars().take(max_len).collect();
314 if prefix.is_empty() {
315 "x".to_string()
316 } else {
317 prefix
318 }
319}
320
321pub fn slugify_uri_component(value: &str) -> String {
322 let mut slug = String::new();
323 let mut prev_dash = false;
324
325 for ch in value.chars() {
326 if ch.is_ascii_alphanumeric() {
327 slug.push(ch.to_ascii_lowercase());
328 prev_dash = false;
329 } else if !prev_dash {
330 slug.push('-');
331 prev_dash = true;
332 }
333 }
334
335 let trimmed = slug.trim_matches('-');
336 if trimmed.is_empty() {
337 "x".to_string()
338 } else {
339 trimmed.to_string()
340 }
341}
342
343fn enum_slug(value: &str) -> String {
344 value.replace('_', "-")
345}
346
347fn memory_kind_as_str(kind: &MemoryKind) -> &'static str {
348 match kind {
349 MemoryKind::Evidence => "evidence",
350 MemoryKind::Knowledge => "knowledge",
351 }
352}
353
354fn memory_domain_as_str(domain: &MemoryDomain) -> &'static str {
355 match domain {
356 MemoryDomain::Project => "project",
357 MemoryDomain::Agent => "agent",
358 MemoryDomain::Skill => "skill",
359 MemoryDomain::Global => "global",
360 }
361}
362
363fn anchor_kind_as_str(kind: &AnchorKind) -> &'static str {
364 match kind {
365 AnchorKind::Global => "global",
366 AnchorKind::Repo => "repo",
367 AnchorKind::Worktree => "worktree",
368 }
369}
370
371fn provenance_as_str(provenance: &Provenance) -> &'static str {
372 match provenance {
373 Provenance::Runtime => "runtime",
374 Provenance::Research => "research",
375 Provenance::Human => "human",
376 }
377}
378
379fn knowledge_tier_as_str(tier: &KnowledgeTier) -> &'static str {
380 match tier {
381 KnowledgeTier::Qi => "qi",
382 KnowledgeTier::Shu => "shu",
383 KnowledgeTier::DaoRen => "dao_ren",
384 KnowledgeTier::DaoTian => "dao_tian",
385 }
386}
387
388fn knowledge_status_as_str(status: &KnowledgeStatus) -> &'static str {
389 match status {
390 KnowledgeStatus::Candidate => "candidate",
391 KnowledgeStatus::Promoted => "promoted",
392 KnowledgeStatus::Canonical => "canonical",
393 KnowledgeStatus::Demoted => "demoted",
394 KnowledgeStatus::Retired => "retired",
395 }
396}
397
398fn normalized_sorted_strings(values: &[String]) -> Vec<String> {
399 let mut normalized = values
400 .iter()
401 .map(|value| value.trim())
402 .filter(|value| !value.is_empty())
403 .map(ToOwned::to_owned)
404 .collect::<Vec<_>>();
405 normalized.sort();
406 normalized
407}
408
409fn matched_keywords(
410 normalized_content: &str,
411 content_terms: &BTreeSet<String>,
412 entry: &TaxonomyEntry,
413) -> Vec<String> {
414 entry
415 .keywords
416 .iter()
417 .map(|keyword| keyword.trim().to_lowercase())
418 .filter(|keyword| {
419 !keyword.is_empty()
420 && (content_terms.contains(keyword)
421 || normalized_content.contains(keyword.as_str()))
422 })
423 .collect()
424}
425
426fn content_terms(content: &str) -> BTreeSet<String> {
427 content
428 .split(|ch: char| !ch.is_alphanumeric())
429 .filter(|term| !term.is_empty())
430 .map(ToOwned::to_owned)
431 .collect()
432}