Skip to main content

qualia_client_core/
directory.rs

1//! Personal directory — an **"Active Directory"-like** service for a person's relationships.
2//!
3//! It hosts the **addressbook** (Parties) organised into **categories** (organisational units / groups —
4//! a Party may be in several, like AD groups), and is the home for the **agreements** governing each
5//! relationship. It unifies the two pre-existing stores — directory [`Actor`](crate::state::Actor)s and
6//! chat [`ChatContact`](crate::social_connect::ChatContact)s — into ONE categorised view joined by pairwise
7//! DID, **without a destructive migration**: it reads both and persists only the additive parts (custom
8//! categories + per-entry category assignments) in their own files under [`app_meta_dir`].
9//!
10//! Agreement links are surfaced per entry from the first-class agreement store ([`crate::agreements`]) —
11//! see `docs/plans/rights-aware-peer-agreement-addressbook.md`. This module is the **P0** foundation of
12//! that plan (the directory that hosts the addressbook); the agreement store is **P1**, and
13//! [`build_view_core`] joins the two by DID so each Party carries the ids of the Agreements governing its
14//! relationship.
15
16use 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/// A directory category — an organisational unit / group (AD-style). Built-in ones are always present;
27/// users can add their own.
28#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
29pub struct DirectoryCategory {
30    pub id: String,
31    pub label: String,
32    /// A grouping/iconography hint (not a hard type).
33    pub kind: String,
34    pub builtin: bool,
35}
36
37/// The built-in categories. `people` is the catch-all every entry falls into.
38pub 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/// One unified addressbook entry (a Party), joined across the directory-actor + chat-contact stores by DID.
56#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
57pub struct DirectoryEntry {
58    /// Pairwise DID — the join key and the party's identifier.
59    pub did: String,
60    pub display_name: String,
61    /// actor_type / roles / contact tags — descriptive labels, not a hard type.
62    pub kinds: Vec<String>,
63    pub organization: Option<String>,
64    pub verification_status: String,
65    pub front_door_did: Option<String>,
66    /// Which store(s) this entry was found in ("directory-actor", "contact").
67    pub sources: Vec<String>,
68    /// Category ids this entry belongs to (an entry may be in several).
69    pub categories: Vec<String>,
70    /// Agreements governing this relationship (ids). Empty until the agreement store lands (plan P1).
71    pub agreement_ids: Vec<String>,
72}
73
74/// The whole categorised directory returned to the UI.
75#[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
120/// All categories: built-in + user-created.
121pub 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
147/// Create a custom category. Idempotent by slug; errors if the slug already exists.
148pub 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
172/// Set the categories an entry (by DID) belongs to. Unknown category ids are dropped; an empty set clears
173/// the explicit assignment (the entry falls back to inferred categories).
174pub 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
191/// Default category inference from an entry's kinds/organisation (used when no explicit assignment exists).
192fn 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
237/// Pure core: merge directory actors + chat contacts into one categorised view, given the persisted
238/// category assignments and the category list. Kept pure (no filesystem) so it is deterministically
239/// testable; [`build_view`] wires it to the persisted stores.
240pub 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            // Join P1: the agreements governing this relationship — the entry is a party to them, or the
307            // agreement names this DID as the relationship it governs.
308            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
328/// Build the unified, categorised directory view over the persisted directory-actor + chat-contact stores,
329/// joined against the persisted agreement store so each entry carries its governing agreement ids.
330pub 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// ===========================================================================
341// Faceted + concept-aware search — for a directory that grows large over time.
342// ===========================================================================
343
344/// One selectable value within a facet, with its count in the current result and whether it's selected.
345#[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/// A facet (a filterable dimension of the directory) and its values.
354#[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/// A search result: the ranked matching entries, the facet counts (drill-down), and the categories.
362#[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
371/// The facet dimensions, in display order.
372const FACET_IDS: [&str; 5] = ["category", "kind", "source", "verification", "agreements"];
373
374/// Concept clusters for **meaning-aware search**: a query token that hits any member of a cluster matches
375/// the whole cluster, so "doctor" finds a "clinician". This is honest concept expansion over the
376/// role/category space — NOT embedding similarity. (The scale path — entries as quins queried through the
377/// semantic graph engine / embeddings — is noted in the plan.)
378fn 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
418/// Expand a single query token to itself plus any concept cluster it belongs to.
419fn 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
497/// Score an entry against the expanded query tokens. `None` = no match (AND across tokens); higher = better
498/// (name-field hits weighted heavier). An empty query matches everything with score 0.
499fn 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; // every query token must match (recall comes from concept expansion)
515        }
516    }
517    Some(score)
518}
519
520/// Does the entry pass the selected facets (AND across facet groups, OR within a group)? `except` skips one
521/// group — used to compute drill-down counts for that group.
522fn 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
539/// Pure search core: rank entries by a concept-expanded query and narrow by selected facets, returning the
540/// entries plus drill-down facet counts. Kept pure (no filesystem) for deterministic testing.
541pub 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    // Entries matching the text query (facets not yet applied), with scores.
550    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    // Facet counts: for each group, count over (query-matched ∩ every OTHER selected group) — the standard
559    // drill-down count so selecting a value shows how many you'd get.
560    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    // Narrow by all selected facets, then rank (score desc, then name).
600    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
623/// Search the persisted directory: build the unified view, then run [`search_core`].
624pub 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        // Everyone is in People.
679        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"); // sorted case-insensitively
728        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        // One agreement governs the relationship with Dr Smith (as relationship_did AND as a party);
773        // none touches Bob.
774        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        // The 'agreements' facet now distinguishes with/without.
798        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        // "doctor" is not a literal token on the entry, but it shares a concept cluster with "clinician".
825        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        // An unrelated concept does not match.
836        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            ), // → health
867            actor("a2", "Bob", "did:wf:bob", "FRIEND", &[]), // → family-friends
868        ];
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        // Selecting the health category narrows to just the clinician.
884        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        // The category facet still reports drill-down counts (health selected, count preserved).
890        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}