Skip to main content

vfa_tui/catalog/
store.rs

1use std::collections::{HashMap, HashSet};
2use std::path::{Path, PathBuf};
3
4use sha2::{Digest, Sha256};
5
6use crate::error::TuiError;
7use crate::models::{
8    Agent, AssetIntegrity, McpReference, ModelAssignments, ModelRegistry, Role, Rule, Skill,
9    WorkflowCatalog,
10};
11
12use super::loader;
13
14// ---------------------------------------------------------------------------
15// ReloadOutcome
16// ---------------------------------------------------------------------------
17
18/// Outcome returned by [`CatalogStore::reload_file`].
19#[derive(Debug)]
20pub enum ReloadOutcome {
21    /// The file was re-parsed successfully and the named catalog was updated.
22    Reloaded { catalog: String },
23    /// The new content failed to parse; the previous valid state was retained.
24    RetainedPrevious { error: String },
25    /// The file's content hash matched the already-loaded version — no update needed.
26    Unchanged,
27}
28
29// ---------------------------------------------------------------------------
30// EdgeKind
31// ---------------------------------------------------------------------------
32
33/// Classifies an edge in the catalog dependency graph.
34#[derive(Debug, Clone, PartialEq, Eq)]
35pub enum EdgeKind {
36    /// Agent → Skill via `companion_skills`.
37    AgentSkill,
38    /// Role → Agent via role membership.
39    RoleAgent,
40    /// Agent → McpReference (detected from harness variants / mcp_refs cross-ref).
41    AgentMcp,
42    /// Agent → Rule (agent is harness-specific and rule shares harness).
43    AgentRule,
44}
45
46// ---------------------------------------------------------------------------
47// CatalogStore
48// ---------------------------------------------------------------------------
49
50/// In-memory catalog store holding all loaded catalog data.
51pub struct CatalogStore {
52    pub agents: Vec<Agent>,
53    pub skills: Vec<Skill>,
54    pub roles: HashMap<String, Role>,
55    pub role_catalog_version: String,
56    pub role_catalog_description: String,
57    pub mcp_refs: Vec<McpReference>,
58    pub rules: Vec<Rule>,
59    pub integrity: Option<AssetIntegrity>,
60    /// Resolved per-agent/per-harness model + reasoning assignments
61    /// (catalog/model-assignments.json); absent on older checkouts.
62    pub model_assignments: Option<ModelAssignments>,
63    /// Verified model / reasoning-effort matrix (catalog/model-registry.json).
64    /// Drives the Model Policy builder's pickers; `None` degrades them to
65    /// free-text entry rather than blocking the view.
66    pub model_registry: Option<ModelRegistry>,
67    /// Workflows declared in `.claude/workflows/`, generated into
68    /// `catalog/workflows.json`; absent on checkouts without any workflow.
69    pub workflows: Option<WorkflowCatalog>,
70    pub load_errors: Vec<TuiError>,
71    /// SHA-256 hex digest of each catalog JSON file's raw bytes, keyed by absolute path.
72    pub content_hashes: HashMap<PathBuf, String>,
73    /// Root directory from which catalogs were loaded.
74    pub catalog_root: PathBuf,
75}
76
77/// Compute SHA-256 hex digest of a byte slice.
78fn sha256_hex(bytes: &[u8]) -> String {
79    use std::fmt::Write as _;
80    let mut hasher = Sha256::new();
81    hasher.update(bytes);
82    let result = hasher.finalize();
83    result.iter().fold(String::new(), |mut s, b| {
84        let _ = write!(s, "{b:02x}");
85        s
86    })
87}
88
89/// Compute SHA-256 hex digest of a file on disk; returns None on I/O error.
90fn hash_file(path: &Path) -> Option<String> {
91    let bytes = std::fs::read(path).ok()?;
92    Some(sha256_hex(&bytes))
93}
94
95impl CatalogStore {
96    /// Load all catalog files from the workspace root.
97    /// Sorts agents, skills, mcp_refs, and rules by ID (case-insensitive).
98    pub fn load(workspace_root: &Path) -> Self {
99        let mut load_errors = Vec::new();
100
101        let (mut agents, errs) = loader::load_agents(workspace_root);
102        load_errors.extend(errs);
103
104        let (mut skills, errs) = loader::load_skills(workspace_root);
105        load_errors.extend(errs);
106
107        let (mut mcp_refs, errs) = loader::load_mcp_refs(workspace_root);
108        load_errors.extend(errs);
109
110        let (mut rules, errs) = loader::load_rules(workspace_root);
111        load_errors.extend(errs);
112
113        let (role_catalog, errs) = loader::load_roles(workspace_root);
114        load_errors.extend(errs);
115
116        let (integrity, errs) = loader::load_integrity(workspace_root);
117        load_errors.extend(errs);
118
119        let (model_assignments, errs) = loader::load_model_assignments(workspace_root);
120        load_errors.extend(errs);
121
122        let (model_registry, errs) = loader::load_model_registry(workspace_root);
123        load_errors.extend(errs);
124
125        let (workflows, errs) = loader::load_workflows(workspace_root);
126        load_errors.extend(errs);
127
128        // Sort by ID, case-insensitive
129        agents.sort_by_key(|a| a.id.to_lowercase());
130        skills.sort_by_key(|a| a.id.to_lowercase());
131        mcp_refs.sort_by_key(|a| a.id.to_lowercase());
132        rules.sort_by_key(|a| a.id.to_lowercase());
133
134        let (roles, role_catalog_version, role_catalog_description) = match role_catalog {
135            Some(rc) => (rc.roles, rc.version, rc.description),
136            None => (HashMap::new(), String::new(), String::new()),
137        };
138
139        // Build content hashes for the catalog JSON files
140        let catalog_dir = workspace_root.join("catalog");
141        let mut content_hashes = HashMap::new();
142        for filename in &[
143            "agents.json",
144            "skills.json",
145            "mcp-references.json",
146            "rules.json",
147            "install-roles.json",
148            "asset-integrity.json",
149            "model-assignments.json",
150            "model-registry.json",
151            "workflows.json",
152        ] {
153            let path = catalog_dir.join(filename);
154            if let Some(hash) = hash_file(&path) {
155                content_hashes.insert(path, hash);
156            }
157        }
158
159        CatalogStore {
160            agents,
161            skills,
162            roles,
163            role_catalog_version,
164            role_catalog_description,
165            mcp_refs,
166            rules,
167            integrity,
168            model_assignments,
169            model_registry,
170            workflows,
171            load_errors,
172            content_hashes,
173            catalog_root: workspace_root.to_path_buf(),
174        }
175    }
176
177    /// Re-parse a single catalog file identified by its path.
178    ///
179    /// - If the file content hash is unchanged from the previously loaded hash,
180    ///   returns `ReloadOutcome::Unchanged`.
181    /// - If the file parses successfully, updates the corresponding in-memory
182    ///   slice and content hash, then returns `ReloadOutcome::Reloaded`.
183    /// - If the file fails to parse (invalid JSON), retains the previous valid
184    ///   state and returns `ReloadOutcome::RetainedPrevious` with error detail
185    ///   (including byte offset when available from serde_json).
186    pub fn reload_file(&mut self, path: &Path) -> ReloadOutcome {
187        // Read raw bytes for hashing, fall back gracefully on I/O error
188        let bytes = match std::fs::read(path) {
189            Ok(b) => b,
190            Err(e) => {
191                return ReloadOutcome::RetainedPrevious {
192                    error: format!("could not read {}: {}", path.display(), e),
193                };
194            }
195        };
196
197        let new_hash = sha256_hex(&bytes);
198
199        // Check if the content changed at all
200        let abs_path = path.to_path_buf();
201        if let Some(existing) = self.content_hashes.get(&abs_path) {
202            if *existing == new_hash {
203                return ReloadOutcome::Unchanged;
204            }
205        }
206
207        let content = match String::from_utf8(bytes) {
208            Ok(s) => s,
209            Err(e) => {
210                return ReloadOutcome::RetainedPrevious {
211                    error: format!("invalid UTF-8 in {}: {}", path.display(), e),
212                };
213            }
214        };
215
216        // Determine which catalog this is by filename
217        let filename = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
218
219        match filename {
220            "agents.json" => match serde_json::from_str::<Vec<Agent>>(&content) {
221                Ok(mut new_agents) => {
222                    new_agents.sort_by_key(|a| a.id.to_lowercase());
223                    self.agents = new_agents;
224                    self.content_hashes.insert(abs_path, new_hash);
225                    ReloadOutcome::Reloaded {
226                        catalog: "agents".to_string(),
227                    }
228                }
229                Err(e) => ReloadOutcome::RetainedPrevious {
230                    error: format!(
231                        "parse error in {} at offset {}: {}",
232                        path.display(),
233                        e.column(),
234                        e
235                    ),
236                },
237            },
238            "skills.json" => match serde_json::from_str::<Vec<Skill>>(&content) {
239                Ok(mut new_skills) => {
240                    new_skills.sort_by_key(|s| s.id.to_lowercase());
241                    self.skills = new_skills;
242                    self.content_hashes.insert(abs_path, new_hash);
243                    ReloadOutcome::Reloaded {
244                        catalog: "skills".to_string(),
245                    }
246                }
247                Err(e) => ReloadOutcome::RetainedPrevious {
248                    error: format!(
249                        "parse error in {} at offset {}: {}",
250                        path.display(),
251                        e.column(),
252                        e
253                    ),
254                },
255            },
256            "mcp-references.json" => match serde_json::from_str::<Vec<McpReference>>(&content) {
257                Ok(mut new_refs) => {
258                    new_refs.sort_by_key(|r| r.id.to_lowercase());
259                    self.mcp_refs = new_refs;
260                    self.content_hashes.insert(abs_path, new_hash);
261                    ReloadOutcome::Reloaded {
262                        catalog: "mcp-references".to_string(),
263                    }
264                }
265                Err(e) => ReloadOutcome::RetainedPrevious {
266                    error: format!(
267                        "parse error in {} at offset {}: {}",
268                        path.display(),
269                        e.column(),
270                        e
271                    ),
272                },
273            },
274            "rules.json" => match serde_json::from_str::<Vec<Rule>>(&content) {
275                Ok(mut new_rules) => {
276                    new_rules.sort_by_key(|r| r.id.to_lowercase());
277                    self.rules = new_rules;
278                    self.content_hashes.insert(abs_path, new_hash);
279                    ReloadOutcome::Reloaded {
280                        catalog: "rules".to_string(),
281                    }
282                }
283                Err(e) => ReloadOutcome::RetainedPrevious {
284                    error: format!(
285                        "parse error in {} at offset {}: {}",
286                        path.display(),
287                        e.column(),
288                        e
289                    ),
290                },
291            },
292            "install-roles.json" => {
293                match serde_json::from_str::<crate::models::RoleCatalog>(&content) {
294                    Ok(rc) => {
295                        self.roles = rc.roles;
296                        self.role_catalog_version = rc.version;
297                        self.role_catalog_description = rc.description;
298                        self.content_hashes.insert(abs_path, new_hash);
299                        ReloadOutcome::Reloaded {
300                            catalog: "install-roles".to_string(),
301                        }
302                    }
303                    Err(e) => ReloadOutcome::RetainedPrevious {
304                        error: format!(
305                            "parse error in {} at offset {}: {}",
306                            path.display(),
307                            e.column(),
308                            e
309                        ),
310                    },
311                }
312            }
313            "asset-integrity.json" => match serde_json::from_str::<AssetIntegrity>(&content) {
314                Ok(new_integrity) => {
315                    self.integrity = Some(new_integrity);
316                    self.content_hashes.insert(abs_path, new_hash);
317                    ReloadOutcome::Reloaded {
318                        catalog: "asset-integrity".to_string(),
319                    }
320                }
321                Err(e) => ReloadOutcome::RetainedPrevious {
322                    error: format!(
323                        "parse error in {} at offset {}: {}",
324                        path.display(),
325                        e.column(),
326                        e
327                    ),
328                },
329            },
330            "model-registry.json" => match serde_json::from_str::<ModelRegistry>(&content) {
331                Ok(new_registry) => {
332                    // Mirror loader::load_model_registry: refuse a tainted
333                    // reload rather than adopting what startup would reject.
334                    if loader::check_model_registry_tainted(&new_registry) {
335                        ReloadOutcome::RetainedPrevious {
336                            error: TuiError::TaintedEntry {
337                                path: path.display().to_string(),
338                                offset: 0,
339                                field: "control bytes detected".to_string(),
340                            }
341                            .to_string(),
342                        }
343                    } else {
344                        self.model_registry = Some(new_registry);
345                        self.content_hashes.insert(abs_path, new_hash);
346                        ReloadOutcome::Reloaded {
347                            catalog: "model-registry".to_string(),
348                        }
349                    }
350                }
351                Err(e) => ReloadOutcome::RetainedPrevious {
352                    error: format!(
353                        "parse error in {} at offset {}: {}",
354                        path.display(),
355                        e.column(),
356                        e
357                    ),
358                },
359            },
360            "model-assignments.json" => match serde_json::from_str::<ModelAssignments>(&content) {
361                Ok(new_assignments) => {
362                    // Mirror the initial-load path (catalog::loader::load_model_assignments):
363                    // reject a reload that carries control-byte-tainted data
364                    // rather than adopting it, so a hot-reload can't smuggle
365                    // in what the startup path would have refused.
366                    if loader::check_model_assignments_tainted(&new_assignments) {
367                        ReloadOutcome::RetainedPrevious {
368                            error: TuiError::TaintedEntry {
369                                path: path.display().to_string(),
370                                offset: 0,
371                                field: "control bytes detected".to_string(),
372                            }
373                            .to_string(),
374                        }
375                    } else {
376                        self.model_assignments = Some(new_assignments);
377                        self.content_hashes.insert(abs_path, new_hash);
378                        ReloadOutcome::Reloaded {
379                            catalog: "model-assignments".to_string(),
380                        }
381                    }
382                }
383                Err(e) => ReloadOutcome::RetainedPrevious {
384                    error: format!(
385                        "parse error in {} at offset {}: {}",
386                        path.display(),
387                        e.column(),
388                        e
389                    ),
390                },
391            },
392            other => ReloadOutcome::RetainedPrevious {
393                error: format!("unknown catalog file: {other}"),
394            },
395        }
396    }
397
398    // -----------------------------------------------------------------------
399    // v2 query methods
400    // -----------------------------------------------------------------------
401
402    /// Look up an agent by its ID.
403    pub fn agent_by_id(&self, id: &str) -> Option<&Agent> {
404        self.agents.iter().find(|a| a.id == id)
405    }
406
407    /// Look up a skill by its ID.
408    pub fn skill_by_id(&self, id: &str) -> Option<&Skill> {
409        self.skills.iter().find(|s| s.id == id)
410    }
411
412    /// Resolved model assignments for one agent (empty when the assignments
413    /// index is absent or the agent has no capable harness variants).
414    pub fn model_assignments_for_agent(
415        &self,
416        agent_id: &str,
417    ) -> Vec<&crate::models::ModelAssignment> {
418        self.model_assignments
419            .as_ref()
420            .map(|m| m.for_agent(agent_id))
421            .unwrap_or_default()
422    }
423
424    /// Return every asset ID (agents + skills + mcp_refs + rules).
425    pub fn all_asset_ids(&self) -> Vec<String> {
426        let mut ids: Vec<String> = self
427            .agents
428            .iter()
429            .map(|a| a.id.clone())
430            .chain(self.skills.iter().map(|s| s.id.clone()))
431            .chain(self.mcp_refs.iter().map(|m| m.id.clone()))
432            .chain(self.rules.iter().map(|r| r.id.clone()))
433            .collect();
434        ids.sort();
435        ids
436    }
437
438    /// Return the SHA-256 hex digest for the catalog file at `path`, or `None`
439    /// if that path was not loaded or could not be hashed.
440    pub fn content_hash_for(&self, path: &Path) -> Option<&str> {
441        self.content_hashes.get(path).map(|s| s.as_str())
442    }
443
444    /// Enumerate all dependency edges derivable from catalog data.
445    ///
446    /// Edge types:
447    /// - `AgentSkill`: agent → skill via `companion_skills`
448    /// - `RoleAgent`: role → agent via role membership
449    /// - `AgentMcp`: agent → mcp-reference when an agent's `harness_variants`
450    ///   value or `path` matches an mcp_ref id
451    /// - `AgentRule`: agent → rule when the agent and rule share at least one
452    ///   harness
453    pub fn dependency_edges(&self) -> Vec<(String, String, EdgeKind)> {
454        let mut edges: Vec<(String, String, EdgeKind)> = Vec::new();
455
456        // AgentSkill edges
457        for agent in &self.agents {
458            for skill_id in &agent.companion_skills {
459                edges.push((agent.id.clone(), skill_id.clone(), EdgeKind::AgentSkill));
460            }
461        }
462
463        // RoleAgent edges
464        for (role_id, role) in &self.roles {
465            for agent_id in &role.agents {
466                edges.push((role_id.clone(), agent_id.clone(), EdgeKind::RoleAgent));
467            }
468        }
469
470        // AgentMcp edges: match harness_variants values against mcp_ref ids
471        let mcp_ids: HashSet<&str> = self.mcp_refs.iter().map(|m| m.id.as_str()).collect();
472        for agent in &self.agents {
473            for val in agent.harness_variants.values() {
474                if mcp_ids.contains(val.as_str()) {
475                    edges.push((agent.id.clone(), val.clone(), EdgeKind::AgentMcp));
476                }
477            }
478        }
479
480        // AgentRule edges: agent and rule share at least one harness
481        for agent in &self.agents {
482            let agent_harnesses: HashSet<_> = agent.harnesses.iter().collect();
483            for rule in &self.rules {
484                let shares = rule.harnesses.iter().any(|h| agent_harnesses.contains(h));
485                if shares {
486                    edges.push((agent.id.clone(), rule.id.clone(), EdgeKind::AgentRule));
487                }
488            }
489        }
490
491        edges
492    }
493
494    // -----------------------------------------------------------------------
495    // Existing query methods (unchanged)
496    // -----------------------------------------------------------------------
497
498    /// Number of loaded agents.
499    pub fn agent_count(&self) -> usize {
500        self.agents.len()
501    }
502
503    /// Number of loaded skills.
504    pub fn skill_count(&self) -> usize {
505        self.skills.len()
506    }
507
508    /// Number of distinct providers across all agents.
509    pub fn provider_count(&self) -> usize {
510        let providers: HashSet<_> = self.agents.iter().map(|a| a.provider).collect();
511        providers.len()
512    }
513
514    /// Filter agents by provider (case-insensitive comparison via Provider enum serialization).
515    pub fn agents_by_provider(&self, provider: &str) -> Vec<&Agent> {
516        let provider_lower = provider.to_lowercase();
517        self.agents
518            .iter()
519            .filter(|a| {
520                let serialized = serde_json::to_value(a.provider)
521                    .ok()
522                    .and_then(|v| v.as_str().map(|s| s.to_string()));
523                match serialized {
524                    Some(p) => p.to_lowercase() == provider_lower,
525                    None => false,
526                }
527            })
528            .collect()
529    }
530
531    /// Get agents listed in a role's agents vec.
532    pub fn agents_for_role(&self, role_id: &str) -> Vec<&Agent> {
533        let role = match self.roles.get(role_id) {
534            Some(r) => r,
535            None => return Vec::new(),
536        };
537
538        let agent_ids: HashSet<&str> = role.agents.iter().map(|s| s.as_str()).collect();
539        self.agents
540            .iter()
541            .filter(|a| agent_ids.contains(a.id.as_str()))
542            .collect()
543    }
544
545    /// Get agents for a role grouped by provider.
546    /// Returns a vec of (provider_name, agents) tuples sorted alphabetically by provider,
547    /// with agents sorted alphabetically by ID within each group.
548    pub fn agents_for_role_by_provider(&self, role_id: &str) -> Vec<(String, Vec<&Agent>)> {
549        let agents = self.agents_for_role(role_id);
550
551        // Group agents by their serialized provider name
552        let mut groups: HashMap<String, Vec<&Agent>> = HashMap::new();
553        for agent in agents {
554            let provider_name = serde_json::to_value(agent.provider)
555                .ok()
556                .and_then(|v| v.as_str().map(|s| s.to_string()))
557                .unwrap_or_else(|| format!("{:?}", agent.provider));
558            groups.entry(provider_name).or_default().push(agent);
559        }
560
561        // Sort agents within each group by ID (case-insensitive)
562        for agents_in_group in groups.values_mut() {
563            agents_in_group.sort_by_key(|a| a.id.to_lowercase());
564        }
565
566        // Sort groups alphabetically by provider name
567        let mut result: Vec<(String, Vec<&Agent>)> = groups.into_iter().collect();
568        result.sort_by_key(|(provider, _)| provider.to_lowercase());
569        result
570    }
571
572    /// Find skills whose id is in an agent's companion_skills.
573    pub fn skills_for_agent(&self, agent_id: &str) -> Vec<&Skill> {
574        let agent = match self.agents.iter().find(|a| a.id == agent_id) {
575            Some(a) => a,
576            None => return Vec::new(),
577        };
578
579        let skill_ids: HashSet<&str> = agent.companion_skills.iter().map(|s| s.as_str()).collect();
580        self.skills
581            .iter()
582            .filter(|s| skill_ids.contains(s.id.as_str()))
583            .collect()
584    }
585
586    /// Reverse lookup: agents whose companion_skills contains the given skill_id.
587    pub fn agents_with_skill(&self, skill_id: &str) -> Vec<&Agent> {
588        self.agents
589            .iter()
590            .filter(|a| a.companion_skills.iter().any(|s| s == skill_id))
591            .collect()
592    }
593
594    /// Reverse lookup: roles that contain the given agent_id.
595    pub fn roles_containing_agent(&self, agent_id: &str) -> Vec<&str> {
596        self.roles
597            .iter()
598            .filter(|(_id, role)| role.agents.iter().any(|a| a == agent_id))
599            .map(|(id, _)| id.as_str())
600            .collect()
601    }
602
603    /// Get all distinct provider names from agents.
604    pub fn provider_names(&self) -> Vec<String> {
605        let mut providers: std::collections::HashSet<String> = std::collections::HashSet::new();
606        for agent in &self.agents {
607            let p = serde_json::to_value(agent.provider)
608                .ok()
609                .and_then(|v| v.as_str().map(|s| s.to_string()))
610                .unwrap_or_else(|| format!("{:?}", agent.provider));
611            providers.insert(p);
612        }
613        let mut list: Vec<String> = providers.into_iter().collect();
614        list.sort();
615        list
616    }
617
618    /// Get all distinct harness names from agents.
619    pub fn harness_names(&self) -> Vec<String> {
620        let mut harnesses: std::collections::HashSet<String> = std::collections::HashSet::new();
621        for agent in &self.agents {
622            for h in &agent.harnesses {
623                let s = serde_json::to_value(h)
624                    .ok()
625                    .and_then(|v| v.as_str().map(|s| s.to_string()))
626                    .unwrap_or_else(|| format!("{h:?}"));
627                harnesses.insert(s);
628            }
629        }
630        let mut list: Vec<String> = harnesses.into_iter().collect();
631        list.sort();
632        list
633    }
634
635    /// Get all role IDs sorted.
636    pub fn role_ids(&self) -> Vec<String> {
637        let mut ids: Vec<String> = self.roles.keys().cloned().collect();
638        ids.sort();
639        ids
640    }
641
642    /// Get all valid platform names for export.
643    pub fn platform_names(&self) -> Vec<&'static str> {
644        vec![
645            "kiro",
646            "claude-code",
647            "cursor",
648            "copilot",
649            "codex",
650            "gemini",
651        ]
652    }
653}
654
655// ---------------------------------------------------------------------------
656// Test helpers (cfg(test) only)
657// ---------------------------------------------------------------------------
658
659#[cfg(test)]
660impl CatalogStore {
661    /// Construct a minimal in-memory store for property tests, without hitting disk.
662    pub fn from_parts(
663        agents: Vec<Agent>,
664        skills: Vec<Skill>,
665        roles: HashMap<String, Role>,
666        mcp_refs: Vec<McpReference>,
667        rules: Vec<Rule>,
668    ) -> Self {
669        CatalogStore {
670            agents,
671            skills,
672            roles,
673            role_catalog_version: String::new(),
674            role_catalog_description: String::new(),
675            mcp_refs,
676            rules,
677            integrity: None,
678            model_assignments: None,
679            model_registry: None,
680            workflows: None,
681            load_errors: Vec::new(),
682            content_hashes: HashMap::new(),
683            catalog_root: PathBuf::from("/tmp"),
684        }
685    }
686}
687
688// ---------------------------------------------------------------------------
689// Tests
690// ---------------------------------------------------------------------------
691
692#[cfg(test)]
693mod tests {
694    use super::*;
695    use std::path::Path;
696
697    fn workspace_root() -> &'static Path {
698        let manifest_dir = env!("CARGO_MANIFEST_DIR");
699        let p = Path::new(manifest_dir).parent().unwrap().parent().unwrap();
700        Box::leak(Box::new(p.to_path_buf()))
701    }
702
703    #[test]
704    fn store_loads_successfully() {
705        let store = CatalogStore::load(workspace_root());
706        assert!(
707            store.load_errors.is_empty(),
708            "load errors: {:?}",
709            store.load_errors
710        );
711        assert!(store.agent_count() > 0);
712        assert!(store.skill_count() > 0);
713        assert!(!store.mcp_refs.is_empty());
714        assert!(!store.rules.is_empty());
715        assert!(!store.roles.is_empty());
716        assert!(store.integrity.is_some());
717    }
718
719    #[test]
720    fn store_provider_count() {
721        let store = CatalogStore::load(workspace_root());
722        assert!(store.provider_count() > 1);
723    }
724
725    #[test]
726    fn store_agents_by_provider() {
727        let store = CatalogStore::load(workspace_root());
728        let aws_agents = store.agents_by_provider("aws");
729        assert!(!aws_agents.is_empty());
730    }
731
732    #[test]
733    fn store_agents_sorted_case_insensitive() {
734        let store = CatalogStore::load(workspace_root());
735        for window in store.agents.windows(2) {
736            assert!(
737                window[0].id.to_lowercase() <= window[1].id.to_lowercase(),
738                "agents not sorted: {} > {}",
739                window[0].id,
740                window[1].id
741            );
742        }
743    }
744
745    #[test]
746    fn store_skills_sorted_case_insensitive() {
747        let store = CatalogStore::load(workspace_root());
748        for window in store.skills.windows(2) {
749            assert!(
750                window[0].id.to_lowercase() <= window[1].id.to_lowercase(),
751                "skills not sorted: {} > {}",
752                window[0].id,
753                window[1].id
754            );
755        }
756    }
757
758    #[test]
759    fn store_agents_for_role() {
760        let store = CatalogStore::load(workspace_root());
761        // Get the first role
762        if let Some((role_id, role)) = store.roles.iter().next() {
763            if !role.agents.is_empty() {
764                let agents = store.agents_for_role(role_id);
765                assert!(!agents.is_empty());
766            }
767        }
768    }
769
770    #[test]
771    fn store_skills_for_agent() {
772        let store = CatalogStore::load(workspace_root());
773        // Find an agent with companion skills
774        if let Some(agent) = store.agents.iter().find(|a| !a.companion_skills.is_empty()) {
775            let skills = store.skills_for_agent(&agent.id);
776            assert!(!skills.is_empty());
777        }
778    }
779
780    #[test]
781    fn store_agents_with_skill() {
782        let store = CatalogStore::load(workspace_root());
783        // Find a skill that some agent references
784        if let Some(agent) = store.agents.iter().find(|a| !a.companion_skills.is_empty()) {
785            let skill_id = &agent.companion_skills[0];
786            let agents = store.agents_with_skill(skill_id);
787            assert!(!agents.is_empty());
788            assert!(agents.iter().any(|a| a.id == agent.id));
789        }
790    }
791
792    #[test]
793    fn store_agents_with_skill_returns_empty_for_unknown() {
794        let store = CatalogStore::load(workspace_root());
795        let agents = store.agents_with_skill("nonexistent-skill-id-xyz");
796        assert!(agents.is_empty());
797    }
798
799    #[test]
800    fn store_agents_for_role_by_provider() {
801        let store = CatalogStore::load(workspace_root());
802        // Find a role with agents from multiple providers
803        if let Some((role_id, _role)) = store.roles.iter().find(|(_, r)| r.agents.len() > 2) {
804            let grouped = store.agents_for_role_by_provider(role_id);
805            assert!(!grouped.is_empty());
806
807            // Verify provider groups are sorted alphabetically
808            for window in grouped.windows(2) {
809                assert!(
810                    window[0].0.to_lowercase() <= window[1].0.to_lowercase(),
811                    "provider groups not sorted: {} > {}",
812                    window[0].0,
813                    window[1].0
814                );
815            }
816
817            // Verify agents within each group are sorted by ID
818            for (_provider, agents) in &grouped {
819                for window in agents.windows(2) {
820                    assert!(
821                        window[0].id.to_lowercase() <= window[1].id.to_lowercase(),
822                        "agents in group not sorted: {} > {}",
823                        window[0].id,
824                        window[1].id
825                    );
826                }
827            }
828
829            // Verify total count matches flat query
830            let flat = store.agents_for_role(role_id);
831            let grouped_total: usize = grouped.iter().map(|(_, agents)| agents.len()).sum();
832            assert_eq!(grouped_total, flat.len());
833        } else {
834            // If no role has >2 agents, just test with any role
835            if let Some((role_id, _)) = store.roles.iter().next() {
836                let grouped = store.agents_for_role_by_provider(role_id);
837                let flat = store.agents_for_role(role_id);
838                let grouped_total: usize = grouped.iter().map(|(_, agents)| agents.len()).sum();
839                assert_eq!(grouped_total, flat.len());
840            }
841        }
842    }
843
844    #[test]
845    fn store_agents_for_role_by_provider_unknown_role() {
846        let store = CatalogStore::load(workspace_root());
847        let grouped = store.agents_for_role_by_provider("nonexistent-role-xyz");
848        assert!(grouped.is_empty());
849    }
850
851    // -----------------------------------------------------------------------
852    // v2 query tests
853    // -----------------------------------------------------------------------
854
855    #[test]
856    fn content_hashes_populated_after_load() {
857        let store = CatalogStore::load(workspace_root());
858        // At least the files that exist should have been hashed
859        assert!(
860            !store.content_hashes.is_empty(),
861            "content_hashes should be populated"
862        );
863        // All hashes should be valid 64-char hex strings (SHA-256)
864        for (path, hash) in &store.content_hashes {
865            assert_eq!(
866                hash.len(),
867                64,
868                "hash for {:?} has wrong length: {}",
869                path,
870                hash
871            );
872        }
873    }
874
875    #[test]
876    fn content_hash_for_returns_known_path() {
877        let store = CatalogStore::load(workspace_root());
878        let agents_path = workspace_root().join("catalog").join("agents.json");
879        let hash = store.content_hash_for(&agents_path);
880        assert!(
881            hash.is_some(),
882            "content_hash_for agents.json should return Some"
883        );
884        assert_eq!(hash.unwrap().len(), 64);
885    }
886
887    #[test]
888    fn content_hash_for_unknown_path_returns_none() {
889        let store = CatalogStore::load(workspace_root());
890        let result = store.content_hash_for(Path::new("/nonexistent/path.json"));
891        assert!(result.is_none());
892    }
893
894    #[test]
895    fn agent_by_id_found() {
896        let store = CatalogStore::load(workspace_root());
897        if let Some(first) = store.agents.first() {
898            let id = first.id.clone();
899            let found = store.agent_by_id(&id);
900            assert!(found.is_some());
901            assert_eq!(found.unwrap().id, id);
902        }
903    }
904
905    #[test]
906    fn agent_by_id_not_found() {
907        let store = CatalogStore::load(workspace_root());
908        assert!(store.agent_by_id("nonexistent-xyz-agent").is_none());
909    }
910
911    #[test]
912    fn skill_by_id_found() {
913        let store = CatalogStore::load(workspace_root());
914        if let Some(first) = store.skills.first() {
915            let id = first.id.clone();
916            let found = store.skill_by_id(&id);
917            assert!(found.is_some());
918            assert_eq!(found.unwrap().id, id);
919        }
920    }
921
922    #[test]
923    fn all_asset_ids_covers_all_types() {
924        let store = CatalogStore::load(workspace_root());
925        let ids = store.all_asset_ids();
926        // Should include agents, skills, mcp_refs, rules
927        assert!(ids.len() >= store.agent_count() + store.skill_count());
928        // Check a known agent ID is present
929        if let Some(a) = store.agents.first() {
930            assert!(ids.contains(&a.id));
931        }
932    }
933
934    #[test]
935    fn dependency_edges_agent_skill() {
936        let store = CatalogStore::load(workspace_root());
937        let edges = store.dependency_edges();
938        // If any agent has companion_skills, we expect AgentSkill edges
939        let has_companion = store.agents.iter().any(|a| !a.companion_skills.is_empty());
940        if has_companion {
941            let agent_skill_edges: Vec<_> = edges
942                .iter()
943                .filter(|(_, _, k)| *k == EdgeKind::AgentSkill)
944                .collect();
945            assert!(!agent_skill_edges.is_empty());
946        }
947    }
948
949    #[test]
950    fn dependency_edges_role_agent() {
951        let store = CatalogStore::load(workspace_root());
952        let edges = store.dependency_edges();
953        let role_agent_edges: Vec<_> = edges
954            .iter()
955            .filter(|(_, _, k)| *k == EdgeKind::RoleAgent)
956            .collect();
957        // Roles have agents, so we expect some RoleAgent edges
958        assert!(!role_agent_edges.is_empty());
959    }
960
961    #[test]
962    fn reload_file_unchanged_on_same_content() {
963        // Reload agents.json — content hasn't changed so should be Unchanged
964        let mut store = CatalogStore::load(workspace_root());
965        let agents_path = workspace_root().join("catalog").join("agents.json");
966        let outcome = store.reload_file(&agents_path);
967        assert!(
968            matches!(outcome, ReloadOutcome::Unchanged),
969            "Expected Unchanged, got {:?}",
970            outcome
971        );
972    }
973
974    #[test]
975    fn reload_file_invalid_json_retains_previous() {
976        use std::io::Write;
977
978        let tmp = tempfile::TempDir::new().unwrap();
979        let catalog_dir = tmp.path().join("catalog");
980        std::fs::create_dir_all(&catalog_dir).unwrap();
981
982        // Write valid agents.json
983        let agents_path = catalog_dir.join("agents.json");
984        std::fs::write(&agents_path, b"[]").unwrap();
985
986        // Load from the temp directory (empty catalog is fine)
987        let mut store = CatalogStore::load(tmp.path());
988        // Initially no agents
989        assert_eq!(store.agent_count(), 0);
990
991        // Now write invalid JSON
992        {
993            let mut f = std::fs::OpenOptions::new()
994                .write(true)
995                .truncate(true)
996                .open(&agents_path)
997                .unwrap();
998            f.write_all(b"{ invalid json {{{{").unwrap();
999        }
1000
1001        let outcome = store.reload_file(&agents_path);
1002        assert!(
1003            matches!(outcome, ReloadOutcome::RetainedPrevious { .. }),
1004            "Expected RetainedPrevious, got {:?}",
1005            outcome
1006        );
1007        // Previous valid state (empty vec) should still be there
1008        assert_eq!(store.agent_count(), 0);
1009    }
1010
1011    #[test]
1012    fn reload_model_assignments_rejects_tainted_control_bytes() {
1013        use std::io::Write;
1014
1015        let tmp = tempfile::TempDir::new().unwrap();
1016        let catalog_dir = tmp.path().join("catalog");
1017        std::fs::create_dir_all(&catalog_dir).unwrap();
1018
1019        let assignments_path = catalog_dir.join("model-assignments.json");
1020        let clean = r#"{
1021          "manifest_version": 1,
1022          "generated_by": "scripts/model-policy.mjs",
1023          "policy_sha256": "abc123",
1024          "capabilities": {},
1025          "assignments": []
1026        }"#;
1027        std::fs::write(&assignments_path, clean).unwrap();
1028
1029        // Initial load (catalog::loader::load_model_assignments) accepts the
1030        // clean file.
1031        let mut store = CatalogStore::load(tmp.path());
1032        assert!(store.model_assignments.is_some());
1033        assert_eq!(
1034            store.model_assignments.as_ref().unwrap().generated_by,
1035            "scripts/model-policy.mjs"
1036        );
1037
1038        // Overwrite with content carrying a control byte (ESC, 0x1B) — the
1039        // same class of taint check_model_assignments_tainted rejects on the
1040        // initial load path. The hot-reload arm must reuse that check rather
1041        // than adopting the new value unchecked.
1042        let tainted = r#"{
1043          "manifest_version": 1,
1044          "generated_by": "scripts/model-policy.mjs\u001b[31m",
1045          "policy_sha256": "abc123",
1046          "capabilities": {},
1047          "assignments": []
1048        }"#;
1049        {
1050            let mut f = std::fs::OpenOptions::new()
1051                .write(true)
1052                .truncate(true)
1053                .open(&assignments_path)
1054                .unwrap();
1055            f.write_all(tainted.as_bytes()).unwrap();
1056        }
1057
1058        let outcome = store.reload_file(&assignments_path);
1059        assert!(
1060            matches!(outcome, ReloadOutcome::RetainedPrevious { .. }),
1061            "Expected RetainedPrevious for a tainted reload, got {:?}",
1062            outcome
1063        );
1064        // The prior, untainted value must still be in memory — not clobbered
1065        // by the rejected parse.
1066        assert_eq!(
1067            store.model_assignments.as_ref().unwrap().generated_by,
1068            "scripts/model-policy.mjs"
1069        );
1070    }
1071}
1072
1073// ---------------------------------------------------------------------------
1074// Property tests (Task 5.2)
1075// ---------------------------------------------------------------------------
1076
1077#[cfg(test)]
1078mod prop_tests {
1079    use super::*;
1080    use crate::models::agent::{AgentType, Lifecycle};
1081    use crate::models::harness::{Harness, SourceType};
1082    use crate::models::provider::Provider;
1083    use crate::models::role::Role;
1084    use crate::models::skill::{Skill, SkillType};
1085    use crate::search::fuzzy::SearchEngine;
1086    use proptest::prelude::*;
1087    use std::collections::HashMap;
1088    use std::io::Write;
1089
1090    // -----------------------------------------------------------------------
1091    // Helpers for building test data
1092    // -----------------------------------------------------------------------
1093
1094    fn make_agent(id: &str, provider: Provider, harness: Harness, skill_ids: Vec<String>) -> Agent {
1095        Agent {
1096            id: id.to_string(),
1097            name: format!("Agent {id}"),
1098            entity_type: AgentType::Agent,
1099            provider,
1100            harnesses: vec![harness],
1101            summary: format!("Summary for {id}"),
1102            source_type: SourceType::Original,
1103            official_docs: vec![],
1104            security_notes: String::new(),
1105            last_verified: "2024-01-01".to_string(),
1106            path: format!("agents/{id}.md"),
1107            companion_skills: skill_ids,
1108            execution_tier: None,
1109            lifecycle: Some(Lifecycle::Stable),
1110            harness_variants: HashMap::new(),
1111            author: None,
1112            version: Some("1.0.0".to_string()),
1113            provider_coverage: None,
1114        }
1115    }
1116
1117    fn make_skill(id: &str, provider: Provider, harness: Harness) -> Skill {
1118        Skill {
1119            id: id.to_string(),
1120            name: format!("Skill {id}"),
1121            entity_type: SkillType::Skill,
1122            provider,
1123            harnesses: vec![harness],
1124            summary: format!("Summary for {id}"),
1125            source_type: SourceType::Original,
1126            official_docs: vec![],
1127            security_notes: String::new(),
1128            last_verified: "2024-01-01".to_string(),
1129            path: format!("skills/{id}.md"),
1130            author: None,
1131            version: Some("1.0.0".to_string()),
1132            category: None,
1133            certifications: None,
1134            companion_agents: None,
1135            companion_review_skills: None,
1136            companion_skills: None,
1137            execution_tier: None,
1138            feeds_skills: None,
1139            lifecycle: None,
1140            mcp_servers: None,
1141            oauth_scopes: None,
1142            production_allowed: None,
1143            run_as_permissions: None,
1144            sandbox_only: None,
1145            source_attribution: None,
1146            verify_before_merge: None,
1147        }
1148    }
1149
1150    fn make_role(agent_ids: Vec<String>) -> Role {
1151        Role {
1152            label: "Test Role".to_string(),
1153            description: "Test".to_string(),
1154            agents: agent_ids,
1155            skills: vec![],
1156        }
1157    }
1158
1159    /// Arbitrary safe identifiers: lowercase alphanumeric + hyphens, non-empty.
1160    fn arb_id() -> impl Strategy<Value = String> {
1161        "[a-z][a-z0-9-]{1,15}".prop_map(|s| s)
1162    }
1163
1164    fn arb_provider() -> impl Strategy<Value = Provider> {
1165        prop_oneof![
1166            Just(Provider::Aws),
1167            Just(Provider::Azure),
1168            Just(Provider::Gcp),
1169            Just(Provider::Claude),
1170            Just(Provider::Kubernetes),
1171            Just(Provider::Php),
1172        ]
1173    }
1174
1175    fn arb_harness() -> impl Strategy<Value = Harness> {
1176        prop_oneof![
1177            Just(Harness::ClaudeCode),
1178            Just(Harness::Cursor),
1179            Just(Harness::Kiro),
1180            Just(Harness::Copilot),
1181        ]
1182    }
1183
1184    // -----------------------------------------------------------------------
1185    // Property 1 (Req 1.3): reload_file on invalid JSON never panics and
1186    // retains previous valid state.
1187    // -----------------------------------------------------------------------
1188    proptest! {
1189        #![proptest_config(ProptestConfig::with_cases(256))]
1190
1191        #[test]
1192        fn prop1_reload_invalid_json_retains_previous(
1193            agent_id in arb_id(),
1194            garbage in ".{0,200}",  // arbitrary bytes (invalidity forced by control-byte prefix below)
1195        ) {
1196            let tmp = tempfile::TempDir::new().unwrap();
1197            let catalog_dir = tmp.path().join("catalog");
1198            std::fs::create_dir_all(&catalog_dir).unwrap();
1199
1200            // Write a valid agents.json with one agent
1201            let valid_agents = serde_json::json!([{
1202                "id": agent_id,
1203                "name": format!("Agent {agent_id}"),
1204                "type": "agent",
1205                "provider": "aws",
1206                "harnesses": ["claude-code"],
1207                "summary": "test",
1208                "source_type": "original",
1209                "official_docs": [],
1210                "security_notes": "",
1211                "last_verified": "2024-01-01",
1212                "path": "agents/test.md",
1213                "companion_skills": [],
1214                "harness_variants": {}
1215            }]);
1216            let agents_path = catalog_dir.join("agents.json");
1217            std::fs::write(&agents_path, valid_agents.to_string().as_bytes()).unwrap();
1218
1219            // Also create other required files as empty arrays/objects
1220            std::fs::write(catalog_dir.join("skills.json"), b"[]").unwrap();
1221            std::fs::write(catalog_dir.join("mcp-references.json"), b"[]").unwrap();
1222            std::fs::write(catalog_dir.join("rules.json"), b"[]").unwrap();
1223
1224            let mut store = CatalogStore::load(tmp.path());
1225            let agent_count_before = store.agent_count();
1226
1227            // Overwrite with garbage (definitely invalid JSON)
1228            {
1229                let mut f = std::fs::OpenOptions::new()
1230                    .write(true)
1231                    .truncate(true)
1232                    .open(&agents_path)
1233                    .unwrap();
1234                // Force it to be invalid by prefixing with control chars
1235                f.write_all(b"\x00\x01\x02").unwrap();
1236                f.write_all(garbage.as_bytes()).unwrap();
1237            }
1238
1239            // This must not panic
1240            let outcome = store.reload_file(&agents_path);
1241
1242            // Outcome should be RetainedPrevious (bytes changed, but invalid JSON)
1243            match outcome {
1244                ReloadOutcome::RetainedPrevious { .. } => {
1245                    // Good — verify state is unchanged
1246                    prop_assert_eq!(store.agent_count(), agent_count_before,
1247                        "store should retain previous agents after invalid reload");
1248                }
1249                ReloadOutcome::Unchanged => {
1250                    // Content hash matched — also acceptable (hash collision with control bytes prefix is impossible in practice)
1251                    prop_assert_eq!(store.agent_count(), agent_count_before);
1252                }
1253                ReloadOutcome::Reloaded { .. } => {
1254                    // This would be a bug: should not have "succeeded" on garbage
1255                    prop_assert!(false, "reload should not succeed on garbage input");
1256                }
1257            }
1258        }
1259
1260        // -----------------------------------------------------------------------
1261        // Property 2 (Req 16.2, 32.2): fuzzy search returns only matching items
1262        // — for any query that IS a substring of some item's id or name, that
1263        //   item appears in results.
1264        // -----------------------------------------------------------------------
1265        #[test]
1266        fn prop2_fuzzy_search_substring_hit(
1267            base_id in arb_id(),
1268            provider in arb_provider(),
1269            harness in arb_harness(),
1270        ) {
1271            // Build a store with one agent whose id contains `base_id`
1272            let agent = make_agent(&base_id, provider, harness, vec![]);
1273            let store = CatalogStore::from_parts(
1274                vec![agent],
1275                vec![],
1276                HashMap::new(),
1277                vec![],
1278                vec![],
1279            );
1280
1281            let mut engine = SearchEngine::new();
1282
1283            // Query using the exact id — must appear in results
1284            let results = engine.search_agents(&base_id, &store.agents, None, None);
1285            prop_assert!(
1286                results.contains(&0),
1287                "agent with id '{}' not found when querying '{}'",
1288                base_id, base_id
1289            );
1290        }
1291
1292        // -----------------------------------------------------------------------
1293        // Property 3 (Req 32.3): combined filter (provider AND harness) returns
1294        // the correct intersection.
1295        // -----------------------------------------------------------------------
1296        #[test]
1297        fn prop3_combined_filter_intersection(
1298            id1 in "[a-z][a-z0-9]{3,8}",
1299            id2 in "[a-z][a-z0-9]{3,8}",
1300        ) {
1301            // Make two agents with different providers / harnesses
1302            let agent_aws_claude = make_agent(&id1, Provider::Aws, Harness::ClaudeCode, vec![]);
1303            let agent_gcp_cursor = make_agent(&id2, Provider::Gcp, Harness::Cursor, vec![]);
1304
1305            let store = CatalogStore::from_parts(
1306                vec![agent_aws_claude, agent_gcp_cursor],
1307                vec![],
1308                HashMap::new(),
1309                vec![],
1310                vec![],
1311            );
1312
1313            let mut engine = SearchEngine::new();
1314
1315            // Filter: provider=aws AND harness=claude-code → only agent 0
1316            let results = engine.search_agents("", &store.agents, Some("aws"), Some("claude-code"));
1317            // Should contain agent at index 0 (aws/claude-code)
1318            prop_assert!(results.contains(&0), "aws/claude-code agent should be in results");
1319            // Should NOT contain agent at index 1 (gcp/cursor)
1320            prop_assert!(!results.contains(&1), "gcp/cursor agent should not be in aws/claude-code results");
1321
1322            // Filter: provider=gcp AND harness=cursor → only agent 1
1323            let results2 = engine.search_agents("", &store.agents, Some("gcp"), Some("cursor"));
1324            prop_assert!(results2.contains(&1), "gcp/cursor agent should be in results");
1325            prop_assert!(!results2.contains(&0), "aws/claude-code agent should not be in gcp/cursor results");
1326
1327            // Filter: provider=aws AND harness=cursor → empty (no agent matches both)
1328            let results3 = engine.search_agents("", &store.agents, Some("aws"), Some("cursor"));
1329            prop_assert!(results3.is_empty(), "no agent matches aws+cursor");
1330        }
1331
1332        // -----------------------------------------------------------------------
1333        // Property 5 (Req 5.2, 5.3, 32.5): reverse-lookup consistency
1334        //   a) agents_with_skill(S) contains agent A when A.companion_skills ∋ S
1335        //   b) roles_containing_agent is consistent with role membership
1336        // -----------------------------------------------------------------------
1337        #[test]
1338        fn prop5_reverse_lookup_consistency(
1339            agent_id in arb_id(),
1340            skill_id in arb_id(),
1341            role_id in arb_id(),
1342            provider in arb_provider(),
1343            harness in arb_harness(),
1344        ) {
1345            // Build agent with companion skill
1346            let agent = make_agent(&agent_id, provider, harness, vec![skill_id.clone()]);
1347            let skill = make_skill(&skill_id, provider, harness);
1348
1349            // Build role containing the agent
1350            let mut roles = HashMap::new();
1351            roles.insert(role_id.clone(), make_role(vec![agent_id.clone()]));
1352
1353            let store = CatalogStore::from_parts(
1354                vec![agent],
1355                vec![skill],
1356                roles,
1357                vec![],
1358                vec![],
1359            );
1360
1361            // (a) agents_with_skill reverse lookup
1362            let agents_with = store.agents_with_skill(&skill_id);
1363            prop_assert!(
1364                agents_with.iter().any(|a| a.id == agent_id),
1365                "agents_with_skill('{}') should contain agent '{}'",
1366                skill_id, agent_id
1367            );
1368
1369            // (b) roles_containing_agent reverse lookup
1370            let roles_with = store.roles_containing_agent(&agent_id);
1371            prop_assert!(
1372                roles_with.contains(&role_id.as_str()),
1373                "roles_containing_agent('{}') should contain role '{}'",
1374                agent_id, role_id
1375            );
1376
1377            // (c) consistency: agents_for_role(role_id) should include the agent
1378            let agents_for = store.agents_for_role(&role_id);
1379            prop_assert!(
1380                agents_for.iter().any(|a| a.id == agent_id),
1381                "agents_for_role('{}') should contain agent '{}'",
1382                role_id, agent_id
1383            );
1384
1385            // (d) skills_for_agent should include the skill
1386            let skills_for = store.skills_for_agent(&agent_id);
1387            prop_assert!(
1388                skills_for.iter().any(|s| s.id == skill_id),
1389                "skills_for_agent('{}') should contain skill '{}'",
1390                agent_id, skill_id
1391            );
1392        }
1393    }
1394}