1use std::collections::BTreeMap;
17use std::fs;
18use std::path::PathBuf;
19
20use serde::{Deserialize, Serialize};
21
22use crate::agreements::Agreement;
23use crate::social_connect::ChatContact;
24use crate::state::{app_meta_dir, Actor};
25
26#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
29pub struct DirectoryCategory {
30 pub id: String,
31 pub label: String,
32 pub kind: String,
34 pub builtin: bool,
35}
36
37pub fn builtin_categories() -> Vec<DirectoryCategory> {
39 let mk = |id: &str, label: &str, kind: &str| DirectoryCategory {
40 id: id.into(),
41 label: label.into(),
42 kind: kind.into(),
43 builtin: true,
44 };
45 vec![
46 mk("people", "People", "people"),
47 mk("health", "Health practitioners", "health"),
48 mk("cooperative", "Cooperative", "cooperative"),
49 mk("organizations", "Organizations", "organization"),
50 mk("agents", "Agents", "agent"),
51 mk("family-friends", "Family & friends", "personal"),
52 ]
53}
54
55#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
57pub struct DirectoryEntry {
58 pub did: String,
60 pub display_name: String,
61 pub kinds: Vec<String>,
63 pub organization: Option<String>,
64 pub verification_status: String,
65 pub front_door_did: Option<String>,
66 pub sources: Vec<String>,
68 pub categories: Vec<String>,
70 pub agreement_ids: Vec<String>,
72}
73
74#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
76pub struct DirectoryView {
77 pub categories: Vec<DirectoryCategory>,
78 pub entries: Vec<DirectoryEntry>,
79}
80
81fn categories_path() -> PathBuf {
82 app_meta_dir().join("directory_categories.json")
83}
84fn assignments_path() -> PathBuf {
85 app_meta_dir().join("directory_assignments.json")
86}
87
88fn load_custom_categories() -> Vec<DirectoryCategory> {
89 fs::read_to_string(categories_path())
90 .ok()
91 .and_then(|t| serde_json::from_str(&t).ok())
92 .unwrap_or_default()
93}
94
95fn save_custom_categories(cats: &[DirectoryCategory]) -> Result<(), String> {
96 let path = categories_path();
97 if let Some(p) = path.parent() {
98 fs::create_dir_all(p).map_err(|e| e.to_string())?;
99 }
100 let text = serde_json::to_string_pretty(cats).map_err(|e| e.to_string())?;
101 fs::write(path, text).map_err(|e| e.to_string())
102}
103
104fn load_assignments() -> BTreeMap<String, Vec<String>> {
105 fs::read_to_string(assignments_path())
106 .ok()
107 .and_then(|t| serde_json::from_str(&t).ok())
108 .unwrap_or_default()
109}
110
111fn save_assignments(a: &BTreeMap<String, Vec<String>>) -> Result<(), String> {
112 let path = assignments_path();
113 if let Some(p) = path.parent() {
114 fs::create_dir_all(p).map_err(|e| e.to_string())?;
115 }
116 let text = serde_json::to_string_pretty(a).map_err(|e| e.to_string())?;
117 fs::write(path, text).map_err(|e| e.to_string())
118}
119
120pub fn list_categories() -> Vec<DirectoryCategory> {
122 let mut cats = builtin_categories();
123 for c in load_custom_categories() {
124 if !cats.iter().any(|e| e.id == c.id) {
125 cats.push(c);
126 }
127 }
128 cats
129}
130
131fn slugify(s: &str) -> String {
132 s.chars()
133 .map(|c| {
134 if c.is_ascii_alphanumeric() {
135 c.to_ascii_lowercase()
136 } else {
137 '-'
138 }
139 })
140 .collect::<String>()
141 .split('-')
142 .filter(|x| !x.is_empty())
143 .collect::<Vec<_>>()
144 .join("-")
145}
146
147pub fn create_category(label: &str) -> Result<DirectoryCategory, String> {
149 let label = label.trim();
150 if label.is_empty() {
151 return Err("category label is empty".into());
152 }
153 let id = slugify(label);
154 if id.is_empty() {
155 return Err("category label has no usable characters".into());
156 }
157 if list_categories().iter().any(|c| c.id == id) {
158 return Err(format!("a category '{label}' already exists"));
159 }
160 let cat = DirectoryCategory {
161 id,
162 label: label.to_string(),
163 kind: "custom".into(),
164 builtin: false,
165 };
166 let mut custom = load_custom_categories();
167 custom.push(cat.clone());
168 save_custom_categories(&custom)?;
169 Ok(cat)
170}
171
172pub fn set_entry_categories(did: &str, categories: Vec<String>) -> Result<(), String> {
175 let valid: std::collections::HashSet<String> =
176 list_categories().into_iter().map(|c| c.id).collect();
177 let cleaned: Vec<String> = categories
178 .into_iter()
179 .map(|c| c.trim().to_string())
180 .filter(|c| valid.contains(c))
181 .collect();
182 let mut a = load_assignments();
183 if cleaned.is_empty() {
184 a.remove(did);
185 } else {
186 a.insert(did.to_string(), cleaned);
187 }
188 save_assignments(&a)
189}
190
191fn infer_categories(kinds: &[String], organization: &Option<String>) -> Vec<String> {
193 let hay = kinds.join(" ").to_lowercase();
194 let has = |needle: &str| hay.contains(needle);
195 let mut cats = vec!["people".to_string()];
196 if has("agent") {
197 cats.push("agents".into());
198 }
199 if has("clinician")
200 || has("practitioner")
201 || has("doctor")
202 || has("health")
203 || has("nurse")
204 || has("therapist")
205 {
206 cats.push("health".into());
207 }
208 if has("cooperative") || has("coop") {
209 cats.push("cooperative".into());
210 }
211 if has("friend") || has("family") {
212 cats.push("family-friends".into());
213 }
214 if organization.is_some() || has("organization") || has("org") {
215 cats.push("organizations".into());
216 }
217 cats.sort();
218 cats.dedup();
219 cats
220}
221
222fn merge_kinds(dst: &mut Vec<String>, src: Vec<String>) {
223 for s in src {
224 let s = s.trim().to_string();
225 if !s.is_empty() && !dst.iter().any(|d| d.eq_ignore_ascii_case(&s)) {
226 dst.push(s);
227 }
228 }
229}
230
231fn push_unique(dst: &mut Vec<String>, v: &str) {
232 if !dst.iter().any(|d| d == v) {
233 dst.push(v.to_string());
234 }
235}
236
237pub fn build_view_core(
241 actors: &[Actor],
242 contacts: &[ChatContact],
243 assignments: &BTreeMap<String, Vec<String>>,
244 categories: Vec<DirectoryCategory>,
245 agreements: &[Agreement],
246) -> DirectoryView {
247 let mut by_did: BTreeMap<String, DirectoryEntry> = BTreeMap::new();
248
249 for a in actors {
250 let did = if a.pairwise_did.is_empty() {
251 a.id.clone()
252 } else {
253 a.pairwise_did.clone()
254 };
255 let entry = by_did.entry(did.clone()).or_insert_with(|| DirectoryEntry {
256 did: did.clone(),
257 display_name: a.name.clone(),
258 kinds: vec![],
259 organization: a.organization.clone(),
260 verification_status: a.verification_status.clone(),
261 front_door_did: a.root_did_uri.clone(),
262 sources: vec![],
263 categories: vec![],
264 agreement_ids: vec![],
265 });
266 if entry.display_name.is_empty() {
267 entry.display_name = a.name.clone();
268 }
269 if entry.organization.is_none() {
270 entry.organization = a.organization.clone();
271 }
272 let mut kinds = vec![a.actor_type.clone()];
273 kinds.extend(a.roles.iter().cloned());
274 merge_kinds(&mut entry.kinds, kinds);
275 push_unique(&mut entry.sources, "directory-actor");
276 }
277
278 for c in contacts {
279 let entry = by_did
280 .entry(c.did.clone())
281 .or_insert_with(|| DirectoryEntry {
282 did: c.did.clone(),
283 display_name: c.display_name.clone(),
284 kinds: vec![],
285 organization: None,
286 verification_status: "INVITE_ACCEPTED".into(),
287 front_door_did: None,
288 sources: vec![],
289 categories: vec![],
290 agreement_ids: vec![],
291 });
292 if entry.display_name.is_empty() {
293 entry.display_name = c.display_name.clone();
294 }
295 merge_kinds(&mut entry.kinds, c.categories.clone());
296 push_unique(&mut entry.sources, "contact");
297 }
298
299 let mut entries: Vec<DirectoryEntry> = by_did
300 .into_values()
301 .map(|mut e| {
302 e.categories = match assignments.get(&e.did) {
303 Some(cats) if !cats.is_empty() => cats.clone(),
304 _ => infer_categories(&e.kinds, &e.organization),
305 };
306 e.agreement_ids = agreements
309 .iter()
310 .filter(|a| a.relationship_did == e.did || a.parties.iter().any(|p| *p == e.did))
311 .map(|a| a.id.clone())
312 .collect();
313 e
314 })
315 .collect();
316 entries.sort_by(|a, b| {
317 a.display_name
318 .to_lowercase()
319 .cmp(&b.display_name.to_lowercase())
320 });
321
322 DirectoryView {
323 categories,
324 entries,
325 }
326}
327
328pub fn build_view(actors: &[Actor], contacts: &[ChatContact]) -> DirectoryView {
331 build_view_core(
332 actors,
333 contacts,
334 &load_assignments(),
335 list_categories(),
336 &crate::agreements::list_agreements(),
337 )
338}
339
340#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
346pub struct FacetValue {
347 pub value: String,
348 pub label: String,
349 pub count: usize,
350 pub selected: bool,
351}
352
353#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
355pub struct Facet {
356 pub id: String,
357 pub label: String,
358 pub values: Vec<FacetValue>,
359}
360
361#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
363pub struct DirectorySearchResult {
364 pub categories: Vec<DirectoryCategory>,
365 pub facets: Vec<Facet>,
366 pub entries: Vec<DirectoryEntry>,
367 pub total: usize,
368 pub query: String,
369}
370
371const FACET_IDS: [&str; 5] = ["category", "kind", "source", "verification", "agreements"];
373
374fn concept_clusters() -> &'static [&'static [&'static str]] {
379 &[
380 &[
381 "doctor",
382 "clinician",
383 "physician",
384 "gp",
385 "practitioner",
386 "medic",
387 "medical",
388 "health",
389 "nurse",
390 "therapist",
391 "care",
392 "psychiatrist",
393 "psychologist",
394 "counsellor",
395 ],
396 &[
397 "cooperative",
398 "coop",
399 "co-op",
400 "member",
401 "collective",
402 "union",
403 ],
404 &["friend", "family", "personal", "kin", "mate"],
405 &[
406 "organization",
407 "organisation",
408 "org",
409 "company",
410 "institution",
411 "business",
412 "ngo",
413 ],
414 &["agent", "ai", "bot", "assistant", "subagent", "sub-agent"],
415 ]
416}
417
418fn expand_token(tok: &str) -> Vec<String> {
420 let t = tok.trim().to_lowercase();
421 let mut out = vec![t.clone()];
422 if !t.is_empty() {
423 for cluster in concept_clusters() {
424 if cluster.iter().any(|w| *w == t) {
425 out.extend(cluster.iter().map(|w| w.to_string()));
426 }
427 }
428 }
429 out.sort();
430 out.dedup();
431 out
432}
433
434fn facet_label(id: &str) -> &'static str {
435 match id {
436 "category" => "Category",
437 "kind" => "Kind",
438 "source" => "Source",
439 "verification" => "Verification",
440 "agreements" => "Agreements",
441 _ => "",
442 }
443}
444
445fn entry_facet_values(e: &DirectoryEntry, facet: &str) -> Vec<String> {
446 match facet {
447 "category" => e.categories.clone(),
448 "kind" => e.kinds.clone(),
449 "source" => e.sources.clone(),
450 "verification" => vec![e.verification_status.clone()],
451 "agreements" => vec![if e.agreement_ids.is_empty() {
452 "without".to_string()
453 } else {
454 "with".to_string()
455 }],
456 _ => vec![],
457 }
458}
459
460fn value_label(fid: &str, value: &str, categories: &[DirectoryCategory]) -> String {
461 match fid {
462 "category" => categories
463 .iter()
464 .find(|c| c.id == value)
465 .map(|c| c.label.clone())
466 .unwrap_or_else(|| value.to_string()),
467 "agreements" => {
468 if value == "with" {
469 "With agreements".to_string()
470 } else {
471 "Without agreements".to_string()
472 }
473 }
474 _ => value.to_string(),
475 }
476}
477
478fn searchable_text(e: &DirectoryEntry, categories: &[DirectoryCategory]) -> String {
479 let mut parts = vec![
480 e.display_name.clone(),
481 e.did.clone(),
482 e.verification_status.clone(),
483 ];
484 if let Some(o) = &e.organization {
485 parts.push(o.clone());
486 }
487 parts.extend(e.kinds.iter().cloned());
488 for c in &e.categories {
489 parts.push(c.clone());
490 if let Some(cat) = categories.iter().find(|x| &x.id == c) {
491 parts.push(cat.label.clone());
492 }
493 }
494 parts.join(" ").to_lowercase()
495}
496
497fn query_score(text: &str, name: &str, expanded: &[Vec<String>]) -> Option<i32> {
500 if expanded.is_empty() {
501 return Some(0);
502 }
503 let name_l = name.to_lowercase();
504 let mut score = 0i32;
505 for token_exp in expanded {
506 let mut hit = false;
507 for w in token_exp {
508 if !w.is_empty() && text.contains(w.as_str()) {
509 hit = true;
510 score += if name_l.contains(w.as_str()) { 3 } else { 1 };
511 }
512 }
513 if !hit {
514 return None; }
516 }
517 Some(score)
518}
519
520fn passes_facets(
523 e: &DirectoryEntry,
524 selected: &BTreeMap<String, Vec<String>>,
525 except: Option<&str>,
526) -> bool {
527 for (fid, vals) in selected {
528 if vals.is_empty() || Some(fid.as_str()) == except {
529 continue;
530 }
531 let ev = entry_facet_values(e, fid);
532 if !vals.iter().any(|v| ev.iter().any(|x| x == v)) {
533 return false;
534 }
535 }
536 true
537}
538
539pub fn search_core(
542 entries: Vec<DirectoryEntry>,
543 categories: Vec<DirectoryCategory>,
544 query: &str,
545 selected: &BTreeMap<String, Vec<String>>,
546) -> DirectorySearchResult {
547 let expanded: Vec<Vec<String>> = query.split_whitespace().map(expand_token).collect();
548
549 let scored: Vec<(DirectoryEntry, i32)> = entries
551 .into_iter()
552 .filter_map(|e| {
553 let text = searchable_text(&e, &categories);
554 query_score(&text, &e.display_name, &expanded).map(|s| (e, s))
555 })
556 .collect();
557
558 let mut facets = Vec::new();
561 for fid in FACET_IDS {
562 let mut counts: BTreeMap<String, usize> = BTreeMap::new();
563 for (e, _) in scored
564 .iter()
565 .filter(|(e, _)| passes_facets(e, selected, Some(fid)))
566 {
567 for v in entry_facet_values(e, fid) {
568 *counts.entry(v).or_insert(0) += 1;
569 }
570 }
571 let sel = selected.get(fid).cloned().unwrap_or_default();
572 let mut values: Vec<FacetValue> = counts
573 .into_iter()
574 .map(|(value, count)| {
575 let label = value_label(fid, &value, &categories);
576 let is_sel = sel.iter().any(|x| x == &value);
577 FacetValue {
578 value,
579 label,
580 count,
581 selected: is_sel,
582 }
583 })
584 .collect();
585 values.sort_by(|a, b| {
586 b.count
587 .cmp(&a.count)
588 .then(a.label.to_lowercase().cmp(&b.label.to_lowercase()))
589 });
590 if !values.is_empty() {
591 facets.push(Facet {
592 id: fid.to_string(),
593 label: facet_label(fid).to_string(),
594 values,
595 });
596 }
597 }
598
599 let mut narrowed: Vec<(DirectoryEntry, i32)> = scored
601 .into_iter()
602 .filter(|(e, _)| passes_facets(e, selected, None))
603 .collect();
604 narrowed.sort_by(|a, b| {
605 b.1.cmp(&a.1).then(
606 a.0.display_name
607 .to_lowercase()
608 .cmp(&b.0.display_name.to_lowercase()),
609 )
610 });
611 let entries: Vec<DirectoryEntry> = narrowed.into_iter().map(|(e, _)| e).collect();
612 let total = entries.len();
613
614 DirectorySearchResult {
615 categories,
616 facets,
617 entries,
618 total,
619 query: query.to_string(),
620 }
621}
622
623pub fn search(
625 actors: &[Actor],
626 contacts: &[ChatContact],
627 query: &str,
628 selected: &BTreeMap<String, Vec<String>>,
629) -> DirectorySearchResult {
630 let view = build_view(actors, contacts);
631 search_core(view.entries, view.categories, query, selected)
632}
633
634#[cfg(test)]
635mod tests {
636 use super::*;
637
638 fn actor(id: &str, name: &str, did: &str, ty: &str, roles: &[&str]) -> Actor {
639 Actor {
640 id: id.into(),
641 actor_type: ty.into(),
642 name: name.into(),
643 organization: None,
644 qualifications: vec![],
645 roles: roles.iter().map(|s| s.to_string()).collect(),
646 verification_status: "VERIFIED".into(),
647 pairwise_did: did.into(),
648 root_did_uri: None,
649 routing_hints: vec![],
650 }
651 }
652
653 fn contact(name: &str, did: &str, categories: &[&str]) -> ChatContact {
654 ChatContact {
655 actor_id: format!("contact-{did}"),
656 display_name: name.into(),
657 did: did.into(),
658 source: "connect".into(),
659 added_at: 0,
660 relay_endpoint: None,
661 categories: categories.iter().map(|s| s.to_string()).collect(),
662 }
663 }
664
665 #[test]
666 fn slugify_makes_stable_ids() {
667 assert_eq!(slugify("Health Practitioners"), "health-practitioners");
668 assert_eq!(slugify(" My Co-op!! "), "my-co-op");
669 assert_eq!(slugify("A/B\\C"), "a-b-c");
670 }
671
672 #[test]
673 fn inference_routes_by_kind_and_org() {
674 assert!(infer_categories(&["clinician".into()], &None).contains(&"health".to_string()));
675 assert!(infer_categories(&["AGENT".into()], &None).contains(&"agents".to_string()));
676 assert!(infer_categories(&["FRIEND".into()], &None).contains(&"family-friends".to_string()));
677 assert!(infer_categories(&[], &Some("Acme".into())).contains(&"organizations".to_string()));
678 assert!(infer_categories(&[], &None).contains(&"people".to_string()));
680 }
681
682 #[test]
683 fn same_did_in_both_stores_merges_to_one_entry() {
684 let actors = vec![actor(
685 "a1",
686 "Dr Smith",
687 "did:wf:smith",
688 "PRACTITIONER",
689 &["clinician"],
690 )];
691 let contacts = vec![contact("Dr Smith", "did:wf:smith", &["health"])];
692 let view = build_view_core(
693 &actors,
694 &contacts,
695 &BTreeMap::new(),
696 builtin_categories(),
697 &[],
698 );
699 assert_eq!(
700 view.entries.len(),
701 1,
702 "one DID → one entry across both stores"
703 );
704 let e = &view.entries[0];
705 assert!(e.sources.contains(&"directory-actor".to_string()));
706 assert!(e.sources.contains(&"contact".to_string()));
707 assert!(e.categories.contains(&"health".to_string()));
708 }
709
710 #[test]
711 fn explicit_assignment_overrides_inference() {
712 let actors = vec![actor("a1", "Bob", "did:wf:bob", "FRIEND", &[])];
713 let mut assignments = BTreeMap::new();
714 assignments.insert("did:wf:bob".to_string(), vec!["cooperative".to_string()]);
715 let view = build_view_core(&actors, &[], &assignments, builtin_categories(), &[]);
716 assert_eq!(view.entries[0].categories, vec!["cooperative".to_string()]);
717 }
718
719 #[test]
720 fn distinct_dids_stay_separate_and_sorted() {
721 let actors = vec![
722 actor("a1", "Zed", "did:wf:z", "FRIEND", &[]),
723 actor("a2", "Ann", "did:wf:a", "FRIEND", &[]),
724 ];
725 let view = build_view_core(&actors, &[], &BTreeMap::new(), builtin_categories(), &[]);
726 assert_eq!(view.entries.len(), 2);
727 assert_eq!(view.entries[0].display_name, "Ann"); assert_eq!(view.entries[1].display_name, "Zed");
729 }
730
731 fn entries_of(actors: &[Actor], contacts: &[ChatContact]) -> Vec<DirectoryEntry> {
732 build_view_core(
733 actors,
734 contacts,
735 &BTreeMap::new(),
736 builtin_categories(),
737 &[],
738 )
739 .entries
740 }
741
742 fn agreement(id: &str, relationship_did: &str, parties: &[&str]) -> Agreement {
743 Agreement {
744 id: id.into(),
745 title: "Care relationship".into(),
746 relationship_did: relationship_did.into(),
747 parties: parties.iter().map(|s| s.to_string()).collect(),
748 values_anchors: vec!["urn:qualia:values:udhr".into()],
749 undertakings: vec![],
750 consents: vec![],
751 stage: crate::agreements::FormationStage::Draft,
752 jurisdiction: None,
753 intents: Vec::new(),
754 artifact_context: None,
755 created_at: 0,
756 updated_at: 0,
757 }
758 }
759
760 #[test]
761 fn agreements_join_onto_the_governed_party() {
762 let actors = vec![
763 actor(
764 "a1",
765 "Dr Smith",
766 "did:wf:smith",
767 "PRACTITIONER",
768 &["clinician"],
769 ),
770 actor("a2", "Bob", "did:wf:bob", "FRIEND", &[]),
771 ];
772 let ags = vec![agreement(
775 "ag-1",
776 "did:wf:smith",
777 &["did:wf:me", "did:wf:smith"],
778 )];
779 let view = build_view_core(&actors, &[], &BTreeMap::new(), builtin_categories(), &ags);
780
781 let smith = view
782 .entries
783 .iter()
784 .find(|e| e.did == "did:wf:smith")
785 .unwrap();
786 assert_eq!(
787 smith.agreement_ids,
788 vec!["ag-1".to_string()],
789 "agreement joins onto its party"
790 );
791 let bob = view.entries.iter().find(|e| e.did == "did:wf:bob").unwrap();
792 assert!(
793 bob.agreement_ids.is_empty(),
794 "unrelated party has no agreements"
795 );
796
797 let r = search_core(view.entries, builtin_categories(), "", &BTreeMap::new());
799 let facet = r
800 .facets
801 .iter()
802 .find(|f| f.id == "agreements")
803 .expect("agreements facet");
804 assert!(facet
805 .values
806 .iter()
807 .any(|v| v.value == "with" && v.count == 1));
808 assert!(facet
809 .values
810 .iter()
811 .any(|v| v.value == "without" && v.count == 1));
812 }
813
814 #[test]
815 fn concept_search_matches_by_meaning() {
816 let actors = vec![actor(
817 "a1",
818 "Dr Smith",
819 "did:wf:smith",
820 "PRACTITIONER",
821 &["clinician"],
822 )];
823 let entries = entries_of(&actors, &[]);
824 let hit = search_core(
826 entries.clone(),
827 builtin_categories(),
828 "doctor",
829 &BTreeMap::new(),
830 );
831 assert_eq!(
832 hit.total, 1,
833 "concept expansion: 'doctor' finds a 'clinician'"
834 );
835 let miss = search_core(
837 entries,
838 builtin_categories(),
839 "cooperative",
840 &BTreeMap::new(),
841 );
842 assert_eq!(miss.total, 0);
843 }
844
845 #[test]
846 fn query_tokens_are_anded() {
847 let actors = vec![
848 actor("a1", "Dr Smith", "did:wf:smith", "FRIEND", &[]),
849 actor("a2", "Dr Jones", "did:wf:jones", "FRIEND", &[]),
850 ];
851 let entries = entries_of(&actors, &[]);
852 let r = search_core(entries, builtin_categories(), "dr smith", &BTreeMap::new());
853 assert_eq!(r.total, 1);
854 assert_eq!(r.entries[0].display_name, "Dr Smith");
855 }
856
857 #[test]
858 fn facets_count_and_narrow() {
859 let actors = vec![
860 actor(
861 "a1",
862 "Dr Smith",
863 "did:wf:smith",
864 "PRACTITIONER",
865 &["clinician"],
866 ), actor("a2", "Bob", "did:wf:bob", "FRIEND", &[]), ];
869 let entries = entries_of(&actors, &[]);
870 let all = search_core(entries.clone(), builtin_categories(), "", &BTreeMap::new());
871 let cat_facet = all
872 .facets
873 .iter()
874 .find(|f| f.id == "category")
875 .expect("category facet");
876 let health = cat_facet
877 .values
878 .iter()
879 .find(|v| v.value == "health")
880 .expect("health value");
881 assert_eq!(health.count, 1);
882
883 let mut sel = BTreeMap::new();
885 sel.insert("category".to_string(), vec!["health".to_string()]);
886 let narrowed = search_core(entries, builtin_categories(), "", &sel);
887 assert_eq!(narrowed.total, 1);
888 assert_eq!(narrowed.entries[0].display_name, "Dr Smith");
889 let cat_facet = narrowed.facets.iter().find(|f| f.id == "category").unwrap();
891 assert!(cat_facet
892 .values
893 .iter()
894 .any(|v| v.value == "health" && v.selected));
895 }
896}