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#[derive(Debug)]
20pub enum ReloadOutcome {
21 Reloaded { catalog: String },
23 RetainedPrevious { error: String },
25 Unchanged,
27}
28
29#[derive(Debug, Clone, PartialEq, Eq)]
35pub enum EdgeKind {
36 AgentSkill,
38 RoleAgent,
40 AgentMcp,
42 AgentRule,
44}
45
46pub 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 pub model_assignments: Option<ModelAssignments>,
63 pub model_registry: Option<ModelRegistry>,
67 pub workflows: Option<WorkflowCatalog>,
70 pub load_errors: Vec<TuiError>,
71 pub content_hashes: HashMap<PathBuf, String>,
73 pub catalog_root: PathBuf,
75}
76
77fn 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
89fn hash_file(path: &Path) -> Option<String> {
91 let bytes = std::fs::read(path).ok()?;
92 Some(sha256_hex(&bytes))
93}
94
95impl CatalogStore {
96 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 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 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 pub fn reload_file(&mut self, path: &Path) -> ReloadOutcome {
187 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 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 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 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 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 pub fn agent_by_id(&self, id: &str) -> Option<&Agent> {
404 self.agents.iter().find(|a| a.id == id)
405 }
406
407 pub fn skill_by_id(&self, id: &str) -> Option<&Skill> {
409 self.skills.iter().find(|s| s.id == id)
410 }
411
412 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 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 pub fn content_hash_for(&self, path: &Path) -> Option<&str> {
441 self.content_hashes.get(path).map(|s| s.as_str())
442 }
443
444 pub fn dependency_edges(&self) -> Vec<(String, String, EdgeKind)> {
454 let mut edges: Vec<(String, String, EdgeKind)> = Vec::new();
455
456 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 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 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 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 pub fn agent_count(&self) -> usize {
500 self.agents.len()
501 }
502
503 pub fn skill_count(&self) -> usize {
505 self.skills.len()
506 }
507
508 pub fn provider_count(&self) -> usize {
510 let providers: HashSet<_> = self.agents.iter().map(|a| a.provider).collect();
511 providers.len()
512 }
513
514 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 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 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 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 for agents_in_group in groups.values_mut() {
563 agents_in_group.sort_by_key(|a| a.id.to_lowercase());
564 }
565
566 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 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 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 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 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 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 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 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#[cfg(test)]
660impl CatalogStore {
661 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#[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 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 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 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 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 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 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 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 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 #[test]
856 fn content_hashes_populated_after_load() {
857 let store = CatalogStore::load(workspace_root());
858 assert!(
860 !store.content_hashes.is_empty(),
861 "content_hashes should be populated"
862 );
863 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 assert!(ids.len() >= store.agent_count() + store.skill_count());
928 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 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 assert!(!role_agent_edges.is_empty());
959 }
960
961 #[test]
962 fn reload_file_unchanged_on_same_content() {
963 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 let agents_path = catalog_dir.join("agents.json");
984 std::fs::write(&agents_path, b"[]").unwrap();
985
986 let mut store = CatalogStore::load(tmp.path());
988 assert_eq!(store.agent_count(), 0);
990
991 {
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 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 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 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 assert_eq!(
1067 store.model_assignments.as_ref().unwrap().generated_by,
1068 "scripts/model-policy.mjs"
1069 );
1070 }
1071}
1072
1073#[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 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 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 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}", ) {
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 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 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 {
1229 let mut f = std::fs::OpenOptions::new()
1230 .write(true)
1231 .truncate(true)
1232 .open(&agents_path)
1233 .unwrap();
1234 f.write_all(b"\x00\x01\x02").unwrap();
1236 f.write_all(garbage.as_bytes()).unwrap();
1237 }
1238
1239 let outcome = store.reload_file(&agents_path);
1241
1242 match outcome {
1244 ReloadOutcome::RetainedPrevious { .. } => {
1245 prop_assert_eq!(store.agent_count(), agent_count_before,
1247 "store should retain previous agents after invalid reload");
1248 }
1249 ReloadOutcome::Unchanged => {
1250 prop_assert_eq!(store.agent_count(), agent_count_before);
1252 }
1253 ReloadOutcome::Reloaded { .. } => {
1254 prop_assert!(false, "reload should not succeed on garbage input");
1256 }
1257 }
1258 }
1259
1260 #[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 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 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 #[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 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 let results = engine.search_agents("", &store.agents, Some("aws"), Some("claude-code"));
1317 prop_assert!(results.contains(&0), "aws/claude-code agent should be in results");
1319 prop_assert!(!results.contains(&1), "gcp/cursor agent should not be in aws/claude-code results");
1321
1322 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 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 #[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 let agent = make_agent(&agent_id, provider, harness, vec![skill_id.clone()]);
1347 let skill = make_skill(&skill_id, provider, harness);
1348
1349 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 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 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 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 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}