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