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#[derive(Debug)]
19pub enum ReloadOutcome {
20 Reloaded { catalog: String },
22 RetainedPrevious { error: String },
24 Unchanged,
26}
27
28#[derive(Debug, Clone, PartialEq, Eq)]
34pub enum EdgeKind {
35 AgentSkill,
37 RoleAgent,
39 AgentMcp,
41 AgentRule,
43}
44
45pub 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 pub model_assignments: Option<ModelAssignments>,
62 pub workflows: Option<WorkflowCatalog>,
65 pub load_errors: Vec<TuiError>,
66 pub content_hashes: HashMap<PathBuf, String>,
68 pub catalog_root: PathBuf,
70}
71
72fn 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
84fn hash_file(path: &Path) -> Option<String> {
86 let bytes = std::fs::read(path).ok()?;
87 Some(sha256_hex(&bytes))
88}
89
90impl CatalogStore {
91 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 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 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 pub fn reload_file(&mut self, path: &Path) -> ReloadOutcome {
177 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 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 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 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 pub fn agent_by_id(&self, id: &str) -> Option<&Agent> {
364 self.agents.iter().find(|a| a.id == id)
365 }
366
367 pub fn skill_by_id(&self, id: &str) -> Option<&Skill> {
369 self.skills.iter().find(|s| s.id == id)
370 }
371
372 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 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 pub fn content_hash_for(&self, path: &Path) -> Option<&str> {
401 self.content_hashes.get(path).map(|s| s.as_str())
402 }
403
404 pub fn dependency_edges(&self) -> Vec<(String, String, EdgeKind)> {
414 let mut edges: Vec<(String, String, EdgeKind)> = Vec::new();
415
416 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 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 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 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 pub fn agent_count(&self) -> usize {
460 self.agents.len()
461 }
462
463 pub fn skill_count(&self) -> usize {
465 self.skills.len()
466 }
467
468 pub fn provider_count(&self) -> usize {
470 let providers: HashSet<_> = self.agents.iter().map(|a| a.provider).collect();
471 providers.len()
472 }
473
474 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 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 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 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 for agents_in_group in groups.values_mut() {
523 agents_in_group.sort_by_key(|a| a.id.to_lowercase());
524 }
525
526 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 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 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 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 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 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 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 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#[cfg(test)]
620impl CatalogStore {
621 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#[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 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 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 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 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 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 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 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 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 #[test]
815 fn content_hashes_populated_after_load() {
816 let store = CatalogStore::load(workspace_root());
817 assert!(
819 !store.content_hashes.is_empty(),
820 "content_hashes should be populated"
821 );
822 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 assert!(ids.len() >= store.agent_count() + store.skill_count());
887 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 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 assert!(!role_agent_edges.is_empty());
918 }
919
920 #[test]
921 fn reload_file_unchanged_on_same_content() {
922 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 let agents_path = catalog_dir.join("agents.json");
943 std::fs::write(&agents_path, b"[]").unwrap();
944
945 let mut store = CatalogStore::load(tmp.path());
947 assert_eq!(store.agent_count(), 0);
949
950 {
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 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 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 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 assert_eq!(
1026 store.model_assignments.as_ref().unwrap().generated_by,
1027 "scripts/model-policy.mjs"
1028 );
1029 }
1030}
1031
1032#[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 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 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 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}", ) {
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 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 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 {
1188 let mut f = std::fs::OpenOptions::new()
1189 .write(true)
1190 .truncate(true)
1191 .open(&agents_path)
1192 .unwrap();
1193 f.write_all(b"\x00\x01\x02").unwrap();
1195 f.write_all(garbage.as_bytes()).unwrap();
1196 }
1197
1198 let outcome = store.reload_file(&agents_path);
1200
1201 match outcome {
1203 ReloadOutcome::RetainedPrevious { .. } => {
1204 prop_assert_eq!(store.agent_count(), agent_count_before,
1206 "store should retain previous agents after invalid reload");
1207 }
1208 ReloadOutcome::Unchanged => {
1209 prop_assert_eq!(store.agent_count(), agent_count_before);
1211 }
1212 ReloadOutcome::Reloaded { .. } => {
1213 prop_assert!(false, "reload should not succeed on garbage input");
1215 }
1216 }
1217 }
1218
1219 #[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 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 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 #[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 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 let results = engine.search_agents("", &store.agents, Some("aws"), Some("claude-code"));
1276 prop_assert!(results.contains(&0), "aws/claude-code agent should be in results");
1278 prop_assert!(!results.contains(&1), "gcp/cursor agent should not be in aws/claude-code results");
1280
1281 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 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 #[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 let agent = make_agent(&agent_id, provider, harness, vec![skill_id.clone()]);
1306 let skill = make_skill(&skill_id, provider, harness);
1307
1308 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 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 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 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 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}